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:
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
119
docs/webapp-next-refactor-audit.md
Normal file
119
docs/webapp-next-refactor-audit.md
Normal 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.
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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} />
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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={
|
||||||
|
|||||||
5
webapp-next/src/components/admin/hooks/index.ts
Normal file
5
webapp-next/src/components/admin/hooks/index.ts
Normal 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";
|
||||||
44
webapp-next/src/components/admin/hooks/use-admin-access.ts
Normal file
44
webapp-next/src/components/admin/hooks/use-admin-access.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
49
webapp-next/src/components/admin/hooks/use-admin-duties.ts
Normal file
49
webapp-next/src/components/admin/hooks/use-admin-duties.ts
Normal 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 };
|
||||||
|
}
|
||||||
116
webapp-next/src/components/admin/hooks/use-admin-reassign.ts
Normal file
116
webapp-next/src/components/admin/hooks/use-admin-reassign.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
45
webapp-next/src/components/admin/hooks/use-admin-users.ts
Normal file
45
webapp-next/src/components/admin/hooks/use-admin-users.ts
Normal 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 };
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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()}
|
||||||
>
|
>
|
||||||
|
|||||||
55
webapp-next/src/components/layout/MiniAppScreen.tsx
Normal file
55
webapp-next/src/components/layout/MiniAppScreen.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
4
webapp-next/src/hooks/telegram/index.ts
Normal file
4
webapp-next/src/hooks/telegram/index.ts
Normal 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";
|
||||||
44
webapp-next/src/hooks/telegram/use-telegram-back-button.ts
Normal file
44
webapp-next/src/hooks/telegram/use-telegram-back-button.ts
Normal 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]);
|
||||||
|
}
|
||||||
19
webapp-next/src/hooks/telegram/use-telegram-close-action.ts
Normal file
19
webapp-next/src/hooks/telegram/use-telegram-close-action.ts
Normal 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]);
|
||||||
|
}
|
||||||
@@ -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]);
|
||||||
|
}
|
||||||
@@ -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]);
|
||||||
|
}
|
||||||
56
webapp-next/src/hooks/use-request-state.ts
Normal file
56
webapp-next/src/hooks/use-request-state.ts
Normal 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 };
|
||||||
|
}
|
||||||
20
webapp-next/src/hooks/use-screen-ready.ts
Normal file
20
webapp-next/src/hooks/use-screen-ready.ts
Normal 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]);
|
||||||
|
}
|
||||||
@@ -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": "Недоступен",
|
||||||
|
|||||||
16
webapp-next/src/lib/telegram-interaction-policy.ts
Normal file
16
webapp-next/src/lib/telegram-interaction-policy.ts
Normal 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;
|
||||||
@@ -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),
|
||||||
}));
|
}));
|
||||||
|
|||||||
24
webapp-next/src/store/selectors.ts
Normal file
24
webapp-next/src/store/selectors.ts
Normal 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,
|
||||||
|
};
|
||||||
41
webapp-next/src/store/slices/calendar-slice.ts
Normal file
41
webapp-next/src/store/slices/calendar-slice.ts
Normal 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 }),
|
||||||
|
});
|
||||||
21
webapp-next/src/store/slices/session-slice.ts
Normal file
21
webapp-next/src/store/slices/session-slice.ts
Normal 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 }),
|
||||||
|
});
|
||||||
41
webapp-next/src/store/slices/view-slice.ts
Normal file
41
webapp-next/src/store/slices/view-slice.ts
Normal 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 }),
|
||||||
|
});
|
||||||
4
webapp-next/src/store/types.ts
Normal file
4
webapp-next/src/store/types.ts
Normal 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;
|
||||||
Reference in New Issue
Block a user