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:
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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). */
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user