From e6a8f93a1e56a683235a8307a3b3f478df27d172 Mon Sep 17 00:00:00 2001 From: hamid Date: Mon, 27 Jul 2026 23:58:16 +0330 Subject: [PATCH] manual improvement 2 & add telegram bot --- client/CLAUDE.md | 85 +- client/messages/en.json | 33 +- client/messages/fa.json | 33 +- client/package-lock.json | 17 + client/package.json | 1 + .../(customer)/bookings/[id]/cancel/page.tsx | 62 +- .../(customer)/bookings/[id]/review/page.tsx | 86 +- .../checkout/bnpl/EligibilityStep.tsx | 136 +-- .../(customer)/bookings/request/page.tsx | 793 +++++++++--------- .../(customer)/patients/[id]/record/page.tsx | 323 +++---- .../(customer)/profile/page.tsx | 147 ++-- .../(private-routes)/admin/holidays/page.tsx | 114 +-- .../(private-routes)/admin/partners/page.tsx | 193 +++-- .../admin/verification/[nurseId]/page.tsx | 132 +-- .../nurse/NurseDashboardScreen.tsx | 56 +- .../(private-routes)/nurse/bank/page.tsx | 107 ++- .../(private-routes)/nurse/profile/page.tsx | 434 +++++----- .../nurse/services/VariantBuilder.tsx | 620 +++++++------- .../nurse/verification/credentials/page.tsx | 571 +++++++------ .../nurse/verification/identity/page.tsx | 282 ++++--- .../visits/[id]/NurseVisitNotesPanel.tsx | 142 ++-- .../components/PatientForm/PatientForm.tsx | 253 +++--- client/src/components/admin/RefundPanel.tsx | 75 +- .../common/AccentCard/AccentCard.tsx | 41 +- .../StickyActionBar/StickyActionBar.test.tsx | 4 +- .../StickyActionBar/StickyActionBar.tsx | 11 +- .../common/form/FormSection.test.tsx | 35 + .../components/common/form/FormSection.tsx | 95 +++ .../common/form/RhfChipSelect.test.tsx | 85 ++ .../components/common/form/RhfChipSelect.tsx | 123 +++ .../common/form/RhfControlGroup.test.tsx | 67 ++ .../common/form/RhfControlGroup.tsx | 61 ++ .../common/form/RhfJalaliDateField.tsx | 45 + .../common/form/RhfTextField.test.tsx | 63 ++ .../components/common/form/RhfTextField.tsx | 63 ++ client/src/components/common/form/index.ts | 13 + client/src/components/common/form/types.ts | 17 + client/src/components/common/index.tsx | 2 + .../src/components/geography/AddressForm.tsx | 216 +++-- .../messaging/ContactSupportDialog.test.tsx | 7 +- .../messaging/ContactSupportDialog.tsx | 77 +- .../messaging/TicketConversationPanel.tsx | 3 +- .../messaging/TicketMessageList.tsx | 10 +- .../components/settings/ThemeModeSetting.tsx | 17 +- client/src/layout/AppFrame.tsx | 78 +- client/src/layout/NurseLayout.tsx | 27 +- .../src/layout/components/BottomBar.test.tsx | 16 +- client/src/layout/components/BottomBar.tsx | 149 ++-- client/src/layout/config.ts | 27 +- telegram-otp-bot/.env.example | 29 + telegram-otp-bot/.gitignore | 4 + telegram-otp-bot/INTEGRATION-PROMPT.md | 153 ++++ telegram-otp-bot/README.md | 125 +++ telegram-otp-bot/package.json | 14 + telegram-otp-bot/src/env.js | 70 ++ telegram-otp-bot/src/server.js | 147 ++++ telegram-otp-bot/src/telegram.js | 76 ++ 57 files changed, 4181 insertions(+), 2484 deletions(-) create mode 100644 client/src/components/common/form/FormSection.test.tsx create mode 100644 client/src/components/common/form/FormSection.tsx create mode 100644 client/src/components/common/form/RhfChipSelect.test.tsx create mode 100644 client/src/components/common/form/RhfChipSelect.tsx create mode 100644 client/src/components/common/form/RhfControlGroup.test.tsx create mode 100644 client/src/components/common/form/RhfControlGroup.tsx create mode 100644 client/src/components/common/form/RhfJalaliDateField.tsx create mode 100644 client/src/components/common/form/RhfTextField.test.tsx create mode 100644 client/src/components/common/form/RhfTextField.tsx create mode 100644 client/src/components/common/form/index.ts create mode 100644 client/src/components/common/form/types.ts create mode 100644 telegram-otp-bot/.env.example create mode 100644 telegram-otp-bot/.gitignore create mode 100644 telegram-otp-bot/INTEGRATION-PROMPT.md create mode 100644 telegram-otp-bot/README.md create mode 100644 telegram-otp-bot/package.json create mode 100644 telegram-otp-bot/src/env.js create mode 100644 telegram-otp-bot/src/server.js create mode 100644 telegram-otp-bot/src/telegram.js diff --git a/client/CLAUDE.md b/client/CLAUDE.md index 7f1bafc..7e1e4c5 100644 --- a/client/CLAUDE.md +++ b/client/CLAUDE.md @@ -24,6 +24,10 @@ i18n, cookies, and the rules every change must follow. - **next-intl v4** for i18n — locales `fa` (default, RTL) and `en`. - **TanStack Query v5** for server state; a small **AuthContext** (React context + reducer, `src/context/auth/`, seeded with server-read auth state) for auth/session state. +- **react-hook-form v7** for every form with more than one field — uncontrolled fields + per-field + subscriptions, so a keystroke re-renders one input rather than the whole screen. Never used through + the raw `useController`/`register` API at a call site: bind through the `components/common/form` + wrappers (`RhfTextField`, `RhfChipSelect`, `RhfJalaliDateField`, `RhfControlGroup`). See **Forms** below. - **notistack** for toasts; **js-cookie** (wrapped) for client cookies. - **Jest** + **Testing Library** for unit tests. - Quality gates: **tsc**, **ESLint 9** (flat config), **Prettier**. @@ -172,14 +176,14 @@ client/ │ │ │ ├── practice/page.tsx # /nurse/practice — «حرفهٔ من» group root (NursePracticeScreen): a listing-status card (own TrustBadge + the real accepting-bookings state) over links to profile/services/coverage/verification, each with a count read off its already-cached query (omitted, never faked, while one is in flight) │ │ │ ├── finance/page.tsx # /nurse/finance — «مالی» group root (NurseFinanceScreen): the SIGNED net payable balance (an owed-back reads as an error tone, never clamped) over links to earnings/payout history/bank │ │ │ ├── more/page.tsx # /nurse/more — «بیشتر» group root (NurseMoreScreen): ProfileSummary + ActorSwitcher, support/notifications links with unread badges, SettingsPanel, SignOutRow. Everything the sidebar drawer header and footer used to carry - │ │ │ ├── NurseDashboardScreen.tsx # ui-phase-7 — the «امروز» operational home replacing the old PlaceholderScreen: greeting+TrustBadge, NextVisitCard (useTodaySessions), RequestsStrip (useNurseRequestInbox, the most time-critical widget — sorts above earnings), EarningsSnapshotCard (useNurseEarningsBalance, signed net + eligible), DashboardActivationSlot, NotificationsEntryRow (useUnreadCount) — every widget is a read of an already-cached query, four-state pattern throughout + │ │ │ ├── NurseDashboardScreen.tsx # ui-phase-7 — the «امروز» operational home replacing the old PlaceholderScreen: NextVisitCard (useTodaySessions), RequestsStrip (useNurseRequestInbox, the most time-critical widget — sorts above earnings), EarningsSnapshotCard (useNurseEarningsBalance, signed net + eligible), DashboardActivationSlot, NotificationsEntryRow (useUnreadCount) — every widget is a read of an already-cached query, four-state pattern throughout. iteration-2 removed the greeting+TrustBadge strip (it spent the screen's most valuable row restating the signed-in name and duplicating the activation tracker's badge) and replaced it with a plain `PageHeader` — load-bearing now that the nav is icon-only, since the page title is the only place the current section is named │ │ │ ├── DashboardActivationSlot.tsx # ui-phase-8 — the named composition point from ui-phase-7's hand-off, now filled with the shared `ActivationChecklist` (same component as `/nurse/services`) instead of the old single-row verification banner │ │ │ ├── requests/ # /nurse/requests — f7 incoming booking-requests inbox, ui-phase-7 redesign: page.tsx = decision-first cards (service+price headline when served — REQ-050, mock-tolerant) + an urgency-tinted CountdownTimer pill (teal/amber/terracotta tiers) + three tabs (در انتظار/پاسخ‌داده merged-client-side page-1-only/منقضی, shared Pager) ↔ requests/[id]/page.tsx detail (only customerNotes + masked city/district; accept now behind a ConfirmDialog + a post-accept payment-window countdown; reject-with-reason invalidate inbox+detail) - │ │ │ ├── profile/ # /nurse/profile — B7 profile bootstrap, ui-phase-8 pass: page.tsx adds editable education level/field (select + "سایر" free-text fallback) + specializations (chips, shared `SPECIALTY_PRESETS` vocab), a beforeunload guard on a staged-but-unsaved avatar, and a link into preview/ ↔ preview/page.tsx «نمایهٔ عمومی من» — the C3 trust-dossier pieces (TrustBadge/VerificationPanel/ServicePriceRow) composed entirely from the nurse's OWN cached data (own profile + useMyVariants + useServiceAreas + own badge), so it renders truthfully pre-publish - │ │ │ ├── services/ # /nurse/services — B7 services half: offerings list ↔ variant builder (page.tsx switches mode; MyServicesList + VariantBuilder + PublishGate co-located). ui-phase-8: MyServicesList mounts the shared `ActivationChecklist` + a link to the profile preview above the list; PublishGate rewritten as the REAL `set_accepting_bookings` toggle (state-driven guidance/start/pause — no more no-op snackbar); VariantBuilder's step-3 renders the actual `VariantCard` as a live listing preview, option groups are chips (not a wrapped ToggleButtonGroup), and a 409 duplicate offers "edit the existing listing" (resolved via `optionSetSignature` against the cached `useMyVariants` list) + │ │ │ ├── profile/ # /nurse/profile — B7 profile bootstrap, ui-phase-8 pass: page.tsx adds editable education level/field (select + "سایر" free-text fallback) + specializations (chips, shared `SPECIALTY_PRESETS` vocab), a beforeunload guard on a staged-but-unsaved avatar, and a link into preview/ ↔ preview/page.tsx «نمایهٔ عمومی من» — the C3 trust-dossier pieces (TrustBadge/VerificationPanel/ServicePriceRow) composed entirely from the nurse's OWN cached data (own profile + useMyVariants + useServiceAreas + own badge), so it renders truthfully pre-publish. iteration-2: rebuilt on react-hook-form + three `FormSection`s (معرفی / تجربه و تحصیلات / تخصص‌ها), with the blocked-until-verified banner as an `AccentCard` and the trust badge in `PageHeader`'s `meta` slot + │ │ │ ├── services/ # /nurse/services — B7 services half: offerings list ↔ variant builder (page.tsx switches mode; MyServicesList + VariantBuilder + PublishGate co-located). ui-phase-8: MyServicesList mounts the shared `ActivationChecklist` + a link to the profile preview above the list; PublishGate rewritten as the REAL `set_accepting_bookings` toggle (state-driven guidance/start/pause — no more no-op snackbar); VariantBuilder's step-3 renders the actual `VariantCard` as a live listing preview, option groups are chips (not a wrapped ToggleButtonGroup), and a 409 duplicate offers "edit the existing listing" (resolved via `optionSetSignature` against the cached `useMyVariants` list). iteration-2 made the create flow deterministic: the step list is DERIVED from the loaded option groups (a category with none skips straight to pricing instead of showing an empty middle step), Next is disabled with the unanswered required groups NAMED under it (was: always enabled, error only after the tap), and the price step recaps the chosen category+options as chips so it doubles as a review; RHF + `FormSection` throughout, sticky step footer │ │ │ ├── coverage/page.tsx # /nurse/coverage — F3 coverage-area editor. ui-phase-8: the separate whole-city/districts scope toggle is gone — `CascadingRegionSelect`'s own district level (its «کل شهر» empty option) is the ONLY control for the whole-city choice; `removeArea` now has an onError toast │ │ │ ├── bank/page.tsx # /nurse/bank — payout IBAN + ownership states (pending/verified/mismatch; `BankStatusPanel` unchanged). ui-phase-8: restructured as an accounts section — a persistent «افزودن حساب دیگر» CTA once ≥1 account exists (never dead-ends a nurse switching banks), an explicit pending-inquiry copy, and a real error state (never the empty-state form) on a failed query - │ │ │ ├── verification/ # /nurse/verification — f5 trust flow: ONE cached VerificationStatus query, four views. ui-phase-8: rebuilt as a single vertical journey — one progress metaphor everywhere (the old flat "X از Y" meter + the B4/B5/B6 3-step `StepperHeader` are both gone) + │ │ │ ├── verification/ # /nurse/verification — f5 trust flow: ONE cached VerificationStatus query, four views. ui-phase-8: rebuilt as a single vertical journey — one progress metaphor everywhere (the old flat "X از Y" meter + the B4/B5/B6 3-step `StepperHeader` are both gone). iteration-2: B4 and B5 are RHF forms grouped into `FormSection`s — B4's three asks (national id / card photo / selfie) each become a section with the card marked optional and the selfie marked required (the submit gate is a real form field, not a caption near the bottom); B5's four (شماره نظام / مدارک with an «n از m» count / تخصص‌ها / جزئیات مدرک, the last two marked optional) mount only once the status query resolves, so the server read-back IS `defaultValues` │ │ │ │ ├── page.tsx # B3 hub — grouped step cards (هویت/مدارک حرفه‌ای/بانک, `VerificationChecklist`) + `TrustBadgePreviewPanel` («این نشان را خانواده‌ها می‌بینند», fills per group) + single continue CTA + not_started/approved states; dev-only mock admin-decision sim │ │ │ │ ├── identity/page.tsx # B4 — national-ID (checksum) + card/selfie local capture → automated KYC + chained Shahkar; a cheap CSS `CaptureGuideFrame` (viewfinder corners / oval) + static hint per capture, `VerificationJourneyHeader` replaces the old StepperHeader │ │ │ │ ├── credentials/page.tsx # B5 — hydrates INO/specialties/registry fields from `status.credentialSubmission` (REQ-056, mock-tolerant) so a returning nurse sees a submitted summary, never blank fields; the INO number locks into a "شمارهٔ نظام ثبت شد" row (never re-prompted, never re-sent blank — the raw value is never read back by design); Jalali `JalaliDateField`s replace the native `type="date"` issue/expiry inputs; the submit gate considers server-side document state too, so a returning nurse is never dead-ended on a disabled button with no explanation @@ -250,13 +254,14 @@ client/ │ │ ├── PageHeader/ # title+subtitle+actions+optional back button (backTo/backLabel); ui-phase-11 added `meta` (a chip-row slot below the title, distinct from the button-oriented `actions`) and `onBack` (a callback alternative to `backTo` — pairs with `useAdminBackToList` for `router.back()`-with-fallback semantics; takes precedence over `backTo` when both are given) │ │ ├── ConfirmDialog/ # promoted from admin/ — required-reason gating + busy-disable, now usable by any actor; ui-phase-11 added `requireTypedConfirmation`/`typedConfirmationLabel`/`typedConfirmationPlaceholder` — confirm stays disabled until the typed value matches one of the given strings, the guard for an irreversible money-moving action (the admin payout run confirm) │ │ ├── SurfaceCard/ # flat Paper wrapper, padding: 'sm'|'md'|'lg' - │ │ ├── AccentCard/ # SurfaceCard + tone → 4px borderInlineStart accent (primary/secondary/success/error/warning/info/trust/neutral) + │ │ ├── AccentCard/ # SurfaceCard + a semantic `tone` for a STATEFUL panel. iteration-2: the colored edge stripe is GONE — a column of cards read as a row of loose vertical rules down the RTL side of the screen. `tone` survives as the semantic label (reaches the DOM as `data-accent-tone`); state is carried by the StatusChip/icon/copy inside the card. Do not reintroduce the stripe + │ │ ├── form/ # (iteration-2) the react-hook-form seam — `FormSection` (a titled/described group of fields, with an optional/status marker) + `RhfTextField` (`transform` normalizes keystrokes into form state; rule message replaces the helper text) / `RhfChipSelect` (multi or single; `allowCustomValues` renders a stored code that isn't in the option list) / `RhfJalaliDateField` / `RhfControlGroup` (label+hint+error shell around ANY non-input control — GenderToggle, RatingInput, a map picker). Every wrapper falls back to the enclosing `FormProvider`'s control, so a form wires it once (all tested) │ │ ├── Money/ # — the one money-rendering primitive (wraps utils/money.ts); size gained `xl` (h4) in ui-phase-6 for the checkout/confirmation prominent-total hero; imports next-intl (see jest.config.ts transformIgnorePatterns note below) │ │ ├── StatusTimeline/ # ordered TimelineNode[] (completed/current/pending/failed) with animated pulse on current (respects prefers-reduced-motion) │ │ ├── JalaliDatePicker/ # calendarEngine.ts (jalaali-js-backed Jalali↔Gregorian) + grid/chips variants (ui-phase-4: chips variant takes optional `todayLabel`/`tomorrowLabel` overrides — the C1 «امروز»/«فردا» date-intent strip), RTL-aware keyboard nav │ │ ├── JalaliDateField/ # read-only TextField + Popover wrapping JalaliDatePicker │ │ ├── JalaliDateIntentPicker/ # ui-phase-5 — extracted from C1's local date-intent widget (near-day chip strip + a calendar-icon Popover entry into the full grid) so C4's real required date field reuses it too, not just C1's intent-only one; caller-owned today/tomorrow/pick-other labels (tested) - │ │ ├── StickyActionBar/ # ui-phase-4 — a `position:sticky` bottom-pinned action-bar shell for a scrolling screen's primary CTA (C1's live-count CTA, C3's booking CTA); composes with the shell's existing BottomBar safe-area padding rather than reimplementing `env(safe-area-inset-bottom)` + │ │ ├── StickyActionBar/ # ui-phase-4 — a `position:sticky` bottom-pinned action-bar shell for a scrolling screen's primary CTA (C1's live-count CTA, C3's booking CTA). iteration-2: offsets off `--bal-chrome-bottom` (published by AppFrame) so it clears the pinned nav; the property already carries `env(safe-area-inset-bottom)` and resolves to `0px` in a chrome-free shell │ │ ├── LocaleSwitcher/ # ui-2 fa/en toggle preserving the current route (`router.replace(pathname, {locale})` via `@/i18n/navigation`); sidebar footers, the customer profile hub, the public shell (tested) │ │ ├── Pager/ # ui-phase-7 — the shared prev/next "page X of Y" control (common namespace i18n) replacing the near-identical inline pagers hand-rolled per list screen (nurse inbox tabs, payout history/earnings) (tested) │ │ ├── InitialsAvatar/ # ui-phase-9 — warm auto-colored initials for a person with no photo: deterministic name-hash → one of 6 `--bal-avatar-*` token pairs (tokens.css, both scheme blocks); `aria-hidden` (decorative next to a visible name); used by `PatientHeader` and (via `ProfileSummary`'s new `initialsFallback` prop) the customer account hub (tested) @@ -317,22 +322,22 @@ client/ │ ├── request.ts # getRequestConfig — loads messages/${locale}.json │ └── navigation.ts # ui-2 createNavigation(routing) — Link/usePathname/useRouter/redirect/getPathname. ALL chrome navigation goes through this: usePathname is locale-stripped (so unprefixed ROUTES.* compare directly) and Link/router add the locale automatically — no manual `/${locale}` prefixing, no middleware redirect hop ├── layout/ # the one mobile app shell — per-actor tabs over a shared phone-width frame - │ ├── AppFrame.tsx # 'use client' — THE device frame every shell renders inside: a centered `APP_FRAME_MAX_WIDTH` column on a `--bal-frame-canvas` backdrop, header/`
`/footer as flex siblings so the frame (not the document) owns the scroll, and `overflowX: hidden` + `minWidth: 0` so an over-wide child clips instead of dragging the app sideways. No shell computes a top offset any more (tested) - │ ├── MobileShell.tsx # 'use client' — the ONE authenticated shell behind all four actor apps: AppFrame + a contextual TopBar (brand lockup on a tab's own path, back chevron + route title on anything deeper) + BottomBar + ErrorBoundary + RouteFadeIn + PageTitleProvider. Actors supply only `tabs` + `headerActions` + │ ├── AppFrame.tsx # 'use client' — THE device frame every shell renders inside: a centered `APP_FRAME_MAX_WIDTH` column on a `--bal-frame-canvas` backdrop, `overflowX: hidden` + `minWidth: 0` so an over-wide child clips instead of dragging the app sideways. iteration-2: above `sm` the column FLOATS as a rounded, shadowed card with a gutter all round (edge-to-edge on a phone); header and footer are pinned OVER the single scrolling `
` with `position: absolute` — never `fixed`, which would break out of the centered column — and `
` reserves their exact height as padding. It also publishes `--bal-chrome-top`/`--bal-chrome-bottom` (0px in a chrome-free shell) so any `position: sticky` element in the tree can clear the bars without importing a constant. No shell computes a top offset any more (tested) + │ ├── MobileShell.tsx # 'use client' — the ONE authenticated shell behind all four actor apps: AppFrame + a contextual TopBar (brand lockup on a tab's own path, back chevron + route title on anything deeper) + BottomBar + ErrorBoundary + RouteFadeIn + PageTitleProvider. Actors supply only `tabs` + `headerActions`. No identity affordance in the header — that lives in each actor's «بیشتر»/account hub, one tap away on the nav │ ├── PrivateLayout.tsx # authenticated wrapper (passthrough today); actor chrome lives in the shells below │ ├── CustomerLayout.tsx # 'use client' — customer tabs: خانه (+/search) · رزروها · حلقهٔ مراقبت · کیف‌پول · پروفایل (+/addresses, /support, /notifications — the account hub that owns appearance/language) - │ ├── NurseLayout.tsx # 'use client' — nurse tabs, one per group the sidebar used to hide: امروز (/nurse) · حرفهٔ من (/nurse/practice) · مالی (/nurse/finance) · بیشتر (/nurse/more, `useSupportUnreadTotal` badge). Each group root is a real page; `matchPaths` keeps the historical destination URLs lighting up their group + │ ├── NurseLayout.tsx # 'use client' — nurse tabs: امروز (/nurse) · درخواست‌ها (/nurse/requests, pending-count badge off the already-cached `useNurseRequestInbox`) · حرفهٔ من (/nurse/practice) · مالی (/nurse/finance) · بیشتر (/nurse/more, `useSupportUnreadTotal` badge). درخواست‌ها earned a tab in iteration-2: it is the one nurse screen with a deadline on it (a pending request expires unanswered) and was previously reachable only from a strip on the dashboard. Each group root is a real page; `matchPaths` keeps the historical destination URLs lighting up their group │ ├── AdminLayout.tsx # 'use client' — admin tabs: نمای کلی · اعتماد · مالی · پشتیبانی · سیستم, each a group root. A group tab hides when `useAdminCapabilities` permits nothing inside it (gating unchanged and still per-console; the server still enforces); سیستم is always present because it carries settings + sign-out │ ├── PartnerLayout.tsx # 'use client' — partner tabs: مرکز · پرستاران · رزروها · تسویه · بیشتر. The center identity + merchant-of-record indicator moved from the top bar into /partner/more │ ├── PublicLayout.tsx # unauthenticated shell — the frame and NOTHING else: no top bar, so the login card's own BrandMark is the only mark on screen and the locale/theme toggles that used to sit up here live in settings │ ├── FocusedLayout.tsx # ui-phase-3 — chrome-free framed shell for can't-tab-away flows (onboarding, and /select-role via its own layout.tsx): a slim logo strip + content, no bottom nav; the route group above it still applies RoleGuard │ ├── routeTitle.tsx # ui-2 static route→title map (longest-prefix over ROUTES.*, off the `nav` namespace) + `PageTitleProvider`/`usePageTitleOverride` per-page dynamic-title slot + `useRouteTitle` │ ├── matchActivePath.ts # ui-2 shared longest-prefix, winner-takes-all active-path matcher (tested) — BottomBar runs it over each tab's own path PLUS its `matchPaths` claims, so a nested route still lights up its parent tab, never a sibling - │ ├── config.ts # APP_FRAME_MAX_WIDTH (480 — mirrored by components/config.ts's CONTENT_MAX_WIDTH) + TOP_BAR_HEIGHT + │ ├── config.ts # APP_FRAME_MAX_WIDTH (480 — mirrored by components/config.ts's CONTENT_MAX_WIDTH) + TOP_BAR_HEIGHT + TOP_CHROME_HEIGHT/BOTTOM_NAV_HEIGHT (the space each floating bar occupies, which AppFrame reserves as `
` padding — keep in sync with the bars) + FLOATING_BAR_SX (the ONE definition of the two bars' shared shape, so header and footer cannot drift apart) │ ├── index.ts │ └── components/ - │ ├── TopBar.tsx # NOT an AppBar — no filled surface, no bottom rule, no elevation: it sits on `background.default` and reads as part of the page rather than a bar covering the top of it. A plain flex row inside AppFrame, never a fixed overlay: title | titleNode override, align ('start' breadcrumb-style | 'center') - │ ├── BottomBar.tsx # the app's only navigation surface (tested) — a FLOATING pill bar (inset from the frame edges, `--bal-radius-pill`, `--bal-shadow-2`) rather than an edge-to-edge slab sealing off the bottom; still a flex sibling of the scrolling main, so nothing is ever hidden under it. ButtonBase tabs with an active pill that fills in behind the icon (`--bal-motion-fast`, so the app-wide reduced-motion gate already covers it), `LinkToPage.badgeCount` badges, and `matchPaths`-aware active matching + │ ├── TopBar.tsx # NOT an AppBar — no elevation or surface of its own: `AppFrame` wraps it in the floating-pill container (`FLOATING_BAR_SX`), so it is the bottom nav mirrored. Content: title | titleNode override, align ('start' breadcrumb-style | 'center') + │ ├── BottomBar.tsx # the app's only navigation surface (tested) — a FLOATING pill bar (inset from the frame edges, `FLOATING_BAR_SX`) rather than an edge-to-edge slab sealing off the bottom; pinned over the scrolling main by AppFrame, which reserves `BOTTOM_NAV_HEIGHT` of padding so nothing is ever hidden under it. ICON-ONLY (iteration-2: the caption was the widest thing in the bar at five tabs and cost a whole line; the label survives as `aria-label`/`title`), each tab a fixed 44px CIRCLE that is simultaneously the target, the hover/press tint and the active fill (`--bal-motion-fast`, so the app-wide reduced-motion gate already covers it), laid out `space-around` so the target keeps one size at any tab count; `LinkToPage.badgeCount` badges and `matchPaths`-aware active matching │ ├── BrandLockup.tsx # ui-2 compact horizontal logo+wordmark — the TopBar title on a shell's root tabs │ ├── ActorSwitcher.tsx # ui-2 dual customer+nurse session switcher (renders nothing for a single-role session); nurse «بیشتر» hub + customer profile hub (tested) │ └── index.tsx @@ -582,6 +587,62 @@ Rules: --- +## Forms + +**Rule: any form with more than one field uses react-hook-form.** A single-field control (a search +box, a filter select, a message composer) does not — it is state, not a form. + +This is not a style preference. The pattern it replaced was one `useState` per input **plus** a +parallel `useState` per error flag, which meant every keystroke re-rendered the whole screen — +including query-backed cards, price previews and uploaders sitting beside the field — and left +"is this form valid?" spread across ad-hoc `if` blocks at the top of each submit handler. + +### How to build one + +1. `useForm({ mode: 'onTouched', defaultValues })` — `onTouched` is the house default: an + error appears once a field has been visited, never while it is first being typed into. +2. Wrap the subtree in `` and bind fields with the + `@/components/common/form` wrappers. They read `control` off the provider, so it is threaded once. + Never call `register`/`useController` at a call site. +3. Put the rule on the field it governs (`rules={{ validate: … }}`), returning the **translated + message**. Cross-field rules read the second `validate` argument (all values) — that is how the + C4 request form's past-date guard reads the chosen start time. +4. Render `` and make the primary + button `type="submit"`. Enter-to-submit then works for free. +5. **Async defaults come from a mounted-when-ready child, not an effect.** When the initial values + depend on a query (the verification credentials read-back, the C4 variant/address defaults), keep + the loading branch in the parent and mount the form component only once the data has resolved, so + `defaultValues` *is* the server state instead of being copied into it later. + +### Wrappers + +| Wrapper | For | +| --- | --- | +| `RhfTextField` | any `TextField`, incl. `select`. `transform` normalizes keystrokes **into form state** (digit-stripping, max length) so the stored value is canonical, not just the displayed one. A rule message replaces `helperText`. | +| `RhfChipSelect` | a chip group over stable codes — `string[]` (multi) or `string \| null` (single). `allowCustomValues` keeps a stored code that isn't in the option list visible. | +| `RhfJalaliDateField` | a Jalali date field; stores the wire ISO (Gregorian) string or `null`. | +| `RhfControlGroup` | **any** non-input control (`GenderToggle`, `RatingInput`, `CascadingRegionSelect`, the map-pin picker, a `Switch`, a `Checkbox`). Gives it the same label/hint/error shell the text fields get. | + +### Two conventions worth knowing + +- **A control that renders its own error text gets a message-less rule** (`validate: (v) => cond`, + no string). `RhfControlGroup` then flags the field without printing a second identical line — + `AddressForm`'s region and pin fields are the reference. +- **When the displayed value isn't the stored value, drop to a bare `Controller`.** Two cases exist + and both are commented at the call site: the variant builder's display-name (stored = the override + only, blank ⇒ the server names it; shown = the live auto-generated name) and the admin refund + channel (stored = "" until explicitly overridden; shown = the server's resolved channel). + +### Structure: `FormSection` + +A long form is grouped into `FormSection`s — a heading, a one-line statement of *why* the group is +being asked for, and an optional/status marker. Applies to the nurse profile, the verification +identity + credentials screens, and the variant builder. The point is that an optional group reads +as skippable and a blocked submit has somewhere to attribute itself; a flat run of ten `TextField`s +makes everything look equally mandatory. + +--- + ## Theme System ### How it works (end-to-end, no-flash — CSS only, no boot script) diff --git a/client/messages/en.json b/client/messages/en.json index f407c83..137a460 100644 --- a/client/messages/en.json +++ b/client/messages/en.json @@ -189,7 +189,11 @@ "nurseProfile": { "title": "Nurse profile", "subtitle": "What families see when they find you.", - "photo": "Profile photo", + "section_intro_title": "Your introduction", + "section_intro_description": "The photo and the words families see first.", + "section_experience_title": "Experience & education", + "section_experience_description": "Your background is how families decide with confidence.", + "section_specializations_description": "The areas you have the most experience in.", "photo_hint": "A clear photo of your face.", "upload": "Upload photo", "uploading": "Uploading…", @@ -220,6 +224,7 @@ "education_other": "Other", "education_level_other_label": "Enter your education level", "education_field_other_label": "Enter your field of study", + "education_other_required": "Fill in the “other” value.", "specializations_label": "Specialties", "preview_cta": "Preview my public profile", "preview_title": "My public profile", @@ -382,11 +387,9 @@ "category_locked": "The category can't be changed after a service is created.", "options_title": "Set the service options", "options_subtitle": "Pick one option for each required item.", - "options_none": "This category has no options to configure.", + "options_missing_named": "Still to choose: {groups}", "required_badge": "Required", "optional_badge": "Optional", - "options_incomplete": "Answer every required item to continue.", - "price_title": "Set the price and unit", "price_label": "Price (Toman)", "price_hint": "Enter the price in Toman.", "price_required": "Enter a valid price greater than zero.", @@ -402,6 +405,10 @@ "preview_untitled": "New service", "summary_options": "Options", "summary_none": "No options", + "section_recap_title": "Selected service", + "section_recap_description": "Review your choices before you price it.", + "section_price_title": "Price & unit", + "section_listing_title": "How it appears in search", "next": "Next", "submit_create": "Add service", "submit_save": "Save changes", @@ -731,8 +738,8 @@ "accept_confirm_cta": "Yes, accept the request" }, "dashboard": { - "greeting": "Hi, {name}", "retry": "Retry", + "title": "Today", "next_visit_title": "Next visit", "next_visit_empty": "No visits scheduled for today.", "next_visit_starts_in": "starts {relative}", @@ -920,29 +927,34 @@ "group_review": "Review", "payoff_title": "This is the badge families will see", "payoff_subtitle": "It fills in as you complete each group below.", - "identity_title": "Verify identity", "identity_subtitle": "Your national ID, a photo of your ID card, and a liveness selfie.", + "identity_step_number_title": "Your national ID", "national_id_label": "National ID", "national_id_hint": "Enter your 10-digit national ID.", "national_id_invalid": "Enter a valid national ID.", "card_label": "National-ID card image", "card_hint": "Photograph your ID card in good light, clearly readable.", - "card_recommended": "Uploading the ID card image is recommended.", + "card_recommended_short": "Recommended", "capture_hint_card": "Good lighting, no blur, all four corners of the card inside the frame.", "selfie_label": "Liveness selfie", "selfie_hint": "Take a selfie for face verification.", "capture_hint_selfie": "Good lighting, face centered and unobstructed.", + "capture_done": "Captured", + "required_badge": "Required", "auto_registry_note": "An automatic civil-registry check is performed.", "error_national_id_mismatch": "The national ID didn't match the civil registry. Please check and try again.", "error_shared_sim": "This SIM doesn't appear to be registered in your name. Please try again with a SIM registered to you.", "error_shahkar_mismatch": "The mobile-to-national-ID match failed. Please check and try again.", "identity_submit": "Submit & verify", "identity_submitting": "Verifying…", + "identity_needs_selfie": "Capture the liveness selfie to continue.", "identity_submitted": "Identity submitted — verification started", "back_to_checklist": "Back to checklist", - "credentials_title": "Professional credentials", "credentials_subtitle": "Your documents are reviewed by our team after upload.", "credentials_needs_start": "Start verification from the status checklist first.", + "credentials_documents_title": "Professional documents", + "credentials_documents_description": "Upload each document — a reviewer checks it.", + "credentials_documents_count": "{done} of {total}", "ino_number_label": "Nursing-council number", "ino_number_hint": "Enter your nursing-council membership number.", "ino_number_required": "Enter your nursing-council number.", @@ -961,10 +973,12 @@ "specialty_wound_care": "Wound care", "specialty_add_placeholder": "Another specialty", "specialty_add": "Add", - "registry_details_label": "Credential details (optional)", "issuing_authority_label": "Issuing authority", "issued_at_label": "Issue date", "expires_at_label": "Expiry date", + "registry_details_title": "Credential details", + "registry_details_description": "The issuer and dates speed the review up.", + "summary_none": "Nothing recorded", "manual_review_note": "These documents are reviewed manually by our team — not an instant approval.", "credentials_submit": "Submit credentials", "credentials_submitting": "Submitting…", @@ -1427,6 +1441,7 @@ "category_label": "Category", "subject_label": "Subject", "message_label": "Message", + "message_required": "Write your message.", "submit": "Send", "submitting": "Sending…", "cancel": "Cancel", diff --git a/client/messages/fa.json b/client/messages/fa.json index fbc8786..bec5600 100644 --- a/client/messages/fa.json +++ b/client/messages/fa.json @@ -189,7 +189,11 @@ "nurseProfile": { "title": "پروفایل پرستار", "subtitle": "چیزی که خانواده‌ها هنگام یافتن شما می‌بینند.", - "photo": "عکس پروفایل", + "section_intro_title": "معرفی شما", + "section_intro_description": "عکس و متنی که خانواده‌ها پیش از هر چیز می‌بینند.", + "section_experience_title": "تجربه و تحصیلات", + "section_experience_description": "سابقه و مدرک شما به خانواده‌ها کمک می‌کند با اطمینان انتخاب کنند.", + "section_specializations_description": "حوزه‌هایی که در آن‌ها بیشترین تجربه را دارید.", "photo_hint": "یک عکس واضح از چهره‌تان.", "upload": "بارگذاری عکس", "uploading": "در حال بارگذاری…", @@ -220,6 +224,7 @@ "education_other": "سایر", "education_level_other_label": "مقطع تحصیلی خود را وارد کنید", "education_field_other_label": "رشتهٔ تحصیلی خود را وارد کنید", + "education_other_required": "مورد «سایر» را وارد کنید.", "specializations_label": "تخصص‌ها", "preview_cta": "پیش‌نمایش نمایهٔ عمومی من", "preview_title": "نمایهٔ عمومی من", @@ -382,11 +387,9 @@ "category_locked": "دسته پس از ایجاد خدمت قابل تغییر نیست.", "options_title": "گزینه‌های خدمت را مشخص کنید", "options_subtitle": "برای هر مورد الزامی یک گزینه انتخاب کنید.", - "options_none": "این دسته گزینه‌ای برای تنظیم ندارد.", + "options_missing_named": "هنوز انتخاب نشده: {groups}", "required_badge": "الزامی", "optional_badge": "اختیاری", - "options_incomplete": "برای ادامه، همهٔ موارد الزامی را انتخاب کنید.", - "price_title": "قیمت و واحد را تعیین کنید", "price_label": "قیمت (تومان)", "price_hint": "قیمت را به تومان وارد کنید.", "price_required": "قیمتی معتبر و بزرگ‌تر از صفر وارد کنید.", @@ -402,6 +405,10 @@ "preview_untitled": "خدمت جدید", "summary_options": "گزینه‌ها", "summary_none": "بدون گزینه", + "section_recap_title": "خدمت انتخابی", + "section_recap_description": "پیش از تعیین قیمت، انتخاب‌های خود را مرور کنید.", + "section_price_title": "قیمت و واحد", + "section_listing_title": "نمایش در جستجو", "next": "بعدی", "submit_create": "ثبت خدمت", "submit_save": "ذخیره تغییرات", @@ -731,8 +738,8 @@ "accept_confirm_cta": "بله، پذیرش درخواست" }, "dashboard": { - "greeting": "سلام، {name}", "retry": "تلاش مجدد", + "title": "امروز", "next_visit_title": "ویزیت بعدی", "next_visit_empty": "امروز ویزیتی ندارید.", "next_visit_starts_in": "شروع {relative}", @@ -920,29 +927,34 @@ "group_review": "بررسی", "payoff_title": "این نشان را خانواده‌ها می‌بینند", "payoff_subtitle": "با تکمیل هر گروه در پایین، این نشان کامل‌تر می‌شود.", - "identity_title": "تأیید هویت", "identity_subtitle": "کد ملی، تصویر کارت ملی و یک سلفی زنده.", + "identity_step_number_title": "شمارهٔ ملی شما", "national_id_label": "کد ملی", "national_id_hint": "کد ملی ۱۰ رقمی خود را وارد کنید.", "national_id_invalid": "کد ملی معتبر وارد کنید.", "card_label": "تصویر کارت ملی", "card_hint": "کارت ملی را در نور کافی و خوانا عکس بگیرید.", - "card_recommended": "بارگذاری تصویر کارت ملی توصیه می‌شود.", + "card_recommended_short": "توصیه‌شده", "capture_hint_card": "نور کافی، بدون تاری، چهار گوشهٔ کارت داخل کادر.", "selfie_label": "سلفی زنده", "selfie_hint": "برای تشخیص چهره، سلفی بگیرید.", "capture_hint_selfie": "نور کافی، چهره در مرکز و بدون پوشش.", + "capture_done": "ثبت شد", + "required_badge": "الزامی", "auto_registry_note": "استعلام خودکار از ثبت احوال انجام می‌شود.", "error_national_id_mismatch": "کد ملی با ثبت احوال مطابقت نداشت. لطفاً بررسی و دوباره تلاش کنید.", "error_shared_sim": "به نظر می‌رسد این سیم‌کارت به نام شما نیست. لطفاً با سیم‌کارتی که به نام خودتان است دوباره تلاش کنید.", "error_shahkar_mismatch": "تطبیق شماره موبایل و کد ملی ناموفق بود. لطفاً بررسی و دوباره تلاش کنید.", "identity_submit": "ثبت و استعلام", "identity_submitting": "در حال استعلام…", + "identity_needs_selfie": "برای ادامه، سلفی زنده را ثبت کنید.", "identity_submitted": "هویت ثبت شد — استعلام آغاز شد", "back_to_checklist": "بازگشت به فهرست", - "credentials_title": "مدارک حرفه‌ای", "credentials_subtitle": "مدارک شما پس از بارگذاری، توسط کارشناس بررسی می‌شود.", "credentials_needs_start": "ابتدا تأیید صلاحیت را از فهرست وضعیت آغاز کنید.", + "credentials_documents_title": "مدارک حرفه‌ای", + "credentials_documents_description": "هر مدرک را بارگذاری کنید؛ کارشناس آن را بررسی می‌کند.", + "credentials_documents_count": "{done} از {total}", "ino_number_label": "شماره نظام پرستاری", "ino_number_hint": "شماره عضویت نظام پرستاری خود را وارد کنید.", "ino_number_required": "شماره نظام پرستاری را وارد کنید.", @@ -961,10 +973,12 @@ "specialty_wound_care": "زخم و پانسمان", "specialty_add_placeholder": "تخصص دیگر", "specialty_add": "افزودن", - "registry_details_label": "جزئیات مدرک (اختیاری)", "issuing_authority_label": "مرجع صادرکننده", "issued_at_label": "تاریخ صدور", "expires_at_label": "تاریخ انقضا", + "registry_details_title": "جزئیات مدرک", + "registry_details_description": "مرجع صادرکننده و تاریخ‌ها بررسی را سریع‌تر می‌کنند.", + "summary_none": "موردی ثبت نشده", "manual_review_note": "این مدارک به‌صورت دستی توسط کارشناس بررسی می‌شوند؛ تأیید فوری نیست.", "credentials_submit": "ثبت مدارک", "credentials_submitting": "در حال ثبت…", @@ -1427,6 +1441,7 @@ "category_label": "دسته", "subject_label": "موضوع", "message_label": "پیام", + "message_required": "متن پیام را بنویسید.", "submit": "ارسال", "submitting": "در حال ارسال…", "cancel": "انصراف", diff --git a/client/package-lock.json b/client/package-lock.json index e6fd45c..dbb8f70 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -28,6 +28,7 @@ "notistack": "^3.0.2", "react": "^19.2.7", "react-dom": "^19.2.7", + "react-hook-form": "^7.83.0", "stylis-plugin-rtl": "^2.1.1" }, "devDependencies": { @@ -10272,6 +10273,22 @@ "react": "^19.2.7" } }, + "node_modules/react-hook-form": { + "version": "7.83.0", + "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.83.0.tgz", + "integrity": "sha512-AXt8cMCmx5a7u4uvpb2uRFVrWQhllI4pV+LSykxIac/hjt44TnQkmX9BKuQi2i+LDC62esmiLpilkav+kjVf/A==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/react-hook-form" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17 || ^18 || ^19" + } + }, "node_modules/react-is": { "version": "18.3.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", diff --git a/client/package.json b/client/package.json index 899e5e6..0f5e734 100644 --- a/client/package.json +++ b/client/package.json @@ -37,6 +37,7 @@ "notistack": "^3.0.2", "react": "^19.2.7", "react-dom": "^19.2.7", + "react-hook-form": "^7.83.0", "stylis-plugin-rtl": "^2.1.1" }, "devDependencies": { diff --git a/client/src/app/[locale]/(private-routes)/(customer)/bookings/[id]/cancel/page.tsx b/client/src/app/[locale]/(private-routes)/(customer)/bookings/[id]/cancel/page.tsx index 0c5972c..5a95649 100644 --- a/client/src/app/[locale]/(private-routes)/(customer)/bookings/[id]/cancel/page.tsx +++ b/client/src/app/[locale]/(private-routes)/(customer)/bookings/[id]/cancel/page.tsx @@ -2,11 +2,13 @@ import { useState } from 'react'; import { useParams, useRouter } from 'next/navigation'; import { useLocale, useTranslations } from 'next-intl'; -import { Checkbox, FormControlLabel, MenuItem, Paper, Stack, TextField, Typography } from '@mui/material'; +import { FormProvider, useForm, useWatch } from 'react-hook-form'; +import { Checkbox, FormControlLabel, MenuItem, Paper, Stack, Typography } from '@mui/material'; import AppButton from '@/components/common/AppButton'; import AppAlert from '@/components/common/AppAlert'; import AppLoading from '@/components/common/AppLoading'; import Money from '@/components/common/Money'; +import { RhfControlGroup, RhfTextField } from '@/components/common/form'; import StepperHeader from '@/components/StepperHeader'; import CancellationPolicyDisclosure from '@/components/CancellationPolicyDisclosure'; import { ContactSupportDialog } from '@/components/messaging'; @@ -34,6 +36,12 @@ function cancelErrorKey(error: unknown): string { return 'err_generic'; } +interface CancelFormValues { + reasonCategory: CancelReasonCategory | ''; + reasonNotes: string; + acknowledged: boolean; +} + /** * Cancellation flow (f10) — the trust-first exit. Step 1 **discloses** the resolved policy tier, the * refund % + fee %, and the concrete Toman amounts (refunded vs kept) **before** anything is submitted; @@ -56,11 +64,16 @@ export default function CancelBookingPage() { const cancel = useCancelBooking(); const [step, setStep] = useState<0 | 1>(0); - const [acknowledged, setAcknowledged] = useState(false); - // Never pre-defaulted (keeps the reason analytics honest) — confirm stays disabled until chosen. - const [reasonCategory, setReasonCategory] = useState(''); - const [reasonNotes, setReasonNotes] = useState(''); const [supportDialogCategory, setSupportDialogCategory] = useState(null); + // `reasonCategory` is never pre-defaulted (that would make the reason analytics lie) — the continue + // CTA stays disabled until it and the acknowledgement are both set. + const form = useForm({ + mode: 'onTouched', + defaultValues: { reasonCategory: '', reasonNotes: '', acknowledged: false }, + }); + const { control, getValues } = form; + const reasonCategory = useWatch({ control, name: 'reasonCategory' }); + const acknowledged = useWatch({ control, name: 'acknowledged' }); const bookingHref = `/${locale}${ROUTES.BOOKINGS}/${bookingId}`; @@ -108,19 +121,22 @@ export default function CancelBookingPage() { ); } - const submit = () => + const submit = () => { + const values = getValues(); cancel.mutate( { bookingId, sessionIds: preview.refundableSessionIds, // Guaranteed non-empty: step 1 is only reachable once a reason is chosen (the continue CTA gate). - reasonCategory: reasonCategory as CancelReasonCategory, - reasonNotes: reasonNotes.trim() || undefined, + reasonCategory: values.reasonCategory as CancelReasonCategory, + reasonNotes: values.reasonNotes.trim() || undefined, }, { onSuccess: () => router.push(`/${locale}${bookingRefundStatusPath(bookingId)}`) }, ); + }; return ( + {t('cancel_title')} @@ -158,13 +174,7 @@ export default function CancelBookingPage() { - setReasonCategory(event.target.value as CancelReasonCategory)} - fullWidth - > + name="reasonCategory" select label={t('reason_field_label')} fullWidth> {t('reason_placeholder')} @@ -173,19 +183,24 @@ export default function CancelBookingPage() { {t(`reason_cat_${category}`)} ))} - - + + name="reasonNotes" label={t('reason_notes_label')} - value={reasonNotes} - onChange={(event) => setReasonNotes(event.target.value)} multiline minRows={2} fullWidth /> - setAcknowledged(event.target.checked)} />} - label={t('acknowledge_label')} - /> + name="acknowledged"> + {({ field }) => ( + field.onChange(event.target.checked)} /> + } + label={t('acknowledge_label')} + /> + )} + router.push(bookingHref)}> @@ -252,5 +267,6 @@ export default function CancelBookingPage() { )} + ); } diff --git a/client/src/app/[locale]/(private-routes)/(customer)/bookings/[id]/review/page.tsx b/client/src/app/[locale]/(private-routes)/(customer)/bookings/[id]/review/page.tsx index cabac41..710c82c 100644 --- a/client/src/app/[locale]/(private-routes)/(customer)/bookings/[id]/review/page.tsx +++ b/client/src/app/[locale]/(private-routes)/(customer)/bookings/[id]/review/page.tsx @@ -1,10 +1,20 @@ 'use client'; -import { useState } from 'react'; import { useLocale, useTranslations } from 'next-intl'; import { useParams, useRouter } from 'next/navigation'; +import { FormProvider, useForm, useWatch } from 'react-hook-form'; import { useSnackbar } from 'notistack'; -import { Avatar, Paper, Skeleton, Stack, TextField, Typography } from '@mui/material'; -import { AppButton, AppIcon, EmptyState, RatingInput, ReviewTagSelector, StatusChip, SurfaceCard } from '@/components'; +import { Avatar, Paper, Skeleton, Stack, Typography } from '@mui/material'; +import { + AppButton, + AppIcon, + EmptyState, + RatingInput, + ReviewTagSelector, + RhfControlGroup, + RhfTextField, + StatusChip, + SurfaceCard, +} from '@/components'; import type { StatusKind } from '@/components'; import { formatShamsiDate } from '@/utils'; import { useBookingDetail } from '@/services/bookings'; @@ -24,6 +34,12 @@ function variantName(snapshotJson: string): string | null { const REVIEW_BODY_MAX = 2000; +interface ReviewFormValues { + rating: number; + body: string; + tagCodes: string[]; +} + /** moderationStatus → StatusChip kind (published=success, pending=warning, rejected=error, hidden=neutral). */ const STATUS_KIND: Record = { pending_moderation: 'pending', @@ -57,19 +73,19 @@ export default function LeaveReviewPage() { const myReview = useMyReviewForBooking(bookingId, { enabled: reviewable }); const createReview = useCreateReview(); - const [rating, setRating] = useState(0); - const [body, setBody] = useState(''); - const [tagCodes, setTagCodes] = useState([]); + const form = useForm({ mode: 'onTouched', defaultValues: { rating: 0, body: '', tagCodes: [] } }); + const { control, handleSubmit } = form; + const rating = useWatch({ control, name: 'rating' }); + const body = useWatch({ control, name: 'body' }); + const tagCodes = useWatch({ control, name: 'tagCodes' }); const nurseName = booking?.nurseName?.trim(); - const submit = () => { - if (rating < 1) return; + const submit = (values: ReviewFormValues) => createReview.mutate( - { bookingId, body: { rating, body: body.trim() || null, tagCodes } }, + { bookingId, body: { rating: values.rating, body: values.body.trim() || null, tagCodes: values.tagCodes } }, { onError: () => enqueueSnackbar(t('error_submit'), { variant: 'error' }) }, ); - }; // ── Already reviewed → the persistent under-review / published state (never a second form) ─────────────── const existing = myReview.data; @@ -144,7 +160,8 @@ export default function LeaveReviewPage() { // ── Eligible → the review form ─────────────────────────────────────────────────────────────────────────── return ( - + + {booking ? : null} @@ -156,44 +173,46 @@ export default function LeaveReviewPage() { - - - {t('rating_label')} - - - + + name="rating" + label={t('rating_label')} + rules={{ validate: (value) => Number(value ?? 0) >= 1 }} + > + {({ field }) => ( + + )} + - + name="body" label={t('body_label')} placeholder={t('body_placeholder')} - value={body} - onChange={(e) => setBody(e.target.value.slice(0, REVIEW_BODY_MAX))} + transform={(raw) => raw.slice(0, REVIEW_BODY_MAX)} multiline minRows={3} fullWidth /> - - - {t('tags_label')} - - (t.has(`tag_${code}`) ? t(`tag_${code}`) : code)} - disabled={createReview.isPending} - /> - + name="tagCodes" label={t('tags_label')}> + {({ field }) => ( + (t.has(`tag_${code}`) ? t(`tag_${code}`) : code)} + disabled={createReview.isPending} + /> + )} + router.back()} disabled={createReview.isPending}> {tc('cancel')} @@ -201,6 +220,7 @@ export default function LeaveReviewPage() { + ); } diff --git a/client/src/app/[locale]/(private-routes)/(customer)/bookings/checkout/bnpl/EligibilityStep.tsx b/client/src/app/[locale]/(private-routes)/(customer)/bookings/checkout/bnpl/EligibilityStep.tsx index 435c11a..358251e 100644 --- a/client/src/app/[locale]/(private-routes)/(customer)/bookings/checkout/bnpl/EligibilityStep.tsx +++ b/client/src/app/[locale]/(private-routes)/(customer)/bookings/checkout/bnpl/EligibilityStep.tsx @@ -1,13 +1,19 @@ 'use client'; -import { FunctionComponent, useState } from 'react'; +import { FunctionComponent } from 'react'; import { useTranslations } from 'next-intl'; -import { Checkbox, CircularProgress, FormControlLabel, Paper, Stack, TextField, Typography } from '@mui/material'; -import { AppButton, AppIcon, Money, PhoneNumberField } from '@/components'; +import { FormProvider, useForm, useWatch } from 'react-hook-form'; +import { Checkbox, CircularProgress, FormControlLabel, Paper, Stack, Typography } from '@mui/material'; +import { AppButton, AppIcon, Money, PhoneNumberField, RhfControlGroup, RhfTextField } from '@/components'; import { digitsOnly } from '@/utils'; import { useCheckEligibility } from '@/services/bnpl'; import { NATIONAL_ID_LENGTH, NATIONAL_ID_PATTERN } from '@/services/bnpl/constants'; import type { BnplEligibilityResult, ProviderCode } from '@/services/bnpl/types'; +interface EligibilityFormValues { + nationalId: string; + consent: boolean; +} + interface EligibilityStepProps { bookingRequestId: number; providerCode: ProviderCode; @@ -36,22 +42,23 @@ const EligibilityStep: FunctionComponent = ({ const t = useTranslations('bnpl'); const tc = useTranslations('common'); - const [nationalId, setNationalId] = useState(''); - const [consent, setConsent] = useState(false); - const [submitted, setSubmitted] = useState(false); + const form = useForm({ mode: 'onTouched', defaultValues: { nationalId: '', consent: false } }); + const { control, handleSubmit } = form; + const consent = useWatch({ control, name: 'consent' }); const check = useCheckEligibility(); // A fresh check wins; otherwise re-show a prior approval carried back from D4. const result = check.data ?? initialResult ?? undefined; - const nationalIdValid = NATIONAL_ID_PATTERN.test(nationalId); - const nationalIdError = submitted && !nationalIdValid; const providerName = t(`provider_${providerCode}`); - const handleSubmit = () => { - setSubmitted(true); - if (!nationalIdValid || !consent) return; - check.mutate({ bookingRequestId, providerCode, nationalId, mobile: sessionMobile, consent }); - }; + const submit = (values: EligibilityFormValues) => + check.mutate({ + bookingRequestId, + providerCode, + nationalId: values.nationalId, + mobile: sessionMobile, + consent: values.consent, + }); // Approved — show the ceiling + advance. if (result?.isEligible) { @@ -111,63 +118,74 @@ const EligibilityStep: FunctionComponent = ({ body={t('eligibility_error')} cardLabel={t('pay_with_card')} onPayWithCard={onPayWithCard} - onRetry={handleSubmit} + onRetry={handleSubmit(submit)} retryLabel={tc('retry')} /> ); } return ( - - - {t('eligibility_title')} - + + + + {t('eligibility_title')} + - setNationalId(digitsOnly(e.target.value).slice(0, NATIONAL_ID_LENGTH))} - error={nationalIdError} - helperText={nationalIdError ? t('national_id_invalid') : undefined} - slotProps={{ htmlInput: { dir: 'ltr', inputMode: 'numeric', maxLength: NATIONAL_ID_LENGTH, style: { textAlign: 'start' } } }} - fullWidth - /> + + name="nationalId" + label={t('national_id_label')} + placeholder={t('national_id_placeholder')} + transform={(raw) => digitsOnly(raw).slice(0, NATIONAL_ID_LENGTH)} + rules={{ validate: (value) => NATIONAL_ID_PATTERN.test(String(value ?? '')) || t('national_id_invalid') }} + slotProps={{ htmlInput: { dir: 'ltr', inputMode: 'numeric', maxLength: NATIONAL_ID_LENGTH, style: { textAlign: 'start' } } }} + fullWidth + /> - undefined} - slotProps={{ input: { readOnly: true } }} - fullWidth - /> + undefined} + slotProps={{ input: { readOnly: true } }} + fullWidth + /> - setConsent(e.target.checked)} color="secondary" />} - label={ - - {t('consent_label', { provider: providerName })} - - } - sx={{ alignItems: 'flex-start', m: 0 }} - /> + name="consent"> + {({ field }) => ( + field.onChange(event.target.checked)} + color="secondary" + /> + } + label={ + + {t('consent_label', { provider: providerName })} + + } + sx={{ alignItems: 'flex-start', m: 0 }} + /> + )} + - - : undefined} - > - {check.isPending ? t('checking_eligibility') : t('check_eligibility')} - - - {t('pay_with_card')} - + + : undefined} + > + {check.isPending ? t('checking_eligibility') : t('check_eligibility')} + + + {t('pay_with_card')} + + - + ); }; diff --git a/client/src/app/[locale]/(private-routes)/(customer)/bookings/request/page.tsx b/client/src/app/[locale]/(private-routes)/(customer)/bookings/request/page.tsx index b11fc91..03906cf 100644 --- a/client/src/app/[locale]/(private-routes)/(customer)/bookings/request/page.tsx +++ b/client/src/app/[locale]/(private-routes)/(customer)/bookings/request/page.tsx @@ -2,6 +2,7 @@ import { Suspense, useMemo, useState } from 'react'; import { useLocale, useTranslations } from 'next-intl'; import { useRouter, useSearchParams } from 'next/navigation'; +import { FormProvider, useForm, useWatch } from 'react-hook-form'; import { Avatar, Box, @@ -21,6 +22,8 @@ import { EmptyState, JalaliDateIntentPicker, PriceDisplay, + RhfControlGroup, + RhfTextField, StepperHeader, TrustBadge, } from '@/components'; @@ -39,6 +42,7 @@ import type { RequiredCaregiverGender, } from '@/services/bookingRequests/types'; import type { CustomerAddress } from '@/services/addresses/types'; +import type { Patient } from '@/services/patients/types'; const GENDER_OPTIONS: RequiredCaregiverGender[] = ['female', 'male', 'any']; @@ -54,7 +58,17 @@ const TIME_WINDOWS: TimeWindowOption[] = [ { key: 'evening', start: '16:00', end: '20:00' }, ]; -type TouchedField = 'patient' | 'service' | 'address' | 'date' | 'time' | 'gender'; +interface RequestFormValues { + patientId: number | ''; + variantId: number | ''; + addressId: number | ''; + gender: RequiredCaregiverGender | ''; + date: string; + window: TimeWindowOption['key'] | 'custom' | null; + timeStart: string; + timeEnd: string; + notes: string; +} /** * C4 — Booking-request form (فرم درخواست). The destination of the C3 "درخواست رزرو" CTA (it carries the @@ -72,6 +86,16 @@ export default function BookingRequestFormPage() { ); } +/** + * Resolves the URL hand-off and waits for every list the form defaults off before mounting it. + * + * The wait is load-bearing rather than cosmetic: the variant and address fields default to "the one + * carried in the URL, else the nurse's first service / the primary address", and those defaults can + * only be computed once the lists exist. Previously the form mounted immediately and re-derived the + * effective value on every render (`variantSel !== '' ? variantSel : firstVariantId`), which meant the + * *stored* value and the *shown* value could disagree, and neither field could carry a plain required + * rule. Mounting once with real `defaultValues` makes the stored value the only value. + */ function BookingRequestForm() { const t = useTranslations('booking'); const locale = useLocale(); @@ -90,68 +114,115 @@ function BookingRequestForm() { const profileQuery = useNurseProfile(hasNurse ? nurseId : undefined); const patientsQuery = usePatients(); const addressesQuery = useAddresses(); + + if (!hasNurse) { + return ( + router.push(`/${locale}${ROUTES.SEARCH}`)} + sx={{ m: 0 }} + > + {t('missing_nurse_cta')} + + } + /> + ); + } + + if (profileQuery.isLoading || patientsQuery.isLoading || addressesQuery.isLoading) return ; + + return ( + + ); +} + +function RequestForm({ + nurseId, + profile, + patients, + addresses, + carried, +}: { + nurseId: number; + profile: NurseProfile | undefined; + patients: Patient[]; + addresses: CustomerAddress[]; + carried: { + variantId: number | null; + patientId: number | null; + addressId: number | null; + gender: RequiredCaregiverGender | null; + }; +}) { + const t = useTranslations('booking'); + const locale = useLocale(); + const router = useRouter(); const createRequest = useCreateBookingRequest(); - const profile = profileQuery.data; - const patients = useMemo(() => patientsQuery.data?.items ?? [], [patientsQuery.data]); - const addresses = useMemo(() => addressesQuery.data?.items ?? [], [addressesQuery.data]); const services = useMemo(() => profile?.services ?? [], [profile]); - - const [patientId, setPatientId] = useState(patientIdParam ?? ''); - const [variantSel, setVariantSel] = useState(variantIdParam ?? ''); - const [addressSel, setAddressSel] = useState(addressIdParam ?? ''); const [addressEditing, setAddressEditing] = useState(false); - const [gender, setGender] = useState( - genderParam === 'male' || genderParam === 'female' ? genderParam : '', - ); - const [date, setDate] = useState(''); - const [windowSel, setWindowSel] = useState(null); - const [timeStart, setTimeStart] = useState(''); - const [timeEnd, setTimeEnd] = useState(''); - const [notes, setNotes] = useState(''); - const [touched, setTouched] = useState>({ - patient: false, - service: false, - address: false, - date: false, - time: false, - gender: false, - }); - const [pastDateError, setPastDateError] = useState(false); const [formError, setFormError] = useState(null); - const markTouched = (field: TouchedField) => - setTouched((prev) => (prev[field] ? prev : { ...prev, [field]: true })); - - // Effective selection = the user's explicit choice, else a sensible default derived from the loaded - // data. Computed during render (no setState-in-effect): the variant defaults to the carried one / the - // first offered, the address to the primary / first. - const firstVariantId: number | '' = services.length > 0 ? services[0].variantId : ''; - const variantId = variantSel !== '' ? variantSel : firstVariantId; const primaryAddressId: number | '' = addresses.length > 0 ? (addresses.find((address) => address.isPrimary)?.id ?? addresses[0].id) : ''; - const addressId = addressSel !== '' ? addressSel : primaryAddressId; - const selectedVariant = useMemo( - () => services.find((service) => service.variantId === variantId), - [services, variantId], - ); - const selectedAddress = useMemo( - () => addresses.find((address) => address.id === addressId), - [addresses, addressId], - ); - const selectedPatient = useMemo( - () => patients.find((patient) => patient.id === patientId), - [patients, patientId], - ); + const form = useForm({ + mode: 'onTouched', + defaultValues: { + patientId: carried.patientId ?? '', + variantId: carried.variantId ?? (services.length > 0 ? services[0].variantId : ''), + addressId: carried.addressId ?? primaryAddressId, + gender: carried.gender ?? '', + date: '', + window: null, + timeStart: '', + timeEnd: '', + notes: '', + }, + }); + const { control, handleSubmit, setValue, getValues } = form; + const values = useWatch({ control }); + + const patientId = values.patientId ?? ''; + const variantId = values.variantId ?? ''; + const addressId = values.addressId ?? ''; + const gender = values.gender ?? ''; + const notes = values.notes ?? ''; + const windowSel = values.window ?? null; + + const selectedVariant = services.find((service) => service.variantId === variantId); + const selectedAddress = addresses.find((address) => address.id === addressId); + const selectedPatient = patients.find((patient) => patient.id === patientId); // A concrete gender that contradicts the (single) nurse's gender is a same-gender mismatch (400) — // block it inline before the round-trip; the server re-validates and is authoritative. - const genderMismatch = - gender !== '' && gender !== 'any' && profile != null && gender !== profile.nurseGender; + const genderMismatch = gender !== '' && gender !== 'any' && profile != null && gender !== profile.nurseGender; - const requiredChosen = - patientId !== '' && variantId !== '' && addressId !== '' && gender !== '' && date !== '' && timeStart !== '' && timeEnd !== ''; + const missingFieldLabels: string[] = []; + if (patientId === '') missingFieldLabels.push(t('cta_missing_patient')); + if (variantId === '') missingFieldLabels.push(t('cta_missing_service')); + if (addressId === '') missingFieldLabels.push(t('cta_missing_address')); + if (values.date === '') missingFieldLabels.push(t('cta_missing_date')); + if (values.timeStart === '' || values.timeEnd === '') missingFieldLabels.push(t('cta_missing_time')); + if (gender === '') missingFieldLabels.push(t('cta_missing_gender')); + const requiredChosen = missingFieldLabels.length === 0; const regionLabel = (address: CustomerAddress): string => { const city = locale === 'en' ? address.cityNameEn : address.cityNameFa; @@ -165,31 +236,15 @@ function BookingRequestForm() { }; const selectWindow = (option: TimeWindowOption) => { - setWindowSel(option.key); - setTimeStart(option.start); - setTimeEnd(option.end); - if (pastDateError) setPastDateError(false); + setValue('window', option.key, { shouldDirty: true }); + setValue('timeStart', option.start, { shouldValidate: true }); + setValue('timeEnd', option.end, { shouldValidate: true }); + // The date's past-guard is a cross-field rule over the start time — re-run it now that one exists. + if (getValues('date')) void form.trigger('date'); }; - const missingFieldLabels: string[] = []; - if (patientId === '') missingFieldLabels.push(t('cta_missing_patient')); - if (variantId === '') missingFieldLabels.push(t('cta_missing_service')); - if (addressId === '') missingFieldLabels.push(t('cta_missing_address')); - if (date === '') missingFieldLabels.push(t('cta_missing_date')); - if (timeStart === '' || timeEnd === '') missingFieldLabels.push(t('cta_missing_time')); - if (gender === '') missingFieldLabels.push(t('cta_missing_gender')); - - const handleSubmit = () => { + const submit = (formValues: RequestFormValues) => { setFormError(null); - if (!requiredChosen) return; - if (timeEnd <= timeStart) return; - // Future date+time guard (local wall-clock, matching the wire's date + time fields). Evaluated in the - // handler (not render) so the render path stays pure; the result drives the inline date error. - if (Date.parse(`${date}T${timeStart}`) < Date.now()) { - setPastDateError(true); - return; - } - setPastDateError(false); if (genderMismatch) return; const context: BookingRequestDisplayContext | undefined = @@ -220,363 +275,321 @@ function BookingRequestForm() { { payload: { nurseId, - variantId: variantId as number, - patientId: patientId as number, - customerAddressId: addressId as number, - requestedDate: date, - requestedTimeStart: `${timeStart}:00`, - requestedTimeEnd: `${timeEnd}:00`, - requiredCaregiverGender: gender as RequiredCaregiverGender, - customerNotes: notes.trim() || null, + variantId: formValues.variantId as number, + patientId: formValues.patientId as number, + customerAddressId: formValues.addressId as number, + requestedDate: formValues.date, + requestedTimeStart: `${formValues.timeStart}:00`, + requestedTimeEnd: `${formValues.timeEnd}:00`, + requiredCaregiverGender: formValues.gender as RequiredCaregiverGender, + customerNotes: formValues.notes.trim() || null, }, context, }, { - onSuccess: (dto) => { - router.push(`/${locale}${ROUTES.BOOKING_REQUEST_STATUS}/${dto.id}`); - }, + onSuccess: (dto) => router.push(`/${locale}${ROUTES.BOOKING_REQUEST_STATUS}/${dto.id}`), onError: (error) => setFormError(mapCreateError(error, t)), }, ); }; - if (!hasNurse) { - return ( - router.push(`/${locale}${ROUTES.SEARCH}`)} - sx={{ m: 0 }} - > - {t('missing_nurse_cta')} - - } - /> - ); - } - - if (profileQuery.isLoading) return ; - - const timeError = touched.time && timeStart !== '' && timeEnd !== '' && timeEnd <= timeStart; - const pastError = pastDateError; - return ( - - {profile ? : null} + + + {profile ? : null} - - - {t('request_title')} - - - {t('form_subtitle')} - - - - - - {t('whathappens_title')} - - - - - {/* Patient */} - {patients.length === 0 ? ( - router.push(`/${locale}${ROUTES.PATIENTS}`)} - /> - ) : ( - setPatientId(Number(event.target.value))} - onBlur={() => markTouched('patient')} - fullWidth - > - - {t('patient_placeholder')} - - {patients.map((patient) => ( - - {patient.displayName} - - ))} - - )} - - {/* Service variant */} - - {services.length === 0 ? ( - - {t('service_empty')} + + + {t('request_title')} + + {t('form_subtitle')} + + + + + + {t('whathappens_title')} + + + + + {/* Patient */} + {patients.length === 0 ? ( + router.push(`/${locale}${ROUTES.PATIENTS}`)} + /> ) : ( - + name="patientId" select - label={t('service_label')} - value={variantId} - error={touched.service && variantId === ''} - helperText={touched.service && variantId === '' ? t('error_service_required') : undefined} - onChange={(event) => setVariantSel(Number(event.target.value))} - onBlur={() => markTouched('service')} + label={t('patient_label')} + rules={{ validate: (value) => value !== '' || t('error_patient_required') }} fullWidth > - {t('service_placeholder')} + {t('patient_placeholder')} - {services.map((service) => ( - - {service.displayName} + {patients.map((patient) => ( + + {patient.displayName} ))} - + )} - {selectedVariant ? ( - - ) : null} - - {/* Address — a compact confirmation row once resolved, with a way back to the select. */} - {addresses.length === 0 ? ( - router.push(`/${locale}${ROUTES.ADDRESSES}`)} - /> - ) : addressEditing || !selectedAddress ? ( - { - setAddressSel(Number(event.target.value)); - setAddressEditing(false); - }} - onBlur={() => markTouched('address')} - fullWidth - > - - {t('address_placeholder')} - - {addresses.map((address) => ( - - {address.title} · {locale === 'en' ? address.cityNameEn : address.cityNameFa} - - ))} - - ) : ( - - - - - {regionLabel(selectedAddress)} + {/* Service variant */} + + {services.length === 0 ? ( + + {t('service_empty')} - {selectedAddress.addressLine ? ( - - {selectedAddress.addressLine} - - ) : null} - - setAddressEditing(true)} sx={{ flexShrink: 0 }}> - {t('address_change_cta')} - - - )} - - {/* Date */} - markTouched('date')}> - - {t('date_label')} - - { - setDate(iso); - if (pastDateError) setPastDateError(false); - }} - min={todayIso()} - todayLabel={t('date_today')} - tomorrowLabel={t('date_tomorrow')} - pickOtherLabel={t('date_pick_other')} - /> - {pastError ? ( - - {t('error_past_date')} - - ) : touched.date && date === '' ? ( - - {t('error_date_required')} - - ) : null} - - - {/* Time window — presets kill the end<=start error class; «زمان دلخواه» reveals free time fields. */} - markTouched('time')}> - - {t('time_window_label')} - - - {TIME_WINDOWS.map((option) => ( - selectWindow(option)} - color={windowSel === option.key ? 'primary' : undefined} - variant={windowSel === option.key ? 'filled' : 'outlined'} - data-window={option.key} + ) : ( + + name="variantId" + select + label={t('service_label')} + rules={{ validate: (value) => value !== '' || t('error_service_required') }} + fullWidth + > + + {t('service_placeholder')} + + {services.map((service) => ( + + {service.displayName} + + ))} + + )} + {selectedVariant ? ( + - ))} - setWindowSel('custom')} - color={windowSel === 'custom' ? 'primary' : undefined} - variant={windowSel === 'custom' ? 'filled' : 'outlined'} - data-window="custom" + ) : null} + + + {/* Address — a compact confirmation row once resolved, with a way back to the select. */} + {addresses.length === 0 ? ( + router.push(`/${locale}${ROUTES.ADDRESSES}`)} /> - - {windowSel === 'custom' ? ( - - setTimeStart(event.target.value)} - slotProps={{ inputLabel: { shrink: true } }} - fullWidth - /> - setTimeEnd(event.target.value)} - slotProps={{ inputLabel: { shrink: true } }} - fullWidth - /> + ) : addressEditing || !selectedAddress ? ( + + name="addressId" + select + label={t('address_label')} + rules={{ validate: (value) => value !== '' || t('error_address_required') }} + // Collapses back to the compact summary row once the menu closes — picking a different + // address is the normal exit, and dismissing without picking leaves the current one shown. + slotProps={{ select: { onClose: () => setAddressEditing(false) } }} + fullWidth + > + + {t('address_placeholder')} + + {addresses.map((address) => ( + + {address.title} · {locale === 'en' ? address.cityNameEn : address.cityNameFa} + + ))} + + ) : ( + + + + + {regionLabel(selectedAddress)} + + {selectedAddress.addressLine ? ( + + {selectedAddress.addressLine} + + ) : null} + + setAddressEditing(true)} sx={{ flexShrink: 0 }}> + {t('address_change_cta')} + - ) : null} - {touched.time && (timeStart === '' || timeEnd === '') ? ( - - {t('error_time_required')} - - ) : null} - + )} - {/* Caregiver gender — first-class, three-way, never silently defaulted */} - markTouched('gender')}> - - {t('gender_label')} - - { - if (next) setGender(next); - }} - sx={{ - '& .MuiToggleButton-root': { - flex: 1, - py: 1.25, - fontWeight: 700, - borderColor: touched.gender && gender === '' ? 'var(--bal-error)' : undefined, + {/* Date — the past-date guard is a cross-field rule against the chosen start time. */} + + name="date" + label={t('date_label')} + rules={{ + validate: { + chosen: (value) => value !== '' || t('error_date_required'), + future: (value, all) => + !all.timeStart || Date.parse(`${value}T${all.timeStart}`) >= Date.now() || t('error_past_date'), }, }} > - {GENDER_OPTIONS.map((option) => ( - - {t(`gender_${option}`)} - - ))} - - - {t('gender_hint')} - - {touched.gender && gender === '' ? ( - - {t('error_gender_required')} + {({ field }) => ( + + )} + + + {/* Time window — presets kill the end<=start error class; «زمان دلخواه» reveals free time fields. */} + + + {t('time_window_label')} - ) : null} + + {TIME_WINDOWS.map((option) => ( + selectWindow(option)} + color={windowSel === option.key ? 'primary' : undefined} + variant={windowSel === option.key ? 'filled' : 'outlined'} + data-window={option.key} + /> + ))} + setValue('window', 'custom', { shouldDirty: true })} + color={windowSel === 'custom' ? 'primary' : undefined} + variant={windowSel === 'custom' ? 'filled' : 'outlined'} + data-window="custom" + /> + + {windowSel === 'custom' ? ( + + + name="timeStart" + type="time" + label={t('time_start_label')} + rules={{ validate: (value) => value !== '' || t('error_time_required') }} + slotProps={{ inputLabel: { shrink: true } }} + fullWidth + /> + + name="timeEnd" + type="time" + label={t('time_end_label')} + rules={{ + validate: { + chosen: (value) => value !== '' || t('error_time_required'), + after: (value, all) => !all.timeStart || String(value) > all.timeStart || t('error_time_range'), + }, + }} + slotProps={{ inputLabel: { shrink: true } }} + fullWidth + /> + + ) : null} + + + {/* Caregiver gender — first-class, three-way, never silently defaulted */} + + name="gender" + label={t('gender_label')} + hint={t('gender_hint')} + rules={{ validate: (value) => value !== '' || t('error_gender_required') }} + > + {({ field, hasError }) => ( + { + if (next) field.onChange(next); + }} + sx={{ + '& .MuiToggleButton-root': { + flex: 1, + py: 1.25, + fontWeight: 700, + borderColor: hasError ? 'var(--bal-error)' : undefined, + }, + }} + > + {GENDER_OPTIONS.map((option) => ( + + {t(`gender_${option}`)} + + ))} + + )} + + {genderMismatch ? ( {t('error_gender_mismatch')} ) : null} - - {/* Stage-1 notes */} - - setNotes(event.target.value.slice(0, CUSTOMER_NOTES_MAX_LENGTH))} - multiline - minRows={3} - fullWidth - helperText={t('notes_hint')} - /> - - {t('notes_counter', { count: notes.length, max: CUSTOMER_NOTES_MAX_LENGTH })} - - + {/* Stage-1 notes */} + + + name="notes" + label={t('notes_label')} + placeholder={t('notes_placeholder')} + helperText={t('notes_hint')} + transform={(raw) => raw.slice(0, CUSTOMER_NOTES_MAX_LENGTH)} + multiline + minRows={3} + fullWidth + /> + + {t('notes_counter', { count: notes.length, max: CUSTOMER_NOTES_MAX_LENGTH })} + + - {formError ? ( - - {formError} - - ) : null} - - - - {createRequest.isPending ? t('submitting') : t('submit')} - - {!requiredChosen && missingFieldLabels.length > 0 ? ( - - {t('cta_missing_caption', { fields: missingFieldLabels.join(locale === 'fa' ? '، ' : ', ') })} + {formError ? ( + + {formError} ) : null} + + + + {createRequest.isPending ? t('submitting') : t('submit')} + + {!requiredChosen ? ( + + {t('cta_missing_caption', { fields: missingFieldLabels.join(locale === 'fa' ? '، ' : ', ') })} + + ) : null} + - + ); } @@ -596,7 +609,9 @@ function NurseIdentityBar({ profile }: { profile: NurseProfile }) { data-nurse-identity-bar sx={{ position: 'sticky', - top: 0, + // Sticks just below the shell's pinned header rather than behind it (AppFrame publishes the + // height); `0px` in a chrome-free shell. + top: 'var(--bal-chrome-top, 0px)', zIndex: 2, gap: 1.5, alignItems: 'center', diff --git a/client/src/app/[locale]/(private-routes)/(customer)/patients/[id]/record/page.tsx b/client/src/app/[locale]/(private-routes)/(customer)/patients/[id]/record/page.tsx index 4076e13..e9ad35a 100644 --- a/client/src/app/[locale]/(private-routes)/(customer)/patients/[id]/record/page.tsx +++ b/client/src/app/[locale]/(private-routes)/(customer)/patients/[id]/record/page.tsx @@ -3,6 +3,7 @@ import { FunctionComponent, ReactNode, useEffect, useState } from 'react'; import { useLocale, useTranslations } from 'next-intl'; import { useParams, useRouter } from 'next/navigation'; import { useQueryClient } from '@tanstack/react-query'; +import { FormProvider, useForm, useWatch } from 'react-hook-form'; import { useSnackbar } from 'notistack'; import { Box, @@ -23,7 +24,16 @@ import { useMediaQuery, } from '@mui/material'; import { useTheme } from '@mui/material/styles'; -import { AppButton, AppIcon, ConfirmDialog, EmptyState, PatientHeader, VisitNoteCard } from '@/components'; +import { + AppButton, + AppIcon, + ConfirmDialog, + EmptyState, + PatientHeader, + RhfControlGroup, + RhfTextField, + VisitNoteCard, +} from '@/components'; import { ROUTES, bookingDetailPath } from '@/constants'; import { formatShamsiDate, formatShamsiMonthYear } from '@/utils'; import { bookingKeys } from '@/services/bookings/keys'; @@ -259,15 +269,23 @@ function RecordItemSheet({ ); } +/** + * Client-side id for a row that has never been saved. Module scope on purpose: `Date.now()` is impure + * and the lint rule can't tell that a submit callback only ever runs from an event, so keeping the + * call out of the component body states the same thing structurally. + */ +function newTempId(): string { + return `new-${Date.now()}`; +} + +/** Save submits the enclosing `
`, so each sheet body owns its submit handler rather than a callback. */ function SheetActions({ onCancel, - onSave, onDelete, saving, canSave, }: { onCancel: () => void; - onSave: () => void; onDelete?: () => void; saving: boolean; canSave: boolean; @@ -285,7 +303,7 @@ function SheetActions({ {tc('cancel')} - + {saving ? tc('saving') : tc('save')} @@ -432,6 +450,16 @@ function MedicationsTab({ ); } +interface MedicationFormValues { + name: string; + doseAmount: string; + doseUnit: DoseUnit | ''; + frequencyCode: FrequencyPreset | null; + frequencyText: string; + timeOfDay: TimeOfDayCode[]; + timingNote: string; +} + function MedicationSheetBody({ initial, saving, @@ -448,107 +476,99 @@ function MedicationSheetBody({ onDirtyChange: (dirty: boolean) => void; }) { const t = useTranslations('records'); - const [name, setName] = useState(initial?.name ?? ''); - const [doseAmount, setDoseAmount] = useState(initial?.doseAmount ?? ''); - const [doseUnit, setDoseUnit] = useState(initial?.doseUnit ?? ''); - const [frequencyCode, setFrequencyCode] = useState(initial?.frequencyCode ?? null); - const [frequencyText, setFrequencyText] = useState(initial?.frequencyText ?? ''); - const [timeOfDay, setTimeOfDay] = useState(initial?.timeOfDay ?? []); - const [timingNote, setTimingNote] = useState(initial?.timingNote ?? ''); + const form = useForm({ + mode: 'onTouched', + defaultValues: { + name: initial?.name ?? '', + doseAmount: initial?.doseAmount ?? '', + doseUnit: initial?.doseUnit ?? '', + frequencyCode: initial?.frequencyCode ?? null, + frequencyText: initial?.frequencyText ?? '', + timeOfDay: initial?.timeOfDay ?? [], + timingNote: initial?.timingNote ?? '', + }, + }); + const { control, formState, handleSubmit, setValue } = form; + const { isDirty } = formState; + const name = useWatch({ control, name: 'name' }); + const frequencyCode = useWatch({ control, name: 'frequencyCode' }); useEffect(() => { - const dirty = - name !== (initial?.name ?? '') || - doseAmount !== (initial?.doseAmount ?? '') || - doseUnit !== (initial?.doseUnit ?? '') || - frequencyCode !== (initial?.frequencyCode ?? null) || - frequencyText !== (initial?.frequencyText ?? '') || - timingNote !== (initial?.timingNote ?? '') || - timeOfDay.length !== (initial?.timeOfDay ?? []).length || - timeOfDay.some((code) => !(initial?.timeOfDay ?? []).includes(code)); - onDirtyChange(dirty); - // eslint-disable-next-line react-hooks/exhaustive-deps -- `initial` is a fresh-mount snapshot (the sheet remounts per open), not reactive state. - }, [name, doseAmount, doseUnit, frequencyCode, frequencyText, timeOfDay, timingNote]); + onDirtyChange(isDirty); + }, [isDirty, onDirtyChange]); - const canSave = name.trim().length > 0; - - const handleSave = () => { + const submit = (values: MedicationFormValues) => { onSave({ - id: initial?.id ?? `new-${Date.now()}`, - name: name.trim(), - doseAmount: doseAmount.trim() || null, - doseUnit: doseUnit || null, - frequencyCode, - frequencyText: frequencyCode ? null : frequencyText.trim() || null, - timeOfDay, - timingNote: timingNote.trim() || null, + id: initial?.id ?? newTempId(), + name: values.name.trim(), + doseAmount: values.doseAmount.trim() || null, + doseUnit: values.doseUnit || null, + frequencyCode: values.frequencyCode, + frequencyText: values.frequencyCode ? null : values.frequencyText.trim() || null, + timeOfDay: values.timeOfDay, + timingNote: values.timingNote.trim() || null, }); }; return ( - - setName(e.target.value)} fullWidth required autoFocus /> + + + name="name" label={t('med_name')} fullWidth required autoFocus /> - - setDoseAmount(e.target.value)} - sx={{ flex: 1 }} - /> - setDoseUnit(e.target.value as DoseUnit)} sx={{ flex: 1 }}> - {t('med_dose_unit_none')} - {DOSE_UNITS.map((unit) => ( - - {t(`dose_unit_${unit}`)} - - ))} - - - - - - {t('med_frequency')} - - - {FREQUENCY_PRESETS.map((preset) => ( - { - setFrequencyCode(frequencyCode === preset ? null : preset); - if (frequencyCode !== preset) setFrequencyText(''); - }} - sx={{ borderRadius: '999px' }} - > - {t(`frequency_${preset}`)} - - ))} + + name="doseAmount" label={t('med_dose_amount')} sx={{ flex: 1 }} /> + name="doseUnit" select label={t('med_dose_unit')} sx={{ flex: 1 }}> + {t('med_dose_unit_none')} + {DOSE_UNITS.map((unit) => ( + + {t(`dose_unit_${unit}`)} + + ))} + - {!frequencyCode ? ( - setFrequencyText(e.target.value)} - fullWidth - size="small" - /> - ) : null} + + + + {t('med_frequency')} + + + {FREQUENCY_PRESETS.map((preset) => ( + { + const next = frequencyCode === preset ? null : preset; + setValue('frequencyCode', next, { shouldDirty: true }); + // A preset and the free-text alternative are mutually exclusive by design. + if (next) setValue('frequencyText', '', { shouldDirty: true }); + }} + sx={{ borderRadius: '999px' }} + > + {t(`frequency_${preset}`)} + + ))} + + {!frequencyCode ? ( + + name="frequencyText" + label={t('med_frequency_text')} + fullWidth + size="small" + /> + ) : null} + + + name="timeOfDay" hint={t('time_of_day')}> + {({ field }) => } + + + name="timingNote" label={t('med_timing')} fullWidth size="small" /> + + 0} /> - - - - {t('time_of_day')} - - - - - setTimingNote(e.target.value)} fullWidth size="small" /> - - - + ); } @@ -652,42 +672,45 @@ function RoutineSheetBody({ onDirtyChange: (dirty: boolean) => void; }) { const t = useTranslations('records'); - const [label, setLabel] = useState(initial?.label ?? ''); - const [timeOfDay, setTimeOfDay] = useState(initial?.timeOfDay ?? []); - const [note, setNote] = useState(initial?.note ?? ''); + const form = useForm<{ label: string; timeOfDay: TimeOfDayCode[]; note: string }>({ + mode: 'onTouched', + defaultValues: { + label: initial?.label ?? '', + timeOfDay: initial?.timeOfDay ?? [], + note: initial?.note ?? '', + }, + }); + const { control, formState, handleSubmit } = form; + const { isDirty } = formState; + const label = useWatch({ control, name: 'label' }); useEffect(() => { - const dirty = - label !== (initial?.label ?? '') || - note !== (initial?.note ?? '') || - timeOfDay.length !== (initial?.timeOfDay ?? []).length || - timeOfDay.some((code) => !(initial?.timeOfDay ?? []).includes(code)); - onDirtyChange(dirty); - // eslint-disable-next-line react-hooks/exhaustive-deps -- `initial` is a fresh-mount snapshot (the sheet remounts per open), not reactive state. - }, [label, note, timeOfDay]); - - const canSave = label.trim().length > 0; - - const handleSave = () => - onSave({ - id: initial?.id ?? `new-${Date.now()}`, - label: label.trim(), - timeOfDay, - note: note.trim() || null, - }); + onDirtyChange(isDirty); + }, [isDirty, onDirtyChange]); return ( - - setLabel(e.target.value)} fullWidth required autoFocus /> - - - {t('time_of_day')} - - + + + onSave({ + id: initial?.id ?? newTempId(), + label: values.label.trim(), + timeOfDay: values.timeOfDay, + note: values.note.trim() || null, + }), + )} + sx={{ gap: 2 }} + > + + + {({ field }) => } + + + 0} /> - setNote(e.target.value)} fullWidth size="small" /> - - + ); } @@ -788,28 +811,42 @@ function TaskSheetBody({ onDirtyChange: (dirty: boolean) => void; }) { const t = useTranslations('records'); - const [label, setLabel] = useState(initial?.label ?? ''); - const [done, setDone] = useState(initial?.done ?? false); + const form = useForm<{ label: string; done: boolean }>({ + mode: 'onTouched', + defaultValues: { label: initial?.label ?? '', done: initial?.done ?? false }, + }); + const { control, formState, handleSubmit } = form; + const { isDirty } = formState; + const label = useWatch({ control, name: 'label' }); useEffect(() => { - onDirtyChange(label !== (initial?.label ?? '') || done !== (initial?.done ?? false)); - // eslint-disable-next-line react-hooks/exhaustive-deps -- `initial` is a fresh-mount snapshot (the sheet remounts per open), not reactive state. - }, [label, done]); - - const canSave = label.trim().length > 0; + onDirtyChange(isDirty); + }, [isDirty, onDirtyChange]); return ( - - setLabel(e.target.value)} fullWidth required autoFocus /> - setDone(e.target.checked)} />} label={t('task_done')} /> - onSave({ id: initial?.id ?? `new-${Date.now()}`, label: label.trim(), done })} - onDelete={onDelete} - saving={saving} - canSave={canSave} - /> - + + + onSave({ id: initial?.id ?? newTempId(), label: values.label.trim(), done: values.done }), + )} + sx={{ gap: 2 }} + > + + + {({ field }) => ( + field.onChange(event.target.checked)} /> + } + label={t('task_done')} + /> + )} + + 0} /> + + ); } diff --git a/client/src/app/[locale]/(private-routes)/(customer)/profile/page.tsx b/client/src/app/[locale]/(private-routes)/(customer)/profile/page.tsx index 8cc3da4..b2cea47 100644 --- a/client/src/app/[locale]/(private-routes)/(customer)/profile/page.tsx +++ b/client/src/app/[locale]/(private-routes)/(customer)/profile/page.tsx @@ -2,8 +2,9 @@ import { FunctionComponent, useState } from 'react'; import { useLocale, useTranslations } from 'next-intl'; import { useRouter } from 'next/navigation'; +import { FormProvider, useForm, useWatch } from 'react-hook-form'; import { useSnackbar } from 'notistack'; -import { Box, Divider, MenuItem, Skeleton, Stack, TextField, Typography } from '@mui/material'; +import { Box, Divider, MenuItem, Skeleton, Stack, Typography } from '@mui/material'; import { AppButton, AppIcon, @@ -12,6 +13,8 @@ import { FormDialogShell, PhoneNumberField, ProfileSummary, + RhfControlGroup, + RhfTextField, } from '@/components'; import LocaleSwitcher from '@/components/common/LocaleSwitcher'; import { ThemeModeSetting } from '@/components/settings'; @@ -44,6 +47,14 @@ export default function CustomerProfilePage() { ); } +interface AccountFormValues { + firstName: string; + lastName: string; + language: string; + emergencyName: string; + emergencyPhone: string; +} + const AccountHub: FunctionComponent<{ initial: CustomerProfile | null; nameFallback: { firstName: string | null; lastName: string | null }; @@ -57,69 +68,63 @@ const AccountHub: FunctionComponent<{ const upsert = useUpsertCustomerProfile(); const logout = useLogout(); - const initialFirstName = initial?.firstName ?? nameFallback.firstName ?? ''; - const initialLastName = initial?.lastName ?? nameFallback.lastName ?? ''; - const initialLanguage = initial?.preferredLanguage ?? 'fa'; - const initialEmergencyName = initial?.defaultEmergencyContactName ?? ''; - const initialEmergencyPhone = digitsOnly(initial?.defaultEmergencyContactPhone ?? ''); - - const [firstName, setFirstName] = useState(initialFirstName); - const [lastName, setLastName] = useState(initialLastName); - const [language, setLanguage] = useState(initialLanguage); - const [emergencyName, setEmergencyName] = useState(initialEmergencyName); - const [emergencyPhone, setEmergencyPhone] = useState(initialEmergencyPhone); - const [personalSheetOpen, setPersonalSheetOpen] = useState(false); const [languageSheetOpen, setLanguageSheetOpen] = useState(false); const [emergencySheetOpen, setEmergencySheetOpen] = useState(false); const [signOutOpen, setSignOutOpen] = useState(false); - const [nameError, setNameError] = useState(false); - const [phoneError, setPhoneError] = useState(false); + // ONE form behind all three sheets. Each sheet edits its own slice, but every save writes the whole + // profile (the wire upsert has no PATCH semantics), so the untouched fields have to come from + // somewhere — a single form is that somewhere, and `dirtyFields` then answers per-sheet "is there + // unsaved work here?" without a hand-written comparison per section. + const form = useForm({ + mode: 'onTouched', + defaultValues: { + firstName: initial?.firstName ?? nameFallback.firstName ?? '', + lastName: initial?.lastName ?? nameFallback.lastName ?? '', + language: initial?.preferredLanguage ?? 'fa', + emergencyName: initial?.defaultEmergencyContactName ?? '', + emergencyPhone: digitsOnly(initial?.defaultEmergencyContactPhone ?? ''), + }, + }); + const { control, formState, getValues, reset, trigger } = form; + const { dirtyFields } = formState; + const watched = useWatch({ control }); - const displayName = [firstName, lastName].filter(Boolean).join(' ').trim() || phone || ''; - const emergencyComplete = Boolean(emergencyName.trim() && emergencyPhone); + const displayName = [watched.firstName, watched.lastName].filter(Boolean).join(' ').trim() || phone || ''; + const emergencyComplete = Boolean(watched.emergencyName?.trim() && watched.emergencyPhone); - // Every sheet saves the FULL profile object (the wire upsert has no PATCH semantics) — `patch` - // carries just the fields that sheet owns, the rest come from the shared draft state so editing - // one section never blanks another (the bug the old flat form was one refactor away from). - const save = ( - patch: Partial>, - onDone: () => void, - ) => { + const save = (onDone: () => void) => { + const values = getValues(); upsert.mutate( { - defaultEmergencyContactName: emergencyName.trim(), - defaultEmergencyContactPhone: emergencyPhone, - firstName: firstName.trim() || null, - lastName: lastName.trim() || null, - preferredLanguage: language, - ...patch, + defaultEmergencyContactName: values.emergencyName.trim(), + defaultEmergencyContactPhone: values.emergencyPhone, + firstName: values.firstName.trim() || null, + lastName: values.lastName.trim() || null, + preferredLanguage: values.language, }, { onSuccess: () => { enqueueSnackbar(t('saved'), { variant: 'success' }); + // Re-baseline so the saved slice stops counting as unsaved work in its sheet's discard guard. + reset(getValues()); onDone(); }, }, ); }; - const savePersonal = () => - save({ firstName: firstName.trim() || null, lastName: lastName.trim() || null }, () => setPersonalSheetOpen(false)); - const saveLanguage = () => save({ preferredLanguage: language }, () => setLanguageSheetOpen(false)); - const saveEmergency = () => { - const nameInvalid = emergencyName.trim().length === 0; - const phoneInvalid = !isIranianMobile(emergencyPhone); - setNameError(nameInvalid); - setPhoneError(phoneInvalid); - if (nameInvalid || phoneInvalid) return; - save({}, () => setEmergencySheetOpen(false)); + const savePersonal = () => save(() => setPersonalSheetOpen(false)); + const saveLanguage = () => save(() => setLanguageSheetOpen(false)); + const saveEmergency = async () => { + if (!(await trigger(['emergencyName', 'emergencyPhone']))) return; + save(() => setEmergencySheetOpen(false)); }; - const personalDirty = firstName !== initialFirstName || lastName !== initialLastName; - const languageDirty = language !== initialLanguage; - const emergencyDirty = emergencyName !== initialEmergencyName || emergencyPhone !== initialEmergencyPhone; + const personalDirty = Boolean(dirtyFields.firstName || dirtyFields.lastName); + const languageDirty = Boolean(dirtyFields.language); + const emergencyDirty = Boolean(dirtyFields.emergencyName || dirtyFields.emergencyPhone); const goTo = (path: string) => router.push(`/${locale}${path}`); @@ -131,6 +136,7 @@ const AccountHub: FunctionComponent<{ const cancelLabel = tc('cancel'); return ( + @@ -138,8 +144,8 @@ const AccountHub: FunctionComponent<{ setPersonalSheetOpen(true)} /> setEmergencySheetOpen(true)} /> goTo(ROUTES.ADDRESSES)} /> @@ -172,8 +178,8 @@ const AccountHub: FunctionComponent<{ discardCancelLabel={cancelLabel} > - setFirstName(e.target.value)} fullWidth /> - setLastName(e.target.value)} fullWidth /> + name="firstName" label={t('first_name')} fullWidth /> + name="lastName" label={t('last_name')} fullWidth /> setPersonalSheetOpen(false)} onSave={savePersonal} saving={upsert.isPending} saveLabel={tc('save')} cancelLabel={cancelLabel} /> @@ -201,16 +207,10 @@ const AccountHub: FunctionComponent<{ - setLanguage(e.target.value)} - helperText={t('language_hint')} - > + name="language" select label={t('language')} helperText={t('language_hint')}> {t('language_fa')} {t('language_en')} - + setLanguageSheetOpen(false)} onSave={saveLanguage} saving={upsert.isPending} saveLabel={tc('save')} cancelLabel={cancelLabel} /> @@ -231,27 +231,27 @@ const AccountHub: FunctionComponent<{ {t('emergency_hint')} - + name="emergencyName" label={t('emergency_name')} - value={emergencyName} - onChange={(e) => { - setEmergencyName(e.target.value); - if (nameError) setNameError(false); - }} - error={nameError} - fullWidth - /> - { - setEmergencyPhone(v); - if (phoneError) setPhoneError(false); - }} - error={phoneError} - helperText={phoneError ? t('emergency_phone_invalid') : undefined} + rules={{ validate: (value) => String(value ?? '').trim().length > 0 }} fullWidth /> + + name="emergencyPhone" + rules={{ validate: (value) => isIranianMobile(String(value ?? '')) }} + > + {({ field, hasError }) => ( + + )} + setEmergencySheetOpen(false)} onSave={saveEmergency} saving={upsert.isPending} saveLabel={tc('save')} cancelLabel={cancelLabel} /> @@ -270,6 +270,7 @@ const AccountHub: FunctionComponent<{ }} /> + ); }; diff --git a/client/src/app/[locale]/(private-routes)/admin/holidays/page.tsx b/client/src/app/[locale]/(private-routes)/admin/holidays/page.tsx index d4a4f1c..f7c31d4 100644 --- a/client/src/app/[locale]/(private-routes)/admin/holidays/page.tsx +++ b/client/src/app/[locale]/(private-routes)/admin/holidays/page.tsx @@ -1,6 +1,7 @@ 'use client'; import { Suspense, useState } from 'react'; import { useLocale, useTranslations } from 'next-intl'; +import { FormProvider, useForm } from 'react-hook-form'; import { useSnackbar } from 'notistack'; import { Box, @@ -14,9 +15,8 @@ import { Skeleton, Stack, Switch, - TextField, } from '@mui/material'; -import { AppButton, AppLoading, JalaliDateField } from '@/components'; +import { AppButton, AppLoading, RhfControlGroup, RhfJalaliDateField, RhfTextField } from '@/components'; import { AdminDataTable, AdminEmptyState, AdminErrorState, AdminPageHeader, AdminPager, type AdminTableColumn } from '@/components/admin'; import { formatShamsiDate } from '@/utils'; import { useAdminCapabilities, useAdminListState } from '@/hooks'; @@ -138,19 +138,20 @@ function HolidayDialog({ holiday, onClose }: { holiday: Holiday | null; onClose: const t = useTranslations('admin'); const { enqueueSnackbar } = useSnackbar(); const upsert = useUpsertHoliday(); - const [form, setForm] = useState(() => ({ - holidayDate: holiday?.holidayDate?.slice(0, 10) ?? todayLocalIso(), - nameFa: holiday?.nameFa ?? '', - type: holiday?.type ?? 'official', - isBankClosed: holiday?.isBankClosed ?? true, - })); + const form = useForm({ + mode: 'onTouched', + defaultValues: { + holidayDate: holiday?.holidayDate?.slice(0, 10) ?? todayLocalIso(), + nameFa: holiday?.nameFa ?? '', + type: holiday?.type ?? 'official', + isBankClosed: holiday?.isBankClosed ?? true, + }, + }); + const { handleSubmit, formState } = form; - const valid = form.holidayDate.length > 0 && form.nameFa.trim().length > 0; - - const onSave = () => { - if (!valid) return; + const onSave = (values: HolidayInput) => upsert.mutate( - { ...form, nameFa: form.nameFa.trim() }, + { ...values, nameFa: values.nameFa.trim() }, { onSuccess: () => { enqueueSnackbar(t('hol_saved'), { variant: 'success' }); @@ -158,50 +159,59 @@ function HolidayDialog({ holiday, onClose }: { holiday: Holiday | null; onClose: }, }, ); - }; return ( {holiday ? t('hol_edit') : t('hol_add')} - - - setForm((f) => ({ ...f, holidayDate: iso }))} - disabled={!!holiday} - /> - setForm((f) => ({ ...f, nameFa: e.target.value }))} - /> - setForm((f) => ({ ...f, type: e.target.value as HolidayType }))} + + + {/* A real so Enter submits from any field; the footer button (outside DialogContent) + calls the same handler directly rather than relying on cross-element form association. */} + + + name="holidayDate" + label={t('hol_col_date')} + rules={{ validate: (value) => String(value ?? '').length > 0 }} + disabled={!!holiday} + /> + + name="nameFa" + label={t('hol_name_fa')} + rules={{ validate: (value) => String(value ?? '').trim().length > 0 }} + /> + name="type" select label={t('hol_col_type')}> + {HOLIDAY_TYPES.map((ty) => ( + + {t(`htype_${ty}`)} + + ))} + + name="isBankClosed"> + {({ field }) => ( + field.onChange(event.target.checked)} /> + } + label={t('hol_bank_hint')} + /> + )} + + + + + + {t('cancel')} + + - {HOLIDAY_TYPES.map((ty) => ( - - {t(`htype_${ty}`)} - - ))} - - setForm((f) => ({ ...f, isBankClosed: e.target.checked }))} />} - label={t('hol_bank_hint')} - /> - - - - - {t('cancel')} - - - {upsert.isPending ? t('saving') : t('save')} - - + {upsert.isPending ? t('saving') : t('save')} + + + ); } diff --git a/client/src/app/[locale]/(private-routes)/admin/partners/page.tsx b/client/src/app/[locale]/(private-routes)/admin/partners/page.tsx index fb20a96..3a3b3ce 100644 --- a/client/src/app/[locale]/(private-routes)/admin/partners/page.tsx +++ b/client/src/app/[locale]/(private-routes)/admin/partners/page.tsx @@ -2,6 +2,7 @@ import { Suspense, useState } from 'react'; import { useLocale, useTranslations } from 'next-intl'; import { useRouter } from 'next/navigation'; +import { FormProvider, useForm, useWatch } from 'react-hook-form'; import { useSnackbar } from 'notistack'; import { Box, @@ -13,10 +14,9 @@ import { Skeleton, Stack, Switch, - TextField, Typography, } from '@mui/material'; -import { AppButton, AppLoading, StatusChip } from '@/components'; +import { AppButton, AppLoading, RhfControlGroup, RhfTextField, StatusChip } from '@/components'; import type { StatusKind } from '@/components'; import { AdminDataTable, @@ -183,37 +183,30 @@ export function PartnerCenterFormDialog({ center, onClose }: { center: PartnerCe const create = useCreatePartnerCenter(); const update = useUpdatePartnerCenter(center?.id ?? 0); const mutation = isEdit ? update : create; - const [form, setForm] = useState(() => initialForm(center)); + + const form = useForm({ mode: 'onTouched', defaultValues: initialForm(center) }); + const { control, handleSubmit, formState } = form; + const isMerchantOfRecord = useWatch({ control, name: 'isMerchantOfRecord' }); + const adminUser = useWatch({ control, name: 'adminUser' }); // Edit mode: the center already has an adminUserId (a plain number) — resolve it to a name so the picker - // opens pre-filled with a person, never a bare id (3.2). Derived in render (never synced into state via an - // effect): once the admin actually picks someone, `form.adminUser` wins over the resolved existing one. + // opens pre-filled with a person, never a bare id (3.2). Derived in render (never synced into form state): + // once the admin actually picks someone, the form value wins over the resolved existing one. const existingAdminId = center?.adminUserId ?? null; const existingAdminLookup = useUserLookup(existingAdminId != null ? [existingAdminId] : []); const resolvedExistingAdmin = existingAdminId != null ? (existingAdminLookup.data?.get(existingAdminId) ?? null) : null; - const displayedAdminUser = form.adminUser !== undefined ? form.adminUser : resolvedExistingAdmin; + const displayedAdminUser = adminUser !== undefined ? adminUser : resolvedExistingAdmin; - const set = (key: K, value: CenterFormState[K]) => - setForm((f) => ({ ...f, [key]: value })); - - const commission = Number(form.commissionRate); - const commissionValid = form.commissionRate.trim() !== '' && Number.isFinite(commission) && commission >= 0 && commission < 1; - // On edit a blank IBAN is allowed (it keeps the stored value); on create an MoR center must supply one. - const ibanValid = !form.isMerchantOfRecord || isEdit || form.settlementIban.trim() !== ''; - const valid = - form.name.trim() !== '' && form.mohEstablishmentPermitNo.trim() !== '' && commissionValid && ibanValid; - - const onSave = () => { - if (!valid) return; + const onSave = (values: CenterFormState) => { const input: PartnerCenterInput = { - name: form.name.trim(), - legalEntityType: form.legalEntityType.trim(), - mohEstablishmentPermitNo: form.mohEstablishmentPermitNo.trim(), - technicalDirectorLicenseNo: form.technicalDirectorLicenseNo.trim() || null, - enamadCode: form.enamadCode.trim() || null, - settlementIban: form.settlementIban.trim() || null, - isMerchantOfRecord: form.isMerchantOfRecord, - commissionRate: commission, + name: values.name.trim(), + legalEntityType: values.legalEntityType.trim(), + mohEstablishmentPermitNo: values.mohEstablishmentPermitNo.trim(), + technicalDirectorLicenseNo: values.technicalDirectorLicenseNo.trim() || null, + enamadCode: values.enamadCode.trim() || null, + settlementIban: values.settlementIban.trim() || null, + isMerchantOfRecord: values.isMerchantOfRecord, + commissionRate: Number(values.commissionRate), adminUserId: displayedAdminUser?.id ?? null, }; mutation.mutate(input, { @@ -227,67 +220,95 @@ export function PartnerCenterFormDialog({ center, onClose }: { center: PartnerCe return ( {isEdit ? t('partner_edit') : t('partner_create')} - - - set('name', e.target.value)} /> - set('legalEntityType', e.target.value)} - /> - set('mohEstablishmentPermitNo', e.target.value)} - /> - set('technicalDirectorLicenseNo', e.target.value)} - /> - set('enamadCode', e.target.value)} /> - set('settlementIban', e.target.value)} - helperText={t('partner_iban_write_hint')} - placeholder={center?.settlementIbanMasked ?? undefined} - slotProps={{ htmlInput: { dir: 'ltr' } }} - /> - - set('isMerchantOfRecord', e.target.checked)} />} - label={t('partner_is_mor')} + + + {/* A real so Enter submits from any field; the footer button (outside DialogContent) + calls the same handler directly rather than relying on cross-element form association. */} + + + name="name" + label={t('partner_name')} + rules={{ validate: (value) => String(value ?? '').trim() !== '' }} /> - - {t('partner_is_mor_hint')} - - - set('commissionRate', e.target.value)} - slotProps={{ htmlInput: { min: 0, max: 0.999, step: 0.01 } }} - /> - set('adminUser', u)} - label={t('partner_admin_user')} - placeholder={t('user_picker_search_ph')} - noOptionsText={t('user_picker_no_options')} - loadingText={t('user_picker_loading')} - /> - - - - - {t('cancel')} - - - {mutation.isPending ? t('saving') : t('save')} - - + name="legalEntityType" label={t('partner_legal_type')} /> + + name="mohEstablishmentPermitNo" + label={t('partner_permit')} + rules={{ validate: (value) => String(value ?? '').trim() !== '' }} + /> + + name="technicalDirectorLicenseNo" + label={t('partner_tech_director_license')} + /> + name="enamadCode" label={t('partner_enamad')} /> + + name="settlementIban" + label={t('partner_iban')} + helperText={t('partner_iban_write_hint')} + placeholder={center?.settlementIbanMasked ?? undefined} + // On edit a blank IBAN keeps the stored value; on create an MoR center must supply one. + rules={{ validate: (value) => !isMerchantOfRecord || isEdit || String(value ?? '').trim() !== '' }} + slotProps={{ htmlInput: { dir: 'ltr' } }} + /> + + name="isMerchantOfRecord"> + {({ field }) => ( + field.onChange(event.target.checked)} + /> + } + label={t('partner_is_mor')} + /> + )} + + + {t('partner_is_mor_hint')} + + + + name="commissionRate" + type="number" + label={t('partner_commission')} + rules={{ + validate: (value) => { + const raw = String(value ?? '').trim(); + const rate = Number(raw); + return raw !== '' && Number.isFinite(rate) && rate >= 0 && rate < 1; + }, + }} + slotProps={{ htmlInput: { min: 0, max: 0.999, step: 0.01 } }} + /> + name="adminUser"> + {({ field }) => ( + + )} + + + + + + {t('cancel')} + + + {mutation.isPending ? t('saving') : t('save')} + + + ); } diff --git a/client/src/app/[locale]/(private-routes)/admin/verification/[nurseId]/page.tsx b/client/src/app/[locale]/(private-routes)/admin/verification/[nurseId]/page.tsx index 85af539..b01541b 100644 --- a/client/src/app/[locale]/(private-routes)/admin/verification/[nurseId]/page.tsx +++ b/client/src/app/[locale]/(private-routes)/admin/verification/[nurseId]/page.tsx @@ -2,6 +2,7 @@ import { Suspense, useCallback, useEffect, useMemo, useState } from 'react'; import { useParams, useRouter, useSearchParams } from 'next/navigation'; import { useLocale, useTranslations } from 'next-intl'; +import { FormProvider, useForm } from 'react-hook-form'; import { useSnackbar } from 'notistack'; import { Box, @@ -12,10 +13,9 @@ import { DialogTitle, Skeleton, Stack, - TextField, Typography, } from '@mui/material'; -import { AppButton, AppLoading, JalaliDateField, PageHeader, StatusChip } from '@/components'; +import { AppButton, AppLoading, PageHeader, RhfJalaliDateField, RhfTextField, StatusChip } from '@/components'; import type { StatusKind } from '@/components'; import { AdminEmptyState, AdminErrorState, ConfirmDialog, DocumentViewer } from '@/components/admin'; import { ROUTES, adminVerificationCasePath } from '@/constants'; @@ -398,6 +398,14 @@ function StepCard({ /** The structured credential form recorded on approving a credential-bearing step. `criminal_record` * requires an expiry date; `credentialNumber` is accepted as input and never echoed back. */ +interface CredentialDecisionValues { + credentialNumber: string; + holderName: string; + issuingAuthority: string; + issuedAt: string; + expiresAt: string; +} + function CredentialDialog({ step, nurseVerificationId, @@ -410,28 +418,26 @@ function CredentialDialog({ const t = useTranslations('admin'); const { enqueueSnackbar } = useSnackbar(); const decide = useDecideStep(); - const [credentialNumber, setCredentialNumber] = useState(''); - const [holderName, setHolderName] = useState(''); - const [issuingAuthority, setIssuingAuthority] = useState(''); - const [issuedAt, setIssuedAt] = useState(''); - const [expiresAt, setExpiresAt] = useState(''); const expiryRequired = step.code === 'criminal_record'; - const expiryMissing = expiryRequired && expiresAt.trim().length === 0; + const form = useForm({ + mode: 'onTouched', + defaultValues: { credentialNumber: '', holderName: '', issuingAuthority: '', issuedAt: '', expiresAt: '' }, + }); + const { handleSubmit, formState } = form; - const onSubmit = () => { - if (expiryMissing) return; + const onSubmit = (values: CredentialDecisionValues) => decide.mutate( { stepId: step.id, nurseVerificationId, input: { approve: true, - credentialNumber: credentialNumber.trim() || undefined, - holderName: holderName.trim() || undefined, - issuingAuthority: issuingAuthority.trim() || undefined, - issuedAt: issuedAt || undefined, - expiresAt: expiresAt || undefined, + credentialNumber: values.credentialNumber.trim() || undefined, + holderName: values.holderName.trim() || undefined, + issuingAuthority: values.issuingAuthority.trim() || undefined, + issuedAt: values.issuedAt || undefined, + expiresAt: values.expiresAt || undefined, }, }, { @@ -441,58 +447,58 @@ function CredentialDialog({ }, }, ); - }; return ( {t('ver_credential_title')} - - - setCredentialNumber(e.target.value)} - /> - setHolderName(e.target.value)} - /> - setIssuingAuthority(e.target.value)} - /> - - - - - - - {t('cancel')} - - - {decide.isPending ? t('saving') : t('save')} - - + + + {/* A real so Enter submits from any field; the footer button (outside DialogContent) + calls the same handler directly rather than relying on cross-element form association. */} + + + name="credentialNumber" + fullWidth + autoFocus + label={t('ver_credential_number')} + /> + + name="holderName" + fullWidth + label={t('ver_holder_name')} + helperText={t('ver_holder_hint')} + /> + + name="issuingAuthority" + fullWidth + label={t('ver_issuing_authority')} + /> + name="issuedAt" fullWidth label={t('ver_issued_at')} /> + + name="expiresAt" + fullWidth + label={t('ver_expires_at')} + required={expiryRequired} + rules={{ + validate: (value) => !expiryRequired || String(value ?? '').trim().length > 0 || t('ver_expiry_required'), + }} + /> + + + + + {t('cancel')} + + + {decide.isPending ? t('saving') : t('save')} + + + ); } diff --git a/client/src/app/[locale]/(private-routes)/nurse/NurseDashboardScreen.tsx b/client/src/app/[locale]/(private-routes)/nurse/NurseDashboardScreen.tsx index 6c9b9b9..47e6012 100644 --- a/client/src/app/[locale]/(private-routes)/nurse/NurseDashboardScreen.tsx +++ b/client/src/app/[locale]/(private-routes)/nurse/NurseDashboardScreen.tsx @@ -9,19 +9,15 @@ import { CountdownTimer, EmptyState, ErrorState, - InitialsAvatar, Money, + PageHeader, SurfaceCard, - TrustBadge, } from '@/components'; import { ROUTES } from '@/constants'; import { formatRelativeTime, formatShamsiDate, localeTag, parseIrr } from '@/utils'; -import { useMe } from '@/services/auth'; import { useNurseRequestInbox } from '@/services/bookingRequests'; import { useTodaySessions } from '@/services/bookings'; import { useNurseEarningsBalance } from '@/services/payouts'; -import { useVerificationStatus } from '@/services/verification'; -import { ownBadgeState } from '@/services/verification/types'; import { coarseResponseLabel } from '@/services/bookingRequests/format'; import DashboardActivationSlot from './DashboardActivationSlot'; @@ -35,17 +31,25 @@ const WARN_THRESHOLD_SECONDS = 2 * 60 * 60; * Rebuilt for the phone-width frame. The previous version stacked five same-weight cards, each * repeating its own icon + bold heading + inline "see all" button; at 480px the buttons wrapped * mid-word, the countdown collided with the request title, and nothing on the screen looked more - * important than anything else. This version gives the page one visual hierarchy: a quiet identity - * strip, then exactly one hero action (the next visit), then sections introduced by a plain label - * with a text link instead of a competing button. + * important than anything else. This version gives the page one visual hierarchy: exactly one hero + * action (the next visit), then sections introduced by a plain label with a text link instead of a + * competing button. + * + * The greeting/identity strip that used to sit above all of it is gone: it spent the most valuable + * row on the screen restating the signed-in name to the person who typed the phone number, and the + * badge beside it duplicated the activation tracker further down. Identity now lives in the shell's + * top bar (`NurseAccountButton`), where it costs no content height and opens the account hub. * * Composition only — every widget reads a query that is already cached elsewhere in the shell, and * order encodes urgency: a missed request expires, an unread earnings figure does not. */ export default function NurseDashboardScreen() { + const t = useTranslations('dashboard'); return ( - + {/* Load-bearing now that the bottom nav is icon-only: this is the only place the current + section is named, and the page's only h1. */} + @@ -74,40 +78,6 @@ function SectionHeader({ title, actionLabel, actionTo }: { title: string; action ); } -/** Greeting + own trust badge. The avatar is initials-based — a nurse photo lives on the profile. */ -function GreetingHeader() { - const t = useTranslations('dashboard'); - const { data: me, isLoading } = useMe(); - const verification = useVerificationStatus(); - - const displayName = me ? [me.firstName, me.lastName].filter(Boolean).join(' ').trim() || me.phone : ''; - - if (isLoading) { - return ( - - - - - ); - } - - return ( - - - - - {t('greeting', { name: displayName })} - - {verification.isLoading ? null : ( - - - - )} - - - ); -} - /** The page's one hero action: the next actionable session, with a full-width primary CTA. */ function NextVisitCard() { const t = useTranslations('dashboard'); diff --git a/client/src/app/[locale]/(private-routes)/nurse/bank/page.tsx b/client/src/app/[locale]/(private-routes)/nurse/bank/page.tsx index 497d164..e767135 100644 --- a/client/src/app/[locale]/(private-routes)/nurse/bank/page.tsx +++ b/client/src/app/[locale]/(private-routes)/nurse/bank/page.tsx @@ -1,13 +1,19 @@ 'use client'; import { useState } from 'react'; import { useTranslations } from 'next-intl'; +import { FormProvider, useForm } from 'react-hook-form'; import { useSnackbar } from 'notistack'; -import { Box, Stack, TextField, Typography } from '@mui/material'; -import { AppButton, AppLoading, BankStatusPanel, EmptyState, ErrorState } from '@/components'; +import { Box, Stack, Typography } from '@mui/material'; +import { AppButton, AppLoading, BankStatusPanel, EmptyState, ErrorState, RhfTextField } from '@/components'; import { useNurseBankAccounts, useAddNurseBankAccount, useSetPrimaryBankAccount } from '@/services/nurse'; import { isValidSheba } from '@/services/nurse/iban'; import { deriveBankStatus } from '@/services/nurse/types'; +interface BankFormValues { + iban: string; + holder: string; +} + /** * Nurse payout bank settings — an **accounts section**, not a one-shot form (ui-phase-8 §3.7): submit * an IBAN (شبا) + account-holder name, then watch the ownership inquiry resolve through its three @@ -26,28 +32,19 @@ export default function NurseBankPage() { const addAccount = useAddNurseBankAccount(); const setPrimary = useSetPrimaryBankAccount(); - const [iban, setIban] = useState(''); - const [holder, setHolder] = useState(''); - const [ibanError, setIbanError] = useState(false); - const [holderError, setHolderError] = useState(false); const [showForm, setShowForm] = useState(false); + const form = useForm({ mode: 'onTouched', defaultValues: { iban: '', holder: '' } }); + const { handleSubmit, reset } = form; const accounts = data ?? []; const showFormNow = !isLoading && !isError && (accounts.length === 0 || showForm); - const submit = () => { - const ibanInvalid = !isValidSheba(iban); - const holderInvalid = holder.trim().length === 0; - setIbanError(ibanInvalid); - setHolderError(holderInvalid); - if (ibanInvalid || holderInvalid) return; - + const submit = (values: BankFormValues) => { addAccount.mutate( - { iban, accountHolderName: holder.trim() }, + { iban: values.iban, accountHolderName: values.holder.trim() }, { onSuccess: () => { - setIban(''); - setHolder(''); + reset(); setShowForm(false); enqueueSnackbar(t('added'), { variant: 'success' }); }, @@ -131,51 +128,43 @@ export default function NurseBankPage() { ) : null} {showFormNow ? ( - - { - setIban(e.target.value.toUpperCase()); - if (ibanError) setIbanError(false); - }} - error={ibanError} - helperText={ibanError ? t('iban_invalid') : t('iban_hint')} - slotProps={{ htmlInput: { dir: 'ltr', style: { textAlign: 'start', letterSpacing: 1 } } }} - fullWidth - /> - { - setHolder(e.target.value); - if (holderError) setHolderError(false); - }} - error={holderError} - helperText={holderError ? t('holder_required') : t('holder_hint')} - fullWidth - /> - - - {addAccount.isPending ? t('submitting') : t('submit')} - - {accounts.length > 0 ? ( - { - setShowForm(false); - setIban(''); - setHolder(''); - setIbanError(false); - setHolderError(false); - }} - disabled={addAccount.isPending} - > - {tc('cancel')} + + + + name="iban" + label={t('iban_label')} + helperText={t('iban_hint')} + transform={(raw) => raw.toUpperCase()} + rules={{ validate: (value) => isValidSheba(String(value ?? '')) || t('iban_invalid') }} + slotProps={{ htmlInput: { dir: 'ltr', style: { textAlign: 'start', letterSpacing: 1 } } }} + fullWidth + /> + + name="holder" + label={t('holder_label')} + helperText={t('holder_hint')} + rules={{ validate: (value) => String(value ?? '').trim().length > 0 || t('holder_required') }} + fullWidth + /> + + + {addAccount.isPending ? t('submitting') : t('submit')} - ) : null} + {accounts.length > 0 ? ( + { + setShowForm(false); + reset(); + }} + disabled={addAccount.isPending} + > + {tc('cancel')} + + ) : null} + - + ) : null} ); diff --git a/client/src/app/[locale]/(private-routes)/nurse/profile/page.tsx b/client/src/app/[locale]/(private-routes)/nurse/profile/page.tsx index 2609a05..d6cedad 100644 --- a/client/src/app/[locale]/(private-routes)/nurse/profile/page.tsx +++ b/client/src/app/[locale]/(private-routes)/nurse/profile/page.tsx @@ -1,20 +1,45 @@ 'use client'; -import { ChangeEvent, FunctionComponent, useEffect, useRef, useState } from 'react'; +import { ChangeEvent, FunctionComponent, useEffect, useRef } from 'react'; import { useLocale, useTranslations } from 'next-intl'; +import { useForm, FormProvider, useWatch } from 'react-hook-form'; import { useSnackbar } from 'notistack'; -import { Avatar, Box, Chip, MenuItem, Paper, Stack, TextField, Typography } from '@mui/material'; -import { AppButton, AppIcon, AppLoading, TrustBadge } from '@/components'; +import { Avatar, Box, MenuItem, Stack, Typography } from '@mui/material'; +import { + AccentCard, + AppButton, + AppIcon, + AppLoading, + FormSection, + PageHeader, + RhfChipSelect, + RhfTextField, + TrustBadge, +} from '@/components'; +import { CONTENT_MAX_WIDTH } from '@/components/config'; import { ROUTES } from '@/constants'; +import { digitsOnly } from '@/utils'; import { useNurseProfile, useUpsertNurseProfile, useUploadAvatar } from '@/services/profiles'; import type { NurseProfile } from '@/services/profiles/types'; import { useVerificationStatus } from '@/services/verification'; import { ownBadgeState, SPECIALTY_PRESETS } from '@/services/verification/types'; const MAX_YEARS = 80; +const MAX_YEARS_DIGITS = 2; const OTHER_CODE = '__other'; const EDUCATION_LEVELS = ['diploma', 'associate', 'bachelor', 'master', 'doctorate'] as const; const EDUCATION_FIELDS = ['nursing', 'midwifery', 'anesthesia', 'operating_room', 'public_health'] as const; +interface ProfileFormValues { + avatarUrl: string | null; + bio: string; + years: string; + educationLevel: string; + educationLevelOther: string; + educationField: string; + educationFieldOther: string; + specializations: string[]; +} + function parseSpecializations(json: string): string[] { try { const parsed = JSON.parse(json); @@ -24,13 +49,36 @@ function parseSpecializations(json: string): string[] { } } -/** Nurse profile bootstrap (B7 header): avatar + bio + years + qualifications. */ +/** + * Splits a stored free-text value into the (select code, "other" free-text) pair the form edits: a + * value the preset list knows becomes the code, anything else becomes «سایر» + the raw text. + */ +function splitPreset(stored: string, presets: readonly string[]): { code: string; other: string } { + if (presets.includes(stored)) return { code: stored, other: '' }; + return stored ? { code: OTHER_CODE, other: stored } : { code: '', other: '' }; +} + +/** Nurse profile bootstrap (B7 header): avatar + bio + experience + qualifications. */ export default function NurseProfilePage() { const { data: profile, isLoading } = useNurseProfile(); if (isLoading) return ; return ; } +/** + * The nurse's public-facing profile, rebuilt around three named questions instead of one undivided + * column of inputs. + * + * What it replaced: a flat stack of avatar → bio → years → two selects → two conditional "other" + * fields → a chip row → a save button, with no headings and no statement of what any of it was for. + * The nurse could not tell which fields families actually see, which were required, or how much was + * left — and every keystroke re-rendered the trust badge, the verification banner and the uploader + * along with the field being typed into, because each input owned a `useState` at page level. + * + * Now: `FormSection` groups the fields into معرفی / تجربه و تحصیلات / تخصص‌ها, each saying who the + * answer is for; react-hook-form owns the values so a keystroke re-renders one field; and validation + * lives on the field it governs rather than in a hand-rolled block at the top of the submit handler. + */ const NurseProfileForm: FunctionComponent<{ initial: NurseProfile | null }> = ({ initial }) => { const t = useTranslations('nurseProfile'); const tv = useTranslations('verification'); @@ -43,28 +91,29 @@ const NurseProfileForm: FunctionComponent<{ initial: NurseProfile | null }> = ({ const badgeState = ownBadgeState(verificationStatus); const fileInputRef = useRef(null); - const [avatarUrl, setAvatarUrl] = useState(initial?.avatarUrl ?? null); - const [bio, setBio] = useState(initial?.bio ?? ''); - const [years, setYears] = useState(initial ? String(initial.yearsOfExperience) : ''); - const [yearsError, setYearsError] = useState(false); + const level = splitPreset(initial?.educationLevel ?? '', EDUCATION_LEVELS); + const field = splitPreset(initial?.educationField ?? '', EDUCATION_FIELDS); - const initialLevel = initial?.educationLevel ?? ''; - const initialField = initial?.educationField ?? ''; - const [educationLevel, setEducationLevel] = useState( - (EDUCATION_LEVELS as readonly string[]).includes(initialLevel) ? initialLevel : initialLevel ? OTHER_CODE : '', - ); - const [educationLevelOther, setEducationLevelOther] = useState( - (EDUCATION_LEVELS as readonly string[]).includes(initialLevel) ? '' : initialLevel, - ); - const [educationField, setEducationField] = useState( - (EDUCATION_FIELDS as readonly string[]).includes(initialField) ? initialField : initialField ? OTHER_CODE : '', - ); - const [educationFieldOther, setEducationFieldOther] = useState( - (EDUCATION_FIELDS as readonly string[]).includes(initialField) ? '' : initialField, - ); - const [specializations, setSpecializations] = useState( - parseSpecializations(initial?.specializationsJson ?? '[]'), - ); + const form = useForm({ + mode: 'onTouched', + defaultValues: { + avatarUrl: initial?.avatarUrl ?? null, + bio: initial?.bio ?? '', + years: initial ? String(initial.yearsOfExperience) : '', + educationLevel: level.code, + educationLevelOther: level.other, + educationField: field.code, + educationFieldOther: field.other, + specializations: parseSpecializations(initial?.specializationsJson ?? '[]'), + }, + }); + const { control, handleSubmit, setValue, getValues } = form; + + // Only these three drive conditional rendering, so they are the only fields worth subscribing the + // page to — the rest re-render nothing but themselves. + const avatarUrl = useWatch({ control, name: 'avatarUrl' }); + const educationLevel = useWatch({ control, name: 'educationLevel' }); + const educationField = useWatch({ control, name: 'educationField' }); // A staged-but-unsaved avatar must never be silently discarded — warn on reload/tab-close. const avatarDirty = avatarUrl !== (initial?.avatarUrl ?? null); @@ -79,224 +128,199 @@ const NurseProfileForm: FunctionComponent<{ initial: NurseProfile | null }> = ({ return () => window.removeEventListener('beforeunload', handler); }, [avatarDirty]); - const pickFile = () => fileInputRef.current?.click(); - const onFileSelected = (event: ChangeEvent) => { const file = event.target.files?.[0]; event.target.value = ''; if (!file) return; uploadAvatar.mutate(file, { - onSuccess: (result) => setAvatarUrl(result.url), + onSuccess: (result) => setValue('avatarUrl', result.url, { shouldDirty: true }), onError: () => enqueueSnackbar(t('avatar_upload_error'), { variant: 'error' }), }); }; - const toggleSpecialty = (value: string) => - setSpecializations((prev) => (prev.includes(value) ? prev.filter((item) => item !== value) : [...prev, value])); - - const handleSave = () => { - const trimmed = years.trim(); - const yearsNum = trimmed === '' ? 0 : Number(trimmed); - const yearsInvalid = !Number.isInteger(yearsNum) || yearsNum < 0 || yearsNum > MAX_YEARS; - setYearsError(yearsInvalid); - if (yearsInvalid) return; - - const resolvedLevel = educationLevel === OTHER_CODE ? educationLevelOther.trim() : educationLevel; - const resolvedField = educationField === OTHER_CODE ? educationFieldOther.trim() : educationField; + const save = (values: ProfileFormValues) => { + const resolvedLevel = + values.educationLevel === OTHER_CODE ? values.educationLevelOther.trim() : values.educationLevel; + const resolvedField = + values.educationField === OTHER_CODE ? values.educationFieldOther.trim() : values.educationField; upsert.mutate( { - bio: bio.trim(), - yearsOfExperience: yearsNum, + bio: values.bio.trim(), + yearsOfExperience: values.years.trim() === '' ? 0 : Number(values.years), educationLevel: resolvedLevel, educationField: resolvedField, - specializationsJson: JSON.stringify(specializations), - avatarUrl, + specializationsJson: JSON.stringify(values.specializations), + avatarUrl: values.avatarUrl, }, { - onSuccess: () => enqueueSnackbar(t('saved'), { variant: 'success' }), + onSuccess: () => { + enqueueSnackbar(t('saved'), { variant: 'success' }); + // Re-baseline so the beforeunload guard and `isDirty` stop reporting saved work as unsaved. + form.reset(getValues()); + }, onError: () => enqueueSnackbar(t('save_error'), { variant: 'error' }), }, ); }; return ( - - - - - {t('title')} - - {/* The public trust signal on the nurse's own profile — the same badge f6 reuses in search. */} - - - - {t('subtitle')} - - + + + } /> - {/* Blocked-until-verified banner — shown until the aggregate is approved (incl. the expired state). */} - {badgeState !== 'verified' ? ( - - - - - - + {/* Blocked-until-verified nudge — shown until the aggregate is approved (incl. the expired state). */} + {badgeState !== 'verified' ? ( + + + + + {t('unverified_title')} - - {t('unverified_body')} - - + + + {t('unverified_body')} + {t('unverified_cta')} + + ) : null} + + + + + {avatarUrl ? null : } + + + + {t('photo_hint')} + + fileInputRef.current?.click()} + disabled={uploadAvatar.isPending} + sx={{ alignSelf: 'flex-start' }} + > + {uploadAvatar.isPending ? t('uploading') : t('upload')} + + + - - ) : null} - - - {avatarUrl ? null : } - - - - {t('photo')} - - - {t('photo_hint')} - - - {uploadAvatar.isPending ? t('uploading') : t('upload')} + + name="bio" + label={t('bio')} + helperText={t('bio_hint')} + multiline + minRows={3} + fullWidth + /> + + + + + name="years" + label={t('years')} + transform={(raw) => digitsOnly(raw).slice(0, MAX_YEARS_DIGITS)} + rules={{ + validate: (value) => { + const trimmed = String(value ?? '').trim(); + if (trimmed === '') return true; + const parsed = Number(trimmed); + return (Number.isInteger(parsed) && parsed >= 0 && parsed <= MAX_YEARS) || t('years_invalid'); + }, + }} + slotProps={{ htmlInput: { dir: 'ltr', inputMode: 'numeric', style: { textAlign: 'start' } } }} + sx={{ maxWidth: 200 }} + /> + + + name="educationLevel" select label={t('education_level_label')} fullWidth> + {EDUCATION_LEVELS.map((code) => ( + + {t(`education_level_${code}`)} + + ))} + {t('education_other')} + + name="educationField" select label={t('education_field_label')} fullWidth> + {EDUCATION_FIELDS.map((code) => ( + + {t(`education_field_${code}`)} + + ))} + {t('education_other')} + + + + {educationLevel === OTHER_CODE ? ( + + name="educationLevelOther" + label={t('education_level_other_label')} + rules={{ required: t('education_other_required') }} + fullWidth + /> + ) : null} + {educationField === OTHER_CODE ? ( + + name="educationFieldOther" + label={t('education_field_other_label')} + rules={{ required: t('education_other_required') }} + fullWidth + /> + ) : null} + + + + + name="specializations" + options={SPECIALTY_PRESETS.map((code) => ({ + code, + label: tv.has(`specialty_${code}`) ? tv(`specialty_${code}`) : code, + }))} + allowCustomValues + /> + + + + + {upsert.isPending ? tc('saving') : t('save')} + + + {t('preview_cta')} - - - setBio(e.target.value)} - helperText={t('bio_hint')} - multiline - minRows={3} - fullWidth - /> - - { - setYears(e.target.value.replace(/\D/g, '').slice(0, 2)); - if (yearsError) setYearsError(false); - }} - error={yearsError} - helperText={yearsError ? t('years_invalid') : undefined} - slotProps={{ htmlInput: { dir: 'ltr', inputMode: 'numeric', style: { textAlign: 'start' } } }} - sx={{ maxWidth: 200 }} - /> - - - setEducationLevel(e.target.value)} fullWidth> - {EDUCATION_LEVELS.map((code) => ( - - {t(`education_level_${code}`)} - - ))} - {t('education_other')} - - setEducationField(e.target.value)} fullWidth> - {EDUCATION_FIELDS.map((code) => ( - - {t(`education_field_${code}`)} - - ))} - {t('education_other')} - - - - {educationLevel === OTHER_CODE ? ( - setEducationLevelOther(e.target.value)} - fullWidth - /> - ) : null} - {educationField === OTHER_CODE ? ( - setEducationFieldOther(e.target.value)} - fullWidth - /> - ) : null} - - - - {t('specializations_label')} + + {t('deferred_services')} - - {SPECIALTY_PRESETS.map((code) => { - const selected = specializations.includes(code); - return ( - toggleSpecialty(code)} - variant={selected ? 'filled' : 'outlined'} - sx={{ - fontWeight: 500, - backgroundColor: selected ? 'var(--bal-primary)' : 'transparent', - color: selected ? 'var(--bal-primary-contrast)' : 'var(--bal-primary)', - borderColor: 'var(--bal-primary)', - }} - /> - ); - })} - - - - - {t('deferred_services')} - - - - {t('preview_cta')} - - - - {upsert.isPending ? tc('saving') : t('save')} - - + + ); }; diff --git a/client/src/app/[locale]/(private-routes)/nurse/services/VariantBuilder.tsx b/client/src/app/[locale]/(private-routes)/nurse/services/VariantBuilder.tsx index 31e6a6a..b8afc73 100644 --- a/client/src/app/[locale]/(private-routes)/nurse/services/VariantBuilder.tsx +++ b/client/src/app/[locale]/(private-routes)/nurse/services/VariantBuilder.tsx @@ -1,9 +1,24 @@ 'use client'; import { FunctionComponent, useMemo, useState } from 'react'; import { useLocale, useTranslations } from 'next-intl'; +import { Controller, FormProvider, useForm, useWatch } from 'react-hook-form'; import { useSnackbar } from 'notistack'; -import { Box, Chip, MenuItem, Paper, Skeleton, Stack, TextField, Typography } from '@mui/material'; -import { AccentCard, AppButton, AppIcon, AppLoading, CategoryTile, ErrorState, StepperHeader, VariantCard } from '@/components'; +import { Box, Chip, MenuItem, Skeleton, Stack, TextField, Typography } from '@mui/material'; +import { + AccentCard, + AppButton, + AppIcon, + AppLoading, + CategoryTile, + ErrorState, + FormSection, + PageHeader, + RhfTextField, + StepperHeader, + SurfaceCard, + VariantCard, +} from '@/components'; +import { CONTENT_MAX_WIDTH } from '@/components/config'; import { ApiError } from '@/lib/api/errors'; import { digitsOnly, rialToToman, tomanToRial } from '@/utils'; import { @@ -23,7 +38,7 @@ import { } from '@/services/catalog/types'; interface VariantBuilderProps { - /** `null` = create (3-step stepper); a variant = edit (category/options locked, price form only). */ + /** `null` = create (stepped flow); a variant = edit (category/options locked, price form only). */ initial: NurseServiceVariant | null; onDone: () => void; onCancel: () => void; @@ -35,15 +50,40 @@ const DEFAULT_UNIT: PriceUnit = 'per_hour'; const MAX_PRICE_DIGITS = 12; const MAX_DURATION_DIGITS = 4; +type StepKey = 'category' | 'options' | 'price'; + +interface VariantFormValues { + categoryId: number | null; + /** Option-group id → chosen value id. One field, so a category switch clears the whole answer set. */ + options: Record; + priceToman: string; + priceUnit: PriceUnit; + duration: string; + /** `null` until the nurse types over the auto-generated name — blank means "let the server name it". */ + displayNameOverride: string | null; +} + /** * The nurse variant builder (`CreateVariant` / `UpdateVariant`). * - * **Create** is a 3-step stepper: pick category → answer required/optional option groups → price + - * unit + duration. Every `is_required` group must be answered before advancing; the price is entered - * in **Toman** and converted to an IRR digit-string at the field boundary (`tomanToRial`, integer-safe, - * never a float); the estimated total is shown only from `price` × `sessionCount`, never `price` alone; - * `display_name` auto-generates from the chosen labels and is editable (left blank ⇒ the server - * generates it). A duplicate identical listing (`409`) shows a friendly inline warning. + * **Create** walks category → options → price. Two things make the flow deterministic where it + * previously was not: + * + * 1. **A step that has nothing to ask is not shown.** Categories with no option groups used to get a + * middle step whose entire content was "این دسته گزینه‌ای برای تنظیم ندارد" plus a Next button. + * The step list is now derived from the loaded groups, so those categories go straight to pricing. + * 2. **Advancing is gated *before* the tap, not after it.** The old Next button was always enabled and + * surfaced an error only once pressed, from a separate `optionsError` flag. Now the unanswered + * required groups are named under the button while it is disabled, so the blocker is visible + * without probing for it. + * + * The final step is a review as well as a form: the chosen category and options are recapped as chips + * beside the live `VariantCard`, so the listing can be checked without stepping backwards. + * + * Money still crosses the field boundary exactly once — the price is entered in **Toman** and + * converted to an IRR digit-string via `tomanToRial` (integer-safe, never a float); the estimated + * total is shown only from `price` × `sessionCount`, never `price` alone. A duplicate identical + * listing (`409`) shows a friendly inline warning that offers to edit the colliding listing instead. * * **Edit** locks the category + option-set (changing them would change identity) and edits only * price/unit/duration/display via `update`. @@ -61,21 +101,29 @@ const VariantBuilder: FunctionComponent = ({ initial, onDon const updateVariant = useUpdateVariant(); const submitting = createVariant.isPending || updateVariant.isPending; - // --- Create-only state (category → options) --- - const [activeStep, setActiveStep] = useState(0); - const [categoryId, setCategoryId] = useState(initial?.serviceCategoryId ?? null); - const [selectedOptions, setSelectedOptions] = useState>({}); - const [optionsError, setOptionsError] = useState(false); - - // --- Shared price state (both create step 3 and edit) --- - // Pre-fill the price field in Toman (the wire carries IRR Rials); the money util does the ÷10. - const [priceToman, setPriceToman] = useState(initial ? String(rialToToman(initial.price)) : ''); - const [priceUnit, setPriceUnit] = useState(initial?.priceUnit ?? DEFAULT_UNIT); - const [durationStr, setDurationStr] = useState(initial?.sessionCount ? String(initial.sessionCount) : ''); - const [displayNameOverride, setDisplayNameOverride] = useState(null); - const [priceError, setPriceError] = useState(false); + const [step, setStep] = useState(isEdit ? 'price' : 'category'); const [duplicate, setDuplicate] = useState(false); + const form = useForm({ + mode: 'onTouched', + defaultValues: { + categoryId: initial?.serviceCategoryId ?? null, + options: {}, + // Pre-fill the price field in Toman (the wire carries IRR Rials); the money util does the ÷10. + priceToman: initial ? String(rialToToman(initial.price)) : '', + priceUnit: initial?.priceUnit ?? DEFAULT_UNIT, + duration: initial?.sessionCount ? String(initial.sessionCount) : '', + displayNameOverride: null, + }, + }); + const { control, handleSubmit, setValue } = form; + const categoryId = useWatch({ control, name: 'categoryId' }); + const selectedOptions = useWatch({ control, name: 'options' }); + const priceToman = useWatch({ control, name: 'priceToman' }); + const priceUnit = useWatch({ control, name: 'priceUnit' }); + const duration = useWatch({ control, name: 'duration' }); + const displayNameOverride = useWatch({ control, name: 'displayNameOverride' }); + const categoriesQuery = useServiceCategories(); const categories = categoriesQuery.data?.items ?? []; const optionGroupsQuery = useCategoryOptionGroups(isEdit ? null : categoryId); @@ -85,6 +133,21 @@ const VariantBuilder: FunctionComponent = ({ initial, onDon const selectedCategory = categories.find((category) => category.id === categoryId) ?? null; const missingRequiredGroups = groups.filter((group) => group.isRequired && selectedOptions[group.id] == null); + // A category with no option groups has no question to ask, so its step doesn't exist. Until the + // groups for the chosen category have actually loaded the answer is unknown, and the assumption is + // "there is an options step" — that way the step count only ever collapses for a category proven to + // have none, instead of starting at two and growing the moment a category is tapped. + const optionsStepUnknown = categoryId == null || optionGroupsQuery.isLoading || optionGroupsQuery.isFetching; + const hasOptionsStep = !isEdit && (optionsStepUnknown || groups.length > 0); + const effectiveStep: StepKey = step === 'options' && !hasOptionsStep ? 'price' : step; + + const visibleSteps: StepKey[] = hasOptionsStep ? ['category', 'options', 'price'] : ['category', 'price']; + const stepLabels: Record = { + category: t('step_category'), + options: t('step_options'), + price: t('step_price'), + }; + // Reuses the already-cached offerings list (MyServicesList holds the same query) to resolve which // existing listing a 409 duplicate collided with, so the recovery can offer "edit that one" directly. const myVariantsQuery = useMyVariants(); @@ -118,53 +181,51 @@ const VariantBuilder: FunctionComponent = ({ initial, onDon const priceValid = priceToman.length > 0 && BigInt(priceToman) > BigInt(0); const irr = priceValid ? tomanToRial(priceToman) : null; - const durationInt = durationStr ? Number(durationStr) : 0; + const durationInt = duration ? Number(duration) : 0; const sessionCount = durationInt > 0 ? durationInt : null; const selectCategory = (id: number) => { if (id === categoryId) return; // Switching category invalidates the previous category's option answers + auto-name. - setCategoryId(id); - setSelectedOptions({}); - setDisplayNameOverride(null); - setOptionsError(false); + setValue('categoryId', id, { shouldDirty: true }); + setValue('options', {}); + setValue('displayNameOverride', null); }; const changeOption = (groupId: number, valueId: number | null) => { - setOptionsError(false); + const next = { ...selectedOptions }; + if (valueId == null) delete next[groupId]; + else next[groupId] = valueId; // A manual displayName override is intentionally left untouched; the auto-name preview tracks // option changes only while the field hasn't been overridden (displayValue = override ?? autoName). - setSelectedOptions((prev) => { - const next = { ...prev }; - if (valueId == null) delete next[groupId]; - else next[groupId] = valueId; - return next; - }); + setValue('options', next, { shouldDirty: true }); }; - const goNextFromOptions = () => { - if (missingRequiredGroups.length > 0) { - setOptionsError(true); + const goNext = () => { + if (effectiveStep === 'category') { + setStep(hasOptionsStep ? 'options' : 'price'); return; } - setActiveStep(2); + setStep('price'); }; - const validatePrice = () => { - if (!priceValid) { - setPriceError(true); - return false; + const goBack = () => { + if (effectiveStep === 'price' && !isEdit) { + setStep(hasOptionsStep ? 'options' : 'category'); + return; } - return true; + setStep('category'); }; - const submit = () => { - if (!validatePrice() || irr == null) return; - const displayName = displayNameOverride?.trim() ? displayNameOverride.trim() : undefined; + const submit = (values: VariantFormValues) => { + // `handleSubmit` has already enforced the price rule; this is the type narrowing that lets the + // IRR string be passed on, not a second gate. + if (irr == null) return; + const displayName = values.displayNameOverride?.trim() ? values.displayNameOverride.trim() : undefined; if (isEdit) { updateVariant.mutate( - { id: initial.id, input: { price: irr, priceUnit, sessionCount, displayName } }, + { id: initial.id, input: { price: irr, priceUnit: values.priceUnit, sessionCount, displayName } }, { onSuccess: () => { enqueueSnackbar(t('saved_toast'), { variant: 'success' }); @@ -176,12 +237,19 @@ const VariantBuilder: FunctionComponent = ({ initial, onDon return; } - const options: VariantOptionSelection[] = Object.entries(selectedOptions).map(([groupId, valueId]) => ({ + const options: VariantOptionSelection[] = Object.entries(values.options).map(([groupId, valueId]) => ({ optionGroupId: Number(groupId), optionValueId: valueId, })); createVariant.mutate( - { serviceCategoryId: categoryId as number, options, price: irr, priceUnit, sessionCount, displayName }, + { + serviceCategoryId: values.categoryId as number, + options, + price: irr, + priceUnit: values.priceUnit, + sessionCount, + displayName, + }, { onSuccess: () => { enqueueSnackbar(t('created_toast'), { variant: 'success' }); @@ -196,13 +264,13 @@ const VariantBuilder: FunctionComponent = ({ initial, onDon ); }; - // Step 3's live preview — the actual VariantCard, so the nurse sees the listing they're composing, - // not just an abstract price readout. + // The price step's live preview — the actual VariantCard, so the nurse sees the listing they're + // composing, not just an abstract price readout. const previewVariant: NurseServiceVariant = { id: 0, serviceCategoryId: categoryId ?? 0, - categoryNameFa: selectedCategory?.nameFa ?? '', - categoryNameEn: selectedCategory?.nameEn ?? '', + categoryNameFa: selectedCategory?.nameFa ?? initial?.categoryNameFa ?? '', + categoryNameEn: selectedCategory?.nameEn ?? initial?.categoryNameEn ?? '', price: irr ?? '0', priceUnit, sessionCount, @@ -211,68 +279,119 @@ const VariantBuilder: FunctionComponent = ({ initial, onDon options: [], }; + /** Category + chosen options, so the last step doubles as a review of the first two. */ + const recapChips = isEdit + ? initial.options.map((option) => ({ + key: String(option.optionGroupId), + label: `${pickCatalogName({ nameFa: option.groupNameFa, nameEn: option.groupNameEn }, locale)}: ${pickCatalogName({ nameFa: option.valueNameFa, nameEn: option.valueNameEn }, locale)}`, + })) + : groups.flatMap((group) => { + const value = group.values.find((candidate) => candidate.id === selectedOptions[group.id]); + return value + ? [{ key: String(group.id), label: `${pickCatalogName(group, locale)}: ${pickCatalogName(value, locale)}` }] + : []; + }); + const priceStep = ( - { - setPriceToman(digitsOnly(event.target.value).slice(0, MAX_PRICE_DIGITS)); - if (priceError) setPriceError(false); - if (duplicate) setDuplicate(false); - }} - error={priceError} - helperText={priceError ? t('price_required') : t('price_hint')} - slotProps={{ htmlInput: { dir: 'ltr', inputMode: 'numeric', style: { textAlign: 'start' } } }} - fullWidth - /> + + + + {selectedCategory + ? pickCatalogName(selectedCategory, locale) + : initial + ? pickCatalogName({ nameFa: initial.categoryNameFa, nameEn: initial.categoryNameEn }, locale) + : ''} + + {isEdit ? ( + + {t('category_locked')} + + ) : null} + + {recapChips.length > 0 ? ( + recapChips.map((chip) => ( + + )) + ) : ( + + {t('summary_none')} + + )} + + + - - setPriceUnit(event.target.value as PriceUnit)} - fullWidth - > - {PRICE_UNITS.map((unit) => ( - - {tCatalog(`unit_${unit}`)} - - ))} - - - setDurationStr(digitsOnly(event.target.value).slice(0, MAX_DURATION_DIGITS))} - helperText={t('duration_hint')} + + + name="priceToman" + label={t('price_label')} + transform={(raw) => digitsOnly(raw).slice(0, MAX_PRICE_DIGITS)} + rules={{ + validate: (value) => { + const raw = String(value ?? ''); + return (raw.length > 0 && BigInt(raw) > BigInt(0)) || t('price_required'); + }, + }} slotProps={{ htmlInput: { dir: 'ltr', inputMode: 'numeric', style: { textAlign: 'start' } } }} fullWidth /> - - {irr ? ( + + name="priceUnit" select label={t('unit_label')} fullWidth> + {PRICE_UNITS.map((unit) => ( + + {tCatalog(`unit_${unit}`)} + + ))} + + + + name="duration" + label={t('duration_label')} + helperText={t('duration_hint')} + transform={(raw) => digitsOnly(raw).slice(0, MAX_DURATION_DIGITS)} + slotProps={{ htmlInput: { dir: 'ltr', inputMode: 'numeric', style: { textAlign: 'start' } } }} + fullWidth + /> + + + {!sessionCount && irr ? ( + + {t('rate_note')} + + ) : null} + + + + {/* Not `RhfTextField`: what is *stored* is the override alone (blank ⇒ the server generates + the name), while what is *shown* falls back to the live auto-generated name. One field, + two values — the one case in this form where the display value isn't the form value. */} + ( + field.onChange(event.target.value)} + onBlur={field.onBlur} + fullWidth + /> + )} + /> + {t('preview_heading')} + {/* Shown even before a price is entered — the preview is what "comprehensive" means here: + the nurse should see the shape of the listing while composing it, not only once it's valid. */} - {!sessionCount ? ( - - {t('rate_note')} - - ) : null} - ) : null} - - setDisplayNameOverride(event.target.value)} - helperText={t('display_name_hint')} - fullWidth - /> + {duplicate ? ( @@ -301,157 +420,70 @@ const VariantBuilder: FunctionComponent = ({ initial, onDon ); - // --- Edit mode: locked category + options summary, then the price form --- - if (isEdit) { - return ( - - - {t('builder_edit_title')} - - - - - - {pickCatalogName({ nameFa: initial.categoryNameFa, nameEn: initial.categoryNameEn }, locale)} - - - {t('category_locked')} - - - {initial.options.length > 0 ? ( - initial.options.map((option) => ( - - )) - ) : ( - - {t('summary_none')} - - )} - - - - - {priceStep} - - - - {tc('cancel')} - - - {submitting ? tc('saving') : t('submit_save')} - - - - ); - } - - // --- Create mode: the 3-step stepper --- return ( - - - {t('builder_add_title')} - + + + - + {isEdit ? null : ( + stepLabels[key])} + activeStep={visibleSteps.indexOf(effectiveStep)} + /> + )} - {activeStep === 0 ? ( - - - - {t('category_title')} - - - {t('category_subtitle')} - - - {categoriesQuery.isLoading ? ( - - {[0, 1, 2, 3].map((key) => ( - - ))} - - ) : categoriesQuery.isError ? ( - - - {t('categories_error')} - - categoriesQuery.refetch()}> - {tc('retry')} - - - ) : ( - - {categories.map((category) => ( - selectCategory(category.id)} - /> - ))} - - )} - - ) : null} + {effectiveStep === 'category' ? ( + + {categoriesQuery.isLoading ? ( + + {[0, 1, 2, 3].map((key) => ( + + ))} + + ) : categoriesQuery.isError ? ( + categoriesQuery.refetch()} /> + ) : ( + + {categories.map((category) => ( + selectCategory(category.id)} + /> + ))} + + )} + + ) : null} - {activeStep === 1 ? ( - - - - {t('options_title')} - - - {t('options_subtitle')} - - - - {optionGroupsQuery.isLoading ? ( - - ) : optionGroupsQuery.isError ? ( - // A failed fetch must never read as "this category has zero options" — that would let the - // nurse skip required options entirely. Block progression until the retry succeeds. - optionGroupsQuery.refetch()} - /> - ) : groups.length === 0 ? ( - - {t('options_none')} - - ) : ( - groups.map((group) => { - const isMissing = optionsError && group.isRequired && selectedOptions[group.id] == null; - return ( + {effectiveStep === 'options' ? ( + + {optionGroupsQuery.isLoading ? ( + + ) : optionGroupsQuery.isError ? ( + // A failed fetch must never read as "this category has zero options" — that would let the + // nurse skip required options entirely. Block progression until the retry succeeds. + optionGroupsQuery.refetch()} /> + ) : ( + groups.map((group) => ( {pickCatalogName(group, locale)} - {/* The required badge turns red on a blocked advance to point at the unanswered group. */} @@ -463,76 +495,64 @@ const VariantBuilder: FunctionComponent = ({ initial, onDon changeOption(group.id, selected ? null : value.id)} + aria-pressed={selected} + clickable + color={selected ? 'primary' : 'default'} variant={selected ? 'filled' : 'outlined'} - sx={{ - fontWeight: 500, - backgroundColor: selected ? 'var(--bal-primary)' : 'transparent', - color: selected ? 'var(--bal-primary-contrast)' : 'var(--bal-primary)', - borderColor: 'var(--bal-primary)', - }} + onClick={() => changeOption(group.id, selected ? null : value.id)} /> ); })} - ); - }) - )} + )) + )} + + ) : null} - {optionsError && missingRequiredGroups.length > 0 ? ( - - {t('options_incomplete')} - - ) : null} - - ) : null} + {effectiveStep === 'price' ? priceStep : null} - {activeStep === 2 ? ( - - - - {t('price_title')} - - - {priceStep} - - ) : null} + {/* Names what is still missing while the button is disabled, instead of revealing it on tap. */} + {effectiveStep === 'options' && missingRequiredGroups.length > 0 ? ( + + {t('options_missing_named', { + groups: missingRequiredGroups.map((group) => pickCatalogName(group, locale)).join('، '), + })} + + ) : null} - - setActiveStep((step) => step - 1)} - disabled={submitting} - > - {activeStep === 0 ? tc('cancel') : tc('back')} - + + + + {effectiveStep === 'category' || isEdit ? tc('cancel') : tc('back')} + - {activeStep === 0 ? ( - setActiveStep(1)} - disabled={categoryId == null} - > - {t('next')} - - ) : activeStep === 1 ? ( - - {t('next')} - - ) : ( - - {submitting ? tc('saving') : t('submit_create')} - - )} - - + {effectiveStep === 'price' ? ( + + {submitting ? tc('saving') : isEdit ? t('submit_save') : t('submit_create')} + + ) : ( + 0 + } + > + {t('next')} + + )} + + + + ); }; diff --git a/client/src/app/[locale]/(private-routes)/nurse/verification/credentials/page.tsx b/client/src/app/[locale]/(private-routes)/nurse/verification/credentials/page.tsx index f582ed7..26e749d 100644 --- a/client/src/app/[locale]/(private-routes)/nurse/verification/credentials/page.tsx +++ b/client/src/app/[locale]/(private-routes)/nurse/verification/credentials/page.tsx @@ -1,10 +1,20 @@ 'use client'; -import { useMemo, useState } from 'react'; +import { FunctionComponent, useMemo, useState } from 'react'; import { useLocale, useTranslations } from 'next-intl'; import { useRouter } from 'next/navigation'; +import { FormProvider, useForm, useWatch } from 'react-hook-form'; import { useSnackbar } from 'notistack'; import { Box, Chip, Paper, Stack, TextField, Typography } from '@mui/material'; -import { AppButton, AppIcon, AppLoading, DocumentUpload, JalaliDateField } from '@/components'; +import { + AppButton, + AppIcon, + AppLoading, + DocumentUpload, + FormSection, + RhfChipSelect, + RhfJalaliDateField, + RhfTextField, +} from '@/components'; import type { UploadedDocInfo } from '@/components'; import { CONTENT_MAX_WIDTH } from '@/components/config'; import { ROUTES } from '@/constants'; @@ -15,101 +25,42 @@ import { useVerificationStatus, } from '@/services/verification'; import { SPECIALTY_PRESETS } from '@/services/verification/types'; -import type { VerificationStep } from '@/services/verification/types'; +import type { VerificationStatus, VerificationStep } from '@/services/verification/types'; import { stepDescriptionKey, stepLabelKey } from '../verificationSteps'; import VerificationJourneyHeader from '../VerificationJourneyHeader'; const MANUAL_CREDENTIAL_CODES = ['moh_competency_license', 'ino_membership', 'criminal_record']; +interface CredentialsFormValues { + inoNumber: string; + specialties: string[]; + issuingAuthority: string; + issuedAt: string | null; + expiresAt: string | null; +} + /** * B5 — professional credentials. Renders a `DocumentUpload` for **each manual credential step in the * status** (data-driven — a new manual step renders without a code change); each upload moves its step * to `in_review` (manual admin review — copy never claims an automated authority check). * - * Hydrates from `status.credentialSubmission` (REQ-056, mock-tolerant): once the INO number has been - * recorded, the field locks into a "شمارهٔ نظام ثبت شد" summary — never re-prompted as if lost, and - * never re-sent blank (the server's `CredentialDetailsInput.inoNumber` is required; the raw number is - * never read back by design, so re-submitting it isn't possible without the nurse re-entering it via - * "تغییر"). A returning, already-submitted nurse can still fix a **rejected** document directly (each - * upload takes effect immediately, no re-submit needed) and simply returns to the journey — no dead - * disabled button, because there is no button to be dead. + * The whole screen used to be one undivided column: a number field, three uploaders, a chip row with + * its own add-a-custom-value sub-form, two date pickers and a submit button, with two independent + * `editingIno ? … : …` branches interleaved through it. Grouping the same content into four named + * sections — شماره نظام / مدارک / تخصص‌ها / جزئیات مدرک — makes the two genuinely optional groups + * visibly optional and gives the "what still blocks submit?" answer somewhere to live. */ export default function CredentialsSubmitPage() { const t = useTranslations('verification'); const locale = useLocale(); - const router = useRouter(); - const { enqueueSnackbar } = useSnackbar(); const { data: status, isLoading } = useVerificationStatus(); - const uploadDocument = useUploadVerificationDocument(); - const submitCredentials = useSubmitCredentials(); - - const submission = status?.credentialSubmission; - - const [inoNumber, setInoNumber] = useState(''); - const [inoError, setInoError] = useState(false); - const [editingIno, setEditingIno] = useState(true); - const [specialties, setSpecialties] = useState([]); - const [customSpecialty, setCustomSpecialty] = useState(''); - const [issuingAuthority, setIssuingAuthority] = useState(''); - const [issuedAt, setIssuedAt] = useState(null); - const [expiresAt, setExpiresAt] = useState(null); - const [uploadedSteps, setUploadedSteps] = useState>({}); - const [hydrated, setHydrated] = useState(false); - - // Hydrate once from the server's read-back, adjusted directly during render (never in an effect — - // that would cascade an extra render) — and never overwrite what the nurse is actively editing. - if (!hydrated && submission) { - setHydrated(true); - setEditingIno(!submission.inoNumberSubmitted); - setSpecialties(submission.specialties); - setIssuingAuthority(submission.issuingAuthority ?? ''); - setIssuedAt(submission.issuedAt ?? null); - setExpiresAt(submission.expiresAt ?? null); - } const manualSteps = useMemo( () => (status?.steps ?? []).filter((step) => MANUAL_CREDENTIAL_CODES.includes(step.code)), [status], ); - const toggleSpecialty = (value: string) => - setSpecialties((prev) => (prev.includes(value) ? prev.filter((item) => item !== value) : [...prev, value])); - - const addCustomSpecialty = () => { - const value = customSpecialty.trim(); - if (value && !specialties.includes(value)) setSpecialties((prev) => [...prev, value]); - setCustomSpecialty(''); - }; - - const uploadToStep = (step: VerificationStep) => async (file: File, onProgress: (percent: number) => void) => { - const doc = await uploadDocument.mutateAsync({ stepId: step.id, file, onProgress }); - return { name: doc.originalFileName ?? file.name, sizeBytes: doc.fileSizeBytes } satisfies UploadedDocInfo; - }; - - const handleSubmit = () => { - const inoValid = inoNumber.trim().length > 0; - setInoError(!inoValid); - if (!inoValid) return; - - submitCredentials.mutate( - { - inoNumber: inoNumber.trim(), - specialties, - issuingAuthority: issuingAuthority.trim() || undefined, - issuedAt: issuedAt ?? undefined, - expiresAt: expiresAt ?? undefined, - }, - { - onSuccess: () => { - enqueueSnackbar(t('credentials_submitted'), { variant: 'success' }); - router.push(`/${locale}${ROUTES.NURSE_VERIFICATION_REVIEW}`); - }, - onError: () => enqueueSnackbar(t('credentials_error'), { variant: 'error' }), - }, - ); - }; - if (isLoading) return ; if (!status || manualSteps.length === 0) { @@ -126,227 +77,293 @@ export default function CredentialsSubmitPage() { ); } + return ; +} + +/** + * Hydrates from `status.credentialSubmission` (REQ-056, mock-tolerant) as react-hook-form + * `defaultValues` — mounted only once the status query has resolved, which is what lets the server + * read-back *be* the initial form state instead of being copied into it by a render-time state + * adjustment. Once the INO number has been recorded the field locks into a "شمارهٔ نظام ثبت شد" + * summary — never re-prompted as if lost, and never re-sent blank (the server's + * `CredentialDetailsInput.inoNumber` is required; the raw number is never read back by design, so + * re-submitting it isn't possible without the nurse re-entering it via "تغییر"). A returning, + * already-submitted nurse can still fix a **rejected** document directly (each upload takes effect + * immediately, no re-submit needed) and simply returns to the journey. + */ +const CredentialsForm: FunctionComponent<{ status: VerificationStatus; manualSteps: VerificationStep[] }> = ({ + status, + manualSteps, +}) => { + const t = useTranslations('verification'); + const tc = useTranslations('common'); + const locale = useLocale(); + const router = useRouter(); + const { enqueueSnackbar } = useSnackbar(); + + const uploadDocument = useUploadVerificationDocument(); + const submitCredentials = useSubmitCredentials(); + const submission = status.credentialSubmission; + + const [editingIno, setEditingIno] = useState(!submission?.inoNumberSubmitted); + const [customSpecialty, setCustomSpecialty] = useState(''); + const [uploadedSteps, setUploadedSteps] = useState>({}); + + const form = useForm({ + mode: 'onTouched', + defaultValues: { + inoNumber: '', + specialties: submission?.specialties ?? [], + issuingAuthority: submission?.issuingAuthority ?? '', + issuedAt: submission?.issuedAt ?? null, + expiresAt: submission?.expiresAt ?? null, + }, + }); + const { control, handleSubmit, setValue } = form; + const specialties = useWatch({ control, name: 'specialties' }); + const issuedAt = useWatch({ control, name: 'issuedAt' }); + const expiresAt = useWatch({ control, name: 'expiresAt' }); + const issuingAuthority = useWatch({ control, name: 'issuingAuthority' }); + + const addCustomSpecialty = () => { + const value = customSpecialty.trim(); + if (value && !specialties.includes(value)) { + setValue('specialties', [...specialties, value], { shouldDirty: true }); + } + setCustomSpecialty(''); + }; + + const uploadToStep = (step: VerificationStep) => async (file: File, onProgress: (percent: number) => void) => { + const doc = await uploadDocument.mutateAsync({ stepId: step.id, file, onProgress }); + return { name: doc.originalFileName ?? file.name, sizeBytes: doc.fileSizeBytes } satisfies UploadedDocInfo; + }; + // A returning nurse with any manual step already on file (server truth) is never dead-ended: while // actively (re-)entering the INO number, the gate also counts server-side documents, not only this // session's uploads. - const hasAnyDocument = - Object.values(uploadedSteps).some(Boolean) || - manualSteps.some((step) => step.status === 'in_review' || step.status === 'passed'); - const canSubmit = hasAnyDocument && !submitCredentials.isPending; + const uploadedCount = + manualSteps.filter((step) => uploadedSteps[step.id] || step.status === 'in_review' || step.status === 'passed') + .length; + const hasAnyDocument = uploadedCount > 0; + + const submit = (values: CredentialsFormValues) => { + submitCredentials.mutate( + { + inoNumber: values.inoNumber.trim(), + specialties: values.specialties, + issuingAuthority: values.issuingAuthority.trim() || undefined, + issuedAt: values.issuedAt ?? undefined, + expiresAt: values.expiresAt ?? undefined, + }, + { + onSuccess: () => { + enqueueSnackbar(t('credentials_submitted'), { variant: 'success' }); + router.push(`/${locale}${ROUTES.NURSE_VERIFICATION_REVIEW}`); + }, + onError: () => enqueueSnackbar(t('credentials_error'), { variant: 'error' }), + }, + ); + }; return ( - - + + + - - - {t('credentials_title')} - {t('credentials_subtitle')} - - {editingIno ? ( - { - setInoNumber(event.target.value); - if (inoError) setInoError(false); - }} - error={inoError} - helperText={inoError ? t('ino_number_required') : t('ino_number_hint')} - slotProps={{ htmlInput: { dir: 'ltr', style: { textAlign: 'start' } } }} - fullWidth - /> - ) : ( - + {editingIno ? ( + + name="inoNumber" + label={t('ino_number_label')} + rules={{ required: t('ino_number_required') }} + slotProps={{ htmlInput: { dir: 'ltr', style: { textAlign: 'start' } } }} + fullWidth + /> + ) : ( + + + + {t('ino_number_submitted')} + + setEditingIno(true)}> + {t('ino_number_change')} + + + )} + + + {/* One uploader per manual credential step — data-driven from the status. Re-uploading a + rejected document always takes effect immediately, whether or not the INO number is locked. */} + + {t('credentials_documents_count', { done: uploadedCount, total: manualSteps.length })} + + } > - - - {t('ino_number_submitted')} - - setEditingIno(true)}> - {t('ino_number_change')} - - - )} + {manualSteps.map((step) => ( + setUploadedSteps((prev) => ({ ...prev, [step.id]: true }))} + rejected={step.status === 'failed'} + rejectionReason={step.failureReason ?? undefined} + existingDoc={step.status === 'in_review' || step.status === 'passed' ? { name: t('doc_uploaded') } : null} + /> + ))} - {/* One uploader per manual credential step — data-driven from the status. Re-uploading a - rejected document always takes effect immediately, whether or not the INO number is locked. */} - - {manualSteps.map((step) => ( - setUploadedSteps((prev) => ({ ...prev, [step.id]: true }))} - rejected={step.status === 'failed'} - rejectionReason={step.failureReason ?? undefined} - existingDoc={step.status === 'in_review' || step.status === 'passed' ? { name: t('doc_uploaded') } : null} - /> - ))} - + {editingIno ? ( + ({ name: file.name })} /> + ) : null} + - {editingIno ? ( - ({ name: file.name })} /> - ) : null} + + {editingIno ? ( + <> + + name="specialties" + options={SPECIALTY_PRESETS.map((preset) => ({ + code: preset, + label: t.has(`specialty_${preset}`) ? t(`specialty_${preset}`) : preset, + }))} + allowCustomValues + /> + + setCustomSpecialty(event.target.value)} + onKeyDown={(event) => { + if (event.key === 'Enter') { + event.preventDefault(); + addCustomSpecialty(); + } + }} + /> + + {t('specialty_add')} + + + + ) : specialties.length > 0 ? ( + + {specialties.map((value) => ( + + ))} + + ) : ( + + {t('summary_none')} + + )} + + + {/* Optional registry details the admin cross-checks — issue/expiry feed the credential-expiry + sweep, so wrong dates are a correctness risk; a Jalali picker replaces the Gregorian-only + native input. */} + {editingIno ? ( + + name="issuingAuthority" label={t('issuing_authority_label')} fullWidth /> + + + name="issuedAt" + label={t('issued_at_label')} + max={expiresAt ?? undefined} + sx={{ flex: 1, minWidth: 160 }} + /> + + name="expiresAt" + label={t('expires_at_label')} + min={issuedAt ?? undefined} + sx={{ flex: 1, minWidth: 160 }} + /> + + + ) : issuingAuthority || issuedAt || expiresAt ? ( + + {issuingAuthority ? {issuingAuthority} : null} + {issuedAt || expiresAt ? ( + + {issuedAt ? formatShamsiDate(issuedAt, locale) : ''} + {issuedAt && expiresAt ? ' – ' : ''} + {expiresAt ? formatShamsiDate(expiresAt, locale) : ''} + + ) : null} + + ) : null} + + + + + {t('manual_review_note')} + + - - - {t('specialties_label')} - {editingIno ? ( <> - - {t('specialties_hint')} - - - {SPECIALTY_PRESETS.map((preset) => { - const selected = specialties.includes(preset); - return ( - toggleSpecialty(preset)} - icon={selected ? : undefined} - variant={selected ? 'filled' : 'outlined'} - sx={{ - fontWeight: 500, - backgroundColor: selected ? 'var(--bal-primary)' : 'transparent', - color: selected ? 'var(--bal-primary-contrast)' : 'var(--bal-primary)', - borderColor: 'var(--bal-primary)', - }} - /> - ); - })} - {specialties - .filter((value) => !SPECIALTY_PRESETS.includes(value)) - .map((value) => ( - toggleSpecialty(value)} - sx={{ fontWeight: 500, backgroundColor: 'var(--bal-primary)', color: 'var(--bal-primary-contrast)' }} - /> - ))} - - - setCustomSpecialty(event.target.value)} - onKeyDown={(event) => { - if (event.key === 'Enter') { - event.preventDefault(); - addCustomSpecialty(); - } - }} - /> - - {t('specialty_add')} + {!hasAnyDocument ? ( + + {t('credentials_needs_document')} + + ) : null} + + + {submitCredentials.isPending ? t('credentials_submitting') : t('credentials_submit')} + + + {t('back_to_checklist')} - ) : specialties.length > 0 ? ( - - {specialties.map((value) => ( - - ))} - ) : ( - - {t('summary_none')} - + + {t('back_to_checklist')} + )} - - - {/* Optional registry details the admin cross-checks — issue/expiry feed the credential-expiry sweep, so - wrong dates are a correctness risk; a Jalali picker replaces the Gregorian-only native input. */} - {editingIno ? ( - - - {t('registry_details_label')} - - setIssuingAuthority(event.target.value)} - fullWidth - /> - - - - - - ) : issuingAuthority || issuedAt || expiresAt ? ( - - - {t('registry_details_label')} - - {issuingAuthority ? {issuingAuthority} : null} - {issuedAt || expiresAt ? ( - - {issuedAt ? formatShamsiDate(issuedAt, locale) : ''} - {issuedAt && expiresAt ? ' – ' : ''} - {expiresAt ? formatShamsiDate(expiresAt, locale) : ''} - - ) : null} - - ) : null} - - - - - {t('manual_review_note')} - - - - {editingIno ? ( - <> - {!hasAnyDocument ? ( - - {t('credentials_needs_document')} - - ) : null} - - - {submitCredentials.isPending ? t('credentials_submitting') : t('credentials_submit')} - - - {t('back_to_checklist')} - - - - ) : ( - - {t('back_to_checklist')} - - )} - + + ); -} +}; diff --git a/client/src/app/[locale]/(private-routes)/nurse/verification/identity/page.tsx b/client/src/app/[locale]/(private-routes)/nurse/verification/identity/page.tsx index 51666ac..88d5490 100644 --- a/client/src/app/[locale]/(private-routes)/nurse/verification/identity/page.tsx +++ b/client/src/app/[locale]/(private-routes)/nurse/verification/identity/page.tsx @@ -2,9 +2,10 @@ import { useState } from 'react'; import { useLocale, useTranslations } from 'next-intl'; import { useRouter } from 'next/navigation'; +import { useForm, FormProvider, useWatch } from 'react-hook-form'; import { useSnackbar } from 'notistack'; -import { Box, Paper, Stack, TextField, Typography } from '@mui/material'; -import { AppAlert, AppButton, AppIcon, DocumentUpload } from '@/components'; +import { Box, Paper, Stack, Typography } from '@mui/material'; +import { AppAlert, AppButton, AppIcon, DocumentUpload, FormSection, RhfTextField } from '@/components'; import { CONTENT_MAX_WIDTH } from '@/components/config'; import { ROUTES } from '@/constants'; import { toEnglishDigits } from '@/utils'; @@ -16,6 +17,12 @@ import VerificationJourneyHeader from '../VerificationJourneyHeader'; type SubmitError = { key: 'national_id_mismatch' | 'shared_sim' | 'shahkar_mismatch' } | null; +interface IdentityFormValues { + nationalId: string; + cardCaptured: boolean; + selfieCaptured: boolean; +} + /** * B4 — identity submission. Collects the national id (10-digit + checksum), a national-ID card image, * and a liveness selfie, then runs the automated civil-registry KYC + the chained Shahkar match. The @@ -23,6 +30,12 @@ type SubmitError = { key: 'national_id_mismatch' | 'shared_sim' | 'shahkar_misma * so `DocumentUpload` runs in local mode here. The auto-query note is honest — this check is performed. * The shared-SIM Shahkar failure surfaces as a clear, non-accusatory message; a national-ID mismatch on * its own step. + * + * Structured as three named steps rather than one column of controls: the screen asks for a number, a + * card photo and a selfie, and the old layout gave no clue that the selfie was the only *required* one + * of the three — the submit button simply stayed dead with the reason buried in a caption near the + * bottom. Each capture's completion is a real form field, so the requirement is declared where it + * applies instead of re-derived in the submit handler. */ export default function IdentitySubmitPage() { const t = useTranslations('verification'); @@ -31,25 +44,23 @@ export default function IdentitySubmitPage() { const { enqueueSnackbar } = useSnackbar(); const submitIdentity = useSubmitIdentity(); - const [nationalId, setNationalId] = useState(''); - const [idError, setIdError] = useState(false); - const [cardCaptured, setCardCaptured] = useState(false); - const [selfieCaptured, setSelfieCaptured] = useState(false); const [submitError, setSubmitError] = useState(null); + const form = useForm({ + mode: 'onTouched', + defaultValues: { nationalId: '', cardCaptured: false, selfieCaptured: false }, + }); + const { control, handleSubmit, setValue } = form; + const cardCaptured = useWatch({ control, name: 'cardCaptured' }); + const selfieCaptured = useWatch({ control, name: 'selfieCaptured' }); + // Local capture: the card/selfie feed the automated KYC (no stored document) — resolve immediately. const captureLocally = async (file: File) => ({ name: file.name }); - const canSubmit = isValidNationalId(nationalId) && selfieCaptured && !submitIdentity.isPending; - - const handleSubmit = () => { - const idValid = isValidNationalId(nationalId); - setIdError(!idValid); + const submit = (values: IdentityFormValues) => { setSubmitError(null); - if (!idValid || !selfieCaptured) return; - submitIdentity.mutate( - { nationalId, livenessCaptured: selfieCaptured }, + { nationalId: values.nationalId, livenessCaptured: values.selfieCaptured }, { onSuccess: (result: SubmitIdentityResult) => { if (result.identity.stepStatus === 'failed') { @@ -68,92 +79,126 @@ export default function IdentitySubmitPage() { }; return ( - - + + + - - - {t('identity_title')} - {t('identity_subtitle')} - - { - setNationalId(toEnglishDigits(event.target.value).replace(/\D/g, '').slice(0, NATIONAL_ID_LENGTH)); - if (idError) setIdError(false); - }} - error={idError} - helperText={idError ? t('national_id_invalid') : t('national_id_hint')} - slotProps={{ htmlInput: { dir: 'ltr', inputMode: 'numeric', style: { textAlign: 'start', letterSpacing: 2 } } }} - fullWidth - /> + + + name="nationalId" + label={t('national_id_label')} + transform={(raw) => toEnglishDigits(raw).replace(/\D/g, '').slice(0, NATIONAL_ID_LENGTH)} + rules={{ validate: (value) => isValidNationalId(String(value ?? '')) || t('national_id_invalid') }} + slotProps={{ htmlInput: { dir: 'ltr', inputMode: 'numeric', style: { textAlign: 'start', letterSpacing: 2 } } }} + fullWidth + /> + - - - setCardCaptured(true)} - /> - - - - - setSelfieCaptured(true)} - /> - - - - - - {t('auto_registry_note')} - - - - {submitError ? ( - - {t(`error_${submitError.key}`)} - - ) : null} - - {!cardCaptured ? ( - - {t('card_recommended')} - - ) : null} - - - : undefined} > - {submitIdentity.isPending ? t('identity_submitting') : t('identity_submit')} - - - {t('back_to_checklist')} - - - + + setValue('cardCaptured', true, { shouldValidate: true })} + /> + + + : } + > + + setValue('selfieCaptured', true, { shouldValidate: true })} + /> + + + + + + {t('auto_registry_note')} + + + + {submitError ? ( + + {t(`error_${submitError.key}`)} + + ) : null} + + {/* The one thing that still gates submit is the selfie, so it says so here rather than leaving + a dead button to be explained by a caption three sections up. */} + {!selfieCaptured ? ( + + {t('identity_needs_selfie')} + + ) : null} + + + + {submitIdentity.isPending ? t('identity_submitting') : t('identity_submit')} + + + {t('back_to_checklist')} + + + + + ); +} + +/** A section's "done" marker — the completion cue a wall of uploaders otherwise never gives. */ +function CapturedMark({ label }: { label: string }) { + return ( + + + + {label} + + + ); +} + +function RequiredMark({ label }: { label: string }) { + return ( + + {label} + ); } @@ -168,36 +213,33 @@ const CORNER_POSITIONS = [ /** * A cheap, dependency-free capture guide — a dashed frame (viewfinder corners for the card, an oval - * for the selfie) plus a static hint line, shown above the corresponding `DocumentUpload`. There is no - * live camera preview to overlay (the native camera app owns capture via the file input's `capture` - * attribute), so this illustrates *how to frame the shot* rather than tracking the actual photo. + * for the selfie), shown above the corresponding `DocumentUpload`. There is no live camera preview to + * overlay (the native camera app owns capture via the file input's `capture` attribute), so this + * illustrates *how to frame the shot* rather than tracking the actual photo. The hint line that used + * to sit under it now rides on the uploader itself, where the tap target is. */ function CaptureGuideFrame({ variant }: { variant: 'card' | 'selfie' }) { - const t = useTranslations('verification'); const isCard = variant === 'card'; return ( - - - {isCard - ? CORNER_POSITIONS.map((pos, index) => ( - - )) - : null} - - - {t(isCard ? 'capture_hint_card' : 'capture_hint_selfie')} - - + + {isCard + ? CORNER_POSITIONS.map((pos, index) => ( + + )) + : null} + ); } diff --git a/client/src/app/[locale]/(private-routes)/nurse/visits/[id]/NurseVisitNotesPanel.tsx b/client/src/app/[locale]/(private-routes)/nurse/visits/[id]/NurseVisitNotesPanel.tsx index f795fde..a5dd6f6 100644 --- a/client/src/app/[locale]/(private-routes)/nurse/visits/[id]/NurseVisitNotesPanel.tsx +++ b/client/src/app/[locale]/(private-routes)/nurse/visits/[id]/NurseVisitNotesPanel.tsx @@ -1,9 +1,9 @@ 'use client'; -import { useState } from 'react'; import { useLocale, useTranslations } from 'next-intl'; +import { FormProvider, useForm, useWatch } from 'react-hook-form'; import { useSnackbar } from 'notistack'; -import { Checkbox, FormControlLabel, Paper, Skeleton, Stack, TextField, Typography } from '@mui/material'; -import { AppButton, AppIcon, VisitNoteCard } from '@/components'; +import { Checkbox, FormControlLabel, Paper, Skeleton, Stack, Typography } from '@mui/material'; +import { AppButton, AppIcon, RhfControlGroup, RhfTextField, VisitNoteCard } from '@/components'; import { formatShamsiDate } from '@/utils'; import { useBookingDetail } from '@/services/bookings'; import { isBookingConfirmedOrBeyond } from '@/services/bookings/types'; @@ -11,6 +11,12 @@ import { useRecordAccess, usePatientCareRecord, usePatientHistory, useCreateVisi import { VISIT_NOTE_MAX_LENGTH } from '@/services/patientRecords/constants'; import type { TaskResult } from '@/services/patientRecords/types'; +interface VisitNoteFormValues { + note: string; + /** Task id → ticked. One field so a whole submitted checklist resets in a single `reset`. */ + checked: Record; +} + /** * E3 (نمای پرستار) — the nurse visit-note authoring, mounted **below** the f8 EVV banner on the nurse booking * detail. **Append-only:** the nurse ticks today's task checklist and writes a free-text note, then submits @@ -37,26 +43,22 @@ export default function NurseVisitNotesPanel({ bookingId }: { bookingId: number const history = usePatientHistory(patientId, 1, { enabled: engaged && patientId > 0 && canView }); const createNote = useCreateVisitNote(patientId); - const [note, setNote] = useState(''); - const [checked, setChecked] = useState>({}); + const form = useForm({ mode: 'onTouched', defaultValues: { note: '', checked: {} } }); + const { control, handleSubmit, reset } = form; + const note = useWatch({ control, name: 'note' }); if (!booking.data || !engaged) return null; const tasks = record.data?.tasks ?? []; - const submit = () => { - if (!note.trim()) { - enqueueSnackbar(t('note_required'), { variant: 'error' }); - return; - } - const taskResults: TaskResult[] = tasks.map((task) => ({ label: task.label, done: Boolean(checked[task.id]) })); + const submit = (values: VisitNoteFormValues) => { + const taskResults: TaskResult[] = tasks.map((task) => ({ label: task.label, done: Boolean(values.checked[task.id]) })); createNote.mutate( - { bookingId, body: note.trim(), taskResults }, + { bookingId, body: values.note.trim(), taskResults }, { onSuccess: () => { enqueueSnackbar(t('note_saved'), { variant: 'success' }); - setNote(''); - setChecked({}); + reset({ note: '', checked: {} }); }, onError: () => enqueueSnackbar(t('note_error'), { variant: 'error' }), }, @@ -72,59 +74,73 @@ export default function NurseVisitNotesPanel({ bookingId }: { bookingId: number elevation={0} sx={{ p: 2.5, borderRadius: 'var(--bal-radius-md)', border: '1px solid', borderColor: 'divider', borderTop: '3px solid var(--bal-secondary)' }} > - - - - - {t('notes_title')} - - - - {record.isLoading || tasks.length > 0 ? ( - - - {t('tasks_checklist_title')} + + + + + + {t('notes_title')} - {record.isLoading ? ( - - ) : ( - tasks.map((task) => ( - setChecked((c) => ({ ...c, [task.id]: e.target.checked }))} - /> - } - label={task.label} - /> - )) - )} - ) : null} - setNote(e.target.value.slice(0, VISIT_NOTE_MAX_LENGTH))} - multiline - minRows={3} - fullWidth - /> + {record.isLoading || tasks.length > 0 ? ( + + + {t('tasks_checklist_title')} + + {record.isLoading ? ( + + ) : ( + name="checked"> + {({ field }) => { + const ticked = (field.value as Record) ?? {}; + return ( + + {tasks.map((task) => ( + + field.onChange({ ...ticked, [task.id]: event.target.checked }) + } + /> + } + label={task.label} + /> + ))} + + ); + }} + + )} + + ) : null} - - {createNote.isPending ? tc('saving') : t('note_submit')} - - + + name="note" + label={t('note_label')} + placeholder={t('note_placeholder')} + transform={(raw) => raw.slice(0, VISIT_NOTE_MAX_LENGTH)} + rules={{ validate: (value) => String(value ?? '').trim().length > 0 || t('note_required') }} + multiline + minRows={3} + fullWidth + /> + + + {createNote.isPending ? tc('saving') : t('note_submit')} + + + ) : null} diff --git a/client/src/components/PatientForm/PatientForm.tsx b/client/src/components/PatientForm/PatientForm.tsx index 7d3aa74..f4e98cd 100644 --- a/client/src/components/PatientForm/PatientForm.tsx +++ b/client/src/components/PatientForm/PatientForm.tsx @@ -1,13 +1,11 @@ 'use client'; -import { FunctionComponent, useEffect, useState } from 'react'; +import { FunctionComponent, useEffect } from 'react'; import { useTranslations } from 'next-intl'; -import FormLabel from '@mui/material/FormLabel'; +import { useForm, FormProvider } from 'react-hook-form'; import Stack from '@mui/material/Stack'; -import TextField from '@mui/material/TextField'; -import Typography from '@mui/material/Typography'; import { AppButton } from '@/components/common'; +import { RhfChipSelect, RhfControlGroup, RhfTextField } from '@/components/common/form'; import GenderToggle from '@/components/GenderToggle'; -import ConditionChips from '@/components/ConditionChips'; import RelationSelect from '@/components/RelationSelect'; import { digitsOnly } from '@/utils'; import { CONDITION_CODES, RELATION_CODES } from '@/services/patients/constants'; @@ -15,6 +13,7 @@ import { ageToBirthDate, birthDateToAge } from '@/services/patients/age'; import type { ConditionCode, CreatePatientInput, Gender, Patient, Relation } from '@/services/patients/types'; const MAX_AGE = 120; +const MAX_AGE_DIGITS = 3; export interface PatientFormProps { /** Prefill for edit, or a relation carried from the onboarding relation step. */ @@ -30,12 +29,25 @@ export interface PatientFormProps { onDirtyChange?: (dirty: boolean) => void; } +interface PatientFormValues { + firstName: string; + lastName: string; + age: string; + gender: Gender | null; + conditions: string[]; + relation: Relation | null; +} + /** * The A4 patient form — first/last name, age, **required** gender, optional condition chips, and * (for E1) the relation. Reused for create and edit. Gender is required and never defaulted; age * maps to `birthDate` (never rendered back as a fabricated full date — display stays age-only). * `lastName` falls back to `firstName` only when left blank (the wire's `lastName` is required), * never guessed by splitting a single field. Strings come from the `onboarding` namespace. + * + * react-hook-form owns the values, which is what lets `onDirtyChange` report `formState.isDirty` + * instead of the hand-written six-way comparison against captured initial values it used to run in an + * effect on every keystroke. * @component PatientForm */ const PatientForm: FunctionComponent = ({ @@ -50,163 +62,120 @@ const PatientForm: FunctionComponent = ({ }) => { const t = useTranslations('onboarding'); - const initialFirstName = initial?.firstName ?? ''; - const initialLastName = initial?.lastName ?? ''; - const initialAge = (() => { - const age = birthDateToAge(initial?.birthDate); - return age == null ? '' : String(age); - })(); - const initialGender = initial?.gender ?? null; - const initialConditions = initial?.conditions ?? []; - const initialRelation = initial?.relation ?? null; + const initialAge = birthDateToAge(initial?.birthDate); - const [firstName, setFirstName] = useState(initialFirstName); - const [lastName, setLastName] = useState(initialLastName); - const [age, setAge] = useState(initialAge); - const [gender, setGender] = useState(initialGender); - const [conditions, setConditions] = useState(initialConditions); - const [relation, setRelation] = useState(initialRelation); - - const [nameError, setNameError] = useState(false); - const [ageError, setAgeError] = useState(false); - const [genderError, setGenderError] = useState(false); - - const conditionOptions = CONDITION_CODES.map((code) => ({ code, label: t(`condition_${code}`) })); - const relationOptions = RELATION_CODES.map((code) => ({ code, label: t(`relation_${code}`) })); + const form = useForm({ + mode: 'onTouched', + defaultValues: { + firstName: initial?.firstName ?? '', + lastName: initial?.lastName ?? '', + age: initialAge == null ? '' : String(initialAge), + gender: initial?.gender ?? null, + conditions: initial?.conditions ?? [], + relation: initial?.relation ?? null, + }, + }); + const { handleSubmit, formState } = form; + const { isDirty } = formState; useEffect(() => { - if (!onDirtyChange) return; - const dirty = - firstName !== initialFirstName || - lastName !== initialLastName || - age !== initialAge || - gender !== initialGender || - relation !== initialRelation || - conditions.length !== initialConditions.length || - conditions.some((code) => !initialConditions.includes(code as ConditionCode)); - onDirtyChange(dirty); - // eslint-disable-next-line react-hooks/exhaustive-deps -- initial* are derived from the `initial` prop once per mount (form is re-keyed by the caller on edit target change), not reactive state. - }, [firstName, lastName, age, gender, relation, conditions]); + onDirtyChange?.(isDirty); + }, [isDirty, onDirtyChange]); - const handleSubmit = () => { - const first = firstName.trim(); - const enteredLast = lastName.trim(); + const submit = (values: PatientFormValues) => { + const first = values.firstName.trim(); + const enteredLast = values.lastName.trim(); // The wire's `lastName` is a required string, so it falls back to the first name when left // blank — but `displayName` reflects only what was actually entered (never a duplicated name). - const last = enteredLast || first; - const ageNum = Number(digitsOnly(age)); - const nameInvalid = first.length === 0; - const ageInvalid = age.trim().length === 0 || !Number.isInteger(ageNum) || ageNum < 0 || ageNum > MAX_AGE; - const genderInvalid = gender == null; - - setNameError(nameInvalid); - setAgeError(ageInvalid); - setGenderError(genderInvalid); - if (nameInvalid || ageInvalid || genderInvalid) return; - onSubmit({ displayName: [first, enteredLast].filter(Boolean).join(' '), firstName: first, - lastName: last, - birthDate: ageToBirthDate(ageNum), - gender: gender as Gender, + lastName: enteredLast || first, + birthDate: ageToBirthDate(Number(digitsOnly(values.age))), + gender: values.gender as Gender, bloodType: null, initialMedicalNotes: null, - relation, - conditions: conditions as ConditionCode[], + relation: values.relation, + conditions: values.conditions as ConditionCode[], }); }; return ( - - - { - setFirstName(event.target.value); - if (nameError) setNameError(false); - }} - error={nameError} - helperText={nameError ? t('name_required') : undefined} - fullWidth - /> - setLastName(event.target.value)} - fullWidth - /> - - - { - setAge(digitsOnly(event.target.value).slice(0, 3)); - if (ageError) setAgeError(false); - }} - error={ageError} - helperText={ageError ? t('age_invalid') : undefined} - slotProps={{ htmlInput: { dir: 'ltr', inputMode: 'numeric', style: { textAlign: 'start' } } }} - sx={{ maxWidth: 160 }} - /> - - - {t('gender_label')} - { - setGender(next); - if (genderError) setGenderError(false); - }} - maleLabel={t('gender_male')} - femaleLabel={t('gender_female')} - error={genderError} - ariaLabel={t('gender_label')} - /> - {genderError ? ( - - {t('gender_required')} - - ) : null} - - - - {t('conditions_label')} - - {t('conditions_hint')} - - - - - {showRelation ? ( - - {t('relation_title')} - setRelation(code as Relation)} + + + + + name="firstName" + label={t('first_name_label')} + rules={{ validate: (value) => String(value ?? '').trim().length > 0 || t('name_required') }} + fullWidth /> + name="lastName" label={t('last_name_label')} fullWidth /> - ) : null} - - {onCancel ? ( - - {cancelLabel} - - ) : null} - + name="age" + label={t('age_label')} + transform={(raw) => digitsOnly(raw).slice(0, MAX_AGE_DIGITS)} + rules={{ + validate: (value) => { + const raw = String(value ?? '').trim(); + const parsed = Number(digitsOnly(raw)); + return (raw.length > 0 && Number.isInteger(parsed) && parsed >= 0 && parsed <= MAX_AGE) || t('age_invalid'); + }, + }} + slotProps={{ htmlInput: { dir: 'ltr', inputMode: 'numeric', style: { textAlign: 'start' } } }} + sx={{ maxWidth: 160 }} + /> + + + name="gender" + label={t('gender_label')} + rules={{ validate: (value) => value != null || t('gender_required') }} > - {submitLabel} - + {({ field, hasError }) => ( + + )} + + + + name="conditions" + label={t('conditions_label')} + hint={t('conditions_hint')} + options={CONDITION_CODES.map((code) => ({ code, label: t(`condition_${code}`) }))} + /> + + {showRelation ? ( + name="relation" label={t('relation_title')}> + {({ field }) => ( + ({ code, label: t(`relation_${code}`) }))} + value={field.value as Relation | null} + onChange={(code) => field.onChange(code as Relation)} + /> + )} + + ) : null} + + + {onCancel ? ( + + {cancelLabel} + + ) : null} + + {submitLabel} + + - + ); }; diff --git a/client/src/components/admin/RefundPanel.tsx b/client/src/components/admin/RefundPanel.tsx index 26518cc..2fbbda2 100644 --- a/client/src/components/admin/RefundPanel.tsx +++ b/client/src/components/admin/RefundPanel.tsx @@ -1,12 +1,14 @@ 'use client'; import { FunctionComponent, useState } from 'react'; import { useLocale, useTranslations } from 'next-intl'; +import { Controller, FormProvider, useForm, useWatch } from 'react-hook-form'; import { useSnackbar } from 'notistack'; import { Alert, Box, Chip, MenuItem, Skeleton, Stack, TextField, Typography } from '@mui/material'; import { formatNumber, formatShamsiDate } from '@/utils'; import type { AdminRefundResult, RefundChannel } from '@/services/refunds/types'; import { useApproveRefund, useInitiateRefund, useRefundPreview, useRejectRefund } from '@/services/refunds'; import AppButton from '../common/AppButton'; +import { RhfTextField } from '../common/form'; import PriceBreakdown, { type PriceBreakdownRow } from '../PriceBreakdown'; import ConfirmDialog from './ConfirmDialog'; @@ -16,6 +18,11 @@ export interface RefundPanelProps { onDone?: (result: AdminRefundResult) => void; } +interface RefundFormValues { + channel: RefundChannel | ''; + notes: string; +} + const CHANNELS: readonly RefundChannel[] = ['psp_card', 'bnpl_revert', 'manual']; /** @@ -36,20 +43,27 @@ const RefundPanel: FunctionComponent = ({ bookingId, ticketId, const approve = useApproveRefund(); const reject = useRejectRefund(); - const [channel, setChannel] = useState(''); - const [notes, setNotes] = useState(''); const [result, setResult] = useState(null); const [confirmOpen, setConfirmOpen] = useState(false); const [rejectOpen, setRejectOpen] = useState(false); const p = preview.data; + const form = useForm({ mode: 'onTouched', defaultValues: { channel: '', notes: '' } }); + const { control, getValues } = form; + const channel = useWatch({ control, name: 'channel' }); const effectiveChannel = (channel || p?.refundChannel) as RefundChannel | undefined; const busy = initiate.isPending || approve.isPending || reject.isPending; const doInitiate = () => { if (!p) return; initiate.mutate( - { bookingId, ticketId, refundChannel: effectiveChannel, reasonCategory: 'customer_request', reasonNotes: notes.trim() || undefined }, + { + bookingId, + ticketId, + refundChannel: effectiveChannel, + reasonCategory: 'customer_request', + reasonNotes: getValues('notes').trim() || undefined, + }, { onSuccess: (r) => { setResult(r); @@ -121,22 +135,34 @@ const RefundPanel: FunctionComponent = ({ bookingId, ticketId, {!activeResult ? ( - <> - setChannel(e.target.value as RefundChannel)} - sx={{ maxWidth: 260 }} - > - {CHANNELS.map((c) => ( - - {t(`channel_${c}`)} - - ))} - + + {/* Not `RhfTextField`: the stored value is "" until an admin overrides the channel, while the + select displays the server's resolved one — the initiate call must be able to tell + «unchanged» from «explicitly re-picked the same value». */} + ( + field.onChange(event.target.value as RefundChannel)} + onBlur={field.onBlur} + sx={{ maxWidth: 260 }} + > + {CHANNELS.map((c) => ( + + {t(`channel_${c}`)} + + ))} + + )} + /> {p.expectedCustomerRefundEta ? ( @@ -151,19 +177,12 @@ const RefundPanel: FunctionComponent = ({ bookingId, ticketId, ) : null} - setNotes(e.target.value)} - multiline - minRows={2} - /> + name="notes" size="small" label={t('refund_notes_ph')} multiline minRows={2} /> setConfirmOpen(true)} disabled={busy} sx={{ alignSelf: 'flex-start' }}> {t('refund_initiate')} - + ) : failed ? ( <> diff --git a/client/src/components/common/AccentCard/AccentCard.tsx b/client/src/components/common/AccentCard/AccentCard.tsx index e896d18..1da7f85 100644 --- a/client/src/components/common/AccentCard/AccentCard.tsx +++ b/client/src/components/common/AccentCard/AccentCard.tsx @@ -3,37 +3,30 @@ import SurfaceCard, { SurfaceCardProps } from '../SurfaceCard'; export type AccentTone = 'primary' | 'secondary' | 'success' | 'error' | 'warning' | 'info' | 'trust' | 'neutral'; -const ACCENT_WIDTH = '4px'; - -const TONE_VAR: Record = { - primary: 'var(--bal-primary)', - secondary: 'var(--bal-secondary)', - success: 'var(--bal-success)', - error: 'var(--bal-error)', - warning: 'var(--bal-warning)', - info: 'var(--bal-info)', - trust: 'var(--bal-trust)', - neutral: 'var(--bal-text-secondary)', -}; - export interface AccentCardProps extends SurfaceCardProps { - /** Semantic tone driving the `borderInlineStart` accent stripe — never a raw color. */ + /** Semantic tone of the panel — a state label, not a color instruction. */ tone: AccentTone; } /** - * `SurfaceCard` plus a `borderInlineStart` accent stripe at one standardized width (4px — this app - * previously drifted between 3px and 4px across `EarningsBalanceHeader`/`PayoutHistoryRow` vs - * `BankStatusPanel`/`DocumentUpload`). Logical property, so the stripe sits on the correct edge under - * RTL automatically. Used for stateful panels (bank ownership, upload state, a nurse's earnings header). + * A `SurfaceCard` for a **stateful** panel (bank ownership, upload state, a nurse's earnings header, + * the next-visit card, the activation tracker). + * + * It no longer paints a colored edge stripe. The stripe was a hard vertical rule on the card's + * inline-start edge, and under RTL a column of these read as a row of loose colored lines down the + * right-hand side of the screen rather than a stack of cards — insetting and rounding it softened + * the artefact without fixing the underlying problem, which was that the device drew the eye to a + * decoration instead of to the card's own content. State is carried by the things inside the card + * that actually say it: a `StatusChip`, a tinted icon, the copy. + * + * `tone` is kept because it is the semantic label callers already pass and it still reaches the DOM + * as `data-accent-tone` (used by tests and available to any future treatment). Keeping the component + * rather than collapsing every call site into `SurfaceCard` also keeps "this panel represents a + * state" stated in the code. * @component AccentCard */ -const AccentCard: FunctionComponent = ({ tone, sx, children, ...rest }) => ( - +const AccentCard: FunctionComponent = ({ tone, children, ...rest }) => ( + {children} ); diff --git a/client/src/components/common/StickyActionBar/StickyActionBar.test.tsx b/client/src/components/common/StickyActionBar/StickyActionBar.test.tsx index 90397f7..a22e577 100644 --- a/client/src/components/common/StickyActionBar/StickyActionBar.test.tsx +++ b/client/src/components/common/StickyActionBar/StickyActionBar.test.tsx @@ -14,6 +14,8 @@ describe(' component', () => { expect(screen.getByText('مشاهده ۱۲ پرستار')).toBeInTheDocument(); const bar = container.querySelector('[data-sticky-action-bar]'); expect(bar).toBeInTheDocument(); - expect(bar).toHaveStyle({ position: 'sticky', bottom: '0px' }); + // The offset is the frame's published chrome height, so the bar clears a pinned bottom nav; the + // `0px` fallback is what a chrome-free shell (and this bare render) resolves to. + expect(bar).toHaveStyle({ position: 'sticky', bottom: 'var(--bal-chrome-bottom, 0px)' }); }); }); diff --git a/client/src/components/common/StickyActionBar/StickyActionBar.tsx b/client/src/components/common/StickyActionBar/StickyActionBar.tsx index 3082a8e..cdcd964 100644 --- a/client/src/components/common/StickyActionBar/StickyActionBar.tsx +++ b/client/src/components/common/StickyActionBar/StickyActionBar.tsx @@ -8,9 +8,12 @@ export interface StickyActionBarProps { /** * A bottom-pinned action bar for a scrolling screen — the C1 live-count CTA and the C3 booking CTA. * Rendered as the last child of a page's scrolling content, `position: sticky` pins it to the bottom - * of the nearest scrolling ancestor (the shell's `main`), so on mobile it naturally sits directly above - * `BottomBar` (a separate flex sibling below `main`, which already owns the `env(safe-area-inset-bottom)` - * padding — this component does not re-implement it) and on desktop it sits at the viewport bottom. + * of the nearest scrolling ancestor (the shell's `main`). + * + * The offset comes from `--bal-chrome-bottom`, which `AppFrame` publishes on that scroll container: + * the bottom nav is pinned *over* the scrollport, so a bar at `bottom: 0` would sit behind it. The + * property already includes `env(safe-area-inset-bottom)` and resolves to `0px` in a chrome-free + * shell, so this component neither re-implements the safe area nor needs to know which shell it is in. * @component StickyActionBar */ const StickyActionBar: FunctionComponent = ({ children }) => ( @@ -18,7 +21,7 @@ const StickyActionBar: FunctionComponent = ({ children }) data-sticky-action-bar sx={{ position: 'sticky', - bottom: 0, + bottom: 'var(--bal-chrome-bottom, 0px)', zIndex: 1, mt: 2, pt: 1.5, diff --git a/client/src/components/common/form/FormSection.test.tsx b/client/src/components/common/form/FormSection.test.tsx new file mode 100644 index 0000000..085a7bd --- /dev/null +++ b/client/src/components/common/form/FormSection.test.tsx @@ -0,0 +1,35 @@ +import { render, screen } from '@testing-library/react'; +import { ThemeProvider } from '../../../theme'; +import FormSection from './FormSection'; + +function renderSection(props: Partial> = {}) { + return render( + + + + + , + ); +} + +describe(' component', () => { + it('names the group with a heading and renders its fields', () => { + renderSection({ description: 'Who you are' }); + expect(screen.getByRole('heading', { name: 'Identity' })).toBeInTheDocument(); + expect(screen.getByText('Who you are')).toBeInTheDocument(); + expect(screen.getByLabelText('national id')).toBeInTheDocument(); + }); + + it('marks a skippable group as optional instead of showing its status', () => { + // The whole point of the optional marker is that it wins the slot — a completion count on a + // group nobody has to fill in is noise. + renderSection({ optional: true, optionalLabel: 'Optional', status: 0 of 3 }); + expect(screen.getByText('Optional')).toBeInTheDocument(); + expect(screen.queryByText('0 of 3')).not.toBeInTheDocument(); + }); + + it('shows the status line on a required group', () => { + renderSection({ status: 2 of 3 }); + expect(screen.getByText('2 of 3')).toBeInTheDocument(); + }); +}); diff --git a/client/src/components/common/form/FormSection.tsx b/client/src/components/common/form/FormSection.tsx new file mode 100644 index 0000000..93e94ad --- /dev/null +++ b/client/src/components/common/form/FormSection.tsx @@ -0,0 +1,95 @@ +'use client'; +import { FunctionComponent, ReactNode } from 'react'; +import { Box, Stack, Typography } from '@mui/material'; +import AppIcon from '../AppIcon'; +import SurfaceCard from '../SurfaceCard'; + +export interface FormSectionProps { + /** Already-translated section heading — names one coherent group of fields. */ + title: string; + /** Already-translated one-line explanation of *why* this group is being asked for. */ + description?: string; + /** Registered `AppIcon` name for the leading glyph. */ + icon?: string; + /** + * Short status line rendered end-aligned in the header — a completion count, an "optional" marker, + * whatever tells the reader where they stand without opening the group. + */ + status?: ReactNode; + /** Renders the group as explicitly skippable. */ + optional?: boolean; + /** Already-translated «اختیاری» label; required when `optional` is set. */ + optionalLabel?: string; + children: ReactNode; +} + +/** + * One labelled group of fields inside a form. + * + * The problem it solves: this app's longer forms (nurse profile, credentials, the variant builder) + * were flat runs of ten-plus `TextField`s with nothing between them — no grouping, no explanation of + * what any stretch of fields was *for*, and no sense of how much was left. Everything looked equally + * important and equally mandatory, which is the worst possible shape for a form a nurse fills in once + * under pressure to get listed. + * + * A section is a `SurfaceCard` with a heading, a one-line rationale, and an optional status/optional + * marker. Grouping is the cheapest legibility win available: it turns "a wall of inputs" into "four + * questions", makes optional groups visibly skippable, and gives errors somewhere to be attributed to. + * Presentational — no state, caller-owned i18n. + * @component FormSection + */ +const FormSection: FunctionComponent = ({ + title, + description, + icon, + status, + optional = false, + optionalLabel, + children, +}) => ( + + + + {icon ? ( + + + ) : null} + + + + + {title} + + {optional && optionalLabel ? ( + + {optionalLabel} + + ) : ( + status + )} + + {description ? ( + + {description} + + ) : null} + + + + {children} + + +); + +export default FormSection; diff --git a/client/src/components/common/form/RhfChipSelect.test.tsx b/client/src/components/common/form/RhfChipSelect.test.tsx new file mode 100644 index 0000000..e0d113f --- /dev/null +++ b/client/src/components/common/form/RhfChipSelect.test.tsx @@ -0,0 +1,85 @@ +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { FormProvider, useForm } from 'react-hook-form'; +import { ThemeProvider } from '../../../theme'; +import RhfChipSelect from './RhfChipSelect'; + +const OPTIONS = [ + { code: 'icu', label: 'ICU' }, + { code: 'elderly', label: 'Elderly' }, +]; + +interface MultiValues { + specialties: string[]; +} +interface SingleValues { + relation: string | null; +} + +function MultiHarness({ onSubmit, initial = [] }: { onSubmit: (values: MultiValues) => void; initial?: string[] }) { + const form = useForm({ defaultValues: { specialties: initial } }); + return ( + + + name="specialties" label="Specialties" options={OPTIONS} allowCustomValues /> + + + + ); +} + +function SingleHarness({ onSubmit }: { onSubmit: (values: SingleValues) => void }) { + const form = useForm({ defaultValues: { relation: null } }); + return ( + +
+ name="relation" options={OPTIONS} multiple={false} /> + + +
+ ); +} + +describe(' component', () => { + it('toggles codes in and out of a multi-select field', async () => { + const user = userEvent.setup(); + const onSubmit = jest.fn(); + render( + + + , + ); + await user.click(screen.getByText('ICU')); + await user.click(screen.getByText('Elderly')); + await user.click(screen.getByText('ICU')); + await user.click(screen.getByText('Save')); + await waitFor(() => expect(onSubmit).toHaveBeenCalledWith({ specialties: ['elderly'] }, expect.anything())); + }); + + it('behaves like a radio group when single-select, clearing on a re-tap', async () => { + const user = userEvent.setup(); + const onSubmit = jest.fn(); + render( + + + , + ); + await user.click(screen.getByText('ICU')); + await user.click(screen.getByText('Elderly')); + await user.click(screen.getByText('Save')); + await waitFor(() => expect(onSubmit).toHaveBeenCalledWith({ relation: 'elderly' }, expect.anything())); + + await user.click(screen.getByText('Elderly')); + await user.click(screen.getByText('Save')); + await waitFor(() => expect(onSubmit).toHaveBeenLastCalledWith({ relation: null }, expect.anything())); + }); + + it('renders a stored value that is not in the option list, so it can never silently vanish', () => { + render( + + + , + ); + expect(screen.getByText('wound care')).toBeInTheDocument(); + }); +}); diff --git a/client/src/components/common/form/RhfChipSelect.tsx b/client/src/components/common/form/RhfChipSelect.tsx new file mode 100644 index 0000000..bc3ed9b --- /dev/null +++ b/client/src/components/common/form/RhfChipSelect.tsx @@ -0,0 +1,123 @@ +'use client'; +import { Box, Chip, FormHelperText, FormLabel, Stack, Typography } from '@mui/material'; +import { Controller, FieldValues, RegisterOptions, useFormContext } from 'react-hook-form'; +import type { RhfFieldProps } from './types'; + +export interface ChipOption { + /** Stable code stored in form state — never the label. */ + code: string; + /** Already-translated display label. */ + label: string; +} + +export type RhfChipSelectProps = RhfFieldProps & { + /** Already-translated group label. */ + label?: string; + /** Already-translated one-line hint under the label. */ + hint?: string; + options: ReadonlyArray; + /** Single-select behaves like a radio group: re-tapping the selected chip clears it. */ + multiple?: boolean; + disabled?: boolean; + /** + * Renders codes that aren't in `options` as removable chips (a free-text specialty the nurse added). + * Without it a stored custom value would silently vanish from the UI while staying in form state. + */ + allowCustomValues?: boolean; +}; + +/** + * A chip group bound to a react-hook-form field — `string[]` when `multiple`, `string | null` + * otherwise. + * + * The same "tap to toggle a code" chip row was hand-rolled with bespoke `sx` in the nurse profile, + * the credentials form and the variant builder, each with its own selected-state colors and its own + * `useState` array. One binding replaces all three, and selection participates in validation like any + * other field (a required group can simply carry a `rules.validate`). + * @component RhfChipSelect + */ +const RhfChipSelect = ({ + name, + control, + rules, + label, + hint, + options, + multiple = true, + disabled = false, + allowCustomValues = false, +}: RhfChipSelectProps) => { + const context = useFormContext(); + const resolvedControl = control ?? context?.control; + + return ( + } + render={({ field, fieldState }) => { + const selected: string[] = multiple + ? ((field.value as string[] | undefined) ?? []) + : field.value == null + ? [] + : [field.value as string]; + + const toggle = (code: string) => { + if (!multiple) { + field.onChange(selected.includes(code) ? null : code); + return; + } + field.onChange(selected.includes(code) ? selected.filter((item) => item !== code) : [...selected, code]); + }; + + const knownCodes = options.map((option) => option.code); + const customCodes = allowCustomValues ? selected.filter((code) => !knownCodes.includes(code)) : []; + + return ( + + {label ? {label} : null} + {hint ? ( + + {hint} + + ) : null} + + + {options.map((option) => { + const isSelected = selected.includes(option.code); + return ( + toggle(option.code)} + /> + ); + })} + {customCodes.map((code) => ( + toggle(code)} + /> + ))} + + + {fieldState.error?.message ? {fieldState.error.message} : null} + + ); + }} + /> + ); +}; + +export default RhfChipSelect; diff --git a/client/src/components/common/form/RhfControlGroup.test.tsx b/client/src/components/common/form/RhfControlGroup.test.tsx new file mode 100644 index 0000000..07048d9 --- /dev/null +++ b/client/src/components/common/form/RhfControlGroup.test.tsx @@ -0,0 +1,67 @@ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { FormProvider, useForm } from 'react-hook-form'; +import { ThemeProvider } from '../../../theme'; +import RhfControlGroup from './RhfControlGroup'; + +interface Values { + gender: string | null; +} + +function Harness({ onSubmit }: { onSubmit: (values: Values) => void }) { + const form = useForm({ mode: 'onTouched', defaultValues: { gender: null } }); + return ( + +
+ + name="gender" + label="Gender" + hint="Required for same-gender matching" + rules={{ validate: (value) => value != null || 'Pick one' }} + > + {({ field, hasError }) => ( + + )} + + + +
+ ); +} + +function renderGroup() { + const onSubmit = jest.fn(); + render( + + + , + ); + return { onSubmit }; +} + +describe(' component', () => { + it('gives a non-input control the same label + hint shell as a text field', () => { + renderGroup(); + expect(screen.getByText('Gender')).toBeInTheDocument(); + expect(screen.getByText('Required for same-gender matching')).toBeInTheDocument(); + }); + + it('surfaces the rule message and flags the control when validation fails', async () => { + const user = userEvent.setup(); + const { onSubmit } = renderGroup(); + await user.click(screen.getByText('Save')); + expect(await screen.findByText('Pick one')).toBeInTheDocument(); + expect(screen.getByText('Female')).toHaveAttribute('data-invalid', 'true'); + expect(onSubmit).not.toHaveBeenCalled(); + }); + + it('writes the control’s value into form state', async () => { + const user = userEvent.setup(); + const { onSubmit } = renderGroup(); + await user.click(screen.getByText('Female')); + await user.click(screen.getByText('Save')); + expect(onSubmit).toHaveBeenCalledWith({ gender: 'female' }, expect.anything()); + }); +}); diff --git a/client/src/components/common/form/RhfControlGroup.tsx b/client/src/components/common/form/RhfControlGroup.tsx new file mode 100644 index 0000000..b1a8c80 --- /dev/null +++ b/client/src/components/common/form/RhfControlGroup.tsx @@ -0,0 +1,61 @@ +'use client'; +import { ReactNode } from 'react'; +import { FormHelperText, FormLabel, Stack, Typography } from '@mui/material'; +import { Controller, ControllerRenderProps, FieldValues, RegisterOptions, useFormContext } from 'react-hook-form'; +import type { RhfFieldProps } from './types'; + +export type RhfControlGroupProps = RhfFieldProps & { + /** Already-translated group label. */ + label?: string; + /** Already-translated one-line hint under the label. */ + hint?: string; + /** + * Renders the actual control. `field` is react-hook-form's binding (`value`/`onChange`/`onBlur`); + * `hasError` lets a control that carries its own error styling (`GenderToggle`) reflect it. + */ + children: (args: { field: ControllerRenderProps; hasError: boolean }) => ReactNode; +}; + +/** + * Binds an arbitrary non-input control — `GenderToggle`, `RelationSelect`, `RatingInput`, a map-pin + * picker — to a react-hook-form field, and gives it the same label/hint/error-message shell the + * `TextField`-based wrappers get for free. + * + * Without it these controls kept their own `useState` plus a hand-rolled `xxxError` boolean and a + * bespoke error `` per screen, which is exactly the pattern that made "is this form + * valid?" un-answerable from one place. + * @component RhfControlGroup + */ +const RhfControlGroup = ({ + name, + control, + rules, + label, + hint, + children, +}: RhfControlGroupProps) => { + const context = useFormContext(); + const resolvedControl = control ?? context?.control; + + return ( + } + render={({ field, fieldState }) => ( + + {label ? {label} : null} + {hint ? ( + + {hint} + + ) : null} + {children({ field: field as ControllerRenderProps, hasError: Boolean(fieldState.error) })} + {fieldState.error?.message ? {fieldState.error.message} : null} + + )} + /> + ); +}; + +export default RhfControlGroup; diff --git a/client/src/components/common/form/RhfJalaliDateField.tsx b/client/src/components/common/form/RhfJalaliDateField.tsx new file mode 100644 index 0000000..80caa45 --- /dev/null +++ b/client/src/components/common/form/RhfJalaliDateField.tsx @@ -0,0 +1,45 @@ +'use client'; +import { Controller, FieldValues, RegisterOptions, useFormContext } from 'react-hook-form'; +import JalaliDateField, { JalaliDateFieldProps } from '../JalaliDateField'; +import type { RhfFieldProps } from './types'; + +export type RhfJalaliDateFieldProps = RhfFieldProps & + Omit & { + helperText?: string; + }; + +/** + * `JalaliDateField` bound to a react-hook-form field. Stores the wire ISO (Gregorian) `YYYY-MM-DD` + * string the picker emits, or `null` — the same shape every date on the wire uses. + * @component RhfJalaliDateField + */ +const RhfJalaliDateField = ({ + name, + control, + rules, + helperText, + ...fieldProps +}: RhfJalaliDateFieldProps) => { + const context = useFormContext(); + const resolvedControl = control ?? context?.control; + + return ( + } + render={({ field, fieldState }) => ( + + )} + /> + ); +}; + +export default RhfJalaliDateField; diff --git a/client/src/components/common/form/RhfTextField.test.tsx b/client/src/components/common/form/RhfTextField.test.tsx new file mode 100644 index 0000000..c8301ad --- /dev/null +++ b/client/src/components/common/form/RhfTextField.test.tsx @@ -0,0 +1,63 @@ +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { FormProvider, useForm } from 'react-hook-form'; +import { ThemeProvider } from '../../../theme'; +import RhfTextField from './RhfTextField'; + +interface Values { + years: string; +} + +function Harness({ onSubmit }: { onSubmit: (values: Values) => void }) { + const form = useForm({ mode: 'onTouched', defaultValues: { years: '' } }); + return ( + +
+ + name="years" + label="Years" + helperText="How long you have practised" + transform={(raw) => raw.replace(/\D/g, '').slice(0, 2)} + rules={{ validate: (value) => String(value ?? '').length > 0 || 'Years is required' }} + /> + + +
+ ); +} + +function renderField() { + const onSubmit = jest.fn(); + render( + + + , + ); + return { onSubmit }; +} + +describe(' component', () => { + it('reads its control off the enclosing FormProvider and shows the helper text', () => { + renderField(); + expect(screen.getByLabelText('Years')).toBeInTheDocument(); + expect(screen.getByText('How long you have practised')).toBeInTheDocument(); + }); + + it('normalizes keystrokes through `transform` before they reach form state', async () => { + const user = userEvent.setup(); + const { onSubmit } = renderField(); + await user.type(screen.getByLabelText('Years'), 'a1b2c3'); + await user.click(screen.getByText('Save')); + // Non-digits stripped and capped at two — the stored value, not just the displayed one. + await waitFor(() => expect(onSubmit).toHaveBeenCalledWith(expect.objectContaining({ years: '12' }), expect.anything())); + }); + + it('replaces the helper text with the field rule message on a failed submit', async () => { + const user = userEvent.setup(); + const { onSubmit } = renderField(); + await user.click(screen.getByText('Save')); + expect(await screen.findByText('Years is required')).toBeInTheDocument(); + expect(screen.queryByText('How long you have practised')).not.toBeInTheDocument(); + expect(onSubmit).not.toHaveBeenCalled(); + }); +}); diff --git a/client/src/components/common/form/RhfTextField.tsx b/client/src/components/common/form/RhfTextField.tsx new file mode 100644 index 0000000..f7d2cf3 --- /dev/null +++ b/client/src/components/common/form/RhfTextField.tsx @@ -0,0 +1,63 @@ +'use client'; +import { ReactNode } from 'react'; +import TextField, { TextFieldProps } from '@mui/material/TextField'; +import { Controller, FieldValues, RegisterOptions, useFormContext } from 'react-hook-form'; +import type { RhfFieldProps } from './types'; + +export type RhfTextFieldProps = RhfFieldProps & + Omit & { + /** + * Normalizes raw keystrokes before they reach form state — digit stripping, max length, locale + * digit folding. Applying it here (rather than in each screen's `onChange`) is what keeps the + * *stored* value canonical instead of merely the *displayed* one. + */ + transform?: (raw: string) => string; + /** Shown below the field whenever there is no validation error to show instead. */ + helperText?: ReactNode; + }; + +/** + * `TextField` bound to a react-hook-form field. + * + * Every form in this app used to hold one `useState` per input plus a parallel `useState` per error + * flag, which re-rendered the entire screen — including price previews, query-backed cards and + * uploaders — on every keystroke, and left validation scattered across ad-hoc `if` blocks in each + * submit handler. `Controller` subscribes only this field to its own value and error, so a keystroke + * re-renders one input; the rules travel with the field they validate. + * + * Reads `control` from `FormProvider` when it isn't passed explicitly, so a form only wires it once. + * @component RhfTextField + */ +const RhfTextField = ({ + name, + control, + rules, + transform, + helperText, + ...textFieldProps +}: RhfTextFieldProps) => { + const context = useFormContext(); + const resolvedControl = control ?? context?.control; + + return ( + } + render={({ field, fieldState }) => ( + field.onChange(transform ? transform(event.target.value) : event.target.value)} + onBlur={field.onBlur} + error={Boolean(fieldState.error)} + helperText={fieldState.error?.message ?? helperText} + /> + )} + /> + ); +}; + +export default RhfTextField; diff --git a/client/src/components/common/form/index.ts b/client/src/components/common/form/index.ts new file mode 100644 index 0000000..58c8eef --- /dev/null +++ b/client/src/components/common/form/index.ts @@ -0,0 +1,13 @@ +import FormSection from './FormSection'; +import RhfChipSelect from './RhfChipSelect'; +import RhfControlGroup from './RhfControlGroup'; +import RhfJalaliDateField from './RhfJalaliDateField'; +import RhfTextField from './RhfTextField'; + +export { FormSection, RhfChipSelect, RhfControlGroup, RhfJalaliDateField, RhfTextField }; +export type { FormSectionProps } from './FormSection'; +export type { ChipOption, RhfChipSelectProps } from './RhfChipSelect'; +export type { RhfControlGroupProps } from './RhfControlGroup'; +export type { RhfJalaliDateFieldProps } from './RhfJalaliDateField'; +export type { RhfTextFieldProps } from './RhfTextField'; +export type { RhfFieldProps } from './types'; diff --git a/client/src/components/common/form/types.ts b/client/src/components/common/form/types.ts new file mode 100644 index 0000000..8c5d28d --- /dev/null +++ b/client/src/components/common/form/types.ts @@ -0,0 +1,17 @@ +import type { Control, FieldPath, FieldValues, RegisterOptions } from 'react-hook-form'; + +/** + * What every `Rhf*` field wrapper in this folder takes to bind itself to one form field. + * + * `control` is optional on purpose: a form that wraps its subtree in `FormProvider` never has to + * thread it through, and one that doesn't (a small local form, a field rendered outside the + * provider) can still pass it explicitly. + */ +export interface RhfFieldProps { + /** Dot-path of the field in the form's value shape — type-checked against it. */ + name: FieldPath; + /** Falls back to the enclosing `FormProvider`'s control when omitted. */ + control?: Control; + /** Validation rules, colocated with the field they govern rather than in the submit handler. */ + rules?: Omit, 'valueAsNumber' | 'valueAsDate' | 'setValueAs' | 'disabled'>; +} diff --git a/client/src/components/common/index.tsx b/client/src/components/common/index.tsx index 5d14cb5..1e837b2 100644 --- a/client/src/components/common/index.tsx +++ b/client/src/components/common/index.tsx @@ -25,6 +25,8 @@ import FormDialogShell from './FormDialogShell'; import RouteFadeIn from './RouteFadeIn'; import NavHubList from './NavHubList'; +export * from './form'; + export { ErrorBoundary, AppAlert, diff --git a/client/src/components/geography/AddressForm.tsx b/client/src/components/geography/AddressForm.tsx index de16f08..326a452 100644 --- a/client/src/components/geography/AddressForm.tsx +++ b/client/src/components/geography/AddressForm.tsx @@ -1,11 +1,12 @@ 'use client'; -import { FunctionComponent, useEffect, useState } from 'react'; +import { FunctionComponent, useEffect } from 'react'; import { useTranslations } from 'next-intl'; +import { FormProvider, useForm, useWatch } from 'react-hook-form'; import FormControlLabel from '@mui/material/FormControlLabel'; import Stack from '@mui/material/Stack'; import Switch from '@mui/material/Switch'; -import TextField from '@mui/material/TextField'; import { AppButton } from '@/components/common'; +import { RhfControlGroup, RhfTextField } from '@/components/common/form'; import { cityCentroid } from '@/services/geography/constants'; import type { CreateAddressInput, LatLng } from '@/services/addresses/types'; import CascadingRegionSelect, { type CascadingRegionValue } from './CascadingRegionSelect'; @@ -32,6 +33,14 @@ export interface AddressFormProps { onDirtyChange?: (dirty: boolean) => void; } +interface AddressFormValues { + title: string; + region: CascadingRegionValue; + pin: LatLng | null; + addressLine: string; + isPrimary: boolean; +} + // Only prefill the region when we have the province — a city id without its province can't drive // the (per-province) city query, so the cascade starts fresh instead of showing a dead value. function initialRegion(initial?: AddressFormInitial): CascadingRegionValue { @@ -50,136 +59,121 @@ function initialPin(initial?: AddressFormInitial): LatLng | null { * title and street address + a "set as primary" toggle. Validation: **city required**, **pin * required** (surfaced inline), title + street required, **district optional**. Emits a * `CreateAddressInput` carrying the picked coordinates. Reused for create and edit. + * + * The region cascade and the map pin are composite values (`{province, city, district}` and a + * lat/lng pair), which is exactly why they belong in form state rather than beside it: as + * react-hook-form fields they carry their own required-rule and their own error, instead of the four + * parallel `xxxError` booleans the submit handler used to set by hand. * @component AddressForm */ const AddressForm: FunctionComponent = ({ initial, submitting = false, onSubmit, onCancel, onDirtyChange }) => { const t = useTranslations('address'); const tc = useTranslations('common'); - const initialTitle = initial?.title ?? ''; - const initialAddressLine = initial?.addressLine ?? ''; - const initialIsPrimary = initial?.isPrimary ?? false; - - const [title, setTitle] = useState(initialTitle); - const [region, setRegion] = useState(() => initialRegion(initial)); - const [addressLine, setAddressLine] = useState(initialAddressLine); - const [pin, setPin] = useState(() => initialPin(initial)); - const [isPrimary, setIsPrimary] = useState(initialIsPrimary); - - const [titleError, setTitleError] = useState(false); - const [cityError, setCityError] = useState(false); - const [lineError, setLineError] = useState(false); - const [pinError, setPinError] = useState(false); + const form = useForm({ + mode: 'onTouched', + defaultValues: { + title: initial?.title ?? '', + region: initialRegion(initial), + pin: initialPin(initial), + addressLine: initial?.addressLine ?? '', + isPrimary: initial?.isPrimary ?? false, + }, + }); + const { control, handleSubmit, formState } = form; + const { isDirty } = formState; + const region = useWatch({ control, name: 'region' }); useEffect(() => { - if (!onDirtyChange) return; - const initialPinValue = initialPin(initial); - const dirty = - title !== initialTitle || - addressLine !== initialAddressLine || - isPrimary !== initialIsPrimary || - region.provinceId !== (initial?.provinceId ?? null) || - region.cityId !== (initial?.cityId ?? null) || - region.districtId !== (initial?.districtId ?? null) || - pin?.latitude !== initialPinValue?.latitude || - pin?.longitude !== initialPinValue?.longitude; - onDirtyChange(dirty); - // eslint-disable-next-line react-hooks/exhaustive-deps -- `initial` is a stable prefill snapshot (the caller re-keys the form on edit-target change), not reactive state to track. - }, [title, addressLine, isPrimary, region, pin]); - - const handleSubmit = () => { - const titleInvalid = title.trim().length === 0; - const cityInvalid = region.cityId == null; - const lineInvalid = addressLine.trim().length === 0; - const pinInvalid = pin == null; - - setTitleError(titleInvalid); - setCityError(cityInvalid); - setLineError(lineInvalid); - setPinError(pinInvalid); - if (titleInvalid || cityInvalid || lineInvalid || pinInvalid) return; + onDirtyChange?.(isDirty); + }, [isDirty, onDirtyChange]); + const submit = (values: AddressFormValues) => { onSubmit({ - title: title.trim(), - provinceId: region.provinceId as number, - cityId: region.cityId as number, - districtId: region.districtId, - addressLine: addressLine.trim(), - latitude: pin!.latitude, - longitude: pin!.longitude, - isPrimary, + title: values.title.trim(), + provinceId: values.region.provinceId as number, + cityId: values.region.cityId as number, + districtId: values.region.districtId, + addressLine: values.addressLine.trim(), + latitude: (values.pin as LatLng).latitude, + longitude: (values.pin as LatLng).longitude, + isPrimary: values.isPrimary, }); }; return ( - - { - setTitle(event.target.value); - if (titleError) setTitleError(false); - }} - error={titleError} - helperText={titleError ? t('title_required') : undefined} - fullWidth - /> + + + + name="title" + label={t('title_label')} + placeholder={t('title_placeholder')} + rules={{ validate: (value) => String(value ?? '').trim().length > 0 || t('title_required') }} + fullWidth + /> - { - setRegion(next); - if (cityError && next.cityId != null) setCityError(false); - }} - cityError={cityError} - cityErrorText={t('city_required')} - /> + {/* Message-less rules on purpose: both controls below already render their own error copy, so + `RhfControlGroup` must flag the field without printing a second identical line. */} + + name="region" + rules={{ validate: (value) => (value as CascadingRegionValue)?.cityId != null }} + > + {({ field, hasError }) => ( + + )} +
- { - setPin(next); - if (pinError) setPinError(false); - }} - center={cityCentroid(region.cityId)} - helperText={t('map_hint')} - latLabel={t('map_lat')} - lngLabel={t('map_lng')} - error={pinError} - errorText={t('map_required')} - /> + name="pin" rules={{ validate: (value) => value != null }}> + {({ field, hasError }) => ( + + )} + - { - setAddressLine(event.target.value); - if (lineError) setLineError(false); - }} - error={lineError} - helperText={lineError ? t('line_required') : t('line_hint')} - multiline - minRows={2} - fullWidth - /> + + name="addressLine" + label={t('line_label')} + helperText={t('line_hint')} + rules={{ validate: (value) => String(value ?? '').trim().length > 0 || t('line_required') }} + multiline + minRows={2} + fullWidth + /> - setIsPrimary(event.target.checked)} />} - label={t('set_primary_toggle')} - /> + name="isPrimary"> + {({ field }) => ( + field.onChange(event.target.checked)} />} + label={t('set_primary_toggle')} + /> + )} + - - {onCancel ? ( - - {tc('cancel')} + + {onCancel ? ( + + {tc('cancel')} + + ) : null} + + {submitting ? tc('saving') : tc('save')} - ) : null} - - {submitting ? tc('saving') : tc('save')} - + -
+ ); }; diff --git a/client/src/components/messaging/ContactSupportDialog.test.tsx b/client/src/components/messaging/ContactSupportDialog.test.tsx index 9a08edb..c45e88b 100644 --- a/client/src/components/messaging/ContactSupportDialog.test.tsx +++ b/client/src/components/messaging/ContactSupportDialog.test.tsx @@ -1,4 +1,4 @@ -import { render, screen, fireEvent } from '@testing-library/react'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; const mockMutate = jest.fn(); const mockReset = jest.fn(); @@ -36,11 +36,12 @@ describe(' component', () => { mockMutate.mockClear(); }); - it('opens a ticket with the typed message on submit', () => { + it('opens a ticket with the typed message on submit', async () => { renderDialog(); fireEvent.change(screen.getByLabelText(/پیام/), { target: { value: 'سوال من درباره ویزیت' } }); fireEvent.click(screen.getByRole('button', { name: 'ارسال' })); - expect(mockMutate).toHaveBeenCalledTimes(1); + // react-hook-form validates before handing off, so the mutation fires a microtask later. + await waitFor(() => expect(mockMutate).toHaveBeenCalledTimes(1)); expect(mockMutate).toHaveBeenCalledWith( expect.objectContaining({ category: 'support', body: 'سوال من درباره ویزیت' }), expect.anything(), diff --git a/client/src/components/messaging/ContactSupportDialog.tsx b/client/src/components/messaging/ContactSupportDialog.tsx index 3a794fc..4d6acb8 100644 --- a/client/src/components/messaging/ContactSupportDialog.tsx +++ b/client/src/components/messaging/ContactSupportDialog.tsx @@ -6,16 +6,23 @@ import DialogContent from '@mui/material/DialogContent'; import DialogActions from '@mui/material/DialogActions'; import MenuItem from '@mui/material/MenuItem'; import Stack from '@mui/material/Stack'; -import TextField from '@mui/material/TextField'; import Typography from '@mui/material/Typography'; import { useLocale, useTranslations } from 'next-intl'; import { useRouter } from 'next/navigation'; +import { FormProvider, useForm, useWatch } from 'react-hook-form'; import AppButton from '@/components/common/AppButton'; import { AppIcon } from '@/components/common'; +import { RhfTextField } from '@/components/common/form'; import { ticketThreadPath } from '@/constants'; import { useOpenTicket } from '@/services/tickets'; import type { OpenTicketResult, TicketCategory } from '@/services/tickets/types'; +interface TicketDraft { + category: TicketCategory; + subject: string; + body: string; +} + export interface ContactSupportDialogProps { open: boolean; onClose: () => void; @@ -50,29 +57,30 @@ const ContactSupportDialog: FunctionComponent = ({ const router = useRouter(); const openTicket = useOpenTicket(); - const [category, setCategory] = useState(defaultCategory); - const [subject, setSubject] = useState(''); - const [body, setBody] = useState(''); const [created, setCreated] = useState(null); - - const reset = () => { - setSubject(''); - setBody(''); - setCreated(null); - setCategory(defaultCategory); - openTicket.reset(); - }; + const form = useForm({ + mode: 'onTouched', + defaultValues: { category: defaultCategory, subject: '', body: '' }, + }); + const { control, handleSubmit } = form; + const body = useWatch({ control, name: 'body' }); const handleClose = () => { - reset(); + form.reset({ category: defaultCategory, subject: '', body: '' }); + setCreated(null); + openTicket.reset(); onClose(); }; - const submit = () => { - const trimmed = body.trim(); - if (!trimmed || openTicket.isPending) return; + const submit = (draft: TicketDraft) => { + if (openTicket.isPending) return; openTicket.mutate( - { category, subject: subject.trim() || null, body: trimmed, bookingId: bookingId ?? null }, + { + category: draft.category, + subject: draft.subject.trim() || null, + body: draft.body.trim(), + bookingId: bookingId ?? null, + }, { onSuccess: (result) => setCreated(result) }, ); }; @@ -116,35 +124,24 @@ const ContactSupportDialog: FunctionComponent = ({ ) : ( - <> + {t('new_ticket_title')} - - setCategory(event.target.value as TicketCategory)} - fullWidth - size="small" - > + {/* A real
so Enter submits from any field; the footer button (outside DialogContent) + calls the same handler directly rather than relying on cross-element form association. */} + + name="category" select label={t('category_label')} fullWidth size="small"> {OPENABLE_CATEGORIES.map((code) => ( {t(`category_${code}`)} ))} - - setSubject(event.target.value)} - fullWidth - size="small" - /> - + name="subject" label={t('subject_label')} fullWidth size="small" /> + + name="body" label={t('message_label')} - value={body} - onChange={(event) => setBody(event.target.value)} + rules={{ validate: (value) => String(value ?? '').trim().length > 0 || t('message_required') }} fullWidth required multiline @@ -165,13 +162,13 @@ const ContactSupportDialog: FunctionComponent = ({ {openTicket.isPending ? t('submitting') : t('submit')} - + )} ); diff --git a/client/src/components/messaging/TicketConversationPanel.tsx b/client/src/components/messaging/TicketConversationPanel.tsx index 07e37bf..0e5f5a8 100644 --- a/client/src/components/messaging/TicketConversationPanel.tsx +++ b/client/src/components/messaging/TicketConversationPanel.tsx @@ -57,7 +57,8 @@ const TicketConversationPanel: FunctionComponent = = ({ ticketId {showNewMessagePill ? ( - + { if (next) setMode(next); }} aria-label={t('appearance')} - sx={{ '& .MuiToggleButton-root': { gap: 0.75, paddingBlock: 0.75, borderRadius: 'var(--bal-radius-sm)' } }} + sx={{ + gap: 1, + // Each segment is already individually rounded, so MUI's default treatment — segments + // butted edge to edge with the shared border collapsed via a negative inline margin — put + // two rounded corners flush against each other with no separation at all. Spacing them + // makes each one read as its own choice; the negative margin has to be undone explicitly, + // otherwise the gap is eaten by it. + '& .MuiToggleButtonGroup-grouped': { + marginInlineStart: 0, + gap: 0.75, + paddingBlock: 0.75, + border: '1px solid', + borderColor: 'divider', + borderRadius: 'var(--bal-radius-sm)', + }, + }} > {MODES.map((option) => ( diff --git a/client/src/layout/AppFrame.tsx b/client/src/layout/AppFrame.tsx index d060256..fcd6b40 100644 --- a/client/src/layout/AppFrame.tsx +++ b/client/src/layout/AppFrame.tsx @@ -1,7 +1,14 @@ -'use client'; import { FunctionComponent, ReactNode } from 'react'; import { Box, Stack } from '@mui/material'; -import { APP_FRAME_MAX_WIDTH } from './config'; +import { APP_FRAME_MAX_WIDTH, BOTTOM_NAV_HEIGHT, FLOATING_BAR_SX, TOP_CHROME_HEIGHT } from './config'; + +/** + * The gutter between a floating chrome bar and the frame edge, and the gap between it and the + * content it floats over. Header and footer are mirror images of each other, so they share both. + */ +const BAR_INLINE_GUTTER = 1.5; +const BAR_OUTER_GAP = '10px'; +const BAR_CONTENT_GAP = 0.5; interface AppFrameProps { /** Pinned to the top of the frame; never scrolls with the content. */ @@ -13,17 +20,25 @@ interface AppFrameProps { /** * The one device frame every shell renders inside — a centered, phone-width column on a canvas - * that absorbs whatever extra viewport there is. Three structural jobs, and it is the only place + * that absorbs whatever extra viewport there is. Four structural jobs, and it is the only place * any of them are solved: * * 1. **Width is capped everywhere.** No shell stretches a header, a nav bar, or a content column * across a desktop monitor; a wide window gets more canvas, not a wider app. - * 2. **The frame owns the scroll, not the document.** Header and footer are flex siblings of a - * single scrolling `
`, so the top bar needs no `position: fixed` and no page needs a - * matching top offset (the source of the old per-shell `pt: TOP_BAR_*` math). + * 2. **The frame owns the scroll, not the document.** A single scrolling `
` fills the frame; + * header and footer are pinned over it and reserve their own space through `
`'s padding, + * so no page needs a top offset of its own (the old per-shell `pt: TOP_BAR_*` math). * 3. **Horizontal scroll is structurally impossible.** `overflowX: hidden` + `minWidth: 0` on the * column means an over-wide child clips instead of dragging the whole app sideways; anything * genuinely wide (a data table) scrolls inside its own container. + * 4. **On a window wide enough to show it, the frame reads as one object.** Above `sm` it lifts + * off the canvas as a rounded, shadowed card with a gutter all round. On a phone it still fills + * the viewport edge to edge — there is no canvas to float on. + * + * The chrome is pinned with `position: absolute` against this frame rather than `position: fixed` + * against the viewport, which is the only way it can stay inside a centered, floating column — a + * viewport-fixed bar would break out and span the whole window. The frame never scrolls (only + * `
` does), so the two are visually identical on a phone, where the frame *is* the viewport. * @layout AppFrame */ const AppFrame: FunctionComponent = ({ header, footer, children }) => ( @@ -32,27 +47,49 @@ const AppFrame: FunctionComponent = ({ header, footer, children } height: '100dvh', display: 'flex', justifyContent: 'center', + // The gutter that turns the column into a floating card; zero on a phone, where the frame IS + // the viewport. `box-sizing: border-box` (globals.css) keeps the inner `height: 100%` honest. + p: { xs: 0, sm: 3 }, bgcolor: 'var(--bal-frame-canvas)', overflow: 'hidden', }} > both paint + // to the frame's edge and would otherwise square off its rounded corners. overflow: 'hidden', }} > + {/* The header is the bottom nav mirrored: the same inset, the same pill radius, the same + border and elevation, floating over the content rather than capping it. `pointerEvents` + hands the transparent gutter around it back to the content scrolling underneath. */} {header ? ( - - {header} + + {header} ) : null} @@ -64,6 +101,16 @@ const AppFrame: FunctionComponent = ({ header, footer, children } minWidth: 0, overflowY: 'auto', overflowX: 'hidden', + // How much of the scrollport each pinned bar covers, published as custom properties so a + // `position: sticky` element anywhere in the tree can clear the chrome without importing a + // constant or knowing which shell it is in. Both resolve to 0px in a chrome-free shell + // (FocusedLayout, PublicLayout), which is why sticky consumers can read them unconditionally. + '--bal-chrome-top': header ? `calc(${TOP_CHROME_HEIGHT}px + env(safe-area-inset-top))` : '0px', + '--bal-chrome-bottom': footer ? `calc(${BOTTOM_NAV_HEIGHT}px + env(safe-area-inset-bottom))` : '0px', + // Reserves the exact height of each pinned bar, so no screen ever has content hidden + // behind the header or the nav and no page has to know either bar exists. + paddingBlockStart: 'var(--bal-chrome-top)', + paddingBlockEnd: 'var(--bal-chrome-bottom)', // Keeps a rubber-band scroll at the end of a list from chaining out to the canvas. overscrollBehaviorY: 'contain', }} @@ -72,8 +119,11 @@ const AppFrame: FunctionComponent = ({ header, footer, children } {footer ? ( - - {footer} + + {footer} ) : null} diff --git a/client/src/layout/NurseLayout.tsx b/client/src/layout/NurseLayout.tsx index c29ef5c..37c6504 100644 --- a/client/src/layout/NurseLayout.tsx +++ b/client/src/layout/NurseLayout.tsx @@ -4,19 +4,30 @@ import { useTranslations } from 'next-intl'; import { NotificationBell } from '@/components/notifications'; import { ROUTES } from '@/constants'; import { LinkToPage } from '@/utils'; +import { useNurseRequestInbox } from '@/services/bookingRequests'; import { useSupportUnreadTotal } from '@/services/tickets'; import MobileShell from './MobileShell'; /** - * Nurse app shell — the "نمای پرستار" workspace. Four bottom-nav destinations, one per group the - * sidebar used to hide behind a hamburger: امروز (the operational home), حرفهٔ من, مالی, and a - * بیشتر hub carrying support, notifications and settings. The identity card that lived in the - * drawer header now opens from that hub, where it can be read rather than glanced at. + * Nurse app shell — the "نمای پرستار" workspace. Five bottom-nav destinations: امروز (the + * operational home), درخواست‌ها, حرفهٔ من, مالی, and a بیشتر hub carrying support, notifications and + * settings. + * + * **درخواست‌ها is a tab, not a link.** It was previously reachable only from a strip on the + * dashboard — which meant the one screen in the whole nurse app with a *deadline* on it (a pending + * request expires unanswered) lived one tab-plus-a-scroll away, and had no way to signal that + * something was waiting from anywhere else in the app. As a tab it carries the pending count as a + * badge, so the nurse sees the queue filling up from any screen. The inbox query is the same cached + * one the dashboard strip and the inbox page already read — no extra request. + * + * Identity lives in the «بیشتر» hub only — never in the top bar. One tap from any screen via the + * nav is close enough for a preference-and-account surface, and the header stays a title bar. * @layout NurseLayout */ const NurseLayout: FunctionComponent = ({ children }) => { const t = useTranslations('nav'); const supportUnreadTotal = useSupportUnreadTotal(); + const { data: pendingRequests } = useNurseRequestInbox(); const tabs: Array = useMemo( () => [ @@ -25,6 +36,12 @@ const NurseLayout: FunctionComponent = ({ children }) => { path: ROUTES.NURSE, icon: 'today', }, + { + title: t('requests'), + path: ROUTES.NURSE_REQUESTS, + icon: 'requests', + badgeCount: pendingRequests?.total || undefined, + }, { title: t('group_profession'), path: ROUTES.NURSE_PRACTICE, @@ -50,7 +67,7 @@ const NurseLayout: FunctionComponent = ({ children }) => { badgeCount: supportUnreadTotal ?? undefined, }, ], - [t, supportUnreadTotal] + [t, supportUnreadTotal, pendingRequests] ); return ( diff --git a/client/src/layout/components/BottomBar.test.tsx b/client/src/layout/components/BottomBar.test.tsx index c43d686..3aedd9b 100644 --- a/client/src/layout/components/BottomBar.test.tsx +++ b/client/src/layout/components/BottomBar.test.tsx @@ -29,9 +29,12 @@ function renderBar(at: string) { ); } -/** The single active tab, read off the aria-current the bar stamps on it. */ +/** + * The single active tab, read off the aria-current the bar stamps on it. The bar is icon-only, so + * the tab's name lives in its `aria-label` — which is exactly the thing a screen-reader user hears. + */ function activeTabName(): string | undefined { - return screen.queryByRole('button', { current: 'page' })?.textContent ?? undefined; + return screen.queryByRole('button', { current: 'page' })?.getAttribute('aria-label') ?? undefined; } describe(' component', () => { @@ -70,9 +73,16 @@ describe(' component', () => { expect(screen.getByText('2')).toBeInTheDocument(); }); + it('is icon-only but keeps every tab nameable', () => { + renderBar('/nurse'); + // The visible caption is gone; the accessible name is not. + expect(screen.getByRole('navigation')).not.toHaveTextContent('Practice'); + TABS.forEach((tab) => expect(screen.getByRole('button', { name: tab.title })).toBeInTheDocument()); + }); + it('floats as a rounded, elevated bar rather than an edge-to-edge slab', () => { renderBar('/nurse'); - const bar = screen.getByRole('navigation').firstElementChild as HTMLElement; + const bar = screen.getByRole('navigation'); expect(bar).toHaveStyle('border-radius: var(--bal-radius-pill)'); expect(bar).toHaveStyle('box-shadow: var(--bal-shadow-2)'); }); diff --git a/client/src/layout/components/BottomBar.tsx b/client/src/layout/components/BottomBar.tsx index 0a1f3da..015e013 100644 --- a/client/src/layout/components/BottomBar.tsx +++ b/client/src/layout/components/BottomBar.tsx @@ -1,29 +1,37 @@ 'use client'; import { FunctionComponent, useCallback, useMemo } from 'react'; -import { Badge, Box, ButtonBase, Stack, Typography } from '@mui/material'; +import { Badge, ButtonBase, Stack } from '@mui/material'; import { LinkToPage } from '@/utils'; import { AppIcon } from '@/components'; import { usePathname, useRouter } from '@/i18n/navigation'; +import { FLOATING_BAR_SX } from '../config'; import { matchActivePath } from '../matchActivePath'; interface Props { items: Array; } -const ICON_SIZE = 22; -/** The active pill behind the icon — wide enough to read as a target, short enough to stay a pill. */ -const PILL_WIDTH = 52; -const PILL_HEIGHT = 28; +const ICON_SIZE = 23; +/** Comfortable-touch size for an icon-only target (WCAG 2.5.8 asks for ≥24px; 44px is the iOS/Material ask). */ +const TAB_SIZE = 44; /** * The app's only navigation surface: a bottom tab bar carrying one destination per top-level * area. It replaced the drawer + hamburger outright — on a phone-width frame a drawer hides the * whole information architecture behind a tap and gives the top bar a job it doesn't need. * - * Each tab is a `ButtonBase` (not MUI's `BottomNavigation`) so the active state can be a pill - * that fills in behind the icon rather than a color-only swap: the shape change is what makes - * the selection legible at a glance. The transition reads through `--bal-motion-fast`, so the - * app-wide reduced-motion gate (globals.css) already collapses it — no local branch. + * **Icon-only.** The caption under each icon is gone: at five tabs inside a 480px frame the + * Persian labels («حرفهٔ من», «درخواست‌ها») were the widest thing in the bar and forced a two-line + * bar to carry one line of meaning. The icon set is already distinct, every tab keeps its label as + * `aria-label`/`title`, and the active tab is unambiguous from the filled pill — so the text was + * paying rent in the one place vertical space is scarcest. + * + * Each tab is a `ButtonBase` (not MUI's `BottomNavigation`) so the whole target IS the indicator: a + * fixed-size circle that hover, press, focus ring and the active fill all share, rather than a + * rectangular ripple squaring off against the rounded bar behind it. Circles are laid out with + * `space-around` instead of `flex: 1` so the target keeps one size whatever the tab count is. The + * transition reads through `--bal-motion-fast`, so the app-wide reduced-motion gate (globals.css) + * already collapses it. * * Locale-aware via the `@/i18n/navigation` wrapper (no manual `/${locale}` prefixing), with the * shared `matchActivePath` longest-prefix helper deciding the active tab, so a nested route @@ -58,88 +66,57 @@ const BottomBar: FunctionComponent = ({ items }) => { return ( // The bar floats: it is inset from the frame edges and fully rounded, so the page background // runs behind and around it instead of the bar sealing off the bottom of the screen with an - // edge-to-edge slab. It is still a flex sibling of the scrolling
(AppFrame), not an - // overlay — content is never hidden underneath it and no screen needs bottom padding for it. - by AppFrame, which reserves exactly + // `BOTTOM_NAV_HEIGHT` of bottom padding so no screen's content ever ends up underneath it. + - - {items.map((item) => { - const isActive = Boolean(item.path) && item.path === activePath; - return ( - onSelect(item)} - aria-current={isActive ? 'page' : undefined} - sx={{ - flex: 1, - minWidth: 0, - flexDirection: 'column', - gap: 0.25, - py: 0.25, - // Matches the bar's own shape so the ripple/focus ring never squares off a - // corner against the rounded container behind it. - borderRadius: 'var(--bal-radius-pill)', - }} + {items.map((item) => { + const isActive = Boolean(item.path) && item.path === activePath; + return ( + onSelect(item)} + aria-current={isActive ? 'page' : undefined} + aria-label={item.title} + title={item.title} + sx={{ + flexShrink: 0, + width: TAB_SIZE, + height: TAB_SIZE, + borderRadius: '50%', + bgcolor: isActive ? 'var(--bal-primary-soft)' : 'transparent', + transition: 'background-color var(--bal-motion-fast) var(--bal-easing-standard)', + '&:hover': { bgcolor: 'var(--bal-primary-soft)' }, + }} + > + - - - - - - - {item.title} - - - ); - })} - - + + + + ); + })} + ); }; diff --git a/client/src/layout/config.ts b/client/src/layout/config.ts index 9e6f226..5e4d6ce 100644 --- a/client/src/layout/config.ts +++ b/client/src/layout/config.ts @@ -15,7 +15,30 @@ export const APP_FRAME_MAX_WIDTH = 480; /** * TopBar configuration — one height at every viewport (the frame never changes width, so the - * old mobile/desktop split had nothing left to switch on). The bar sits *inside* the frame as a - * normal flex row rather than `position: fixed`, so no page needs a matching top offset. + * old mobile/desktop split had nothing left to switch on). */ export const TOP_BAR_HEIGHT = 56; + +/** + * Total vertical space the floating header occupies: the bar is `TOP_BAR_HEIGHT` (56) + a 1px border + * each side = 58, plus a 10px gap above it and a 4px gap below. Deliberately the same total as + * `BOTTOM_NAV_HEIGHT` — the two bars are the same object mirrored, so they must be the same size. + */ +export const TOP_CHROME_HEIGHT = 72; + +/** + * Total vertical space the floating bottom nav occupies, measured rather than guessed: the pill is + * `TAB_SIZE` (44) + its own 6px padding top and bottom + a 1px border each side = 58, plus the 4px + * gap above it and the 10px gap below. `AppFrame` reserves exactly this much padding under the + * scrolling content, so nothing is ever hidden behind the bar. Keep in sync with `BottomBar`. + */ +export const BOTTOM_NAV_HEIGHT = 72; + +/** Shared geometry of the two floating chrome bars — one definition, so they cannot drift apart. */ +export const FLOATING_BAR_SX = { + bgcolor: 'background.paper', + border: '1px solid', + borderColor: 'divider', + borderRadius: 'var(--bal-radius-pill)', + boxShadow: 'var(--bal-shadow-2)', +} as const; diff --git a/telegram-otp-bot/.env.example b/telegram-otp-bot/.env.example new file mode 100644 index 0000000..a215289 --- /dev/null +++ b/telegram-otp-bot/.env.example @@ -0,0 +1,29 @@ +# Copy to .env and fill in. Never commit .env. + +# From @BotFather — the full token, e.g. 1234567890:AAH.... +TELEGRAM_BOT_TOKEN= + +# Comma-separated Telegram chat ids that receive every OTP. +# Each of these users MUST have sent the bot at least one message first +# (Telegram forbids a bot from opening a conversation). +# Discover them with: GET http://localhost:5010/chat_ids (with the X-Api-Key header) +TELEGRAM_CHAT_IDS= + +# REQUIRED. Shared secret the caller must send as the `X-Api-Key` header. +# Minimum 16 chars; the process refuses to start without it. +# Generate one: node -e "console.log(require('crypto').randomBytes(24).toString('hex'))" +# The same value goes into the .NET side's Seams:Sms:Telegram:ApiKey (user-secrets, never committed). +API_KEY= + +# HTTP listener +PORT=5010 +HOST=127.0.0.1 + +# Set to `true` to keep the OTP code out of this process's stdout logs. +# The code is still delivered over Telegram either way. +REDACT_CODE_IN_LOGS=false + +# api.telegram.org is filtered in Iran. On Node 24+, these two make the built-in fetch +# use your local proxy; point HTTPS_PROXY at whatever your VPN/proxy client listens on. +# NODE_USE_ENV_PROXY=1 +# HTTPS_PROXY=http://127.0.0.1:10809 diff --git a/telegram-otp-bot/.gitignore b/telegram-otp-bot/.gitignore new file mode 100644 index 0000000..c3dd7ca --- /dev/null +++ b/telegram-otp-bot/.gitignore @@ -0,0 +1,4 @@ +.env +.env.local +node_modules/ +*.log diff --git a/telegram-otp-bot/INTEGRATION-PROMPT.md b/telegram-otp-bot/INTEGRATION-PROMPT.md new file mode 100644 index 0000000..9d286e6 --- /dev/null +++ b/telegram-otp-bot/INTEGRATION-PROMPT.md @@ -0,0 +1,153 @@ +# Prompt — wire the Telegram OTP relay into the .NET API + +> Paste everything below into a fresh Claude Code session opened at the repo root. + +--- + +Implement a **Development-only Telegram OTP delivery channel** on the server, behind the existing +`ISmsSender` seam. It must be **opt-in from `appsettings`** — an environment that does not configure it +behaves byte-for-byte as it does today. + +## Context you should read first + +- `server/CLAUDE.md` → the "External rails go real — config-selected vendor adapters" paragraph and the + "Startup wiring" section. +- `server/src/Core/Baya.Application/Contracts/Common/ISmsSender.cs` — the contract (`SendOtpAsync`, `SendAsync`). +- `server/src/Infrastructure/Baya.Infrastructure.CrossCutting/Seams/Real/KavenegarSmsSender.cs` — the shape + every real SMS adapter follows. Mirror it. +- `server/src/Infrastructure/Baya.Infrastructure.CrossCutting/Seams/SeamOptions.cs` — `SmsOptions` + `SeamProviders`. +- `server/src/Infrastructure/Baya.Infrastructure.CrossCutting/ServiceConfiguration/ServiceCollectionExtension.cs` + → the `RegisterSms` method — the config-selected registration pattern. +- `telegram-otp-bot/README.md` — the relay's HTTP contract (it already exists and runs; do not modify it). + +## What the relay already is + +A standalone zero-dependency Node service at `telegram-otp-bot/` (its own project — **not** part of +`client/` or `server/`). It forwards messages to a fixed list of Telegram chat ids. Contract: + +| Route | Auth | Request | Success | Failure | +| --- | --- | --- | --- | --- | +| `POST /send_otp` | `X-Api-Key` header | `{"phone":"...","code":"..."}` | `200` `{"ok":true,"delivered":[...],"failed":[...]}` | `502` when **no** recipient got it; `503` no recipients configured; `401` bad key; `400` bad body | +| `POST /send` | `X-Api-Key` header | `{"phone":"...","message":"..."}` | same | same | +| `GET /health` | none | — | `200` `{"ok":true}` | — | + +It is a **broadcast**, not per-user routing: every configured Telegram recipient receives every code, +regardless of which phone requested it. That is exactly why this is Development-only. + +## Requirements + +### 1. Config surface — opt-in, default off + +Add to `SmsOptions` (`SeamOptions.cs`) a nested `TelegramOptions Telegram { get; set; } = new();` with: + +- `BaseUrl` — the relay root, e.g. `http://127.0.0.1:5010`. +- `ApiKey` — **the shared secret sent as the `X-Api-Key` header. It is a secret**: the committed + `appsettings*.json` carries an empty string or the repo's `SET_VIA_USER_SECRETS_OR_ENV` placeholder, + never a real value. Real value via user-secrets / environment only. +- `TimeoutSeconds` — default `10`. + +Add `public const string Telegram = "telegram";` to `SeamProviders`. Document on the options class, in the +`SmsOptions` XML doc, and in the `SeamProviders` SMS-gateway group that `telegram` is a **Development +convenience channel, not an SMS gateway**. + +The feature is selected exactly like every other rail: + +```jsonc +// server/src/API/Baya.Web.Api/appsettings.Development.json +"Seams": { + "Sms": { + "Provider": "telegram", + "Telegram": { + "BaseUrl": "http://127.0.0.1:5010", + "ApiKey": "", // real value via user-secrets: Seams:Sms:Telegram:ApiKey + "TimeoutSeconds": 10 + } + } +} +``` + +**Default must stay `mock`.** Do not change the committed `Provider` value in any shared +`appsettings.json` — document the opt-in instead (see §5). An unconfigured environment must resolve +`LoggingSmsSender` exactly as it does now. + +### 2. The adapter + +New `TelegramSmsSender : ISmsSender` in +`server/src/Infrastructure/Baya.Infrastructure.CrossCutting/Seams/Real/`, following `KavenegarSmsSender`'s +shape — primary-constructor injection of `HttpClient` + `IOptions` + `ILogger`, +`System.Text.Json`, **no new NuGet package**. + +- `SendOtpAsync(phone, code, ct)` → `POST {BaseUrl}/send_otp` with `{"phone":…,"code":…}`. +- `SendAsync(phone, message, ct)` → `POST {BaseUrl}/send` with `{"phone":…,"message":…}`. +- Send `X-Api-Key: {ApiKey}` on every request. +- Snake-case JSON body keys (`phone`, `code`, `message`) — the relay reads exactly those names. +- **Non-2xx, or a body with `ok != true`, is a delivery failure** — log a warning and throw + `InvalidOperationException`, exactly as `KavenegarSmsSender` does, so `RequestOtpCommand` reports a real + send failure instead of silently "succeeding". A `502` from the relay means nobody received the code and + must not be swallowed. +- **Never log the OTP code.** Log the phone tail only — reuse Kavenegar's `Tail(phone)` helper approach. +- Guard against an unconfigured `ApiKey`: throw a clear startup/first-call error naming + `Seams:Sms:Telegram:ApiKey` rather than sending an unauthenticated request that the relay will 401. + +### 3. Registration + +In `ServiceCollectionExtension.RegisterSms`, add a branch **before** the `smsir`/`ghasedak` +`NotSupportedException`: + +```csharp +else if (Is(provider, SeamProviders.Telegram)) +{ + services.AddHttpClient(HttpClients.Telegram, c => { /* BaseAddress + Timeout from options */ }); + services.AddSingleton(sp => new TelegramSmsSender(...)); +} +``` + +Add the `HttpClients.Telegram` named-client constant alongside the existing ones. Follow the file's own +`Is(...)` / `BaseOrDefault(...)` / `Client(sp, name)` helpers — do not invent a parallel style. + +### 4. Keep the `/dev/last_otp` bridge working + +`Program.cs` currently disables the Development OTP-capture decorator whenever a non-mock SMS provider is +selected (`usingMockSms`), because a real gateway must never have the code logged/captured. **Telegram is +the exception**: it is a Development-only channel, and the `GET /api/v1/dev/last_otp/{phone}` helper and its +e2e tests should keep working alongside it. + +Widen that condition to allow the capture bridge for `mock` **or** `telegram` (keep it strictly disallowed +for `kavenegar` and any future real gateway), and update the explanatory comment above it to say why — +the current comment asserts the bridge is off for *every* non-mock provider, and that will become wrong. +Rename the local to something accurate (e.g. `otpCaptureAllowedProvider`). The `IsDevelopment()` guard stays. + +### 5. Docs — same change, non-negotiable + +- `server/CLAUDE.md` → the "External rails go real" paragraph: add `TelegramSmsSender` + (`Sms:Provider=telegram`) to the adapter list, flagged **Development-only, broadcast, not a gateway**, + and note that it is the one non-mock SMS provider that keeps the OTP-capture bridge enabled. +- `server/CLAUDE.md` → "Startup wiring": update the `AddDevelopmentOtpCapture()` comment to match the new + condition. +- `dev/post-phase/refinement/RUNBOOK.md` → a short "OTP over Telegram" subsection: start the relay + (`cd telegram-otp-bot && npm start`), set the same secret on both sides + (`dotnet user-secrets set "Seams:Sms:Telegram:ApiKey" ""`), flip `Seams:Sms:Provider` to + `telegram` in `appsettings.Development.json`, log in, read the code in Telegram. Link to + `telegram-otp-bot/README.md` for bot creation and chat-id discovery. + +### 6. Tests + +Add unit tests for `TelegramSmsSender` next to the existing seam tests (`Baya.Test.Foundation`), using a +stubbed `HttpMessageHandler` — no network: + +- `SendOtpAsync` posts to `/send_otp` with the right body **and** the `X-Api-Key` header. +- A `502` / `{"ok":false}` response throws. +- The OTP code never appears in the log output. +- Registration: `Provider = "mock"` (and an unset provider) still resolves `LoggingSmsSender`; `Provider = + "telegram"` resolves `TelegramSmsSender`. **This is the regression that matters** — the feature must be + invisible unless opted in. + +## Constraints + +- Server-side only. Do not touch `client/`. +- No handler changes — `RequestOtpCommand` depends on `ISmsSender` and must stay untouched. +- No new NuGet packages; versions are centrally pinned in `Directory.Packages.props` regardless. +- Follow `server/CONVENTIONS.md`: `sealed` classes, no unused usings/locals, *why*-comments only. +- Never commit the API key or the bot token. +- Finish with `dotnet build Baya.sln` (zero new warnings) and `dotnet test Baya.sln` (all green), and report + the actual output. diff --git a/telegram-otp-bot/README.md b/telegram-otp-bot/README.md new file mode 100644 index 0000000..dcb0ab9 --- /dev/null +++ b/telegram-otp-bot/README.md @@ -0,0 +1,125 @@ +# balinyaar-telegram-otp-bot + +A **standalone, dev-only** Telegram relay. It is not part of `client/` or `server/` — it is its own +tiny Node project with **zero dependencies** (Node 18+ built-ins only: `node:http` + global `fetch`). + +Its whole job: expose an HTTP endpoint that the .NET API calls, and forward the message to a fixed +list of Telegram chat ids. That replaces "read the OTP out of the server log" during manual testing — +you get the code on your phone instead, without paying an Iranian SMS gateway. + +> **Development only.** There is no per-user routing: *every* configured recipient receives *every* +> OTP, regardless of which phone number requested it. That is fine for a test group; it is not an SMS +> gateway. Do not point a real environment at this. + +--- + +## Setup + +1. **Create the bot.** Message [@BotFather](https://t.me/BotFather) → `/newbot` → follow the prompts → + copy the token (`1234567890:AAH...`). + +2. **Configure.** + ```bash + cd telegram-otp-bot + cp .env.example .env # PowerShell: Copy-Item .env.example .env + node -e "console.log(require('crypto').randomBytes(24).toString('hex'))" + ``` + Paste the token into `TELEGRAM_BOT_TOKEN` and the generated secret into `API_KEY` + (**required** — the process refuses to start without it). Leave `TELEGRAM_CHAT_IDS` empty for now. + +3. **Run it.** + ```bash + npm start + ``` + No `npm install` needed — there are no dependencies. On boot it prints the bot's `@username`, which + confirms the token works. + +4. **Discover the chat ids.** Each recipient opens the bot in Telegram and presses **Start** (a bot + cannot open a conversation — the user must message it first). Then: + ```bash + curl -H "X-Api-Key: $API_KEY" http://localhost:5010/chat_ids + ``` + Copy the returned `chat_id` values into `TELEGRAM_CHAT_IDS` (comma-separated) and restart. + +5. **Test it.** + ```bash + curl -X POST http://localhost:5010/send_otp \ + -H "X-Api-Key: $API_KEY" \ + -H 'content-type: application/json' \ + -d '{"phone":"09120000001","code":"123456"}' + ``` + +--- + +## HTTP API + +Base URL: `http://127.0.0.1:5010` (configurable via `HOST`/`PORT`). + +**Every route except `GET /health` requires the `X-Api-Key` header**, compared against `API_KEY` in +constant time. There is no way to disable it: `API_KEY` is mandatory (min 16 chars) and the process +exits at boot without one, so the relay is never reachable unauthenticated. A rejected request is +logged with the caller's address. + +| Route | Auth | Body | Purpose | +| --- | --- | --- | --- | +| `GET /health` | — | — | Liveness only; echoes no configuration. | +| `GET /chat_ids` | `X-Api-Key` | — | Chat ids that recently messaged the bot (setup helper). | +| `POST /send_otp` | `X-Api-Key` | `{ "phone": "...", "code": "..." }` | Broadcast a login code. | +| `POST /send` | `X-Api-Key` | `{ "phone": "...", "message": "..." }` | Broadcast a free-form transactional message. | + +The two POST routes mirror the server's `ISmsSender` (`SendOtpAsync` / `SendAsync`) one-for-one, so a +`TelegramSmsSender` adapter is a thin HTTP call per method. + +**Response** + +```json +{ "ok": true, "delivered": ["11111111"], "failed": [{ "chatId": "22222222", "error": "chat not found" }] } +``` + +- `200` — at least one recipient received it (partial delivery still counts; one reachable reader is + enough to complete a login). +- `502` — **no** recipient received it. The caller should treat this as a delivery failure so the OTP + command fails loudly rather than pretending an undeliverable code was sent. +- `503` — `TELEGRAM_CHAT_IDS` is empty. +- `400` / `401` — bad body / missing-or-wrong `X-Api-Key`. + +--- + +## Configuration + +| Variable | Default | Meaning | +| --- | --- | --- | +| `TELEGRAM_BOT_TOKEN` | — | **Required.** From @BotFather. Process exits without it. | +| `API_KEY` | — | **Required**, min 16 chars. Shared secret expected in `X-Api-Key`. Process exits without it. | +| `TELEGRAM_CHAT_IDS` | — | Comma-separated recipient chat ids. Empty ⇒ every send returns `503`. | +| `PORT` | `5010` | HTTP port. | +| `HOST` | `127.0.0.1` | Bind address. Keep it loopback unless the API runs on another machine. | +| `REDACT_CODE_IN_LOGS` | `false` | Keep the code out of *this process's* stdout (still delivered). | + +The API key is the only access control — there is no IP allow-list and no TLS. Keep `HOST` on +loopback when the API runs on the same machine; if you must expose it, put it behind something that +terminates TLS, or the key travels in clear text. + +Values come from `.env` (git-ignored) or from real environment variables, which take precedence. + +## Troubleshooting + +| Symptom | Cause | +| --- | --- | +| `chat not found` in `failed[]` | That user never messaged the bot. Press **Start** in Telegram. | +| `GET /chat_ids` returns nothing | No message in the last 24h, or a webhook is set on the bot (`getUpdates` returns nothing while a webhook is registered — call `deleteWebhook`). | +| `token check FAILED` / `fetch failed` | Wrong/revoked token, or no outbound access to `api.telegram.org` — see below. | +| `401` on every call | The caller isn't sending `X-Api-Key`, or its value differs from `API_KEY`. | + +### Reaching Telegram from Iran + +`api.telegram.org` is filtered, so the machine running this needs a proxy. On **Node 24+** the built-in +`fetch` honours the standard proxy variables once opted in — uncomment these in `.env`: + +``` +NODE_USE_ENV_PROXY=1 +HTTPS_PROXY=http://127.0.0.1:10809 +``` + +pointing `HTTPS_PROXY` at whatever your VPN/proxy client listens on. On older Node, run the process +under a system-wide/TUN-mode proxy instead — `fetch` there ignores the env vars. diff --git a/telegram-otp-bot/package.json b/telegram-otp-bot/package.json new file mode 100644 index 0000000..ab0b3cd --- /dev/null +++ b/telegram-otp-bot/package.json @@ -0,0 +1,14 @@ +{ + "name": "balinyaar-telegram-otp-bot", + "version": "1.0.0", + "private": true, + "description": "Standalone dev-only Telegram relay: exposes an HTTP endpoint the Balinyaar API calls to deliver OTPs to a fixed list of Telegram chat ids.", + "type": "module", + "main": "src/server.js", + "engines": { + "node": ">=18" + }, + "scripts": { + "start": "node src/server.js" + } +} diff --git a/telegram-otp-bot/src/env.js b/telegram-otp-bot/src/env.js new file mode 100644 index 0000000..09b821c --- /dev/null +++ b/telegram-otp-bot/src/env.js @@ -0,0 +1,70 @@ +import { readFileSync } from 'node:fs'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const projectRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..'); + +/** + * Loads `.env` into process.env without a dependency. Node's own `--env-file` needs 20.6+; + * parsing it here keeps the runbook a plain `npm start` on any Node 18+. + * Real environment variables always win, so a container/CI can override the file. + */ +function loadDotEnv() { + let raw; + try { + raw = readFileSync(resolve(projectRoot, '.env'), 'utf8'); + } catch { + return; + } + + for (const line of raw.split(/\r?\n/)) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith('#')) continue; + + const eq = trimmed.indexOf('='); + if (eq === -1) continue; + + const key = trimmed.slice(0, eq).trim(); + let value = trimmed.slice(eq + 1).trim(); + if (value.length >= 2 && ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'")))) { + value = value.slice(1, -1); + } + + if (!(key in process.env)) process.env[key] = value; + } +} + +loadDotEnv(); + +const botToken = (process.env.TELEGRAM_BOT_TOKEN ?? '').trim(); +if (!botToken) { + console.error('FATAL: TELEGRAM_BOT_TOKEN is not set. Copy .env.example to .env and paste the token from @BotFather.'); + process.exit(1); +} + +const chatIds = (process.env.TELEGRAM_CHAT_IDS ?? '') + .split(',') + .map((id) => id.trim()) + .filter(Boolean); + +// The API key is mandatory, with no opt-out: an OTP relay that anyone can POST to is an open +// message cannon pointed at the test group's phones. Fail to start rather than listen unprotected. +const apiKey = (process.env.API_KEY ?? '').trim(); +if (!apiKey) { + console.error('FATAL: API_KEY is not set. Choose any shared secret, put it in .env, and configure the same value on the caller.'); + process.exit(1); +} + +if (apiKey.length < 16) { + console.error(`FATAL: API_KEY is too short (${apiKey.length} chars). Use at least 16 — e.g. output of: node -e "console.log(require('crypto').randomBytes(24).toString('hex'))"`); + process.exit(1); +} + +export const config = { + botToken, + chatIds, + apiKey, + host: (process.env.HOST ?? '127.0.0.1').trim(), + port: Number(process.env.PORT ?? 5010), + redactCodeInLogs: (process.env.REDACT_CODE_IN_LOGS ?? 'false').toLowerCase() === 'true', +}; diff --git a/telegram-otp-bot/src/server.js b/telegram-otp-bot/src/server.js new file mode 100644 index 0000000..1ddb8c3 --- /dev/null +++ b/telegram-otp-bot/src/server.js @@ -0,0 +1,147 @@ +import { createServer } from 'node:http'; +import { timingSafeEqual } from 'node:crypto'; + +import { config } from './env.js'; +import { broadcast, discoverChatIds, getBotIdentity } from './telegram.js'; + +const MAX_BODY_BYTES = 16 * 1024; + +function json(res, status, payload) { + const body = JSON.stringify(payload); + res.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'content-length': Buffer.byteLength(body) }); + res.end(body); +} + +function readJsonBody(req) { + return new Promise((resolve, reject) => { + let size = 0; + const chunks = []; + + req.on('data', (chunk) => { + size += chunk.length; + if (size > MAX_BODY_BYTES) { + reject(new Error('request body too large')); + req.destroy(); + return; + } + chunks.push(chunk); + }); + + req.on('error', reject); + req.on('end', () => { + const raw = Buffer.concat(chunks).toString('utf8').trim(); + if (!raw) return resolve({}); + try { + resolve(JSON.parse(raw)); + } catch { + reject(new Error('request body is not valid JSON')); + } + }); + }); +} + +/** Constant-time `X-Api-Key` comparison. The key is always required — `config` guarantees one exists. */ +function isAuthorized(req) { + const supplied = req.headers['x-api-key']; + if (typeof supplied !== 'string') return false; + + const a = Buffer.from(supplied); + const b = Buffer.from(config.apiKey); + return a.length === b.length && timingSafeEqual(a, b); +} + +function otpMessage(phone, code) { + return ['🔐 Balinyaar — login code', '', `phone: ${phone}`, `code: ${code}`, '', new Date().toISOString()].join('\n'); +} + +function plainMessage(phone, message) { + return ['📩 Balinyaar', '', `phone: ${phone}`, '', message].join('\n'); +} + +async function deliver(res, text, logLine) { + if (config.chatIds.length === 0) { + console.error('refused: TELEGRAM_CHAT_IDS is empty — nowhere to deliver'); + return json(res, 503, { ok: false, error: 'no recipients configured (TELEGRAM_CHAT_IDS is empty)' }); + } + + const { delivered, failed } = await broadcast(text); + console.log(`${logLine} → delivered ${delivered.length}/${config.chatIds.length}`); + for (const f of failed) console.error(` ✗ ${f.chatId}: ${f.error}`); + + // Partial delivery still counts as sent — one reachable recipient is enough to read the code. + // Only a total failure is reported upstream, so the API's OTP command fails loudly instead of + // pretending an undeliverable code was sent. + const status = delivered.length > 0 ? 200 : 502; + return json(res, status, { ok: delivered.length > 0, delivered, failed }); +} + +const routes = { + // The only unauthenticated route, and it deliberately echoes no configuration — it exists so a + // caller can answer "is the relay up?" without holding the key. + 'GET /health': async (_req, res) => json(res, 200, { ok: true }), + + 'GET /chat_ids': async (_req, res) => { + const chats = await discoverChatIds(); + return json(res, 200, { + ok: true, + chats, + hint: chats.length + ? 'Copy the chat_id values into TELEGRAM_CHAT_IDS (comma-separated) and restart.' + : 'No recent messages. Open the bot in Telegram, press Start / send it any message, then call this again.', + }); + }, + + 'POST /send_otp': async (req, res) => { + const { phone, code } = await readJsonBody(req); + if (!phone || !code) return json(res, 400, { ok: false, error: '`phone` and `code` are required' }); + + const shown = config.redactCodeInLogs ? '******' : code; + return deliver(res, otpMessage(phone, code), `otp ${shown} for ${phone}`); + }, + + 'POST /send': async (req, res) => { + const { phone, message } = await readJsonBody(req); + if (!phone || !message) return json(res, 400, { ok: false, error: '`phone` and `message` are required' }); + + return deliver(res, plainMessage(phone, message), `message for ${phone}`); + }, +}; + +const server = createServer(async (req, res) => { + const path = new URL(req.url, 'http://localhost').pathname.replace(/\/+$/, '') || '/'; + const key = `${req.method} ${path}`; + const handler = routes[key]; + + if (!handler) { + return json(res, 404, { ok: false, error: `no route for ${key}`, routes: Object.keys(routes) }); + } + + if (key !== 'GET /health' && !isAuthorized(req)) { + console.error(`401 ${key} from ${req.socket.remoteAddress} — missing or invalid X-Api-Key`); + return json(res, 401, { ok: false, error: 'missing or invalid X-Api-Key' }); + } + + try { + await handler(req, res); + } catch (error) { + console.error(`${key} failed:`, error.message); + if (!res.headersSent) json(res, 500, { ok: false, error: error.message }); + } +}); + +server.listen(config.port, config.host, async () => { + console.log(`balinyaar telegram-otp-bot listening on http://${config.host}:${config.port}`); + console.log(` recipients: ${config.chatIds.length ? config.chatIds.join(', ') : '(none — call GET /chat_ids to discover)'}`); + console.log(' auth: X-Api-Key required on every route except GET /health'); + + try { + const me = await getBotIdentity(); + console.log(` bot: @${me.username} (${me.id})`); + } catch (error) { + console.error(` bot: token check FAILED — ${error.message}`); + } +}); + +for (const signal of ['SIGINT', 'SIGTERM']) { + process.on(signal, () => server.close(() => process.exit(0))); +} diff --git a/telegram-otp-bot/src/telegram.js b/telegram-otp-bot/src/telegram.js new file mode 100644 index 0000000..ea6634e --- /dev/null +++ b/telegram-otp-bot/src/telegram.js @@ -0,0 +1,76 @@ +import { config } from './env.js'; + +const API_BASE = `https://api.telegram.org/bot${config.botToken}`; + +async function callApi(method, payload, { timeoutMs = 10_000 } = {}) { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeoutMs); + + try { + const response = await fetch(`${API_BASE}/${method}`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(payload), + signal: controller.signal, + }); + + const body = await response.json().catch(() => ({})); + if (!response.ok || body.ok !== true) { + // Telegram carries the real failure in `description` (e.g. "chat not found" when the + // recipient never messaged the bot first) — surface it verbatim so the cause is obvious. + const reason = body.description ?? `HTTP ${response.status}`; + throw new Error(reason); + } + + return body.result; + } finally { + clearTimeout(timer); + } +} + +/** Sends one message to every configured chat id. Never rejects — per-recipient outcomes are returned. */ +export async function broadcast(text) { + const results = await Promise.all( + config.chatIds.map(async (chatId) => { + try { + await callApi('sendMessage', { chat_id: chatId, text, disable_notification: false }); + return { chatId, ok: true }; + } catch (error) { + return { chatId, ok: false, error: error.message }; + } + }), + ); + + return { + delivered: results.filter((r) => r.ok).map((r) => r.chatId), + failed: results.filter((r) => !r.ok).map(({ chatId, error }) => ({ chatId, error })), + }; +} + +/** + * Lists the chat ids that have messaged the bot recently, so a new recipient can be discovered + * without hunting through the Telegram API by hand. getUpdates only sees messages newer than the + * last 24h and returns nothing while a webhook is set. + */ +export async function discoverChatIds() { + const updates = await callApi('getUpdates', { limit: 100, timeout: 0 }); + const seen = new Map(); + + for (const update of updates) { + const chat = update.message?.chat ?? update.edited_message?.chat ?? update.channel_post?.chat; + if (!chat) continue; + + seen.set(String(chat.id), { + chat_id: String(chat.id), + type: chat.type, + username: chat.username ?? null, + title: chat.title ?? ([chat.first_name, chat.last_name].filter(Boolean).join(' ') || null), + }); + } + + return [...seen.values()]; +} + +export async function getBotIdentity() { + return callApi('getMe', {}); +}