Compare commits
23 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0ee77ee5c1 | |||
| 3f34c7951f | |||
| dc87b3ad97 | |||
| 7cd00893ad | |||
| 95d3af4930 | |||
| 24d6ecbedb | |||
| 34001d22d9 | |||
| 4d09c8641c | |||
| 172d145f0e | |||
| 45c65e3025 | |||
| fa22976e75 | |||
| 43cd3bbd7d | |||
| 26a9443e1b | |||
| 40e2b5adc4 | |||
| 76bff6dc05 | |||
| 6da6c87d3c | |||
| 02a586a1c5 | |||
| 53a899ea26 | |||
| a3152a4545 | |||
| c390a4dd6e | |||
| 68b1884b73 | |||
| fb786c4c3a | |||
| 07e22079ee |
@@ -27,6 +27,7 @@ The Mini App lives in `webapp-next/`. It is built as a static export and served
|
|||||||
| Duty list | `src/components/duty/` — DutyList, DutyTimelineCard, DutyItem |
|
| Duty list | `src/components/duty/` — DutyList, DutyTimelineCard, DutyItem |
|
||||||
| Day detail | `src/components/day-detail/` — DayDetail (Sheet/Popover), DayDetailContent |
|
| Day detail | `src/components/day-detail/` — DayDetail (Sheet/Popover), DayDetailContent |
|
||||||
| Current duty view | `src/components/current-duty/CurrentDutyView.tsx` |
|
| Current duty view | `src/components/current-duty/CurrentDutyView.tsx` |
|
||||||
|
| Admin | `src/components/admin/` — useAdminPage, AdminDutyList, ReassignSheet |
|
||||||
| Contact links | `src/components/contact/ContactLinks.tsx` |
|
| Contact links | `src/components/contact/ContactLinks.tsx` |
|
||||||
| State views | `src/components/states/` — LoadingState, ErrorState, AccessDenied |
|
| State views | `src/components/states/` — LoadingState, ErrorState, AccessDenied |
|
||||||
| Hooks | `src/hooks/` — use-telegram-theme, use-telegram-auth, use-month-data, use-swipe, use-media-query, use-sticky-scroll, use-auto-refresh |
|
| Hooks | `src/hooks/` — use-telegram-theme, use-telegram-auth, use-month-data, use-swipe, use-media-query, use-sticky-scroll, use-auto-refresh |
|
||||||
@@ -40,8 +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`.
|
||||||
|
- **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
|
||||||
|
|
||||||
@@ -50,4 +54,17 @@ The Mini App lives in `webapp-next/`. It is built as a static export and served
|
|||||||
- **Run:** `cd webapp-next && npm test` (or `npm run test`). Build: `npm run build`.
|
- **Run:** `cd webapp-next && npm test` (or `npm run test`). Build: `npm run build`.
|
||||||
- **Coverage:** Unit tests for lib (api, date-utils, calendar-data, i18n, etc.) and component tests for calendar, duty list, day detail, current duty, states.
|
- **Coverage:** Unit tests for lib (api, date-utils, calendar-data, i18n, etc.) and component tests for calendar, duty list, day detail, current duty, states.
|
||||||
|
|
||||||
|
## Design guideline
|
||||||
|
|
||||||
|
When adding or changing UI in the Mini App, **follow the [Mini App design guideline](../../docs/miniapp-design.md)**:
|
||||||
|
|
||||||
|
- Use only design tokens from `globals.css` and Tailwind/shadcn aliases (no hardcoded colours).
|
||||||
|
- 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.).
|
||||||
|
- 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.
|
||||||
|
|
||||||
Consider these rules when changing the Mini App or adding frontend features.
|
Consider these rules when changing the Mini App or adding frontend features.
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ Duty Teller is a Telegram bot plus Mini App for team duty shift calendar and gro
|
|||||||
| Duty-schedule parser | `duty_teller/importers/` |
|
| Duty-schedule parser | `duty_teller/importers/` |
|
||||||
| Config (env vars) | `duty_teller/config.py` |
|
| Config (env vars) | `duty_teller/config.py` |
|
||||||
| Miniapp frontend | `webapp-next/` (Next.js, Tailwind, shadcn/ui; static export in `webapp-next/out/`) |
|
| Miniapp frontend | `webapp-next/` (Next.js, Tailwind, shadcn/ui; static export in `webapp-next/out/`) |
|
||||||
|
| Admin panel (Mini App) | `webapp-next/src/app/admin/page.tsx`; API: `GET /api/admin/me`, `GET /api/admin/users`, `PATCH /api/admin/duties/:id` |
|
||||||
| Migrations | `alembic/` (config in `pyproject.toml` under `[tool.alembic]`) |
|
| Migrations | `alembic/` (config in `pyproject.toml` under `[tool.alembic]`) |
|
||||||
|
|
||||||
## Running and testing
|
## Running and testing
|
||||||
@@ -35,6 +36,7 @@ Duty Teller is a Telegram bot plus Mini App for team duty shift calendar and gro
|
|||||||
## Documentation
|
## Documentation
|
||||||
|
|
||||||
- User and architecture docs: [docs/](docs/), [docs/architecture.md](docs/architecture.md).
|
- User and architecture docs: [docs/](docs/), [docs/architecture.md](docs/architecture.md).
|
||||||
|
- [Mini App design guideline](docs/miniapp-design.md) — Theme, layout, safe areas, component patterns, accessibility for webapp-next.
|
||||||
- Configuration reference: [docs/configuration.md](docs/configuration.md).
|
- Configuration reference: [docs/configuration.md](docs/configuration.md).
|
||||||
- Build docs: `pip install -e ".[docs]"`, then `mkdocs build` / `mkdocs serve`.
|
- Build docs: `pip install -e ".[docs]"`, then `mkdocs build` / `mkdocs serve`.
|
||||||
|
|
||||||
@@ -48,4 +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, 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.
|
||||||
|
|||||||
22
CHANGELOG.md
22
CHANGELOG.md
@@ -7,6 +7,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [2.1.3] - 2025-03-07
|
||||||
|
|
||||||
|
(No changes documented; release for version sync.)
|
||||||
|
|
||||||
|
## [2.1.2] - 2025-03-06
|
||||||
|
|
||||||
|
(No changes documented; release for version sync.)
|
||||||
|
|
||||||
|
## [2.1.1] - 2025-03-06
|
||||||
|
|
||||||
|
(No changes documented; release for version sync.)
|
||||||
|
|
||||||
|
## [2.0.6] - 2025-03-04
|
||||||
|
|
||||||
|
(No changes documented; release for version sync.)
|
||||||
|
|
||||||
## [2.0.4] - 2025-03-04
|
## [2.0.4] - 2025-03-04
|
||||||
|
|
||||||
(No changes documented; release for version sync.)
|
(No changes documented; release for version sync.)
|
||||||
@@ -56,7 +72,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
- Input validation and initData hash verification for Miniapp access.
|
- Input validation and initData hash verification for Miniapp access.
|
||||||
- Optional CORS and init_data_max_age; use env for secrets.
|
- Optional CORS and init_data_max_age; use env for secrets.
|
||||||
|
|
||||||
[Unreleased]: https://github.com/your-org/duty-teller/compare/v2.0.4...HEAD
|
[Unreleased]: https://github.com/your-org/duty-teller/compare/v2.1.3...HEAD
|
||||||
|
[2.1.3]: https://github.com/your-org/duty-teller/releases/tag/v2.1.3
|
||||||
|
[2.1.2]: https://github.com/your-org/duty-teller/releases/tag/v2.1.2
|
||||||
|
[2.1.1]: https://github.com/your-org/duty-teller/releases/tag/v2.1.1 <!-- placeholder: set to your repo URL when publishing -->
|
||||||
|
[2.0.6]: https://github.com/your-org/duty-teller/releases/tag/v2.0.6 <!-- placeholder: set to your repo URL when publishing -->
|
||||||
[2.0.4]: https://github.com/your-org/duty-teller/releases/tag/v2.0.4 <!-- placeholder: set to your repo URL when publishing -->
|
[2.0.4]: https://github.com/your-org/duty-teller/releases/tag/v2.0.4 <!-- placeholder: set to your repo URL when publishing -->
|
||||||
[2.0.3]: https://github.com/your-org/duty-teller/releases/tag/v2.0.3 <!-- placeholder: set to your repo URL when publishing -->
|
[2.0.3]: https://github.com/your-org/duty-teller/releases/tag/v2.0.3 <!-- placeholder: set to your repo URL when publishing -->
|
||||||
[2.0.2]: https://github.com/your-org/duty-teller/releases/tag/v2.0.2 <!-- placeholder: set to your repo URL when publishing -->
|
[2.0.2]: https://github.com/your-org/duty-teller/releases/tag/v2.0.2 <!-- placeholder: set to your repo URL when publishing -->
|
||||||
|
|||||||
@@ -18,6 +18,9 @@ High-level architecture of Duty Teller: components, data flow, and package relat
|
|||||||
- **Miniapp → API**
|
- **Miniapp → API**
|
||||||
Browser opens `/app`; frontend calls `GET /api/duties` and `GET /api/calendar-events` with date range. FastAPI dependencies: DB session, Telegram initData validation (`require_miniapp_username`), date validation. Data is read via `duty_teller.db.repository`.
|
Browser opens `/app`; frontend calls `GET /api/duties` and `GET /api/calendar-events` with date range. FastAPI dependencies: DB session, Telegram initData validation (`require_miniapp_username`), date validation. Data is read via `duty_teller.db.repository`.
|
||||||
|
|
||||||
|
- **Admin panel (Mini App)**
|
||||||
|
Admins see an "Admin" link on the calendar (when `GET /api/admin/me` returns `is_admin: true`). The admin page at `/app/admin` lists duties for the current month and allows reassigning a duty to another user. It uses `GET /api/admin/users` (admin-only) for the user dropdown and `PATCH /api/admin/duties/:id` with `{ user_id }` to reassign. All admin endpoints require valid initData; `/users` and PATCH `/duties` additionally require the user to have the admin role (`require_admin_telegram_id`). PATCH error messages (e.g. duty not found, user not found) use the request `Accept-Language` header for i18n. The reassign dropdown shows only users with role `user` or `admin` (role_id 1 or 2 per migration 007).
|
||||||
|
|
||||||
- **Import**
|
- **Import**
|
||||||
Admin sends JSON file via `/import_duty_schedule`. Handler reads file → `duty_teller.importers.duty_schedule.parse_duty_schedule()` → `DutyScheduleResult` → `duty_teller.services.import_service.run_import()` → repository (`get_or_create_user_by_full_name`, `delete_duties_in_range`, `insert_duty`).
|
Admin sends JSON file via `/import_duty_schedule`. Handler reads file → `duty_teller.importers.duty_schedule.parse_duty_schedule()` → `DutyScheduleResult` → `duty_teller.services.import_service.run_import()` → repository (`get_or_create_user_by_full_name`, `delete_duties_in_range`, `insert_duty`).
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ Telegram bot for team duty shift calendar and group reminder. The bot and web UI
|
|||||||
|
|
||||||
- [Configuration](configuration.md) — Environment variables (types, defaults, examples).
|
- [Configuration](configuration.md) — Environment variables (types, defaults, examples).
|
||||||
- [Architecture](architecture.md) — Components, data flow, package relationships.
|
- [Architecture](architecture.md) — Components, data flow, package relationships.
|
||||||
|
- [Mini App design](miniapp-design.md) — Design guideline for the Telegram Mini App (webapp-next): theme, layout, components, accessibility.
|
||||||
- [Import format](import-format.md) — Duty-schedule JSON format and example.
|
- [Import format](import-format.md) — Duty-schedule JSON format and example.
|
||||||
- [Runbook](runbook.md) — Running the app, logs, common errors, DB and migrations.
|
- [Runbook](runbook.md) — Running the app, logs, common errors, DB and migrations.
|
||||||
- [API Reference](api-reference.md) — Generated from code (api, db, services, handlers, importers, config).
|
- [API Reference](api-reference.md) — Generated from code (api, db, services, handlers, importers, config).
|
||||||
|
|||||||
263
docs/miniapp-design.md
Normal file
263
docs/miniapp-design.md
Normal file
@@ -0,0 +1,263 @@
|
|||||||
|
# Mini App Design Guideline
|
||||||
|
|
||||||
|
This document defines the design rules for the Duty Teller Mini App (Next.js frontend in `webapp-next/`). It aligns with [Telegram’s official Mini App design guidelines](https://core.telegram.org/bots/webapps#designing-mini-apps) (Color Schemes and Design Guidelines) and codifies the current implementation (calendar, duty list, admin) as the reference for new screens and components.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Telegram design principles
|
||||||
|
|
||||||
|
Telegram’s guidelines state:
|
||||||
|
|
||||||
|
- **Mobile-first:** All elements must be responsive and designed for mobile first.
|
||||||
|
- **Consistency:** Interactive elements should match the style, behaviour, and intent of existing Telegram UI components.
|
||||||
|
- **Performance:** Animations should be smooth, ideally 60fps.
|
||||||
|
- **Accessibility:** Inputs and images must have labels.
|
||||||
|
- **Theme:** The app must use the dynamic theme-based colors provided by the API (Day/Night and custom themes).
|
||||||
|
- **Safe areas:** The interface must respect the **safe area** and **content safe area** so content does not overlap system or Telegram UI, especially in fullscreen.
|
||||||
|
- **Android:** On Android, use the extra User-Agent data (e.g. performance class) and reduce animations and effects on low-performance devices for smooth operation.
|
||||||
|
|
||||||
|
**Color schemes:** Mini Apps receive the user’s current **theme** in real time. Use the `ThemeParams` object and the CSS variables Telegram exposes (e.g. `--tg-theme-bg-color`, `--tg-theme-text-color`) so the UI adapts when the user switches Day/Night or custom themes.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Theme and colors
|
||||||
|
|
||||||
|
### 2.1 Sources
|
||||||
|
|
||||||
|
Theme is resolved in this order:
|
||||||
|
|
||||||
|
1. Hash parameters: `tgWebAppColorScheme`, `tgWebAppThemeParams` (parsed in the shared inline script from `webapp-next/src/lib/theme-bootstrap-script.ts`, used in layout and global-error).
|
||||||
|
2. At runtime: `Telegram.WebApp.colorScheme` and `Telegram.WebApp.themeParams` via **TelegramProvider** (theme sync is provider-owned in `ThemeSync` / `useTelegramTheme`), so every route (/, /admin, not-found, error) receives live theme updates.
|
||||||
|
|
||||||
|
The inline script maps all Telegram theme keys to `--tg-theme-*` CSS variables on the document root. The provider sets `data-theme` (`light` / `dark`) and applies Mini App background/header colors.
|
||||||
|
|
||||||
|
### 2.2 Mapping (ThemeParams → app tokens)
|
||||||
|
|
||||||
|
In `webapp-next/src/app/globals.css`, `:root` and `[data-theme="light"]` / `[data-theme="dark"]` map Telegram’s ThemeParams to internal design tokens:
|
||||||
|
|
||||||
|
| Telegram ThemeParam (CSS var) | App token / usage |
|
||||||
|
|--------------------------------------|-------------------|
|
||||||
|
| `--tg-theme-bg-color` | `--bg` (background) |
|
||||||
|
| `--tg-theme-secondary-bg-color` | `--surface` (cards, panels) |
|
||||||
|
| `--tg-theme-text-color` | `--text` (primary text) |
|
||||||
|
| `--tg-theme-hint-color`, `--tg-theme-subtitle-text-color` | `--muted` (secondary text) |
|
||||||
|
| `--tg-theme-link-color` | `--accent` (links, secondary actions) |
|
||||||
|
| `--tg-theme-header-bg-color` | `--header-bg` |
|
||||||
|
| `--tg-theme-section-bg-color` | `--card` (sections) |
|
||||||
|
| `--tg-theme-section-header-text-color` | `--section-header` |
|
||||||
|
| `--tg-theme-section-separator-color` | `--border` |
|
||||||
|
| `--tg-theme-button-color` | `--primary` |
|
||||||
|
| `--tg-theme-button-text-color` | `--primary-foreground` |
|
||||||
|
| `--tg-theme-destructive-text-color` | `--error` |
|
||||||
|
| `--tg-theme-accent-text-color` | `--accent-text` |
|
||||||
|
|
||||||
|
Tailwind/shadcn semantic tokens are wired to these: `--background` → `--bg`, `--foreground` → `--text`, `--primary`, `--secondary` → `--surface`, etc.
|
||||||
|
|
||||||
|
### 2.3 Domain colors
|
||||||
|
|
||||||
|
Duty-specific semantics (fixed per theme, not from ThemeParams):
|
||||||
|
|
||||||
|
- `--duty` — duty shift (e.g. green).
|
||||||
|
- `--unavailable` — unavailable (e.g. amber).
|
||||||
|
- `--vacation` — vacation (e.g. blue).
|
||||||
|
- `--today` — “today” highlight; tied to `--tg-theme-accent-text-color` / `--tg-theme-link-color`.
|
||||||
|
|
||||||
|
Use Tailwind classes such as `bg-duty`, `border-l-unavailable`, `text-today`, `bg-today`, etc.
|
||||||
|
|
||||||
|
### 2.4 Derived tokens (color-mix)
|
||||||
|
|
||||||
|
Prefer these instead of ad-hoc color mixes:
|
||||||
|
|
||||||
|
- `--surface-hover`, `--surface-hover-10` — hover states for surfaces.
|
||||||
|
- `--surface-today-tint`, `--surface-muted-tint` — subtle tints.
|
||||||
|
- `--today-hover`, `--today-border`, `--today-border-selected`, `--today-gradient-end` — today state.
|
||||||
|
- `--muted-fade`, `--shadow-card`, etc.
|
||||||
|
|
||||||
|
Defined in `globals.css`; use via `var(--surface-hover)` in classes (e.g. `hover:bg-[var(--surface-hover)]`).
|
||||||
|
|
||||||
|
### 2.5 Rule for new work
|
||||||
|
|
||||||
|
Use **only** these tokens and Tailwind/shadcn aliases (`bg-background`, `text-muted`, `bg-surface`, `text-accent`, `border-l-duty`, `bg-today`, etc.). Do not hardcode hex or RGB in new components or screens.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Layout and safe areas
|
||||||
|
|
||||||
|
### 3.1 Width
|
||||||
|
|
||||||
|
- **Token:** `--max-width-app: 420px` (in `@theme` in `globals.css`).
|
||||||
|
- **Usage:** Page wrapper uses `max-w-[var(--max-width-app)]` (e.g. in `page.tsx`, `CalendarPage.tsx`, `admin/page.tsx`). Content is centred with `mx-auto`.
|
||||||
|
|
||||||
|
### 3.2 Height
|
||||||
|
|
||||||
|
- **Viewport:** Prefer `min-h-[var(--tg-viewport-stable-height,100vh)]` for the main content area so the Mini App fills the visible height correctly when expanded/collapsed. Fallback `100vh` when not in Telegram.
|
||||||
|
- **Body:** In `globals.css`, `body` already has `min-height: var(--tg-viewport-stable-height, 100vh)` and `background: var(--bg)`.
|
||||||
|
|
||||||
|
### 3.3 Safe area and content safe area
|
||||||
|
|
||||||
|
- **CSS custom properties** (in `globals.css`): `--app-safe-top`, `--app-safe-bottom`, `--app-safe-left`, `--app-safe-right` use Telegram viewport content-safe-area insets with `env(safe-area-inset-*)` fallbacks. Use these for sticky positioning and padding so layout works on notched and landscape devices.
|
||||||
|
- **Class `.content-safe`**: Applies padding on all four sides using the above tokens so content does not sit under Telegram header, bottom bar, or side chrome (Bot API 8.0+). Use `.content-safe` on the **root container of each page** and on full-screen fallback screens (not-found, error, access denied).
|
||||||
|
- **Sticky headers:** Use `top-[var(--app-safe-top)]` (not `top-0`) for sticky elements (e.g. calendar header, admin header) so they sit below the Telegram UI instead of overlapping it.
|
||||||
|
- Lists that extend to the bottom should also account for bottom inset (e.g. `padding-bottom: var(--tg-viewport-content-safe-area-inset-bottom, 12px)` in `.container-app`).
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
Bottom sheets and modals that sit at the bottom of the screen must add safe area to their padding, e.g.:
|
||||||
|
|
||||||
|
`pb-[calc(24px+env(safe-area-inset-bottom,0px))]`
|
||||||
|
|
||||||
|
See `webapp-next/src/components/day-detail/DayDetail.tsx` for the Sheet content.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Typography and spacing
|
||||||
|
|
||||||
|
### 4.1 Font
|
||||||
|
|
||||||
|
- **Family:** `system-ui, -apple-system, sans-serif` (set in `globals.css` and Tailwind theme).
|
||||||
|
|
||||||
|
### 4.2 Patterns from the calendar and duty list
|
||||||
|
|
||||||
|
| Element | Classes / tokens |
|
||||||
|
|--------|-------------------|
|
||||||
|
| Month title | `text-[1.1rem]` / `sm:text-[1.25rem]`, `font-semibold` |
|
||||||
|
| Year (above month) | `text-xs`, `text-muted` |
|
||||||
|
| Nav buttons (prev/next month) | `size-10`, `rounded-[10px]` |
|
||||||
|
| Calendar day cell | `text-[0.85rem]`, `rounded-lg`, `p-1` |
|
||||||
|
| Duty timeline card | `px-2.5 py-2`, `rounded-lg` |
|
||||||
|
|
||||||
|
### 4.3 Page and block spacing
|
||||||
|
|
||||||
|
- Page container: `px-3 pb-6` in addition to `.content-safe`.
|
||||||
|
- Between sections: `mb-3`, `mb-4` as appropriate.
|
||||||
|
- Grids: `gap-1` for tight layouts (e.g. calendar grid), larger gaps where needed.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Component patterns
|
||||||
|
|
||||||
|
### 5.1 Buttons
|
||||||
|
|
||||||
|
- **Primary:** Use the default Button variant: `bg-primary text-primary-foreground` (from `webapp-next/src/components/ui/button.tsx`).
|
||||||
|
- **Secondary icon buttons** (e.g. calendar nav):
|
||||||
|
`bg-surface text-accent hover:bg-[var(--surface-hover)] focus-visible:outline-accent active:scale-95`
|
||||||
|
with `size-10` and `rounded-[10px]`.
|
||||||
|
- Keep focus visible (e.g. `focus-visible:outline-accent` or ring); do not remove outline without a visible replacement.
|
||||||
|
|
||||||
|
### 5.2 Cards
|
||||||
|
|
||||||
|
- **Background:** `bg-surface` or `bg-card` (both resolve to theme tokens).
|
||||||
|
- **Borders:** `border`, `--border` (section separator color).
|
||||||
|
- **Emphasis:** `var(--shadow-card)` for highlighted cards (e.g. current duty).
|
||||||
|
- **Left stripe by type:** `border-l-[3px]` with:
|
||||||
|
- `border-l-duty`, `border-l-unavailable`, `border-l-vacation` for event types;
|
||||||
|
- `border-l-today` for “current duty” (see `.border-l-today` in `globals.css`).
|
||||||
|
|
||||||
|
### 5.3 Calendar grid
|
||||||
|
|
||||||
|
- **Structure:** 7 columns × 6 rows; use `role="grid"` on the container and `role="gridcell"` on each cell.
|
||||||
|
- **Layout:** `min-h-[var(--calendar-grid-min-height)]`, cells `aspect-square` with `min-h-8`, `rounded-lg`, `gap-1`.
|
||||||
|
- **Today:** `bg-today text-[var(--bg)]`; hover `hover:bg-[var(--today-hover)]`.
|
||||||
|
- **Other month:** `opacity-40`, `pointer-events-none`, `bg-[var(--surface-muted-tint)]`.
|
||||||
|
|
||||||
|
### 5.4 Timeline list (duties)
|
||||||
|
|
||||||
|
- **Dates:** Horizontal line and vertical tick from shared CSS in `globals.css`:
|
||||||
|
`.duty-timeline-date`, `.duty-timeline-date--today` (with `::before` / `::after`).
|
||||||
|
- **Cards:** Same card rules as above; `border-l-[3px]` + type class; optional flip card for contacts (see `DutyTimelineCard.tsx`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Motion and performance
|
||||||
|
|
||||||
|
### 6.1 Timing
|
||||||
|
|
||||||
|
- **Tokens:** `--transition-fast: 0.15s`, `--transition-normal: 0.25s`, `--ease-out: cubic-bezier(0.32, 0.72, 0, 1)`.
|
||||||
|
- Use these for transitions and short animations so behaviour is consistent and predictable.
|
||||||
|
|
||||||
|
### 6.2 Reduced motion
|
||||||
|
|
||||||
|
- **Rule:** `@media (prefers-reduced-motion: reduce)` in `globals.css` shortens animation and transition durations globally. New animations should remain optional or short so they degrade gracefully when reduced.
|
||||||
|
|
||||||
|
### 6.3 Android low-performance devices
|
||||||
|
|
||||||
|
- **Detection:** `webapp-next/src/lib/telegram-android-perf.ts` reads Telegram’s User-Agent and sets `data-perf="low"` on the document root when the device performance class is LOW.
|
||||||
|
- **CSS:** `[data-perf="low"] *` in `globals.css` minimizes animation/transition duration. Avoid adding heavy or long animations without considering this; prefer simple or no animation on low-end devices.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Accessibility
|
||||||
|
|
||||||
|
- **Focus:** Use `focus-visible:outline-accent` (or equivalent ring) on interactive elements; do not remove focus outline without a visible alternative.
|
||||||
|
- **Calendar:** Use `role="grid"` and `role="gridcell"`, `aria-label` on nav buttons (e.g. “Previous month”), and a composite `aria-label` on each day cell (date + event types). See `webapp-next/src/components/calendar/CalendarDay.tsx`.
|
||||||
|
- **Images and inputs:** Always provide labels (per Telegram’s guidelines and WCAG).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Telegram integration
|
||||||
|
|
||||||
|
- **Ready gate:** `callMiniAppReadyOnce()` (in `lib/telegram-ready.ts`) is invoked by the layout’s `ReadyGate` when `appContentReady` becomes true. Any route (/, /admin, not-found, in-app error) that sets `appContentReady` will trigger it so Telegram hides its loader; no route-specific logic is required.
|
||||||
|
- **Header and background:** On init (layout script and provider’s theme sync), call:
|
||||||
|
- `setBackgroundColor('bg_color')`
|
||||||
|
- `setHeaderColor('bg_color')`
|
||||||
|
- `setBottomBarColor('bottom_bar_bg_color')` when available (Bot API 7.10+).
|
||||||
|
- **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
|
||||||
|
|
||||||
|
Use this for review when adding or changing UI:
|
||||||
|
|
||||||
|
- [ ] Use only design tokens from `globals.css` and Tailwind/shadcn aliases; no hardcoded colours.
|
||||||
|
- [ ] Page wrapper has `.content-safe`, `max-w-[var(--max-width-app)]`, and appropriate min-height (viewport-stable-height or `min-h-screen` with fallback).
|
||||||
|
- [ ] Buttons and cards follow the patterns above (variants, surfaces, border-l by type).
|
||||||
|
- [ ] Safe area is respected for bottom padding and for sheets/modals.
|
||||||
|
- [ ] 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).
|
||||||
|
- [ ] 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,7 +6,7 @@ from datetime import date, timedelta
|
|||||||
|
|
||||||
import duty_teller.config as config
|
import duty_teller.config as config
|
||||||
|
|
||||||
from fastapi import Depends, FastAPI, Request
|
from fastapi import Depends, FastAPI, HTTPException, Request
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
from fastapi.responses import JSONResponse, Response
|
from fastapi.responses import JSONResponse, Response
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
@@ -14,19 +14,34 @@ from sqlalchemy.orm import Session
|
|||||||
|
|
||||||
from duty_teller.api.calendar_ics import get_calendar_events
|
from duty_teller.api.calendar_ics import get_calendar_events
|
||||||
from duty_teller.api.dependencies import (
|
from duty_teller.api.dependencies import (
|
||||||
|
_lang_from_accept_language,
|
||||||
fetch_duties_response,
|
fetch_duties_response,
|
||||||
|
get_authenticated_telegram_id_dep,
|
||||||
get_db_session,
|
get_db_session,
|
||||||
get_validated_dates,
|
get_validated_dates,
|
||||||
|
require_admin_telegram_id,
|
||||||
require_miniapp_username,
|
require_miniapp_username,
|
||||||
)
|
)
|
||||||
from duty_teller.api.personal_calendar_ics import build_personal_ics, build_team_ics
|
from duty_teller.api.personal_calendar_ics import build_personal_ics, build_team_ics
|
||||||
from duty_teller.cache import ics_calendar_cache
|
from duty_teller.cache import invalidate_duty_related_caches, ics_calendar_cache
|
||||||
from duty_teller.db.repository import (
|
from duty_teller.db.repository import (
|
||||||
get_duties,
|
get_duties,
|
||||||
get_duties_for_user,
|
get_duties_for_user,
|
||||||
|
get_duty_by_id,
|
||||||
get_user_by_calendar_token,
|
get_user_by_calendar_token,
|
||||||
|
get_users_for_admin,
|
||||||
|
is_admin_for_telegram_user,
|
||||||
|
update_duty_user,
|
||||||
)
|
)
|
||||||
from duty_teller.db.schemas import CalendarEvent, DutyWithUser
|
from duty_teller.db.models import User
|
||||||
|
from duty_teller.db.schemas import (
|
||||||
|
AdminDutyReassignBody,
|
||||||
|
CalendarEvent,
|
||||||
|
DutyInDb,
|
||||||
|
DutyWithUser,
|
||||||
|
UserForAdmin,
|
||||||
|
)
|
||||||
|
from duty_teller.i18n import t
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -154,7 +169,8 @@ def app_config_js() -> Response:
|
|||||||
tz = _safe_tz_string(config.DUTY_DISPLAY_TZ)
|
tz = _safe_tz_string(config.DUTY_DISPLAY_TZ)
|
||||||
tz_js = f'\nwindow.__DT_TZ = "{tz}";' if tz else "\nwindow.__DT_TZ = undefined;"
|
tz_js = f'\nwindow.__DT_TZ = "{tz}";' if tz else "\nwindow.__DT_TZ = undefined;"
|
||||||
body = (
|
body = (
|
||||||
f'window.__DT_LANG = "{lang}";\nwindow.__DT_LOG_LEVEL = "{log_level}";{tz_js}'
|
f'window.__DT_LANG = "{lang}";\nwindow.__DT_LOG_LEVEL = "{log_level}";{tz_js}\n'
|
||||||
|
'if (typeof window !== "undefined") window.dispatchEvent(new Event("dt-config-loaded"));'
|
||||||
)
|
)
|
||||||
return Response(
|
return Response(
|
||||||
content=body,
|
content=body,
|
||||||
@@ -283,6 +299,96 @@ def get_personal_calendar_ical(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# --- Admin API (initData + admin role required for GET /users and PATCH /duties) ---
|
||||||
|
|
||||||
|
|
||||||
|
@app.get(
|
||||||
|
"/api/admin/me",
|
||||||
|
summary="Check admin status",
|
||||||
|
description=(
|
||||||
|
"Returns is_admin for the authenticated Mini App user. "
|
||||||
|
"Requires valid initData (same as /api/duties)."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
def admin_me(
|
||||||
|
_username: str = Depends(require_miniapp_username),
|
||||||
|
telegram_id: int = Depends(get_authenticated_telegram_id_dep),
|
||||||
|
session: Session = Depends(get_db_session),
|
||||||
|
) -> dict:
|
||||||
|
"""Return { is_admin: true } or { is_admin: false } for the current user."""
|
||||||
|
is_admin = is_admin_for_telegram_user(session, telegram_id)
|
||||||
|
return {"is_admin": is_admin}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get(
|
||||||
|
"/api/admin/users",
|
||||||
|
response_model=list[UserForAdmin],
|
||||||
|
summary="List users for admin dropdown",
|
||||||
|
description="Returns id, full_name, username for all users. Admin only.",
|
||||||
|
)
|
||||||
|
def admin_list_users(
|
||||||
|
_admin_telegram_id: int = Depends(require_admin_telegram_id),
|
||||||
|
session: Session = Depends(get_db_session),
|
||||||
|
) -> list[UserForAdmin]:
|
||||||
|
"""Return all users ordered by full_name for admin reassign dropdown."""
|
||||||
|
users = get_users_for_admin(session)
|
||||||
|
return [
|
||||||
|
UserForAdmin(
|
||||||
|
id=u.id,
|
||||||
|
full_name=u.full_name,
|
||||||
|
username=u.username,
|
||||||
|
role_id=u.role_id,
|
||||||
|
)
|
||||||
|
for u in users
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@app.patch(
|
||||||
|
"/api/admin/duties/{duty_id}",
|
||||||
|
response_model=DutyInDb,
|
||||||
|
summary="Reassign duty to another user",
|
||||||
|
description="Update duty's user_id. Admin only. Invalidates ICS and pin caches.",
|
||||||
|
)
|
||||||
|
def admin_reassign_duty(
|
||||||
|
duty_id: int,
|
||||||
|
body: AdminDutyReassignBody,
|
||||||
|
request: Request,
|
||||||
|
_admin_telegram_id: int = Depends(require_admin_telegram_id),
|
||||||
|
session: Session = Depends(get_db_session),
|
||||||
|
) -> DutyInDb:
|
||||||
|
"""Reassign duty to another user; return updated duty or 404/400 with i18n detail."""
|
||||||
|
lang = _lang_from_accept_language(request.headers.get("Accept-Language"))
|
||||||
|
if duty_id <= 0 or body.user_id <= 0:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail=t(lang, "api.bad_request"),
|
||||||
|
)
|
||||||
|
duty = get_duty_by_id(session, duty_id)
|
||||||
|
if duty is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=404,
|
||||||
|
detail=t(lang, "admin.duty_not_found"),
|
||||||
|
)
|
||||||
|
if session.get(User, body.user_id) is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail=t(lang, "admin.user_not_found"),
|
||||||
|
)
|
||||||
|
updated = update_duty_user(session, duty_id, body.user_id, commit=True)
|
||||||
|
if updated is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=404,
|
||||||
|
detail=t(lang, "admin.duty_not_found"),
|
||||||
|
)
|
||||||
|
invalidate_duty_related_caches()
|
||||||
|
return DutyInDb(
|
||||||
|
id=updated.id,
|
||||||
|
user_id=updated.user_id,
|
||||||
|
start_at=updated.start_at,
|
||||||
|
end_at=updated.end_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
webapp_path = config.PROJECT_ROOT / "webapp-next" / "out"
|
webapp_path = config.PROJECT_ROOT / "webapp-next" / "out"
|
||||||
if webapp_path.is_dir():
|
if webapp_path.is_dir():
|
||||||
app.mount("/app", StaticFiles(directory=str(webapp_path), html=True), name="webapp")
|
app.mount("/app", StaticFiles(directory=str(webapp_path), html=True), name="webapp")
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ from duty_teller.db.repository import (
|
|||||||
get_duties,
|
get_duties,
|
||||||
get_user_by_telegram_id,
|
get_user_by_telegram_id,
|
||||||
can_access_miniapp_for_telegram_user,
|
can_access_miniapp_for_telegram_user,
|
||||||
|
is_admin_for_telegram_user,
|
||||||
)
|
)
|
||||||
from duty_teller.db.schemas import DUTY_EVENT_TYPES, DutyWithUser
|
from duty_teller.db.schemas import DUTY_EVENT_TYPES, DutyWithUser
|
||||||
from duty_teller.db.session import session_scope
|
from duty_teller.db.session import session_scope
|
||||||
@@ -159,6 +160,103 @@ def get_authenticated_username(
|
|||||||
return username or (user.full_name or "") or f"id:{telegram_user_id}"
|
return username or (user.full_name or "") or f"id:{telegram_user_id}"
|
||||||
|
|
||||||
|
|
||||||
|
def get_authenticated_telegram_id(
|
||||||
|
request: Request,
|
||||||
|
x_telegram_init_data: str | None,
|
||||||
|
session: Session,
|
||||||
|
) -> int:
|
||||||
|
"""Return Telegram user id for the authenticated miniapp user; 0 if skip-auth.
|
||||||
|
|
||||||
|
Same validation as get_authenticated_username. Used to check is_admin.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
request: FastAPI request (for Accept-Language in error messages).
|
||||||
|
x_telegram_init_data: Raw X-Telegram-Init-Data header value.
|
||||||
|
session: DB session.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
telegram_user_id (int). When MINI_APP_SKIP_AUTH, returns 0 (no real user).
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
HTTPException: 403 if initData missing/invalid or user not in allowlist.
|
||||||
|
"""
|
||||||
|
if config.MINI_APP_SKIP_AUTH:
|
||||||
|
return 0
|
||||||
|
init_data = (x_telegram_init_data or "").strip()
|
||||||
|
if not init_data:
|
||||||
|
log.warning("no X-Telegram-Init-Data header")
|
||||||
|
lang = _lang_from_accept_language(request.headers.get("Accept-Language"))
|
||||||
|
raise HTTPException(status_code=403, detail=t(lang, "api.open_from_telegram"))
|
||||||
|
max_age = config.INIT_DATA_MAX_AGE_SECONDS or None
|
||||||
|
telegram_user_id, username, auth_reason, lang = validate_init_data_with_reason(
|
||||||
|
init_data, config.BOT_TOKEN, max_age_seconds=max_age
|
||||||
|
)
|
||||||
|
if auth_reason != "ok":
|
||||||
|
log.warning("initData validation failed: %s", auth_reason)
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=403, detail=_auth_error_detail(auth_reason, lang)
|
||||||
|
)
|
||||||
|
if telegram_user_id is None:
|
||||||
|
log.warning("initData valid but telegram_user_id missing")
|
||||||
|
raise HTTPException(status_code=403, detail=t(lang, "api.access_denied"))
|
||||||
|
user = get_user_by_telegram_id(session, telegram_user_id)
|
||||||
|
if not user:
|
||||||
|
log.warning(
|
||||||
|
"user not in DB (username=%s, telegram_id=%s)",
|
||||||
|
username,
|
||||||
|
telegram_user_id,
|
||||||
|
)
|
||||||
|
raise HTTPException(status_code=403, detail=t(lang, "api.access_denied"))
|
||||||
|
if not can_access_miniapp_for_telegram_user(session, telegram_user_id):
|
||||||
|
failed_phone = config.normalize_phone(user.phone) if user.phone else None
|
||||||
|
log.warning(
|
||||||
|
"access denied (username=%s, telegram_id=%s, phone=%s)",
|
||||||
|
username,
|
||||||
|
telegram_user_id,
|
||||||
|
failed_phone or "—",
|
||||||
|
)
|
||||||
|
raise HTTPException(status_code=403, detail=t(lang, "api.access_denied"))
|
||||||
|
return telegram_user_id
|
||||||
|
|
||||||
|
|
||||||
|
def get_authenticated_telegram_id_dep(
|
||||||
|
request: Request,
|
||||||
|
x_telegram_init_data: Annotated[
|
||||||
|
str | None, Header(alias="X-Telegram-Init-Data")
|
||||||
|
] = None,
|
||||||
|
session: Session = Depends(get_db_session),
|
||||||
|
) -> int:
|
||||||
|
"""FastAPI dependency: return telegram_user_id for authenticated miniapp user (0 if skip-auth)."""
|
||||||
|
return get_authenticated_telegram_id(request, x_telegram_init_data, session)
|
||||||
|
|
||||||
|
|
||||||
|
def require_admin_telegram_id(
|
||||||
|
request: Request,
|
||||||
|
x_telegram_init_data: Annotated[
|
||||||
|
str | None, Header(alias="X-Telegram-Init-Data")
|
||||||
|
] = None,
|
||||||
|
session: Session = Depends(get_db_session),
|
||||||
|
) -> int:
|
||||||
|
"""FastAPI dependency: require valid miniapp auth and admin role; return telegram_user_id.
|
||||||
|
|
||||||
|
When MINI_APP_SKIP_AUTH is True, admin routes are disabled (403).
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
HTTPException: 403 if initData missing/invalid, user not in allowlist, or not admin.
|
||||||
|
"""
|
||||||
|
if config.MINI_APP_SKIP_AUTH:
|
||||||
|
log.warning("Admin routes disabled when MINI_APP_SKIP_AUTH is set")
|
||||||
|
lang = _lang_from_accept_language(request.headers.get("Accept-Language"))
|
||||||
|
raise HTTPException(status_code=403, detail=t(lang, "import.admin_only"))
|
||||||
|
telegram_user_id = get_authenticated_telegram_id(
|
||||||
|
request, x_telegram_init_data, session
|
||||||
|
)
|
||||||
|
if not is_admin_for_telegram_user(session, telegram_user_id):
|
||||||
|
lang = _lang_from_accept_language(request.headers.get("Accept-Language"))
|
||||||
|
raise HTTPException(status_code=403, detail=t(lang, "import.admin_only"))
|
||||||
|
return telegram_user_id
|
||||||
|
|
||||||
|
|
||||||
def fetch_duties_response(
|
def fetch_duties_response(
|
||||||
session: Session, from_date: str, to_date: str
|
session: Session, from_date: str, to_date: str
|
||||||
) -> list[DutyWithUser]:
|
) -> list[DutyWithUser]:
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ from duty_teller.db.models import Base, User, Duty, Role
|
|||||||
from duty_teller.db.schemas import (
|
from duty_teller.db.schemas import (
|
||||||
UserCreate,
|
UserCreate,
|
||||||
UserInDb,
|
UserInDb,
|
||||||
|
UserForAdmin,
|
||||||
DutyCreate,
|
DutyCreate,
|
||||||
DutyInDb,
|
DutyInDb,
|
||||||
DutyWithUser,
|
DutyWithUser,
|
||||||
@@ -16,11 +17,14 @@ from duty_teller.db.session import (
|
|||||||
)
|
)
|
||||||
from duty_teller.db.repository import (
|
from duty_teller.db.repository import (
|
||||||
delete_duties_in_range,
|
delete_duties_in_range,
|
||||||
|
get_duties,
|
||||||
|
get_duty_by_id,
|
||||||
get_or_create_user,
|
get_or_create_user,
|
||||||
get_or_create_user_by_full_name,
|
get_or_create_user_by_full_name,
|
||||||
get_duties,
|
get_users_for_admin,
|
||||||
insert_duty,
|
insert_duty,
|
||||||
set_user_phone,
|
set_user_phone,
|
||||||
|
update_duty_user,
|
||||||
update_user_display_name,
|
update_user_display_name,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -31,6 +35,7 @@ __all__ = [
|
|||||||
"Role",
|
"Role",
|
||||||
"UserCreate",
|
"UserCreate",
|
||||||
"UserInDb",
|
"UserInDb",
|
||||||
|
"UserForAdmin",
|
||||||
"DutyCreate",
|
"DutyCreate",
|
||||||
"DutyInDb",
|
"DutyInDb",
|
||||||
"DutyWithUser",
|
"DutyWithUser",
|
||||||
@@ -39,11 +44,14 @@ __all__ = [
|
|||||||
"get_session",
|
"get_session",
|
||||||
"session_scope",
|
"session_scope",
|
||||||
"delete_duties_in_range",
|
"delete_duties_in_range",
|
||||||
|
"get_duties",
|
||||||
|
"get_duty_by_id",
|
||||||
"get_or_create_user",
|
"get_or_create_user",
|
||||||
"get_or_create_user_by_full_name",
|
"get_or_create_user_by_full_name",
|
||||||
"get_duties",
|
"get_users_for_admin",
|
||||||
"insert_duty",
|
"insert_duty",
|
||||||
"set_user_phone",
|
"set_user_phone",
|
||||||
|
"update_duty_user",
|
||||||
"update_user_display_name",
|
"update_user_display_name",
|
||||||
"init_db",
|
"init_db",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -322,6 +322,61 @@ def delete_duties_in_range(
|
|||||||
return count
|
return count
|
||||||
|
|
||||||
|
|
||||||
|
def get_duty_by_id(session: Session, duty_id: int) -> Duty | None:
|
||||||
|
"""Return duty by primary key.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
session: DB session.
|
||||||
|
duty_id: Duty id (duties.id).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Duty or None if not found.
|
||||||
|
"""
|
||||||
|
return session.get(Duty, duty_id)
|
||||||
|
|
||||||
|
|
||||||
|
def update_duty_user(
|
||||||
|
session: Session,
|
||||||
|
duty_id: int,
|
||||||
|
new_user_id: int,
|
||||||
|
*,
|
||||||
|
commit: bool = True,
|
||||||
|
) -> Duty | None:
|
||||||
|
"""Update the assigned user of a duty.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
session: DB session.
|
||||||
|
duty_id: Duty id (duties.id).
|
||||||
|
new_user_id: New user id (users.id).
|
||||||
|
commit: If True, commit immediately. If False, caller commits.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Updated Duty or None if duty not found.
|
||||||
|
"""
|
||||||
|
duty = session.get(Duty, duty_id)
|
||||||
|
if duty is None:
|
||||||
|
return None
|
||||||
|
duty.user_id = new_user_id
|
||||||
|
if commit:
|
||||||
|
session.commit()
|
||||||
|
session.refresh(duty)
|
||||||
|
else:
|
||||||
|
session.flush()
|
||||||
|
return duty
|
||||||
|
|
||||||
|
|
||||||
|
def get_users_for_admin(session: Session) -> list[User]:
|
||||||
|
"""Return all users ordered by full_name for admin dropdown (id, full_name, username).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
session: DB session.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of User instances ordered by full_name.
|
||||||
|
"""
|
||||||
|
return session.query(User).order_by(User.full_name).all()
|
||||||
|
|
||||||
|
|
||||||
def get_duties(
|
def get_duties(
|
||||||
session: Session,
|
session: Session,
|
||||||
from_date: str,
|
from_date: str,
|
||||||
|
|||||||
@@ -69,6 +69,21 @@ class DutyWithUser(DutyInDb):
|
|||||||
model_config = ConfigDict(from_attributes=True)
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
|
||||||
|
class UserForAdmin(BaseModel):
|
||||||
|
"""User summary for admin dropdown: id, full_name, username, role_id."""
|
||||||
|
|
||||||
|
id: int
|
||||||
|
full_name: str
|
||||||
|
username: str | None = None
|
||||||
|
role_id: int | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class AdminDutyReassignBody(BaseModel):
|
||||||
|
"""Request body for PATCH /api/admin/duties/:id — reassign duty to another user."""
|
||||||
|
|
||||||
|
user_id: int
|
||||||
|
|
||||||
|
|
||||||
class CalendarEvent(BaseModel):
|
class CalendarEvent(BaseModel):
|
||||||
"""External calendar event (e.g. holiday) for a single day."""
|
"""External calendar event (e.g. holiday) for a single day."""
|
||||||
|
|
||||||
|
|||||||
@@ -88,6 +88,7 @@ MESSAGES: dict[str, dict[str, str]] = {
|
|||||||
),
|
),
|
||||||
"api.auth_invalid": "Invalid auth data",
|
"api.auth_invalid": "Invalid auth data",
|
||||||
"api.access_denied": "Access denied",
|
"api.access_denied": "Access denied",
|
||||||
|
"api.bad_request": "Bad request",
|
||||||
"dates.bad_format": "Parameters from and to must be in YYYY-MM-DD format",
|
"dates.bad_format": "Parameters from and to must be in YYYY-MM-DD format",
|
||||||
"dates.from_after_to": "from date must not be after to",
|
"dates.from_after_to": "from date must not be after to",
|
||||||
"dates.range_too_large": "Date range is too large. Request a shorter period.",
|
"dates.range_too_large": "Date range is too large. Request a shorter period.",
|
||||||
@@ -98,6 +99,9 @@ MESSAGES: dict[str, dict[str, str]] = {
|
|||||||
"current_duty.shift": "Shift",
|
"current_duty.shift": "Shift",
|
||||||
"current_duty.remaining": "Remaining: {hours}h {minutes}min",
|
"current_duty.remaining": "Remaining: {hours}h {minutes}min",
|
||||||
"current_duty.back": "Back to calendar",
|
"current_duty.back": "Back to calendar",
|
||||||
|
"admin.duty_not_found": "Duty not found",
|
||||||
|
"admin.user_not_found": "User not found",
|
||||||
|
"admin.reassign_success": "Duty reassigned successfully",
|
||||||
},
|
},
|
||||||
"ru": {
|
"ru": {
|
||||||
"start.greeting": "Привет! Я бот календаря дежурств. Используй /help для списка команд.",
|
"start.greeting": "Привет! Я бот календаря дежурств. Используй /help для списка команд.",
|
||||||
@@ -174,6 +178,7 @@ MESSAGES: dict[str, dict[str, str]] = {
|
|||||||
"из которого открыт календарь (тот же бот, что в меню).",
|
"из которого открыт календарь (тот же бот, что в меню).",
|
||||||
"api.auth_invalid": "Неверные данные авторизации",
|
"api.auth_invalid": "Неверные данные авторизации",
|
||||||
"api.access_denied": "Доступ запрещён",
|
"api.access_denied": "Доступ запрещён",
|
||||||
|
"api.bad_request": "Неверный запрос",
|
||||||
"dates.bad_format": "Параметры from и to должны быть в формате YYYY-MM-DD",
|
"dates.bad_format": "Параметры from и to должны быть в формате YYYY-MM-DD",
|
||||||
"dates.from_after_to": "Дата from не должна быть позже to",
|
"dates.from_after_to": "Дата from не должна быть позже to",
|
||||||
"dates.range_too_large": "Диапазон дат слишком большой. Запросите более короткий период.",
|
"dates.range_too_large": "Диапазон дат слишком большой. Запросите более короткий период.",
|
||||||
@@ -184,5 +189,8 @@ MESSAGES: dict[str, dict[str, str]] = {
|
|||||||
"current_duty.shift": "Смена",
|
"current_duty.shift": "Смена",
|
||||||
"current_duty.remaining": "Осталось: {hours}ч {minutes}мин",
|
"current_duty.remaining": "Осталось: {hours}ч {minutes}мин",
|
||||||
"current_duty.back": "Назад к календарю",
|
"current_duty.back": "Назад к календарю",
|
||||||
|
"admin.duty_not_found": "Дежурство не найдено",
|
||||||
|
"admin.user_not_found": "Пользователь не найден",
|
||||||
|
"admin.reassign_success": "Дежурство успешно переназначено",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "duty-teller"
|
name = "duty-teller"
|
||||||
version = "2.0.4"
|
version = "2.1.3"
|
||||||
description = "Telegram bot for team duty shift calendar and group reminder"
|
description = "Telegram bot for team duty shift calendar and group reminder"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.11"
|
requires-python = ">=3.11"
|
||||||
|
|||||||
396
tests/test_admin_api.py
Normal file
396
tests/test_admin_api.py
Normal file
@@ -0,0 +1,396 @@
|
|||||||
|
"""Tests for admin API: GET /api/admin/me, GET /api/admin/users, PATCH /api/admin/duties/:id."""
|
||||||
|
|
||||||
|
from unittest.mock import ANY, MagicMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from duty_teller.api.app import app
|
||||||
|
from duty_teller.api.dependencies import get_db_session
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def client():
|
||||||
|
return TestClient(app)
|
||||||
|
|
||||||
|
|
||||||
|
def _override_get_db_session(mock_session: MagicMock):
|
||||||
|
"""Dependency override that returns mock_session (no real DB). Used as get_db_session override."""
|
||||||
|
|
||||||
|
def _override() -> Session:
|
||||||
|
return mock_session
|
||||||
|
|
||||||
|
return _override
|
||||||
|
|
||||||
|
|
||||||
|
# --- GET /api/admin/me ---
|
||||||
|
|
||||||
|
|
||||||
|
@patch("duty_teller.api.dependencies.config.MINI_APP_SKIP_AUTH", True)
|
||||||
|
def test_admin_me_skip_auth_returns_is_admin_false(client):
|
||||||
|
"""With MINI_APP_SKIP_AUTH, GET /api/admin/me returns is_admin: false (no real user)."""
|
||||||
|
# Override get_db_session so the endpoint does not open the real DB (CI has no data/ dir).
|
||||||
|
mock_session = MagicMock()
|
||||||
|
mock_session.query.return_value.filter.return_value.first.return_value = None
|
||||||
|
app.dependency_overrides[get_db_session] = _override_get_db_session(mock_session)
|
||||||
|
try:
|
||||||
|
r = client.get("/api/admin/me")
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert r.json() == {"is_admin": False}
|
||||||
|
finally:
|
||||||
|
app.dependency_overrides.pop(get_db_session, None)
|
||||||
|
|
||||||
|
|
||||||
|
@patch("duty_teller.api.app.is_admin_for_telegram_user")
|
||||||
|
@patch("duty_teller.api.dependencies.can_access_miniapp_for_telegram_user")
|
||||||
|
@patch("duty_teller.api.dependencies.get_user_by_telegram_id")
|
||||||
|
@patch("duty_teller.api.dependencies.validate_init_data_with_reason")
|
||||||
|
@patch("duty_teller.api.dependencies.config.MINI_APP_SKIP_AUTH", False)
|
||||||
|
def test_admin_me_returns_is_admin_true_when_admin(
|
||||||
|
mock_validate, mock_get_user, mock_can_access, mock_is_admin, client
|
||||||
|
):
|
||||||
|
"""When user is admin, GET /api/admin/me returns is_admin: true."""
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
mock_validate.return_value = (100, "user", "ok", "en")
|
||||||
|
mock_get_user.return_value = SimpleNamespace(full_name="Admin", username="admin")
|
||||||
|
mock_can_access.return_value = True
|
||||||
|
mock_is_admin.return_value = True
|
||||||
|
r = client.get(
|
||||||
|
"/api/admin/me",
|
||||||
|
headers={
|
||||||
|
"X-Telegram-Init-Data": "auth_date=1&user=%7B%22id%22%3A100%7D&hash=x"
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert r.json() == {"is_admin": True}
|
||||||
|
|
||||||
|
|
||||||
|
@patch("duty_teller.api.app.is_admin_for_telegram_user")
|
||||||
|
@patch("duty_teller.api.dependencies.can_access_miniapp_for_telegram_user")
|
||||||
|
@patch("duty_teller.api.dependencies.get_user_by_telegram_id")
|
||||||
|
@patch("duty_teller.api.dependencies.validate_init_data_with_reason")
|
||||||
|
@patch("duty_teller.api.dependencies.config.MINI_APP_SKIP_AUTH", False)
|
||||||
|
def test_admin_me_returns_is_admin_false_when_not_admin(
|
||||||
|
mock_validate, mock_get_user, mock_can_access, mock_is_admin, client
|
||||||
|
):
|
||||||
|
"""When user is not admin, GET /api/admin/me returns is_admin: false."""
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
mock_validate.return_value = (200, "user", "ok", "en")
|
||||||
|
mock_get_user.return_value = SimpleNamespace(full_name="User", username="user")
|
||||||
|
mock_can_access.return_value = True
|
||||||
|
mock_is_admin.return_value = False
|
||||||
|
r = client.get(
|
||||||
|
"/api/admin/me",
|
||||||
|
headers={
|
||||||
|
"X-Telegram-Init-Data": "auth_date=1&user=%7B%22id%22%3A200%7D&hash=x"
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert r.json() == {"is_admin": False}
|
||||||
|
|
||||||
|
|
||||||
|
# --- GET /api/admin/users ---
|
||||||
|
|
||||||
|
|
||||||
|
@patch("duty_teller.api.dependencies.config.MINI_APP_SKIP_AUTH", False)
|
||||||
|
def test_admin_users_403_without_init_data(client):
|
||||||
|
"""GET /api/admin/users without initData returns 403."""
|
||||||
|
r = client.get("/api/admin/users")
|
||||||
|
assert r.status_code == 403
|
||||||
|
|
||||||
|
|
||||||
|
@patch("duty_teller.api.dependencies.is_admin_for_telegram_user")
|
||||||
|
@patch("duty_teller.api.dependencies.can_access_miniapp_for_telegram_user")
|
||||||
|
@patch("duty_teller.api.dependencies.get_user_by_telegram_id")
|
||||||
|
@patch("duty_teller.api.dependencies.validate_init_data_with_reason")
|
||||||
|
@patch("duty_teller.api.dependencies.config.MINI_APP_SKIP_AUTH", False)
|
||||||
|
def test_admin_users_403_when_not_admin(
|
||||||
|
mock_validate, mock_get_user, mock_can_access, mock_is_admin, client
|
||||||
|
):
|
||||||
|
"""GET /api/admin/users when not admin returns 403 with admin_only message."""
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
mock_validate.return_value = (100, "u", "ok", "en")
|
||||||
|
mock_get_user.return_value = SimpleNamespace(full_name="U", username="u")
|
||||||
|
mock_can_access.return_value = True
|
||||||
|
mock_is_admin.return_value = False # not admin
|
||||||
|
r = client.get(
|
||||||
|
"/api/admin/users",
|
||||||
|
headers={
|
||||||
|
"X-Telegram-Init-Data": "auth_date=1&user=%7B%22id%22%3A100%7D&hash=x"
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert r.status_code == 403
|
||||||
|
detail = r.json()["detail"]
|
||||||
|
assert "admin" in detail.lower() or "администратор" in detail or "only" in detail
|
||||||
|
|
||||||
|
|
||||||
|
@patch("duty_teller.api.app.get_users_for_admin")
|
||||||
|
@patch("duty_teller.api.dependencies.is_admin_for_telegram_user")
|
||||||
|
@patch("duty_teller.api.dependencies.can_access_miniapp_for_telegram_user")
|
||||||
|
@patch("duty_teller.api.dependencies.get_user_by_telegram_id")
|
||||||
|
@patch("duty_teller.api.dependencies.validate_init_data_with_reason")
|
||||||
|
@patch("duty_teller.api.dependencies.config.MINI_APP_SKIP_AUTH", False)
|
||||||
|
def test_admin_users_200_returns_list(
|
||||||
|
mock_validate, mock_get_user, mock_can_access, mock_is_admin, mock_get_users, client
|
||||||
|
):
|
||||||
|
"""GET /api/admin/users returns list of id, full_name, username, role_id."""
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
mock_validate.return_value = (1, "admin", "ok", "en")
|
||||||
|
mock_get_user.return_value = SimpleNamespace(full_name="Admin", username="admin")
|
||||||
|
mock_can_access.return_value = True
|
||||||
|
mock_is_admin.return_value = True
|
||||||
|
mock_get_users.return_value = [
|
||||||
|
SimpleNamespace(id=1, full_name="Alice", username="alice", role_id=1),
|
||||||
|
SimpleNamespace(id=2, full_name="Bob", username=None, role_id=2),
|
||||||
|
]
|
||||||
|
r = client.get(
|
||||||
|
"/api/admin/users",
|
||||||
|
headers={"X-Telegram-Init-Data": "auth_date=1&user=%7B%22id%22%3A1%7D&hash=x"},
|
||||||
|
)
|
||||||
|
assert r.status_code == 200
|
||||||
|
data = r.json()
|
||||||
|
assert len(data) == 2
|
||||||
|
assert data[0]["id"] == 1
|
||||||
|
assert data[0]["full_name"] == "Alice"
|
||||||
|
assert data[0]["username"] == "alice"
|
||||||
|
assert data[0]["role_id"] == 1
|
||||||
|
assert data[1]["id"] == 2
|
||||||
|
assert data[1]["full_name"] == "Bob"
|
||||||
|
assert data[1]["username"] is None
|
||||||
|
assert data[1]["role_id"] == 2
|
||||||
|
|
||||||
|
|
||||||
|
# --- PATCH /api/admin/duties/:id ---
|
||||||
|
|
||||||
|
|
||||||
|
@patch("duty_teller.api.dependencies.config.MINI_APP_SKIP_AUTH", False)
|
||||||
|
def test_admin_reassign_403_without_auth(client):
|
||||||
|
"""PATCH /api/admin/duties/1 without auth returns 403."""
|
||||||
|
r = client.patch(
|
||||||
|
"/api/admin/duties/1",
|
||||||
|
json={"user_id": 2},
|
||||||
|
)
|
||||||
|
assert r.status_code == 403
|
||||||
|
|
||||||
|
|
||||||
|
@patch("duty_teller.api.app.require_admin_telegram_id")
|
||||||
|
@patch("duty_teller.api.dependencies.config.MINI_APP_SKIP_AUTH", False)
|
||||||
|
def test_admin_reassign_403_when_not_admin(mock_require_admin, client):
|
||||||
|
"""PATCH /api/admin/duties/1 when not admin returns 403."""
|
||||||
|
from fastapi import HTTPException
|
||||||
|
|
||||||
|
from duty_teller.i18n import t
|
||||||
|
|
||||||
|
mock_require_admin.side_effect = HTTPException(
|
||||||
|
status_code=403, detail=t("en", "import.admin_only")
|
||||||
|
)
|
||||||
|
r = client.patch(
|
||||||
|
"/api/admin/duties/1",
|
||||||
|
json={"user_id": 2},
|
||||||
|
headers={"X-Telegram-Init-Data": "x"},
|
||||||
|
)
|
||||||
|
assert r.status_code == 403
|
||||||
|
|
||||||
|
|
||||||
|
@patch("duty_teller.api.app.invalidate_duty_related_caches")
|
||||||
|
@patch("duty_teller.api.app.update_duty_user")
|
||||||
|
@patch("duty_teller.api.app.get_duty_by_id")
|
||||||
|
@patch("duty_teller.api.dependencies.is_admin_for_telegram_user")
|
||||||
|
@patch("duty_teller.api.dependencies.can_access_miniapp_for_telegram_user")
|
||||||
|
@patch("duty_teller.api.dependencies.get_user_by_telegram_id")
|
||||||
|
@patch("duty_teller.api.dependencies.validate_init_data_with_reason")
|
||||||
|
@patch("duty_teller.api.dependencies.config.MINI_APP_SKIP_AUTH", False)
|
||||||
|
def test_admin_reassign_404_when_duty_missing(
|
||||||
|
mock_validate,
|
||||||
|
mock_get_user,
|
||||||
|
mock_can_access,
|
||||||
|
mock_is_admin,
|
||||||
|
mock_get_duty,
|
||||||
|
mock_update,
|
||||||
|
mock_invalidate,
|
||||||
|
client,
|
||||||
|
):
|
||||||
|
"""PATCH /api/admin/duties/999 returns 404 when duty not found."""
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
mock_validate.return_value = (1, "admin", "ok", "en")
|
||||||
|
mock_get_user.return_value = SimpleNamespace(full_name="Admin", username="admin")
|
||||||
|
mock_can_access.return_value = True
|
||||||
|
mock_is_admin.return_value = True
|
||||||
|
mock_get_duty.return_value = None
|
||||||
|
r = client.patch(
|
||||||
|
"/api/admin/duties/999",
|
||||||
|
json={"user_id": 2},
|
||||||
|
headers={"X-Telegram-Init-Data": "auth_date=1&user=%7B%22id%22%3A1%7D&hash=x"},
|
||||||
|
)
|
||||||
|
assert r.status_code == 404
|
||||||
|
mock_update.assert_not_called()
|
||||||
|
mock_invalidate.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
@patch("duty_teller.api.app.invalidate_duty_related_caches")
|
||||||
|
@patch("duty_teller.api.app.update_duty_user")
|
||||||
|
@patch("duty_teller.api.app.get_duty_by_id")
|
||||||
|
@patch("duty_teller.api.dependencies.is_admin_for_telegram_user")
|
||||||
|
@patch("duty_teller.api.dependencies.can_access_miniapp_for_telegram_user")
|
||||||
|
@patch("duty_teller.api.dependencies.get_user_by_telegram_id")
|
||||||
|
@patch("duty_teller.api.dependencies.validate_init_data_with_reason")
|
||||||
|
@patch("duty_teller.api.dependencies.config.MINI_APP_SKIP_AUTH", False)
|
||||||
|
def test_admin_reassign_400_when_user_not_found(
|
||||||
|
mock_validate,
|
||||||
|
mock_get_user,
|
||||||
|
mock_can_access,
|
||||||
|
mock_is_admin,
|
||||||
|
mock_get_duty,
|
||||||
|
mock_update,
|
||||||
|
mock_invalidate,
|
||||||
|
client,
|
||||||
|
):
|
||||||
|
"""PATCH /api/admin/duties/1 returns 400 when user_id does not exist."""
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
mock_validate.return_value = (1, "admin", "ok", "en")
|
||||||
|
mock_get_user.return_value = SimpleNamespace(full_name="Admin", username="admin")
|
||||||
|
mock_can_access.return_value = True
|
||||||
|
mock_is_admin.return_value = True
|
||||||
|
mock_get_duty.return_value = SimpleNamespace(
|
||||||
|
id=1, user_id=10, start_at="2026-01-15T09:00:00Z", end_at="2026-01-15T18:00:00Z"
|
||||||
|
)
|
||||||
|
mock_session = MagicMock()
|
||||||
|
mock_session.get.return_value = None # User not found
|
||||||
|
app.dependency_overrides[get_db_session] = _override_get_db_session(mock_session)
|
||||||
|
try:
|
||||||
|
r = client.patch(
|
||||||
|
"/api/admin/duties/1",
|
||||||
|
json={"user_id": 999},
|
||||||
|
headers={
|
||||||
|
"X-Telegram-Init-Data": "auth_date=1&user=%7B%22id%22%3A1%7D&hash=x"
|
||||||
|
},
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
app.dependency_overrides.pop(get_db_session, None)
|
||||||
|
assert r.status_code == 400
|
||||||
|
mock_update.assert_not_called()
|
||||||
|
mock_invalidate.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
@patch("duty_teller.api.app.invalidate_duty_related_caches")
|
||||||
|
@patch("duty_teller.api.app.update_duty_user")
|
||||||
|
@patch("duty_teller.api.app.get_duty_by_id")
|
||||||
|
@patch("duty_teller.api.dependencies.is_admin_for_telegram_user")
|
||||||
|
@patch("duty_teller.api.dependencies.can_access_miniapp_for_telegram_user")
|
||||||
|
@patch("duty_teller.api.dependencies.get_user_by_telegram_id")
|
||||||
|
@patch("duty_teller.api.dependencies.validate_init_data_with_reason")
|
||||||
|
@patch("duty_teller.api.dependencies.config.MINI_APP_SKIP_AUTH", False)
|
||||||
|
def test_admin_reassign_200_updates_and_invalidates(
|
||||||
|
mock_validate,
|
||||||
|
mock_get_user,
|
||||||
|
mock_can_access,
|
||||||
|
mock_is_admin,
|
||||||
|
mock_get_duty,
|
||||||
|
mock_update_duty_user,
|
||||||
|
mock_invalidate,
|
||||||
|
client,
|
||||||
|
):
|
||||||
|
"""PATCH /api/admin/duties/1 with valid body returns 200 and invalidates caches."""
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
mock_validate.return_value = (1, "admin", "ok", "en")
|
||||||
|
mock_get_user.return_value = SimpleNamespace(full_name="Admin", username="admin")
|
||||||
|
mock_can_access.return_value = True
|
||||||
|
mock_is_admin.return_value = True
|
||||||
|
duty = SimpleNamespace(
|
||||||
|
id=1,
|
||||||
|
user_id=10,
|
||||||
|
start_at="2026-01-15T09:00:00Z",
|
||||||
|
end_at="2026-01-15T18:00:00Z",
|
||||||
|
)
|
||||||
|
updated_duty = SimpleNamespace(
|
||||||
|
id=1,
|
||||||
|
user_id=2,
|
||||||
|
start_at="2026-01-15T09:00:00Z",
|
||||||
|
end_at="2026-01-15T18:00:00Z",
|
||||||
|
)
|
||||||
|
mock_get_duty.return_value = duty
|
||||||
|
mock_update_duty_user.return_value = updated_duty
|
||||||
|
mock_session = MagicMock()
|
||||||
|
mock_session.get.return_value = SimpleNamespace(id=2) # User exists
|
||||||
|
app.dependency_overrides[get_db_session] = _override_get_db_session(mock_session)
|
||||||
|
try:
|
||||||
|
r = client.patch(
|
||||||
|
"/api/admin/duties/1",
|
||||||
|
json={"user_id": 2},
|
||||||
|
headers={
|
||||||
|
"X-Telegram-Init-Data": "auth_date=1&user=%7B%22id%22%3A1%7D&hash=x"
|
||||||
|
},
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
app.dependency_overrides.pop(get_db_session, None)
|
||||||
|
assert r.status_code == 200
|
||||||
|
data = r.json()
|
||||||
|
assert data["id"] == 1
|
||||||
|
assert data["user_id"] == 2
|
||||||
|
assert data["start_at"] == "2026-01-15T09:00:00Z"
|
||||||
|
assert data["end_at"] == "2026-01-15T18:00:00Z"
|
||||||
|
mock_update_duty_user.assert_called_once_with(ANY, 1, 2, commit=True)
|
||||||
|
mock_invalidate.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
|
@patch("duty_teller.api.dependencies.config.MINI_APP_SKIP_AUTH", True)
|
||||||
|
def test_admin_users_403_when_skip_auth(client):
|
||||||
|
"""GET /api/admin/users with MINI_APP_SKIP_AUTH returns 403 (admin routes disabled)."""
|
||||||
|
r = client.get("/api/admin/users")
|
||||||
|
assert r.status_code == 403
|
||||||
|
detail = r.json()["detail"]
|
||||||
|
assert "admin" in detail.lower() or "администратор" in detail
|
||||||
|
|
||||||
|
|
||||||
|
@patch("duty_teller.api.dependencies.config.MINI_APP_SKIP_AUTH", True)
|
||||||
|
def test_admin_reassign_403_when_skip_auth(client):
|
||||||
|
"""PATCH /api/admin/duties/1 with MINI_APP_SKIP_AUTH returns 403."""
|
||||||
|
r = client.patch(
|
||||||
|
"/api/admin/duties/1",
|
||||||
|
json={"user_id": 2},
|
||||||
|
)
|
||||||
|
assert r.status_code == 403
|
||||||
|
|
||||||
|
|
||||||
|
@patch("duty_teller.api.app.get_duty_by_id")
|
||||||
|
@patch("duty_teller.api.dependencies.is_admin_for_telegram_user")
|
||||||
|
@patch("duty_teller.api.dependencies.can_access_miniapp_for_telegram_user")
|
||||||
|
@patch("duty_teller.api.dependencies.get_user_by_telegram_id")
|
||||||
|
@patch("duty_teller.api.dependencies.validate_init_data_with_reason")
|
||||||
|
@patch("duty_teller.api.dependencies.config.MINI_APP_SKIP_AUTH", False)
|
||||||
|
def test_admin_reassign_404_uses_accept_language_for_detail(
|
||||||
|
mock_validate,
|
||||||
|
mock_get_user,
|
||||||
|
mock_can_access,
|
||||||
|
mock_is_admin,
|
||||||
|
mock_get_duty,
|
||||||
|
client,
|
||||||
|
):
|
||||||
|
"""PATCH with Accept-Language: ru returns 404 detail in Russian."""
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
mock_validate.return_value = (1, "admin", "ok", "en")
|
||||||
|
mock_get_user.return_value = SimpleNamespace(full_name="Admin", username="admin")
|
||||||
|
mock_can_access.return_value = True
|
||||||
|
mock_is_admin.return_value = True
|
||||||
|
mock_get_duty.return_value = None
|
||||||
|
with patch("duty_teller.api.app._lang_from_accept_language") as mock_lang:
|
||||||
|
mock_lang.return_value = "ru"
|
||||||
|
r = client.patch(
|
||||||
|
"/api/admin/duties/999",
|
||||||
|
json={"user_id": 2},
|
||||||
|
headers={
|
||||||
|
"X-Telegram-Init-Data": "auth_date=1&user=%7B%22id%22%3A1%7D&hash=x",
|
||||||
|
"Accept-Language": "ru",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert r.status_code == 404
|
||||||
|
assert r.json()["detail"] == "Дежурство не найдено"
|
||||||
@@ -9,9 +9,12 @@ from duty_teller.db.repository import (
|
|||||||
delete_duties_in_range,
|
delete_duties_in_range,
|
||||||
get_duties,
|
get_duties,
|
||||||
get_duties_for_user,
|
get_duties_for_user,
|
||||||
|
get_duty_by_id,
|
||||||
get_or_create_user,
|
get_or_create_user,
|
||||||
get_or_create_user_by_full_name,
|
get_or_create_user_by_full_name,
|
||||||
|
get_users_for_admin,
|
||||||
insert_duty,
|
insert_duty,
|
||||||
|
update_duty_user,
|
||||||
update_user_display_name,
|
update_user_display_name,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -217,6 +220,52 @@ def test_get_or_create_user_keeps_name_when_flag_true_updates_username(session):
|
|||||||
assert u2.username == "new_username"
|
assert u2.username == "new_username"
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_duty_by_id_returns_duty(session, user_a):
|
||||||
|
"""get_duty_by_id returns the duty when it exists."""
|
||||||
|
duty = insert_duty(
|
||||||
|
session, user_a.id, "2026-02-01T09:00:00Z", "2026-02-01T18:00:00Z"
|
||||||
|
)
|
||||||
|
found = get_duty_by_id(session, duty.id)
|
||||||
|
assert found is not None
|
||||||
|
assert found.id == duty.id
|
||||||
|
assert found.user_id == user_a.id
|
||||||
|
assert found.start_at == "2026-02-01T09:00:00Z"
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_duty_by_id_returns_none_when_missing(session):
|
||||||
|
"""get_duty_by_id returns None for non-existent id."""
|
||||||
|
assert get_duty_by_id(session, 99999) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_update_duty_user_changes_user(session, user_a):
|
||||||
|
"""update_duty_user updates user_id and returns the duty."""
|
||||||
|
user_b = get_or_create_user_by_full_name(session, "User B")
|
||||||
|
duty = insert_duty(
|
||||||
|
session, user_a.id, "2026-02-01T09:00:00Z", "2026-02-01T18:00:00Z"
|
||||||
|
)
|
||||||
|
updated = update_duty_user(session, duty.id, user_b.id, commit=True)
|
||||||
|
assert updated is not None
|
||||||
|
assert updated.id == duty.id
|
||||||
|
assert updated.user_id == user_b.id
|
||||||
|
session.refresh(duty)
|
||||||
|
assert duty.user_id == user_b.id
|
||||||
|
|
||||||
|
|
||||||
|
def test_update_duty_user_returns_none_when_duty_missing(session, user_a):
|
||||||
|
"""update_duty_user returns None when duty does not exist."""
|
||||||
|
assert update_duty_user(session, 99999, user_a.id, commit=True) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_users_for_admin_returns_all_ordered_by_full_name(session, user_a):
|
||||||
|
"""get_users_for_admin returns all users ordered by full_name."""
|
||||||
|
get_or_create_user_by_full_name(session, "Alice")
|
||||||
|
get_or_create_user_by_full_name(session, "Борис")
|
||||||
|
users = get_users_for_admin(session)
|
||||||
|
assert len(users) >= 3
|
||||||
|
full_names = [u.full_name for u in users]
|
||||||
|
assert full_names == sorted(full_names)
|
||||||
|
|
||||||
|
|
||||||
def test_update_user_display_name_sets_flag_then_get_or_create_user_keeps_name(session):
|
def test_update_user_display_name_sets_flag_then_get_or_create_user_keeps_name(session):
|
||||||
"""update_user_display_name sets name and flag; get_or_create_user then does not overwrite name."""
|
"""update_user_display_name sets name and flag; get_or_create_user then does not overwrite name."""
|
||||||
get_or_create_user(
|
get_or_create_user(
|
||||||
|
|||||||
244
webapp-next/src/app/admin/page.test.tsx
Normal file
244
webapp-next/src/app/admin/page.test.tsx
Normal file
@@ -0,0 +1,244 @@
|
|||||||
|
/**
|
||||||
|
* Component tests for admin page: render, access denied, duty list, reassign sheet.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||||
|
import { render, screen, waitFor, fireEvent } from "@testing-library/react";
|
||||||
|
import AdminPage from "./page";
|
||||||
|
import { resetAppStore } from "@/test/test-utils";
|
||||||
|
import { useAppStore } from "@/store/app-store";
|
||||||
|
|
||||||
|
vi.mock("@/hooks/use-telegram-auth", () => ({
|
||||||
|
useTelegramAuth: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("next/navigation", () => ({
|
||||||
|
useRouter: vi.fn(() => ({ push: vi.fn() })),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const mockUseTelegramAuth = vi.mocked(
|
||||||
|
await import("@/hooks/use-telegram-auth").then((m) => m.useTelegramAuth)
|
||||||
|
);
|
||||||
|
|
||||||
|
const sampleUsers = [
|
||||||
|
{ id: 1, full_name: "Alice", username: "alice", role_id: 1 },
|
||||||
|
{ id: 2, full_name: "Bob", username: null, role_id: 2 },
|
||||||
|
];
|
||||||
|
|
||||||
|
const sampleDuties: Array<{
|
||||||
|
id: number;
|
||||||
|
user_id: number;
|
||||||
|
start_at: string;
|
||||||
|
end_at: string;
|
||||||
|
full_name: string;
|
||||||
|
event_type: string;
|
||||||
|
phone: string | null;
|
||||||
|
username: string | null;
|
||||||
|
}> = [
|
||||||
|
{
|
||||||
|
id: 10,
|
||||||
|
user_id: 1,
|
||||||
|
start_at: "2030-01-15T09:00:00Z",
|
||||||
|
end_at: "2030-01-15T18:00:00Z",
|
||||||
|
full_name: "Alice",
|
||||||
|
event_type: "duty",
|
||||||
|
phone: null,
|
||||||
|
username: "alice",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
function mockFetchForAdmin(
|
||||||
|
users: Array<{ id: number; full_name: string; username: string | null; role_id?: number }> = sampleUsers,
|
||||||
|
duties = sampleDuties,
|
||||||
|
options?: { adminMe?: { is_admin: boolean } }
|
||||||
|
) {
|
||||||
|
const adminMe = options?.adminMe ?? { is_admin: true };
|
||||||
|
vi.stubGlobal(
|
||||||
|
"fetch",
|
||||||
|
vi.fn((url: string, _init?: RequestInit) => {
|
||||||
|
if (url.includes("/api/admin/me")) {
|
||||||
|
return Promise.resolve({
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
json: () => Promise.resolve(adminMe),
|
||||||
|
} as Response);
|
||||||
|
}
|
||||||
|
if (url.includes("/api/admin/users")) {
|
||||||
|
return Promise.resolve({
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
json: () => Promise.resolve(users),
|
||||||
|
} as Response);
|
||||||
|
}
|
||||||
|
if (url.includes("/api/duties")) {
|
||||||
|
return Promise.resolve({
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
json: () => Promise.resolve(duties),
|
||||||
|
} as Response);
|
||||||
|
}
|
||||||
|
return Promise.reject(new Error(`Unexpected URL: ${url}`));
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("AdminPage", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
resetAppStore();
|
||||||
|
useAppStore.getState().setCurrentMonth(new Date(2025, 0, 1));
|
||||||
|
mockUseTelegramAuth.mockReturnValue({
|
||||||
|
initDataRaw: "test-init",
|
||||||
|
startParam: undefined,
|
||||||
|
isLocalhost: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows admin title (month/year) when allowed and data loaded", async () => {
|
||||||
|
mockFetchForAdmin();
|
||||||
|
render(<AdminPage />);
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByRole("heading", { name: /admin|админка/i })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows access denied when fetchAdminMe returns is_admin false", async () => {
|
||||||
|
mockFetchForAdmin(sampleUsers, [], { adminMe: { is_admin: false } });
|
||||||
|
render(<AdminPage />);
|
||||||
|
await waitFor(
|
||||||
|
() => {
|
||||||
|
expect(
|
||||||
|
screen.getByText(/Access only for administrators|Доступ только для администраторов/i)
|
||||||
|
).toBeInTheDocument();
|
||||||
|
},
|
||||||
|
{ timeout: 2000 }
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows access denied message when GET /api/admin/users returns 403", async () => {
|
||||||
|
vi.stubGlobal(
|
||||||
|
"fetch",
|
||||||
|
vi.fn((url: string) => {
|
||||||
|
if (url.includes("/api/admin/me")) {
|
||||||
|
return Promise.resolve({
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
json: () => Promise.resolve({ is_admin: true }),
|
||||||
|
} as Response);
|
||||||
|
}
|
||||||
|
if (url.includes("/api/admin/users")) {
|
||||||
|
return Promise.resolve({
|
||||||
|
ok: false,
|
||||||
|
status: 403,
|
||||||
|
json: () => Promise.resolve({ detail: "Admin only" }),
|
||||||
|
} as Response);
|
||||||
|
}
|
||||||
|
if (url.includes("/api/duties")) {
|
||||||
|
return Promise.resolve({
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
json: () => Promise.resolve([]),
|
||||||
|
} as Response);
|
||||||
|
}
|
||||||
|
return Promise.reject(new Error(`Unexpected: ${url}`));
|
||||||
|
})
|
||||||
|
);
|
||||||
|
render(<AdminPage />);
|
||||||
|
await waitFor(
|
||||||
|
() => {
|
||||||
|
expect(
|
||||||
|
screen.getByText(/Admin only|Access only for administrators|Доступ только для администраторов/i)
|
||||||
|
).toBeInTheDocument();
|
||||||
|
},
|
||||||
|
{ timeout: 2000 }
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows duty row and opens reassign sheet on click", async () => {
|
||||||
|
mockFetchForAdmin();
|
||||||
|
render(<AdminPage />);
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText("Alice")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
const dutyButton = screen.getByRole("button", { name: /Alice/ });
|
||||||
|
fireEvent.click(dutyButton);
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(
|
||||||
|
screen.getByRole("radiogroup", { name: /select user|выберите пользователя/i })
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
expect(screen.getByRole("button", { name: /save|сохранить/i })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows no users message in sheet when usersForSelect is empty", async () => {
|
||||||
|
mockFetchForAdmin([], sampleDuties);
|
||||||
|
render(<AdminPage />);
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText("Alice")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /Alice/ }));
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(
|
||||||
|
screen.getByText(/No users available for assignment|Нет пользователей для назначения/i)
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows success message after successful reassign", async () => {
|
||||||
|
mockFetchForAdmin();
|
||||||
|
vi.stubGlobal(
|
||||||
|
"fetch",
|
||||||
|
vi.fn((url: string, init?: RequestInit) => {
|
||||||
|
if (url.includes("/api/admin/me")) {
|
||||||
|
return Promise.resolve({
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
json: () => Promise.resolve({ is_admin: true }),
|
||||||
|
} as Response);
|
||||||
|
}
|
||||||
|
if (url.includes("/api/admin/users")) {
|
||||||
|
return Promise.resolve({
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
json: () => Promise.resolve(sampleUsers),
|
||||||
|
} as Response);
|
||||||
|
}
|
||||||
|
if (url.includes("/api/duties")) {
|
||||||
|
return Promise.resolve({
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
json: () => Promise.resolve(sampleDuties),
|
||||||
|
} as Response);
|
||||||
|
}
|
||||||
|
if (url.includes("/api/admin/duties/") && init?.method === "PATCH") {
|
||||||
|
return Promise.resolve({
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
json: () =>
|
||||||
|
Promise.resolve({
|
||||||
|
id: 10,
|
||||||
|
user_id: 2,
|
||||||
|
start_at: "2030-01-15T09:00:00Z",
|
||||||
|
end_at: "2030-01-15T18:00:00Z",
|
||||||
|
}),
|
||||||
|
} as Response);
|
||||||
|
}
|
||||||
|
return Promise.reject(new Error(`Unexpected URL: ${url}`));
|
||||||
|
})
|
||||||
|
);
|
||||||
|
render(<AdminPage />);
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText("Alice")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /Alice/ }));
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByRole("radiogroup", { name: /select user|выберите пользователя/i })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
fireEvent.click(screen.getByRole("radio", { name: /Bob/ }));
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /save|сохранить/i }));
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(
|
||||||
|
screen.getByText(/Duty reassigned|Дежурство переназначено/i)
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
131
webapp-next/src/app/admin/page.tsx
Normal file
131
webapp-next/src/app/admin/page.tsx
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
/**
|
||||||
|
* Admin page: list duties for the month and reassign duty to another user.
|
||||||
|
* Visible only to admins (link shown on calendar when GET /api/admin/me returns is_admin).
|
||||||
|
* Logic and heavy UI live in components/admin (useAdminPage, AdminDutyList, ReassignSheet).
|
||||||
|
*/
|
||||||
|
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useAdminPage, AdminDutyList, ReassignSheet } from "@/components/admin";
|
||||||
|
import { useTranslation } from "@/i18n/use-translation";
|
||||||
|
import { MonthNavHeader } from "@/components/calendar/MonthNavHeader";
|
||||||
|
import { AccessDeniedScreen } from "@/components/states/AccessDeniedScreen";
|
||||||
|
import { LoadingState } from "@/components/states/LoadingState";
|
||||||
|
import { ErrorState } from "@/components/states/ErrorState";
|
||||||
|
import { MiniAppScreen, MiniAppScreenContent, MiniAppStickyHeader } from "@/components/layout/MiniAppScreen";
|
||||||
|
import { useScreenReady } from "@/hooks/use-screen-ready";
|
||||||
|
|
||||||
|
export default function AdminPage() {
|
||||||
|
const { t, monthName } = useTranslation();
|
||||||
|
const admin = useAdminPage();
|
||||||
|
useScreenReady(true);
|
||||||
|
|
||||||
|
if (!admin.isAllowed) {
|
||||||
|
return (
|
||||||
|
<MiniAppScreen>
|
||||||
|
<MiniAppScreenContent>
|
||||||
|
<AccessDeniedScreen primaryAction="reload" />
|
||||||
|
</MiniAppScreenContent>
|
||||||
|
</MiniAppScreen>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (admin.adminCheckComplete === null) {
|
||||||
|
return (
|
||||||
|
<MiniAppScreen>
|
||||||
|
<MiniAppScreenContent>
|
||||||
|
<div className="py-4 flex flex-col items-center gap-2">
|
||||||
|
<LoadingState />
|
||||||
|
<p className="text-sm text-muted-foreground">{t("admin.loading_users")}</p>
|
||||||
|
</div>
|
||||||
|
</MiniAppScreenContent>
|
||||||
|
</MiniAppScreen>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (admin.adminAccessDenied) {
|
||||||
|
return (
|
||||||
|
<MiniAppScreen>
|
||||||
|
<MiniAppScreenContent>
|
||||||
|
<div className="flex flex-col gap-4 py-6">
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
{admin.adminAccessDeniedDetail ?? t("admin.access_denied")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</MiniAppScreenContent>
|
||||||
|
</MiniAppScreen>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const month = admin.adminMonth.getMonth();
|
||||||
|
const year = admin.adminMonth.getFullYear();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<MiniAppScreen>
|
||||||
|
<MiniAppScreenContent>
|
||||||
|
<MiniAppStickyHeader className="flex flex-col items-center border-b border-border py-3">
|
||||||
|
<MonthNavHeader
|
||||||
|
month={admin.adminMonth}
|
||||||
|
disabled={admin.loading}
|
||||||
|
onPrevMonth={admin.onPrevMonth}
|
||||||
|
onNextMonth={admin.onNextMonth}
|
||||||
|
titleAriaLabel={`${t("admin.title")}, ${monthName(month)} ${year}`}
|
||||||
|
className="w-full px-1"
|
||||||
|
/>
|
||||||
|
</MiniAppStickyHeader>
|
||||||
|
|
||||||
|
{admin.successMessage && (
|
||||||
|
<p className="mt-3 text-sm text-[var(--duty)]" role="status" aria-live="polite">
|
||||||
|
{admin.successMessage}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{admin.loading && (
|
||||||
|
<div className="py-4 flex flex-col items-center gap-2">
|
||||||
|
<LoadingState />
|
||||||
|
<p className="text-sm text-muted-foreground">{t("admin.loading_users")}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{admin.error && !admin.loading && (
|
||||||
|
<ErrorState
|
||||||
|
message={admin.error}
|
||||||
|
onRetry={() => window.location.reload()}
|
||||||
|
className="my-3"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!admin.loading && !admin.error && (
|
||||||
|
<div className="mt-3 flex flex-col gap-2">
|
||||||
|
{admin.visibleGroups.length > 0 && (
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{t("admin.reassign_duty")}: {t("admin.select_user")}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<AdminDutyList
|
||||||
|
groups={admin.visibleGroups}
|
||||||
|
hasMore={admin.hasMore}
|
||||||
|
sentinelRef={admin.sentinelRef}
|
||||||
|
onSelectDuty={admin.openReassign}
|
||||||
|
t={t}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<ReassignSheet
|
||||||
|
open={!admin.sheetExiting && admin.selectedDuty !== null}
|
||||||
|
selectedDuty={admin.selectedDuty}
|
||||||
|
selectedUserId={admin.selectedUserId}
|
||||||
|
setSelectedUserId={admin.setSelectedUserId}
|
||||||
|
users={admin.usersForSelect}
|
||||||
|
saving={admin.saving}
|
||||||
|
reassignErrorKey={admin.reassignErrorKey}
|
||||||
|
onReassign={admin.handleReassign}
|
||||||
|
onRequestClose={admin.requestCloseSheet}
|
||||||
|
onCloseAnimationEnd={admin.closeReassign}
|
||||||
|
t={t}
|
||||||
|
/>
|
||||||
|
</MiniAppScreenContent>
|
||||||
|
</MiniAppScreen>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -5,8 +5,13 @@
|
|||||||
|
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect } from "react";
|
||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
import { getLang, translate } from "@/i18n/messages";
|
import { getLang, translate } from "@/i18n/messages";
|
||||||
|
import { callMiniAppReadyOnce } from "@/lib/telegram-ready";
|
||||||
|
import { THEME_BOOTSTRAP_SCRIPT } from "@/lib/theme-bootstrap-script";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { MiniAppScreen, MiniAppScreenContent } from "@/components/layout/MiniAppScreen";
|
||||||
|
|
||||||
export default function GlobalError({
|
export default function GlobalError({
|
||||||
error,
|
error,
|
||||||
@@ -16,6 +21,11 @@ export default function GlobalError({
|
|||||||
reset: () => void;
|
reset: () => void;
|
||||||
}) {
|
}) {
|
||||||
const lang = getLang();
|
const lang = getLang();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
callMiniAppReadyOnce();
|
||||||
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<html
|
<html
|
||||||
lang={lang === "ru" ? "ru" : "en"}
|
lang={lang === "ru" ? "ru" : "en"}
|
||||||
@@ -23,29 +33,22 @@ export default function GlobalError({
|
|||||||
suppressHydrationWarning
|
suppressHydrationWarning
|
||||||
>
|
>
|
||||||
<head>
|
<head>
|
||||||
{/* Same theme detection as layout: hash / Telegram / prefers-color-scheme → data-theme */}
|
<script dangerouslySetInnerHTML={{ __html: THEME_BOOTSTRAP_SCRIPT }} />
|
||||||
<script
|
|
||||||
dangerouslySetInnerHTML={{
|
|
||||||
__html: `(function(){var scheme='dark';var root=document.documentElement;var hash=typeof location!=='undefined'&&location.hash?location.hash.slice(1):'';if(hash){var params={};hash.split('&').forEach(function(p){var i=p.indexOf('=');if(i===-1)return;var k=decodeURIComponent(p.slice(0,i));var v=p.slice(i+1);params[k]=v;});if(params.tgWebAppColorScheme==='light'||params.tgWebAppColorScheme==='dark')scheme=params.tgWebAppColorScheme;if(params.tgWebAppThemeParams){try{var theme=JSON.parse(decodeURIComponent(params.tgWebAppThemeParams));var tgKeys=['bg_color','text_color','hint_color','link_color','button_color','button_text_color','secondary_bg_color','header_bg_color','accent_text_color','destructive_text_color','section_bg_color','section_header_text_color','section_separator_color','subtitle_text_color','bottom_bar_bg_color'];tgKeys.forEach(function(k){var v=theme[k];if(v&&typeof v==='string')root.style.setProperty('--tg-theme-'+k.replace(/_/g,'-'),v);});Object.keys(theme).forEach(function(k){var v=theme[k];if(v&&typeof v==='string')root.style.setProperty('--tg-theme-'+k.replace(/_/g,'-'),v);});}catch(e){}}}var twa=typeof window!=='undefined'&&window.Telegram&&window.Telegram.WebApp;if(scheme!=='light'&&scheme!=='dark'&&twa&&(twa.colorScheme==='light'||twa.colorScheme==='dark'))scheme=twa.colorScheme;if(scheme!=='light'&&scheme!=='dark'){try{var s=getComputedStyle(root).getPropertyValue('--tg-color-scheme').trim();if(s==='light'||s==='dark')scheme=s;}catch(e){} }if(scheme!=='light'&&scheme!=='dark')scheme=window.matchMedia&&window.matchMedia('(prefers-color-scheme: dark)').matches?'dark':'light';root.setAttribute('data-theme',scheme);if(twa){if(twa.setBackgroundColor)twa.setBackgroundColor('bg_color');if(twa.setHeaderColor)twa.setHeaderColor('bg_color');}})();`,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</head>
|
</head>
|
||||||
<body className="antialiased">
|
<body className="antialiased">
|
||||||
<div className="flex min-h-screen 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>
|
||||||
<p className="text-center text-muted-foreground">
|
<p className="text-center text-muted-foreground">
|
||||||
{translate(lang, "error_boundary.description")}
|
{translate(lang, "error_boundary.description")}
|
||||||
</p>
|
</p>
|
||||||
<button
|
<Button type="button" onClick={() => reset()}>
|
||||||
type="button"
|
|
||||||
onClick={() => reset()}
|
|
||||||
className="rounded-lg bg-primary px-4 py-2 text-primary-foreground hover:bg-primary/90"
|
|
||||||
>
|
|
||||||
{translate(lang, "error_boundary.reload")}
|
{translate(lang, "error_boundary.reload")}
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</MiniAppScreenContent>
|
||||||
|
</MiniAppScreen>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -76,6 +76,7 @@
|
|||||||
--today-hover: color-mix(in srgb, var(--bg) 15%, var(--today));
|
--today-hover: color-mix(in srgb, var(--bg) 15%, var(--today));
|
||||||
--today-border: color-mix(in srgb, var(--today) 35%, transparent);
|
--today-border: color-mix(in srgb, var(--today) 35%, transparent);
|
||||||
--today-border-selected: color-mix(in srgb, var(--bg) 50%, transparent);
|
--today-border-selected: color-mix(in srgb, var(--bg) 50%, transparent);
|
||||||
|
--today-holiday-outline: color-mix(in srgb, var(--bg) 28%, var(--today));
|
||||||
--today-gradient-end: color-mix(in srgb, var(--today) 15%, transparent);
|
--today-gradient-end: color-mix(in srgb, var(--today) 15%, transparent);
|
||||||
--muted-fade: color-mix(in srgb, var(--muted) 40%, transparent);
|
--muted-fade: color-mix(in srgb, var(--muted) 40%, transparent);
|
||||||
--handle-bg: color-mix(in srgb, var(--muted) 80%, var(--text));
|
--handle-bg: color-mix(in srgb, var(--muted) 80%, var(--text));
|
||||||
@@ -89,6 +90,11 @@
|
|||||||
--ease-out: cubic-bezier(0.32, 0.72, 0, 1);
|
--ease-out: cubic-bezier(0.32, 0.72, 0, 1);
|
||||||
--radius: 0.625rem;
|
--radius: 0.625rem;
|
||||||
--calendar-block-min-height: 260px;
|
--calendar-block-min-height: 260px;
|
||||||
|
/** Safe-area insets for sticky headers and full-screen (Telegram viewport + env fallbacks). */
|
||||||
|
--app-safe-top: var(--tg-viewport-content-safe-area-inset-top, env(safe-area-inset-top, 0));
|
||||||
|
--app-safe-bottom: var(--tg-viewport-content-safe-area-inset-bottom, env(safe-area-inset-bottom, 0));
|
||||||
|
--app-safe-left: var(--tg-viewport-content-safe-area-inset-left, env(safe-area-inset-left, 0));
|
||||||
|
--app-safe-right: var(--tg-viewport-content-safe-area-inset-right, env(safe-area-inset-right, 0));
|
||||||
/** Minimum height for the 6-row calendar grid so cells stay comfortably large. */
|
/** Minimum height for the 6-row calendar grid so cells stay comfortably large. */
|
||||||
--calendar-grid-min-height: 264px;
|
--calendar-grid-min-height: 264px;
|
||||||
/** Minimum height per calendar row (6 rows × 44px ≈ 264px). */
|
/** Minimum height per calendar row (6 rows × 44px ≈ 264px). */
|
||||||
@@ -174,7 +180,7 @@ html::-webkit-scrollbar {
|
|||||||
margin-right: auto;
|
margin-right: auto;
|
||||||
padding: 12px;
|
padding: 12px;
|
||||||
padding-top: 0;
|
padding-top: 0;
|
||||||
padding-bottom: env(safe-area-inset-bottom, 12px);
|
padding-bottom: var(--tg-viewport-content-safe-area-inset-bottom, env(safe-area-inset-bottom, 12px));
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -261,9 +267,27 @@ html::-webkit-scrollbar {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Android low-performance devices: minimize animations (Telegram User-Agent). */
|
||||||
|
[data-perf="low"] *,
|
||||||
|
[data-perf="low"] *::before,
|
||||||
|
[data-perf="low"] *::after {
|
||||||
|
animation-duration: 0.01ms !important;
|
||||||
|
animation-iteration-count: 1 !important;
|
||||||
|
transition-duration: 0.01ms !important;
|
||||||
|
}
|
||||||
|
|
||||||
/* Safe area for Telegram Mini App (notch / status bar). */
|
/* Safe area for Telegram Mini App (notch / status bar). */
|
||||||
.pt-safe {
|
.pt-safe {
|
||||||
padding-top: env(safe-area-inset-top, 0);
|
padding-top: var(--app-safe-top);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Content safe area: top/bottom/left/right so content and sticky chrome sit below Telegram UI.
|
||||||
|
Horizontal padding has a minimum of 0.75rem (12px) when safe insets are zero. */
|
||||||
|
.content-safe {
|
||||||
|
padding-top: var(--app-safe-top);
|
||||||
|
padding-bottom: var(--app-safe-bottom);
|
||||||
|
padding-left: max(var(--app-safe-left), 0.75rem);
|
||||||
|
padding-right: max(var(--app-safe-right), 0.75rem);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Sticky calendar header: shadow when scrolled (useStickyScroll). */
|
/* Sticky calendar header: shadow when scrolled (useStickyScroll). */
|
||||||
@@ -305,7 +329,7 @@ html::-webkit-scrollbar {
|
|||||||
body {
|
body {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
min-height: 100vh;
|
min-height: var(--tg-viewport-stable-height, 100vh);
|
||||||
background: var(--bg);
|
background: var(--bg);
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
font-family: system-ui, -apple-system, sans-serif;
|
font-family: system-ui, -apple-system, sans-serif;
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import type { Metadata, Viewport } from "next";
|
import type { Metadata, Viewport } from "next";
|
||||||
import { TooltipProvider } from "@/components/ui/tooltip";
|
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||||
import { TelegramProvider } from "@/components/providers/TelegramProvider";
|
import { TelegramProvider } from "@/components/providers/TelegramProvider";
|
||||||
|
import { AppShell } from "@/components/AppShell";
|
||||||
import { AppErrorBoundary } from "@/components/AppErrorBoundary";
|
import { AppErrorBoundary } from "@/components/AppErrorBoundary";
|
||||||
|
import { THEME_BOOTSTRAP_SCRIPT } from "@/lib/theme-bootstrap-script";
|
||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
@@ -23,12 +25,7 @@ export default function RootLayout({
|
|||||||
return (
|
return (
|
||||||
<html lang="en" data-theme="dark" suppressHydrationWarning>
|
<html lang="en" data-theme="dark" suppressHydrationWarning>
|
||||||
<head>
|
<head>
|
||||||
{/* Inline script: theme from hash (tgWebAppColorScheme + all 14 TG themeParams → --tg-theme-*), then data-theme and Mini App colors. */}
|
<script dangerouslySetInnerHTML={{ __html: THEME_BOOTSTRAP_SCRIPT }} />
|
||||||
<script
|
|
||||||
dangerouslySetInnerHTML={{
|
|
||||||
__html: `(function(){var scheme='dark';var root=document.documentElement;var hash=typeof location!=='undefined'&&location.hash?location.hash.slice(1):'';if(hash){var params={};hash.split('&').forEach(function(p){var i=p.indexOf('=');if(i===-1)return;var k=decodeURIComponent(p.slice(0,i));var v=p.slice(i+1);params[k]=v;});if(params.tgWebAppColorScheme==='light'||params.tgWebAppColorScheme==='dark')scheme=params.tgWebAppColorScheme;if(params.tgWebAppThemeParams){try{var theme=JSON.parse(decodeURIComponent(params.tgWebAppThemeParams));var tgKeys=['bg_color','text_color','hint_color','link_color','button_color','button_text_color','secondary_bg_color','header_bg_color','accent_text_color','destructive_text_color','section_bg_color','section_header_text_color','section_separator_color','subtitle_text_color','bottom_bar_bg_color'];tgKeys.forEach(function(k){var v=theme[k];if(v&&typeof v==='string')root.style.setProperty('--tg-theme-'+k.replace(/_/g,'-'),v);});Object.keys(theme).forEach(function(k){var v=theme[k];if(v&&typeof v==='string')root.style.setProperty('--tg-theme-'+k.replace(/_/g,'-'),v);});}catch(e){}}}var twa=typeof window!=='undefined'&&window.Telegram&&window.Telegram.WebApp;if(scheme!=='light'&&scheme!=='dark'&&twa&&(twa.colorScheme==='light'||twa.colorScheme==='dark'))scheme=twa.colorScheme;if(scheme!=='light'&&scheme!=='dark'){try{var s=getComputedStyle(root).getPropertyValue('--tg-color-scheme').trim();if(s==='light'||s==='dark')scheme=s;}catch(e){} }if(scheme!=='light'&&scheme!=='dark')scheme=window.matchMedia&&window.matchMedia('(prefers-color-scheme: dark)').matches?'dark':'light';root.setAttribute('data-theme',scheme);if(twa){if(twa.setBackgroundColor)twa.setBackgroundColor('bg_color');if(twa.setHeaderColor)twa.setHeaderColor('bg_color');}})();`,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<script
|
<script
|
||||||
dangerouslySetInnerHTML={{
|
dangerouslySetInnerHTML={{
|
||||||
__html: `(function(){if(typeof window!=='undefined'&&window.__DT_LANG==null)window.__DT_LANG='en';})();`,
|
__html: `(function(){if(typeof window!=='undefined'&&window.__DT_LANG==null)window.__DT_LANG='en';})();`,
|
||||||
@@ -39,7 +36,9 @@ export default function RootLayout({
|
|||||||
<body className="antialiased">
|
<body className="antialiased">
|
||||||
<TelegramProvider>
|
<TelegramProvider>
|
||||||
<AppErrorBoundary>
|
<AppErrorBoundary>
|
||||||
<TooltipProvider>{children}</TooltipProvider>
|
<TooltipProvider>
|
||||||
|
<AppShell>{children}</AppShell>
|
||||||
|
</TooltipProvider>
|
||||||
</AppErrorBoundary>
|
</AppErrorBoundary>
|
||||||
</TelegramProvider>
|
</TelegramProvider>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -7,19 +7,24 @@
|
|||||||
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useTranslation } from "@/i18n/use-translation";
|
import { useTranslation } from "@/i18n/use-translation";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
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();
|
||||||
|
useScreenReady(true);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-screen flex-col items-center justify-center gap-4 bg-background px-4 text-foreground">
|
<FullScreenStateShell
|
||||||
<h1 className="text-xl font-semibold">{t("not_found.title")}</h1>
|
title={t("not_found.title")}
|
||||||
<p className="text-muted-foreground">{t("not_found.description")}</p>
|
description={t("not_found.description")}
|
||||||
<Link
|
primaryAction={
|
||||||
href="/"
|
<Button asChild>
|
||||||
className="rounded-lg bg-primary px-4 py-2 text-primary-foreground hover:bg-primary/90"
|
<Link href="/">{t("not_found.open_calendar")}</Link>
|
||||||
>
|
</Button>
|
||||||
{t("not_found.open_calendar")}
|
}
|
||||||
</Link>
|
role="status"
|
||||||
</div>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,12 +4,15 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||||
import { render, screen, waitFor, act } from "@testing-library/react";
|
import { render, screen, act } from "@testing-library/react";
|
||||||
import Page from "./page";
|
import Page from "./page";
|
||||||
import { resetAppStore } from "@/test/test-utils";
|
import { resetAppStore } from "@/test/test-utils";
|
||||||
import { useAppStore } from "@/store/app-store";
|
|
||||||
import { useTelegramAuth } from "@/hooks/use-telegram-auth";
|
import { useTelegramAuth } from "@/hooks/use-telegram-auth";
|
||||||
|
|
||||||
|
vi.mock("next/navigation", () => ({
|
||||||
|
useRouter: () => ({ push: vi.fn(), replace: vi.fn(), prefetch: vi.fn() }),
|
||||||
|
}));
|
||||||
|
|
||||||
vi.mock("@/hooks/use-telegram-auth", () => ({
|
vi.mock("@/hooks/use-telegram-auth", () => ({
|
||||||
useTelegramAuth: vi.fn(),
|
useTelegramAuth: vi.fn(),
|
||||||
}));
|
}));
|
||||||
@@ -37,23 +40,6 @@ describe("Page", () => {
|
|||||||
expect(screen.getByRole("button", { name: /next month/i })).toBeInTheDocument();
|
expect(screen.getByRole("button", { name: /next month/i })).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("sets document title and lang from store lang", async () => {
|
|
||||||
useAppStore.getState().setLang("en");
|
|
||||||
render(<Page />);
|
|
||||||
await screen.findByRole("grid", { name: "Calendar" });
|
|
||||||
expect(document.title).toBe("Duty Calendar");
|
|
||||||
expect(document.documentElement.lang).toBe("en");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("sets document title for ru when store lang is ru", async () => {
|
|
||||||
(globalThis.window as unknown as { __DT_LANG?: string }).__DT_LANG = "ru";
|
|
||||||
render(<Page />);
|
|
||||||
await screen.findByRole("grid", { name: "Calendar" });
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(document.title).toBe("Календарь дежурств");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("renders AccessDeniedScreen when not allowed and delay has passed", async () => {
|
it("renders AccessDeniedScreen when not allowed and delay has passed", async () => {
|
||||||
const { RETRY_DELAY_MS } = await import("@/lib/constants");
|
const { RETRY_DELAY_MS } = await import("@/lib/constants");
|
||||||
vi.mocked(useTelegramAuth).mockReturnValue({
|
vi.mocked(useTelegramAuth).mockReturnValue({
|
||||||
|
|||||||
@@ -8,22 +8,31 @@
|
|||||||
import { useCallback, useEffect } from "react";
|
import { useCallback, useEffect } from "react";
|
||||||
import { useAppStore, type AppState } from "@/store/app-store";
|
import { useAppStore, type AppState } from "@/store/app-store";
|
||||||
import { useShallow } from "zustand/react/shallow";
|
import { useShallow } from "zustand/react/shallow";
|
||||||
import { useTelegramTheme } from "@/hooks/use-telegram-theme";
|
|
||||||
import { useTelegramAuth } from "@/hooks/use-telegram-auth";
|
import { useTelegramAuth } from "@/hooks/use-telegram-auth";
|
||||||
import { useAppInit } from "@/hooks/use-app-init";
|
import { useAppInit } from "@/hooks/use-app-init";
|
||||||
import { callMiniAppReadyOnce } from "@/lib/telegram-ready";
|
import { fetchAdminMe } from "@/lib/api";
|
||||||
|
import { getLang } from "@/i18n/messages";
|
||||||
import { AccessDeniedScreen } from "@/components/states/AccessDeniedScreen";
|
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() {
|
||||||
useTelegramTheme();
|
|
||||||
|
|
||||||
const { initDataRaw, startParam, isLocalhost } = useTelegramAuth();
|
const { initDataRaw, startParam, isLocalhost } = useTelegramAuth();
|
||||||
const isAllowed = isLocalhost || !!initDataRaw;
|
const isAllowed = isLocalhost || !!initDataRaw;
|
||||||
|
|
||||||
useAppInit({ isAllowed, startParam });
|
useAppInit({ isAllowed, startParam });
|
||||||
|
|
||||||
|
const setIsAdmin = useAppStore((s) => s.setIsAdmin);
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isAllowed || !initDataRaw) {
|
||||||
|
setIsAdmin(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
fetchAdminMe(initDataRaw, getLang()).then(({ is_admin }) => setIsAdmin(is_admin));
|
||||||
|
}, [isAllowed, initDataRaw, setIsAdmin]);
|
||||||
|
|
||||||
const { accessDenied, currentView, setCurrentView, setSelectedDay, appContentReady } =
|
const { accessDenied, currentView, setCurrentView, setSelectedDay, appContentReady } =
|
||||||
useAppStore(
|
useAppStore(
|
||||||
useShallow((s: AppState) => ({
|
useShallow((s: AppState) => ({
|
||||||
@@ -35,12 +44,7 @@ export default function Home() {
|
|||||||
}))
|
}))
|
||||||
);
|
);
|
||||||
|
|
||||||
// When content is ready, tell Telegram to hide native loading and show our app.
|
useScreenReady(accessDenied || currentView === "currentDuty");
|
||||||
useEffect(() => {
|
|
||||||
if (appContentReady) {
|
|
||||||
callMiniAppReadyOnce();
|
|
||||||
}
|
|
||||||
}, [appContentReady]);
|
|
||||||
|
|
||||||
const handleBackFromCurrentDuty = useCallback(() => {
|
const handleBackFromCurrentDuty = useCallback(() => {
|
||||||
setCurrentView("calendar");
|
setCurrentView("calendar");
|
||||||
@@ -48,23 +52,29 @@ export default function Home() {
|
|||||||
}, [setCurrentView, setSelectedDay]);
|
}, [setCurrentView, setSelectedDay]);
|
||||||
|
|
||||||
const content = accessDenied ? (
|
const content = accessDenied ? (
|
||||||
|
<MiniAppScreen>
|
||||||
|
<MiniAppScreenContent>
|
||||||
<AccessDeniedScreen primaryAction="reload" />
|
<AccessDeniedScreen primaryAction="reload" />
|
||||||
|
</MiniAppScreenContent>
|
||||||
|
</MiniAppScreen>
|
||||||
) : currentView === "currentDuty" ? (
|
) : currentView === "currentDuty" ? (
|
||||||
<div className="mx-auto flex min-h-screen w-full max-w-[var(--max-width-app)] flex-col bg-background px-3 pb-6 pt-safe">
|
<MiniAppScreen>
|
||||||
|
<MiniAppScreenContent>
|
||||||
<CurrentDutyView
|
<CurrentDutyView
|
||||||
onBack={handleBackFromCurrentDuty}
|
onBack={handleBackFromCurrentDuty}
|
||||||
openedFromPin={startParam === "duty"}
|
openedFromPin={startParam === "duty"}
|
||||||
/>
|
/>
|
||||||
</div>
|
</MiniAppScreenContent>
|
||||||
|
</MiniAppScreen>
|
||||||
) : (
|
) : (
|
||||||
<CalendarPage isAllowed={isAllowed} initDataRaw={initDataRaw} />
|
<CalendarPage isAllowed={isAllowed} initDataRaw={initDataRaw} />
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
className="min-h-[var(--tg-viewport-stable-height,100vh)]"
|
||||||
style={{
|
style={{
|
||||||
visibility: appContentReady ? "visible" : "hidden",
|
visibility: appContentReady ? "visible" : "hidden",
|
||||||
minHeight: "100vh",
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{content}
|
{content}
|
||||||
|
|||||||
@@ -7,9 +7,9 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { getLang } from "@/i18n/messages";
|
import { getLang, translate } from "@/i18n/messages";
|
||||||
import { translate } from "@/i18n/messages";
|
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { FullScreenStateShell } from "@/components/states/FullScreenStateShell";
|
||||||
|
|
||||||
interface AppErrorBoundaryProps {
|
interface AppErrorBoundaryProps {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
@@ -52,23 +52,18 @@ export class AppErrorBoundary extends React.Component<
|
|||||||
if (this.state.hasError) {
|
if (this.state.hasError) {
|
||||||
const lang = getLang();
|
const lang = getLang();
|
||||||
const message = translate(lang, "error_boundary.message");
|
const message = translate(lang, "error_boundary.message");
|
||||||
|
const description = translate(lang, "error_boundary.description");
|
||||||
const reloadLabel = translate(lang, "error_boundary.reload");
|
const reloadLabel = translate(lang, "error_boundary.reload");
|
||||||
return (
|
return (
|
||||||
<div
|
<FullScreenStateShell
|
||||||
className="flex min-h-[200px] flex-col items-center justify-center gap-4 rounded-xl bg-surface py-8 px-4 text-center"
|
title={message}
|
||||||
role="alert"
|
description={description}
|
||||||
>
|
primaryAction={
|
||||||
<p className="m-0 text-sm font-medium text-foreground">{message}</p>
|
<Button type="button" variant="default" onClick={this.handleReload}>
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="default"
|
|
||||||
size="sm"
|
|
||||||
onClick={this.handleReload}
|
|
||||||
className="bg-primary text-primary-foreground hover:opacity-90"
|
|
||||||
>
|
|
||||||
{reloadLabel}
|
{reloadLabel}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
}
|
||||||
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return this.props.children;
|
return this.props.children;
|
||||||
|
|||||||
17
webapp-next/src/components/AppShell.tsx
Normal file
17
webapp-next/src/components/AppShell.tsx
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
/**
|
||||||
|
* App shell: wraps children with ReadyGate so any route can trigger miniAppReady().
|
||||||
|
* Rendered inside TelegramProvider so theme and SDK are available.
|
||||||
|
*/
|
||||||
|
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { ReadyGate } from "@/components/ReadyGate";
|
||||||
|
|
||||||
|
export function AppShell({ children }: { children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<ReadyGate />
|
||||||
|
{children}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useRef, useState, useEffect, useCallback } from "react";
|
import { useRef, useState, useEffect, useCallback } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
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";
|
||||||
@@ -17,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;
|
||||||
@@ -53,11 +58,11 @@ export function CalendarPage({ isAllowed, initDataRaw }: CalendarPageProps) {
|
|||||||
duties,
|
duties,
|
||||||
calendarEvents,
|
calendarEvents,
|
||||||
selectedDay,
|
selectedDay,
|
||||||
|
isAdmin,
|
||||||
nextMonth,
|
nextMonth,
|
||||||
prevMonth,
|
prevMonth,
|
||||||
setCurrentMonth,
|
setCurrentMonth,
|
||||||
setSelectedDay,
|
setSelectedDay,
|
||||||
setAppContentReady,
|
|
||||||
} = useAppStore(
|
} = useAppStore(
|
||||||
useShallow((s) => ({
|
useShallow((s) => ({
|
||||||
currentMonth: s.currentMonth,
|
currentMonth: s.currentMonth,
|
||||||
@@ -68,14 +73,16 @@ export function CalendarPage({ isAllowed, initDataRaw }: CalendarPageProps) {
|
|||||||
duties: s.duties,
|
duties: s.duties,
|
||||||
calendarEvents: s.calendarEvents,
|
calendarEvents: s.calendarEvents,
|
||||||
selectedDay: s.selectedDay,
|
selectedDay: s.selectedDay,
|
||||||
|
isAdmin: s.isAdmin,
|
||||||
nextMonth: s.nextMonth,
|
nextMonth: s.nextMonth,
|
||||||
prevMonth: s.prevMonth,
|
prevMonth: s.prevMonth,
|
||||||
setCurrentMonth: s.setCurrentMonth,
|
setCurrentMonth: s.setCurrentMonth,
|
||||||
setSelectedDay: s.setSelectedDay,
|
setSelectedDay: s.setSelectedDay,
|
||||||
setAppContentReady: s.setAppContentReady,
|
|
||||||
}))
|
}))
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
const { retry } = useMonthData({
|
const { retry } = useMonthData({
|
||||||
initDataRaw,
|
initDataRaw,
|
||||||
enabled: isAllowed,
|
enabled: isAllowed,
|
||||||
@@ -104,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) => {
|
||||||
@@ -123,20 +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(() => {
|
useTelegramSettingsButton({
|
||||||
if ((!loading || accessDenied) && !readyCalledRef.current) {
|
enabled: isAdmin,
|
||||||
readyCalledRef.current = true;
|
onClick: () => router.push("/admin"),
|
||||||
setAppContentReady(true);
|
});
|
||||||
}
|
|
||||||
}, [loading, accessDenied, setAppContentReady]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto flex min-h-screen w-full max-w-[var(--max-width-app)] flex-col bg-background px-3 pb-6 pt-safe">
|
<MiniAppScreen>
|
||||||
<div
|
<MiniAppScreenContent>
|
||||||
|
<MiniAppStickyHeader
|
||||||
ref={calendarStickyRef}
|
ref={calendarStickyRef}
|
||||||
className="sticky top-0 z-10 min-h-[var(--calendar-block-min-height)] bg-background pb-2"
|
className="min-h-[var(--calendar-block-min-height)] pb-2 touch-pan-y"
|
||||||
>
|
>
|
||||||
<CalendarHeader
|
<CalendarHeader
|
||||||
month={currentMonth}
|
month={currentMonth}
|
||||||
@@ -150,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" />
|
||||||
@@ -168,6 +175,7 @@ export function CalendarPage({ isAllowed, initDataRaw }: CalendarPageProps) {
|
|||||||
calendarEvents={calendarEvents}
|
calendarEvents={calendarEvents}
|
||||||
onClose={handleCloseDayDetail}
|
onClose={handleCloseDayDetail}
|
||||||
/>
|
/>
|
||||||
</div>
|
</MiniAppScreenContent>
|
||||||
|
</MiniAppScreen>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
23
webapp-next/src/components/ReadyGate.tsx
Normal file
23
webapp-next/src/components/ReadyGate.tsx
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
/**
|
||||||
|
* Route-agnostic gate: when appContentReady becomes true, calls miniAppReady() once
|
||||||
|
* so Telegram hides its native loader. Used in layout so any route (/, /admin, not-found,
|
||||||
|
* error) can trigger ready when its first screen is shown.
|
||||||
|
*/
|
||||||
|
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect } from "react";
|
||||||
|
import { useAppStore } from "@/store/app-store";
|
||||||
|
import { callMiniAppReadyOnce } from "@/lib/telegram-ready";
|
||||||
|
|
||||||
|
export function ReadyGate() {
|
||||||
|
const appContentReady = useAppStore((s) => s.appContentReady);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (appContentReady) {
|
||||||
|
callMiniAppReadyOnce();
|
||||||
|
}
|
||||||
|
}, [appContentReady]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
120
webapp-next/src/components/admin/AdminDutyList.tsx
Normal file
120
webapp-next/src/components/admin/AdminDutyList.tsx
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
/**
|
||||||
|
* Admin duty list: visible duties with infinite-scroll sentinel. Presentational only.
|
||||||
|
*/
|
||||||
|
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import type { DutyWithUser } from "@/types";
|
||||||
|
import { localDateString, formatHHMM, dateKeyToDDMM } from "@/lib/date-utils";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
export interface AdminDutyGroup {
|
||||||
|
dateKey: string;
|
||||||
|
duties: DutyWithUser[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Empty state when there are no duties: message and "Back to calendar" CTA. */
|
||||||
|
function AdminEmptyState({
|
||||||
|
t,
|
||||||
|
}: {
|
||||||
|
t: (key: string, params?: Record<string, string>) => string;
|
||||||
|
}) {
|
||||||
|
const router = useRouter();
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center gap-4 py-8 text-center">
|
||||||
|
<h2 className="text-[1.1rem] font-semibold leading-tight m-0">
|
||||||
|
{t("admin.no_duties")}
|
||||||
|
</h2>
|
||||||
|
<p className="text-sm text-muted-foreground m-0 max-w-[280px]">
|
||||||
|
{t("duty.none_this_month_hint")}
|
||||||
|
</p>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="secondary"
|
||||||
|
onClick={() => router.push("/")}
|
||||||
|
className="bg-surface text-accent hover:bg-[var(--surface-hover)]"
|
||||||
|
>
|
||||||
|
{t("admin.back_to_calendar")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AdminDutyListProps {
|
||||||
|
/** Duty groups by date (already sliced to visibleCount by parent). */
|
||||||
|
groups: AdminDutyGroup[];
|
||||||
|
/** Whether there are more items; when true, sentinel is rendered for intersection observer. */
|
||||||
|
hasMore: boolean;
|
||||||
|
/** Ref for the sentinel element (infinite scroll). */
|
||||||
|
sentinelRef: React.RefObject<HTMLDivElement | null>;
|
||||||
|
/** Called when user selects a duty to reassign. */
|
||||||
|
onSelectDuty: (duty: DutyWithUser) => void;
|
||||||
|
/** Translation function. */
|
||||||
|
t: (key: string, params?: Record<string, string>) => string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AdminDutyList({
|
||||||
|
groups,
|
||||||
|
hasMore,
|
||||||
|
sentinelRef,
|
||||||
|
onSelectDuty,
|
||||||
|
t,
|
||||||
|
}: AdminDutyListProps) {
|
||||||
|
if (groups.length === 0) {
|
||||||
|
return <AdminEmptyState t={t} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
const todayKey = localDateString(new Date());
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-3" role="list" aria-label={t("admin.list_aria")}>
|
||||||
|
{groups.map(({ dateKey, duties }) => {
|
||||||
|
const isToday = dateKey === todayKey;
|
||||||
|
const dateLabel = isToday ? t("duty.today") : dateKeyToDDMM(dateKey);
|
||||||
|
return (
|
||||||
|
<section
|
||||||
|
key={dateKey}
|
||||||
|
className="flex flex-col gap-1.5"
|
||||||
|
aria-label={t("admin.section_aria", { date: dateLabel })}
|
||||||
|
>
|
||||||
|
<ul className="flex flex-col gap-1.5 list-none m-0 p-0">
|
||||||
|
{duties.map((duty) => {
|
||||||
|
const dateStr = localDateString(new Date(duty.start_at));
|
||||||
|
const timeStr = `${formatHHMM(duty.start_at)} – ${formatHHMM(duty.end_at)}`;
|
||||||
|
return (
|
||||||
|
<li key={duty.id}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onSelectDuty(duty)}
|
||||||
|
aria-label={t("admin.reassign_aria", {
|
||||||
|
date: dateStr,
|
||||||
|
time: timeStr,
|
||||||
|
name: duty.full_name,
|
||||||
|
})}
|
||||||
|
className={cn(
|
||||||
|
"w-full min-h-0 rounded-lg border-l-[3px] bg-surface px-2.5 py-2 shadow-sm text-left text-sm transition-colors",
|
||||||
|
"hover:bg-[var(--surface-hover)] focus-visible:outline-accent flex flex-col gap-0.5",
|
||||||
|
isToday ? "border-l-today bg-[var(--surface-today-tint)]" : "border-l-duty"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span>
|
||||||
|
<span className="font-medium">{dateStr}</span>
|
||||||
|
<span className="mx-2 text-muted-foreground">·</span>
|
||||||
|
<span className="text-muted-foreground">{timeStr}</span>
|
||||||
|
</span>
|
||||||
|
<span className="font-semibold">{duty.full_name}</span>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{hasMore && (
|
||||||
|
<div ref={sentinelRef} className="h-2" data-sentinel aria-hidden="true" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
197
webapp-next/src/components/admin/ReassignSheet.tsx
Normal file
197
webapp-next/src/components/admin/ReassignSheet.tsx
Normal file
@@ -0,0 +1,197 @@
|
|||||||
|
/**
|
||||||
|
* Bottom sheet for reassigning a duty to another user. Uses design tokens and safe area.
|
||||||
|
*/
|
||||||
|
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import type { DutyWithUser } from "@/types";
|
||||||
|
import type { UserForAdmin } from "@/lib/api";
|
||||||
|
import { localDateString, formatHHMM, dateKeyToDDMM } from "@/lib/date-utils";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Sheet,
|
||||||
|
SheetContent,
|
||||||
|
SheetHeader,
|
||||||
|
SheetTitle,
|
||||||
|
SheetDescription,
|
||||||
|
SheetFooter,
|
||||||
|
} from "@/components/ui/sheet";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
export interface ReassignSheetProps {
|
||||||
|
/** Whether the sheet is open (and not in exiting state). */
|
||||||
|
open: boolean;
|
||||||
|
/** Selected duty to reassign; when null, content may still render with previous duty during close. */
|
||||||
|
selectedDuty: DutyWithUser | null;
|
||||||
|
/** Current selected user id for the select. */
|
||||||
|
selectedUserId: number | "";
|
||||||
|
/** Called when user changes selection. */
|
||||||
|
setSelectedUserId: (value: number | "") => void;
|
||||||
|
/** Users to show in the dropdown (role_id 1 or 2). */
|
||||||
|
users: UserForAdmin[];
|
||||||
|
/** Reassign request in progress. */
|
||||||
|
saving: boolean;
|
||||||
|
/** i18n key for error message from last reassign attempt; null when no error. */
|
||||||
|
reassignErrorKey: string | null;
|
||||||
|
/** Called when user confirms reassign. */
|
||||||
|
onReassign: () => void;
|
||||||
|
/** Called when user requests close (start close animation). */
|
||||||
|
onRequestClose: () => void;
|
||||||
|
/** Called when close animation ends (clear state). */
|
||||||
|
onCloseAnimationEnd: () => void;
|
||||||
|
/** Translation function. */
|
||||||
|
t: (key: string, params?: Record<string, string>) => string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ReassignSheet({
|
||||||
|
open,
|
||||||
|
selectedDuty,
|
||||||
|
selectedUserId,
|
||||||
|
setSelectedUserId,
|
||||||
|
users,
|
||||||
|
saving,
|
||||||
|
reassignErrorKey,
|
||||||
|
onReassign,
|
||||||
|
onRequestClose,
|
||||||
|
onCloseAnimationEnd,
|
||||||
|
t,
|
||||||
|
}: ReassignSheetProps) {
|
||||||
|
const todayKey = localDateString(new Date());
|
||||||
|
const dateKey = selectedDuty
|
||||||
|
? localDateString(new Date(selectedDuty.start_at))
|
||||||
|
: "";
|
||||||
|
const sheetTitle = selectedDuty
|
||||||
|
? dateKey === todayKey
|
||||||
|
? t("duty.today") + ", " + dateKeyToDDMM(dateKey)
|
||||||
|
: dateKeyToDDMM(dateKey)
|
||||||
|
: t("admin.reassign_duty");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Sheet open={open} onOpenChange={(isOpen) => !isOpen && onRequestClose()}>
|
||||||
|
<SheetContent
|
||||||
|
side="bottom"
|
||||||
|
className="flex max-h-[85vh] flex-col rounded-t-2xl bg-[var(--surface)] p-0 pt-3"
|
||||||
|
overlayClassName="backdrop-blur-md"
|
||||||
|
showCloseButton={false}
|
||||||
|
closeLabel={t("day_detail.close")}
|
||||||
|
onCloseAnimationEnd={onCloseAnimationEnd}
|
||||||
|
onOpenAutoFocus={(e) => e.preventDefault()}
|
||||||
|
>
|
||||||
|
<div className="relative min-h-0 flex-1 overflow-y-auto px-4">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="absolute top-2 right-2 h-8 w-8 text-muted hover:text-[var(--text)] rounded-lg"
|
||||||
|
onClick={onRequestClose}
|
||||||
|
aria-label={t("day_detail.close")}
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="18"
|
||||||
|
height="18"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
aria-hidden
|
||||||
|
>
|
||||||
|
<line x1="18" y1="6" x2="6" y2="18" />
|
||||||
|
<line x1="6" y1="6" x2="18" y2="18" />
|
||||||
|
</svg>
|
||||||
|
</Button>
|
||||||
|
<div
|
||||||
|
className="w-10 h-1 rounded-full bg-[var(--handle-bg)] mx-auto mb-2"
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
|
<SheetHeader className="p-0">
|
||||||
|
<SheetTitle>{sheetTitle}</SheetTitle>
|
||||||
|
<SheetDescription className="sr-only">
|
||||||
|
{t("admin.select_user")}
|
||||||
|
</SheetDescription>
|
||||||
|
</SheetHeader>
|
||||||
|
{selectedDuty && (
|
||||||
|
<div className="flex flex-col gap-4 pt-2 pb-4">
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{formatHHMM(selectedDuty.start_at)} – {formatHHMM(selectedDuty.end_at)}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{t("admin.current_assignee")}: {selectedDuty.full_name}
|
||||||
|
</p>
|
||||||
|
{users.length === 0 ? (
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{t("admin.no_users_for_assign")}
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{t("admin.select_user")}
|
||||||
|
</p>
|
||||||
|
<div
|
||||||
|
className="flex flex-col gap-1 max-h-[40vh] overflow-y-auto rounded-lg border border-border py-1"
|
||||||
|
role="radiogroup"
|
||||||
|
aria-label={t("admin.select_user")}
|
||||||
|
>
|
||||||
|
{users.map((u) => {
|
||||||
|
const isCurrent = u.id === selectedDuty.user_id;
|
||||||
|
const isSelected = selectedUserId === u.id;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={u.id}
|
||||||
|
type="button"
|
||||||
|
role="radio"
|
||||||
|
aria-checked={isSelected}
|
||||||
|
disabled={saving}
|
||||||
|
onClick={() => setSelectedUserId(u.id)}
|
||||||
|
className={cn(
|
||||||
|
"flex flex-col items-start gap-0.5 rounded-md px-3 py-2.5 text-left text-sm transition-colors",
|
||||||
|
"hover:bg-[var(--surface-hover)] focus-visible:outline-accent focus-visible:ring-2 focus-visible:ring-accent",
|
||||||
|
isCurrent && "bg-[var(--surface-today-tint)]",
|
||||||
|
isSelected && "ring-2 ring-accent ring-inset"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className="font-medium">
|
||||||
|
{u.full_name}
|
||||||
|
{isCurrent && (
|
||||||
|
<span className="ml-2 text-xs text-muted-foreground font-normal">
|
||||||
|
({t("admin.current_assignee")})
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
{u.username && (
|
||||||
|
<span className="text-xs text-muted-foreground">@{u.username}</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{reassignErrorKey && (
|
||||||
|
<p id="admin-reassign-error" className="text-sm text-destructive" role="alert">
|
||||||
|
{t(reassignErrorKey)}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</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+var(--app-safe-bottom,env(safe-area-inset-bottom,0px)))]">
|
||||||
|
<Button
|
||||||
|
onClick={onReassign}
|
||||||
|
disabled={
|
||||||
|
saving ||
|
||||||
|
selectedUserId === "" ||
|
||||||
|
selectedUserId === selectedDuty?.user_id ||
|
||||||
|
users.length === 0
|
||||||
|
}
|
||||||
|
aria-describedby={reassignErrorKey ? "admin-reassign-error" : undefined}
|
||||||
|
>
|
||||||
|
{saving ? t("loading") : t("admin.save")}
|
||||||
|
</Button>
|
||||||
|
</SheetFooter>
|
||||||
|
</SheetContent>
|
||||||
|
</Sheet>
|
||||||
|
);
|
||||||
|
}
|
||||||
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 };
|
||||||
|
}
|
||||||
9
webapp-next/src/components/admin/index.ts
Normal file
9
webapp-next/src/components/admin/index.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
/**
|
||||||
|
* Admin feature: hook and presentational components for the admin page.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export { useAdminPage } from "./useAdminPage";
|
||||||
|
export { AdminDutyList } from "./AdminDutyList";
|
||||||
|
export { ReassignSheet } from "./ReassignSheet";
|
||||||
|
export type { AdminDutyListProps, AdminDutyGroup } from "./AdminDutyList";
|
||||||
|
export type { ReassignSheetProps } from "./ReassignSheet";
|
||||||
129
webapp-next/src/components/admin/useAdminPage.ts
Normal file
129
webapp-next/src/components/admin/useAdminPage.ts
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
/**
|
||||||
|
* Admin page composition hook.
|
||||||
|
* Delegates access/users/duties/reassign/infinite-list concerns to focused hooks.
|
||||||
|
*/
|
||||||
|
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useCallback, useRef, useMemo } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { useTelegramSdkReady } from "@/components/providers/TelegramProvider";
|
||||||
|
import { useAppStore } from "@/store/app-store";
|
||||||
|
import { useShallow } from "zustand/react/shallow";
|
||||||
|
import { useTelegramAuth } from "@/hooks/use-telegram-auth";
|
||||||
|
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 {
|
||||||
|
useAdminAccess,
|
||||||
|
useAdminUsers,
|
||||||
|
useAdminDuties,
|
||||||
|
useInfiniteDutyGroups,
|
||||||
|
useAdminReassign,
|
||||||
|
} from "@/components/admin/hooks";
|
||||||
|
import { useRequestState } from "@/hooks/use-request-state";
|
||||||
|
|
||||||
|
export function useAdminPage() {
|
||||||
|
const router = useRouter();
|
||||||
|
const { sdkReady } = useTelegramSdkReady();
|
||||||
|
const { initDataRaw, isLocalhost } = useTelegramAuth();
|
||||||
|
const isAllowed = isLocalhost || !!initDataRaw;
|
||||||
|
|
||||||
|
const { lang } = useAppStore(useShallow((s) => ({ lang: s.lang })));
|
||||||
|
const currentMonth = useAppStore((s) => s.currentMonth);
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
/** Local month for admin view; does not change global calendar month. */
|
||||||
|
const [adminMonth, setAdminMonth] = useState<Date>(() => new Date(currentMonth.getFullYear(), currentMonth.getMonth(), 1));
|
||||||
|
const navigateHomeRef = useRef(() => router.push("/"));
|
||||||
|
navigateHomeRef.current = () => router.push("/");
|
||||||
|
const request = useRequestState("idle");
|
||||||
|
const access = useAdminAccess({ isAllowed, initDataRaw, lang });
|
||||||
|
const handleAdminAccessDenied = useCallback(
|
||||||
|
(detail: string | null) => {
|
||||||
|
access.setAdminAccessDenied(true);
|
||||||
|
access.setAdminAccessDeniedDetail(detail);
|
||||||
|
},
|
||||||
|
[access.setAdminAccessDenied, access.setAdminAccessDeniedDetail]
|
||||||
|
);
|
||||||
|
|
||||||
|
const onPrevMonth = useCallback(() => {
|
||||||
|
setAdminMonth((prev) => new Date(prev.getFullYear(), prev.getMonth() - 1, 1));
|
||||||
|
}, []);
|
||||||
|
const onNextMonth = useCallback(() => {
|
||||||
|
setAdminMonth((prev) => new Date(prev.getFullYear(), prev.getMonth() + 1, 1));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useTelegramBackButton({
|
||||||
|
enabled: sdkReady && !isLocalhost,
|
||||||
|
onClick: () => navigateHomeRef.current(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const usersRequest = useAdminUsers({
|
||||||
|
isAllowed,
|
||||||
|
initDataRaw,
|
||||||
|
lang,
|
||||||
|
adminCheckComplete: access.adminCheckComplete,
|
||||||
|
onAccessDenied: handleAdminAccessDenied,
|
||||||
|
onError: request.setError,
|
||||||
|
});
|
||||||
|
|
||||||
|
const dutiesRequest = useAdminDuties({
|
||||||
|
isAllowed,
|
||||||
|
initDataRaw,
|
||||||
|
lang,
|
||||||
|
adminCheckComplete: access.adminCheckComplete,
|
||||||
|
adminMonth,
|
||||||
|
onError: request.setError,
|
||||||
|
clearError: request.reset,
|
||||||
|
});
|
||||||
|
|
||||||
|
const reassign = useAdminReassign({
|
||||||
|
initDataRaw,
|
||||||
|
lang,
|
||||||
|
users: usersRequest.users,
|
||||||
|
setDuties: dutiesRequest.setDuties,
|
||||||
|
t,
|
||||||
|
});
|
||||||
|
|
||||||
|
useTelegramClosingConfirmation(
|
||||||
|
ENABLE_CLOSING_CONFIRMATION_FOR_STATEFUL_ADMIN_FLOW && reassign.selectedDuty !== null
|
||||||
|
);
|
||||||
|
|
||||||
|
const list = useInfiniteDutyGroups(dutiesRequest.duties, dutiesRequest.from, dutiesRequest.to);
|
||||||
|
const usersForSelect = useMemo(
|
||||||
|
() => usersRequest.users.filter((u) => u.role_id === 1 || u.role_id === 2),
|
||||||
|
[usersRequest.users]
|
||||||
|
);
|
||||||
|
const error = request.state.error;
|
||||||
|
const loading = usersRequest.loadingUsers || dutiesRequest.loadingDuties;
|
||||||
|
|
||||||
|
return {
|
||||||
|
isAllowed,
|
||||||
|
adminCheckComplete: access.adminCheckComplete,
|
||||||
|
adminAccessDenied: access.adminAccessDenied,
|
||||||
|
adminAccessDeniedDetail: access.adminAccessDeniedDetail,
|
||||||
|
error,
|
||||||
|
loading,
|
||||||
|
adminMonth,
|
||||||
|
onPrevMonth,
|
||||||
|
onNextMonth,
|
||||||
|
successMessage: reassign.successMessage,
|
||||||
|
dutyOnly: list.dutyOnly,
|
||||||
|
usersForSelect,
|
||||||
|
visibleDuties: list.visibleDuties,
|
||||||
|
visibleGroups: list.visibleGroups,
|
||||||
|
hasMore: list.hasMore,
|
||||||
|
sentinelRef: list.sentinelRef,
|
||||||
|
selectedDuty: reassign.selectedDuty,
|
||||||
|
selectedUserId: reassign.selectedUserId,
|
||||||
|
setSelectedUserId: reassign.setSelectedUserId,
|
||||||
|
saving: reassign.saving,
|
||||||
|
reassignErrorKey: reassign.reassignErrorKey,
|
||||||
|
sheetExiting: reassign.sheetExiting,
|
||||||
|
openReassign: reassign.openReassign,
|
||||||
|
requestCloseSheet: reassign.requestCloseSheet,
|
||||||
|
handleReassign: reassign.handleReassign,
|
||||||
|
closeReassign: reassign.closeReassign,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -76,4 +76,20 @@ describe("CalendarDay", () => {
|
|||||||
const button = screen.getByRole("button", { name: /15/ });
|
const button = screen.getByRole("button", { name: /15/ });
|
||||||
expect(button.getAttribute("aria-disabled")).not.toBe("true");
|
expect(button.getAttribute("aria-disabled")).not.toBe("true");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("applies today base and holiday outline when isToday and eventSummaries are set", () => {
|
||||||
|
render(
|
||||||
|
<CalendarDay
|
||||||
|
{...defaultProps}
|
||||||
|
isOtherMonth={false}
|
||||||
|
isToday={true}
|
||||||
|
eventSummaries={["Holiday"]}
|
||||||
|
onDayClick={() => {}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
const button = screen.getByRole("button", { name: /15/ });
|
||||||
|
expect(button.className).toMatch(/bg-today|today/);
|
||||||
|
expect(button.className).toMatch(/ring-2|today-holiday-outline/);
|
||||||
|
expect(button.getAttribute("aria-disabled")).not.toBe("true");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ function CalendarDayInner({
|
|||||||
[duties]
|
[duties]
|
||||||
);
|
);
|
||||||
const hasEvent = eventSummaries.length > 0;
|
const hasEvent = eventSummaries.length > 0;
|
||||||
|
const isTodayHoliday = isToday && hasEvent;
|
||||||
const showIndicator = !isOtherMonth;
|
const showIndicator = !isOtherMonth;
|
||||||
const hasAny = duties.length > 0 || hasEvent;
|
const hasAny = duties.length > 0 || hasEvent;
|
||||||
|
|
||||||
@@ -82,10 +83,9 @@ function CalendarDayInner({
|
|||||||
showIndicator && hasAny && "font-bold",
|
showIndicator && hasAny && "font-bold",
|
||||||
showIndicator &&
|
showIndicator &&
|
||||||
hasEvent &&
|
hasEvent &&
|
||||||
|
!isToday &&
|
||||||
"bg-[linear-gradient(135deg,var(--surface)_0%,var(--today-gradient-end)_100%)] border border-[var(--today-border)]",
|
"bg-[linear-gradient(135deg,var(--surface)_0%,var(--today-gradient-end)_100%)] border border-[var(--today-border)]",
|
||||||
isToday &&
|
isTodayHoliday && "ring-1 ring-inset ring-[var(--today-holiday-outline)]"
|
||||||
hasEvent &&
|
|
||||||
"bg-today text-[var(--bg)] border border-[var(--today-border-selected)]"
|
|
||||||
)}
|
)}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
if (isOtherMonth) return;
|
if (isOtherMonth) return;
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -5,13 +5,9 @@
|
|||||||
|
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { useTranslation } from "@/i18n/use-translation";
|
import { useTranslation } from "@/i18n/use-translation";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import {
|
import { MonthNavHeader } from "@/components/calendar/MonthNavHeader";
|
||||||
ChevronLeft as ChevronLeftIcon,
|
|
||||||
ChevronRight as ChevronRightIcon,
|
|
||||||
} from "lucide-react";
|
|
||||||
|
|
||||||
export interface CalendarHeaderProps {
|
export interface CalendarHeaderProps {
|
||||||
/** Currently displayed month (used for title). */
|
/** Currently displayed month (used for title). */
|
||||||
@@ -30,51 +26,19 @@ export function CalendarHeader({
|
|||||||
onNextMonth,
|
onNextMonth,
|
||||||
className,
|
className,
|
||||||
}: CalendarHeaderProps) {
|
}: CalendarHeaderProps) {
|
||||||
const { t, monthName, weekdayLabels } = useTranslation();
|
const { weekdayLabels } = useTranslation();
|
||||||
const year = month.getFullYear();
|
|
||||||
const monthIndex = month.getMonth();
|
|
||||||
const labels = weekdayLabels();
|
const labels = weekdayLabels();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header className={cn("flex flex-col", className)}>
|
<header className={cn("flex flex-col", className)}>
|
||||||
<div className="flex items-center justify-between mb-3">
|
<MonthNavHeader
|
||||||
<Button
|
month={month}
|
||||||
type="button"
|
|
||||||
variant="secondary"
|
|
||||||
size="icon"
|
|
||||||
className="size-10 rounded-[10px] bg-surface text-accent hover:bg-[var(--surface-hover)] focus-visible:outline-accent active:scale-95 disabled:opacity-50"
|
|
||||||
aria-label={t("nav.prev_month")}
|
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
onClick={onPrevMonth}
|
onPrevMonth={onPrevMonth}
|
||||||
>
|
onNextMonth={onNextMonth}
|
||||||
<ChevronLeftIcon className="size-5" aria-hidden />
|
ariaLive
|
||||||
</Button>
|
className="mb-3"
|
||||||
<div className="flex min-h-[2rem] flex-col items-center justify-center gap-0">
|
/>
|
||||||
<h1
|
|
||||||
className="m-0 flex flex-col items-center justify-center gap-0 leading-none"
|
|
||||||
aria-live="polite"
|
|
||||||
aria-atomic="true"
|
|
||||||
>
|
|
||||||
<span className="text-xs font-normal leading-none text-muted">
|
|
||||||
{year}
|
|
||||||
</span>
|
|
||||||
<span className="text-[1.1rem] font-semibold leading-tight sm:text-[1.25rem]">
|
|
||||||
{monthName(monthIndex)}
|
|
||||||
</span>
|
|
||||||
</h1>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="secondary"
|
|
||||||
size="icon"
|
|
||||||
className="size-10 rounded-[10px] bg-surface text-accent hover:bg-[var(--surface-hover)] focus-visible:outline-accent active:scale-95 disabled:opacity-50"
|
|
||||||
aria-label={t("nav.next_month")}
|
|
||||||
disabled={disabled}
|
|
||||||
onClick={onNextMonth}
|
|
||||||
>
|
|
||||||
<ChevronRightIcon className="size-5" aria-hidden />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-7 gap-0.5 mb-1.5 text-center text-[0.75rem] text-muted">
|
<div className="grid grid-cols-7 gap-0.5 mb-1.5 text-center text-[0.75rem] text-muted">
|
||||||
{labels.map((label, i) => (
|
{labels.map((label, i) => (
|
||||||
<span key={i} aria-hidden>
|
<span key={i} aria-hidden>
|
||||||
|
|||||||
86
webapp-next/src/components/calendar/MonthNavHeader.tsx
Normal file
86
webapp-next/src/components/calendar/MonthNavHeader.tsx
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
/**
|
||||||
|
* Shared month navigation row: prev button, year + month title, next button.
|
||||||
|
* Used by CalendarHeader and admin page for consistent layout and spacing.
|
||||||
|
*/
|
||||||
|
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { useTranslation } from "@/i18n/use-translation";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import {
|
||||||
|
ChevronLeft as ChevronLeftIcon,
|
||||||
|
ChevronRight as ChevronRightIcon,
|
||||||
|
} from "lucide-react";
|
||||||
|
|
||||||
|
export interface MonthNavHeaderProps {
|
||||||
|
/** Currently displayed month (used for title). */
|
||||||
|
month: Date;
|
||||||
|
/** Whether month navigation is disabled (e.g. during loading). */
|
||||||
|
disabled?: boolean;
|
||||||
|
onPrevMonth: () => void;
|
||||||
|
onNextMonth: () => void;
|
||||||
|
/** Optional aria-label for the month title (e.g. admin page). */
|
||||||
|
titleAriaLabel?: string;
|
||||||
|
/** When true, title is announced on change (e.g. calendar). */
|
||||||
|
ariaLive?: boolean;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const NAV_BUTTON_CLASS =
|
||||||
|
"size-10 rounded-[10px] bg-surface text-accent hover:bg-[var(--surface-hover)] focus-visible:outline-accent active:scale-95 disabled:opacity-50";
|
||||||
|
|
||||||
|
export function MonthNavHeader({
|
||||||
|
month,
|
||||||
|
disabled = false,
|
||||||
|
onPrevMonth,
|
||||||
|
onNextMonth,
|
||||||
|
titleAriaLabel,
|
||||||
|
ariaLive = false,
|
||||||
|
className,
|
||||||
|
}: MonthNavHeaderProps) {
|
||||||
|
const { t, monthName } = useTranslation();
|
||||||
|
const year = month.getFullYear();
|
||||||
|
const monthIndex = month.getMonth();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn("flex items-center justify-between", className)}>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="secondary"
|
||||||
|
size="icon"
|
||||||
|
className={NAV_BUTTON_CLASS}
|
||||||
|
aria-label={t("nav.prev_month")}
|
||||||
|
disabled={disabled}
|
||||||
|
onClick={onPrevMonth}
|
||||||
|
>
|
||||||
|
<ChevronLeftIcon className="size-5" aria-hidden />
|
||||||
|
</Button>
|
||||||
|
<div className="flex min-h-[2rem] flex-col items-center justify-center gap-0">
|
||||||
|
<h1
|
||||||
|
className="m-0 flex flex-col items-center justify-center gap-0 leading-none"
|
||||||
|
{...(titleAriaLabel ? { "aria-label": titleAriaLabel } : {})}
|
||||||
|
{...(ariaLive ? { "aria-live": "polite", "aria-atomic": true } : {})}
|
||||||
|
>
|
||||||
|
<span className="text-xs font-normal leading-none text-muted">
|
||||||
|
{year}
|
||||||
|
</span>
|
||||||
|
<span className="text-[1.1rem] font-semibold leading-tight sm:text-[1.25rem]">
|
||||||
|
{monthName(monthIndex)}
|
||||||
|
</span>
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="secondary"
|
||||||
|
size="icon"
|
||||||
|
className={NAV_BUTTON_CLASS}
|
||||||
|
aria-label={t("nav.next_month")}
|
||||||
|
disabled={disabled}
|
||||||
|
onClick={onNextMonth}
|
||||||
|
>
|
||||||
|
<ChevronRightIcon className="size-5" aria-hidden />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -3,14 +3,41 @@
|
|||||||
* Ported from webapp/js/contactHtml.test.js buildContactLinksHtml.
|
* Ported from webapp/js/contactHtml.test.js buildContactLinksHtml.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { describe, it, expect, beforeEach } from "vitest";
|
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||||
import { render, screen } from "@testing-library/react";
|
import { render, screen, fireEvent, act } from "@testing-library/react";
|
||||||
import { ContactLinks } from "./ContactLinks";
|
import { ContactLinks } from "./ContactLinks";
|
||||||
|
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||||
import { resetAppStore } from "@/test/test-utils";
|
import { resetAppStore } from "@/test/test-utils";
|
||||||
|
|
||||||
|
function renderWithTooltip(ui: React.ReactElement) {
|
||||||
|
return render(<TooltipProvider>{ui}</TooltipProvider>);
|
||||||
|
}
|
||||||
|
|
||||||
|
const openPhoneLinkMock = vi.fn();
|
||||||
|
const openTelegramProfileMock = vi.fn();
|
||||||
|
const triggerHapticLightMock = vi.fn();
|
||||||
|
const copyToClipboardMock = vi.fn();
|
||||||
|
|
||||||
|
vi.mock("@/lib/open-phone-link", () => ({
|
||||||
|
openPhoneLink: (...args: unknown[]) => openPhoneLinkMock(...args),
|
||||||
|
}));
|
||||||
|
vi.mock("@/lib/telegram-link", () => ({
|
||||||
|
openTelegramProfile: (...args: unknown[]) => openTelegramProfileMock(...args),
|
||||||
|
}));
|
||||||
|
vi.mock("@/lib/telegram-haptic", () => ({
|
||||||
|
triggerHapticLight: () => triggerHapticLightMock(),
|
||||||
|
}));
|
||||||
|
vi.mock("@/lib/copy-to-clipboard", () => ({
|
||||||
|
copyToClipboard: (...args: unknown[]) => copyToClipboardMock(...args),
|
||||||
|
}));
|
||||||
|
|
||||||
describe("ContactLinks", () => {
|
describe("ContactLinks", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
resetAppStore();
|
resetAppStore();
|
||||||
|
openPhoneLinkMock.mockClear();
|
||||||
|
openTelegramProfileMock.mockClear();
|
||||||
|
triggerHapticLightMock.mockClear();
|
||||||
|
copyToClipboardMock.mockClear();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns null when phone and username are missing", () => {
|
it("returns null when phone and username are missing", () => {
|
||||||
@@ -57,4 +84,178 @@ describe("ContactLinks", () => {
|
|||||||
expect(link).toBeInTheDocument();
|
expect(link).toBeInTheDocument();
|
||||||
expect(link?.textContent).toContain("@alice");
|
expect(link?.textContent).toContain("@alice");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("calls openPhoneLink and triggerHapticLight when phone link is clicked", () => {
|
||||||
|
render(
|
||||||
|
<ContactLinks phone="+79991234567" username={null} showLabels={false} />
|
||||||
|
);
|
||||||
|
const telLink = document.querySelector<HTMLAnchorElement>('a[href^="tel:"]');
|
||||||
|
expect(telLink).toBeInTheDocument();
|
||||||
|
|
||||||
|
fireEvent.click(telLink!);
|
||||||
|
|
||||||
|
expect(openPhoneLinkMock).toHaveBeenCalledWith("+79991234567");
|
||||||
|
expect(triggerHapticLightMock).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("calls openTelegramProfile and triggerHapticLight when Telegram link is clicked", () => {
|
||||||
|
render(
|
||||||
|
<ContactLinks phone={null} username="alice_dev" showLabels={false} />
|
||||||
|
);
|
||||||
|
const tgLink = document.querySelector<HTMLAnchorElement>('a[href*="t.me"]');
|
||||||
|
expect(tgLink).toBeInTheDocument();
|
||||||
|
|
||||||
|
fireEvent.click(tgLink!);
|
||||||
|
|
||||||
|
expect(openTelegramProfileMock).toHaveBeenCalledWith("alice_dev");
|
||||||
|
expect(triggerHapticLightMock).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("showCopyButtons with layout block", () => {
|
||||||
|
it("renders copy phone button with aria-label when phone is present", () => {
|
||||||
|
renderWithTooltip(
|
||||||
|
<ContactLinks
|
||||||
|
phone="+79991234567"
|
||||||
|
username={null}
|
||||||
|
layout="block"
|
||||||
|
showCopyButtons
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
screen.getByRole("button", { name: /Copy phone number|Скопировать номер/i })
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders copy Telegram button with aria-label when username is present", () => {
|
||||||
|
renderWithTooltip(
|
||||||
|
<ContactLinks
|
||||||
|
phone={null}
|
||||||
|
username="alice_dev"
|
||||||
|
layout="block"
|
||||||
|
showCopyButtons
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
screen.getByRole("button", {
|
||||||
|
name: /Copy Telegram username|Скопировать логин Telegram/i,
|
||||||
|
})
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("calls copyToClipboard with raw phone and triggerHapticLight when copy phone is clicked", async () => {
|
||||||
|
copyToClipboardMock.mockResolvedValue(true);
|
||||||
|
renderWithTooltip(
|
||||||
|
<ContactLinks
|
||||||
|
phone="+79991234567"
|
||||||
|
username={null}
|
||||||
|
layout="block"
|
||||||
|
showCopyButtons
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
const copyBtn = screen.getByRole("button", {
|
||||||
|
name: /Copy phone number|Скопировать номер/i,
|
||||||
|
});
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.click(copyBtn);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(copyToClipboardMock).toHaveBeenCalledWith("+79991234567");
|
||||||
|
expect(triggerHapticLightMock).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("calls copyToClipboard with @username when copy Telegram is clicked", async () => {
|
||||||
|
copyToClipboardMock.mockResolvedValue(true);
|
||||||
|
renderWithTooltip(
|
||||||
|
<ContactLinks
|
||||||
|
phone={null}
|
||||||
|
username="alice_dev"
|
||||||
|
layout="block"
|
||||||
|
showCopyButtons
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
const copyBtn = screen.getByRole("button", {
|
||||||
|
name: /Copy Telegram username|Скопировать логин Telegram/i,
|
||||||
|
});
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.click(copyBtn);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(copyToClipboardMock).toHaveBeenCalledWith("@alice_dev");
|
||||||
|
expect(triggerHapticLightMock).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows Copied via button aria-label and no tooltip after successful copy", async () => {
|
||||||
|
copyToClipboardMock.mockResolvedValue(true);
|
||||||
|
render(
|
||||||
|
<ContactLinks
|
||||||
|
phone="+79991234567"
|
||||||
|
username={null}
|
||||||
|
layout="block"
|
||||||
|
showCopyButtons
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
const copyBtn = screen.getByRole("button", {
|
||||||
|
name: /Copy phone number|Скопировать номер/i,
|
||||||
|
});
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.click(copyBtn);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(screen.queryByRole("tooltip")).not.toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.getByRole("button", { name: /Copied|Скопировано/i })
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reverts first copy button to Copy icon when copying the other field", async () => {
|
||||||
|
copyToClipboardMock.mockResolvedValue(true);
|
||||||
|
render(
|
||||||
|
<ContactLinks
|
||||||
|
phone="+79991234567"
|
||||||
|
username="alice_dev"
|
||||||
|
layout="block"
|
||||||
|
showCopyButtons
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
const copyPhoneBtn = screen.getByRole("button", {
|
||||||
|
name: /Copy phone number|Скопировать номер/i,
|
||||||
|
});
|
||||||
|
const copyTelegramBtn = screen.getByRole("button", {
|
||||||
|
name: /Copy Telegram username|Скопировать логин Telegram/i,
|
||||||
|
});
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.click(copyPhoneBtn);
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
screen.getByRole("button", { name: /Copied|Скопировано/i })
|
||||||
|
).toBeInTheDocument();
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.click(copyTelegramBtn);
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
screen.getByRole("button", { name: /Copy phone number|Скопировать номер/i })
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.getByRole("button", { name: /Copied|Скопировано/i })
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not show copy buttons when showCopyButtons is false", () => {
|
||||||
|
render(
|
||||||
|
<ContactLinks
|
||||||
|
phone="+79991234567"
|
||||||
|
username="bob"
|
||||||
|
layout="block"
|
||||||
|
showCopyButtons={false}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
screen.queryByRole("button", { name: /Copy phone number|Скопировать номер/i })
|
||||||
|
).not.toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.queryByRole("button", { name: /Copy Telegram username|Скопировать логин Telegram/i })
|
||||||
|
).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -5,11 +5,17 @@
|
|||||||
|
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useRef, useEffect } from "react";
|
||||||
import { useTranslation } from "@/i18n/use-translation";
|
import { useTranslation } from "@/i18n/use-translation";
|
||||||
import { formatPhoneDisplay } from "@/lib/phone-format";
|
import { formatPhoneDisplay } from "@/lib/phone-format";
|
||||||
import { Button } from "@/components/ui/button";
|
import { openPhoneLink } from "@/lib/open-phone-link";
|
||||||
|
import { openTelegramProfile } from "@/lib/telegram-link";
|
||||||
|
import { triggerHapticLight } from "@/lib/telegram-haptic";
|
||||||
|
import { copyToClipboard } from "@/lib/copy-to-clipboard";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { Phone as PhoneIcon, Send as TelegramIcon } from "lucide-react";
|
import { Phone as PhoneIcon, Send as TelegramIcon, Copy, Check } from "lucide-react";
|
||||||
|
|
||||||
|
const COPIED_RESET_MS = 1800;
|
||||||
|
|
||||||
export interface ContactLinksProps {
|
export interface ContactLinksProps {
|
||||||
phone?: string | null;
|
phone?: string | null;
|
||||||
@@ -18,6 +24,8 @@ export interface ContactLinksProps {
|
|||||||
showLabels?: boolean;
|
showLabels?: boolean;
|
||||||
/** Optional label for aria-label on links (e.g. duty holder name for "Call …", "Message … on Telegram"). */
|
/** Optional label for aria-label on links (e.g. duty holder name for "Call …", "Message … on Telegram"). */
|
||||||
contextLabel?: string;
|
contextLabel?: string;
|
||||||
|
/** When true and layout is "block", show copy buttons for phone and Telegram username. */
|
||||||
|
showCopyButtons?: boolean;
|
||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -33,16 +41,62 @@ export function ContactLinks({
|
|||||||
layout = "inline",
|
layout = "inline",
|
||||||
showLabels = true,
|
showLabels = true,
|
||||||
contextLabel,
|
contextLabel,
|
||||||
|
showCopyButtons = false,
|
||||||
className,
|
className,
|
||||||
}: ContactLinksProps) {
|
}: ContactLinksProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const [copiedKind, setCopiedKind] = useState<"phone" | "telegram" | null>(null);
|
||||||
|
const copiedTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
const hasPhone = Boolean(phone && String(phone).trim());
|
const hasPhone = Boolean(phone && String(phone).trim());
|
||||||
const rawUsername = username && String(username).trim();
|
const rawUsername = username && String(username).trim();
|
||||||
const cleanUsername = rawUsername ? rawUsername.replace(/^@+/, "") : "";
|
const cleanUsername = rawUsername ? rawUsername.replace(/^@+/, "") : "";
|
||||||
const hasUsername = Boolean(cleanUsername);
|
const hasUsername = Boolean(cleanUsername);
|
||||||
|
|
||||||
|
const showCopy = layout === "block" && showCopyButtons;
|
||||||
|
|
||||||
|
useEffect(() => () => clearCopiedTimeout(), []);
|
||||||
|
|
||||||
|
const clearCopiedTimeout = () => {
|
||||||
|
if (copiedTimeoutRef.current) {
|
||||||
|
clearTimeout(copiedTimeoutRef.current);
|
||||||
|
copiedTimeoutRef.current = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const showCopiedFeedback = (kind: "phone" | "telegram") => {
|
||||||
|
clearCopiedTimeout();
|
||||||
|
setCopiedKind(kind);
|
||||||
|
copiedTimeoutRef.current = setTimeout(() => {
|
||||||
|
setCopiedKind(null);
|
||||||
|
copiedTimeoutRef.current = null;
|
||||||
|
}, COPIED_RESET_MS);
|
||||||
|
};
|
||||||
|
|
||||||
if (!hasPhone && !hasUsername) return null;
|
if (!hasPhone && !hasUsername) return null;
|
||||||
|
|
||||||
|
const handlePhoneClick = (e: React.MouseEvent<HTMLAnchorElement>) => {
|
||||||
|
e.preventDefault();
|
||||||
|
openPhoneLink(phone ?? undefined);
|
||||||
|
triggerHapticLight();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCopyPhone = async (e: React.MouseEvent<HTMLButtonElement>) => {
|
||||||
|
e.preventDefault();
|
||||||
|
triggerHapticLight();
|
||||||
|
const rawPhone = String(phone).trim();
|
||||||
|
const ok = await copyToClipboard(rawPhone);
|
||||||
|
if (ok) showCopiedFeedback("phone");
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCopyTelegram = async (e: React.MouseEvent<HTMLButtonElement>) => {
|
||||||
|
e.preventDefault();
|
||||||
|
triggerHapticLight();
|
||||||
|
const text = `@${cleanUsername}`;
|
||||||
|
const ok = await copyToClipboard(text);
|
||||||
|
if (ok) showCopiedFeedback("telegram");
|
||||||
|
};
|
||||||
|
|
||||||
const ariaCall = contextLabel
|
const ariaCall = contextLabel
|
||||||
? t("contact.aria_call", { name: contextLabel })
|
? t("contact.aria_call", { name: contextLabel })
|
||||||
: t("contact.phone");
|
: t("contact.phone");
|
||||||
@@ -51,38 +105,78 @@ export function ContactLinks({
|
|||||||
: t("contact.telegram");
|
: t("contact.telegram");
|
||||||
|
|
||||||
if (layout === "block") {
|
if (layout === "block") {
|
||||||
|
const rowClass =
|
||||||
|
"flex h-12 items-center gap-0 rounded-md border border-input bg-background shadow-xs text-accent hover:bg-accent/10 hover:text-accent dark:bg-input/30 dark:hover:bg-input/50";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn("flex flex-col gap-2", className)}>
|
<div className={cn("flex flex-col gap-2", className)}>
|
||||||
{hasPhone && (
|
{hasPhone && (
|
||||||
<Button
|
<div className={rowClass}>
|
||||||
variant="outline"
|
<a
|
||||||
size="sm"
|
href={`tel:${String(phone).trim()}`}
|
||||||
className="h-12 justify-start gap-3 px-4 text-accent hover:bg-accent/10 hover:text-accent"
|
aria-label={ariaCall}
|
||||||
asChild
|
onClick={handlePhoneClick}
|
||||||
|
className="flex min-w-0 flex-1 items-center gap-3 px-4 py-2"
|
||||||
>
|
>
|
||||||
<a href={`tel:${String(phone).trim()}`} aria-label={ariaCall}>
|
<PhoneIcon className="size-5 shrink-0" aria-hidden />
|
||||||
<PhoneIcon className="size-5" aria-hidden />
|
<span className="truncate">{formatPhoneDisplay(phone!)}</span>
|
||||||
<span>{formatPhoneDisplay(phone!)}</span>
|
|
||||||
</a>
|
</a>
|
||||||
</Button>
|
{showCopy && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="flex h-12 w-12 shrink-0 items-center justify-center rounded-r-[calc(theme(borderRadius.md)-1px)] text-accent hover:bg-accent/15 focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent focus-visible:outline-offset-[-2px]"
|
||||||
|
aria-label={copiedKind === "phone" ? t("contact.copied") : t("contact.copy_phone")}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
void handleCopyPhone(e);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{copiedKind === "phone" ? (
|
||||||
|
<Check className="size-5" aria-hidden />
|
||||||
|
) : (
|
||||||
|
<Copy className="size-5" aria-hidden />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
{hasUsername && (
|
{hasUsername && (
|
||||||
<Button
|
<div className={rowClass}>
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
className="h-12 justify-start gap-3 px-4 text-accent hover:bg-accent/10 hover:text-accent"
|
|
||||||
asChild
|
|
||||||
>
|
|
||||||
<a
|
<a
|
||||||
href={`https://t.me/${encodeURIComponent(cleanUsername)}`}
|
href={`https://t.me/${encodeURIComponent(cleanUsername)}`}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
aria-label={ariaTelegram}
|
aria-label={ariaTelegram}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
openTelegramProfile(cleanUsername);
|
||||||
|
triggerHapticLight();
|
||||||
|
}}
|
||||||
|
className="flex min-w-0 flex-1 items-center gap-3 px-4 py-2"
|
||||||
>
|
>
|
||||||
<TelegramIcon className="size-5" aria-hidden />
|
<TelegramIcon className="size-5 shrink-0" aria-hidden />
|
||||||
<span>@{cleanUsername}</span>
|
<span className="truncate">@{cleanUsername}</span>
|
||||||
</a>
|
</a>
|
||||||
</Button>
|
{showCopy && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="flex h-12 w-12 shrink-0 items-center justify-center rounded-r-[calc(theme(borderRadius.md)-1px)] text-accent hover:bg-accent/15 focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent focus-visible:outline-offset-[-2px]"
|
||||||
|
aria-label={copiedKind === "telegram" ? t("contact.copied") : t("contact.copy_telegram")}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
void handleCopyTelegram(e);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{copiedKind === "telegram" ? (
|
||||||
|
<Check className="size-5" aria-hidden />
|
||||||
|
) : (
|
||||||
|
<Copy className="size-5" aria-hidden />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -99,6 +193,7 @@ export function ContactLinks({
|
|||||||
href={`tel:${String(phone).trim()}`}
|
href={`tel:${String(phone).trim()}`}
|
||||||
className={linkClass}
|
className={linkClass}
|
||||||
aria-label={ariaCall}
|
aria-label={ariaCall}
|
||||||
|
onClick={handlePhoneClick}
|
||||||
>
|
>
|
||||||
{displayPhone}
|
{displayPhone}
|
||||||
</a>
|
</a>
|
||||||
@@ -109,6 +204,7 @@ export function ContactLinks({
|
|||||||
href={`tel:${String(phone).trim()}`}
|
href={`tel:${String(phone).trim()}`}
|
||||||
className={linkClass}
|
className={linkClass}
|
||||||
aria-label={ariaCall}
|
aria-label={ariaCall}
|
||||||
|
onClick={handlePhoneClick}
|
||||||
>
|
>
|
||||||
{displayPhone}
|
{displayPhone}
|
||||||
</a>
|
</a>
|
||||||
@@ -117,6 +213,11 @@ export function ContactLinks({
|
|||||||
}
|
}
|
||||||
if (hasUsername) {
|
if (hasUsername) {
|
||||||
const href = `https://t.me/${encodeURIComponent(cleanUsername)}`;
|
const href = `https://t.me/${encodeURIComponent(cleanUsername)}`;
|
||||||
|
const handleTelegramClick = (e: React.MouseEvent<HTMLAnchorElement>) => {
|
||||||
|
e.preventDefault();
|
||||||
|
openTelegramProfile(cleanUsername);
|
||||||
|
triggerHapticLight();
|
||||||
|
};
|
||||||
const link = (
|
const link = (
|
||||||
<a
|
<a
|
||||||
key="tg"
|
key="tg"
|
||||||
@@ -125,6 +226,7 @@ export function ContactLinks({
|
|||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className={linkClass}
|
className={linkClass}
|
||||||
aria-label={ariaTelegram}
|
aria-label={ariaTelegram}
|
||||||
|
onClick={handleTelegramClick}
|
||||||
>
|
>
|
||||||
@{cleanUsername}
|
@{cleanUsername}
|
||||||
</a>
|
</a>
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||||
import { render, screen, fireEvent } from "@testing-library/react";
|
import { render, screen, fireEvent } from "@testing-library/react";
|
||||||
import { CurrentDutyView } from "./CurrentDutyView";
|
import { CurrentDutyView } from "./CurrentDutyView";
|
||||||
|
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||||
import { resetAppStore } from "@/test/test-utils";
|
import { resetAppStore } from "@/test/test-utils";
|
||||||
|
|
||||||
vi.mock("@/hooks/use-telegram-auth", () => ({
|
vi.mock("@/hooks/use-telegram-auth", () => ({
|
||||||
@@ -148,4 +149,37 @@ describe("CurrentDutyView", () => {
|
|||||||
expect(onBack).toHaveBeenCalled();
|
expect(onBack).toHaveBeenCalled();
|
||||||
vi.mocked(fetchDuties).mockResolvedValue([]);
|
vi.mocked(fetchDuties).mockResolvedValue([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("shows copy phone and copy Telegram buttons when duty has contacts", async () => {
|
||||||
|
const { fetchDuties } = await import("@/lib/api");
|
||||||
|
const now = new Date();
|
||||||
|
const start = new Date(now.getTime() - 60 * 60 * 1000);
|
||||||
|
const end = new Date(now.getTime() + 60 * 60 * 1000);
|
||||||
|
const dutyWithContacts = {
|
||||||
|
id: 1,
|
||||||
|
user_id: 1,
|
||||||
|
start_at: start.toISOString(),
|
||||||
|
end_at: end.toISOString(),
|
||||||
|
event_type: "duty" as const,
|
||||||
|
full_name: "Test User",
|
||||||
|
phone: "+79991234567",
|
||||||
|
username: "testuser",
|
||||||
|
};
|
||||||
|
vi.mocked(fetchDuties).mockResolvedValue([dutyWithContacts]);
|
||||||
|
render(
|
||||||
|
<TooltipProvider>
|
||||||
|
<CurrentDutyView onBack={vi.fn()} />
|
||||||
|
</TooltipProvider>
|
||||||
|
);
|
||||||
|
await screen.findByText("Test User", {}, { timeout: 3000 });
|
||||||
|
expect(
|
||||||
|
screen.getByRole("button", { name: /Copy phone number|Скопировать номер/i })
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.getByRole("button", {
|
||||||
|
name: /Copy Telegram username|Скопировать логин Telegram/i,
|
||||||
|
})
|
||||||
|
).toBeInTheDocument();
|
||||||
|
vi.mocked(fetchDuties).mockResolvedValue([]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -8,9 +8,9 @@
|
|||||||
"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 { translate } from "@/i18n/messages";
|
||||||
import { useAppStore } from "@/store/app-store";
|
import { useAppStore } from "@/store/app-store";
|
||||||
import { useTelegramAuth } from "@/hooks/use-telegram-auth";
|
import { useTelegramAuth } from "@/hooks/use-telegram-auth";
|
||||||
import { fetchDuties, AccessDeniedError } from "@/lib/api";
|
import { fetchDuties, AccessDeniedError } from "@/lib/api";
|
||||||
@@ -20,6 +20,7 @@ import {
|
|||||||
formatHHMM,
|
formatHHMM,
|
||||||
} from "@/lib/date-utils";
|
} from "@/lib/date-utils";
|
||||||
import { getRemainingTime, findCurrentDuty } from "@/lib/current-duty";
|
import { getRemainingTime, findCurrentDuty } from "@/lib/current-duty";
|
||||||
|
import { triggerHapticLight } from "@/lib/telegram-haptic";
|
||||||
import { ContactLinks } from "@/components/contact/ContactLinks";
|
import { ContactLinks } from "@/components/contact/ContactLinks";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
@@ -32,6 +33,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). */
|
||||||
@@ -40,22 +44,26 @@ 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) => {
|
||||||
@@ -68,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 {
|
||||||
@@ -77,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(translate(lang, "error_generic"));
|
||||||
setErrorMessage(t("error_generic"));
|
|
||||||
setDuty(null);
|
setDuty(null);
|
||||||
setRemaining(null);
|
setRemaining(null);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[initDataRaw, lang, t]
|
[initDataRaw, lang, 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.
|
||||||
@@ -99,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(() => {
|
||||||
@@ -115,45 +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();
|
||||||
onBack();
|
onBack();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const closeMiniAppOrFallback = useTelegramCloseAction(onBack);
|
||||||
|
|
||||||
const handleClose = () => {
|
const handleClose = () => {
|
||||||
if (closeMiniApp.isAvailable()) {
|
triggerHapticLight();
|
||||||
closeMiniApp();
|
closeMiniAppOrFallback();
|
||||||
} else {
|
|
||||||
onBack();
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const primaryButtonLabel = openedFromPin ? t("current_duty.close") : t("current_duty.back");
|
const primaryButtonLabel = openedFromPin ? t("current_duty.close") : t("current_duty.back");
|
||||||
@@ -162,10 +139,10 @@ 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 px-4"
|
className="flex min-h-[50vh] flex-col items-center justify-center gap-4"
|
||||||
role="status"
|
role="status"
|
||||||
aria-live="polite"
|
aria-live="polite"
|
||||||
aria-label={t("loading")}
|
aria-label={t("loading")}
|
||||||
@@ -200,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}
|
||||||
@@ -211,16 +188,17 @@ export function CurrentDutyView({ onBack, openedFromPin = false }: CurrentDutyVi
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (state === "error") {
|
if (isError) {
|
||||||
const handleRetry = () => {
|
const handleRetry = () => {
|
||||||
setState("loading");
|
triggerHapticLight();
|
||||||
|
setLoading();
|
||||||
loadTodayDuties();
|
loadTodayDuties();
|
||||||
};
|
};
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-[50vh] flex-col items-center justify-center gap-4 px-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
|
||||||
@@ -245,7 +223,7 @@ export function CurrentDutyView({ onBack, openedFromPin = false }: CurrentDutyVi
|
|||||||
|
|
||||||
if (!duty) {
|
if (!duty) {
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-[50vh] flex-col items-center justify-center gap-4 px-4">
|
<div className="flex min-h-[50vh] flex-col items-center justify-center gap-4">
|
||||||
<Card className="current-duty-card--no-duty w-full max-w-[var(--max-width-app)] border-t-4 border-t-muted">
|
<Card className="current-duty-card--no-duty w-full max-w-[var(--max-width-app)] border-t-4 border-t-muted">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>{t("current_duty.title")}</CardTitle>
|
<CardTitle>{t("current_duty.title")}</CardTitle>
|
||||||
@@ -303,7 +281,7 @@ export function CurrentDutyView({ onBack, openedFromPin = false }: CurrentDutyVi
|
|||||||
Boolean(duty.username && String(duty.username).trim());
|
Boolean(duty.username && String(duty.username).trim());
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-[50vh] flex-col items-center justify-center gap-4 px-4">
|
<div className="flex min-h-[50vh] flex-col items-center justify-center gap-4">
|
||||||
<Card
|
<Card
|
||||||
className="current-duty-card w-full max-w-[var(--max-width-app)] border-t-4 border-t-duty animate-in fade-in-0 slide-in-from-bottom-4 duration-300 motion-reduce:animate-none motion-reduce:duration-0"
|
className="current-duty-card w-full max-w-[var(--max-width-app)] border-t-4 border-t-duty animate-in fade-in-0 slide-in-from-bottom-4 duration-300 motion-reduce:animate-none motion-reduce:duration-0"
|
||||||
role="article"
|
role="article"
|
||||||
@@ -336,13 +314,12 @@ export function CurrentDutyView({ onBack, openedFromPin = false }: CurrentDutyVi
|
|||||||
</section>
|
</section>
|
||||||
<div
|
<div
|
||||||
className="rounded-lg bg-duty/10 px-3 py-2.5 flex flex-col gap-1"
|
className="rounded-lg bg-duty/10 px-3 py-2.5 flex flex-col gap-1"
|
||||||
aria-live="polite"
|
aria-label={t("current_duty.remaining_label")}
|
||||||
aria-atomic="true"
|
|
||||||
>
|
>
|
||||||
<span className="text-xs text-muted-foreground">
|
<span className="text-xs text-muted-foreground">
|
||||||
{t("current_duty.remaining_label")}
|
{t("current_duty.remaining_label")}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-xl font-semibold text-foreground tabular-nums">
|
<span className="text-xl font-semibold text-foreground tabular-nums" aria-hidden>
|
||||||
{remainingValueStr}
|
{remainingValueStr}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-xs text-muted-foreground">
|
<span className="text-xs text-muted-foreground">
|
||||||
@@ -357,6 +334,7 @@ export function CurrentDutyView({ onBack, openedFromPin = false }: CurrentDutyVi
|
|||||||
layout="block"
|
layout="block"
|
||||||
showLabels={true}
|
showLabels={true}
|
||||||
contextLabel={duty.full_name ?? undefined}
|
contextLabel={duty.full_name ?? undefined}
|
||||||
|
showCopyButtons={true}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
|
|||||||
@@ -168,7 +168,7 @@ export const DayDetail = React.forwardRef<DayDetailHandle, DayDetailProps>(
|
|||||||
if (!open || !selectedDay) return null;
|
if (!open || !selectedDay) return null;
|
||||||
|
|
||||||
const panelClassName =
|
const panelClassName =
|
||||||
"max-w-[min(360px,calc(100vw-24px))] max-h-[70vh] overflow-auto bg-surface text-[var(--text)] rounded-xl shadow-lg p-4 pt-9";
|
"max-w-[min(360px,calc(100vw - var(--app-safe-left, 0) - var(--app-safe-right, 0) - 24px))] max-h-[70vh] overflow-auto bg-surface text-[var(--text)] rounded-xl shadow-lg p-4 pt-9";
|
||||||
const closeButton = (
|
const closeButton = (
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -204,11 +204,12 @@ export const DayDetail = React.forwardRef<DayDetailHandle, DayDetailProps>(
|
|||||||
<SheetContent
|
<SheetContent
|
||||||
side="bottom"
|
side="bottom"
|
||||||
className={cn(
|
className={cn(
|
||||||
"rounded-t-2xl pt-3 pb-[calc(24px+env(safe-area-inset-bottom,0px))] max-h-[70vh] bg-[var(--surface)]",
|
"rounded-t-2xl pt-3 max-h-[70vh] bg-[var(--surface)]",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
@@ -1,28 +1,82 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect } from "react";
|
import { createContext, useContext, useEffect, useState } from "react";
|
||||||
import {
|
import {
|
||||||
init,
|
init,
|
||||||
mountMiniAppSync,
|
mountMiniAppSync,
|
||||||
mountThemeParamsSync,
|
mountThemeParamsSync,
|
||||||
bindThemeParamsCssVars,
|
bindThemeParamsCssVars,
|
||||||
|
mountViewport,
|
||||||
|
bindViewportCssVars,
|
||||||
|
unmountViewport,
|
||||||
} from "@telegram-apps/sdk-react";
|
} from "@telegram-apps/sdk-react";
|
||||||
import { fixSurfaceContrast } from "@/hooks/use-telegram-theme";
|
import { fixSurfaceContrast, useTelegramTheme } from "@/hooks/use-telegram-theme";
|
||||||
|
import { applyAndroidPerformanceClass } from "@/lib/telegram-android-perf";
|
||||||
|
import { useAppStore } from "@/store/app-store";
|
||||||
|
import { getLang } from "@/i18n/messages";
|
||||||
|
import { useTranslation } from "@/i18n/use-translation";
|
||||||
|
|
||||||
|
const EVENT_CONFIG_LOADED = "dt-config-loaded";
|
||||||
|
|
||||||
|
export interface TelegramSdkContextValue {
|
||||||
|
/** True after init() and sync mounts have run; safe to use backButton etc. */
|
||||||
|
sdkReady: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TelegramSdkContext = createContext<TelegramSdkContextValue>({
|
||||||
|
sdkReady: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
export function useTelegramSdkReady(): TelegramSdkContextValue {
|
||||||
|
return useContext(TelegramSdkContext);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Wraps the app with Telegram Mini App SDK initialization.
|
* Wraps the app with Telegram Mini App SDK initialization.
|
||||||
* Calls init(acceptCustomStyles), mounts theme params (binds --tg-theme-* CSS vars),
|
* Calls init(acceptCustomStyles), mounts theme params (binds --tg-theme-* CSS vars),
|
||||||
* and mounts the mini app. Does not call ready() here — the app calls
|
* mounts the mini app, then mounts viewport and binds viewport CSS vars
|
||||||
* callMiniAppReadyOnce() from lib/telegram-ready when the first visible screen
|
* (--tg-viewport-stable-height, --tg-viewport-content-safe-area-inset-*, etc.).
|
||||||
* has finished loading, so Telegram keeps its native loading animation until then.
|
* Does not call ready() here — the app calls callMiniAppReadyOnce() from
|
||||||
|
* lib/telegram-ready when the first visible screen has finished loading.
|
||||||
* Theme is set before first paint by the inline script in layout.tsx (URL hash);
|
* Theme is set before first paint by the inline script in layout.tsx (URL hash);
|
||||||
* useTelegramTheme() in the app handles ongoing theme changes.
|
* useTelegramTheme() in the app handles ongoing theme changes.
|
||||||
|
* Syncs lang from window.__DT_LANG on mount and when config.js fires dt-config-loaded.
|
||||||
*/
|
*/
|
||||||
|
/**
|
||||||
|
* Live theme sync: provider-owned so every route (/, /admin, not-found, error) gets
|
||||||
|
* data-theme and Mini App chrome color updates when Telegram theme changes.
|
||||||
|
*/
|
||||||
|
function ThemeSync() {
|
||||||
|
useTelegramTheme();
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
export function TelegramProvider({
|
export function TelegramProvider({
|
||||||
children,
|
children,
|
||||||
}: {
|
}: {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}) {
|
}) {
|
||||||
|
const [sdkReady, setSdkReady] = useState(false);
|
||||||
|
const setLang = useAppStore((s) => s.setLang);
|
||||||
|
const lang = useAppStore((s) => s.lang);
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
// Sync lang from backend config: on mount and when config.js has loaded (all routes, including admin).
|
||||||
|
useEffect(() => {
|
||||||
|
if (typeof window === "undefined") return;
|
||||||
|
setLang(getLang());
|
||||||
|
const onConfigLoaded = () => setLang(getLang());
|
||||||
|
window.addEventListener(EVENT_CONFIG_LOADED, onConfigLoaded);
|
||||||
|
return () => window.removeEventListener(EVENT_CONFIG_LOADED, onConfigLoaded);
|
||||||
|
}, [setLang]);
|
||||||
|
|
||||||
|
// Apply lang to document (title and html lang) so all routes including admin get correct title.
|
||||||
|
useEffect(() => {
|
||||||
|
if (typeof document === "undefined") return;
|
||||||
|
document.documentElement.lang = lang;
|
||||||
|
document.title = t("app.title");
|
||||||
|
}, [lang, t]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const cleanup = init({ acceptCustomStyles: true });
|
const cleanup = init({ acceptCustomStyles: true });
|
||||||
|
|
||||||
@@ -39,8 +93,35 @@ export function TelegramProvider({
|
|||||||
mountMiniAppSync();
|
mountMiniAppSync();
|
||||||
}
|
}
|
||||||
|
|
||||||
return cleanup;
|
applyAndroidPerformanceClass();
|
||||||
|
|
||||||
|
setSdkReady(true);
|
||||||
|
|
||||||
|
let unbindViewportCssVars: (() => void) | undefined;
|
||||||
|
if (mountViewport.isAvailable()) {
|
||||||
|
mountViewport()
|
||||||
|
.then(() => {
|
||||||
|
if (bindViewportCssVars.isAvailable()) {
|
||||||
|
unbindViewportCssVars = bindViewportCssVars();
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
// Viewport not supported (e.g. not in Mini App); ignore.
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
setSdkReady(false);
|
||||||
|
unbindViewportCssVars?.();
|
||||||
|
unmountViewport();
|
||||||
|
cleanup();
|
||||||
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return <>{children}</>;
|
return (
|
||||||
|
<TelegramSdkContext.Provider value={{ sdkReady }}>
|
||||||
|
<ThemeSync />
|
||||||
|
{children}
|
||||||
|
</TelegramSdkContext.Provider>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,9 @@
|
|||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
import { getLang, translate } from "@/i18n/messages";
|
import { getLang, translate } from "@/i18n/messages";
|
||||||
import { useAppStore } from "@/store/app-store";
|
import { useAppStore } from "@/store/app-store";
|
||||||
|
import { triggerHapticLight } from "@/lib/telegram-haptic";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { FullScreenStateShell } from "@/components/states/FullScreenStateShell";
|
||||||
|
|
||||||
export interface AccessDeniedScreenProps {
|
export interface AccessDeniedScreenProps {
|
||||||
/** Optional detail from API 403 response, shown below the hint. */
|
/** Optional detail from API 403 response, shown below the hint. */
|
||||||
@@ -40,6 +43,7 @@ export function AccessDeniedScreen({
|
|||||||
const hasDetail = Boolean(serverDetail && String(serverDetail).trim());
|
const hasDetail = Boolean(serverDetail && String(serverDetail).trim());
|
||||||
|
|
||||||
const handleClick = () => {
|
const handleClick = () => {
|
||||||
|
triggerHapticLight();
|
||||||
if (primaryAction === "reload") {
|
if (primaryAction === "reload") {
|
||||||
if (typeof window !== "undefined") {
|
if (typeof window !== "undefined") {
|
||||||
window.location.reload();
|
window.location.reload();
|
||||||
@@ -57,28 +61,20 @@ export function AccessDeniedScreen({
|
|||||||
: translate(lang, "current_duty.back");
|
: translate(lang, "current_duty.back");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<FullScreenStateShell
|
||||||
className="flex min-h-screen flex-col items-center justify-center gap-4 bg-background px-4 text-foreground"
|
title={translate(lang, "access_denied")}
|
||||||
role="alert"
|
description={translate(lang, "access_denied.hint")}
|
||||||
|
primaryAction={
|
||||||
|
<Button type="button" onClick={handleClick}>
|
||||||
|
{buttonLabel}
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<h1 className="text-xl font-semibold">
|
|
||||||
{translate(lang, "access_denied")}
|
|
||||||
</h1>
|
|
||||||
<p className="text-center text-muted-foreground">
|
|
||||||
{translate(lang, "access_denied.hint")}
|
|
||||||
</p>
|
|
||||||
{hasDetail && (
|
{hasDetail && (
|
||||||
<p className="text-center text-sm text-muted-foreground">
|
<p className="text-center text-sm text-muted-foreground">
|
||||||
{serverDetail}
|
{serverDetail}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
<button
|
</FullScreenStateShell>
|
||||||
type="button"
|
|
||||||
onClick={handleClick}
|
|
||||||
className="rounded-lg bg-primary px-4 py-2 text-primary-foreground hover:bg-primary/90"
|
|
||||||
>
|
|
||||||
{buttonLabel}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
import { useTranslation } from "@/i18n/use-translation";
|
import { useTranslation } from "@/i18n/use-translation";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
import { triggerHapticLight } from "@/lib/telegram-haptic";
|
||||||
|
|
||||||
export interface ErrorStateProps {
|
export interface ErrorStateProps {
|
||||||
/** Error message to display. If not provided, uses generic i18n message. */
|
/** Error message to display. If not provided, uses generic i18n message. */
|
||||||
@@ -65,7 +66,10 @@ export function ErrorState({ message, onRetry, className }: ErrorStateProps) {
|
|||||||
variant="default"
|
variant="default"
|
||||||
size="sm"
|
size="sm"
|
||||||
className="mt-1 bg-primary text-primary-foreground hover:opacity-90 focus-visible:outline-2 focus-visible:outline-accent focus-visible:outline-offset-2"
|
className="mt-1 bg-primary text-primary-foreground hover:opacity-90 focus-visible:outline-2 focus-visible:outline-accent focus-visible:outline-offset-2"
|
||||||
onClick={onRetry}
|
onClick={() => {
|
||||||
|
triggerHapticLight();
|
||||||
|
onRetry();
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{t("error.retry")}
|
{t("error.retry")}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
51
webapp-next/src/components/states/FullScreenStateShell.tsx
Normal file
51
webapp-next/src/components/states/FullScreenStateShell.tsx
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
/**
|
||||||
|
* Shared full-screen state layout for fallback screens (access denied, not-found, error).
|
||||||
|
* Uses content-safe, app tokens, and consistent spacing so all fallback screens look like the same app.
|
||||||
|
*/
|
||||||
|
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { MiniAppScreen, MiniAppScreenContent } from "@/components/layout/MiniAppScreen";
|
||||||
|
|
||||||
|
export interface FullScreenStateShellProps {
|
||||||
|
/** Main heading (e.g. "Access denied", "Page not found"). */
|
||||||
|
title: React.ReactNode;
|
||||||
|
/** Optional description or message below the title. */
|
||||||
|
description?: React.ReactNode;
|
||||||
|
/** Optional extra content (e.g. server detail, secondary text). */
|
||||||
|
children?: React.ReactNode;
|
||||||
|
/** Primary action (Button or Link). */
|
||||||
|
primaryAction: React.ReactNode;
|
||||||
|
/** Wrapper role. Default "alert" for error/denied states. */
|
||||||
|
role?: "alert" | "status";
|
||||||
|
/** Optional extra class names for the wrapper. */
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Full-screen centered shell with title, optional description, and primary action.
|
||||||
|
* Use for access denied, not-found, and in-app error boundary screens.
|
||||||
|
*/
|
||||||
|
export function FullScreenStateShell({
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
children,
|
||||||
|
primaryAction,
|
||||||
|
role = "alert",
|
||||||
|
className,
|
||||||
|
}: FullScreenStateShellProps) {
|
||||||
|
return (
|
||||||
|
<MiniAppScreen className={className}>
|
||||||
|
<MiniAppScreenContent className="items-center justify-center gap-4 px-3 text-foreground">
|
||||||
|
<div role={role} className="flex flex-col items-center gap-4">
|
||||||
|
<h1 className="text-xl font-semibold">{title}</h1>
|
||||||
|
{description != null && (
|
||||||
|
<p className="text-center text-muted-foreground">{description}</p>
|
||||||
|
)}
|
||||||
|
{children}
|
||||||
|
{primaryAction}
|
||||||
|
</div>
|
||||||
|
</MiniAppScreenContent>
|
||||||
|
</MiniAppScreen>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -20,7 +20,7 @@ function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
|
|||||||
<div
|
<div
|
||||||
data-slot="card-header"
|
data-slot="card-header"
|
||||||
className={cn(
|
className={cn(
|
||||||
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
|
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-4 sm:px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
@@ -65,7 +65,7 @@ function CardContent({ className, ...props }: React.ComponentProps<"div">) {
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
data-slot="card-content"
|
data-slot="card-content"
|
||||||
className={cn("px-6", className)}
|
className={cn("px-4 sm:px-6", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
@@ -75,7 +75,7 @@ function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
data-slot="card-footer"
|
data-slot="card-footer"
|
||||||
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
|
className={cn("flex items-center px-4 sm:px-6 [.border-t]:pt-6", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
@@ -94,7 +97,7 @@ function SheetContent({
|
|||||||
side === "top" &&
|
side === "top" &&
|
||||||
"inset-x-0 top-0 h-auto border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top",
|
"inset-x-0 top-0 h-auto border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top",
|
||||||
side === "bottom" &&
|
side === "bottom" &&
|
||||||
"inset-x-0 bottom-0 h-auto border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom",
|
"inset-x-0 bottom-0 h-auto border-t pb-[calc(24px+var(--tg-viewport-content-safe-area-inset-bottom,env(safe-area-inset-bottom,0px)))] data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ function TooltipContent({
|
|||||||
data-slot="tooltip-content"
|
data-slot="tooltip-content"
|
||||||
sideOffset={sideOffset}
|
sideOffset={sideOffset}
|
||||||
className={cn(
|
className={cn(
|
||||||
"z-50 w-fit max-w-[min(98vw,380px)] origin-(--radix-tooltip-content-transform-origin) animate-in rounded-lg bg-surface px-3 py-2 text-[0.85rem] leading-snug text-[var(--text)] shadow-[0_4px_12px_rgba(0,0,0,0.4)] fade-in-0 zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95",
|
"z-50 w-fit max-w-[min(98vw,380px)] origin-(--radix-tooltip-content-transform-origin) animate-in rounded-lg bg-surface px-3 py-2 text-[0.85rem] leading-snug text-[var(--text)] shadow-[var(--shadow-card)] fade-in-0 zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
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]);
|
||||||
|
}
|
||||||
@@ -1,14 +1,12 @@
|
|||||||
/**
|
/**
|
||||||
* Application initialization: language sync, access-denied logic, deep link routing.
|
* Application initialization: access-denied logic and deep link routing.
|
||||||
* Runs effects that depend on Telegram auth (isAllowed, startParam); caller provides those.
|
* Document lang/title are owned by TelegramProvider (all routes).
|
||||||
*/
|
*/
|
||||||
|
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
import { useAppStore } from "@/store/app-store";
|
import { useAppStore } from "@/store/app-store";
|
||||||
import { getLang } from "@/i18n/messages";
|
|
||||||
import { useTranslation } from "@/i18n/use-translation";
|
|
||||||
import { RETRY_DELAY_MS } from "@/lib/constants";
|
import { RETRY_DELAY_MS } from "@/lib/constants";
|
||||||
|
|
||||||
export interface UseAppInitParams {
|
export interface UseAppInitParams {
|
||||||
@@ -19,29 +17,12 @@ export interface UseAppInitParams {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Syncs language from backend config, applies document lang/title, handles access denied
|
* Handles access denied when not allowed and routes to current duty view when opened via startParam=duty.
|
||||||
* when not allowed, and routes to current duty view when opened via startParam=duty.
|
|
||||||
*/
|
*/
|
||||||
export function useAppInit({ isAllowed, startParam }: UseAppInitParams): void {
|
export function useAppInit({ isAllowed, startParam }: UseAppInitParams): void {
|
||||||
const setLang = useAppStore((s) => s.setLang);
|
|
||||||
const lang = useAppStore((s) => s.lang);
|
|
||||||
const setAccessDenied = useAppStore((s) => s.setAccessDenied);
|
const setAccessDenied = useAppStore((s) => s.setAccessDenied);
|
||||||
const setLoading = useAppStore((s) => s.setLoading);
|
const setLoading = useAppStore((s) => s.setLoading);
|
||||||
const setCurrentView = useAppStore((s) => s.setCurrentView);
|
const setCurrentView = useAppStore((s) => s.setCurrentView);
|
||||||
const { t } = useTranslation();
|
|
||||||
|
|
||||||
// Sync lang from backend config (window.__DT_LANG).
|
|
||||||
useEffect(() => {
|
|
||||||
if (typeof window === "undefined") return;
|
|
||||||
setLang(getLang());
|
|
||||||
}, [setLang]);
|
|
||||||
|
|
||||||
// Apply lang to document (title and html lang) for accessibility and i18n.
|
|
||||||
useEffect(() => {
|
|
||||||
if (typeof document === "undefined") return;
|
|
||||||
document.documentElement.lang = lang;
|
|
||||||
document.title = t("app.title");
|
|
||||||
}, [lang, t]);
|
|
||||||
|
|
||||||
// When not allowed (no initData and not localhost), show access denied after delay.
|
// When not allowed (no initData and not localhost), show access denied after delay.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
42
webapp-next/src/hooks/use-auto-refresh.test.ts
Normal file
42
webapp-next/src/hooks/use-auto-refresh.test.ts
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
/**
|
||||||
|
* Unit tests for useAutoRefresh: no immediate refresh (avoids duplicate first fetch).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||||
|
import { renderHook } from "@testing-library/react";
|
||||||
|
import { useAutoRefresh } from "./use-auto-refresh";
|
||||||
|
|
||||||
|
describe("useAutoRefresh", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not call refresh immediately when isCurrentMonth is true", () => {
|
||||||
|
const refresh = vi.fn();
|
||||||
|
renderHook(() => useAutoRefresh(refresh, true));
|
||||||
|
|
||||||
|
expect(refresh).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("calls refresh on interval when isCurrentMonth is true", () => {
|
||||||
|
const refresh = vi.fn();
|
||||||
|
renderHook(() => useAutoRefresh(refresh, true));
|
||||||
|
|
||||||
|
vi.advanceTimersByTime(60_000);
|
||||||
|
expect(refresh).toHaveBeenCalledTimes(1);
|
||||||
|
vi.advanceTimersByTime(60_000);
|
||||||
|
expect(refresh).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not call refresh when isCurrentMonth is false", () => {
|
||||||
|
const refresh = vi.fn();
|
||||||
|
renderHook(() => useAutoRefresh(refresh, false));
|
||||||
|
|
||||||
|
vi.advanceTimersByTime(120_000);
|
||||||
|
expect(refresh).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -10,7 +10,8 @@ import { useEffect, useRef } from "react";
|
|||||||
const AUTO_REFRESH_INTERVAL_MS = 60000;
|
const AUTO_REFRESH_INTERVAL_MS = 60000;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* When isCurrentMonth is true, calls refresh() immediately, then every 60 seconds.
|
* When isCurrentMonth is true, starts a 60-second interval to refresh. Does not call refresh()
|
||||||
|
* immediately so the initial load is handled only by useMonthData (avoids duplicate first fetch).
|
||||||
* When isCurrentMonth becomes false or on unmount, the interval is cleared.
|
* When isCurrentMonth becomes false or on unmount, the interval is cleared.
|
||||||
*/
|
*/
|
||||||
export function useAutoRefresh(
|
export function useAutoRefresh(
|
||||||
@@ -22,7 +23,6 @@ export function useAutoRefresh(
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isCurrentMonth) return;
|
if (!isCurrentMonth) return;
|
||||||
refreshRef.current();
|
|
||||||
const id = setInterval(() => refreshRef.current(), AUTO_REFRESH_INTERVAL_MS);
|
const id = setInterval(() => refreshRef.current(), AUTO_REFRESH_INTERVAL_MS);
|
||||||
return () => clearInterval(id);
|
return () => clearInterval(id);
|
||||||
}, [isCurrentMonth]);
|
}, [isCurrentMonth]);
|
||||||
|
|||||||
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]);
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
/**
|
/**
|
||||||
* Touch swipe detection for horizontal month navigation.
|
* Touch swipe detection for horizontal month navigation.
|
||||||
* Replaces swipe logic from webapp/js/main.js (threshold 50px).
|
* Tracks move/cancel so diagonal or vertical scroll does not trigger month change.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
"use client";
|
"use client";
|
||||||
@@ -15,8 +15,9 @@ export interface UseSwipeOptions {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Attaches touchstart/touchend to the element ref and invokes onSwipeLeft or onSwipeRight
|
* Attaches touchstart/touchmove/touchend to the element ref. Fires onSwipeLeft or onSwipeRight
|
||||||
* when a horizontal swipe exceeds the threshold. Vertical swipes are ignored.
|
* only when horizontal movement exceeds threshold and dominates over vertical (no cancel during move).
|
||||||
|
* Use touch-action: pan-y on the swipe area so vertical scroll is preserved and horizontal intent is clearer.
|
||||||
*/
|
*/
|
||||||
export function useSwipe(
|
export function useSwipe(
|
||||||
elementRef: React.RefObject<HTMLElement | null>,
|
elementRef: React.RefObject<HTMLElement | null>,
|
||||||
@@ -27,6 +28,7 @@ export function useSwipe(
|
|||||||
const { threshold = 50, disabled = false } = options;
|
const { threshold = 50, disabled = false } = options;
|
||||||
const startX = useRef(0);
|
const startX = useRef(0);
|
||||||
const startY = useRef(0);
|
const startY = useRef(0);
|
||||||
|
const cancelledRef = useRef(false);
|
||||||
const onSwipeLeftRef = useRef(onSwipeLeft);
|
const onSwipeLeftRef = useRef(onSwipeLeft);
|
||||||
const onSwipeRightRef = useRef(onSwipeRight);
|
const onSwipeRightRef = useRef(onSwipeRight);
|
||||||
onSwipeLeftRef.current = onSwipeLeft;
|
onSwipeLeftRef.current = onSwipeLeft;
|
||||||
@@ -41,10 +43,21 @@ export function useSwipe(
|
|||||||
const t = e.changedTouches[0];
|
const t = e.changedTouches[0];
|
||||||
startX.current = t.clientX;
|
startX.current = t.clientX;
|
||||||
startY.current = t.clientY;
|
startY.current = t.clientY;
|
||||||
|
cancelledRef.current = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMove = (e: TouchEvent) => {
|
||||||
|
if (e.changedTouches.length === 0 || cancelledRef.current) return;
|
||||||
|
const t = e.changedTouches[0];
|
||||||
|
const deltaX = Math.abs(t.clientX - startX.current);
|
||||||
|
const deltaY = Math.abs(t.clientY - startY.current);
|
||||||
|
if (deltaY > deltaX) {
|
||||||
|
cancelledRef.current = true;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleEnd = (e: TouchEvent) => {
|
const handleEnd = (e: TouchEvent) => {
|
||||||
if (e.changedTouches.length === 0) return;
|
if (e.changedTouches.length === 0 || cancelledRef.current) return;
|
||||||
const t = e.changedTouches[0];
|
const t = e.changedTouches[0];
|
||||||
const deltaX = t.clientX - startX.current;
|
const deltaX = t.clientX - startX.current;
|
||||||
const deltaY = t.clientY - startY.current;
|
const deltaY = t.clientY - startY.current;
|
||||||
@@ -58,9 +71,11 @@ export function useSwipe(
|
|||||||
};
|
};
|
||||||
|
|
||||||
el.addEventListener("touchstart", handleStart, { passive: true });
|
el.addEventListener("touchstart", handleStart, { passive: true });
|
||||||
|
el.addEventListener("touchmove", handleMove, { passive: true });
|
||||||
el.addEventListener("touchend", handleEnd, { passive: true });
|
el.addEventListener("touchend", handleEnd, { passive: true });
|
||||||
return () => {
|
return () => {
|
||||||
el.removeEventListener("touchstart", handleStart);
|
el.removeEventListener("touchstart", handleStart);
|
||||||
|
el.removeEventListener("touchmove", handleMove);
|
||||||
el.removeEventListener("touchend", handleEnd);
|
el.removeEventListener("touchend", handleEnd);
|
||||||
};
|
};
|
||||||
}, [elementRef, disabled, threshold]);
|
}, [elementRef, disabled, threshold]);
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ vi.mock("@telegram-apps/sdk-react", () => ({
|
|||||||
isThemeParamsDark: vi.fn(),
|
isThemeParamsDark: vi.fn(),
|
||||||
setMiniAppBackgroundColor: { isAvailable: vi.fn(() => false) },
|
setMiniAppBackgroundColor: { isAvailable: vi.fn(() => false) },
|
||||||
setMiniAppHeaderColor: { isAvailable: vi.fn(() => false) },
|
setMiniAppHeaderColor: { isAvailable: vi.fn(() => false) },
|
||||||
|
setMiniAppBottomBarColor: { isAvailable: vi.fn(() => false) },
|
||||||
}));
|
}));
|
||||||
|
|
||||||
describe("getFallbackScheme", () => {
|
describe("getFallbackScheme", () => {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
isThemeParamsDark,
|
isThemeParamsDark,
|
||||||
setMiniAppBackgroundColor,
|
setMiniAppBackgroundColor,
|
||||||
setMiniAppHeaderColor,
|
setMiniAppHeaderColor,
|
||||||
|
setMiniAppBottomBarColor,
|
||||||
} from "@telegram-apps/sdk-react";
|
} from "@telegram-apps/sdk-react";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -69,6 +70,9 @@ export function applyTheme(scheme?: "dark" | "light"): void {
|
|||||||
if (setMiniAppHeaderColor.isAvailable()) {
|
if (setMiniAppHeaderColor.isAvailable()) {
|
||||||
setMiniAppHeaderColor("bg_color");
|
setMiniAppHeaderColor("bg_color");
|
||||||
}
|
}
|
||||||
|
if (setMiniAppBottomBarColor.isAvailable()) {
|
||||||
|
setMiniAppBottomBarColor("bottom_bar_bg_color");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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",
|
||||||
@@ -61,6 +62,9 @@ export const MESSAGES: Record<Lang, Record<string, string>> = {
|
|||||||
"contact.telegram": "Telegram",
|
"contact.telegram": "Telegram",
|
||||||
"contact.aria_call": "Call {name}",
|
"contact.aria_call": "Call {name}",
|
||||||
"contact.aria_telegram": "Message {name} on Telegram",
|
"contact.aria_telegram": "Message {name} on Telegram",
|
||||||
|
"contact.copy_phone": "Copy phone number",
|
||||||
|
"contact.copy_telegram": "Copy Telegram username",
|
||||||
|
"contact.copied": "Copied",
|
||||||
"current_duty.title": "Current Duty",
|
"current_duty.title": "Current Duty",
|
||||||
"current_duty.no_duty": "No one is on duty right now",
|
"current_duty.no_duty": "No one is on duty right now",
|
||||||
"current_duty.shift": "Shift",
|
"current_duty.shift": "Shift",
|
||||||
@@ -82,6 +86,27 @@ export const MESSAGES: Record<Lang, Record<string, string>> = {
|
|||||||
"not_found.open_calendar": "Open calendar",
|
"not_found.open_calendar": "Open calendar",
|
||||||
"access_denied.hint": "Open the app again from Telegram.",
|
"access_denied.hint": "Open the app again from Telegram.",
|
||||||
"access_denied.reload": "Reload",
|
"access_denied.reload": "Reload",
|
||||||
|
"admin.link": "Admin",
|
||||||
|
"admin.title": "Admin",
|
||||||
|
"admin.reassign_duty": "Reassign duty",
|
||||||
|
"admin.select_user": "Select user",
|
||||||
|
"admin.current_assignee": "Current",
|
||||||
|
"admin.reassign_success": "Duty reassigned",
|
||||||
|
"admin.access_denied": "Access only for administrators.",
|
||||||
|
"admin.duty_not_found": "Duty not found",
|
||||||
|
"admin.user_not_found": "User not found",
|
||||||
|
"admin.back_to_calendar": "Back to calendar",
|
||||||
|
"admin.loading_users": "Loading users…",
|
||||||
|
"admin.no_duties": "No duties this month.",
|
||||||
|
"admin.no_users_for_assign": "No users available for assignment.",
|
||||||
|
"admin.save": "Save",
|
||||||
|
"admin.list_aria": "List of duties to reassign",
|
||||||
|
"admin.reassign_aria": "Reassign duty: {date}, {time}, {name}",
|
||||||
|
"admin.section_aria": "Duties for {date}",
|
||||||
|
"admin.reassign_error_generic": "Could not reassign duty. Try again.",
|
||||||
|
"admin.reassign_error_denied": "Access denied.",
|
||||||
|
"admin.reassign_error_not_found": "Duty or user not found.",
|
||||||
|
"admin.reassign_error_network": "Network error. Check your connection.",
|
||||||
},
|
},
|
||||||
ru: {
|
ru: {
|
||||||
"app.title": "Календарь дежурств",
|
"app.title": "Календарь дежурств",
|
||||||
@@ -115,6 +140,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": "Недоступен",
|
||||||
@@ -138,6 +164,9 @@ export const MESSAGES: Record<Lang, Record<string, string>> = {
|
|||||||
"contact.telegram": "Telegram",
|
"contact.telegram": "Telegram",
|
||||||
"contact.aria_call": "Позвонить {name}",
|
"contact.aria_call": "Позвонить {name}",
|
||||||
"contact.aria_telegram": "Написать {name} в Telegram",
|
"contact.aria_telegram": "Написать {name} в Telegram",
|
||||||
|
"contact.copy_phone": "Скопировать номер",
|
||||||
|
"contact.copy_telegram": "Скопировать логин Telegram",
|
||||||
|
"contact.copied": "Скопировано",
|
||||||
"current_duty.title": "Сейчас дежурит",
|
"current_duty.title": "Сейчас дежурит",
|
||||||
"current_duty.no_duty": "Сейчас никто не дежурит",
|
"current_duty.no_duty": "Сейчас никто не дежурит",
|
||||||
"current_duty.shift": "Смена",
|
"current_duty.shift": "Смена",
|
||||||
@@ -159,6 +188,27 @@ export const MESSAGES: Record<Lang, Record<string, string>> = {
|
|||||||
"not_found.open_calendar": "Открыть календарь",
|
"not_found.open_calendar": "Открыть календарь",
|
||||||
"access_denied.hint": "Откройте приложение снова из Telegram.",
|
"access_denied.hint": "Откройте приложение снова из Telegram.",
|
||||||
"access_denied.reload": "Обновить",
|
"access_denied.reload": "Обновить",
|
||||||
|
"admin.link": "Админка",
|
||||||
|
"admin.title": "Админка",
|
||||||
|
"admin.reassign_duty": "Переназначить дежурного",
|
||||||
|
"admin.select_user": "Выберите пользователя",
|
||||||
|
"admin.current_assignee": "Текущий",
|
||||||
|
"admin.reassign_success": "Дежурство переназначено",
|
||||||
|
"admin.access_denied": "Доступ только для администраторов.",
|
||||||
|
"admin.duty_not_found": "Дежурство не найдено",
|
||||||
|
"admin.user_not_found": "Пользователь не найден",
|
||||||
|
"admin.back_to_calendar": "Назад к календарю",
|
||||||
|
"admin.loading_users": "Загрузка пользователей…",
|
||||||
|
"admin.no_duties": "В этом месяце дежурств нет.",
|
||||||
|
"admin.no_users_for_assign": "Нет пользователей для назначения.",
|
||||||
|
"admin.save": "Сохранить",
|
||||||
|
"admin.list_aria": "Список дежурств для перераспределения",
|
||||||
|
"admin.reassign_aria": "Переназначить дежурство: {date}, {time}, {name}",
|
||||||
|
"admin.section_aria": "Дежурства за {date}",
|
||||||
|
"admin.reassign_error_generic": "Не удалось переназначить дежурство. Попробуйте снова.",
|
||||||
|
"admin.reassign_error_denied": "Доступ запрещён.",
|
||||||
|
"admin.reassign_error_not_found": "Дежурство или пользователь не найдены.",
|
||||||
|
"admin.reassign_error_network": "Ошибка сети. Проверьте подключение.",
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,14 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||||
import { fetchDuties, fetchCalendarEvents, AccessDeniedError } from "./api";
|
import {
|
||||||
|
fetchDuties,
|
||||||
|
fetchCalendarEvents,
|
||||||
|
fetchAdminMe,
|
||||||
|
fetchAdminUsers,
|
||||||
|
patchAdminDuty,
|
||||||
|
AccessDeniedError,
|
||||||
|
} from "./api";
|
||||||
|
|
||||||
describe("fetchDuties", () => {
|
describe("fetchDuties", () => {
|
||||||
const originalFetch = globalThis.fetch;
|
const originalFetch = globalThis.fetch;
|
||||||
@@ -174,3 +181,160 @@ describe("fetchCalendarEvents", () => {
|
|||||||
).rejects.toMatchObject({ name: "AbortError" });
|
).rejects.toMatchObject({ name: "AbortError" });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("fetchAdminMe", () => {
|
||||||
|
const originalFetch = globalThis.fetch;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.stubGlobal("fetch", vi.fn());
|
||||||
|
(globalThis.fetch as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
json: () => Promise.resolve({ is_admin: true }),
|
||||||
|
} as Response);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.stubGlobal("fetch", originalFetch);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns is_admin true on 200", async () => {
|
||||||
|
const result = await fetchAdminMe("init-data", "en");
|
||||||
|
expect(result).toEqual({ is_admin: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns is_admin false on 403", async () => {
|
||||||
|
(globalThis.fetch as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||||
|
ok: false,
|
||||||
|
status: 403,
|
||||||
|
} as Response);
|
||||||
|
const result = await fetchAdminMe("init-data", "en");
|
||||||
|
expect(result).toEqual({ is_admin: false });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns is_admin false on non-200 and non-403", async () => {
|
||||||
|
(globalThis.fetch as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||||
|
ok: false,
|
||||||
|
status: 500,
|
||||||
|
} as Response);
|
||||||
|
const result = await fetchAdminMe("init-data", "en");
|
||||||
|
expect(result).toEqual({ is_admin: false });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns is_admin false when response is not boolean", async () => {
|
||||||
|
(globalThis.fetch as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
json: () => Promise.resolve({ is_admin: "yes" }),
|
||||||
|
} as Response);
|
||||||
|
const result = await fetchAdminMe("init-data", "en");
|
||||||
|
expect(result).toEqual({ is_admin: false });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("fetchAdminUsers", () => {
|
||||||
|
const originalFetch = globalThis.fetch;
|
||||||
|
const validUsers = [
|
||||||
|
{ id: 1, full_name: "Alice", username: "alice", role_id: 1 },
|
||||||
|
{ id: 2, full_name: "Bob", username: null, role_id: 2 },
|
||||||
|
];
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.stubGlobal("fetch", vi.fn());
|
||||||
|
(globalThis.fetch as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
json: () => Promise.resolve(validUsers),
|
||||||
|
} as Response);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.stubGlobal("fetch", originalFetch);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns users array on 200", async () => {
|
||||||
|
const result = await fetchAdminUsers("init-data", "en");
|
||||||
|
expect(result).toEqual(validUsers);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws AccessDeniedError on 403", async () => {
|
||||||
|
(globalThis.fetch as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||||
|
ok: false,
|
||||||
|
status: 403,
|
||||||
|
json: () => Promise.resolve({ detail: "Admin only" }),
|
||||||
|
} as Response);
|
||||||
|
await expect(fetchAdminUsers("init-data", "en")).rejects.toThrow(AccessDeniedError);
|
||||||
|
await expect(fetchAdminUsers("init-data", "en")).rejects.toMatchObject({
|
||||||
|
message: "ACCESS_DENIED",
|
||||||
|
serverDetail: "Admin only",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("filters invalid items from response", async () => {
|
||||||
|
(globalThis.fetch as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
json: () =>
|
||||||
|
Promise.resolve([
|
||||||
|
validUsers[0],
|
||||||
|
{ id: 2, full_name: "Bob", username: 123, role_id: 2 },
|
||||||
|
{ id: 3 },
|
||||||
|
]),
|
||||||
|
} as Response);
|
||||||
|
const result = await fetchAdminUsers("init-data", "en");
|
||||||
|
expect(result).toEqual([validUsers[0]]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("patchAdminDuty", () => {
|
||||||
|
const originalFetch = globalThis.fetch;
|
||||||
|
const updatedDuty = {
|
||||||
|
id: 5,
|
||||||
|
user_id: 2,
|
||||||
|
start_at: "2025-03-01T09:00:00Z",
|
||||||
|
end_at: "2025-03-01T18:00:00Z",
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.stubGlobal("fetch", vi.fn());
|
||||||
|
(globalThis.fetch as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
json: () => Promise.resolve(updatedDuty),
|
||||||
|
} as Response);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.stubGlobal("fetch", originalFetch);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sends PATCH with user_id and returns updated duty", async () => {
|
||||||
|
const result = await patchAdminDuty(5, 2, "init-data", "en");
|
||||||
|
expect(result).toEqual(updatedDuty);
|
||||||
|
const fetchCall = (globalThis.fetch as ReturnType<typeof vi.fn>).mock.calls[0];
|
||||||
|
expect(fetchCall[1]?.method).toBe("PATCH");
|
||||||
|
expect(fetchCall[1]?.body).toBe(JSON.stringify({ user_id: 2 }));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws AccessDeniedError on 403", async () => {
|
||||||
|
(globalThis.fetch as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||||
|
ok: false,
|
||||||
|
status: 403,
|
||||||
|
json: () => Promise.resolve({ detail: "Admin only" }),
|
||||||
|
} as Response);
|
||||||
|
await expect(
|
||||||
|
patchAdminDuty(5, 2, "init-data", "en")
|
||||||
|
).rejects.toThrow(AccessDeniedError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws with server detail on 400", async () => {
|
||||||
|
(globalThis.fetch as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||||
|
ok: false,
|
||||||
|
status: 400,
|
||||||
|
json: () => Promise.resolve({ detail: "User not found" }),
|
||||||
|
} as Response);
|
||||||
|
await expect(
|
||||||
|
patchAdminDuty(5, 999, "init-data", "en")
|
||||||
|
).rejects.toThrow("User not found");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -11,6 +11,22 @@ import { translate } from "@/i18n/messages";
|
|||||||
|
|
||||||
type ApiLang = "ru" | "en";
|
type ApiLang = "ru" | "en";
|
||||||
|
|
||||||
|
/** User summary for admin dropdown (GET /api/admin/users). */
|
||||||
|
export interface UserForAdmin {
|
||||||
|
id: number;
|
||||||
|
full_name: string;
|
||||||
|
username: string | null;
|
||||||
|
role_id: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Minimal duty shape returned by PATCH /api/admin/duties/:id. */
|
||||||
|
export interface DutyReassignResponse {
|
||||||
|
id: number;
|
||||||
|
user_id: number;
|
||||||
|
start_at: string;
|
||||||
|
end_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
/** Minimal runtime check for a single duty item (required fields). */
|
/** Minimal runtime check for a single duty item (required fields). */
|
||||||
function isDutyWithUser(x: unknown): x is DutyWithUser {
|
function isDutyWithUser(x: unknown): x is DutyWithUser {
|
||||||
if (!x || typeof x !== "object") return false;
|
if (!x || typeof x !== "object") return false;
|
||||||
@@ -112,6 +128,27 @@ function buildFetchOptions(
|
|||||||
return { headers, signal: controller.signal, cleanup };
|
return { headers, signal: controller.signal, cleanup };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse 403 response body for user-facing detail. Returns default i18n message if body is invalid.
|
||||||
|
*/
|
||||||
|
async function handle403Response(
|
||||||
|
res: Response,
|
||||||
|
acceptLang: ApiLang,
|
||||||
|
defaultI18nKey: string
|
||||||
|
): Promise<string> {
|
||||||
|
let detail = translate(acceptLang, defaultI18nKey);
|
||||||
|
try {
|
||||||
|
const body = await res.json();
|
||||||
|
if (body && (body as { detail?: string }).detail !== undefined) {
|
||||||
|
const d = (body as { detail: string | { msg?: string } }).detail;
|
||||||
|
detail = typeof d === "string" ? d : (d.msg ?? JSON.stringify(d));
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
return detail;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetch duties for date range. Throws AccessDeniedError on 403.
|
* Fetch duties for date range. Throws AccessDeniedError on 403.
|
||||||
* Rethrows AbortError when the request is cancelled (e.g. stale load).
|
* Rethrows AbortError when the request is cancelled (e.g. stale load).
|
||||||
@@ -132,17 +169,7 @@ export async function fetchDuties(
|
|||||||
const res = await fetch(url, { headers: opts.headers, signal: opts.signal });
|
const res = await fetch(url, { headers: opts.headers, signal: opts.signal });
|
||||||
if (res.status === 403) {
|
if (res.status === 403) {
|
||||||
logger.warn("Access denied", from, to);
|
logger.warn("Access denied", from, to);
|
||||||
let detail = translate(acceptLang, "access_denied");
|
const detail = await handle403Response(res, acceptLang, "access_denied");
|
||||||
try {
|
|
||||||
const body = await res.json();
|
|
||||||
if (body && (body as { detail?: string }).detail !== undefined) {
|
|
||||||
const d = (body as { detail: string | { msg?: string } }).detail;
|
|
||||||
detail =
|
|
||||||
typeof d === "string" ? d : (d.msg ?? JSON.stringify(d));
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
/* ignore */
|
|
||||||
}
|
|
||||||
throw new AccessDeniedError(API_ACCESS_DENIED, detail);
|
throw new AccessDeniedError(API_ACCESS_DENIED, detail);
|
||||||
}
|
}
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
@@ -181,17 +208,7 @@ export async function fetchCalendarEvents(
|
|||||||
const res = await fetch(url, { headers: opts.headers, signal: opts.signal });
|
const res = await fetch(url, { headers: opts.headers, signal: opts.signal });
|
||||||
if (res.status === 403) {
|
if (res.status === 403) {
|
||||||
logger.warn("Access denied", from, to, "calendar-events");
|
logger.warn("Access denied", from, to, "calendar-events");
|
||||||
let detail = translate(acceptLang, "access_denied");
|
const detail = await handle403Response(res, acceptLang, "access_denied");
|
||||||
try {
|
|
||||||
const body = await res.json();
|
|
||||||
if (body && (body as { detail?: string }).detail !== undefined) {
|
|
||||||
const d = (body as { detail: string | { msg?: string } }).detail;
|
|
||||||
detail =
|
|
||||||
typeof d === "string" ? d : (d.msg ?? JSON.stringify(d));
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
/* ignore */
|
|
||||||
}
|
|
||||||
throw new AccessDeniedError(API_ACCESS_DENIED, detail);
|
throw new AccessDeniedError(API_ACCESS_DENIED, detail);
|
||||||
}
|
}
|
||||||
if (!res.ok) return [];
|
if (!res.ok) return [];
|
||||||
@@ -207,3 +224,133 @@ export async function fetchCalendarEvents(
|
|||||||
opts.cleanup();
|
opts.cleanup();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch admin status for the current user (GET /api/admin/me).
|
||||||
|
* Returns { is_admin: true } or { is_admin: false }. On 403, treat as not admin.
|
||||||
|
*/
|
||||||
|
export async function fetchAdminMe(
|
||||||
|
initData: string,
|
||||||
|
acceptLang: ApiLang
|
||||||
|
): Promise<{ is_admin: boolean }> {
|
||||||
|
const base =
|
||||||
|
typeof window !== "undefined" ? window.location.origin : "";
|
||||||
|
const url = `${base}/api/admin/me`;
|
||||||
|
const opts = buildFetchOptions(initData, acceptLang);
|
||||||
|
try {
|
||||||
|
logger.debug("API request", "/api/admin/me");
|
||||||
|
const res = await fetch(url, { headers: opts.headers, signal: opts.signal });
|
||||||
|
if (res.status === 403) {
|
||||||
|
return { is_admin: false };
|
||||||
|
}
|
||||||
|
if (!res.ok) {
|
||||||
|
return { is_admin: false };
|
||||||
|
}
|
||||||
|
const data = (await res.json()) as { is_admin?: boolean };
|
||||||
|
return {
|
||||||
|
is_admin: typeof data?.is_admin === "boolean" ? data.is_admin : false,
|
||||||
|
};
|
||||||
|
} catch (e) {
|
||||||
|
logger.warn("API request failed", "/api/admin/me", e);
|
||||||
|
return { is_admin: false };
|
||||||
|
} finally {
|
||||||
|
opts.cleanup();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch users for admin dropdown (GET /api/admin/users). Admin only; throws AccessDeniedError on 403.
|
||||||
|
*/
|
||||||
|
export async function fetchAdminUsers(
|
||||||
|
initData: string,
|
||||||
|
acceptLang: ApiLang,
|
||||||
|
signal?: AbortSignal | null
|
||||||
|
): Promise<UserForAdmin[]> {
|
||||||
|
const base =
|
||||||
|
typeof window !== "undefined" ? window.location.origin : "";
|
||||||
|
const url = `${base}/api/admin/users`;
|
||||||
|
const opts = buildFetchOptions(initData, acceptLang, signal);
|
||||||
|
try {
|
||||||
|
logger.debug("API request", "/api/admin/users");
|
||||||
|
const res = await fetch(url, { headers: opts.headers, signal: opts.signal });
|
||||||
|
if (res.status === 403) {
|
||||||
|
const detail = await handle403Response(res, acceptLang, "admin.access_denied");
|
||||||
|
throw new AccessDeniedError(API_ACCESS_DENIED, detail);
|
||||||
|
}
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(translate(acceptLang, "error_load_failed"));
|
||||||
|
}
|
||||||
|
const data = await res.json();
|
||||||
|
if (!Array.isArray(data)) return [];
|
||||||
|
return data.filter(
|
||||||
|
(x: unknown): x is UserForAdmin =>
|
||||||
|
x != null &&
|
||||||
|
typeof (x as UserForAdmin).id === "number" &&
|
||||||
|
typeof (x as UserForAdmin).full_name === "string" &&
|
||||||
|
((x as UserForAdmin).username === null ||
|
||||||
|
typeof (x as UserForAdmin).username === "string") &&
|
||||||
|
((x as UserForAdmin).role_id === null ||
|
||||||
|
typeof (x as UserForAdmin).role_id === "number")
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
if ((e as Error).name === "AbortError" || e instanceof AccessDeniedError) {
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
logger.error("API request failed", "/api/admin/users", e);
|
||||||
|
throw e;
|
||||||
|
} finally {
|
||||||
|
opts.cleanup();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reassign a duty to another user (PATCH /api/admin/duties/:id). Admin only.
|
||||||
|
* Returns updated duty (id, user_id, start_at, end_at). Throws on 403/404/400.
|
||||||
|
*/
|
||||||
|
export async function patchAdminDuty(
|
||||||
|
dutyId: number,
|
||||||
|
userId: number,
|
||||||
|
initData: string,
|
||||||
|
acceptLang: ApiLang
|
||||||
|
): Promise<DutyReassignResponse> {
|
||||||
|
const base =
|
||||||
|
typeof window !== "undefined" ? window.location.origin : "";
|
||||||
|
const url = `${base}/api/admin/duties/${dutyId}`;
|
||||||
|
const opts = buildFetchOptions(initData, acceptLang);
|
||||||
|
try {
|
||||||
|
logger.debug("API request", "PATCH", url, { user_id: userId });
|
||||||
|
const res = await fetch(url, {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: {
|
||||||
|
...opts.headers,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ user_id: userId }),
|
||||||
|
signal: opts.signal,
|
||||||
|
});
|
||||||
|
if (res.status === 403) {
|
||||||
|
const detail = await handle403Response(res, acceptLang, "admin.access_denied");
|
||||||
|
throw new AccessDeniedError(API_ACCESS_DENIED, detail);
|
||||||
|
}
|
||||||
|
const data = await res.json().catch(() => ({}));
|
||||||
|
if (!res.ok) {
|
||||||
|
const msg =
|
||||||
|
data && typeof (data as { detail?: string }).detail === "string"
|
||||||
|
? (data as { detail: string }).detail
|
||||||
|
: translate(acceptLang, "error_generic");
|
||||||
|
throw new Error(msg);
|
||||||
|
}
|
||||||
|
const out = data as DutyReassignResponse;
|
||||||
|
if (
|
||||||
|
typeof out?.id !== "number" ||
|
||||||
|
typeof out?.user_id !== "number" ||
|
||||||
|
typeof out?.start_at !== "string" ||
|
||||||
|
typeof out?.end_at !== "string"
|
||||||
|
) {
|
||||||
|
throw new Error(translate(acceptLang, "error_load_failed"));
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
} finally {
|
||||||
|
opts.cleanup();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
64
webapp-next/src/lib/copy-to-clipboard.test.ts
Normal file
64
webapp-next/src/lib/copy-to-clipboard.test.ts
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
/**
|
||||||
|
* Unit tests for copyToClipboard: Clipboard API and execCommand fallback.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||||
|
|
||||||
|
describe("copyToClipboard", () => {
|
||||||
|
const writeTextMock = vi.fn();
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
vi.resetModules();
|
||||||
|
writeTextMock.mockReset();
|
||||||
|
Object.defineProperty(navigator, "clipboard", {
|
||||||
|
value: { writeText: writeTextMock },
|
||||||
|
configurable: true,
|
||||||
|
writable: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns true when navigator.clipboard.writeText succeeds", async () => {
|
||||||
|
writeTextMock.mockResolvedValue(undefined);
|
||||||
|
const { copyToClipboard } = await import("./copy-to-clipboard");
|
||||||
|
|
||||||
|
const result = await copyToClipboard("+79991234567");
|
||||||
|
|
||||||
|
expect(result).toBe(true);
|
||||||
|
expect(writeTextMock).toHaveBeenCalledWith("+79991234567");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns false when text is empty", async () => {
|
||||||
|
const { copyToClipboard } = await import("./copy-to-clipboard");
|
||||||
|
|
||||||
|
expect(await copyToClipboard("")).toBe(false);
|
||||||
|
expect(await copyToClipboard(" ")).toBe(false);
|
||||||
|
expect(writeTextMock).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns false when clipboard.writeText rejects", async () => {
|
||||||
|
writeTextMock.mockRejectedValue(new Error("Permission denied"));
|
||||||
|
const { copyToClipboard } = await import("./copy-to-clipboard");
|
||||||
|
|
||||||
|
const result = await copyToClipboard("hello");
|
||||||
|
|
||||||
|
expect(result).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses execCommand fallback when clipboard is missing", async () => {
|
||||||
|
Object.defineProperty(navigator, "clipboard", { value: undefined, configurable: true });
|
||||||
|
const execCommandMock = vi.fn().mockReturnValue(true);
|
||||||
|
(document as unknown as { execCommand?: ReturnType<typeof vi.fn> }).execCommand =
|
||||||
|
execCommandMock;
|
||||||
|
vi.resetModules();
|
||||||
|
const { copyToClipboard } = await import("./copy-to-clipboard");
|
||||||
|
|
||||||
|
const result = await copyToClipboard("fallback text");
|
||||||
|
|
||||||
|
expect(result).toBe(true);
|
||||||
|
expect(execCommandMock).toHaveBeenCalledWith("copy");
|
||||||
|
});
|
||||||
|
});
|
||||||
41
webapp-next/src/lib/copy-to-clipboard.ts
Normal file
41
webapp-next/src/lib/copy-to-clipboard.ts
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
/**
|
||||||
|
* Copy text to the clipboard. Uses Clipboard API with execCommand fallback.
|
||||||
|
* Used for copying phone/Telegram on the current duty view.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Copies the given text to the clipboard.
|
||||||
|
*
|
||||||
|
* @param text - Raw text to copy (e.g. phone number or @username).
|
||||||
|
* @returns Promise resolving to true on success, false on failure. Errors are logged, not thrown.
|
||||||
|
*/
|
||||||
|
export async function copyToClipboard(text: string): Promise<boolean> {
|
||||||
|
if (typeof text !== "string" || text.trim() === "") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof navigator !== "undefined" && navigator.clipboard?.writeText) {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(text);
|
||||||
|
return true;
|
||||||
|
} catch (err) {
|
||||||
|
console.warn("clipboard.writeText failed", err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const textarea = document.createElement("textarea");
|
||||||
|
textarea.value = text;
|
||||||
|
textarea.setAttribute("readonly", "");
|
||||||
|
textarea.style.position = "absolute";
|
||||||
|
textarea.style.left = "-9999px";
|
||||||
|
document.body.appendChild(textarea);
|
||||||
|
textarea.select();
|
||||||
|
const ok = document.execCommand("copy");
|
||||||
|
document.body.removeChild(textarea);
|
||||||
|
return ok;
|
||||||
|
} catch (err) {
|
||||||
|
console.warn("execCommand copy fallback failed", err);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
51
webapp-next/src/lib/open-phone-link.test.ts
Normal file
51
webapp-next/src/lib/open-phone-link.test.ts
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
/**
|
||||||
|
* Unit tests for openPhoneLink: creates a temporary tel: link and triggers click.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||||
|
import { openPhoneLink } from "./open-phone-link";
|
||||||
|
|
||||||
|
describe("openPhoneLink", () => {
|
||||||
|
let appendedAnchor: HTMLAnchorElement | null;
|
||||||
|
let anchorClickSpy: ReturnType<typeof vi.fn>;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
appendedAnchor = null;
|
||||||
|
anchorClickSpy = vi.fn();
|
||||||
|
vi.spyOn(window, "open").mockReturnValue(null);
|
||||||
|
vi.spyOn(document.body, "appendChild").mockImplementation((node: Node) => {
|
||||||
|
const el = node as HTMLAnchorElement;
|
||||||
|
if (el.tagName === "A" && el.href) {
|
||||||
|
appendedAnchor = el;
|
||||||
|
el.click = anchorClickSpy;
|
||||||
|
}
|
||||||
|
return node;
|
||||||
|
});
|
||||||
|
vi.spyOn(document.body, "removeChild").mockImplementation((node: Node) => node);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does nothing when phone is null or empty", () => {
|
||||||
|
openPhoneLink(null);
|
||||||
|
openPhoneLink("");
|
||||||
|
openPhoneLink(" ");
|
||||||
|
expect(appendedAnchor).toBeNull();
|
||||||
|
expect(anchorClickSpy).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("creates anchor with tel URL and triggers click for valid phone", () => {
|
||||||
|
openPhoneLink("+79991234567");
|
||||||
|
expect(appendedAnchor).not.toBeNull();
|
||||||
|
expect(appendedAnchor!.href).toMatch(/tel:\+79991234567$/);
|
||||||
|
expect(anchorClickSpy).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("builds correct tel URL for 10-digit Russian number", () => {
|
||||||
|
openPhoneLink("9146522209");
|
||||||
|
expect(appendedAnchor!.href).toMatch(/tel:\+79146522209$/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("builds correct tel URL for 11-digit number starting with 8", () => {
|
||||||
|
openPhoneLink("89146522209");
|
||||||
|
expect(appendedAnchor!.href).toMatch(/tel:\+79146522209$/);
|
||||||
|
});
|
||||||
|
});
|
||||||
46
webapp-next/src/lib/open-phone-link.ts
Normal file
46
webapp-next/src/lib/open-phone-link.ts
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
/**
|
||||||
|
* Opens a phone number for calling. Uses native tel: navigation so the OS
|
||||||
|
* or WebView can open the dialer. Does not use Telegram openLink() for tel:
|
||||||
|
* (protocol is not supported; it closes the app on desktop and does nothing on mobile).
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds a tel: URL from a phone string (digits and optional leading + only).
|
||||||
|
*/
|
||||||
|
function buildTelUrl(phone: string): string {
|
||||||
|
const trimmed = String(phone).trim();
|
||||||
|
if (trimmed === "") return "";
|
||||||
|
const digits = trimmed.replace(/\D/g, "");
|
||||||
|
if (digits.length === 11 && (digits[0] === "7" || digits[0] === "8")) {
|
||||||
|
return `tel:+7${digits.slice(1)}`;
|
||||||
|
}
|
||||||
|
if (digits.length === 10) {
|
||||||
|
return `tel:+7${digits}`;
|
||||||
|
}
|
||||||
|
return `tel:${trimmed.startsWith("+") ? "+" : ""}${digits}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Opens the given phone number for calling. Tries window.open(telUrl) first
|
||||||
|
* (reported to work for tel: in some Telegram WebViews), then a programmatic
|
||||||
|
* click on a temporary tel: link. openLink(tel:) is not used — Telegram does
|
||||||
|
* not support it (closes app on desktop, no dialer on mobile). Safe to call
|
||||||
|
* from click handlers; does not throw.
|
||||||
|
*/
|
||||||
|
export function openPhoneLink(phone: string | null | undefined): void {
|
||||||
|
if (phone == null || String(phone).trim() === "") return;
|
||||||
|
const telUrl = buildTelUrl(phone);
|
||||||
|
if (telUrl === "") return;
|
||||||
|
if (typeof window === "undefined" || !window.document) return;
|
||||||
|
|
||||||
|
const opened = window.open(telUrl);
|
||||||
|
if (opened !== null) return;
|
||||||
|
|
||||||
|
const anchor = window.document.createElement("a");
|
||||||
|
anchor.href = telUrl;
|
||||||
|
anchor.style.display = "none";
|
||||||
|
anchor.setAttribute("aria-hidden", "true");
|
||||||
|
window.document.body.appendChild(anchor);
|
||||||
|
anchor.click();
|
||||||
|
anchor.remove();
|
||||||
|
}
|
||||||
26
webapp-next/src/lib/telegram-android-perf.ts
Normal file
26
webapp-next/src/lib/telegram-android-perf.ts
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
/**
|
||||||
|
* Detects Android Telegram Mini App performance class from User-Agent and sets
|
||||||
|
* data-perf on document.documentElement so CSS can reduce animations on low-end devices.
|
||||||
|
* @see https://core.telegram.org/bots/webapps#additional-data-in-user-agent
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { retrieveAndroidDeviceData } from "@telegram-apps/sdk-react";
|
||||||
|
|
||||||
|
const DATA_ATTR = "data-perf";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Runs once: if running in Telegram on Android with LOW performance class,
|
||||||
|
* sets data-perf="low" on the document root for CSS to minimize animations.
|
||||||
|
*/
|
||||||
|
export function applyAndroidPerformanceClass(): void {
|
||||||
|
if (typeof document === "undefined") return;
|
||||||
|
try {
|
||||||
|
const data = retrieveAndroidDeviceData();
|
||||||
|
const perf = data?.performanceClass;
|
||||||
|
if (perf === "LOW") {
|
||||||
|
document.documentElement.setAttribute(DATA_ATTR, "low");
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Not in Telegram or not Android; ignore.
|
||||||
|
}
|
||||||
|
}
|
||||||
19
webapp-next/src/lib/telegram-haptic.ts
Normal file
19
webapp-next/src/lib/telegram-haptic.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
/**
|
||||||
|
* Triggers Telegram Mini App haptic feedback when available.
|
||||||
|
* Use on primary actions and key interactions to mimic native Telegram behavior.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { hapticFeedbackImpactOccurred } from "@telegram-apps/sdk-react";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Triggers light impact haptic feedback. No-op when not in Telegram or unsupported.
|
||||||
|
*/
|
||||||
|
export function triggerHapticLight(): void {
|
||||||
|
try {
|
||||||
|
if (hapticFeedbackImpactOccurred.isAvailable()) {
|
||||||
|
hapticFeedbackImpactOccurred("light");
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// SDK not available; ignore.
|
||||||
|
}
|
||||||
|
}
|
||||||
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;
|
||||||
60
webapp-next/src/lib/telegram-link.test.ts
Normal file
60
webapp-next/src/lib/telegram-link.test.ts
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
/**
|
||||||
|
* Unit tests for openTelegramProfile: Mini App–friendly Telegram link opening.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
|
||||||
|
const openTelegramLinkMock = vi.fn();
|
||||||
|
const isAvailableFn = vi.fn().mockReturnValue(true);
|
||||||
|
|
||||||
|
vi.mock("@telegram-apps/sdk-react", () => ({
|
||||||
|
openTelegramLink: Object.assign(openTelegramLinkMock, {
|
||||||
|
isAvailable: () => isAvailableFn(),
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe("openTelegramProfile", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
isAvailableFn.mockReturnValue(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("calls openTelegramLink with t.me URL when SDK is available", async () => {
|
||||||
|
const { openTelegramProfile } = await import("./telegram-link");
|
||||||
|
|
||||||
|
openTelegramProfile("alice");
|
||||||
|
expect(openTelegramLinkMock).toHaveBeenCalledWith("https://t.me/alice");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("strips leading @ from username", async () => {
|
||||||
|
const { openTelegramProfile } = await import("./telegram-link");
|
||||||
|
|
||||||
|
openTelegramProfile("@bob");
|
||||||
|
expect(openTelegramLinkMock).toHaveBeenCalledWith("https://t.me/bob");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does nothing when username is empty", async () => {
|
||||||
|
const { openTelegramProfile } = await import("./telegram-link");
|
||||||
|
|
||||||
|
openTelegramProfile("");
|
||||||
|
openTelegramProfile(null);
|
||||||
|
openTelegramProfile(undefined);
|
||||||
|
expect(openTelegramLinkMock).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to window.open when SDK is unavailable", async () => {
|
||||||
|
isAvailableFn.mockReturnValue(false);
|
||||||
|
const openSpy = vi.spyOn(window, "open").mockImplementation(() => null);
|
||||||
|
vi.resetModules();
|
||||||
|
const { openTelegramProfile } = await import("./telegram-link");
|
||||||
|
|
||||||
|
openTelegramProfile("alice");
|
||||||
|
|
||||||
|
expect(openSpy).toHaveBeenCalledWith(
|
||||||
|
"https://t.me/alice",
|
||||||
|
"_blank",
|
||||||
|
"noopener,noreferrer"
|
||||||
|
);
|
||||||
|
openSpy.mockRestore();
|
||||||
|
});
|
||||||
|
});
|
||||||
28
webapp-next/src/lib/telegram-link.ts
Normal file
28
webapp-next/src/lib/telegram-link.ts
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
/**
|
||||||
|
* Opens a Telegram profile (t.me/username) in a Mini App–friendly way.
|
||||||
|
* Uses SDK openTelegramLink() when available so the link opens inside Telegram;
|
||||||
|
* otherwise falls back to window.open.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { openTelegramLink } from "@telegram-apps/sdk-react";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Opens the given Telegram username profile. When running inside the Mini App,
|
||||||
|
* uses openTelegramLink() so the link opens in-app; otherwise opens in a new tab.
|
||||||
|
* Safe to call from click handlers; does not throw.
|
||||||
|
*/
|
||||||
|
export function openTelegramProfile(username: string | null | undefined): void {
|
||||||
|
const clean = username ? String(username).trim().replace(/^@+/, "") : "";
|
||||||
|
if (!clean) return;
|
||||||
|
if (typeof window === "undefined") return;
|
||||||
|
const url = `https://t.me/${encodeURIComponent(clean)}`;
|
||||||
|
try {
|
||||||
|
if (openTelegramLink.isAvailable()) {
|
||||||
|
openTelegramLink(url);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// SDK not available or not in Mini App context.
|
||||||
|
}
|
||||||
|
window.open(url, "_blank", "noopener,noreferrer");
|
||||||
|
}
|
||||||
@@ -1,15 +1,17 @@
|
|||||||
/**
|
/**
|
||||||
* Single-call wrapper for Telegram Mini App ready().
|
* Single-call wrapper for Telegram Mini App ready() and expand().
|
||||||
* Called once when the first visible screen has finished loading so Telegram
|
* Called once when the first visible screen has finished loading so Telegram
|
||||||
* hides its native loading animation only after our content is ready.
|
* hides its native loading animation only after our content is ready.
|
||||||
|
* Also expands the Mini App to full height when supported.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { miniAppReady } from "@telegram-apps/sdk-react";
|
import { miniAppReady, expandViewport } from "@telegram-apps/sdk-react";
|
||||||
|
|
||||||
let readyCalled = false;
|
let readyCalled = false;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Calls Telegram miniAppReady() at most once per session.
|
* Calls Telegram miniAppReady() at most once per session, then expandViewport()
|
||||||
|
* when available so the app opens to full height.
|
||||||
* Safe when SDK is unavailable (e.g. non-Telegram environment).
|
* Safe when SDK is unavailable (e.g. non-Telegram environment).
|
||||||
*/
|
*/
|
||||||
export function callMiniAppReadyOnce(): void {
|
export function callMiniAppReadyOnce(): void {
|
||||||
@@ -19,6 +21,9 @@ export function callMiniAppReadyOnce(): void {
|
|||||||
miniAppReady();
|
miniAppReady();
|
||||||
readyCalled = true;
|
readyCalled = true;
|
||||||
}
|
}
|
||||||
|
if (expandViewport.isAvailable()) {
|
||||||
|
expandViewport();
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// SDK not available or not in Mini App context; no-op.
|
// SDK not available or not in Mini App context; no-op.
|
||||||
}
|
}
|
||||||
|
|||||||
7
webapp-next/src/lib/theme-bootstrap-script.ts
Normal file
7
webapp-next/src/lib/theme-bootstrap-script.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
/**
|
||||||
|
* Inline script for first-paint theme: hash (tgWebAppColorScheme + themeParams),
|
||||||
|
* then Telegram WebApp, then CSS --tg-color-scheme, then prefers-color-scheme.
|
||||||
|
* Sets data-theme and Mini App bg/header colors. Shared by layout and global-error.
|
||||||
|
*/
|
||||||
|
export const THEME_BOOTSTRAP_SCRIPT =
|
||||||
|
"(function(){var scheme='dark';var root=document.documentElement;var hash=typeof location!=='undefined'&&location.hash?location.hash.slice(1):'';if(hash){var params={};hash.split('&').forEach(function(p){var i=p.indexOf('=');if(i===-1)return;var k=decodeURIComponent(p.slice(0,i));var v=p.slice(i+1);params[k]=v;});if(params.tgWebAppColorScheme==='light'||params.tgWebAppColorScheme==='dark')scheme=params.tgWebAppColorScheme;if(params.tgWebAppThemeParams){try{var theme=JSON.parse(decodeURIComponent(params.tgWebAppThemeParams));var tgKeys=['bg_color','text_color','hint_color','link_color','button_color','button_text_color','secondary_bg_color','header_bg_color','accent_text_color','destructive_text_color','section_bg_color','section_header_text_color','section_separator_color','subtitle_text_color','bottom_bar_bg_color'];tgKeys.forEach(function(k){var v=theme[k];if(v&&typeof v==='string')root.style.setProperty('--tg-theme-'+k.replace(/_/g,'-'),v);});Object.keys(theme).forEach(function(k){var v=theme[k];if(v&&typeof v==='string')root.style.setProperty('--tg-theme-'+k.replace(/_/g,'-'),v);});}catch(e){}}}var twa=typeof window!=='undefined'&&window.Telegram&&window.Telegram.WebApp;if(scheme!=='light'&&scheme!=='dark'&&twa&&(twa.colorScheme==='light'||twa.colorScheme==='dark'))scheme=twa.colorScheme;if(scheme!=='light'&&scheme!=='dark'){try{var s=getComputedStyle(root).getPropertyValue('--tg-color-scheme').trim();if(s==='light'||s==='dark')scheme=s;}catch(e){} }if(scheme!=='light'&&scheme!=='dark')scheme=window.matchMedia&&window.matchMedia('(prefers-color-scheme: dark)').matches?'dark':'light';root.setAttribute('data-theme',scheme);if(twa){if(twa.setBackgroundColor)twa.setBackgroundColor('bg_color');if(twa.setHeaderColor)twa.setHeaderColor('bg_color');}})();";
|
||||||
@@ -4,92 +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;
|
||||||
|
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;
|
|
||||||
/** 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">>) => 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,
|
|
||||||
|
|
||||||
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 }),
|
|
||||||
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