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:
|
||||
|
||||
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 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')`
|
||||
- `setHeaderColor('bg_color')`
|
||||
- `setBottomBarColor('bottom_bar_bg_color')` when available (Bot API 7.10+).
|
||||
|
||||
@@ -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 ? (
|
||||
<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
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
<FullScreenStateShell
|
||||
title={message}
|
||||
description={description}
|
||||
primaryAction={
|
||||
<Button type="button" variant="default" onClick={this.handleReload}>
|
||||
{reloadLabel}
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
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
|
||||
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}
|
||||
|
||||
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";
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
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.
|
||||
* 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(() => {
|
||||
|
||||
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;
|
||||
|
||||
/**
|
||||
* 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]);
|
||||
|
||||
@@ -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]);
|
||||
|
||||
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