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:
2026-03-06 16:48:24 +03:00
parent 76bff6dc05
commit 40e2b5adc4
25 changed files with 396 additions and 131 deletions

View File

@@ -26,10 +26,10 @@ Telegrams guidelines state:
Theme is resolved in this order:
1. Hash parameters: `tgWebAppColorScheme`, `tgWebAppThemeParams` (parsed in the inline script in `webapp-next/src/app/layout.tsx`).
2. At runtime: `Telegram.WebApp.colorScheme` and `Telegram.WebApp.themeParams` via `use-telegram-theme.ts` and `TelegramProvider`.
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 **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)
@@ -95,10 +95,9 @@ Use **only** these tokens and Tailwind/shadcn aliases (`bg-background`, `text-mu
### 3.3 Safe area and content safe area
- **Class `.content-safe`** (in `globals.css`): Applies:
- `padding-top: var(--tg-viewport-content-safe-area-inset-top, env(safe-area-inset-top, 0))`
- `padding-bottom: var(--tg-viewport-content-safe-area-inset-bottom, env(safe-area-inset-bottom, 0))`
- 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+).
- **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.
- **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).
- **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.
- 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
@@ -197,7 +196,8 @@ See `webapp-next/src/components/day-detail/DayDetail.tsx` for the Sheet content.
## 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 layouts `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 providers theme sync), call:
- `setBackgroundColor('bg_color')`
- `setHeaderColor('bg_color')`
- `setBottomBarColor('bottom_bar_bg_color')` when available (Bot API 7.10+).

View File

