feat: migrate to Next.js for Mini App and enhance project structure
- Replaced the previous webapp with a new Mini App built using Next.js, improving performance and maintainability. - Updated the `.gitignore` to exclude Next.js build artifacts and node modules. - Revised documentation in `AGENTS.md`, `README.md`, and `architecture.md` to reflect the new Mini App structure and technology stack. - Enhanced Dockerfile to support the new build process for the Next.js application. - Updated CI workflow to build and test the Next.js application. - Added new configuration options for the Mini App, including `MINI_APP_SHORT_NAME` for improved deep linking. - Refactored frontend testing setup to accommodate the new structure and testing framework. - Removed legacy webapp files and dependencies to streamline the project.
This commit is contained in:
BIN
webapp-next/src/app/favicon.ico
Normal file
BIN
webapp-next/src/app/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
52
webapp-next/src/app/global-error.tsx
Normal file
52
webapp-next/src/app/global-error.tsx
Normal file
@@ -0,0 +1,52 @@
|
||||
/**
|
||||
* Next.js root error boundary. Replaces the root layout when an unhandled error occurs.
|
||||
* Must define its own html/body. For most runtime errors the in-app AppErrorBoundary is used.
|
||||
*/
|
||||
|
||||
"use client";
|
||||
|
||||
import "./globals.css";
|
||||
import { getLang, translate } from "@/i18n/messages";
|
||||
|
||||
export default function GlobalError({
|
||||
error,
|
||||
reset,
|
||||
}: {
|
||||
error: Error & { digest?: string };
|
||||
reset: () => void;
|
||||
}) {
|
||||
const lang = getLang();
|
||||
return (
|
||||
<html
|
||||
lang={lang === "ru" ? "ru" : "en"}
|
||||
data-theme="dark"
|
||||
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');}})();`,
|
||||
}}
|
||||
/>
|
||||
</head>
|
||||
<body className="antialiased">
|
||||
<div className="flex min-h-screen 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"
|
||||
>
|
||||
{translate(lang, "error_boundary.reload")}
|
||||
</button>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
287
webapp-next/src/app/globals.css
Normal file
287
webapp-next/src/app/globals.css
Normal file
@@ -0,0 +1,287 @@
|
||||
@import "tailwindcss";
|
||||
@import "tw-animate-css";
|
||||
@import "shadcn/tailwind.css";
|
||||
|
||||
@custom-variant dark (&:is([data-theme="dark"] *));
|
||||
|
||||
@theme inline {
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--font-sans: system-ui, -apple-system, sans-serif;
|
||||
--font-mono: ui-monospace, monospace;
|
||||
--color-chart-5: var(--chart-5);
|
||||
--color-chart-4: var(--chart-4);
|
||||
--color-chart-3: var(--chart-3);
|
||||
--color-chart-2: var(--chart-2);
|
||||
--color-chart-1: var(--chart-1);
|
||||
--color-ring: var(--ring);
|
||||
--color-input: var(--input);
|
||||
--color-border: var(--border);
|
||||
--color-destructive: var(--destructive);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
--color-accent: var(--accent);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
--color-muted: var(--muted);
|
||||
--color-secondary-foreground: var(--secondary-foreground);
|
||||
--color-secondary: var(--secondary);
|
||||
--color-primary-foreground: var(--primary-foreground);
|
||||
--color-primary: var(--primary);
|
||||
--color-popover-foreground: var(--popover-foreground);
|
||||
--color-popover: var(--popover);
|
||||
--color-card-foreground: var(--card-foreground);
|
||||
--color-card: var(--card);
|
||||
--radius-sm: calc(var(--radius) - 4px);
|
||||
--radius-md: calc(var(--radius) - 2px);
|
||||
--radius-lg: var(--radius);
|
||||
--radius-xl: calc(var(--radius) + 4px);
|
||||
--radius-2xl: calc(var(--radius) + 8px);
|
||||
--radius-3xl: calc(var(--radius) + 12px);
|
||||
--radius-4xl: calc(var(--radius) + 16px);
|
||||
/* App design tokens (Telegram Mini App) */
|
||||
--color-surface: var(--surface);
|
||||
--color-duty: var(--duty);
|
||||
--color-today: var(--today);
|
||||
--color-unavailable: var(--unavailable);
|
||||
--color-vacation: var(--vacation);
|
||||
--color-error: var(--error);
|
||||
--max-width-app: 420px;
|
||||
}
|
||||
|
||||
/* App design tokens: use Telegram theme vars with dark fallbacks so TG vars apply before data-theme */
|
||||
:root {
|
||||
--bg: var(--tg-theme-bg-color, #17212b);
|
||||
--surface: var(--tg-theme-secondary-bg-color, #232e3c);
|
||||
--text: var(--tg-theme-text-color, #f5f5f5);
|
||||
--muted: var(--tg-theme-hint-color, var(--tg-theme-subtitle-text-color, #708499));
|
||||
--accent: var(--tg-theme-link-color, #6ab3f3);
|
||||
--header-bg: var(--tg-theme-header-bg-color, #232e3c);
|
||||
--card: var(--tg-theme-section-bg-color, var(--surface));
|
||||
--section-header: var(--tg-theme-section-header-text-color, #f5f5f5);
|
||||
--border: var(--tg-theme-section-separator-color, color-mix(in srgb, var(--text) 10%, transparent));
|
||||
--primary: var(--tg-theme-button-color, var(--accent));
|
||||
--primary-foreground: var(--tg-theme-button-text-color, #17212b);
|
||||
--error: var(--tg-theme-destructive-text-color, #e06c75);
|
||||
--accent-text: var(--tg-theme-accent-text-color, #6ab2f2);
|
||||
--duty: #5c9b4a;
|
||||
--today: var(--tg-theme-accent-text-color, var(--tg-theme-link-color, #6ab2f2));
|
||||
--unavailable: #b8860b;
|
||||
--vacation: #5a9bb8;
|
||||
--timeline-date-width: 3.6em;
|
||||
--timeline-track-width: 10px;
|
||||
/* Reusable color-mix tokens (avoid repeating in Tailwind classes). */
|
||||
--surface-hover: color-mix(in srgb, var(--accent) 15%, var(--surface));
|
||||
--surface-hover-10: color-mix(in srgb, var(--accent) 10%, var(--surface));
|
||||
--surface-today-tint: color-mix(in srgb, var(--today) 12%, var(--surface));
|
||||
--surface-muted-tint: color-mix(in srgb, var(--muted) 8%, var(--surface));
|
||||
--today-hover: color-mix(in srgb, var(--bg) 15%, var(--today));
|
||||
--today-border: color-mix(in srgb, var(--today) 35%, transparent);
|
||||
--today-border-selected: color-mix(in srgb, var(--bg) 50%, transparent);
|
||||
--today-gradient-end: color-mix(in srgb, var(--today) 15%, transparent);
|
||||
--muted-fade: color-mix(in srgb, var(--muted) 40%, transparent);
|
||||
--handle-bg: color-mix(in srgb, var(--muted) 80%, var(--text));
|
||||
--indicator-today-duty: color-mix(in srgb, var(--duty) 65%, var(--bg));
|
||||
--indicator-today-unavailable: color-mix(in srgb, var(--unavailable) 65%, var(--bg));
|
||||
--indicator-today-vacation: color-mix(in srgb, var(--vacation) 65%, var(--bg));
|
||||
--indicator-today-events: color-mix(in srgb, var(--accent) 65%, var(--bg));
|
||||
--shadow-card: 0 4px 12px color-mix(in srgb, var(--text) 12%, transparent);
|
||||
--transition-fast: 0.15s;
|
||||
--transition-normal: 0.25s;
|
||||
--ease-out: cubic-bezier(0.32, 0.72, 0, 1);
|
||||
--radius: 0.625rem;
|
||||
--calendar-block-min-height: 260px;
|
||||
/** 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). */
|
||||
--calendar-row-min-height: 2.75rem;
|
||||
/* Align Tailwind/shadcn semantic tokens with app tokens for Mini App */
|
||||
--background: var(--bg);
|
||||
--foreground: var(--text);
|
||||
--card-foreground: var(--text);
|
||||
--popover: var(--surface);
|
||||
--popover-foreground: var(--text);
|
||||
--secondary: var(--surface);
|
||||
--secondary-foreground: var(--text);
|
||||
--muted-foreground: var(--muted);
|
||||
--accent-foreground: var(--bg);
|
||||
--destructive: var(--error);
|
||||
--input: color-mix(in srgb, var(--text) 15%, transparent);
|
||||
--ring: var(--accent);
|
||||
--chart-1: var(--duty);
|
||||
--chart-2: var(--vacation);
|
||||
--chart-3: var(--unavailable);
|
||||
--chart-4: var(--today);
|
||||
--chart-5: var(--accent);
|
||||
}
|
||||
|
||||
/* Light theme: full Telegram themeParams (14 params) mapping */
|
||||
[data-theme="light"] {
|
||||
--bg: var(--tg-theme-bg-color, #f0f1f3);
|
||||
--surface: var(--tg-theme-secondary-bg-color, #e0e2e6);
|
||||
--text: var(--tg-theme-text-color, #343b58);
|
||||
--muted: var(--tg-theme-hint-color, var(--tg-theme-subtitle-text-color, #6b7089));
|
||||
--accent: var(--tg-theme-link-color, #2e7de0);
|
||||
--header-bg: var(--tg-theme-header-bg-color, #e0e2e6);
|
||||
--card: var(--tg-theme-section-bg-color, #e0e2e6);
|
||||
--section-header: var(--tg-theme-section-header-text-color, #343b58);
|
||||
--border: var(--tg-theme-section-separator-color, color-mix(in srgb, var(--text) 15%, transparent));
|
||||
--primary: var(--tg-theme-button-color, #2e7de0);
|
||||
--primary-foreground: var(--tg-theme-button-text-color, #ffffff);
|
||||
--error: var(--tg-theme-destructive-text-color, #c43b3b);
|
||||
--accent-text: var(--tg-theme-accent-text-color, #2481cc);
|
||||
--duty: #587d0a;
|
||||
--today: var(--tg-theme-accent-text-color, var(--tg-theme-link-color, #2481cc));
|
||||
--unavailable: #b8860b;
|
||||
--vacation: #0d6b9e;
|
||||
}
|
||||
|
||||
/* Dark theme: full Telegram themeParams (14 params) mapping */
|
||||
[data-theme="dark"] {
|
||||
--bg: var(--tg-theme-bg-color, #17212b);
|
||||
--surface: var(--tg-theme-secondary-bg-color, #232e3c);
|
||||
--text: var(--tg-theme-text-color, #f5f5f5);
|
||||
--muted: var(--tg-theme-hint-color, var(--tg-theme-subtitle-text-color, #708499));
|
||||
--accent: var(--tg-theme-link-color, #6ab3f3);
|
||||
--header-bg: var(--tg-theme-header-bg-color, #232e3c);
|
||||
--card: var(--tg-theme-section-bg-color, #232e3c);
|
||||
--section-header: var(--tg-theme-section-header-text-color, #f5f5f5);
|
||||
--border: var(--tg-theme-section-separator-color, color-mix(in srgb, var(--text) 10%, transparent));
|
||||
--primary: var(--tg-theme-button-color, #6ab3f3);
|
||||
--primary-foreground: var(--tg-theme-button-text-color, #17212b);
|
||||
--error: var(--tg-theme-destructive-text-color, #e06c75);
|
||||
--accent-text: var(--tg-theme-accent-text-color, #6ab2f2);
|
||||
--duty: #5c9b4a;
|
||||
--today: var(--tg-theme-accent-text-color, var(--tg-theme-link-color, #6ab2f2));
|
||||
--unavailable: #b8860b;
|
||||
--vacation: #5a9bb8;
|
||||
}
|
||||
|
||||
/* === Layout & base (ported from webapp/css/base.css) */
|
||||
html {
|
||||
scrollbar-gutter: stable;
|
||||
scrollbar-width: none;
|
||||
-ms-overflow-style: none;
|
||||
overscroll-behavior: none;
|
||||
}
|
||||
|
||||
html::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Container: max-width, padding, safe area. Use .container for main wrapper if needed. */
|
||||
.container-app {
|
||||
max-width: var(--max-width-app, 420px);
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
padding: 12px;
|
||||
padding-top: 0;
|
||||
padding-bottom: env(safe-area-inset-bottom, 12px);
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
/* Duty list: timeline date cell (non-today) — horizontal line and vertical tick to track */
|
||||
.duty-timeline-date:not(.duty-timeline-date--today)::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 0;
|
||||
bottom: 4px;
|
||||
width: calc(100% + var(--timeline-track-width) / 2);
|
||||
height: 2px;
|
||||
background: linear-gradient(
|
||||
to right,
|
||||
var(--muted-fade) 0%,
|
||||
var(--muted-fade) 50%,
|
||||
var(--muted) 70%,
|
||||
var(--muted) 100%
|
||||
);
|
||||
}
|
||||
|
||||
.duty-timeline-date:not(.duty-timeline-date--today)::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: calc(100% + (var(--timeline-track-width) / 2) - 1px);
|
||||
bottom: 2px;
|
||||
width: 2px;
|
||||
height: 6px;
|
||||
background: var(--muted);
|
||||
}
|
||||
|
||||
/* Duty list: flip card (front = duty info, back = contacts) */
|
||||
.duty-flip-card {
|
||||
perspective: 600px;
|
||||
}
|
||||
.duty-flip-inner {
|
||||
transform-style: preserve-3d;
|
||||
}
|
||||
.duty-flip-front {
|
||||
backface-visibility: hidden;
|
||||
-webkit-backface-visibility: hidden;
|
||||
}
|
||||
.duty-flip-back {
|
||||
backface-visibility: hidden;
|
||||
-webkit-backface-visibility: hidden;
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
transform: rotateY(180deg);
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
animation-duration: 0.01ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* Safe area for Telegram Mini App (notch / status bar). */
|
||||
.pt-safe {
|
||||
padding-top: env(safe-area-inset-top, 0);
|
||||
}
|
||||
|
||||
/* Sticky calendar header: shadow when scrolled (useStickyScroll). */
|
||||
.sticky.is-scrolled {
|
||||
box-shadow: 0 1px 0 0 var(--border);
|
||||
}
|
||||
|
||||
/* Calendar grid: 6 rows with minimum height so cells stay large (restore pre-audit look). */
|
||||
.calendar-grid {
|
||||
grid-template-rows: repeat(6, minmax(var(--calendar-row-min-height), 1fr));
|
||||
}
|
||||
|
||||
/* Current duty card: entrance animation (respects prefers-reduced-motion via global rule). */
|
||||
@keyframes card-appear {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(16px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.current-duty-card {
|
||||
box-shadow: var(--shadow-card);
|
||||
animation: card-appear 0.3s var(--ease-out);
|
||||
}
|
||||
|
||||
.current-duty-card--no-duty {
|
||||
border-top-color: var(--muted);
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
@apply border-border outline-ring/50;
|
||||
}
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
min-height: 100vh;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-family: system-ui, -apple-system, sans-serif;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
}
|
||||
48
webapp-next/src/app/layout.tsx
Normal file
48
webapp-next/src/app/layout.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
import type { Metadata, Viewport } from "next";
|
||||
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||
import { TelegramProvider } from "@/components/providers/TelegramProvider";
|
||||
import { AppErrorBoundary } from "@/components/AppErrorBoundary";
|
||||
import "./globals.css";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Duty Teller",
|
||||
description: "Team duty shift calendar and reminders",
|
||||
};
|
||||
|
||||
export const viewport: Viewport = {
|
||||
width: "device-width",
|
||||
initialScale: 1,
|
||||
viewportFit: "cover",
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
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: `(function(){if(typeof window!=='undefined'&&window.__DT_LANG==null)window.__DT_LANG='en';})();`,
|
||||
}}
|
||||
/>
|
||||
<script src="/app/config.js" />
|
||||
</head>
|
||||
<body className="antialiased">
|
||||
<TelegramProvider>
|
||||
<AppErrorBoundary>
|
||||
<TooltipProvider>{children}</TooltipProvider>
|
||||
</AppErrorBoundary>
|
||||
</TelegramProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
25
webapp-next/src/app/not-found.tsx
Normal file
25
webapp-next/src/app/not-found.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
/**
|
||||
* Next.js 404 page. Shown when notFound() is called or route is unknown.
|
||||
* For static export with a single route this is rarely hit; added for consistency.
|
||||
*/
|
||||
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useTranslation } from "@/i18n/use-translation";
|
||||
|
||||
export default function NotFound() {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="flex min-h-screen 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>
|
||||
);
|
||||
}
|
||||
54
webapp-next/src/app/page.test.tsx
Normal file
54
webapp-next/src/app/page.test.tsx
Normal file
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* Integration test for main page: calendar and header visible, lang from store.
|
||||
* Ported from webapp/js/main.test.js applyLangToUi.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import Page from "./page";
|
||||
import { resetAppStore } from "@/test/test-utils";
|
||||
import { useAppStore } from "@/store/app-store";
|
||||
|
||||
vi.mock("@/hooks/use-telegram-auth", () => ({
|
||||
useTelegramAuth: () => ({
|
||||
initDataRaw: "test-init",
|
||||
startParam: undefined,
|
||||
isLocalhost: true,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/hooks/use-month-data", () => ({
|
||||
useMonthData: () => ({
|
||||
retry: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
describe("Page", () => {
|
||||
beforeEach(() => {
|
||||
resetAppStore();
|
||||
});
|
||||
|
||||
it("renders calendar and header when store has default state", async () => {
|
||||
render(<Page />);
|
||||
expect(await screen.findByRole("grid", { name: "Calendar" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: /previous month/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: /next month/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("sets document title and lang from store lang", async () => {
|
||||
useAppStore.getState().setLang("en");
|
||||
render(<Page />);
|
||||
await screen.findByRole("grid", { name: "Calendar" });
|
||||
expect(document.title).toBe("Duty Calendar");
|
||||
expect(document.documentElement.lang).toBe("en");
|
||||
});
|
||||
|
||||
it("sets document title for ru when store lang is ru", async () => {
|
||||
(globalThis.window as unknown as { __DT_LANG?: string }).__DT_LANG = "ru";
|
||||
render(<Page />);
|
||||
await screen.findByRole("grid", { name: "Calendar" });
|
||||
await waitFor(() => {
|
||||
expect(document.title).toBe("Календарь дежурств");
|
||||
});
|
||||
});
|
||||
});
|
||||
50
webapp-next/src/app/page.tsx
Normal file
50
webapp-next/src/app/page.tsx
Normal file
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* Main Mini App page: current duty deep link or calendar view.
|
||||
* Delegates to CurrentDutyView or CalendarPage; runs theme and app init.
|
||||
*/
|
||||
|
||||
"use client";
|
||||
|
||||
import { useCallback } from "react";
|
||||
import { useAppStore } 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 { 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;
|
||||
|
||||
useAppInit({ isAllowed, startParam });
|
||||
|
||||
const { currentView, setCurrentView, setSelectedDay } = useAppStore(
|
||||
useShallow((s) => ({
|
||||
currentView: s.currentView,
|
||||
setCurrentView: s.setCurrentView,
|
||||
setSelectedDay: s.setSelectedDay,
|
||||
}))
|
||||
);
|
||||
|
||||
const handleBackFromCurrentDuty = useCallback(() => {
|
||||
setCurrentView("calendar");
|
||||
setSelectedDay(null);
|
||||
}, [setCurrentView, setSelectedDay]);
|
||||
|
||||
if (currentView === "currentDuty") {
|
||||
return (
|
||||
<div className="mx-auto flex min-h-screen w-full max-w-[var(--max-width-app)] flex-col bg-background px-3 pb-6 pt-safe">
|
||||
<CurrentDutyView
|
||||
onBack={handleBackFromCurrentDuty}
|
||||
openedFromPin={startParam === "duty"}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <CalendarPage isAllowed={isAllowed} initDataRaw={initDataRaw} />;
|
||||
}
|
||||
Reference in New Issue
Block a user