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

@@ -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