@@ -6,8 +6,10 @@
"use client";
import { useEffect } from "react";
import { useAdminPage, AdminDutyList, ReassignSheet } from "@/components/admin";
import { useTranslation } from "@/i18n/use-translation";
import { useAppStore } from "@/store/app-store";
import { AccessDeniedScreen } from "@/components/states/AccessDeniedScreen";
import { LoadingState } from "@/components/states/LoadingState";
import { ErrorState } from "@/components/states/ErrorState";
@@ -20,6 +22,12 @@ const PAGE_WRAPPER_CLASS =
export default function AdminPage() {
const { t, monthName } = useTranslation();
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) {
return (
@@ -57,7 +65,7 @@ export default function AdminPage() {
return (
<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">
<Button
type="button"

View File

@@ -5,8 +5,12 @@
"use client";
import { useEffect } from "react";
import "./globals.css";
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({
error,
@@ -16,6 +20,11 @@ export default function GlobalError({
reset: () => void;
}) {
const lang = getLang();
useEffect(() => {
callMiniAppReadyOnce();
}, []);
return (
<html
lang={lang === "ru" ? "ru" : "en"}
@@ -23,28 +32,19 @@ export default function GlobalError({
suppressHydrationWarning
>
<head>
{/* Same theme detection as layout: hash / Telegram / prefers-color-scheme → data-theme */}
<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 dangerouslySetInnerHTML={{ __html: THEME_BOOTSTRAP_SCRIPT }} />
</head>
<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">
{translate(lang, "error_boundary.message")}
</h1>
<p className="text-center text-muted-foreground">
{translate(lang, "error_boundary.description")}
</p>
<button
type="button"
onClick={() => reset()}
className="rounded-lg bg-primary px-4 py-2 text-primary-foreground hover:bg-primary/90"
>
<Button type="button" onClick={() => reset()}>
{translate(lang, "error_boundary.reload")}
</button>
</Button>
</div>
</body>
</html>

View File

@@ -89,6 +89,11 @@
--ease-out: cubic-bezier(0.32, 0.72, 0, 1);
--radius: 0.625rem;
--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. */
--calendar-grid-min-height: 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). */
.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+).
Horizontal padding is left to layout classes (e.g. px-3) so indents are preserved when viewport vars are 0. */
/* Content safe area: top/bottom/left/right so content and sticky chrome sit below Telegram UI.
Use for full-screen wrappers; horizontal padding can be extended by layout (e.g. px-3). */
.content-safe {
padding-top: var(--tg-viewport-content-safe-area-inset-top, env(safe-area-inset-top, 0));
padding-bottom: var(--tg-viewport-content-safe-area-inset-bottom, env(safe-area-inset-bottom, 0));
padding-top: var(--app-safe-top);
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). */

View File

@@ -1,7 +1,9 @@
import type { Metadata, Viewport } from "next";
import { TooltipProvider } from "@/components/ui/tooltip";
import { TelegramProvider } from "@/components/providers/TelegramProvider";
import { AppShell } from "@/components/AppShell";
import { AppErrorBoundary } from "@/components/AppErrorBoundary";
import { THEME_BOOTSTRAP_SCRIPT } from "@/lib/theme-bootstrap-script";
import "./globals.css";
export const metadata: Metadata = {
@@ -23,12 +25,7 @@ export default function RootLayout({
return (
<html lang="en" data-theme="dark" suppressHydrationWarning>
<head>
{/* Inline script: theme from hash (tgWebAppColorScheme + all 14 TG themeParams → --tg-theme-*), then data-theme and Mini App colors. */}
<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 dangerouslySetInnerHTML={{ __html: THEME_BOOTSTRAP_SCRIPT }} />
<script
dangerouslySetInnerHTML={{
__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">
<TelegramProvider>
<AppErrorBoundary>
<TooltipProvider>{children}</TooltipProvider>
<TooltipProvider>
<AppShell>{children}</AppShell>
</TooltipProvider>
</AppErrorBoundary>
</TelegramProvider>
</body>

View File

@@ -5,21 +5,31 @@
"use client";
import { useEffect } from "react";
import Link from "next/link";
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() {
const { t } = useTranslation();
const setAppContentReady = useAppStore((s) => s.setAppContentReady);
useEffect(() => {
setAppContentReady(true);
}, [setAppContentReady]);
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">
<h1 className="text-xl font-semibold">{t("not_found.title")}</h1>
<p className="text-muted-foreground">{t("not_found.description")}</p>
<Link
href="/"
className="rounded-lg bg-primary px-4 py-2 text-primary-foreground hover:bg-primary/90"
>
{t("not_found.open_calendar")}
</Link>
</div>
<FullScreenStateShell
title={t("not_found.title")}
description={t("not_found.description")}
primaryAction={
<Button asChild>
<Link href="/">{t("not_found.open_calendar")}</Link>
</Button>
}
role="status"
/>
);
}

View File

@@ -4,10 +4,9 @@
*/
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 { resetAppStore } from "@/test/test-utils";
import { useAppStore } from "@/store/app-store";
import { useTelegramAuth } from "@/hooks/use-telegram-auth";
vi.mock("next/navigation", () => ({
@@ -41,22 +40,6 @@ describe("Page", () => {
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 () => {
const { RETRY_DELAY_MS } = await import("@/lib/constants");
vi.mocked(useTelegramAuth).mockReturnValue({

View File

@@ -8,10 +8,8 @@
import { useCallback, useEffect } from "react";
import { useAppStore, type AppState } from "@/store/app-store";
import { useShallow } from "zustand/react/shallow";
import { useTelegramTheme } from "@/hooks/use-telegram-theme";
import { useTelegramAuth } from "@/hooks/use-telegram-auth";
import { useAppInit } from "@/hooks/use-app-init";
import { callMiniAppReadyOnce } from "@/lib/telegram-ready";
import { fetchAdminMe } from "@/lib/api";
import { getLang } from "@/i18n/messages";
import { AccessDeniedScreen } from "@/components/states/AccessDeniedScreen";
@@ -19,8 +17,6 @@ import { CurrentDutyView } from "@/components/current-duty/CurrentDutyView";
import { CalendarPage } from "@/components/CalendarPage";
export default function Home() {
useTelegramTheme();
const { initDataRaw, startParam, isLocalhost } = useTelegramAuth();
const isAllowed = isLocalhost || !!initDataRaw;
@@ -35,7 +31,7 @@ export default function Home() {
fetchAdminMe(initDataRaw, getLang()).then(({ is_admin }) => setIsAdmin(is_admin));
}, [isAllowed, initDataRaw, setIsAdmin]);
const { accessDenied, currentView, setCurrentView, setSelectedDay, appContentReady } =
const { accessDenied, currentView, setCurrentView, setSelectedDay, appContentReady, setAppContentReady } =
useAppStore(
useShallow((s: AppState) => ({
accessDenied: s.accessDenied,
@@ -43,15 +39,16 @@ export default function Home() {
setCurrentView: s.setCurrentView,
setSelectedDay: s.setSelectedDay,
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(() => {
if (appContentReady) {
callMiniAppReadyOnce();
if (accessDenied || currentView === "currentDuty") {
setAppContentReady(true);
}
}, [appContentReady]);
}, [accessDenied, currentView, setAppContentReady]);
const handleBackFromCurrentDuty = useCallback(() => {
setCurrentView("calendar");
@@ -59,7 +56,9 @@ export default function Home() {
}, [setCurrentView, setSelectedDay]);
const content = accessDenied ? (
<AccessDeniedScreen primaryAction="reload" />
<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" />
</div>
) : 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">
<CurrentDutyView

View File

@@ -7,9 +7,9 @@
"use client";
import React from "react";
import { getLang } from "@/i18n/messages";
import { translate } from "@/i18n/messages";
import { getLang, translate } from "@/i18n/messages";
import { Button } from "@/components/ui/button";
import { FullScreenStateShell } from "@/components/states/FullScreenStateShell";
interface AppErrorBoundaryProps {
children: React.ReactNode;
@@ -52,23 +52,18 @@ export class AppErrorBoundary extends React.Component<
if (this.state.hasError) {
const lang = getLang();
const message = translate(lang, "error_boundary.message");
const description = translate(lang, "error_boundary.description");
const reloadLabel = translate(lang, "error_boundary.reload");
return (
<div
className="flex min-h-[200px] flex-col items-center justify-center gap-4 rounded-xl bg-surface py-8 px-4 text-center"
role="alert"
>
<p className="m-0 text-sm font-medium text-foreground">{message}</p>
<Button
type="button"
variant="default"
size="sm"
onClick={this.handleReload}
className="bg-primary text-primary-foreground hover:opacity-90"
>
{reloadLabel}
</Button>
</div>
<FullScreenStateShell
title={message}
description={description}
primaryAction={
<Button type="button" variant="default" onClick={this.handleReload}>
{reloadLabel}
</Button>
}
/>
);
}
return this.props.children;

View 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}
</>
);
}

View File

@@ -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
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
month={currentMonth}

View 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;
}

View File

@@ -9,11 +9,15 @@ import { ContactLinks } from "./ContactLinks";
import { resetAppStore } from "@/test/test-utils";
const openPhoneLinkMock = vi.fn();
const openTelegramProfileMock = vi.fn();
const triggerHapticLightMock = vi.fn();
vi.mock("@/lib/open-phone-link", () => ({
openPhoneLink: (...args: unknown[]) => openPhoneLinkMock(...args),
}));
vi.mock("@/lib/telegram-link", () => ({
openTelegramProfile: (...args: unknown[]) => openTelegramProfileMock(...args),
}));
vi.mock("@/lib/telegram-haptic", () => ({
triggerHapticLight: () => triggerHapticLightMock(),
}));
@@ -22,6 +26,7 @@ describe("ContactLinks", () => {
beforeEach(() => {
resetAppStore();
openPhoneLinkMock.mockClear();
openTelegramProfileMock.mockClear();
triggerHapticLightMock.mockClear();
});
@@ -82,4 +87,17 @@ describe("ContactLinks", () => {
expect(openPhoneLinkMock).toHaveBeenCalledWith("+79991234567");
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();
});
});

View File

@@ -8,6 +8,7 @@
import { useTranslation } from "@/i18n/use-translation";
import { formatPhoneDisplay } from "@/lib/phone-format";
import { openPhoneLink } from "@/lib/open-phone-link";
import { openTelegramProfile } from "@/lib/telegram-link";
import { triggerHapticLight } from "@/lib/telegram-haptic";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
@@ -90,6 +91,11 @@ export function ContactLinks({
target="_blank"
rel="noopener noreferrer"
aria-label={ariaTelegram}
onClick={(e) => {
e.preventDefault();
openTelegramProfile(cleanUsername);
triggerHapticLight();
}}
>
<TelegramIcon className="size-5" aria-hidden />
<span>@{cleanUsername}</span>
@@ -131,6 +137,11 @@ export function ContactLinks({
}
if (hasUsername) {
const href = `https://t.me/${encodeURIComponent(cleanUsername)}`;
const handleTelegramClick = (e: React.MouseEvent<HTMLAnchorElement>) => {
e.preventDefault();
openTelegramProfile(cleanUsername);
triggerHapticLight();
};
const link = (
<a
key="tg"
@@ -139,6 +150,7 @@ export function ContactLinks({
rel="noopener noreferrer"
className={linkClass}
aria-label={ariaTelegram}
onClick={handleTelegramClick}
>
@{cleanUsername}
</a>

View File

@@ -340,13 +340,12 @@ export function CurrentDutyView({ onBack, openedFromPin = false }: CurrentDutyVi
</section>
<div
className="rounded-lg bg-duty/10 px-3 py-2.5 flex flex-col gap-1"
aria-live="polite"
aria-atomic="true"
aria-label={t("current_duty.remaining_label")}
>
<span className="text-xs text-muted-foreground">
{t("current_duty.remaining_label")}
</span>
<span className="text-xl font-semibold text-foreground tabular-nums">
<span className="text-xl font-semibold text-foreground tabular-nums" aria-hidden>
{remainingValueStr}
</span>
<span className="text-xs text-muted-foreground">

View File

@@ -10,7 +10,7 @@ import {
bindViewportCssVars,
unmountViewport,
} 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 { useAppStore } from "@/store/app-store";
import { getLang } from "@/i18n/messages";
@@ -42,6 +42,15 @@ export function useTelegramSdkReady(): TelegramSdkContextValue {
* useTelegramTheme() in the app handles ongoing theme changes.
* 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({
children,
}: {
@@ -111,6 +120,7 @@ export function TelegramProvider({
return (
<TelegramSdkContext.Provider value={{ sdkReady }}>
<ThemeSync />
{children}
</TelegramSdkContext.Provider>
);

View File

@@ -9,6 +9,8 @@ import { useEffect } from "react";
import { getLang, translate } from "@/i18n/messages";
import { useAppStore } from "@/store/app-store";
import { triggerHapticLight } from "@/lib/telegram-haptic";
import { Button } from "@/components/ui/button";
import { FullScreenStateShell } from "@/components/states/FullScreenStateShell";
export interface AccessDeniedScreenProps {
/** Optional detail from API 403 response, shown below the hint. */
@@ -59,28 +61,20 @@ export function AccessDeniedScreen({
: translate(lang, "current_duty.back");
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"
role="alert"
<FullScreenStateShell
title={translate(lang, "access_denied")}
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 && (
<p className="text-center text-sm text-muted-foreground">
{serverDetail}
</p>
)}
<button
type="button"
onClick={handleClick}
className="rounded-lg bg-primary px-4 py-2 text-primary-foreground hover:bg-primary/90"
>
{buttonLabel}
</button>
</div>
</FullScreenStateShell>
);
}

View 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>
);
}

View File

@@ -1,13 +1,12 @@
/**
* Application initialization: language sync, access-denied logic, deep link routing.
* Runs effects that depend on Telegram auth (isAllowed, startParam); caller provides those.
* Application initialization: access-denied logic and deep link routing.
* Document lang/title are owned by TelegramProvider (all routes).
*/
"use client";
import { useEffect } from "react";
import { useAppStore } from "@/store/app-store";
import { useTranslation } from "@/i18n/use-translation";
import { RETRY_DELAY_MS } from "@/lib/constants";
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.
* Language is synced from window.__DT_LANG in TelegramProvider (all routes).
*/
export function useAppInit({ isAllowed, startParam }: UseAppInitParams): void {
const lang = useAppStore((s) => s.lang);
const setAccessDenied = useAppStore((s) => s.setAccessDenied);
const setLoading = useAppStore((s) => s.setLoading);
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.
useEffect(() => {

View 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();
});
});

View File

@@ -10,7 +10,8 @@ import { useEffect, useRef } from "react";
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.
*/
export function useAutoRefresh(
@@ -22,7 +23,6 @@ export function useAutoRefresh(
useEffect(() => {
if (!isCurrentMonth) return;
refreshRef.current();
const id = setInterval(() => refreshRef.current(), AUTO_REFRESH_INTERVAL_MS);
return () => clearInterval(id);
}, [isCurrentMonth]);

View File

@@ -1,6 +1,6 @@
/**
* 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";
@@ -15,8 +15,9 @@ export interface UseSwipeOptions {
}
/**
* Attaches touchstart/touchend to the element ref and invokes onSwipeLeft or onSwipeRight
* when a horizontal swipe exceeds the threshold. Vertical swipes are ignored.
* Attaches touchstart/touchmove/touchend to the element ref. Fires onSwipeLeft or onSwipeRight
* 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(
elementRef: React.RefObject<HTMLElement | null>,
@@ -27,6 +28,7 @@ export function useSwipe(
const { threshold = 50, disabled = false } = options;
const startX = useRef(0);
const startY = useRef(0);
const cancelledRef = useRef(false);
const onSwipeLeftRef = useRef(onSwipeLeft);
const onSwipeRightRef = useRef(onSwipeRight);
onSwipeLeftRef.current = onSwipeLeft;
@@ -41,10 +43,21 @@ export function useSwipe(
const t = e.changedTouches[0];
startX.current = t.clientX;
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) => {
if (e.changedTouches.length === 0) return;
if (e.changedTouches.length === 0 || cancelledRef.current) return;
const t = e.changedTouches[0];
const deltaX = t.clientX - startX.current;
const deltaY = t.clientY - startY.current;
@@ -58,9 +71,11 @@ export function useSwipe(
};
el.addEventListener("touchstart", handleStart, { passive: true });
el.addEventListener("touchmove", handleMove, { passive: true });
el.addEventListener("touchend", handleEnd, { passive: true });
return () => {
el.removeEventListener("touchstart", handleStart);
el.removeEventListener("touchmove", handleMove);
el.removeEventListener("touchend", handleEnd);
};
}, [elementRef, disabled, threshold]);

View File

@@ -0,0 +1,60 @@
/**
* Unit tests for openTelegramProfile: Mini Appfriendly 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();
});
});

View File

@@ -0,0 +1,28 @@
/**
* Opens a Telegram profile (t.me/username) in a Mini Appfriendly 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");
}

View 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');}})();";