feat: enhance Mini App design guidelines and refactor layout components

- Updated Mini App design guidelines to include detailed instructions on UI changes, accessibility rules, and verification processes.
- Refactored multiple components to utilize `MiniAppScreen` and `MiniAppScreenContent` for consistent layout structure across the application.
- Improved error handling in `GlobalError` and `NotFound` components by integrating new layout components for better user experience.
- Introduced new hooks for admin functionality, streamlining access checks and data loading processes.
- Enhanced documentation to reflect changes in design policies and component usage, ensuring clarity for future development.
This commit is contained in:
2026-03-06 17:51:33 +03:00
parent 43cd3bbd7d
commit fa22976e75
38 changed files with 1166 additions and 512 deletions

View File

@@ -41,9 +41,11 @@ The Mini App lives in `webapp-next/`. It is built as a static export and served
- **Client components:** Use `'use client'` where hooks or browser APIs are used (layout loads config script; page and most UI are client). - **Client components:** Use `'use client'` where hooks or browser APIs are used (layout loads config script; page and most UI are client).
- **Theme:** CSS variables in `globals.css`; `useTelegramTheme` maps Telegram theme params to `--tg-theme-*` and sets `data-theme` on `<html>`. - **Theme:** CSS variables in `globals.css`; `useTelegramTheme` maps Telegram theme params to `--tg-theme-*` and sets `data-theme` on `<html>`.
- **Auth:** `useTelegramAuth` provides initData for API; access gated for non-Telegram except localhost. - **Auth:** `useTelegramAuth` provides initData for API; access gated for non-Telegram except localhost.
- **i18n:** `useTranslation()` from store lang; `window.__DT_LANG` set by `/app/config.js` (backend). - **i18n:** `useTranslation()` for React UI; `getLang()/translate()` only for early bootstrap or non-React boundaries.
- **API:** `fetchDuties`, `fetchCalendarEvents` in `src/lib/api.ts`; pass initData, lang, AbortSignal; handle ACCESS_DENIED. - **API:** `fetchDuties`, `fetchCalendarEvents` in `src/lib/api.ts`; pass initData, lang, AbortSignal; handle ACCESS_DENIED.
- **Heavy pages:** For feature-heavy routes (e.g. admin), use a custom hook (state, effects, callbacks) plus presentational components; keep the page as a thin layer (early returns + composition). Example: `admin/page.tsx` uses `useAdminPage`, `AdminDutyList`, and `ReassignSheet`. - **Heavy pages:** For feature-heavy routes (e.g. admin), use a custom hook (state, effects, callbacks) plus presentational components; keep the page as a thin layer (early returns + composition). Example: `admin/page.tsx` uses `useAdminPage`, `AdminDutyList`, and `ReassignSheet`.
- **Platform boundary:** Use `src/hooks/telegram/*` adapters for Back/Settings/Close/swipe/closing behavior instead of direct SDK control calls in feature components.
- **Screen shells:** Reuse `src/components/layout/MiniAppScreen.tsx` wrappers for route and full-screen states.
## Testing ## Testing
@@ -60,6 +62,8 @@ When adding or changing UI in the Mini App, **follow the [Mini App design guidel
- Page wrappers: `content-safe`, `max-w-[var(--max-width-app)]`, viewport height; respect safe area for sheets/modals. - Page wrappers: `content-safe`, `max-w-[var(--max-width-app)]`, viewport height; respect safe area for sheets/modals.
- Reuse component patterns (buttons, cards, calendar grid, timeline list) and left-stripe semantics (`border-l-duty`, `border-l-today`, etc.). - Reuse component patterns (buttons, cards, calendar grid, timeline list) and left-stripe semantics (`border-l-duty`, `border-l-today`, etc.).
- Add ARIA labels and roles for interactive elements and grids; respect `prefers-reduced-motion` and `data-perf="low"` for animations. - Add ARIA labels and roles for interactive elements and grids; respect `prefers-reduced-motion` and `data-perf="low"` for animations.
- Keep all user-facing strings and `aria-label`/`sr-only` text localized.
- Follow Telegram interaction policy from the design guideline: vertical swipes enabled by default, closing confirmation only for stateful flows.
Use the checklist in the design doc when introducing new screens or components. Use the checklist in the design doc when introducing new screens or components.

View File

@@ -50,5 +50,5 @@ Docstrings and code comments must be in English (Google-style docstrings). UI st
- **Config:** Environment variables (e.g. `.env`); no hardcoded secrets. - **Config:** Environment variables (e.g. `.env`); no hardcoded secrets.
- **Database:** One logical transaction per `session_scope` — a single `commit` at the end of the business operation (e.g. in `run_import`). Repository helpers used inside such a flow (e.g. `get_or_create_user_by_full_name`) accept `commit=False` and let the caller commit once. - **Database:** One logical transaction per `session_scope` — a single `commit` at the end of the business operation (e.g. in `run_import`). Repository helpers used inside such a flow (e.g. `get_or_create_user_by_full_name`) accept `commit=False` and let the caller commit once.
- **Error handling:** Do not send `str(exception)` from parsers or DB to the user. Use generic i18n keys (e.g. `import.parse_error_generic`, `import.import_error_generic`) and log the full exception server-side. - **Error handling:** Do not send `str(exception)` from parsers or DB to the user. Use generic i18n keys (e.g. `import.parse_error_generic`, `import.import_error_generic`) and log the full exception server-side.
- **Mini App (webapp-next):** When adding or changing UI in `webapp-next/`, follow the [Mini App design guideline](docs/miniapp-design.md): use only design tokens and Tailwind aliases from it, apply layout/safe-area/accessibility rules, and use the checklist for new screens or components. - **Mini App (webapp-next):** When adding or changing UI in `webapp-next/`, follow the [Mini App design guideline](docs/miniapp-design.md): use only design tokens and Tailwind aliases, use shared Mini App screen shells, keep Telegram SDK access behind `src/hooks/telegram/*`, apply safe-area/content-safe-area and accessibility rules, and run the Mini App verification matrix (light/dark, iOS/Android safe area, low-perf Android, deep links, admin flow, fallback states).
- **Cursor:** The project does not version `.cursor/`. You can mirror this file in `.cursor/rules/` locally; [AGENTS.md](AGENTS.md) is the single versioned reference for AI and maintainers. - **Cursor:** The project does not version `.cursor/`. You can mirror this file in `.cursor/rules/` locally; [AGENTS.md](AGENTS.md) is the single versioned reference for AI and maintainers.

View File

@@ -100,6 +100,12 @@ Use **only** these tokens and Tailwind/shadcn aliases (`bg-background`, `text-mu
- **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. - **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`). - 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`).
Official terminology (Telegram docs) uses `safeAreaInset` and `contentSafeAreaInset`
plus events `safeAreaChanged` and `contentSafeAreaChanged`. In our code, these values
are exposed through SDK CSS bindings and consumed via app aliases (`--app-safe-*`).
When updating safe-area code, preserve this mapping and avoid mixing raw `env(...)`
and Telegram content-safe insets within the same component.
### 3.4 Sheets and modals ### 3.4 Sheets and modals
Bottom sheets and modals that sit at the bottom of the screen must add safe area to their padding, e.g.: Bottom sheets and modals that sit at the bottom of the screen must add safe area to their padding, e.g.:
@@ -203,6 +209,31 @@ See `webapp-next/src/components/day-detail/DayDetail.tsx` for the Sheet content.
- `setBottomBarColor('bottom_bar_bg_color')` when available (Bot API 7.10+). - `setBottomBarColor('bottom_bar_bg_color')` when available (Bot API 7.10+).
- **Surface contrast:** When `--surface` equals `--bg` (e.g. some iOS OLED themes), `fixSurfaceContrast()` in `use-telegram-theme.ts` adjusts `--surface` using ThemeParams or a light color-mix so cards and panels remain visible. - **Surface contrast:** When `--surface` equals `--bg` (e.g. some iOS OLED themes), `fixSurfaceContrast()` in `use-telegram-theme.ts` adjusts `--surface` using ThemeParams or a light color-mix so cards and panels remain visible.
### 8.1 Native control policy
- Use platform wrappers in `src/hooks/telegram/` rather than direct SDK calls in
feature components.
- **BackButton:** preferred for route-level back navigation in Telegram context.
- **SettingsButton:** use for route actions like opening `/admin` from calendar.
- **Main/Secondary button:** optional; use only if action must align with Telegram
bottom action affordance (do not duplicate with conflicting in-app primary CTA).
- **Haptics:** trigger only on meaningful user actions (submit, confirm, close).
### 8.2 Swipe and closing policy
- Keep vertical swipes enabled by default (`enableVerticalSwipes` behavior).
- Disable vertical swipes only on screens with explicit gesture conflict and document
the reason in code review.
- Enable closing confirmation only for stateful flows where accidental close can
lose user intent (e.g. reassignment flow in admin sheet).
### 8.3 Fullscreen/newer APIs policy
- Fullscreen APIs (`requestFullscreen`, `exitFullscreen`) are currently optional and
out of scope unless a feature explicitly requires immersive mode.
- If fullscreen is introduced, review safe area/content safe area and verify
`safeAreaChanged`, `contentSafeAreaChanged`, and `fullscreenChanged` handling.
--- ---
## 9. Checklist for new screens and components ## 9. Checklist for new screens and components
@@ -215,3 +246,18 @@ Use this for review when adding or changing UI:
- [ ] Safe area is respected for bottom padding and for sheets/modals. - [ ] Safe area is respected for bottom padding and for sheets/modals.
- [ ] Interactive elements and grids/lists have appropriate `aria-label`s and roles. - [ ] Interactive elements and grids/lists have appropriate `aria-label`s and roles.
- [ ] New animations respect `prefers-reduced-motion` and `data-perf="low"` (short or minimal on low-end Android). - [ ] New animations respect `prefers-reduced-motion` and `data-perf="low"` (short or minimal on low-end Android).
- [ ] User-facing strings and `aria-label`/`sr-only` text are localized via i18n (no hardcoded English in shared UI).
- [ ] Telegram controls are connected through platform hooks (`src/hooks/telegram/*`) instead of direct SDK calls.
- [ ] Vertical swipe and closing confirmation behavior follows the policy above.
## 10. Verification matrix (Mini App)
At minimum verify:
- Telegram light + dark themes.
- iOS and Android safe area/content-safe-area behavior (portrait + landscape).
- Android low-performance behavior (`data-perf="low"`).
- Deep link current duty (`startParam=duty`).
- Direct `/admin` open and reassignment flow.
- Access denied, not-found, and error boundary screens.
- Calendar swipe navigation with sticky header and native Telegram controls.

View File

@@ -0,0 +1,119 @@
# Webapp-next Refactor Baseline Audit
This note captures the baseline before the phased refactor. It defines current risks,
duplication hotspots, and expected behavior that must not regress.
## 1) Screens and boundaries
- Home route orchestration: `webapp-next/src/app/page.tsx`
- Chooses among `AccessDeniedScreen`, `CurrentDutyView`, `CalendarPage`.
- Controls app visibility via `appContentReady`.
- Admin route orchestration: `webapp-next/src/app/admin/page.tsx`
- Thin route, but still owns shell duplication and content-ready signaling.
- Calendar composition root: `webapp-next/src/components/CalendarPage.tsx`
- Combines sticky layout, swipe, month loading, auto-refresh, settings button.
- Current duty feature root: `webapp-next/src/components/current-duty/CurrentDutyView.tsx`
- Combines data loading, error/access states, back button, and close action.
- Admin feature state root: `webapp-next/src/components/admin/useAdminPage.ts`
- Combines SDK button handling, admin access, users/duties loading, sheet state,
mutation and infinite scroll concerns.
## 2) Telegram integration touchpoints
- SDK/provider bootstrap:
- `webapp-next/src/components/providers/TelegramProvider.tsx`
- `webapp-next/src/components/ReadyGate.tsx`
- `webapp-next/src/lib/telegram-ready.ts`
- Direct control usage in feature code:
- `backButton` in `CurrentDutyView` and `useAdminPage`
- `settingsButton` in `CalendarPage`
- `closeMiniApp` in `CurrentDutyView`
- Haptics in feature-level handlers:
- `webapp-next/src/lib/telegram-haptic.ts`
Risk: platform behavior is spread across feature components instead of a narrow
platform boundary.
## 3) Layout and shell duplication
Repeated outer wrappers appear across route and state screens:
- `content-safe min-h-[var(--tg-viewport-stable-height,100vh)] bg-background`
- `mx-auto flex w-full max-w-[var(--max-width-app)] flex-col`
Known locations:
- `webapp-next/src/app/page.tsx`
- `webapp-next/src/app/admin/page.tsx`
- `webapp-next/src/components/CalendarPage.tsx`
- `webapp-next/src/components/states/FullScreenStateShell.tsx`
- `webapp-next/src/app/not-found.tsx`
- `webapp-next/src/app/global-error.tsx`
Risk: future safe-area or viewport fixes require multi-file edits.
## 4) Readiness and lifecycle coupling
`appContentReady` is set by multiple screens/routes:
- `page.tsx`
- `admin/page.tsx`
- `CalendarPage.tsx`
- `CurrentDutyView.tsx`
`ReadyGate` is route-agnostic, but signaling is currently ad hoc.
Risk: race conditions or deadlock-like "hidden app" scenarios when screen states
change in future refactors.
## 5) Async/data-loading duplication
Repeated manual patterns (abort, retries, state machine):
- `webapp-next/src/hooks/use-month-data.ts`
- `webapp-next/src/components/current-duty/CurrentDutyView.tsx`
- `webapp-next/src/components/admin/useAdminPage.ts`
Risk: inconsistent retry/access-denied behavior and difficult maintenance.
## 6) Store mixing concerns
`webapp-next/src/store/app-store.ts` currently mixes:
- session/platform concerns (`lang`, `appContentReady`, `isAdmin`)
- calendar/domain concerns (`currentMonth`, `pendingMonth`, duties/events)
- view concerns (`currentView`, `selectedDay`, `error`, `accessDenied`)
Risk: high coupling and larger blast radius for otherwise local changes.
## 7) i18n/a11y gaps to close
- Hardcoded grid label in `CalendarGrid`: `aria-label="Calendar"`.
- Hardcoded sr-only close text in shared `Sheet`: `"Close"`.
- Mixed language access strategy (`useTranslation()` vs `getLang()/translate()`),
valid for bootstrap/error boundary, but not explicitly codified in one place.
## 8) Telegram Mini Apps compliance checklist (baseline)
Already implemented well:
- Dynamic theme + runtime sync.
- Safe-area/content-safe-area usage via CSS vars and layout classes.
- `ready()` gate and Telegram loader handoff.
- Android low-performance class handling.
Needs explicit policy/consistency:
- Vertical swipes policy for gesture-heavy screens.
- Closing confirmation policy for stateful admin flows.
- Main/Secondary button usage policy for primary actions.
- Terminology alignment with current official docs:
`safeAreaInset`, `contentSafeAreaInset`, fullscreen events.
## 9) Expected behavior (non-regression)
- `/`:
- Shows access denied screen if not allowed.
- Opens current-duty view for `startParam=duty`.
- Otherwise opens calendar.
- `/admin`:
- Denies non-admin users.
- Loads users and duties for selected admin month.
- Allows reassignment with visible feedback.
- Error/fallback states:
- `not-found` and global error remain full-screen and theme-safe.
- Telegram UX:
- Back/settings controls remain functional in Telegram context.
- Ready handoff happens when first useful screen is visible.

View File

@@ -6,64 +6,54 @@
"use client"; "use client";
import { useEffect } from "react";
import { useAdminPage, AdminDutyList, ReassignSheet } from "@/components/admin"; import { useAdminPage, AdminDutyList, ReassignSheet } from "@/components/admin";
import { useTranslation } from "@/i18n/use-translation"; import { useTranslation } from "@/i18n/use-translation";
import { useAppStore } from "@/store/app-store";
import { MonthNavHeader } from "@/components/calendar/MonthNavHeader"; import { MonthNavHeader } from "@/components/calendar/MonthNavHeader";
import { AccessDeniedScreen } from "@/components/states/AccessDeniedScreen"; import { AccessDeniedScreen } from "@/components/states/AccessDeniedScreen";
import { LoadingState } from "@/components/states/LoadingState"; import { LoadingState } from "@/components/states/LoadingState";
import { ErrorState } from "@/components/states/ErrorState"; import { ErrorState } from "@/components/states/ErrorState";
import { MiniAppScreen, MiniAppScreenContent, MiniAppStickyHeader } from "@/components/layout/MiniAppScreen";
const OUTER_CLASS = import { useScreenReady } from "@/hooks/use-screen-ready";
"content-safe min-h-[var(--tg-viewport-stable-height,100vh)] bg-background";
const INNER_CLASS =
"mx-auto flex w-full max-w-[var(--max-width-app)] flex-col";
export default function AdminPage() { export default function AdminPage() {
const { t, monthName } = useTranslation(); const { t, monthName } = useTranslation();
const admin = useAdminPage(); const admin = useAdminPage();
const setAppContentReady = useAppStore((s) => s.setAppContentReady); useScreenReady(true);
// Signal ready so Telegram hides loader when opening /admin directly.
useEffect(() => {
setAppContentReady(true);
}, [setAppContentReady]);
if (!admin.isAllowed) { if (!admin.isAllowed) {
return ( return (
<div className={OUTER_CLASS}> <MiniAppScreen>
<div className={INNER_CLASS}> <MiniAppScreenContent>
<AccessDeniedScreen primaryAction="reload" /> <AccessDeniedScreen primaryAction="reload" />
</div> </MiniAppScreenContent>
</div> </MiniAppScreen>
); );
} }
if (admin.adminCheckComplete === null) { if (admin.adminCheckComplete === null) {
return ( return (
<div className={OUTER_CLASS}> <MiniAppScreen>
<div className={INNER_CLASS}> <MiniAppScreenContent>
<div className="py-4 flex flex-col items-center gap-2"> <div className="py-4 flex flex-col items-center gap-2">
<LoadingState /> <LoadingState />
<p className="text-sm text-muted-foreground">{t("admin.loading_users")}</p> <p className="text-sm text-muted-foreground">{t("admin.loading_users")}</p>
</div> </div>
</div> </MiniAppScreenContent>
</div> </MiniAppScreen>
); );
} }
if (admin.adminAccessDenied) { if (admin.adminAccessDenied) {
return ( return (
<div className={OUTER_CLASS}> <MiniAppScreen>
<div className={INNER_CLASS}> <MiniAppScreenContent>
<div className="flex flex-col gap-4 py-6"> <div className="flex flex-col gap-4 py-6">
<p className="text-muted-foreground"> <p className="text-muted-foreground">
{admin.adminAccessDeniedDetail ?? t("admin.access_denied")} {admin.adminAccessDeniedDetail ?? t("admin.access_denied")}
</p> </p>
</div> </div>
</div> </MiniAppScreenContent>
</div> </MiniAppScreen>
); );
} }
@@ -71,9 +61,9 @@ export default function AdminPage() {
const year = admin.adminMonth.getFullYear(); const year = admin.adminMonth.getFullYear();
return ( return (
<div className={OUTER_CLASS}> <MiniAppScreen>
<div className={INNER_CLASS}> <MiniAppScreenContent>
<header className="sticky top-[var(--app-safe-top)] z-10 flex flex-col items-center border-b border-border bg-background py-3"> <MiniAppStickyHeader className="flex flex-col items-center border-b border-border py-3">
<MonthNavHeader <MonthNavHeader
month={admin.adminMonth} month={admin.adminMonth}
disabled={admin.loading} disabled={admin.loading}
@@ -82,7 +72,7 @@ export default function AdminPage() {
titleAriaLabel={`${t("admin.title")}, ${monthName(month)} ${year}`} titleAriaLabel={`${t("admin.title")}, ${monthName(month)} ${year}`}
className="w-full px-1" className="w-full px-1"
/> />
</header> </MiniAppStickyHeader>
{admin.successMessage && ( {admin.successMessage && (
<p className="mt-3 text-sm text-[var(--duty)]" role="status" aria-live="polite"> <p className="mt-3 text-sm text-[var(--duty)]" role="status" aria-live="polite">
@@ -135,7 +125,7 @@ export default function AdminPage() {
onCloseAnimationEnd={admin.closeReassign} onCloseAnimationEnd={admin.closeReassign}
t={t} t={t}
/> />
</div> </MiniAppScreenContent>
</div> </MiniAppScreen>
); );
} }

View File

@@ -11,6 +11,7 @@ import { getLang, translate } from "@/i18n/messages";
import { callMiniAppReadyOnce } from "@/lib/telegram-ready"; import { callMiniAppReadyOnce } from "@/lib/telegram-ready";
import { THEME_BOOTSTRAP_SCRIPT } from "@/lib/theme-bootstrap-script"; import { THEME_BOOTSTRAP_SCRIPT } from "@/lib/theme-bootstrap-script";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { MiniAppScreen, MiniAppScreenContent } from "@/components/layout/MiniAppScreen";
export default function GlobalError({ export default function GlobalError({
error, error,
@@ -35,7 +36,8 @@ export default function GlobalError({
<script dangerouslySetInnerHTML={{ __html: THEME_BOOTSTRAP_SCRIPT }} /> <script dangerouslySetInnerHTML={{ __html: THEME_BOOTSTRAP_SCRIPT }} />
</head> </head>
<body className="antialiased"> <body className="antialiased">
<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"> <MiniAppScreen>
<MiniAppScreenContent className="items-center justify-center gap-4 px-4 text-foreground">
<h1 className="text-xl font-semibold"> <h1 className="text-xl font-semibold">
{translate(lang, "error_boundary.message")} {translate(lang, "error_boundary.message")}
</h1> </h1>
@@ -45,7 +47,8 @@ export default function GlobalError({
<Button type="button" onClick={() => reset()}> <Button type="button" onClick={() => reset()}>
{translate(lang, "error_boundary.reload")} {translate(lang, "error_boundary.reload")}
</Button> </Button>
</div> </MiniAppScreenContent>
</MiniAppScreen>
</body> </body>
</html> </html>
); );

View File

@@ -5,20 +5,15 @@
"use client"; "use client";
import { useEffect } from "react";
import Link from "next/link"; import Link from "next/link";
import { useTranslation } from "@/i18n/use-translation"; import { useTranslation } from "@/i18n/use-translation";
import { useAppStore } from "@/store/app-store";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { FullScreenStateShell } from "@/components/states/FullScreenStateShell"; import { FullScreenStateShell } from "@/components/states/FullScreenStateShell";
import { useScreenReady } from "@/hooks/use-screen-ready";
export default function NotFound() { export default function NotFound() {
const { t } = useTranslation(); const { t } = useTranslation();
const setAppContentReady = useAppStore((s) => s.setAppContentReady); useScreenReady(true);
useEffect(() => {
setAppContentReady(true);
}, [setAppContentReady]);
return ( return (
<FullScreenStateShell <FullScreenStateShell

View File

@@ -15,6 +15,8 @@ import { getLang } from "@/i18n/messages";
import { AccessDeniedScreen } from "@/components/states/AccessDeniedScreen"; import { AccessDeniedScreen } from "@/components/states/AccessDeniedScreen";
import { CurrentDutyView } from "@/components/current-duty/CurrentDutyView"; import { CurrentDutyView } from "@/components/current-duty/CurrentDutyView";
import { CalendarPage } from "@/components/CalendarPage"; import { CalendarPage } from "@/components/CalendarPage";
import { MiniAppScreen, MiniAppScreenContent } from "@/components/layout/MiniAppScreen";
import { useScreenReady } from "@/hooks/use-screen-ready";
export default function Home() { export default function Home() {
const { initDataRaw, startParam, isLocalhost } = useTelegramAuth(); const { initDataRaw, startParam, isLocalhost } = useTelegramAuth();
@@ -31,7 +33,7 @@ export default function Home() {
fetchAdminMe(initDataRaw, getLang()).then(({ is_admin }) => setIsAdmin(is_admin)); fetchAdminMe(initDataRaw, getLang()).then(({ is_admin }) => setIsAdmin(is_admin));
}, [isAllowed, initDataRaw, setIsAdmin]); }, [isAllowed, initDataRaw, setIsAdmin]);
const { accessDenied, currentView, setCurrentView, setSelectedDay, appContentReady, setAppContentReady } = const { accessDenied, currentView, setCurrentView, setSelectedDay, appContentReady } =
useAppStore( useAppStore(
useShallow((s: AppState) => ({ useShallow((s: AppState) => ({
accessDenied: s.accessDenied, accessDenied: s.accessDenied,
@@ -39,16 +41,10 @@ export default function Home() {
setCurrentView: s.setCurrentView, setCurrentView: s.setCurrentView,
setSelectedDay: s.setSelectedDay, setSelectedDay: s.setSelectedDay,
appContentReady: s.appContentReady, appContentReady: s.appContentReady,
setAppContentReady: s.setAppContentReady,
})) }))
); );
// When showing access-denied or current-duty view, mark content ready so ReadyGate can call miniAppReady(). useScreenReady(accessDenied || currentView === "currentDuty");
useEffect(() => {
if (accessDenied || currentView === "currentDuty") {
setAppContentReady(true);
}
}, [accessDenied, currentView, setAppContentReady]);
const handleBackFromCurrentDuty = useCallback(() => { const handleBackFromCurrentDuty = useCallback(() => {
setCurrentView("calendar"); setCurrentView("calendar");
@@ -56,20 +52,20 @@ export default function Home() {
}, [setCurrentView, setSelectedDay]); }, [setCurrentView, setSelectedDay]);
const content = accessDenied ? ( const content = accessDenied ? (
<div className="content-safe min-h-[var(--tg-viewport-stable-height,100vh)] bg-background"> <MiniAppScreen>
<div className="mx-auto flex w-full max-w-[var(--max-width-app)] flex-col"> <MiniAppScreenContent>
<AccessDeniedScreen primaryAction="reload" /> <AccessDeniedScreen primaryAction="reload" />
</div> </MiniAppScreenContent>
</div> </MiniAppScreen>
) : currentView === "currentDuty" ? ( ) : currentView === "currentDuty" ? (
<div className="content-safe min-h-[var(--tg-viewport-stable-height,100vh)] bg-background"> <MiniAppScreen>
<div className="mx-auto flex w-full max-w-[var(--max-width-app)] flex-col"> <MiniAppScreenContent>
<CurrentDutyView <CurrentDutyView
onBack={handleBackFromCurrentDuty} onBack={handleBackFromCurrentDuty}
openedFromPin={startParam === "duty"} openedFromPin={startParam === "duty"}
/> />
</div> </MiniAppScreenContent>
</div> </MiniAppScreen>
) : ( ) : (
<CalendarPage isAllowed={isAllowed} initDataRaw={initDataRaw} /> <CalendarPage isAllowed={isAllowed} initDataRaw={initDataRaw} />
); );

View File

@@ -7,7 +7,6 @@
import { useRef, useState, useEffect, useCallback } from "react"; import { useRef, useState, useEffect, useCallback } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { settingsButton } from "@telegram-apps/sdk-react";
import { useAppStore } from "@/store/app-store"; import { useAppStore } from "@/store/app-store";
import { useShallow } from "zustand/react/shallow"; import { useShallow } from "zustand/react/shallow";
import { useMonthData } from "@/hooks/use-month-data"; import { useMonthData } from "@/hooks/use-month-data";
@@ -19,6 +18,10 @@ import { CalendarGrid } from "@/components/calendar/CalendarGrid";
import { DutyList } from "@/components/duty/DutyList"; import { DutyList } from "@/components/duty/DutyList";
import { DayDetail, type DayDetailHandle } from "@/components/day-detail"; import { DayDetail, type DayDetailHandle } from "@/components/day-detail";
import { ErrorState } from "@/components/states/ErrorState"; import { ErrorState } from "@/components/states/ErrorState";
import { MiniAppScreen, MiniAppScreenContent, MiniAppStickyHeader } from "@/components/layout/MiniAppScreen";
import { useTelegramSettingsButton, useTelegramVerticalSwipePolicy } from "@/hooks/telegram";
import { DISABLE_VERTICAL_SWIPES_BY_DEFAULT } from "@/lib/telegram-interaction-policy";
import { useScreenReady } from "@/hooks/use-screen-ready";
/** Fallback height (px) until ResizeObserver reports the sticky block size. Matches --calendar-block-min-height + pb-2 (260 + 8). */ /** Fallback height (px) until ResizeObserver reports the sticky block size. Matches --calendar-block-min-height + pb-2 (260 + 8). */
const STICKY_HEIGHT_FALLBACK_PX = 268; const STICKY_HEIGHT_FALLBACK_PX = 268;
@@ -60,7 +63,6 @@ export function CalendarPage({ isAllowed, initDataRaw }: CalendarPageProps) {
prevMonth, prevMonth,
setCurrentMonth, setCurrentMonth,
setSelectedDay, setSelectedDay,
setAppContentReady,
} = useAppStore( } = useAppStore(
useShallow((s) => ({ useShallow((s) => ({
currentMonth: s.currentMonth, currentMonth: s.currentMonth,
@@ -76,7 +78,6 @@ export function CalendarPage({ isAllowed, initDataRaw }: CalendarPageProps) {
prevMonth: s.prevMonth, prevMonth: s.prevMonth,
setCurrentMonth: s.setCurrentMonth, setCurrentMonth: s.setCurrentMonth,
setSelectedDay: s.setSelectedDay, setSelectedDay: s.setSelectedDay,
setAppContentReady: s.setAppContentReady,
})) }))
); );
@@ -110,6 +111,7 @@ export function CalendarPage({ isAllowed, initDataRaw }: CalendarPageProps) {
{ threshold: 50, disabled: navDisabled } { threshold: 50, disabled: navDisabled }
); );
useStickyScroll(calendarStickyRef); useStickyScroll(calendarStickyRef);
useTelegramVerticalSwipePolicy(DISABLE_VERTICAL_SWIPES_BY_DEFAULT);
const handleDayClick = useCallback( const handleDayClick = useCallback(
(dateKey: string, anchorRect: DOMRect) => { (dateKey: string, anchorRect: DOMRect) => {
@@ -129,50 +131,19 @@ export function CalendarPage({ isAllowed, initDataRaw }: CalendarPageProps) {
setSelectedDay(null); setSelectedDay(null);
}, [setSelectedDay]); }, [setSelectedDay]);
const readyCalledRef = useRef(false); useScreenReady(!loading || accessDenied);
// Mark content ready when first load finishes or access denied, so page can call ready() and show content.
useEffect(() => {
if ((!loading || accessDenied) && !readyCalledRef.current) {
readyCalledRef.current = true;
setAppContentReady(true);
}
}, [loading, accessDenied, setAppContentReady]);
// Show native Settings button in Telegram context menu for admins; click navigates to /admin. useTelegramSettingsButton({
useEffect(() => { enabled: isAdmin,
if (!isAdmin) return; onClick: () => router.push("/admin"),
let offClick: (() => void) | undefined; });
try {
if (settingsButton.mount.isAvailable()) {
settingsButton.mount();
}
if (settingsButton.show.isAvailable()) {
settingsButton.show();
}
if (settingsButton.onClick.isAvailable()) {
offClick = settingsButton.onClick(() => router.push("/admin"));
}
} catch {
// Non-Telegram environment; SettingsButton not available.
}
return () => {
try {
if (typeof offClick === "function") offClick();
if (settingsButton.hide.isAvailable()) {
settingsButton.hide();
}
} catch {
// Ignore cleanup errors in non-Telegram environment.
}
};
}, [isAdmin, router]);
return ( return (
<div className="content-safe min-h-[var(--tg-viewport-stable-height,100vh)] bg-background"> <MiniAppScreen>
<div className="mx-auto flex w-full max-w-[var(--max-width-app)] flex-col"> <MiniAppScreenContent>
<div <MiniAppStickyHeader
ref={calendarStickyRef} ref={calendarStickyRef}
className="sticky top-[var(--app-safe-top)] z-10 min-h-[var(--calendar-block-min-height)] bg-background pb-2 touch-pan-y" className="min-h-[var(--calendar-block-min-height)] pb-2 touch-pan-y"
> >
<CalendarHeader <CalendarHeader
month={currentMonth} month={currentMonth}
@@ -186,7 +157,7 @@ export function CalendarPage({ isAllowed, initDataRaw }: CalendarPageProps) {
calendarEvents={calendarEvents} calendarEvents={calendarEvents}
onDayClick={handleDayClick} onDayClick={handleDayClick}
/> />
</div> </MiniAppStickyHeader>
{error && ( {error && (
<ErrorState message={error} onRetry={retry} className="my-3" /> <ErrorState message={error} onRetry={retry} className="my-3" />
@@ -204,7 +175,7 @@ export function CalendarPage({ isAllowed, initDataRaw }: CalendarPageProps) {
calendarEvents={calendarEvents} calendarEvents={calendarEvents}
onClose={handleCloseDayDetail} onClose={handleCloseDayDetail}
/> />
</div> </MiniAppScreenContent>
</div> </MiniAppScreen>
); );
} }

View File

@@ -73,6 +73,7 @@ export function ReassignSheet({
className="flex max-h-[85vh] flex-col rounded-t-2xl bg-[var(--surface)] p-0 pt-3" className="flex max-h-[85vh] flex-col rounded-t-2xl bg-[var(--surface)] p-0 pt-3"
overlayClassName="backdrop-blur-md" overlayClassName="backdrop-blur-md"
showCloseButton={false} showCloseButton={false}
closeLabel={t("day_detail.close")}
onCloseAnimationEnd={onCloseAnimationEnd} onCloseAnimationEnd={onCloseAnimationEnd}
onOpenAutoFocus={(e) => e.preventDefault()} onOpenAutoFocus={(e) => e.preventDefault()}
> >
@@ -176,7 +177,7 @@ export function ReassignSheet({
</div> </div>
)} )}
</div> </div>
<SheetFooter className="flex-shrink-0 flex-row justify-end gap-2 border-t border-border bg-[var(--surface)] px-4 py-3 pb-[calc(12px+env(safe-area-inset-bottom,0px))]"> <SheetFooter className="flex-shrink-0 flex-row justify-end gap-2 border-t border-border bg-[var(--surface)] px-4 py-3 pb-[calc(12px+var(--app-safe-bottom,env(safe-area-inset-bottom,0px)))]">
<Button <Button
onClick={onReassign} onClick={onReassign}
disabled={ disabled={

View File

@@ -0,0 +1,5 @@
export * from "./use-admin-access";
export * from "./use-admin-users";
export * from "./use-admin-duties";
export * from "./use-infinite-duty-groups";
export * from "./use-admin-reassign";

View File

@@ -0,0 +1,44 @@
"use client";
import { useEffect, useState } from "react";
import { fetchAdminMe } from "@/lib/api";
export interface UseAdminAccessOptions {
isAllowed: boolean;
initDataRaw: string | undefined;
lang: "ru" | "en";
}
export function useAdminAccess({ isAllowed, initDataRaw, lang }: UseAdminAccessOptions) {
const [adminCheckComplete, setAdminCheckComplete] = useState<boolean | null>(null);
const [adminAccessDenied, setAdminAccessDenied] = useState(false);
const [adminAccessDeniedDetail, setAdminAccessDeniedDetail] = useState<string | null>(null);
useEffect(() => {
if (!isAllowed || !initDataRaw) return;
setAdminCheckComplete(null);
setAdminAccessDenied(false);
setAdminAccessDeniedDetail(null);
fetchAdminMe(initDataRaw, lang)
.then(({ is_admin }) => {
if (!is_admin) {
setAdminAccessDenied(true);
setAdminCheckComplete(false);
return;
}
setAdminCheckComplete(true);
})
.catch(() => {
setAdminAccessDenied(true);
setAdminCheckComplete(false);
});
}, [isAllowed, initDataRaw, lang]);
return {
adminCheckComplete,
adminAccessDenied,
adminAccessDeniedDetail,
setAdminAccessDenied,
setAdminAccessDeniedDetail,
};
}

View File

@@ -0,0 +1,49 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { fetchDuties } from "@/lib/api";
import type { DutyWithUser } from "@/types";
import { firstDayOfMonth, lastDayOfMonth, localDateString } from "@/lib/date-utils";
export interface UseAdminDutiesOptions {
isAllowed: boolean;
initDataRaw: string | undefined;
lang: "ru" | "en";
adminCheckComplete: boolean | null;
adminMonth: Date;
onError: (message: string) => void;
clearError: () => void;
}
export function useAdminDuties({
isAllowed,
initDataRaw,
lang,
adminCheckComplete,
adminMonth,
onError,
clearError,
}: UseAdminDutiesOptions) {
const [duties, setDuties] = useState<DutyWithUser[]>([]);
const [loadingDuties, setLoadingDuties] = useState(true);
const from = useMemo(() => localDateString(firstDayOfMonth(adminMonth)), [adminMonth]);
const to = useMemo(() => localDateString(lastDayOfMonth(adminMonth)), [adminMonth]);
useEffect(() => {
if (!isAllowed || !initDataRaw || adminCheckComplete !== true) return;
const controller = new AbortController();
setLoadingDuties(true);
clearError();
fetchDuties(from, to, initDataRaw, lang, controller.signal)
.then((list) => setDuties(list))
.catch((e) => {
if ((e as Error)?.name === "AbortError") return;
onError(e instanceof Error ? e.message : String(e));
})
.finally(() => setLoadingDuties(false));
return () => controller.abort();
}, [isAllowed, initDataRaw, lang, from, to, adminCheckComplete, onError, clearError]);
return { duties, setDuties, loadingDuties, from, to };
}

View File

@@ -0,0 +1,116 @@
"use client";
import { useCallback, useEffect, useState, type Dispatch, type SetStateAction } from "react";
import { AccessDeniedError, patchAdminDuty, type UserForAdmin } from "@/lib/api";
import { triggerHapticLight } from "@/lib/telegram-haptic";
import type { DutyWithUser } from "@/types";
export interface UseAdminReassignOptions {
initDataRaw: string | undefined;
lang: "ru" | "en";
users: UserForAdmin[];
setDuties: Dispatch<SetStateAction<DutyWithUser[]>>;
t: (key: string, params?: Record<string, string>) => string;
}
export function useAdminReassign({
initDataRaw,
lang,
users,
setDuties,
t,
}: UseAdminReassignOptions) {
const [selectedDuty, setSelectedDuty] = useState<DutyWithUser | null>(null);
const [selectedUserId, setSelectedUserId] = useState<number | "">("");
const [saving, setSaving] = useState(false);
const [reassignErrorKey, setReassignErrorKey] = useState<string | null>(null);
const [successMessage, setSuccessMessage] = useState<string | null>(null);
const [sheetExiting, setSheetExiting] = useState(false);
const closeReassign = useCallback(() => {
setSelectedDuty(null);
setSelectedUserId("");
setReassignErrorKey(null);
setSheetExiting(false);
}, []);
useEffect(() => {
if (!sheetExiting) return;
const fallback = window.setTimeout(() => {
closeReassign();
}, 320);
return () => window.clearTimeout(fallback);
}, [sheetExiting, closeReassign]);
const openReassign = useCallback((duty: DutyWithUser) => {
setSelectedDuty(duty);
setSelectedUserId(duty.user_id);
setReassignErrorKey(null);
}, []);
const requestCloseSheet = useCallback(() => {
setSheetExiting(true);
}, []);
const handleReassign = useCallback(() => {
if (!selectedDuty || selectedUserId === "" || !initDataRaw) return;
if (selectedUserId === selectedDuty.user_id) {
closeReassign();
return;
}
setSaving(true);
setReassignErrorKey(null);
patchAdminDuty(selectedDuty.id, selectedUserId, initDataRaw, lang)
.then((updated) => {
setDuties((prev) =>
prev.map((d) =>
d.id === updated.id
? {
...d,
user_id: updated.user_id,
full_name:
users.find((u) => u.id === updated.user_id)?.full_name ?? d.full_name,
}
: d
)
);
setSuccessMessage(t("admin.reassign_success"));
try {
triggerHapticLight();
} catch {
// Haptic not available (e.g. non-Telegram).
}
requestCloseSheet();
setTimeout(() => setSuccessMessage(null), 3000);
})
.catch((e) => {
if (e instanceof AccessDeniedError) {
setReassignErrorKey("admin.reassign_error_denied");
} else if (e instanceof Error && /not found|не найден/i.test(e.message)) {
setReassignErrorKey("admin.reassign_error_not_found");
} else if (
e instanceof TypeError ||
(e instanceof Error && (e.message === "Failed to fetch" || e.message === "Load failed"))
) {
setReassignErrorKey("admin.reassign_error_network");
} else {
setReassignErrorKey("admin.reassign_error_generic");
}
})
.finally(() => setSaving(false));
}, [selectedDuty, selectedUserId, initDataRaw, lang, users, closeReassign, requestCloseSheet, t, setDuties]);
return {
selectedDuty,
selectedUserId,
setSelectedUserId,
saving,
reassignErrorKey,
successMessage,
sheetExiting,
openReassign,
requestCloseSheet,
handleReassign,
closeReassign,
};
}

View File

@@ -0,0 +1,45 @@
"use client";
import { useEffect, useState } from "react";
import { AccessDeniedError, fetchAdminUsers, type UserForAdmin } from "@/lib/api";
export interface UseAdminUsersOptions {
isAllowed: boolean;
initDataRaw: string | undefined;
lang: "ru" | "en";
adminCheckComplete: boolean | null;
onAccessDenied: (detail: string | null) => void;
onError: (message: string) => void;
}
export function useAdminUsers({
isAllowed,
initDataRaw,
lang,
adminCheckComplete,
onAccessDenied,
onError,
}: UseAdminUsersOptions) {
const [users, setUsers] = useState<UserForAdmin[]>([]);
const [loadingUsers, setLoadingUsers] = useState(true);
useEffect(() => {
if (!isAllowed || !initDataRaw || adminCheckComplete !== true) return;
const controller = new AbortController();
setLoadingUsers(true);
fetchAdminUsers(initDataRaw, lang, controller.signal)
.then((list) => setUsers(list))
.catch((e) => {
if ((e as Error)?.name === "AbortError") return;
if (e instanceof AccessDeniedError) {
onAccessDenied(e.serverDetail ?? null);
return;
}
onError(e instanceof Error ? e.message : String(e));
})
.finally(() => setLoadingUsers(false));
return () => controller.abort();
}, [isAllowed, initDataRaw, lang, adminCheckComplete, onAccessDenied, onError]);
return { users, setUsers, loadingUsers };
}

View File

@@ -0,0 +1,56 @@
"use client";
import { useEffect, useMemo, useRef, useState } from "react";
import type { DutyWithUser } from "@/types";
import { localDateString } from "@/lib/date-utils";
export const ADMIN_PAGE_SIZE = 20;
export function useInfiniteDutyGroups(duties: DutyWithUser[], from: string, to: string) {
const [visibleCount, setVisibleCount] = useState(ADMIN_PAGE_SIZE);
const sentinelRef = useRef<HTMLDivElement | null>(null);
const dutyOnly = useMemo(() => {
const now = new Date();
return duties
.filter((d) => d.event_type === "duty" && new Date(d.end_at) > now)
.sort(
(a, b) =>
new Date(a.start_at).getTime() - new Date(b.start_at).getTime()
);
}, [duties]);
useEffect(() => {
setVisibleCount(ADMIN_PAGE_SIZE);
}, [from, to]);
const visibleDuties = useMemo(() => dutyOnly.slice(0, visibleCount), [dutyOnly, visibleCount]);
const hasMore = visibleCount < dutyOnly.length;
const visibleGroups = useMemo(() => {
const map = new Map<string, DutyWithUser[]>();
for (const d of visibleDuties) {
const key = localDateString(new Date(d.start_at));
if (!map.has(key)) map.set(key, []);
map.get(key)!.push(d);
}
return Array.from(map.entries()).map(([dateKey, items]) => ({ dateKey, duties: items }));
}, [visibleDuties]);
useEffect(() => {
if (!hasMore || !sentinelRef.current) return;
const el = sentinelRef.current;
const observer = new IntersectionObserver(
(entries) => {
if (entries[0]?.isIntersecting) {
setVisibleCount((prev) => Math.min(prev + ADMIN_PAGE_SIZE, dutyOnly.length));
}
},
{ root: null, rootMargin: "200px", threshold: 0 }
);
observer.observe(el);
return () => observer.disconnect();
}, [hasMore, dutyOnly.length]);
return { dutyOnly, visibleDuties, visibleGroups, hasMore, sentinelRef };
}

View File

@@ -1,35 +1,27 @@
/** /**
* Admin page hook: BackButton, admin check, users/duties loading, reassign sheet state, * Admin page composition hook.
* infinite scroll, and derived data. Used by the admin page for composition. * Delegates access/users/duties/reassign/infinite-list concerns to focused hooks.
*/ */
"use client"; "use client";
import { useEffect, useState, useCallback, useRef } from "react"; import { useState, useCallback, useRef, useMemo } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { backButton } from "@telegram-apps/sdk-react";
import { useTelegramSdkReady } from "@/components/providers/TelegramProvider"; import { useTelegramSdkReady } from "@/components/providers/TelegramProvider";
import { useAppStore } from "@/store/app-store"; import { useAppStore } from "@/store/app-store";
import { useShallow } from "zustand/react/shallow"; import { useShallow } from "zustand/react/shallow";
import { useTelegramAuth } from "@/hooks/use-telegram-auth"; import { useTelegramAuth } from "@/hooks/use-telegram-auth";
import { useTranslation } from "@/i18n/use-translation"; import { useTranslation } from "@/i18n/use-translation";
import { useTelegramBackButton, useTelegramClosingConfirmation } from "@/hooks/telegram";
import { ENABLE_CLOSING_CONFIRMATION_FOR_STATEFUL_ADMIN_FLOW } from "@/lib/telegram-interaction-policy";
import { import {
fetchDuties, useAdminAccess,
fetchAdminMe, useAdminUsers,
fetchAdminUsers, useAdminDuties,
patchAdminDuty, useInfiniteDutyGroups,
AccessDeniedError, useAdminReassign,
type UserForAdmin, } from "@/components/admin/hooks";
} from "@/lib/api"; import { useRequestState } from "@/hooks/use-request-state";
import { triggerHapticLight } from "@/lib/telegram-haptic";
import type { DutyWithUser } from "@/types";
import {
firstDayOfMonth,
lastDayOfMonth,
localDateString,
} from "@/lib/date-utils";
const PAGE_SIZE = 20;
export function useAdminPage() { export function useAdminPage() {
const router = useRouter(); const router = useRouter();
@@ -43,28 +35,17 @@ export function useAdminPage() {
/** Local month for admin view; does not change global calendar month. */ /** Local month for admin view; does not change global calendar month. */
const [adminMonth, setAdminMonth] = useState<Date>(() => new Date(currentMonth.getFullYear(), currentMonth.getMonth(), 1)); const [adminMonth, setAdminMonth] = useState<Date>(() => new Date(currentMonth.getFullYear(), currentMonth.getMonth(), 1));
const [users, setUsers] = useState<UserForAdmin[]>([]);
const [duties, setDuties] = useState<DutyWithUser[]>([]);
const [loadingUsers, setLoadingUsers] = useState(true);
const [loadingDuties, setLoadingDuties] = useState(true);
const [adminCheckComplete, setAdminCheckComplete] = useState<boolean | null>(null);
const [adminAccessDenied, setAdminAccessDenied] = useState(false);
const [adminAccessDeniedDetail, setAdminAccessDeniedDetail] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [selectedDuty, setSelectedDuty] = useState<DutyWithUser | null>(null);
const [selectedUserId, setSelectedUserId] = useState<number | "">("");
const [saving, setSaving] = useState(false);
const [reassignErrorKey, setReassignErrorKey] = useState<string | null>(null);
const [successMessage, setSuccessMessage] = useState<string | null>(null);
const [visibleCount, setVisibleCount] = useState(PAGE_SIZE);
const [sheetExiting, setSheetExiting] = useState(false);
const sentinelRef = useRef<HTMLDivElement | null>(null);
const navigateHomeRef = useRef(() => router.push("/")); const navigateHomeRef = useRef(() => router.push("/"));
navigateHomeRef.current = () => router.push("/"); navigateHomeRef.current = () => router.push("/");
const request = useRequestState("idle");
const from = localDateString(firstDayOfMonth(adminMonth)); const access = useAdminAccess({ isAllowed, initDataRaw, lang });
const to = localDateString(lastDayOfMonth(adminMonth)); const handleAdminAccessDenied = useCallback(
(detail: string | null) => {
access.setAdminAccessDenied(true);
access.setAdminAccessDeniedDetail(detail);
},
[access.setAdminAccessDenied, access.setAdminAccessDeniedDetail]
);
const onPrevMonth = useCallback(() => { const onPrevMonth = useCallback(() => {
setAdminMonth((prev) => new Date(prev.getFullYear(), prev.getMonth() - 1, 1)); setAdminMonth((prev) => new Date(prev.getFullYear(), prev.getMonth() - 1, 1));
@@ -73,231 +54,76 @@ export function useAdminPage() {
setAdminMonth((prev) => new Date(prev.getFullYear(), prev.getMonth() + 1, 1)); setAdminMonth((prev) => new Date(prev.getFullYear(), prev.getMonth() + 1, 1));
}, []); }, []);
useEffect(() => { useTelegramBackButton({
if (!sdkReady || isLocalhost) return; enabled: sdkReady && !isLocalhost,
let offClick: (() => void) | undefined; onClick: () => navigateHomeRef.current(),
try {
if (backButton.mount.isAvailable()) {
backButton.mount();
}
if (backButton.show.isAvailable()) {
backButton.show();
}
if (backButton.onClick.isAvailable()) {
offClick = backButton.onClick(() => navigateHomeRef.current());
}
} catch {
// Non-Telegram environment; BackButton not available.
}
return () => {
try {
if (typeof offClick === "function") offClick();
if (backButton.hide.isAvailable()) {
backButton.hide();
}
} catch {
// Ignore cleanup errors in non-Telegram environment.
}
};
}, [sdkReady, isLocalhost, router]);
useEffect(() => {
if (!isAllowed || !initDataRaw) return;
setAdminCheckComplete(null);
setAdminAccessDenied(false);
fetchAdminMe(initDataRaw, lang)
.then(({ is_admin }) => {
if (!is_admin) {
setAdminAccessDenied(true);
setAdminAccessDeniedDetail(null);
setAdminCheckComplete(false);
} else {
setAdminCheckComplete(true);
}
})
.catch(() => {
setAdminAccessDenied(true);
setAdminAccessDeniedDetail(null);
setAdminCheckComplete(false);
}); });
}, [isAllowed, initDataRaw, lang]);
useEffect(() => { const usersRequest = useAdminUsers({
if (!isAllowed || !initDataRaw || adminCheckComplete !== true) return; isAllowed,
const controller = new AbortController(); initDataRaw,
setLoadingUsers(true); lang,
fetchAdminUsers(initDataRaw, lang, controller.signal) adminCheckComplete: access.adminCheckComplete,
.then((list) => setUsers(list)) onAccessDenied: handleAdminAccessDenied,
.catch((e) => { onError: request.setError,
if ((e as Error)?.name === "AbortError") return; });
if (e instanceof AccessDeniedError) {
setAdminAccessDenied(true);
setAdminAccessDeniedDetail(e.serverDetail ?? null);
} else {
setError(e instanceof Error ? e.message : String(e));
}
})
.finally(() => setLoadingUsers(false));
return () => controller.abort();
}, [isAllowed, initDataRaw, lang, adminCheckComplete]);
useEffect(() => { const dutiesRequest = useAdminDuties({
if (!isAllowed || !initDataRaw || adminCheckComplete !== true) return; isAllowed,
const controller = new AbortController(); initDataRaw,
setLoadingDuties(true); lang,
setError(null); adminCheckComplete: access.adminCheckComplete,
fetchDuties(from, to, initDataRaw, lang, controller.signal) adminMonth,
.then((list) => setDuties(list)) onError: request.setError,
.catch((e) => { clearError: request.reset,
if ((e as Error)?.name === "AbortError") return; });
setError(e instanceof Error ? e.message : String(e));
})
.finally(() => setLoadingDuties(false));
return () => controller.abort();
}, [isAllowed, initDataRaw, lang, from, to, adminCheckComplete]);
useEffect(() => { const reassign = useAdminReassign({
setVisibleCount(PAGE_SIZE); initDataRaw,
}, [from, to]); lang,
users: usersRequest.users,
setDuties: dutiesRequest.setDuties,
t,
});
const closeReassign = useCallback(() => { useTelegramClosingConfirmation(
setSelectedDuty(null); ENABLE_CLOSING_CONFIRMATION_FOR_STATEFUL_ADMIN_FLOW && reassign.selectedDuty !== null
setSelectedUserId("");
setReassignErrorKey(null);
setSheetExiting(false);
}, []);
useEffect(() => {
if (!sheetExiting) return;
const fallback = window.setTimeout(() => {
closeReassign();
}, 320);
return () => window.clearTimeout(fallback);
}, [sheetExiting, closeReassign]);
const openReassign = useCallback((duty: DutyWithUser) => {
setSelectedDuty(duty);
setSelectedUserId(duty.user_id);
setReassignErrorKey(null);
}, []);
const requestCloseSheet = useCallback(() => {
setSheetExiting(true);
}, []);
const handleReassign = useCallback(() => {
if (!selectedDuty || selectedUserId === "" || !initDataRaw) return;
if (selectedUserId === selectedDuty.user_id) {
closeReassign();
return;
}
setSaving(true);
setReassignErrorKey(null);
patchAdminDuty(selectedDuty.id, selectedUserId, initDataRaw, lang)
.then((updated) => {
setDuties((prev) =>
prev.map((d) =>
d.id === updated.id
? {
...d,
user_id: updated.user_id,
full_name:
users.find((u) => u.id === updated.user_id)?.full_name ?? d.full_name,
}
: d
)
);
setSuccessMessage(t("admin.reassign_success"));
try {
triggerHapticLight();
} catch {
// Haptic not available (e.g. non-Telegram).
}
requestCloseSheet();
setTimeout(() => setSuccessMessage(null), 3000);
})
.catch((e) => {
if (e instanceof AccessDeniedError) {
setReassignErrorKey("admin.reassign_error_denied");
} else if (e instanceof Error && /not found|не найден/i.test(e.message)) {
setReassignErrorKey("admin.reassign_error_not_found");
} else if (
e instanceof TypeError ||
(e instanceof Error && (e.message === "Failed to fetch" || e.message === "Load failed"))
) {
setReassignErrorKey("admin.reassign_error_network");
} else {
setReassignErrorKey("admin.reassign_error_generic");
}
})
.finally(() => setSaving(false));
}, [selectedDuty, selectedUserId, initDataRaw, lang, users, closeReassign, requestCloseSheet, t]);
const now = new Date();
const dutyOnly = duties
.filter((d) => d.event_type === "duty" && new Date(d.end_at) > now)
.sort(
(a, b) =>
new Date(a.start_at).getTime() - new Date(b.start_at).getTime()
); );
const usersForSelect = users.filter((u) => u.role_id === 1 || u.role_id === 2); const list = useInfiniteDutyGroups(dutiesRequest.duties, dutiesRequest.from, dutiesRequest.to);
const visibleDuties = dutyOnly.slice(0, visibleCount); const usersForSelect = useMemo(
const hasMore = visibleCount < dutyOnly.length; () => usersRequest.users.filter((u) => u.role_id === 1 || u.role_id === 2),
const loading = loadingUsers || loadingDuties; [usersRequest.users]
/** Group visible duties by date (dateKey) for sectioned list. Order preserved by insertion. */
const visibleGroups = (() => {
const map = new Map<string, DutyWithUser[]>();
for (const d of visibleDuties) {
const key = localDateString(new Date(d.start_at));
if (!map.has(key)) map.set(key, []);
map.get(key)!.push(d);
}
return Array.from(map.entries()).map(([dateKey, duties]) => ({ dateKey, duties }));
})();
useEffect(() => {
if (!hasMore || !sentinelRef.current) return;
const el = sentinelRef.current;
const observer = new IntersectionObserver(
(entries) => {
if (entries[0]?.isIntersecting) {
setVisibleCount((prev) => Math.min(prev + PAGE_SIZE, dutyOnly.length));
}
},
{ root: null, rootMargin: "200px", threshold: 0 }
); );
observer.observe(el); const error = request.state.error;
return () => observer.disconnect(); const loading = usersRequest.loadingUsers || dutiesRequest.loadingDuties;
}, [hasMore, dutyOnly.length]);
return { return {
isAllowed, isAllowed,
adminCheckComplete, adminCheckComplete: access.adminCheckComplete,
adminAccessDenied, adminAccessDenied: access.adminAccessDenied,
adminAccessDeniedDetail, adminAccessDeniedDetail: access.adminAccessDeniedDetail,
error, error,
loading, loading,
adminMonth, adminMonth,
onPrevMonth, onPrevMonth,
onNextMonth, onNextMonth,
successMessage, successMessage: reassign.successMessage,
dutyOnly, dutyOnly: list.dutyOnly,
usersForSelect, usersForSelect,
visibleDuties, visibleDuties: list.visibleDuties,
visibleGroups, visibleGroups: list.visibleGroups,
hasMore, hasMore: list.hasMore,
sentinelRef, sentinelRef: list.sentinelRef,
selectedDuty, selectedDuty: reassign.selectedDuty,
selectedUserId, selectedUserId: reassign.selectedUserId,
setSelectedUserId, setSelectedUserId: reassign.setSelectedUserId,
saving, saving: reassign.saving,
reassignErrorKey, reassignErrorKey: reassign.reassignErrorKey,
sheetExiting, sheetExiting: reassign.sheetExiting,
openReassign, openReassign: reassign.openReassign,
requestCloseSheet, requestCloseSheet: reassign.requestCloseSheet,
handleReassign, handleReassign: reassign.handleReassign,
closeReassign, closeReassign: reassign.closeReassign,
}; };
} }

View File

@@ -15,6 +15,7 @@ import type { CalendarEvent, DutyWithUser } from "@/types";
import { dutiesByDate, calendarEventsByDate } from "@/lib/calendar-data"; import { dutiesByDate, calendarEventsByDate } from "@/lib/calendar-data";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { CalendarDay } from "./CalendarDay"; import { CalendarDay } from "./CalendarDay";
import { useTranslation } from "@/i18n/use-translation";
export interface CalendarGridProps { export interface CalendarGridProps {
/** Currently displayed month. */ /** Currently displayed month. */
@@ -37,6 +38,7 @@ export function CalendarGrid({
onDayClick, onDayClick,
className, className,
}: CalendarGridProps) { }: CalendarGridProps) {
const { t } = useTranslation();
const dutiesByDateMap = useMemo( const dutiesByDateMap = useMemo(
() => dutiesByDate(duties), () => dutiesByDate(duties),
[duties] [duties]
@@ -67,7 +69,7 @@ export function CalendarGrid({
className className
)} )}
role="grid" role="grid"
aria-label="Calendar" aria-label={t("aria.calendar")}
> >
{cells.map(({ date, key, month }, i) => { {cells.map(({ date, key, month }, i) => {
const isOtherMonth = month !== currentMonth.getMonth(); const isOtherMonth = month !== currentMonth.getMonth();

View File

@@ -8,7 +8,6 @@
"use client"; "use client";
import { useEffect, useState, useCallback } from "react"; import { useEffect, useState, useCallback } from "react";
import { backButton, closeMiniApp } from "@telegram-apps/sdk-react";
import { Calendar } from "lucide-react"; import { Calendar } from "lucide-react";
import { useTranslation } from "@/i18n/use-translation"; import { useTranslation } from "@/i18n/use-translation";
import { useAppStore } from "@/store/app-store"; import { useAppStore } from "@/store/app-store";
@@ -33,6 +32,9 @@ import {
import { Skeleton } from "@/components/ui/skeleton"; import { Skeleton } from "@/components/ui/skeleton";
import { AccessDeniedScreen } from "@/components/states/AccessDeniedScreen"; import { AccessDeniedScreen } from "@/components/states/AccessDeniedScreen";
import type { DutyWithUser } from "@/types"; import type { DutyWithUser } from "@/types";
import { useTelegramBackButton, useTelegramCloseAction } from "@/hooks/telegram";
import { useScreenReady } from "@/hooks/use-screen-ready";
import { useRequestState } from "@/hooks/use-request-state";
export interface CurrentDutyViewProps { export interface CurrentDutyViewProps {
/** Called when user taps Back (in-app button or Telegram BackButton). */ /** Called when user taps Back (in-app button or Telegram BackButton). */
@@ -41,25 +43,30 @@ export interface CurrentDutyViewProps {
openedFromPin?: boolean; openedFromPin?: boolean;
} }
type ViewState = "loading" | "error" | "accessDenied" | "ready";
/** /**
* Full-screen current duty view with Telegram BackButton and auto-updating remaining time. * Full-screen current duty view with Telegram BackButton and auto-updating remaining time.
*/ */
export function CurrentDutyView({ onBack, openedFromPin = false }: CurrentDutyViewProps) { export function CurrentDutyView({ onBack, openedFromPin = false }: CurrentDutyViewProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const lang = useAppStore((s) => s.lang); const lang = useAppStore((s) => s.lang);
const setAppContentReady = useAppStore((s) => s.setAppContentReady);
const { initDataRaw } = useTelegramAuth(); const { initDataRaw } = useTelegramAuth();
const [state, setState] = useState<ViewState>("loading");
const [duty, setDuty] = useState<DutyWithUser | null>(null); const [duty, setDuty] = useState<DutyWithUser | null>(null);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [accessDeniedDetail, setAccessDeniedDetail] = useState<string | null>(null);
const [remaining, setRemaining] = useState<{ hours: number; minutes: number } | null>(null); const [remaining, setRemaining] = useState<{ hours: number; minutes: number } | null>(null);
const {
state: requestState,
setLoading,
setSuccess,
setError,
setAccessDenied,
isLoading,
isError,
isAccessDenied,
} = useRequestState("loading");
const loadTodayDuties = useCallback( const loadTodayDuties = useCallback(
async (signal?: AbortSignal | null) => { async (signal?: AbortSignal | null) => {
setLoading();
const today = new Date(); const today = new Date();
const from = localDateString(today); const from = localDateString(today);
const to = from; const to = from;
@@ -69,7 +76,7 @@ export function CurrentDutyView({ onBack, openedFromPin = false }: CurrentDutyVi
if (signal?.aborted) return; if (signal?.aborted) return;
const active = findCurrentDuty(duties); const active = findCurrentDuty(duties);
setDuty(active); setDuty(active);
setState("ready"); setSuccess();
if (active) { if (active) {
setRemaining(getRemainingTime(active.end_at)); setRemaining(getRemainingTime(active.end_at));
} else { } else {
@@ -78,19 +85,17 @@ export function CurrentDutyView({ onBack, openedFromPin = false }: CurrentDutyVi
} catch (e) { } catch (e) {
if (signal?.aborted) return; if (signal?.aborted) return;
if (e instanceof AccessDeniedError) { if (e instanceof AccessDeniedError) {
setState("accessDenied"); setAccessDenied(e.serverDetail ?? null);
setAccessDeniedDetail(e.serverDetail ?? null);
setDuty(null); setDuty(null);
setRemaining(null); setRemaining(null);
} else { } else {
setState("error"); setError(t("error_generic"));
setErrorMessage(t("error_generic"));
setDuty(null); setDuty(null);
setRemaining(null); setRemaining(null);
} }
} }
}, },
[initDataRaw, lang, t] [initDataRaw, lang, t, setLoading, setSuccess, setAccessDenied, setError]
); );
// Fetch today's duties on mount; abort on unmount to avoid setState after unmount. // Fetch today's duties on mount; abort on unmount to avoid setState after unmount.
@@ -100,12 +105,7 @@ export function CurrentDutyView({ onBack, openedFromPin = false }: CurrentDutyVi
return () => controller.abort(); return () => controller.abort();
}, [loadTodayDuties]); }, [loadTodayDuties]);
// Mark content ready when data is loaded or error, so page can call ready() and show content. useScreenReady(!isLoading);
useEffect(() => {
if (state !== "loading") {
setAppContentReady(true);
}
}, [state, setAppContentReady]);
// Auto-update remaining time every second when there is an active duty. // Auto-update remaining time every second when there is an active duty.
useEffect(() => { useEffect(() => {
@@ -116,47 +116,21 @@ export function CurrentDutyView({ onBack, openedFromPin = false }: CurrentDutyVi
return () => clearInterval(interval); return () => clearInterval(interval);
}, [duty]); }, [duty]);
// Telegram BackButton: show on mount, hide on unmount, handle click. useTelegramBackButton({
useEffect(() => { enabled: true,
let offClick: (() => void) | undefined; onClick: onBack,
try { });
if (backButton.mount.isAvailable()) {
backButton.mount();
}
if (backButton.show.isAvailable()) {
backButton.show();
}
if (backButton.onClick.isAvailable()) {
offClick = backButton.onClick(onBack);
}
} catch {
// Non-Telegram environment; BackButton not available.
}
return () => {
try {
if (typeof offClick === "function") offClick();
if (backButton.hide.isAvailable()) {
backButton.hide();
}
} catch {
// Ignore cleanup errors in non-Telegram environment.
}
};
}, [onBack]);
const handleBack = () => { const handleBack = () => {
triggerHapticLight(); triggerHapticLight();
onBack(); onBack();
}; };
const closeMiniAppOrFallback = useTelegramCloseAction(onBack);
const handleClose = () => { const handleClose = () => {
triggerHapticLight(); triggerHapticLight();
if (closeMiniApp.isAvailable()) { closeMiniAppOrFallback();
closeMiniApp();
} else {
onBack();
}
}; };
const primaryButtonLabel = openedFromPin ? t("current_duty.close") : t("current_duty.back"); const primaryButtonLabel = openedFromPin ? t("current_duty.close") : t("current_duty.back");
@@ -165,7 +139,7 @@ export function CurrentDutyView({ onBack, openedFromPin = false }: CurrentDutyVi
: t("current_duty.back"); : t("current_duty.back");
const handlePrimaryAction = openedFromPin ? handleClose : handleBack; const handlePrimaryAction = openedFromPin ? handleClose : handleBack;
if (state === "loading") { if (isLoading) {
return ( return (
<div <div
className="flex min-h-[50vh] flex-col items-center justify-center gap-4" className="flex min-h-[50vh] flex-col items-center justify-center gap-4"
@@ -203,10 +177,10 @@ export function CurrentDutyView({ onBack, openedFromPin = false }: CurrentDutyVi
); );
} }
if (state === "accessDenied") { if (isAccessDenied) {
return ( return (
<AccessDeniedScreen <AccessDeniedScreen
serverDetail={accessDeniedDetail} serverDetail={requestState.accessDeniedDetail}
primaryAction="back" primaryAction="back"
onBack={handlePrimaryAction} onBack={handlePrimaryAction}
openedFromPin={openedFromPin} openedFromPin={openedFromPin}
@@ -214,17 +188,16 @@ export function CurrentDutyView({ onBack, openedFromPin = false }: CurrentDutyVi
); );
} }
if (state === "error") { if (isError) {
const handleRetry = () => { const handleRetry = () => {
triggerHapticLight(); triggerHapticLight();
setState("loading");
loadTodayDuties(); loadTodayDuties();
}; };
return ( return (
<div className="flex min-h-[50vh] flex-col items-center justify-center gap-4"> <div className="flex min-h-[50vh] flex-col items-center justify-center gap-4">
<Card className="w-full max-w-[var(--max-width-app)]"> <Card className="w-full max-w-[var(--max-width-app)]">
<CardContent className="pt-6"> <CardContent className="pt-6">
<p className="text-error">{errorMessage}</p> <p className="text-error">{requestState.error}</p>
</CardContent> </CardContent>
<CardFooter className="flex flex-col gap-2 sm:flex-row sm:justify-end"> <CardFooter className="flex flex-col gap-2 sm:flex-row sm:justify-end">
<Button <Button

View File

@@ -209,6 +209,7 @@ export const DayDetail = React.forwardRef<DayDetailHandle, DayDetailProps>(
)} )}
overlayClassName="backdrop-blur-md" overlayClassName="backdrop-blur-md"
showCloseButton={false} showCloseButton={false}
closeLabel={t("day_detail.close")}
onCloseAnimationEnd={handleClose} onCloseAnimationEnd={handleClose}
onOpenAutoFocus={(e) => e.preventDefault()} onOpenAutoFocus={(e) => e.preventDefault()}
> >

View File

@@ -0,0 +1,55 @@
/**
* Shared Mini App screen wrappers for safe-area aware pages.
* Keep route and fallback screens visually consistent and DRY.
*/
"use client";
import { cn } from "@/lib/utils";
import { forwardRef } from "react";
export interface MiniAppScreenProps {
children: React.ReactNode;
className?: string;
}
export interface MiniAppScreenContentProps {
children: React.ReactNode;
className?: string;
}
export interface MiniAppStickyHeaderProps {
children: React.ReactNode;
className?: string;
}
const BASE_SCREEN_CLASS = "content-safe min-h-[var(--tg-viewport-stable-height,100vh)] bg-background";
const BASE_CONTENT_CLASS = "mx-auto flex w-full max-w-[var(--max-width-app)] flex-col";
const BASE_STICKY_HEADER_CLASS = "sticky top-[var(--app-safe-top)] z-10 bg-background";
/**
* Top-level page shell with safe-area and stable viewport height.
*/
export function MiniAppScreen({ children, className }: MiniAppScreenProps) {
return <div className={cn(BASE_SCREEN_CLASS, className)}>{children}</div>;
}
/**
* Inner centered content constrained to app max width.
*/
export function MiniAppScreenContent({ children, className }: MiniAppScreenContentProps) {
return <div className={cn(BASE_CONTENT_CLASS, className)}>{children}</div>;
}
/**
* Sticky top section that respects Telegram safe top inset.
*/
export const MiniAppStickyHeader = forwardRef<HTMLDivElement, MiniAppStickyHeaderProps>(
function MiniAppStickyHeader({ children, className }, ref) {
return (
<div ref={ref} className={cn(BASE_STICKY_HEADER_CLASS, className)}>
{children}
</div>
);
}
);

View File

@@ -5,6 +5,8 @@
"use client"; "use client";
import { MiniAppScreen, MiniAppScreenContent } from "@/components/layout/MiniAppScreen";
export interface FullScreenStateShellProps { export interface FullScreenStateShellProps {
/** Main heading (e.g. "Access denied", "Page not found"). */ /** Main heading (e.g. "Access denied", "Page not found"). */
title: React.ReactNode; title: React.ReactNode;
@@ -20,10 +22,6 @@ export interface FullScreenStateShellProps {
className?: string; className?: string;
} }
const OUTER_CLASS =
"content-safe flex min-h-[var(--tg-viewport-stable-height,100vh)] flex-col items-center justify-center gap-4 bg-background text-foreground";
const INNER_CLASS = "mx-auto w-full max-w-[var(--max-width-app)] px-3 flex flex-col items-center gap-4";
/** /**
* Full-screen centered shell with title, optional description, and primary action. * Full-screen centered shell with title, optional description, and primary action.
* Use for access denied, not-found, and in-app error boundary screens. * Use for access denied, not-found, and in-app error boundary screens.
@@ -37,11 +35,9 @@ export function FullScreenStateShell({
className, className,
}: FullScreenStateShellProps) { }: FullScreenStateShellProps) {
return ( return (
<div <MiniAppScreen className={className}>
className={className ? `${OUTER_CLASS} ${className}` : OUTER_CLASS} <MiniAppScreenContent className="items-center justify-center gap-4 px-3 text-foreground">
role={role} <div role={role} className="flex flex-col items-center gap-4">
>
<div className={INNER_CLASS}>
<h1 className="text-xl font-semibold">{title}</h1> <h1 className="text-xl font-semibold">{title}</h1>
{description != null && ( {description != null && (
<p className="text-center text-muted-foreground">{description}</p> <p className="text-center text-muted-foreground">{description}</p>
@@ -49,6 +45,7 @@ export function FullScreenStateShell({
{children} {children}
{primaryAction} {primaryAction}
</div> </div>
</div> </MiniAppScreenContent>
</MiniAppScreen>
); );
} }

View File

@@ -54,6 +54,7 @@ function SheetContent({
onCloseAnimationEnd, onCloseAnimationEnd,
onAnimationEnd, onAnimationEnd,
overlayClassName, overlayClassName,
closeLabel = "Close",
...props ...props
}: React.ComponentProps<typeof SheetPrimitive.Content> & { }: React.ComponentProps<typeof SheetPrimitive.Content> & {
side?: "top" | "right" | "bottom" | "left" side?: "top" | "right" | "bottom" | "left"
@@ -62,6 +63,8 @@ function SheetContent({
onCloseAnimationEnd?: () => void onCloseAnimationEnd?: () => void
/** Optional class name applied to the overlay (e.g. backdrop-blur-md). */ /** Optional class name applied to the overlay (e.g. backdrop-blur-md). */
overlayClassName?: string overlayClassName?: string
/** Accessible label for the close button text (sr-only). */
closeLabel?: string
}) { }) {
const useForceMount = Boolean(onCloseAnimationEnd) const useForceMount = Boolean(onCloseAnimationEnd)
@@ -103,7 +106,7 @@ function SheetContent({
{showCloseButton && ( {showCloseButton && (
<SheetPrimitive.Close className="absolute top-4 right-4 rounded-xs opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:ring-2 focus:ring-ring focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none data-[state=open]:bg-secondary"> <SheetPrimitive.Close className="absolute top-4 right-4 rounded-xs opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:ring-2 focus:ring-ring focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none data-[state=open]:bg-secondary">
<XIcon className="size-4" /> <XIcon className="size-4" />
<span className="sr-only">Close</span> <span className="sr-only">{closeLabel}</span>
</SheetPrimitive.Close> </SheetPrimitive.Close>
)} )}
</SheetPrimitive.Content> </SheetPrimitive.Content>

View File

@@ -0,0 +1,4 @@
export * from "./use-telegram-back-button";
export * from "./use-telegram-settings-button";
export * from "./use-telegram-close-action";
export * from "./use-telegram-interaction-policy";

View File

@@ -0,0 +1,44 @@
/**
* Telegram BackButton adapter.
* Keeps SDK calls out of feature components.
*/
"use client";
import { useEffect } from "react";
import { backButton } from "@telegram-apps/sdk-react";
export interface UseTelegramBackButtonOptions {
enabled: boolean;
onClick: () => void;
}
export function useTelegramBackButton({ enabled, onClick }: UseTelegramBackButtonOptions) {
useEffect(() => {
if (!enabled) return;
let offClick: (() => void) | undefined;
try {
if (backButton.mount.isAvailable()) {
backButton.mount();
}
if (backButton.show.isAvailable()) {
backButton.show();
}
if (backButton.onClick.isAvailable()) {
offClick = backButton.onClick(onClick);
}
} catch {
// Non-Telegram environment; ignore.
}
return () => {
try {
if (typeof offClick === "function") offClick();
if (backButton.hide.isAvailable()) {
backButton.hide();
}
} catch {
// Ignore cleanup errors in non-Telegram environment.
}
};
}, [enabled, onClick]);
}

View File

@@ -0,0 +1,19 @@
/**
* Telegram close adapter.
* Provides one place to prefer Mini App close and fallback safely.
*/
"use client";
import { useCallback } from "react";
import { closeMiniApp } from "@telegram-apps/sdk-react";
export function useTelegramCloseAction(onFallback: () => void) {
return useCallback(() => {
if (closeMiniApp.isAvailable()) {
closeMiniApp();
return;
}
onFallback();
}, [onFallback]);
}

View File

@@ -0,0 +1,80 @@
/**
* Telegram interaction policy hooks.
* Policy defaults: keep vertical swipes enabled; enable closing confirmation only
* for stateful flows where user input can be lost.
*/
"use client";
import { useEffect } from "react";
function getTelegramWebApp(): {
enableVerticalSwipes?: () => void;
disableVerticalSwipes?: () => void;
enableClosingConfirmation?: () => void;
disableClosingConfirmation?: () => void;
} | null {
if (typeof window === "undefined") return null;
return (window as unknown as { Telegram?: { WebApp?: unknown } }).Telegram
?.WebApp as {
enableVerticalSwipes?: () => void;
disableVerticalSwipes?: () => void;
enableClosingConfirmation?: () => void;
disableClosingConfirmation?: () => void;
} | null;
}
/**
* Keep Telegram vertical swipes enabled by default.
* Disable only for screens with conflicting in-app gestures.
*/
export function useTelegramVerticalSwipePolicy(disableVerticalSwipes: boolean) {
useEffect(() => {
const webApp = getTelegramWebApp();
if (!webApp) return;
try {
if (disableVerticalSwipes) {
webApp.disableVerticalSwipes?.();
} else {
webApp.enableVerticalSwipes?.();
}
} catch {
// Ignore unsupported clients.
}
return () => {
if (!disableVerticalSwipes) return;
try {
webApp.enableVerticalSwipes?.();
} catch {
// Ignore unsupported clients.
}
};
}, [disableVerticalSwipes]);
}
/**
* Enable confirmation before closing Mini App for stateful flows.
*/
export function useTelegramClosingConfirmation(enabled: boolean) {
useEffect(() => {
const webApp = getTelegramWebApp();
if (!webApp) return;
try {
if (enabled) {
webApp.enableClosingConfirmation?.();
} else {
webApp.disableClosingConfirmation?.();
}
} catch {
// Ignore unsupported clients.
}
return () => {
if (!enabled) return;
try {
webApp.disableClosingConfirmation?.();
} catch {
// Ignore unsupported clients.
}
};
}, [enabled]);
}

View File

@@ -0,0 +1,44 @@
/**
* Telegram SettingsButton adapter.
* Keeps SDK calls out of feature components.
*/
"use client";
import { useEffect } from "react";
import { settingsButton } from "@telegram-apps/sdk-react";
export interface UseTelegramSettingsButtonOptions {
enabled: boolean;
onClick: () => void;
}
export function useTelegramSettingsButton({ enabled, onClick }: UseTelegramSettingsButtonOptions) {
useEffect(() => {
if (!enabled) return;
let offClick: (() => void) | undefined;
try {
if (settingsButton.mount.isAvailable()) {
settingsButton.mount();
}
if (settingsButton.show.isAvailable()) {
settingsButton.show();
}
if (settingsButton.onClick.isAvailable()) {
offClick = settingsButton.onClick(onClick);
}
} catch {
// Non-Telegram environment; ignore.
}
return () => {
try {
if (typeof offClick === "function") offClick();
if (settingsButton.hide.isAvailable()) {
settingsButton.hide();
}
} catch {
// Ignore cleanup errors in non-Telegram environment.
}
};
}, [enabled, onClick]);
}

View File

@@ -0,0 +1,56 @@
/**
* Shared request-state model for async flows.
*/
"use client";
import { useCallback, useMemo, useState } from "react";
export type RequestPhase = "idle" | "loading" | "success" | "error" | "accessDenied";
export interface RequestState {
phase: RequestPhase;
error: string | null;
accessDeniedDetail: string | null;
}
export function useRequestState(initialPhase: RequestPhase = "idle") {
const [state, setState] = useState<RequestState>({
phase: initialPhase,
error: null,
accessDeniedDetail: null,
});
const setLoading = useCallback(() => {
setState({ phase: "loading", error: null, accessDeniedDetail: null });
}, []);
const setSuccess = useCallback(() => {
setState({ phase: "success", error: null, accessDeniedDetail: null });
}, []);
const setError = useCallback((error: string) => {
setState({ phase: "error", error, accessDeniedDetail: null });
}, []);
const setAccessDenied = useCallback((detail: string | null = null) => {
setState({ phase: "accessDenied", error: null, accessDeniedDetail: detail });
}, []);
const reset = useCallback((phase: RequestPhase = "idle") => {
setState({ phase, error: null, accessDeniedDetail: null });
}, []);
const flags = useMemo(
() => ({
isIdle: state.phase === "idle",
isLoading: state.phase === "loading",
isSuccess: state.phase === "success",
isError: state.phase === "error",
isAccessDenied: state.phase === "accessDenied",
}),
[state.phase]
);
return { state, setLoading, setSuccess, setError, setAccessDenied, reset, ...flags };
}

View File

@@ -0,0 +1,20 @@
/**
* Unified screen-readiness signal for ReadyGate.
* Marks app content as ready once when condition becomes true.
*/
"use client";
import { useEffect, useRef } from "react";
import { useAppStore } from "@/store/app-store";
export function useScreenReady(ready: boolean) {
const setAppContentReady = useAppStore((s) => s.setAppContentReady);
const markedRef = useRef(false);
useEffect(() => {
if (!ready || markedRef.current) return;
markedRef.current = true;
setAppContentReady(true);
}, [ready, setAppContentReady]);
}

View File

@@ -38,6 +38,7 @@ export const MESSAGES: Record<Lang, Record<string, string>> = {
"aria.duty": "On duty", "aria.duty": "On duty",
"aria.unavailable": "Unavailable", "aria.unavailable": "Unavailable",
"aria.vacation": "Vacation", "aria.vacation": "Vacation",
"aria.calendar": "Calendar",
"aria.day_info": "Day info", "aria.day_info": "Day info",
"event_type.duty": "Duty", "event_type.duty": "Duty",
"event_type.unavailable": "Unavailable", "event_type.unavailable": "Unavailable",
@@ -136,6 +137,7 @@ export const MESSAGES: Record<Lang, Record<string, string>> = {
"aria.duty": "Дежурные", "aria.duty": "Дежурные",
"aria.unavailable": "Недоступен", "aria.unavailable": "Недоступен",
"aria.vacation": "Отпуск", "aria.vacation": "Отпуск",
"aria.calendar": "Календарь",
"aria.day_info": "Информация о дне", "aria.day_info": "Информация о дне",
"event_type.duty": "Дежурство", "event_type.duty": "Дежурство",
"event_type.unavailable": "Недоступен", "event_type.unavailable": "Недоступен",

View File

@@ -0,0 +1,16 @@
/**
* Telegram interaction policy for Mini App behavior.
* Keep this as a single source of truth for platform UX decisions.
*/
/**
* Keep vertical swipes enabled unless a specific screen has a hard conflict
* with Telegram swipe-to-minimize behavior.
*/
export const DISABLE_VERTICAL_SWIPES_BY_DEFAULT = false;
/**
* Show closing confirmation only for stateful flows where user choices can be
* lost by accidental close/minimize.
*/
export const ENABLE_CLOSING_CONFIRMATION_FOR_STATEFUL_ADMIN_FLOW = true;

View File

@@ -4,97 +4,38 @@
*/ */
import { create } from "zustand"; import { create } from "zustand";
import type { DutyWithUser, CalendarEvent } from "@/types"; import type { CalendarSlice } from "@/store/slices/calendar-slice";
import { getStartParamFromUrl } from "@/lib/launch-params"; import { createCalendarSlice } from "@/store/slices/calendar-slice";
import type { SessionSlice } from "@/store/slices/session-slice";
import { createSessionSlice } from "@/store/slices/session-slice";
import type { ViewSlice } from "@/store/slices/view-slice";
import { createViewSlice } from "@/store/slices/view-slice";
export type CurrentView = "calendar" | "currentDuty"; type AppStatePatch = Partial<{
/** YYYY-MM key for the month that duties/calendarEvents belong to; null when none loaded. */
export type DataForMonthKey = string | null;
export interface AppState {
currentMonth: Date; currentMonth: Date;
/** When set, we are loading this month; currentMonth and data stay until load completes. */
pendingMonth: Date | null; pendingMonth: Date | null;
lang: "ru" | "en"; lang: "ru" | "en";
duties: DutyWithUser[]; duties: CalendarSlice["duties"];
calendarEvents: CalendarEvent[]; calendarEvents: CalendarSlice["calendarEvents"];
/** YYYY-MM: duties and calendarEvents are for this month; null when loading or no data. */ dataForMonthKey: CalendarSlice["dataForMonthKey"];
dataForMonthKey: DataForMonthKey;
loading: boolean; loading: boolean;
error: string | null; error: string | null;
accessDenied: boolean; accessDenied: boolean;
/** Server detail from API 403 response; shown in AccessDeniedScreen. */
accessDeniedDetail: string | null; accessDeniedDetail: string | null;
currentView: CurrentView; currentView: ViewSlice["currentView"];
selectedDay: string | null; selectedDay: string | null;
/** True when the first visible screen has finished loading; used to hide content until ready(). */
appContentReady: boolean; appContentReady: boolean;
/** True when GET /api/admin/me returned is_admin: true; used to show Admin link. */
isAdmin: boolean; isAdmin: boolean;
}>;
setCurrentMonth: (d: Date) => void; export interface AppState extends SessionSlice, CalendarSlice, ViewSlice {
nextMonth: () => void;
prevMonth: () => void;
setDuties: (d: DutyWithUser[]) => void;
setCalendarEvents: (e: CalendarEvent[]) => void;
setLoading: (v: boolean) => void;
setError: (msg: string | null) => void;
setAccessDenied: (v: boolean) => void;
setAccessDeniedDetail: (v: string | null) => void;
setLang: (v: "ru" | "en") => void;
setCurrentView: (v: CurrentView) => void;
setSelectedDay: (key: string | null) => void;
setAppContentReady: (v: boolean) => void;
setIsAdmin: (v: boolean) => void;
/** Batch multiple state updates into a single re-render. */ /** Batch multiple state updates into a single re-render. */
batchUpdate: (partial: Partial<Pick<AppState, "currentMonth" | "pendingMonth" | "lang" | "duties" | "calendarEvents" | "dataForMonthKey" | "loading" | "error" | "accessDenied" | "accessDeniedDetail" | "currentView" | "selectedDay" | "appContentReady" | "isAdmin">>) => void; batchUpdate: (partial: AppStatePatch) => void;
}
const now = new Date();
const initialMonth = new Date(now.getFullYear(), now.getMonth(), 1);
/** Initial view: currentDuty when opened via deep link (startParam=duty), else calendar. */
function getInitialView(): CurrentView {
if (typeof window === "undefined") return "calendar";
return getStartParamFromUrl() === "duty" ? "currentDuty" : "calendar";
} }
export const useAppStore = create<AppState>((set) => ({ export const useAppStore = create<AppState>((set) => ({
currentMonth: initialMonth, ...createSessionSlice(set),
pendingMonth: null, ...createCalendarSlice(set),
lang: "en", ...createViewSlice(set),
duties: [],
calendarEvents: [],
dataForMonthKey: null,
loading: false,
error: null,
accessDenied: false,
accessDeniedDetail: null,
currentView: getInitialView(),
selectedDay: null,
appContentReady: false,
isAdmin: false,
setCurrentMonth: (d) => set({ currentMonth: d }),
nextMonth: () =>
set((s) => ({
pendingMonth: new Date(s.currentMonth.getFullYear(), s.currentMonth.getMonth() + 1, 1),
})),
prevMonth: () =>
set((s) => ({
pendingMonth: new Date(s.currentMonth.getFullYear(), s.currentMonth.getMonth() - 1, 1),
})),
setDuties: (d) => set({ duties: d }),
setCalendarEvents: (e) => set({ calendarEvents: e }),
setLoading: (v) => set({ loading: v }),
setError: (msg) => set({ error: msg }),
setAccessDenied: (v) => set({ accessDenied: v }),
setAccessDeniedDetail: (v) => set({ accessDeniedDetail: v }),
setLang: (v) => set({ lang: v }),
setCurrentView: (v) => set({ currentView: v }),
setSelectedDay: (key) => set({ selectedDay: key }),
setAppContentReady: (v) => set({ appContentReady: v }),
setIsAdmin: (v) => set({ isAdmin: v }),
batchUpdate: (partial) => set(partial), batchUpdate: (partial) => set(partial),
})); }));

View File

@@ -0,0 +1,24 @@
import type { AppState } from "@/store/app-store";
export const sessionSelectors = {
lang: (s: AppState) => s.lang,
appContentReady: (s: AppState) => s.appContentReady,
isAdmin: (s: AppState) => s.isAdmin,
};
export const calendarSelectors = {
currentMonth: (s: AppState) => s.currentMonth,
pendingMonth: (s: AppState) => s.pendingMonth,
duties: (s: AppState) => s.duties,
calendarEvents: (s: AppState) => s.calendarEvents,
dataForMonthKey: (s: AppState) => s.dataForMonthKey,
selectedDay: (s: AppState) => s.selectedDay,
};
export const viewSelectors = {
loading: (s: AppState) => s.loading,
error: (s: AppState) => s.error,
accessDenied: (s: AppState) => s.accessDenied,
accessDeniedDetail: (s: AppState) => s.accessDeniedDetail,
currentView: (s: AppState) => s.currentView,
};

View File

@@ -0,0 +1,41 @@
import type { CalendarEvent, DutyWithUser } from "@/types";
import type { DataForMonthKey } from "@/store/types";
export interface CalendarSlice {
currentMonth: Date;
/** When set, we are loading this month; currentMonth and data stay until load completes. */
pendingMonth: Date | null;
duties: DutyWithUser[];
calendarEvents: CalendarEvent[];
/** YYYY-MM: duties and calendarEvents are for this month; null when loading or no data. */
dataForMonthKey: DataForMonthKey;
setCurrentMonth: (d: Date) => void;
nextMonth: () => void;
prevMonth: () => void;
setDuties: (d: DutyWithUser[]) => void;
setCalendarEvents: (e: CalendarEvent[]) => void;
}
const now = new Date();
const initialMonth = new Date(now.getFullYear(), now.getMonth(), 1);
type CalendarSet = (updater: Partial<CalendarSlice> | ((state: CalendarSlice) => Partial<CalendarSlice>)) => void;
export const createCalendarSlice = (set: CalendarSet): CalendarSlice => ({
currentMonth: initialMonth,
pendingMonth: null,
duties: [],
calendarEvents: [],
dataForMonthKey: null,
setCurrentMonth: (d) => set({ currentMonth: d }),
nextMonth: () =>
set((s) => ({
pendingMonth: new Date(s.currentMonth.getFullYear(), s.currentMonth.getMonth() + 1, 1),
})),
prevMonth: () =>
set((s) => ({
pendingMonth: new Date(s.currentMonth.getFullYear(), s.currentMonth.getMonth() - 1, 1),
})),
setDuties: (d) => set({ duties: d }),
setCalendarEvents: (e) => set({ calendarEvents: e }),
});

View File

@@ -0,0 +1,21 @@
export interface SessionSlice {
lang: "ru" | "en";
/** True when the first visible screen has finished loading; used to hide content until ready(). */
appContentReady: boolean;
/** True when GET /api/admin/me returned is_admin: true; used to show Admin link. */
isAdmin: boolean;
setLang: (v: "ru" | "en") => void;
setAppContentReady: (v: boolean) => void;
setIsAdmin: (v: boolean) => void;
}
type SessionSet = (updater: Partial<SessionSlice> | ((state: SessionSlice) => Partial<SessionSlice>)) => void;
export const createSessionSlice = (set: SessionSet): SessionSlice => ({
lang: "en",
appContentReady: false,
isAdmin: false,
setLang: (v) => set({ lang: v }),
setAppContentReady: (v) => set({ appContentReady: v }),
setIsAdmin: (v) => set({ isAdmin: v }),
});

View File

@@ -0,0 +1,41 @@
import { getStartParamFromUrl } from "@/lib/launch-params";
import type { CurrentView } from "@/store/types";
export interface ViewSlice {
loading: boolean;
error: string | null;
accessDenied: boolean;
/** Server detail from API 403 response; shown in AccessDeniedScreen. */
accessDeniedDetail: string | null;
currentView: CurrentView;
selectedDay: string | null;
setLoading: (v: boolean) => void;
setError: (msg: string | null) => void;
setAccessDenied: (v: boolean) => void;
setAccessDeniedDetail: (v: string | null) => void;
setCurrentView: (v: CurrentView) => void;
setSelectedDay: (key: string | null) => void;
}
/** Initial view: currentDuty when opened via deep link (startParam=duty), else calendar. */
function getInitialView(): CurrentView {
if (typeof window === "undefined") return "calendar";
return getStartParamFromUrl() === "duty" ? "currentDuty" : "calendar";
}
type ViewSet = (updater: Partial<ViewSlice> | ((state: ViewSlice) => Partial<ViewSlice>)) => void;
export const createViewSlice = (set: ViewSet): ViewSlice => ({
loading: false,
error: null,
accessDenied: false,
accessDeniedDetail: null,
currentView: getInitialView(),
selectedDay: null,
setLoading: (v) => set({ loading: v }),
setError: (msg) => set({ error: msg }),
setAccessDenied: (v) => set({ accessDenied: v }),
setAccessDeniedDetail: (v) => set({ accessDeniedDetail: v }),
setCurrentView: (v) => set({ currentView: v }),
setSelectedDay: (key) => set({ selectedDay: key }),
});

View File

@@ -0,0 +1,4 @@
export type CurrentView = "calendar" | "currentDuty";
/** YYYY-MM key for the month that duties/calendarEvents belong to; null when none loaded. */
export type DataForMonthKey = string | null;