feat: enhance theme handling and layout components for Telegram Mini App
- Updated theme resolution logic to utilize a shared inline script for consistent theme application across routes. - Introduced `AppShell` and `ReadyGate` components to manage app readiness and theme synchronization, improving user experience. - Enhanced `GlobalError` and `NotFound` pages with a unified full-screen layout for better accessibility and visual consistency. - Refactored CSS to implement safe area insets for sticky headers and content safety, ensuring proper layout on various devices. - Added unit tests for new functionality and improved existing tests for better coverage and reliability.
This commit is contained in:
@@ -26,10 +26,10 @@ Telegram’s guidelines state:
|
|||||||
|
|
||||||
Theme is resolved in this order:
|
Theme is resolved in this order:
|
||||||
|
|
||||||
1. Hash parameters: `tgWebAppColorScheme`, `tgWebAppThemeParams` (parsed in the inline script in `webapp-next/src/app/layout.tsx`).
|
1. Hash parameters: `tgWebAppColorScheme`, `tgWebAppThemeParams` (parsed in the shared inline script from `webapp-next/src/lib/theme-bootstrap-script.ts`, used in layout and global-error).
|
||||||
2. At runtime: `Telegram.WebApp.colorScheme` and `Telegram.WebApp.themeParams` via `use-telegram-theme.ts` and `TelegramProvider`.
|
2. At runtime: `Telegram.WebApp.colorScheme` and `Telegram.WebApp.themeParams` via **TelegramProvider** (theme sync is provider-owned in `ThemeSync` / `useTelegramTheme`), so every route (/, /admin, not-found, error) receives live theme updates.
|
||||||
|
|
||||||
The inline script in the layout maps all Telegram theme keys to `--tg-theme-*` CSS variables on the document root. The hook sets `data-theme` (`light` / `dark`) and applies Mini App background/header colors.
|
The inline script maps all Telegram theme keys to `--tg-theme-*` CSS variables on the document root. The provider sets `data-theme` (`light` / `dark`) and applies Mini App background/header colors.
|
||||||
|
|
||||||
### 2.2 Mapping (ThemeParams → app tokens)
|
### 2.2 Mapping (ThemeParams → app tokens)
|
||||||
|
|
||||||
@@ -95,10 +95,9 @@ Use **only** these tokens and Tailwind/shadcn aliases (`bg-background`, `text-mu
|
|||||||
|
|
||||||
### 3.3 Safe area and content safe area
|
### 3.3 Safe area and content safe area
|
||||||
|
|
||||||
- **Class `.content-safe`** (in `globals.css`): Applies:
|
- **CSS custom properties** (in `globals.css`): `--app-safe-top`, `--app-safe-bottom`, `--app-safe-left`, `--app-safe-right` use Telegram viewport content-safe-area insets with `env(safe-area-inset-*)` fallbacks. Use these for sticky positioning and padding so layout works on notched and landscape devices.
|
||||||
- `padding-top: var(--tg-viewport-content-safe-area-inset-top, env(safe-area-inset-top, 0))`
|
- **Class `.content-safe`**: Applies padding on all four sides using the above tokens so content does not sit under Telegram header, bottom bar, or side chrome (Bot API 8.0+). Use `.content-safe` on the **root container of each page** and on full-screen fallback screens (not-found, error, access denied).
|
||||||
- `padding-bottom: var(--tg-viewport-content-safe-area-inset-bottom, env(safe-area-inset-bottom, 0))`
|
- **Sticky headers:** Use `top-[var(--app-safe-top)]` (not `top-0`) for sticky elements (e.g. calendar header, admin header) so they sit below the Telegram UI instead of overlapping it.
|
||||||
- Use `.content-safe` on the **root container of each page** so content is not covered by the Telegram header or bottom bar (Bot API 8.0+).
|
|
||||||
- Lists that extend to the bottom should also account for bottom inset (e.g. `padding-bottom: var(--tg-viewport-content-safe-area-inset-bottom, 12px)` in `.container-app`).
|
- Lists that extend to the bottom should also account for bottom inset (e.g. `padding-bottom: var(--tg-viewport-content-safe-area-inset-bottom, 12px)` in `.container-app`).
|
||||||
|
|
||||||
### 3.4 Sheets and modals
|
### 3.4 Sheets and modals
|
||||||
@@ -197,7 +196,8 @@ See `webapp-next/src/components/day-detail/DayDetail.tsx` for the Sheet content.
|
|||||||
|
|
||||||
## 8. Telegram integration
|
## 8. Telegram integration
|
||||||
|
|
||||||
- **Header and background:** On init (layout script and `use-telegram-theme.ts`), call:
|
- **Ready gate:** `callMiniAppReadyOnce()` (in `lib/telegram-ready.ts`) is invoked by the layout’s `ReadyGate` when `appContentReady` becomes true. Any route (/, /admin, not-found, in-app error) that sets `appContentReady` will trigger it so Telegram hides its loader; no route-specific logic is required.
|
||||||
|
- **Header and background:** On init (layout script and provider’s theme sync), call:
|
||||||
- `setBackgroundColor('bg_color')`
|
- `setBackgroundColor('bg_color')`
|
||||||
- `setHeaderColor('bg_color')`
|
- `setHeaderColor('bg_color')`
|
||||||
- `setBottomBarColor('bottom_bar_bg_color')` when available (Bot API 7.10+).
|
- `setBottomBarColor('bottom_bar_bg_color')` when available (Bot API 7.10+).
|
||||||
|
|||||||
@@ -6,8 +6,10 @@
|
|||||||
|
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect } from "react";
|
||||||
import { useAdminPage, AdminDutyList, ReassignSheet } from "@/components/admin";
|
import { useAdminPage, AdminDutyList, ReassignSheet } from "@/components/admin";
|
||||||
import { useTranslation } from "@/i18n/use-translation";
|
import { useTranslation } from "@/i18n/use-translation";
|
||||||
|
import { useAppStore } from "@/store/app-store";
|
||||||
import { AccessDeniedScreen } from "@/components/states/AccessDeniedScreen";
|
import { AccessDeniedScreen } from "@/components/states/AccessDeniedScreen";
|
||||||
import { LoadingState } from "@/components/states/LoadingState";
|
import { LoadingState } from "@/components/states/LoadingState";
|
||||||
import { ErrorState } from "@/components/states/ErrorState";
|
import { ErrorState } from "@/components/states/ErrorState";
|
||||||
@@ -20,6 +22,12 @@ const PAGE_WRAPPER_CLASS =
|
|||||||
export default function AdminPage() {
|
export default function AdminPage() {
|
||||||
const { t, monthName } = useTranslation();
|
const { t, monthName } = useTranslation();
|
||||||
const admin = useAdminPage();
|
const admin = useAdminPage();
|
||||||
|
const setAppContentReady = useAppStore((s) => s.setAppContentReady);
|
||||||
|
|
||||||
|
// Signal ready so Telegram hides loader when opening /admin directly.
|
||||||
|
useEffect(() => {
|
||||||
|
setAppContentReady(true);
|
||||||
|
}, [setAppContentReady]);
|
||||||
|
|
||||||
if (!admin.isAllowed) {
|
if (!admin.isAllowed) {
|
||||||
return (
|
return (
|
||||||
@@ -57,7 +65,7 @@ export default function AdminPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={PAGE_WRAPPER_CLASS}>
|
<div className={PAGE_WRAPPER_CLASS}>
|
||||||
<header className="sticky top-0 z-10 flex flex-col items-center border-b border-border bg-background py-3">
|
<header className="sticky top-[var(--app-safe-top)] z-10 flex flex-col items-center border-b border-border bg-background py-3">
|
||||||
<div className="flex w-full items-center justify-between px-1">
|
<div className="flex w-full items-center justify-between px-1">
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -5,8 +5,12 @@
|
|||||||
|
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect } from "react";
|
||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
import { getLang, translate } from "@/i18n/messages";
|
import { getLang, translate } from "@/i18n/messages";
|
||||||
|
import { callMiniAppReadyOnce } from "@/lib/telegram-ready";
|
||||||
|
import { THEME_BOOTSTRAP_SCRIPT } from "@/lib/theme-bootstrap-script";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
|
||||||
export default function GlobalError({
|
export default function GlobalError({
|
||||||
error,
|
error,
|
||||||
@@ -16,6 +20,11 @@ export default function GlobalError({
|
|||||||
reset: () => void;
|
reset: () => void;
|
||||||
}) {
|
}) {
|
||||||
const lang = getLang();
|
const lang = getLang();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
callMiniAppReadyOnce();
|
||||||
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<html
|
<html
|
||||||
lang={lang === "ru" ? "ru" : "en"}
|
lang={lang === "ru" ? "ru" : "en"}
|
||||||
@@ -23,28 +32,19 @@ export default function GlobalError({
|
|||||||
suppressHydrationWarning
|
suppressHydrationWarning
|
||||||
>
|
>
|
||||||
<head>
|
<head>
|
||||||
{/* Same theme detection as layout: hash / Telegram / prefers-color-scheme → data-theme */}
|
<script dangerouslySetInnerHTML={{ __html: THEME_BOOTSTRAP_SCRIPT }} />
|
||||||
<script
|
|
||||||
dangerouslySetInnerHTML={{
|
|
||||||
__html: `(function(){var scheme='dark';var root=document.documentElement;var hash=typeof location!=='undefined'&&location.hash?location.hash.slice(1):'';if(hash){var params={};hash.split('&').forEach(function(p){var i=p.indexOf('=');if(i===-1)return;var k=decodeURIComponent(p.slice(0,i));var v=p.slice(i+1);params[k]=v;});if(params.tgWebAppColorScheme==='light'||params.tgWebAppColorScheme==='dark')scheme=params.tgWebAppColorScheme;if(params.tgWebAppThemeParams){try{var theme=JSON.parse(decodeURIComponent(params.tgWebAppThemeParams));var tgKeys=['bg_color','text_color','hint_color','link_color','button_color','button_text_color','secondary_bg_color','header_bg_color','accent_text_color','destructive_text_color','section_bg_color','section_header_text_color','section_separator_color','subtitle_text_color','bottom_bar_bg_color'];tgKeys.forEach(function(k){var v=theme[k];if(v&&typeof v==='string')root.style.setProperty('--tg-theme-'+k.replace(/_/g,'-'),v);});Object.keys(theme).forEach(function(k){var v=theme[k];if(v&&typeof v==='string')root.style.setProperty('--tg-theme-'+k.replace(/_/g,'-'),v);});}catch(e){}}}var twa=typeof window!=='undefined'&&window.Telegram&&window.Telegram.WebApp;if(scheme!=='light'&&scheme!=='dark'&&twa&&(twa.colorScheme==='light'||twa.colorScheme==='dark'))scheme=twa.colorScheme;if(scheme!=='light'&&scheme!=='dark'){try{var s=getComputedStyle(root).getPropertyValue('--tg-color-scheme').trim();if(s==='light'||s==='dark')scheme=s;}catch(e){} }if(scheme!=='light'&&scheme!=='dark')scheme=window.matchMedia&&window.matchMedia('(prefers-color-scheme: dark)').matches?'dark':'light';root.setAttribute('data-theme',scheme);if(twa){if(twa.setBackgroundColor)twa.setBackgroundColor('bg_color');if(twa.setHeaderColor)twa.setHeaderColor('bg_color');}})();`,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</head>
|
</head>
|
||||||
<body className="antialiased">
|
<body className="antialiased">
|
||||||
<div className="flex min-h-[var(--tg-viewport-stable-height,100vh)] flex-col items-center justify-center gap-4 bg-background px-4 text-foreground">
|
<div className="content-safe flex min-h-[var(--tg-viewport-stable-height,100vh)] flex-col items-center justify-center gap-4 bg-background px-4 text-foreground">
|
||||||
<h1 className="text-xl font-semibold">
|
<h1 className="text-xl font-semibold">
|
||||||
{translate(lang, "error_boundary.message")}
|
{translate(lang, "error_boundary.message")}
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-center text-muted-foreground">
|
<p className="text-center text-muted-foreground">
|
||||||
{translate(lang, "error_boundary.description")}
|
{translate(lang, "error_boundary.description")}
|
||||||
</p>
|
</p>
|
||||||
<button
|
<Button type="button" onClick={() => reset()}>
|
||||||
type="button"
|
|
||||||
onClick={() => reset()}
|
|
||||||
className="rounded-lg bg-primary px-4 py-2 text-primary-foreground hover:bg-primary/90"
|
|
||||||
>
|
|
||||||
{translate(lang, "error_boundary.reload")}
|
{translate(lang, "error_boundary.reload")}
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -89,6 +89,11 @@
|
|||||||
--ease-out: cubic-bezier(0.32, 0.72, 0, 1);
|
--ease-out: cubic-bezier(0.32, 0.72, 0, 1);
|
||||||
--radius: 0.625rem;
|
--radius: 0.625rem;
|
||||||
--calendar-block-min-height: 260px;
|
--calendar-block-min-height: 260px;
|
||||||
|
/** Safe-area insets for sticky headers and full-screen (Telegram viewport + env fallbacks). */
|
||||||
|
--app-safe-top: var(--tg-viewport-content-safe-area-inset-top, env(safe-area-inset-top, 0));
|
||||||
|
--app-safe-bottom: var(--tg-viewport-content-safe-area-inset-bottom, env(safe-area-inset-bottom, 0));
|
||||||
|
--app-safe-left: var(--tg-viewport-content-safe-area-inset-left, env(safe-area-inset-left, 0));
|
||||||
|
--app-safe-right: var(--tg-viewport-content-safe-area-inset-right, env(safe-area-inset-right, 0));
|
||||||
/** Minimum height for the 6-row calendar grid so cells stay comfortably large. */
|
/** Minimum height for the 6-row calendar grid so cells stay comfortably large. */
|
||||||
--calendar-grid-min-height: 264px;
|
--calendar-grid-min-height: 264px;
|
||||||
/** Minimum height per calendar row (6 rows × 44px ≈ 264px). */
|
/** Minimum height per calendar row (6 rows × 44px ≈ 264px). */
|
||||||
@@ -272,14 +277,16 @@ html::-webkit-scrollbar {
|
|||||||
|
|
||||||
/* Safe area for Telegram Mini App (notch / status bar). */
|
/* Safe area for Telegram Mini App (notch / status bar). */
|
||||||
.pt-safe {
|
.pt-safe {
|
||||||
padding-top: var(--tg-viewport-content-safe-area-inset-top, env(safe-area-inset-top, 0));
|
padding-top: var(--app-safe-top);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Content safe area: top/bottom only to avoid overlap with Telegram header/bottom bar (Bot API 8.0+).
|
/* Content safe area: top/bottom/left/right so content and sticky chrome sit below Telegram UI.
|
||||||
Horizontal padding is left to layout classes (e.g. px-3) so indents are preserved when viewport vars are 0. */
|
Use for full-screen wrappers; horizontal padding can be extended by layout (e.g. px-3). */
|
||||||
.content-safe {
|
.content-safe {
|
||||||
padding-top: var(--tg-viewport-content-safe-area-inset-top, env(safe-area-inset-top, 0));
|
padding-top: var(--app-safe-top);
|
||||||
padding-bottom: var(--tg-viewport-content-safe-area-inset-bottom, env(safe-area-inset-bottom, 0));
|
padding-bottom: var(--app-safe-bottom);
|
||||||
|
padding-left: var(--app-safe-left);
|
||||||
|
padding-right: var(--app-safe-right);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Sticky calendar header: shadow when scrolled (useStickyScroll). */
|
/* Sticky calendar header: shadow when scrolled (useStickyScroll). */
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import type { Metadata, Viewport } from "next";
|
import type { Metadata, Viewport } from "next";
|
||||||
import { TooltipProvider } from "@/components/ui/tooltip";
|
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||||
import { TelegramProvider } from "@/components/providers/TelegramProvider";
|
import { TelegramProvider } from "@/components/providers/TelegramProvider";
|
||||||
|
import { AppShell } from "@/components/AppShell";
|
||||||
import { AppErrorBoundary } from "@/components/AppErrorBoundary";
|
import { AppErrorBoundary } from "@/components/AppErrorBoundary";
|
||||||
|
import { THEME_BOOTSTRAP_SCRIPT } from "@/lib/theme-bootstrap-script";
|
||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
@@ -23,12 +25,7 @@ export default function RootLayout({
|
|||||||
return (
|
return (
|
||||||
<html lang="en" data-theme="dark" suppressHydrationWarning>
|
<html lang="en" data-theme="dark" suppressHydrationWarning>
|
||||||
<head>
|
<head>
|
||||||
{/* Inline script: theme from hash (tgWebAppColorScheme + all 14 TG themeParams → --tg-theme-*), then data-theme and Mini App colors. */}
|
<script dangerouslySetInnerHTML={{ __html: THEME_BOOTSTRAP_SCRIPT }} />
|
||||||
<script
|
|
||||||
dangerouslySetInnerHTML={{
|
|
||||||
__html: `(function(){var scheme='dark';var root=document.documentElement;var hash=typeof location!=='undefined'&&location.hash?location.hash.slice(1):'';if(hash){var params={};hash.split('&').forEach(function(p){var i=p.indexOf('=');if(i===-1)return;var k=decodeURIComponent(p.slice(0,i));var v=p.slice(i+1);params[k]=v;});if(params.tgWebAppColorScheme==='light'||params.tgWebAppColorScheme==='dark')scheme=params.tgWebAppColorScheme;if(params.tgWebAppThemeParams){try{var theme=JSON.parse(decodeURIComponent(params.tgWebAppThemeParams));var tgKeys=['bg_color','text_color','hint_color','link_color','button_color','button_text_color','secondary_bg_color','header_bg_color','accent_text_color','destructive_text_color','section_bg_color','section_header_text_color','section_separator_color','subtitle_text_color','bottom_bar_bg_color'];tgKeys.forEach(function(k){var v=theme[k];if(v&&typeof v==='string')root.style.setProperty('--tg-theme-'+k.replace(/_/g,'-'),v);});Object.keys(theme).forEach(function(k){var v=theme[k];if(v&&typeof v==='string')root.style.setProperty('--tg-theme-'+k.replace(/_/g,'-'),v);});}catch(e){}}}var twa=typeof window!=='undefined'&&window.Telegram&&window.Telegram.WebApp;if(scheme!=='light'&&scheme!=='dark'&&twa&&(twa.colorScheme==='light'||twa.colorScheme==='dark'))scheme=twa.colorScheme;if(scheme!=='light'&&scheme!=='dark'){try{var s=getComputedStyle(root).getPropertyValue('--tg-color-scheme').trim();if(s==='light'||s==='dark')scheme=s;}catch(e){} }if(scheme!=='light'&&scheme!=='dark')scheme=window.matchMedia&&window.matchMedia('(prefers-color-scheme: dark)').matches?'dark':'light';root.setAttribute('data-theme',scheme);if(twa){if(twa.setBackgroundColor)twa.setBackgroundColor('bg_color');if(twa.setHeaderColor)twa.setHeaderColor('bg_color');}})();`,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<script
|
<script
|
||||||
dangerouslySetInnerHTML={{
|
dangerouslySetInnerHTML={{
|
||||||
__html: `(function(){if(typeof window!=='undefined'&&window.__DT_LANG==null)window.__DT_LANG='en';})();`,
|
__html: `(function(){if(typeof window!=='undefined'&&window.__DT_LANG==null)window.__DT_LANG='en';})();`,
|
||||||
@@ -39,7 +36,9 @@ export default function RootLayout({
|
|||||||
<body className="antialiased">
|
<body className="antialiased">
|
||||||
<TelegramProvider>
|
<TelegramProvider>
|
||||||
<AppErrorBoundary>
|
<AppErrorBoundary>
|
||||||
<TooltipProvider>{children}</TooltipProvider>
|
<TooltipProvider>
|
||||||
|
<AppShell>{children}</AppShell>
|
||||||
|
</TooltipProvider>
|
||||||
</AppErrorBoundary>
|
</AppErrorBoundary>
|
||||||
</TelegramProvider>
|
</TelegramProvider>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -5,21 +5,31 @@
|
|||||||
|
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect } from "react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useTranslation } from "@/i18n/use-translation";
|
import { useTranslation } from "@/i18n/use-translation";
|
||||||
|
import { useAppStore } from "@/store/app-store";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { FullScreenStateShell } from "@/components/states/FullScreenStateShell";
|
||||||
|
|
||||||
export default function NotFound() {
|
export default function NotFound() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const setAppContentReady = useAppStore((s) => s.setAppContentReady);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setAppContentReady(true);
|
||||||
|
}, [setAppContentReady]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-[var(--tg-viewport-stable-height,100vh)] flex-col items-center justify-center gap-4 bg-background px-4 text-foreground">
|
<FullScreenStateShell
|
||||||
<h1 className="text-xl font-semibold">{t("not_found.title")}</h1>
|
title={t("not_found.title")}
|
||||||
<p className="text-muted-foreground">{t("not_found.description")}</p>
|
description={t("not_found.description")}
|
||||||
<Link
|
primaryAction={
|
||||||
href="/"
|
<Button asChild>
|
||||||
className="rounded-lg bg-primary px-4 py-2 text-primary-foreground hover:bg-primary/90"
|
<Link href="/">{t("not_found.open_calendar")}</Link>
|
||||||
>
|
</Button>
|
||||||
{t("not_found.open_calendar")}
|
}
|
||||||
</Link>
|
role="status"
|
||||||
</div>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,10 +4,9 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||||
import { render, screen, waitFor, act } from "@testing-library/react";
|
import { render, screen, act } from "@testing-library/react";
|
||||||
import Page from "./page";
|
import Page from "./page";
|
||||||
import { resetAppStore } from "@/test/test-utils";
|
import { resetAppStore } from "@/test/test-utils";
|
||||||
import { useAppStore } from "@/store/app-store";
|
|
||||||
import { useTelegramAuth } from "@/hooks/use-telegram-auth";
|
import { useTelegramAuth } from "@/hooks/use-telegram-auth";
|
||||||
|
|
||||||
vi.mock("next/navigation", () => ({
|
vi.mock("next/navigation", () => ({
|
||||||
@@ -41,22 +40,6 @@ describe("Page", () => {
|
|||||||
expect(screen.getByRole("button", { name: /next month/i })).toBeInTheDocument();
|
expect(screen.getByRole("button", { name: /next month/i })).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("sets document title and lang from store lang", async () => {
|
|
||||||
useAppStore.getState().setLang("en");
|
|
||||||
render(<Page />);
|
|
||||||
await screen.findByRole("grid", { name: "Calendar" });
|
|
||||||
expect(document.title).toBe("Duty Calendar");
|
|
||||||
expect(document.documentElement.lang).toBe("en");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("sets document title for ru when store lang is ru", async () => {
|
|
||||||
useAppStore.getState().setLang("ru");
|
|
||||||
render(<Page />);
|
|
||||||
await screen.findByRole("grid", { name: "Calendar" });
|
|
||||||
expect(document.title).toBe("Календарь дежурств");
|
|
||||||
expect(document.documentElement.lang).toBe("ru");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("renders AccessDeniedScreen when not allowed and delay has passed", async () => {
|
it("renders AccessDeniedScreen when not allowed and delay has passed", async () => {
|
||||||
const { RETRY_DELAY_MS } = await import("@/lib/constants");
|
const { RETRY_DELAY_MS } = await import("@/lib/constants");
|
||||||
vi.mocked(useTelegramAuth).mockReturnValue({
|
vi.mocked(useTelegramAuth).mockReturnValue({
|
||||||
|
|||||||
@@ -8,10 +8,8 @@
|
|||||||
import { useCallback, useEffect } from "react";
|
import { useCallback, useEffect } from "react";
|
||||||
import { useAppStore, type AppState } from "@/store/app-store";
|
import { useAppStore, type AppState } from "@/store/app-store";
|
||||||
import { useShallow } from "zustand/react/shallow";
|
import { useShallow } from "zustand/react/shallow";
|
||||||
import { useTelegramTheme } from "@/hooks/use-telegram-theme";
|
|
||||||
import { useTelegramAuth } from "@/hooks/use-telegram-auth";
|
import { useTelegramAuth } from "@/hooks/use-telegram-auth";
|
||||||
import { useAppInit } from "@/hooks/use-app-init";
|
import { useAppInit } from "@/hooks/use-app-init";
|
||||||
import { callMiniAppReadyOnce } from "@/lib/telegram-ready";
|
|
||||||
import { fetchAdminMe } from "@/lib/api";
|
import { fetchAdminMe } from "@/lib/api";
|
||||||
import { getLang } from "@/i18n/messages";
|
import { getLang } from "@/i18n/messages";
|
||||||
import { AccessDeniedScreen } from "@/components/states/AccessDeniedScreen";
|
import { AccessDeniedScreen } from "@/components/states/AccessDeniedScreen";
|
||||||
@@ -19,8 +17,6 @@ import { CurrentDutyView } from "@/components/current-duty/CurrentDutyView";
|
|||||||
import { CalendarPage } from "@/components/CalendarPage";
|
import { CalendarPage } from "@/components/CalendarPage";
|
||||||
|
|
||||||
export default function Home() {
|
export default function Home() {
|
||||||
useTelegramTheme();
|
|
||||||
|
|
||||||
const { initDataRaw, startParam, isLocalhost } = useTelegramAuth();
|
const { initDataRaw, startParam, isLocalhost } = useTelegramAuth();
|
||||||
const isAllowed = isLocalhost || !!initDataRaw;
|
const isAllowed = isLocalhost || !!initDataRaw;
|
||||||
|
|
||||||
@@ -35,7 +31,7 @@ export default function Home() {
|
|||||||
fetchAdminMe(initDataRaw, getLang()).then(({ is_admin }) => setIsAdmin(is_admin));
|
fetchAdminMe(initDataRaw, getLang()).then(({ is_admin }) => setIsAdmin(is_admin));
|
||||||
}, [isAllowed, initDataRaw, setIsAdmin]);
|
}, [isAllowed, initDataRaw, setIsAdmin]);
|
||||||
|
|
||||||
const { accessDenied, currentView, setCurrentView, setSelectedDay, appContentReady } =
|
const { accessDenied, currentView, setCurrentView, setSelectedDay, appContentReady, setAppContentReady } =
|
||||||
useAppStore(
|
useAppStore(
|
||||||
useShallow((s: AppState) => ({
|
useShallow((s: AppState) => ({
|
||||||
accessDenied: s.accessDenied,
|
accessDenied: s.accessDenied,
|
||||||
@@ -43,15 +39,16 @@ export default function Home() {
|
|||||||
setCurrentView: s.setCurrentView,
|
setCurrentView: s.setCurrentView,
|
||||||
setSelectedDay: s.setSelectedDay,
|
setSelectedDay: s.setSelectedDay,
|
||||||
appContentReady: s.appContentReady,
|
appContentReady: s.appContentReady,
|
||||||
|
setAppContentReady: s.setAppContentReady,
|
||||||
}))
|
}))
|
||||||
);
|
);
|
||||||
|
|
||||||
// When content is ready, tell Telegram to hide native loading and show our app.
|
// When showing access-denied or current-duty view, mark content ready so ReadyGate can call miniAppReady().
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (appContentReady) {
|
if (accessDenied || currentView === "currentDuty") {
|
||||||
callMiniAppReadyOnce();
|
setAppContentReady(true);
|
||||||
}
|
}
|
||||||
}, [appContentReady]);
|
}, [accessDenied, currentView, setAppContentReady]);
|
||||||
|
|
||||||
const handleBackFromCurrentDuty = useCallback(() => {
|
const handleBackFromCurrentDuty = useCallback(() => {
|
||||||
setCurrentView("calendar");
|
setCurrentView("calendar");
|
||||||
@@ -59,7 +56,9 @@ export default function Home() {
|
|||||||
}, [setCurrentView, setSelectedDay]);
|
}, [setCurrentView, setSelectedDay]);
|
||||||
|
|
||||||
const content = accessDenied ? (
|
const content = accessDenied ? (
|
||||||
|
<div className="content-safe mx-auto flex min-h-[var(--tg-viewport-stable-height,100vh)] w-full max-w-[var(--max-width-app)] flex-col bg-background px-3 pb-6">
|
||||||
<AccessDeniedScreen primaryAction="reload" />
|
<AccessDeniedScreen primaryAction="reload" />
|
||||||
|
</div>
|
||||||
) : currentView === "currentDuty" ? (
|
) : currentView === "currentDuty" ? (
|
||||||
<div className="content-safe mx-auto flex min-h-[var(--tg-viewport-stable-height,100vh)] w-full max-w-[var(--max-width-app)] flex-col bg-background px-3 pb-6">
|
<div className="content-safe mx-auto flex min-h-[var(--tg-viewport-stable-height,100vh)] w-full max-w-[var(--max-width-app)] flex-col bg-background px-3 pb-6">
|
||||||
<CurrentDutyView
|
<CurrentDutyView
|
||||||
|
|||||||
@@ -7,9 +7,9 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { getLang } from "@/i18n/messages";
|
import { getLang, translate } from "@/i18n/messages";
|
||||||
import { translate } from "@/i18n/messages";
|
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { FullScreenStateShell } from "@/components/states/FullScreenStateShell";
|
||||||
|
|
||||||
interface AppErrorBoundaryProps {
|
interface AppErrorBoundaryProps {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
@@ -52,23 +52,18 @@ export class AppErrorBoundary extends React.Component<
|
|||||||
if (this.state.hasError) {
|
if (this.state.hasError) {
|
||||||
const lang = getLang();
|
const lang = getLang();
|
||||||
const message = translate(lang, "error_boundary.message");
|
const message = translate(lang, "error_boundary.message");
|
||||||
|
const description = translate(lang, "error_boundary.description");
|
||||||
const reloadLabel = translate(lang, "error_boundary.reload");
|
const reloadLabel = translate(lang, "error_boundary.reload");
|
||||||
return (
|
return (
|
||||||
<div
|
<FullScreenStateShell
|
||||||
className="flex min-h-[200px] flex-col items-center justify-center gap-4 rounded-xl bg-surface py-8 px-4 text-center"
|
title={message}
|
||||||
role="alert"
|
description={description}
|
||||||
>
|
primaryAction={
|
||||||
<p className="m-0 text-sm font-medium text-foreground">{message}</p>
|
<Button type="button" variant="default" onClick={this.handleReload}>
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="default"
|
|
||||||
size="sm"
|
|
||||||
onClick={this.handleReload}
|
|
||||||
className="bg-primary text-primary-foreground hover:opacity-90"
|
|
||||||
>
|
|
||||||
{reloadLabel}
|
{reloadLabel}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
}
|
||||||
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return this.props.children;
|
return this.props.children;
|
||||||
|
|||||||
17
webapp-next/src/components/AppShell.tsx
Normal file
17
webapp-next/src/components/AppShell.tsx
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
/**
|
||||||
|
* App shell: wraps children with ReadyGate so any route can trigger miniAppReady().
|
||||||
|
* Rendered inside TelegramProvider so theme and SDK are available.
|
||||||
|
*/
|
||||||
|
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { ReadyGate } from "@/components/ReadyGate";
|
||||||
|
|
||||||
|
export function AppShell({ children }: { children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<ReadyGate />
|
||||||
|
{children}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -171,7 +171,7 @@ export function CalendarPage({ isAllowed, initDataRaw }: CalendarPageProps) {
|
|||||||
<div className="content-safe mx-auto flex min-h-[var(--tg-viewport-stable-height,100vh)] w-full max-w-[var(--max-width-app)] flex-col bg-background px-3 pb-6">
|
<div className="content-safe mx-auto flex min-h-[var(--tg-viewport-stable-height,100vh)] w-full max-w-[var(--max-width-app)] flex-col bg-background px-3 pb-6">
|
||||||
<div
|
<div
|
||||||
ref={calendarStickyRef}
|
ref={calendarStickyRef}
|
||||||
className="sticky top-0 z-10 min-h-[var(--calendar-block-min-height)] bg-background pb-2"
|
className="sticky top-[var(--app-safe-top)] z-10 min-h-[var(--calendar-block-min-height)] bg-background pb-2 touch-pan-y"
|
||||||
>
|
>
|
||||||
<CalendarHeader
|
<CalendarHeader
|
||||||
month={currentMonth}
|
month={currentMonth}
|
||||||
|
|||||||
23
webapp-next/src/components/ReadyGate.tsx
Normal file
23
webapp-next/src/components/ReadyGate.tsx
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
/**
|
||||||
|
* Route-agnostic gate: when appContentReady becomes true, calls miniAppReady() once
|
||||||
|
* so Telegram hides its native loader. Used in layout so any route (/, /admin, not-found,
|
||||||
|
* error) can trigger ready when its first screen is shown.
|
||||||
|
*/
|
||||||
|
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect } from "react";
|
||||||
|
import { useAppStore } from "@/store/app-store";
|
||||||
|
import { callMiniAppReadyOnce } from "@/lib/telegram-ready";
|
||||||
|
|
||||||
|
export function ReadyGate() {
|
||||||
|
const appContentReady = useAppStore((s) => s.appContentReady);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (appContentReady) {
|
||||||
|
callMiniAppReadyOnce();
|
||||||
|
}
|
||||||
|
}, [appContentReady]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -9,11 +9,15 @@ import { ContactLinks } from "./ContactLinks";
|
|||||||
import { resetAppStore } from "@/test/test-utils";
|
import { resetAppStore } from "@/test/test-utils";
|
||||||
|
|
||||||
const openPhoneLinkMock = vi.fn();
|
const openPhoneLinkMock = vi.fn();
|
||||||
|
const openTelegramProfileMock = vi.fn();
|
||||||
const triggerHapticLightMock = vi.fn();
|
const triggerHapticLightMock = vi.fn();
|
||||||
|
|
||||||
vi.mock("@/lib/open-phone-link", () => ({
|
vi.mock("@/lib/open-phone-link", () => ({
|
||||||
openPhoneLink: (...args: unknown[]) => openPhoneLinkMock(...args),
|
openPhoneLink: (...args: unknown[]) => openPhoneLinkMock(...args),
|
||||||
}));
|
}));
|
||||||
|
vi.mock("@/lib/telegram-link", () => ({
|
||||||
|
openTelegramProfile: (...args: unknown[]) => openTelegramProfileMock(...args),
|
||||||
|
}));
|
||||||
vi.mock("@/lib/telegram-haptic", () => ({
|
vi.mock("@/lib/telegram-haptic", () => ({
|
||||||
triggerHapticLight: () => triggerHapticLightMock(),
|
triggerHapticLight: () => triggerHapticLightMock(),
|
||||||
}));
|
}));
|
||||||
@@ -22,6 +26,7 @@ describe("ContactLinks", () => {
|
|||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
resetAppStore();
|
resetAppStore();
|
||||||
openPhoneLinkMock.mockClear();
|
openPhoneLinkMock.mockClear();
|
||||||
|
openTelegramProfileMock.mockClear();
|
||||||
triggerHapticLightMock.mockClear();
|
triggerHapticLightMock.mockClear();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -82,4 +87,17 @@ describe("ContactLinks", () => {
|
|||||||
expect(openPhoneLinkMock).toHaveBeenCalledWith("+79991234567");
|
expect(openPhoneLinkMock).toHaveBeenCalledWith("+79991234567");
|
||||||
expect(triggerHapticLightMock).toHaveBeenCalled();
|
expect(triggerHapticLightMock).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("calls openTelegramProfile and triggerHapticLight when Telegram link is clicked", () => {
|
||||||
|
render(
|
||||||
|
<ContactLinks phone={null} username="alice_dev" showLabels={false} />
|
||||||
|
);
|
||||||
|
const tgLink = document.querySelector<HTMLAnchorElement>('a[href*="t.me"]');
|
||||||
|
expect(tgLink).toBeInTheDocument();
|
||||||
|
|
||||||
|
fireEvent.click(tgLink!);
|
||||||
|
|
||||||
|
expect(openTelegramProfileMock).toHaveBeenCalledWith("alice_dev");
|
||||||
|
expect(triggerHapticLightMock).toHaveBeenCalled();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
import { useTranslation } from "@/i18n/use-translation";
|
import { useTranslation } from "@/i18n/use-translation";
|
||||||
import { formatPhoneDisplay } from "@/lib/phone-format";
|
import { formatPhoneDisplay } from "@/lib/phone-format";
|
||||||
import { openPhoneLink } from "@/lib/open-phone-link";
|
import { openPhoneLink } from "@/lib/open-phone-link";
|
||||||
|
import { openTelegramProfile } from "@/lib/telegram-link";
|
||||||
import { triggerHapticLight } from "@/lib/telegram-haptic";
|
import { triggerHapticLight } from "@/lib/telegram-haptic";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
@@ -90,6 +91,11 @@ export function ContactLinks({
|
|||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
aria-label={ariaTelegram}
|
aria-label={ariaTelegram}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
openTelegramProfile(cleanUsername);
|
||||||
|
triggerHapticLight();
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<TelegramIcon className="size-5" aria-hidden />
|
<TelegramIcon className="size-5" aria-hidden />
|
||||||
<span>@{cleanUsername}</span>
|
<span>@{cleanUsername}</span>
|
||||||
@@ -131,6 +137,11 @@ export function ContactLinks({
|
|||||||
}
|
}
|
||||||
if (hasUsername) {
|
if (hasUsername) {
|
||||||
const href = `https://t.me/${encodeURIComponent(cleanUsername)}`;
|
const href = `https://t.me/${encodeURIComponent(cleanUsername)}`;
|
||||||
|
const handleTelegramClick = (e: React.MouseEvent<HTMLAnchorElement>) => {
|
||||||
|
e.preventDefault();
|
||||||
|
openTelegramProfile(cleanUsername);
|
||||||
|
triggerHapticLight();
|
||||||
|
};
|
||||||
const link = (
|
const link = (
|
||||||
<a
|
<a
|
||||||
key="tg"
|
key="tg"
|
||||||
@@ -139,6 +150,7 @@ export function ContactLinks({
|
|||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className={linkClass}
|
className={linkClass}
|
||||||
aria-label={ariaTelegram}
|
aria-label={ariaTelegram}
|
||||||
|
onClick={handleTelegramClick}
|
||||||
>
|
>
|
||||||
@{cleanUsername}
|
@{cleanUsername}
|
||||||
</a>
|
</a>
|
||||||
|
|||||||
@@ -340,13 +340,12 @@ export function CurrentDutyView({ onBack, openedFromPin = false }: CurrentDutyVi
|
|||||||
</section>
|
</section>
|
||||||
<div
|
<div
|
||||||
className="rounded-lg bg-duty/10 px-3 py-2.5 flex flex-col gap-1"
|
className="rounded-lg bg-duty/10 px-3 py-2.5 flex flex-col gap-1"
|
||||||
aria-live="polite"
|
aria-label={t("current_duty.remaining_label")}
|
||||||
aria-atomic="true"
|
|
||||||
>
|
>
|
||||||
<span className="text-xs text-muted-foreground">
|
<span className="text-xs text-muted-foreground">
|
||||||
{t("current_duty.remaining_label")}
|
{t("current_duty.remaining_label")}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-xl font-semibold text-foreground tabular-nums">
|
<span className="text-xl font-semibold text-foreground tabular-nums" aria-hidden>
|
||||||
{remainingValueStr}
|
{remainingValueStr}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-xs text-muted-foreground">
|
<span className="text-xs text-muted-foreground">
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import {
|
|||||||
bindViewportCssVars,
|
bindViewportCssVars,
|
||||||
unmountViewport,
|
unmountViewport,
|
||||||
} from "@telegram-apps/sdk-react";
|
} from "@telegram-apps/sdk-react";
|
||||||
import { fixSurfaceContrast } from "@/hooks/use-telegram-theme";
|
import { fixSurfaceContrast, useTelegramTheme } from "@/hooks/use-telegram-theme";
|
||||||
import { applyAndroidPerformanceClass } from "@/lib/telegram-android-perf";
|
import { applyAndroidPerformanceClass } from "@/lib/telegram-android-perf";
|
||||||
import { useAppStore } from "@/store/app-store";
|
import { useAppStore } from "@/store/app-store";
|
||||||
import { getLang } from "@/i18n/messages";
|
import { getLang } from "@/i18n/messages";
|
||||||
@@ -42,6 +42,15 @@ export function useTelegramSdkReady(): TelegramSdkContextValue {
|
|||||||
* useTelegramTheme() in the app handles ongoing theme changes.
|
* useTelegramTheme() in the app handles ongoing theme changes.
|
||||||
* Syncs lang from window.__DT_LANG on mount and when config.js fires dt-config-loaded.
|
* Syncs lang from window.__DT_LANG on mount and when config.js fires dt-config-loaded.
|
||||||
*/
|
*/
|
||||||
|
/**
|
||||||
|
* Live theme sync: provider-owned so every route (/, /admin, not-found, error) gets
|
||||||
|
* data-theme and Mini App chrome color updates when Telegram theme changes.
|
||||||
|
*/
|
||||||
|
function ThemeSync() {
|
||||||
|
useTelegramTheme();
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
export function TelegramProvider({
|
export function TelegramProvider({
|
||||||
children,
|
children,
|
||||||
}: {
|
}: {
|
||||||
@@ -111,6 +120,7 @@ export function TelegramProvider({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<TelegramSdkContext.Provider value={{ sdkReady }}>
|
<TelegramSdkContext.Provider value={{ sdkReady }}>
|
||||||
|
<ThemeSync />
|
||||||
{children}
|
{children}
|
||||||
</TelegramSdkContext.Provider>
|
</TelegramSdkContext.Provider>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ import { useEffect } from "react";
|
|||||||
import { getLang, translate } from "@/i18n/messages";
|
import { getLang, translate } from "@/i18n/messages";
|
||||||
import { useAppStore } from "@/store/app-store";
|
import { useAppStore } from "@/store/app-store";
|
||||||
import { triggerHapticLight } from "@/lib/telegram-haptic";
|
import { triggerHapticLight } from "@/lib/telegram-haptic";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { FullScreenStateShell } from "@/components/states/FullScreenStateShell";
|
||||||
|
|
||||||
export interface AccessDeniedScreenProps {
|
export interface AccessDeniedScreenProps {
|
||||||
/** Optional detail from API 403 response, shown below the hint. */
|
/** Optional detail from API 403 response, shown below the hint. */
|
||||||
@@ -59,28 +61,20 @@ export function AccessDeniedScreen({
|
|||||||
: translate(lang, "current_duty.back");
|
: translate(lang, "current_duty.back");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<FullScreenStateShell
|
||||||
className="flex min-h-[var(--tg-viewport-stable-height,100vh)] flex-col items-center justify-center gap-4 bg-background px-4 text-foreground"
|
title={translate(lang, "access_denied")}
|
||||||
role="alert"
|
description={translate(lang, "access_denied.hint")}
|
||||||
|
primaryAction={
|
||||||
|
<Button type="button" onClick={handleClick}>
|
||||||
|
{buttonLabel}
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<h1 className="text-xl font-semibold">
|
|
||||||
{translate(lang, "access_denied")}
|
|
||||||
</h1>
|
|
||||||
<p className="text-center text-muted-foreground">
|
|
||||||
{translate(lang, "access_denied.hint")}
|
|
||||||
</p>
|
|
||||||
{hasDetail && (
|
{hasDetail && (
|
||||||
<p className="text-center text-sm text-muted-foreground">
|
<p className="text-center text-sm text-muted-foreground">
|
||||||
{serverDetail}
|
{serverDetail}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
<button
|
</FullScreenStateShell>
|
||||||
type="button"
|
|
||||||
onClick={handleClick}
|
|
||||||
className="rounded-lg bg-primary px-4 py-2 text-primary-foreground hover:bg-primary/90"
|
|
||||||
>
|
|
||||||
{buttonLabel}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
51
webapp-next/src/components/states/FullScreenStateShell.tsx
Normal file
51
webapp-next/src/components/states/FullScreenStateShell.tsx
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
/**
|
||||||
|
* Shared full-screen state layout for fallback screens (access denied, not-found, error).
|
||||||
|
* Uses content-safe, app tokens, and consistent spacing so all fallback screens look like the same app.
|
||||||
|
*/
|
||||||
|
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
export interface FullScreenStateShellProps {
|
||||||
|
/** Main heading (e.g. "Access denied", "Page not found"). */
|
||||||
|
title: React.ReactNode;
|
||||||
|
/** Optional description or message below the title. */
|
||||||
|
description?: React.ReactNode;
|
||||||
|
/** Optional extra content (e.g. server detail, secondary text). */
|
||||||
|
children?: React.ReactNode;
|
||||||
|
/** Primary action (Button or Link). */
|
||||||
|
primaryAction: React.ReactNode;
|
||||||
|
/** Wrapper role. Default "alert" for error/denied states. */
|
||||||
|
role?: "alert" | "status";
|
||||||
|
/** Optional extra class names for the wrapper. */
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const WRAPPER_CLASS =
|
||||||
|
"content-safe flex min-h-[var(--tg-viewport-stable-height,100vh)] flex-col items-center justify-center gap-4 bg-background px-4 text-foreground";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Full-screen centered shell with title, optional description, and primary action.
|
||||||
|
* Use for access denied, not-found, and in-app error boundary screens.
|
||||||
|
*/
|
||||||
|
export function FullScreenStateShell({
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
children,
|
||||||
|
primaryAction,
|
||||||
|
role = "alert",
|
||||||
|
className,
|
||||||
|
}: FullScreenStateShellProps) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={className ? `${WRAPPER_CLASS} ${className}` : WRAPPER_CLASS}
|
||||||
|
role={role}
|
||||||
|
>
|
||||||
|
<h1 className="text-xl font-semibold">{title}</h1>
|
||||||
|
{description != null && (
|
||||||
|
<p className="text-center text-muted-foreground">{description}</p>
|
||||||
|
)}
|
||||||
|
{children}
|
||||||
|
{primaryAction}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,13 +1,12 @@
|
|||||||
/**
|
/**
|
||||||
* Application initialization: language sync, access-denied logic, deep link routing.
|
* Application initialization: access-denied logic and deep link routing.
|
||||||
* Runs effects that depend on Telegram auth (isAllowed, startParam); caller provides those.
|
* Document lang/title are owned by TelegramProvider (all routes).
|
||||||
*/
|
*/
|
||||||
|
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
import { useAppStore } from "@/store/app-store";
|
import { useAppStore } from "@/store/app-store";
|
||||||
import { useTranslation } from "@/i18n/use-translation";
|
|
||||||
import { RETRY_DELAY_MS } from "@/lib/constants";
|
import { RETRY_DELAY_MS } from "@/lib/constants";
|
||||||
|
|
||||||
export interface UseAppInitParams {
|
export interface UseAppInitParams {
|
||||||
@@ -18,23 +17,12 @@ export interface UseAppInitParams {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Applies document lang/title from store (when this hook runs, e.g. main page).
|
|
||||||
* Handles access denied when not allowed and routes to current duty view when opened via startParam=duty.
|
* Handles access denied when not allowed and routes to current duty view when opened via startParam=duty.
|
||||||
* Language is synced from window.__DT_LANG in TelegramProvider (all routes).
|
|
||||||
*/
|
*/
|
||||||
export function useAppInit({ isAllowed, startParam }: UseAppInitParams): void {
|
export function useAppInit({ isAllowed, startParam }: UseAppInitParams): void {
|
||||||
const lang = useAppStore((s) => s.lang);
|
|
||||||
const setAccessDenied = useAppStore((s) => s.setAccessDenied);
|
const setAccessDenied = useAppStore((s) => s.setAccessDenied);
|
||||||
const setLoading = useAppStore((s) => s.setLoading);
|
const setLoading = useAppStore((s) => s.setLoading);
|
||||||
const setCurrentView = useAppStore((s) => s.setCurrentView);
|
const setCurrentView = useAppStore((s) => s.setCurrentView);
|
||||||
const { t } = useTranslation();
|
|
||||||
|
|
||||||
// Apply lang to document (title and html lang) when main page is mounted (tests render Page without TelegramProvider).
|
|
||||||
useEffect(() => {
|
|
||||||
if (typeof document === "undefined") return;
|
|
||||||
document.documentElement.lang = lang;
|
|
||||||
document.title = t("app.title");
|
|
||||||
}, [lang, t]);
|
|
||||||
|
|
||||||
// When not allowed (no initData and not localhost), show access denied after delay.
|
// When not allowed (no initData and not localhost), show access denied after delay.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
42
webapp-next/src/hooks/use-auto-refresh.test.ts
Normal file
42
webapp-next/src/hooks/use-auto-refresh.test.ts
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
/**
|
||||||
|
* Unit tests for useAutoRefresh: no immediate refresh (avoids duplicate first fetch).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||||
|
import { renderHook } from "@testing-library/react";
|
||||||
|
import { useAutoRefresh } from "./use-auto-refresh";
|
||||||
|
|
||||||
|
describe("useAutoRefresh", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not call refresh immediately when isCurrentMonth is true", () => {
|
||||||
|
const refresh = vi.fn();
|
||||||
|
renderHook(() => useAutoRefresh(refresh, true));
|
||||||
|
|
||||||
|
expect(refresh).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("calls refresh on interval when isCurrentMonth is true", () => {
|
||||||
|
const refresh = vi.fn();
|
||||||
|
renderHook(() => useAutoRefresh(refresh, true));
|
||||||
|
|
||||||
|
vi.advanceTimersByTime(60_000);
|
||||||
|
expect(refresh).toHaveBeenCalledTimes(1);
|
||||||
|
vi.advanceTimersByTime(60_000);
|
||||||
|
expect(refresh).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not call refresh when isCurrentMonth is false", () => {
|
||||||
|
const refresh = vi.fn();
|
||||||
|
renderHook(() => useAutoRefresh(refresh, false));
|
||||||
|
|
||||||
|
vi.advanceTimersByTime(120_000);
|
||||||
|
expect(refresh).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -10,7 +10,8 @@ import { useEffect, useRef } from "react";
|
|||||||
const AUTO_REFRESH_INTERVAL_MS = 60000;
|
const AUTO_REFRESH_INTERVAL_MS = 60000;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* When isCurrentMonth is true, calls refresh() immediately, then every 60 seconds.
|
* When isCurrentMonth is true, starts a 60-second interval to refresh. Does not call refresh()
|
||||||
|
* immediately so the initial load is handled only by useMonthData (avoids duplicate first fetch).
|
||||||
* When isCurrentMonth becomes false or on unmount, the interval is cleared.
|
* When isCurrentMonth becomes false or on unmount, the interval is cleared.
|
||||||
*/
|
*/
|
||||||
export function useAutoRefresh(
|
export function useAutoRefresh(
|
||||||
@@ -22,7 +23,6 @@ export function useAutoRefresh(
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isCurrentMonth) return;
|
if (!isCurrentMonth) return;
|
||||||
refreshRef.current();
|
|
||||||
const id = setInterval(() => refreshRef.current(), AUTO_REFRESH_INTERVAL_MS);
|
const id = setInterval(() => refreshRef.current(), AUTO_REFRESH_INTERVAL_MS);
|
||||||
return () => clearInterval(id);
|
return () => clearInterval(id);
|
||||||
}, [isCurrentMonth]);
|
}, [isCurrentMonth]);
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
/**
|
/**
|
||||||
* Touch swipe detection for horizontal month navigation.
|
* Touch swipe detection for horizontal month navigation.
|
||||||
* Replaces swipe logic from webapp/js/main.js (threshold 50px).
|
* Tracks move/cancel so diagonal or vertical scroll does not trigger month change.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
"use client";
|
"use client";
|
||||||
@@ -15,8 +15,9 @@ export interface UseSwipeOptions {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Attaches touchstart/touchend to the element ref and invokes onSwipeLeft or onSwipeRight
|
* Attaches touchstart/touchmove/touchend to the element ref. Fires onSwipeLeft or onSwipeRight
|
||||||
* when a horizontal swipe exceeds the threshold. Vertical swipes are ignored.
|
* only when horizontal movement exceeds threshold and dominates over vertical (no cancel during move).
|
||||||
|
* Use touch-action: pan-y on the swipe area so vertical scroll is preserved and horizontal intent is clearer.
|
||||||
*/
|
*/
|
||||||
export function useSwipe(
|
export function useSwipe(
|
||||||
elementRef: React.RefObject<HTMLElement | null>,
|
elementRef: React.RefObject<HTMLElement | null>,
|
||||||
@@ -27,6 +28,7 @@ export function useSwipe(
|
|||||||
const { threshold = 50, disabled = false } = options;
|
const { threshold = 50, disabled = false } = options;
|
||||||
const startX = useRef(0);
|
const startX = useRef(0);
|
||||||
const startY = useRef(0);
|
const startY = useRef(0);
|
||||||
|
const cancelledRef = useRef(false);
|
||||||
const onSwipeLeftRef = useRef(onSwipeLeft);
|
const onSwipeLeftRef = useRef(onSwipeLeft);
|
||||||
const onSwipeRightRef = useRef(onSwipeRight);
|
const onSwipeRightRef = useRef(onSwipeRight);
|
||||||
onSwipeLeftRef.current = onSwipeLeft;
|
onSwipeLeftRef.current = onSwipeLeft;
|
||||||
@@ -41,10 +43,21 @@ export function useSwipe(
|
|||||||
const t = e.changedTouches[0];
|
const t = e.changedTouches[0];
|
||||||
startX.current = t.clientX;
|
startX.current = t.clientX;
|
||||||
startY.current = t.clientY;
|
startY.current = t.clientY;
|
||||||
|
cancelledRef.current = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMove = (e: TouchEvent) => {
|
||||||
|
if (e.changedTouches.length === 0 || cancelledRef.current) return;
|
||||||
|
const t = e.changedTouches[0];
|
||||||
|
const deltaX = Math.abs(t.clientX - startX.current);
|
||||||
|
const deltaY = Math.abs(t.clientY - startY.current);
|
||||||
|
if (deltaY > deltaX) {
|
||||||
|
cancelledRef.current = true;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleEnd = (e: TouchEvent) => {
|
const handleEnd = (e: TouchEvent) => {
|
||||||
if (e.changedTouches.length === 0) return;
|
if (e.changedTouches.length === 0 || cancelledRef.current) return;
|
||||||
const t = e.changedTouches[0];
|
const t = e.changedTouches[0];
|
||||||
const deltaX = t.clientX - startX.current;
|
const deltaX = t.clientX - startX.current;
|
||||||
const deltaY = t.clientY - startY.current;
|
const deltaY = t.clientY - startY.current;
|
||||||
@@ -58,9 +71,11 @@ export function useSwipe(
|
|||||||
};
|
};
|
||||||
|
|
||||||
el.addEventListener("touchstart", handleStart, { passive: true });
|
el.addEventListener("touchstart", handleStart, { passive: true });
|
||||||
|
el.addEventListener("touchmove", handleMove, { passive: true });
|
||||||
el.addEventListener("touchend", handleEnd, { passive: true });
|
el.addEventListener("touchend", handleEnd, { passive: true });
|
||||||
return () => {
|
return () => {
|
||||||
el.removeEventListener("touchstart", handleStart);
|
el.removeEventListener("touchstart", handleStart);
|
||||||
|
el.removeEventListener("touchmove", handleMove);
|
||||||
el.removeEventListener("touchend", handleEnd);
|
el.removeEventListener("touchend", handleEnd);
|
||||||
};
|
};
|
||||||
}, [elementRef, disabled, threshold]);
|
}, [elementRef, disabled, threshold]);
|
||||||
|
|||||||
60
webapp-next/src/lib/telegram-link.test.ts
Normal file
60
webapp-next/src/lib/telegram-link.test.ts
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
/**
|
||||||
|
* Unit tests for openTelegramProfile: Mini App–friendly Telegram link opening.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
|
||||||
|
const openTelegramLinkMock = vi.fn();
|
||||||
|
const isAvailableFn = vi.fn().mockReturnValue(true);
|
||||||
|
|
||||||
|
vi.mock("@telegram-apps/sdk-react", () => ({
|
||||||
|
openTelegramLink: Object.assign(openTelegramLinkMock, {
|
||||||
|
isAvailable: () => isAvailableFn(),
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe("openTelegramProfile", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
isAvailableFn.mockReturnValue(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("calls openTelegramLink with t.me URL when SDK is available", async () => {
|
||||||
|
const { openTelegramProfile } = await import("./telegram-link");
|
||||||
|
|
||||||
|
openTelegramProfile("alice");
|
||||||
|
expect(openTelegramLinkMock).toHaveBeenCalledWith("https://t.me/alice");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("strips leading @ from username", async () => {
|
||||||
|
const { openTelegramProfile } = await import("./telegram-link");
|
||||||
|
|
||||||
|
openTelegramProfile("@bob");
|
||||||
|
expect(openTelegramLinkMock).toHaveBeenCalledWith("https://t.me/bob");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does nothing when username is empty", async () => {
|
||||||
|
const { openTelegramProfile } = await import("./telegram-link");
|
||||||
|
|
||||||
|
openTelegramProfile("");
|
||||||
|
openTelegramProfile(null);
|
||||||
|
openTelegramProfile(undefined);
|
||||||
|
expect(openTelegramLinkMock).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to window.open when SDK is unavailable", async () => {
|
||||||
|
isAvailableFn.mockReturnValue(false);
|
||||||
|
const openSpy = vi.spyOn(window, "open").mockImplementation(() => null);
|
||||||
|
vi.resetModules();
|
||||||
|
const { openTelegramProfile } = await import("./telegram-link");
|
||||||
|
|
||||||
|
openTelegramProfile("alice");
|
||||||
|
|
||||||
|
expect(openSpy).toHaveBeenCalledWith(
|
||||||
|
"https://t.me/alice",
|
||||||
|
"_blank",
|
||||||
|
"noopener,noreferrer"
|
||||||
|
);
|
||||||
|
openSpy.mockRestore();
|
||||||
|
});
|
||||||
|
});
|
||||||
28
webapp-next/src/lib/telegram-link.ts
Normal file
28
webapp-next/src/lib/telegram-link.ts
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
/**
|
||||||
|
* Opens a Telegram profile (t.me/username) in a Mini App–friendly way.
|
||||||
|
* Uses SDK openTelegramLink() when available so the link opens inside Telegram;
|
||||||
|
* otherwise falls back to window.open.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { openTelegramLink } from "@telegram-apps/sdk-react";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Opens the given Telegram username profile. When running inside the Mini App,
|
||||||
|
* uses openTelegramLink() so the link opens in-app; otherwise opens in a new tab.
|
||||||
|
* Safe to call from click handlers; does not throw.
|
||||||
|
*/
|
||||||
|
export function openTelegramProfile(username: string | null | undefined): void {
|
||||||
|
const clean = username ? String(username).trim().replace(/^@+/, "") : "";
|
||||||
|
if (!clean) return;
|
||||||
|
if (typeof window === "undefined") return;
|
||||||
|
const url = `https://t.me/${encodeURIComponent(clean)}`;
|
||||||
|
try {
|
||||||
|
if (openTelegramLink.isAvailable()) {
|
||||||
|
openTelegramLink(url);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// SDK not available or not in Mini App context.
|
||||||
|
}
|
||||||
|
window.open(url, "_blank", "noopener,noreferrer");
|
||||||
|
}
|
||||||
7
webapp-next/src/lib/theme-bootstrap-script.ts
Normal file
7
webapp-next/src/lib/theme-bootstrap-script.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
/**
|
||||||
|
* Inline script for first-paint theme: hash (tgWebAppColorScheme + themeParams),
|
||||||
|
* then Telegram WebApp, then CSS --tg-color-scheme, then prefers-color-scheme.
|
||||||
|
* Sets data-theme and Mini App bg/header colors. Shared by layout and global-error.
|
||||||
|
*/
|
||||||
|
export const THEME_BOOTSTRAP_SCRIPT =
|
||||||
|
"(function(){var scheme='dark';var root=document.documentElement;var hash=typeof location!=='undefined'&&location.hash?location.hash.slice(1):'';if(hash){var params={};hash.split('&').forEach(function(p){var i=p.indexOf('=');if(i===-1)return;var k=decodeURIComponent(p.slice(0,i));var v=p.slice(i+1);params[k]=v;});if(params.tgWebAppColorScheme==='light'||params.tgWebAppColorScheme==='dark')scheme=params.tgWebAppColorScheme;if(params.tgWebAppThemeParams){try{var theme=JSON.parse(decodeURIComponent(params.tgWebAppThemeParams));var tgKeys=['bg_color','text_color','hint_color','link_color','button_color','button_text_color','secondary_bg_color','header_bg_color','accent_text_color','destructive_text_color','section_bg_color','section_header_text_color','section_separator_color','subtitle_text_color','bottom_bar_bg_color'];tgKeys.forEach(function(k){var v=theme[k];if(v&&typeof v==='string')root.style.setProperty('--tg-theme-'+k.replace(/_/g,'-'),v);});Object.keys(theme).forEach(function(k){var v=theme[k];if(v&&typeof v==='string')root.style.setProperty('--tg-theme-'+k.replace(/_/g,'-'),v);});}catch(e){}}}var twa=typeof window!=='undefined'&&window.Telegram&&window.Telegram.WebApp;if(scheme!=='light'&&scheme!=='dark'&&twa&&(twa.colorScheme==='light'||twa.colorScheme==='dark'))scheme=twa.colorScheme;if(scheme!=='light'&&scheme!=='dark'){try{var s=getComputedStyle(root).getPropertyValue('--tg-color-scheme').trim();if(s==='light'||s==='dark')scheme=s;}catch(e){} }if(scheme!=='light'&&scheme!=='dark')scheme=window.matchMedia&&window.matchMedia('(prefers-color-scheme: dark)').matches?'dark':'light';root.setAttribute('data-theme',scheme);if(twa){if(twa.setBackgroundColor)twa.setBackgroundColor('bg_color');if(twa.setHeaderColor)twa.setHeaderColor('bg_color');}})();";
|
||||||
Reference in New Issue
Block a user