manual improvement 2 & add telegram bot

This commit is contained in:
hamid
2026-07-27 23:58:16 +03:30
parent baa3cc63cd
commit e6a8f93a1e
57 changed files with 4181 additions and 2484 deletions
+73 -12
View File
@@ -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/ # <Money amountIrr size tone deduction hideUnit strikethrough> — 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/`<main>`/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 `<main>` with `position: absolute` — never `fixed`, which would break out of the centered column — and `<main>` 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 `<main>` 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<Values>({ 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 `<FormProvider {...form}>` 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 `<Stack component="form" noValidate onSubmit={handleSubmit(submit)}>` 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)
+24 -9
View File
@@ -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",
+24 -9
View File
@@ -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": "انصراف",
+17
View File
@@ -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",
+1
View File
@@ -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": {
@@ -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<CancelReasonCategory | ''>('');
const [reasonNotes, setReasonNotes] = useState('');
const [supportDialogCategory, setSupportDialogCategory] = useState<TicketCategory | null>(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<CancelFormValues>({
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 (
<FormProvider {...form}>
<Stack sx={{ gap: 3, maxWidth: 640, mx: 'auto', width: '100%', py: 2 }}>
<Typography variant="h5" component="h1" sx={{ fontWeight: 800 }}>
{t('cancel_title')}
@@ -158,13 +174,7 @@ export default function CancelBookingPage() {
<CancellationPolicyDisclosure preview={preview} />
<TextField
select
label={t('reason_field_label')}
value={reasonCategory}
onChange={(event) => setReasonCategory(event.target.value as CancelReasonCategory)}
fullWidth
>
<RhfTextField<CancelFormValues> name="reasonCategory" select label={t('reason_field_label')} fullWidth>
<MenuItem value="" disabled>
{t('reason_placeholder')}
</MenuItem>
@@ -173,19 +183,24 @@ export default function CancelBookingPage() {
{t(`reason_cat_${category}`)}
</MenuItem>
))}
</TextField>
<TextField
</RhfTextField>
<RhfTextField<CancelFormValues>
name="reasonNotes"
label={t('reason_notes_label')}
value={reasonNotes}
onChange={(event) => setReasonNotes(event.target.value)}
multiline
minRows={2}
fullWidth
/>
<FormControlLabel
control={<Checkbox checked={acknowledged} onChange={(event) => setAcknowledged(event.target.checked)} />}
label={t('acknowledge_label')}
/>
<RhfControlGroup<CancelFormValues> name="acknowledged">
{({ field }) => (
<FormControlLabel
control={
<Checkbox checked={Boolean(field.value)} onChange={(event) => field.onChange(event.target.checked)} />
}
label={t('acknowledge_label')}
/>
)}
</RhfControlGroup>
<Stack direction="row" sx={{ gap: 1, justifyContent: 'space-between', flexWrap: 'wrap' }}>
<AppButton variant="text" color="inherit" onClick={() => router.push(bookingHref)}>
@@ -252,5 +267,6 @@ export default function CancelBookingPage() {
</>
)}
</Stack>
</FormProvider>
);
}
@@ -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<ModerationStatus, StatusKind> = {
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<string[]>([]);
const form = useForm<ReviewFormValues>({ 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 (
<Stack sx={{ gap: 3, maxWidth: 560, mx: 'auto', width: '100%' }}>
<FormProvider {...form}>
<Stack component="form" noValidate onSubmit={handleSubmit(submit)} sx={{ gap: 3, maxWidth: 560, mx: 'auto', width: '100%' }}>
<PageHeading title={t('title')} subtitle={nurseName ? t('for_nurse', { name: nurseName }) : t('subtitle')} />
{booking ? <ReviewContextRecap booking={booking} locale={locale} /> : null}
@@ -156,44 +173,46 @@ export default function LeaveReviewPage() {
</Typography>
</Stack>
<Stack sx={{ gap: 1 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>
{t('rating_label')}
</Typography>
<RatingInput value={rating} onChange={setRating} ariaLabel={t('rating_label')} />
</Stack>
<RhfControlGroup<ReviewFormValues>
name="rating"
label={t('rating_label')}
rules={{ validate: (value) => Number(value ?? 0) >= 1 }}
>
{({ field }) => (
<RatingInput value={Number(field.value) || 0} onChange={field.onChange} ariaLabel={t('rating_label')} />
)}
</RhfControlGroup>
<TextField
<RhfTextField<ReviewFormValues>
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
/>
<Stack sx={{ gap: 1 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>
{t('tags_label')}
</Typography>
<ReviewTagSelector
codes={REVIEW_TAG_CODES}
selected={tagCodes}
onChange={setTagCodes}
labelFor={(code) => (t.has(`tag_${code}`) ? t(`tag_${code}`) : code)}
disabled={createReview.isPending}
/>
</Stack>
<RhfControlGroup<ReviewFormValues> name="tagCodes" label={t('tags_label')}>
{({ field }) => (
<ReviewTagSelector
codes={REVIEW_TAG_CODES}
selected={(field.value as string[]) ?? []}
onChange={field.onChange}
labelFor={(code) => (t.has(`tag_${code}`) ? t(`tag_${code}`) : code)}
disabled={createReview.isPending}
/>
)}
</RhfControlGroup>
<Stack direction="row" sx={{ gap: 1, justifyContent: 'flex-end' }}>
<AppButton variant="text" color="primary" onClick={() => router.back()} disabled={createReview.isPending}>
{tc('cancel')}
</AppButton>
<AppButton
type="submit"
variant="contained"
color="primary"
onClick={submit}
disabled={rating < 1 || createReview.isPending}
startIcon="star"
>
@@ -201,6 +220,7 @@ export default function LeaveReviewPage() {
</AppButton>
</Stack>
</Stack>
</FormProvider>
);
}
@@ -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<EligibilityStepProps> = ({
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<EligibilityFormValues>({ 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<EligibilityStepProps> = ({
body={t('eligibility_error')}
cardLabel={t('pay_with_card')}
onPayWithCard={onPayWithCard}
onRetry={handleSubmit}
onRetry={handleSubmit(submit)}
retryLabel={tc('retry')}
/>
);
}
return (
<Stack sx={{ gap: 2 }}>
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
{t('eligibility_title')}
</Typography>
<FormProvider {...form}>
<Stack component="form" noValidate onSubmit={handleSubmit(submit)} sx={{ gap: 2 }}>
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
{t('eligibility_title')}
</Typography>
<TextField
label={t('national_id_label')}
placeholder={t('national_id_placeholder')}
value={nationalId}
onChange={(e) => 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
/>
<RhfTextField<EligibilityFormValues>
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
/>
<PhoneNumberField
label={t('mobile_label')}
value={sessionMobile}
onChange={() => undefined}
slotProps={{ input: { readOnly: true } }}
fullWidth
/>
<PhoneNumberField
label={t('mobile_label')}
value={sessionMobile}
onChange={() => undefined}
slotProps={{ input: { readOnly: true } }}
fullWidth
/>
<FormControlLabel
control={<Checkbox checked={consent} onChange={(e) => setConsent(e.target.checked)} color="secondary" />}
label={
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('consent_label', { provider: providerName })}
</Typography>
}
sx={{ alignItems: 'flex-start', m: 0 }}
/>
<RhfControlGroup<EligibilityFormValues> name="consent">
{({ field }) => (
<FormControlLabel
control={
<Checkbox
checked={Boolean(field.value)}
onChange={(event) => field.onChange(event.target.checked)}
color="secondary"
/>
}
label={
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('consent_label', { provider: providerName })}
</Typography>
}
sx={{ alignItems: 'flex-start', m: 0 }}
/>
)}
</RhfControlGroup>
<Stack sx={{ gap: 1 }}>
<AppButton
color="secondary"
variant="contained"
size="large"
disabled={!consent || check.isPending}
onClick={handleSubmit}
startIcon={check.isPending ? <CircularProgress size={18} color="inherit" /> : undefined}
>
{check.isPending ? t('checking_eligibility') : t('check_eligibility')}
</AppButton>
<AppButton variant="text" color="primary" onClick={onPayWithCard}>
{t('pay_with_card')}
</AppButton>
<Stack sx={{ gap: 1 }}>
<AppButton
type="submit"
color="secondary"
variant="contained"
size="large"
disabled={!consent || check.isPending}
startIcon={check.isPending ? <CircularProgress size={18} color="inherit" /> : undefined}
>
{check.isPending ? t('checking_eligibility') : t('check_eligibility')}
</AppButton>
<AppButton variant="text" color="primary" onClick={onPayWithCard}>
{t('pay_with_card')}
</AppButton>
</Stack>
</Stack>
</Stack>
</FormProvider>
);
};
@@ -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 (
<EmptyState
icon="search"
title={t('missing_nurse_title')}
body={t('missing_nurse_body')}
action={
<AppButton
variant="contained"
color="primary"
onClick={() => router.push(`/${locale}${ROUTES.SEARCH}`)}
sx={{ m: 0 }}
>
{t('missing_nurse_cta')}
</AppButton>
}
/>
);
}
if (profileQuery.isLoading || patientsQuery.isLoading || addressesQuery.isLoading) return <FormSkeleton />;
return (
<RequestForm
nurseId={nurseId}
profile={profileQuery.data}
patients={patientsQuery.data?.items ?? []}
addresses={addressesQuery.data?.items ?? []}
carried={{
variantId: variantIdParam,
patientId: patientIdParam,
addressId: addressIdParam,
gender: genderParam === 'male' || genderParam === 'female' ? genderParam : null,
}}
/>
);
}
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<number | ''>(patientIdParam ?? '');
const [variantSel, setVariantSel] = useState<number | ''>(variantIdParam ?? '');
const [addressSel, setAddressSel] = useState<number | ''>(addressIdParam ?? '');
const [addressEditing, setAddressEditing] = useState(false);
const [gender, setGender] = useState<RequiredCaregiverGender | ''>(
genderParam === 'male' || genderParam === 'female' ? genderParam : '',
);
const [date, setDate] = useState('');
const [windowSel, setWindowSel] = useState<TimeWindowOption['key'] | 'custom' | null>(null);
const [timeStart, setTimeStart] = useState('');
const [timeEnd, setTimeEnd] = useState('');
const [notes, setNotes] = useState('');
const [touched, setTouched] = useState<Record<TouchedField, boolean>>({
patient: false,
service: false,
address: false,
date: false,
time: false,
gender: false,
});
const [pastDateError, setPastDateError] = useState(false);
const [formError, setFormError] = useState<string | null>(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<RequestFormValues>({
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 (
<EmptyState
icon="search"
title={t('missing_nurse_title')}
body={t('missing_nurse_body')}
action={
<AppButton
variant="contained"
color="primary"
onClick={() => router.push(`/${locale}${ROUTES.SEARCH}`)}
sx={{ m: 0 }}
>
{t('missing_nurse_cta')}
</AppButton>
}
/>
);
}
if (profileQuery.isLoading) return <FormSkeleton />;
const timeError = touched.time && timeStart !== '' && timeEnd !== '' && timeEnd <= timeStart;
const pastError = pastDateError;
return (
<Stack sx={{ gap: 3 }}>
{profile ? <NurseIdentityBar profile={profile} /> : null}
<FormProvider {...form}>
<Stack component="form" noValidate onSubmit={handleSubmit(submit)} sx={{ gap: 3 }}>
{profile ? <NurseIdentityBar profile={profile} /> : null}
<Box>
<Typography variant="h5" component="h1">
{t('request_title')}
</Typography>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('form_subtitle')}
</Typography>
</Box>
<Box>
<Typography variant="subtitle2" sx={{ fontWeight: 700, mb: 0.5 }}>
{t('whathappens_title')}
</Typography>
<StepperHeader steps={[t('step_submitted'), t('step_awaiting'), t('step_payment')]} activeStep={0} />
</Box>
{/* Patient */}
{patients.length === 0 ? (
<FieldEmpty
label={t('patient_label')}
message={t('patient_empty')}
ctaLabel={t('patient_add_cta')}
onCta={() => router.push(`/${locale}${ROUTES.PATIENTS}`)}
/>
) : (
<TextField
select
label={t('patient_label')}
value={patientId}
error={touched.patient && patientId === ''}
helperText={touched.patient && patientId === '' ? t('error_patient_required') : undefined}
onChange={(event) => setPatientId(Number(event.target.value))}
onBlur={() => markTouched('patient')}
fullWidth
>
<MenuItem value="" disabled>
{t('patient_placeholder')}
</MenuItem>
{patients.map((patient) => (
<MenuItem key={patient.id} value={patient.id}>
{patient.displayName}
</MenuItem>
))}
</TextField>
)}
{/* Service variant */}
<Stack sx={{ gap: 1 }}>
{services.length === 0 ? (
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('service_empty')}
<Box>
<Typography variant="h5" component="h1">
{t('request_title')}
</Typography>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('form_subtitle')}
</Typography>
</Box>
<Box>
<Typography variant="subtitle2" sx={{ fontWeight: 700, mb: 0.5 }}>
{t('whathappens_title')}
</Typography>
<StepperHeader steps={[t('step_submitted'), t('step_awaiting'), t('step_payment')]} activeStep={0} />
</Box>
{/* Patient */}
{patients.length === 0 ? (
<FieldEmpty
label={t('patient_label')}
message={t('patient_empty')}
ctaLabel={t('patient_add_cta')}
onCta={() => router.push(`/${locale}${ROUTES.PATIENTS}`)}
/>
) : (
<TextField
<RhfTextField<RequestFormValues>
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
>
<MenuItem value="" disabled>
{t('service_placeholder')}
{t('patient_placeholder')}
</MenuItem>
{services.map((service) => (
<MenuItem key={service.variantId} value={service.variantId}>
{service.displayName}
{patients.map((patient) => (
<MenuItem key={patient.id} value={patient.id}>
{patient.displayName}
</MenuItem>
))}
</TextField>
</RhfTextField>
)}
{selectedVariant ? (
<PriceDisplay
price={selectedVariant.priceIrr}
priceUnit={selectedVariant.priceUnit}
sessionCount={selectedVariant.sessionCount}
/>
) : null}
</Stack>
{/* Address — a compact confirmation row once resolved, with a way back to the select. */}
{addresses.length === 0 ? (
<FieldEmpty
label={t('address_label')}
message={t('address_empty')}
ctaLabel={t('address_add_cta')}
onCta={() => router.push(`/${locale}${ROUTES.ADDRESSES}`)}
/>
) : addressEditing || !selectedAddress ? (
<TextField
select
label={t('address_label')}
value={addressId}
error={touched.address && addressId === ''}
helperText={touched.address && addressId === '' ? t('error_address_required') : undefined}
onChange={(event) => {
setAddressSel(Number(event.target.value));
setAddressEditing(false);
}}
onBlur={() => markTouched('address')}
fullWidth
>
<MenuItem value="" disabled>
{t('address_placeholder')}
</MenuItem>
{addresses.map((address) => (
<MenuItem key={address.id} value={address.id}>
{address.title} · {locale === 'en' ? address.cityNameEn : address.cityNameFa}
</MenuItem>
))}
</TextField>
) : (
<Stack
direction="row"
sx={{
gap: 1.5,
alignItems: 'center',
p: 1.5,
border: '1px solid',
borderColor: 'divider',
borderRadius: 'var(--bal-radius-md)',
}}
>
<AppIcon icon="location" size={20} color="var(--bal-text-secondary)" />
<Stack sx={{ gap: 0.25, minWidth: 0, flexGrow: 1 }}>
<Typography variant="body2" sx={{ fontWeight: 500 }} noWrap>
{regionLabel(selectedAddress)}
{/* Service variant */}
<Stack sx={{ gap: 1 }}>
{services.length === 0 ? (
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('service_empty')}
</Typography>
{selectedAddress.addressLine ? (
<Typography variant="caption" sx={{ color: 'text.secondary' }} noWrap>
{selectedAddress.addressLine}
</Typography>
) : null}
</Stack>
<AppButton variant="text" size="small" onClick={() => setAddressEditing(true)} sx={{ flexShrink: 0 }}>
{t('address_change_cta')}
</AppButton>
</Stack>
)}
{/* Date */}
<Stack sx={{ gap: 1 }} onBlur={() => markTouched('date')}>
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>
{t('date_label')}
</Typography>
<JalaliDateIntentPicker
value={date}
onChange={(iso) => {
setDate(iso);
if (pastDateError) setPastDateError(false);
}}
min={todayIso()}
todayLabel={t('date_today')}
tomorrowLabel={t('date_tomorrow')}
pickOtherLabel={t('date_pick_other')}
/>
{pastError ? (
<Typography variant="caption" sx={{ color: 'var(--bal-error)' }}>
{t('error_past_date')}
</Typography>
) : touched.date && date === '' ? (
<Typography variant="caption" sx={{ color: 'var(--bal-error)' }}>
{t('error_date_required')}
</Typography>
) : null}
</Stack>
{/* Time window — presets kill the end<=start error class; «زمان دلخواه» reveals free time fields. */}
<Stack sx={{ gap: 1 }} onBlur={() => markTouched('time')}>
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>
{t('time_window_label')}
</Typography>
<Stack direction="row" sx={{ gap: 1, flexWrap: 'wrap' }}>
{TIME_WINDOWS.map((option) => (
<Chip
key={option.key}
clickable
label={t(`window_${option.key}`)}
onClick={() => selectWindow(option)}
color={windowSel === option.key ? 'primary' : undefined}
variant={windowSel === option.key ? 'filled' : 'outlined'}
data-window={option.key}
) : (
<RhfTextField<RequestFormValues>
name="variantId"
select
label={t('service_label')}
rules={{ validate: (value) => value !== '' || t('error_service_required') }}
fullWidth
>
<MenuItem value="" disabled>
{t('service_placeholder')}
</MenuItem>
{services.map((service) => (
<MenuItem key={service.variantId} value={service.variantId}>
{service.displayName}
</MenuItem>
))}
</RhfTextField>
)}
{selectedVariant ? (
<PriceDisplay
price={selectedVariant.priceIrr}
priceUnit={selectedVariant.priceUnit}
sessionCount={selectedVariant.sessionCount}
/>
))}
<Chip
clickable
label={t('window_custom')}
onClick={() => setWindowSel('custom')}
color={windowSel === 'custom' ? 'primary' : undefined}
variant={windowSel === 'custom' ? 'filled' : 'outlined'}
data-window="custom"
) : null}
</Stack>
{/* Address — a compact confirmation row once resolved, with a way back to the select. */}
{addresses.length === 0 ? (
<FieldEmpty
label={t('address_label')}
message={t('address_empty')}
ctaLabel={t('address_add_cta')}
onCta={() => router.push(`/${locale}${ROUTES.ADDRESSES}`)}
/>
</Stack>
{windowSel === 'custom' ? (
<Stack direction={{ xs: 'column', sm: 'row' }} sx={{ gap: 2 }}>
<TextField
type="time"
label={t('time_start_label')}
value={timeStart}
onChange={(event) => setTimeStart(event.target.value)}
slotProps={{ inputLabel: { shrink: true } }}
fullWidth
/>
<TextField
type="time"
label={t('time_end_label')}
value={timeEnd}
error={timeError}
helperText={timeError ? t('error_time_range') : undefined}
onChange={(event) => setTimeEnd(event.target.value)}
slotProps={{ inputLabel: { shrink: true } }}
fullWidth
/>
) : addressEditing || !selectedAddress ? (
<RhfTextField<RequestFormValues>
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
>
<MenuItem value="" disabled>
{t('address_placeholder')}
</MenuItem>
{addresses.map((address) => (
<MenuItem key={address.id} value={address.id}>
{address.title} · {locale === 'en' ? address.cityNameEn : address.cityNameFa}
</MenuItem>
))}
</RhfTextField>
) : (
<Stack
direction="row"
sx={{
gap: 1.5,
alignItems: 'center',
p: 1.5,
border: '1px solid',
borderColor: 'divider',
borderRadius: 'var(--bal-radius-md)',
}}
>
<AppIcon icon="location" size={20} color="var(--bal-text-secondary)" />
<Stack sx={{ gap: 0.25, minWidth: 0, flexGrow: 1 }}>
<Typography variant="body2" sx={{ fontWeight: 500 }} noWrap>
{regionLabel(selectedAddress)}
</Typography>
{selectedAddress.addressLine ? (
<Typography variant="caption" sx={{ color: 'text.secondary' }} noWrap>
{selectedAddress.addressLine}
</Typography>
) : null}
</Stack>
<AppButton variant="text" size="small" onClick={() => setAddressEditing(true)} sx={{ flexShrink: 0 }}>
{t('address_change_cta')}
</AppButton>
</Stack>
) : null}
{touched.time && (timeStart === '' || timeEnd === '') ? (
<Typography variant="caption" sx={{ color: 'var(--bal-error)' }}>
{t('error_time_required')}
</Typography>
) : null}
</Stack>
)}
{/* Caregiver gender — first-class, three-way, never silently defaulted */}
<Stack sx={{ gap: 1 }} onBlur={() => markTouched('gender')}>
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>
{t('gender_label')}
</Typography>
<ToggleButtonGroup
exclusive
color="primary"
value={gender || null}
onChange={(_event, next: RequiredCaregiverGender | null) => {
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. */}
<RhfControlGroup<RequestFormValues>
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) => (
<ToggleButton key={option} value={option} data-gender={option}>
{t(`gender_${option}`)}
</ToggleButton>
))}
</ToggleButtonGroup>
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{t('gender_hint')}
</Typography>
{touched.gender && gender === '' ? (
<Typography variant="caption" sx={{ color: 'var(--bal-error)' }}>
{t('error_gender_required')}
{({ field }) => (
<JalaliDateIntentPicker
value={(field.value as string) ?? ''}
onChange={field.onChange}
min={todayIso()}
todayLabel={t('date_today')}
tomorrowLabel={t('date_tomorrow')}
pickOtherLabel={t('date_pick_other')}
/>
)}
</RhfControlGroup>
{/* Time window — presets kill the end<=start error class; «زمان دلخواه» reveals free time fields. */}
<Stack sx={{ gap: 1 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>
{t('time_window_label')}
</Typography>
) : null}
<Stack direction="row" sx={{ gap: 1, flexWrap: 'wrap' }}>
{TIME_WINDOWS.map((option) => (
<Chip
key={option.key}
clickable
label={t(`window_${option.key}`)}
onClick={() => selectWindow(option)}
color={windowSel === option.key ? 'primary' : undefined}
variant={windowSel === option.key ? 'filled' : 'outlined'}
data-window={option.key}
/>
))}
<Chip
clickable
label={t('window_custom')}
onClick={() => setValue('window', 'custom', { shouldDirty: true })}
color={windowSel === 'custom' ? 'primary' : undefined}
variant={windowSel === 'custom' ? 'filled' : 'outlined'}
data-window="custom"
/>
</Stack>
{windowSel === 'custom' ? (
<Stack direction={{ xs: 'column', sm: 'row' }} sx={{ gap: 2 }}>
<RhfTextField<RequestFormValues>
name="timeStart"
type="time"
label={t('time_start_label')}
rules={{ validate: (value) => value !== '' || t('error_time_required') }}
slotProps={{ inputLabel: { shrink: true } }}
fullWidth
/>
<RhfTextField<RequestFormValues>
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
/>
</Stack>
) : null}
</Stack>
{/* Caregiver gender — first-class, three-way, never silently defaulted */}
<RhfControlGroup<RequestFormValues>
name="gender"
label={t('gender_label')}
hint={t('gender_hint')}
rules={{ validate: (value) => value !== '' || t('error_gender_required') }}
>
{({ field, hasError }) => (
<ToggleButtonGroup
exclusive
color="primary"
value={field.value || null}
onChange={(_event, next: RequiredCaregiverGender | null) => {
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) => (
<ToggleButton key={option} value={option} data-gender={option}>
{t(`gender_${option}`)}
</ToggleButton>
))}
</ToggleButtonGroup>
)}
</RhfControlGroup>
{genderMismatch ? (
<Typography variant="caption" sx={{ color: 'var(--bal-error)' }}>
{t('error_gender_mismatch')}
</Typography>
) : null}
</Stack>
{/* Stage-1 notes */}
<Stack sx={{ gap: 0.5 }}>
<TextField
label={t('notes_label')}
placeholder={t('notes_placeholder')}
value={notes}
onChange={(event) => setNotes(event.target.value.slice(0, CUSTOMER_NOTES_MAX_LENGTH))}
multiline
minRows={3}
fullWidth
helperText={t('notes_hint')}
/>
<Typography variant="caption" sx={{ color: 'text.secondary', textAlign: 'end' }}>
{t('notes_counter', { count: notes.length, max: CUSTOMER_NOTES_MAX_LENGTH })}
</Typography>
</Stack>
{/* Stage-1 notes */}
<Stack sx={{ gap: 0.5 }}>
<RhfTextField<RequestFormValues>
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
/>
<Typography variant="caption" sx={{ color: 'text.secondary', textAlign: 'end' }}>
{t('notes_counter', { count: notes.length, max: CUSTOMER_NOTES_MAX_LENGTH })}
</Typography>
</Stack>
{formError ? (
<Typography variant="body2" sx={{ color: 'var(--bal-error)' }}>
{formError}
</Typography>
) : null}
<Stack sx={{ gap: 1 }}>
<AppButton
color="primary"
variant="contained"
size="large"
startIcon="requests"
disabled={!requiredChosen || genderMismatch || createRequest.isPending}
onClick={handleSubmit}
sx={{ py: 1.5 }}
>
{createRequest.isPending ? t('submitting') : t('submit')}
</AppButton>
{!requiredChosen && missingFieldLabels.length > 0 ? (
<Typography variant="caption" sx={{ color: 'text.secondary', textAlign: 'center' }}>
{t('cta_missing_caption', { fields: missingFieldLabels.join(locale === 'fa' ? '، ' : ', ') })}
{formError ? (
<Typography variant="body2" sx={{ color: 'var(--bal-error)' }}>
{formError}
</Typography>
) : null}
<Stack sx={{ gap: 1 }}>
<AppButton
type="submit"
color="primary"
variant="contained"
size="large"
startIcon="requests"
disabled={!requiredChosen || genderMismatch || createRequest.isPending}
sx={{ py: 1.5 }}
>
{createRequest.isPending ? t('submitting') : t('submit')}
</AppButton>
{!requiredChosen ? (
<Typography variant="caption" sx={{ color: 'text.secondary', textAlign: 'center' }}>
{t('cta_missing_caption', { fields: missingFieldLabels.join(locale === 'fa' ? '، ' : ', ') })}
</Typography>
) : null}
</Stack>
</Stack>
</Stack>
</FormProvider>
);
}
@@ -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',
@@ -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 `<form>`, 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({
<AppButton variant="text" onClick={onCancel} disabled={saving}>
{tc('cancel')}
</AppButton>
<AppButton variant="contained" color="primary" onClick={onSave} disabled={saving || !canSave}>
<AppButton type="submit" variant="contained" color="primary" disabled={saving || !canSave}>
{saving ? tc('saving') : tc('save')}
</AppButton>
</Stack>
@@ -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<DoseUnit | ''>(initial?.doseUnit ?? '');
const [frequencyCode, setFrequencyCode] = useState<FrequencyPreset | null>(initial?.frequencyCode ?? null);
const [frequencyText, setFrequencyText] = useState(initial?.frequencyText ?? '');
const [timeOfDay, setTimeOfDay] = useState<TimeOfDayCode[]>(initial?.timeOfDay ?? []);
const [timingNote, setTimingNote] = useState(initial?.timingNote ?? '');
const form = useForm<MedicationFormValues>({
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 (
<Stack sx={{ gap: 2 }}>
<TextField label={t('med_name')} value={name} onChange={(e) => setName(e.target.value)} fullWidth required autoFocus />
<FormProvider {...form}>
<Stack component="form" noValidate onSubmit={handleSubmit(submit)} sx={{ gap: 2 }}>
<RhfTextField<MedicationFormValues> name="name" label={t('med_name')} fullWidth required autoFocus />
<Stack direction="row" sx={{ gap: 1.5 }}>
<TextField
label={t('med_dose_amount')}
value={doseAmount}
onChange={(e) => setDoseAmount(e.target.value)}
sx={{ flex: 1 }}
/>
<TextField select label={t('med_dose_unit')} value={doseUnit} onChange={(e) => setDoseUnit(e.target.value as DoseUnit)} sx={{ flex: 1 }}>
<MenuItem value="">{t('med_dose_unit_none')}</MenuItem>
{DOSE_UNITS.map((unit) => (
<MenuItem key={unit} value={unit}>
{t(`dose_unit_${unit}`)}
</MenuItem>
))}
</TextField>
</Stack>
<Stack sx={{ gap: 1 }}>
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{t('med_frequency')}
</Typography>
<Stack direction="row" sx={{ gap: 1, flexWrap: 'wrap' }}>
{FREQUENCY_PRESETS.map((preset) => (
<AppButton
key={preset}
variant={frequencyCode === preset ? 'contained' : 'outlined'}
color="primary"
size="small"
onClick={() => {
setFrequencyCode(frequencyCode === preset ? null : preset);
if (frequencyCode !== preset) setFrequencyText('');
}}
sx={{ borderRadius: '999px' }}
>
{t(`frequency_${preset}`)}
</AppButton>
))}
<Stack direction="row" sx={{ gap: 1.5 }}>
<RhfTextField<MedicationFormValues> name="doseAmount" label={t('med_dose_amount')} sx={{ flex: 1 }} />
<RhfTextField<MedicationFormValues> name="doseUnit" select label={t('med_dose_unit')} sx={{ flex: 1 }}>
<MenuItem value="">{t('med_dose_unit_none')}</MenuItem>
{DOSE_UNITS.map((unit) => (
<MenuItem key={unit} value={unit}>
{t(`dose_unit_${unit}`)}
</MenuItem>
))}
</RhfTextField>
</Stack>
{!frequencyCode ? (
<TextField
label={t('med_frequency_text')}
value={frequencyText}
onChange={(e) => setFrequencyText(e.target.value)}
fullWidth
size="small"
/>
) : null}
<Stack sx={{ gap: 1 }}>
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{t('med_frequency')}
</Typography>
<Stack direction="row" sx={{ gap: 1, flexWrap: 'wrap' }}>
{FREQUENCY_PRESETS.map((preset) => (
<AppButton
key={preset}
variant={frequencyCode === preset ? 'contained' : 'outlined'}
color="primary"
size="small"
onClick={() => {
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}`)}
</AppButton>
))}
</Stack>
{!frequencyCode ? (
<RhfTextField<MedicationFormValues>
name="frequencyText"
label={t('med_frequency_text')}
fullWidth
size="small"
/>
) : null}
</Stack>
<RhfControlGroup<MedicationFormValues> name="timeOfDay" hint={t('time_of_day')}>
{({ field }) => <TimeOfDayChipRow value={(field.value as TimeOfDayCode[]) ?? []} onChange={field.onChange} />}
</RhfControlGroup>
<RhfTextField<MedicationFormValues> name="timingNote" label={t('med_timing')} fullWidth size="small" />
<SheetActions onCancel={onCancel} onDelete={onDelete} saving={saving} canSave={name.trim().length > 0} />
</Stack>
<Stack sx={{ gap: 1 }}>
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{t('time_of_day')}
</Typography>
<TimeOfDayChipRow value={timeOfDay} onChange={setTimeOfDay} />
</Stack>
<TextField label={t('med_timing')} value={timingNote} onChange={(e) => setTimingNote(e.target.value)} fullWidth size="small" />
<SheetActions onCancel={onCancel} onSave={handleSave} onDelete={onDelete} saving={saving} canSave={canSave} />
</Stack>
</FormProvider>
);
}
@@ -652,42 +672,45 @@ function RoutineSheetBody({
onDirtyChange: (dirty: boolean) => void;
}) {
const t = useTranslations('records');
const [label, setLabel] = useState(initial?.label ?? '');
const [timeOfDay, setTimeOfDay] = useState<TimeOfDayCode[]>(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 (
<Stack sx={{ gap: 2 }}>
<TextField label={t('routine_label')} value={label} onChange={(e) => setLabel(e.target.value)} fullWidth required autoFocus />
<Stack sx={{ gap: 1 }}>
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{t('time_of_day')}
</Typography>
<TimeOfDayChipRow value={timeOfDay} onChange={setTimeOfDay} />
<FormProvider {...form}>
<Stack
component="form"
noValidate
onSubmit={handleSubmit((values) =>
onSave({
id: initial?.id ?? newTempId(),
label: values.label.trim(),
timeOfDay: values.timeOfDay,
note: values.note.trim() || null,
}),
)}
sx={{ gap: 2 }}
>
<RhfTextField name="label" label={t('routine_label')} fullWidth required autoFocus />
<RhfControlGroup name="timeOfDay" hint={t('time_of_day')}>
{({ field }) => <TimeOfDayChipRow value={(field.value as TimeOfDayCode[]) ?? []} onChange={field.onChange} />}
</RhfControlGroup>
<RhfTextField name="note" label={t('routine_note')} fullWidth size="small" />
<SheetActions onCancel={onCancel} onDelete={onDelete} saving={saving} canSave={label.trim().length > 0} />
</Stack>
<TextField label={t('routine_note')} value={note} onChange={(e) => setNote(e.target.value)} fullWidth size="small" />
<SheetActions onCancel={onCancel} onSave={handleSave} onDelete={onDelete} saving={saving} canSave={canSave} />
</Stack>
</FormProvider>
);
}
@@ -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 (
<Stack sx={{ gap: 2 }}>
<TextField label={t('task_label')} value={label} onChange={(e) => setLabel(e.target.value)} fullWidth required autoFocus />
<FormControlLabel control={<Checkbox checked={done} onChange={(e) => setDone(e.target.checked)} />} label={t('task_done')} />
<SheetActions
onCancel={onCancel}
onSave={() => onSave({ id: initial?.id ?? `new-${Date.now()}`, label: label.trim(), done })}
onDelete={onDelete}
saving={saving}
canSave={canSave}
/>
</Stack>
<FormProvider {...form}>
<Stack
component="form"
noValidate
onSubmit={handleSubmit((values) =>
onSave({ id: initial?.id ?? newTempId(), label: values.label.trim(), done: values.done }),
)}
sx={{ gap: 2 }}
>
<RhfTextField name="label" label={t('task_label')} fullWidth required autoFocus />
<RhfControlGroup name="done">
{({ field }) => (
<FormControlLabel
control={
<Checkbox checked={Boolean(field.value)} onChange={(event) => field.onChange(event.target.checked)} />
}
label={t('task_done')}
/>
)}
</RhfControlGroup>
<SheetActions onCancel={onCancel} onDelete={onDelete} saving={saving} canSave={label.trim().length > 0} />
</Stack>
</FormProvider>
);
}
@@ -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<AccountFormValues>({
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<Record<'firstName' | 'lastName' | 'preferredLanguage', string | null>>,
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 (
<FormProvider {...form}>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3, maxWidth: 520 }}>
<ProfileSummary displayName={displayName} phone={phone} initialsFallback={displayName || undefined} />
@@ -138,8 +144,8 @@ const AccountHub: FunctionComponent<{
<AccountRow icon="account" label={t('row_personal')} onClick={() => setPersonalSheetOpen(true)} />
<EmergencyContactCard
complete={emergencyComplete}
name={emergencyName}
phone={emergencyPhone}
name={watched.emergencyName ?? ''}
phone={watched.emergencyPhone ?? ''}
onEdit={() => setEmergencySheetOpen(true)}
/>
<AccountRow icon="location" label={t('row_addresses')} onClick={() => goTo(ROUTES.ADDRESSES)} />
@@ -172,8 +178,8 @@ const AccountHub: FunctionComponent<{
discardCancelLabel={cancelLabel}
>
<Stack sx={{ gap: 2.5 }}>
<TextField label={t('first_name')} value={firstName} onChange={(e) => setFirstName(e.target.value)} fullWidth />
<TextField label={t('last_name')} value={lastName} onChange={(e) => setLastName(e.target.value)} fullWidth />
<RhfTextField<AccountFormValues> name="firstName" label={t('first_name')} fullWidth />
<RhfTextField<AccountFormValues> name="lastName" label={t('last_name')} fullWidth />
<SheetActions onCancel={() => setPersonalSheetOpen(false)} onSave={savePersonal} saving={upsert.isPending} saveLabel={tc('save')} cancelLabel={cancelLabel} />
</Stack>
</FormDialogShell>
@@ -201,16 +207,10 @@ const AccountHub: FunctionComponent<{
<LocaleSwitcher />
</Stack>
<Divider />
<TextField
select
label={t('language')}
value={language}
onChange={(e) => setLanguage(e.target.value)}
helperText={t('language_hint')}
>
<RhfTextField<AccountFormValues> name="language" select label={t('language')} helperText={t('language_hint')}>
<MenuItem value="fa">{t('language_fa')}</MenuItem>
<MenuItem value="en">{t('language_en')}</MenuItem>
</TextField>
</RhfTextField>
<SheetActions onCancel={() => setLanguageSheetOpen(false)} onSave={saveLanguage} saving={upsert.isPending} saveLabel={tc('save')} cancelLabel={cancelLabel} />
</Stack>
</FormDialogShell>
@@ -231,27 +231,27 @@ const AccountHub: FunctionComponent<{
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('emergency_hint')}
</Typography>
<TextField
<RhfTextField<AccountFormValues>
name="emergencyName"
label={t('emergency_name')}
value={emergencyName}
onChange={(e) => {
setEmergencyName(e.target.value);
if (nameError) setNameError(false);
}}
error={nameError}
fullWidth
/>
<PhoneNumberField
label={t('emergency_phone')}
value={emergencyPhone}
onChange={(v) => {
setEmergencyPhone(v);
if (phoneError) setPhoneError(false);
}}
error={phoneError}
helperText={phoneError ? t('emergency_phone_invalid') : undefined}
rules={{ validate: (value) => String(value ?? '').trim().length > 0 }}
fullWidth
/>
<RhfControlGroup<AccountFormValues>
name="emergencyPhone"
rules={{ validate: (value) => isIranianMobile(String(value ?? '')) }}
>
{({ field, hasError }) => (
<PhoneNumberField
label={t('emergency_phone')}
value={(field.value as string) ?? ''}
onChange={field.onChange}
error={hasError}
helperText={hasError ? t('emergency_phone_invalid') : undefined}
fullWidth
/>
)}
</RhfControlGroup>
<SheetActions onCancel={() => setEmergencySheetOpen(false)} onSave={saveEmergency} saving={upsert.isPending} saveLabel={tc('save')} cancelLabel={cancelLabel} />
</Stack>
</FormDialogShell>
@@ -270,6 +270,7 @@ const AccountHub: FunctionComponent<{
}}
/>
</Box>
</FormProvider>
);
};
@@ -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<HolidayInput>(() => ({
holidayDate: holiday?.holidayDate?.slice(0, 10) ?? todayLocalIso(),
nameFa: holiday?.nameFa ?? '',
type: holiday?.type ?? 'official',
isBankClosed: holiday?.isBankClosed ?? true,
}));
const form = useForm<HolidayInput>({
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 (
<Dialog open onClose={upsert.isPending ? undefined : onClose} fullWidth maxWidth="xs">
<DialogTitle sx={{ fontWeight: 800 }}>{holiday ? t('hol_edit') : t('hol_add')}</DialogTitle>
<DialogContent>
<Stack sx={{ gap: 2, mt: 1 }}>
<JalaliDateField
label={t('hol_col_date')}
value={form.holidayDate || null}
onChange={(iso) => setForm((f) => ({ ...f, holidayDate: iso }))}
disabled={!!holiday}
/>
<TextField
label={t('hol_name_fa')}
value={form.nameFa}
onChange={(e) => setForm((f) => ({ ...f, nameFa: e.target.value }))}
/>
<TextField
select
label={t('hol_col_type')}
value={form.type}
onChange={(e) => setForm((f) => ({ ...f, type: e.target.value as HolidayType }))}
<FormProvider {...form}>
<DialogContent>
{/* A real <form> so Enter submits from any field; the footer button (outside DialogContent)
calls the same handler directly rather than relying on cross-element form association. */}
<Stack component="form" noValidate onSubmit={handleSubmit(onSave)} sx={{ gap: 2, mt: 1 }}>
<RhfJalaliDateField<HolidayInput>
name="holidayDate"
label={t('hol_col_date')}
rules={{ validate: (value) => String(value ?? '').length > 0 }}
disabled={!!holiday}
/>
<RhfTextField<HolidayInput>
name="nameFa"
label={t('hol_name_fa')}
rules={{ validate: (value) => String(value ?? '').trim().length > 0 }}
/>
<RhfTextField<HolidayInput> name="type" select label={t('hol_col_type')}>
{HOLIDAY_TYPES.map((ty) => (
<MenuItem key={ty} value={ty}>
{t(`htype_${ty}`)}
</MenuItem>
))}
</RhfTextField>
<RhfControlGroup<HolidayInput> name="isBankClosed">
{({ field }) => (
<FormControlLabel
control={
<Switch checked={Boolean(field.value)} onChange={(event) => field.onChange(event.target.checked)} />
}
label={t('hol_bank_hint')}
/>
)}
</RhfControlGroup>
</Stack>
</DialogContent>
<DialogActions sx={{ px: 3, pb: 2 }}>
<AppButton variant="text" color="inherit" onClick={onClose} disabled={upsert.isPending}>
{t('cancel')}
</AppButton>
<AppButton
variant="contained"
color="primary"
onClick={handleSubmit(onSave)}
disabled={!formState.isValid || upsert.isPending}
>
{HOLIDAY_TYPES.map((ty) => (
<MenuItem key={ty} value={ty}>
{t(`htype_${ty}`)}
</MenuItem>
))}
</TextField>
<FormControlLabel
control={<Switch checked={form.isBankClosed} onChange={(e) => setForm((f) => ({ ...f, isBankClosed: e.target.checked }))} />}
label={t('hol_bank_hint')}
/>
</Stack>
</DialogContent>
<DialogActions sx={{ px: 3, pb: 2 }}>
<AppButton variant="text" color="inherit" onClick={onClose} disabled={upsert.isPending}>
{t('cancel')}
</AppButton>
<AppButton variant="contained" color="primary" onClick={onSave} disabled={!valid || upsert.isPending}>
{upsert.isPending ? t('saving') : t('save')}
</AppButton>
</DialogActions>
{upsert.isPending ? t('saving') : t('save')}
</AppButton>
</DialogActions>
</FormProvider>
</Dialog>
);
}
@@ -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<CenterFormState>(() => initialForm(center));
const form = useForm<CenterFormState>({ 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 = <K extends keyof CenterFormState>(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 (
<Dialog open onClose={mutation.isPending ? undefined : onClose} fullWidth maxWidth="sm">
<DialogTitle sx={{ fontWeight: 800 }}>{isEdit ? t('partner_edit') : t('partner_create')}</DialogTitle>
<DialogContent>
<Stack sx={{ gap: 2, mt: 1 }}>
<TextField label={t('partner_name')} value={form.name} onChange={(e) => set('name', e.target.value)} />
<TextField
label={t('partner_legal_type')}
value={form.legalEntityType}
onChange={(e) => set('legalEntityType', e.target.value)}
/>
<TextField
label={t('partner_permit')}
value={form.mohEstablishmentPermitNo}
onChange={(e) => set('mohEstablishmentPermitNo', e.target.value)}
/>
<TextField
label={t('partner_tech_director_license')}
value={form.technicalDirectorLicenseNo}
onChange={(e) => set('technicalDirectorLicenseNo', e.target.value)}
/>
<TextField label={t('partner_enamad')} value={form.enamadCode} onChange={(e) => set('enamadCode', e.target.value)} />
<TextField
label={t('partner_iban')}
value={form.settlementIban}
onChange={(e) => set('settlementIban', e.target.value)}
helperText={t('partner_iban_write_hint')}
placeholder={center?.settlementIbanMasked ?? undefined}
slotProps={{ htmlInput: { dir: 'ltr' } }}
/>
<Box>
<FormControlLabel
control={<Switch checked={form.isMerchantOfRecord} onChange={(e) => set('isMerchantOfRecord', e.target.checked)} />}
label={t('partner_is_mor')}
<FormProvider {...form}>
<DialogContent>
{/* A real <form> so Enter submits from any field; the footer button (outside DialogContent)
calls the same handler directly rather than relying on cross-element form association. */}
<Stack component="form" noValidate onSubmit={handleSubmit(onSave)} sx={{ gap: 2, mt: 1 }}>
<RhfTextField<CenterFormState>
name="name"
label={t('partner_name')}
rules={{ validate: (value) => String(value ?? '').trim() !== '' }}
/>
<Typography variant="caption" sx={{ display: 'block', color: 'text.secondary' }}>
{t('partner_is_mor_hint')}
</Typography>
</Box>
<TextField
type="number"
label={t('partner_commission')}
value={form.commissionRate}
onChange={(e) => set('commissionRate', e.target.value)}
slotProps={{ htmlInput: { min: 0, max: 0.999, step: 0.01 } }}
/>
<UserPicker
value={displayedAdminUser}
onChange={(u) => 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')}
/>
</Stack>
</DialogContent>
<DialogActions sx={{ px: 3, pb: 2 }}>
<AppButton variant="text" color="inherit" onClick={onClose} disabled={mutation.isPending}>
{t('cancel')}
</AppButton>
<AppButton variant="contained" color="primary" onClick={onSave} disabled={!valid || mutation.isPending}>
{mutation.isPending ? t('saving') : t('save')}
</AppButton>
</DialogActions>
<RhfTextField<CenterFormState> name="legalEntityType" label={t('partner_legal_type')} />
<RhfTextField<CenterFormState>
name="mohEstablishmentPermitNo"
label={t('partner_permit')}
rules={{ validate: (value) => String(value ?? '').trim() !== '' }}
/>
<RhfTextField<CenterFormState>
name="technicalDirectorLicenseNo"
label={t('partner_tech_director_license')}
/>
<RhfTextField<CenterFormState> name="enamadCode" label={t('partner_enamad')} />
<RhfTextField<CenterFormState>
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' } }}
/>
<Box>
<RhfControlGroup<CenterFormState> name="isMerchantOfRecord">
{({ field }) => (
<FormControlLabel
control={
<Switch
checked={Boolean(field.value)}
onChange={(event) => field.onChange(event.target.checked)}
/>
}
label={t('partner_is_mor')}
/>
)}
</RhfControlGroup>
<Typography variant="caption" sx={{ display: 'block', color: 'text.secondary' }}>
{t('partner_is_mor_hint')}
</Typography>
</Box>
<RhfTextField<CenterFormState>
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 } }}
/>
<RhfControlGroup<CenterFormState> name="adminUser">
{({ field }) => (
<UserPicker
value={displayedAdminUser}
onChange={field.onChange}
label={t('partner_admin_user')}
placeholder={t('user_picker_search_ph')}
noOptionsText={t('user_picker_no_options')}
loadingText={t('user_picker_loading')}
/>
)}
</RhfControlGroup>
</Stack>
</DialogContent>
<DialogActions sx={{ px: 3, pb: 2 }}>
<AppButton variant="text" color="inherit" onClick={onClose} disabled={mutation.isPending}>
{t('cancel')}
</AppButton>
<AppButton
variant="contained"
color="primary"
onClick={handleSubmit(onSave)}
disabled={!formState.isValid || mutation.isPending}
>
{mutation.isPending ? t('saving') : t('save')}
</AppButton>
</DialogActions>
</FormProvider>
</Dialog>
);
}
@@ -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<CredentialDecisionValues>({
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 (
<Dialog open onClose={decide.isPending ? undefined : onClose} fullWidth maxWidth="sm">
<DialogTitle sx={{ fontWeight: 800 }}>{t('ver_credential_title')}</DialogTitle>
<DialogContent>
<Stack sx={{ gap: 2, mt: 1 }}>
<TextField
fullWidth
autoFocus
label={t('ver_credential_number')}
value={credentialNumber}
onChange={(e) => setCredentialNumber(e.target.value)}
/>
<TextField
fullWidth
label={t('ver_holder_name')}
helperText={t('ver_holder_hint')}
value={holderName}
onChange={(e) => setHolderName(e.target.value)}
/>
<TextField
fullWidth
label={t('ver_issuing_authority')}
value={issuingAuthority}
onChange={(e) => setIssuingAuthority(e.target.value)}
/>
<JalaliDateField fullWidth label={t('ver_issued_at')} value={issuedAt || null} onChange={setIssuedAt} />
<JalaliDateField
fullWidth
label={t('ver_expires_at')}
value={expiresAt || null}
onChange={setExpiresAt}
required={expiryRequired}
error={expiryMissing}
helperText={expiryMissing ? t('ver_expiry_required') : undefined}
/>
</Stack>
</DialogContent>
<DialogActions sx={{ px: 3, pb: 2 }}>
<AppButton variant="text" color="inherit" onClick={onClose} disabled={decide.isPending}>
{t('cancel')}
</AppButton>
<AppButton
variant="contained"
color="primary"
onClick={onSubmit}
disabled={decide.isPending || expiryMissing}
>
{decide.isPending ? t('saving') : t('save')}
</AppButton>
</DialogActions>
<FormProvider {...form}>
<DialogContent>
{/* A real <form> so Enter submits from any field; the footer button (outside DialogContent)
calls the same handler directly rather than relying on cross-element form association. */}
<Stack component="form" noValidate onSubmit={handleSubmit(onSubmit)} sx={{ gap: 2, mt: 1 }}>
<RhfTextField<CredentialDecisionValues>
name="credentialNumber"
fullWidth
autoFocus
label={t('ver_credential_number')}
/>
<RhfTextField<CredentialDecisionValues>
name="holderName"
fullWidth
label={t('ver_holder_name')}
helperText={t('ver_holder_hint')}
/>
<RhfTextField<CredentialDecisionValues>
name="issuingAuthority"
fullWidth
label={t('ver_issuing_authority')}
/>
<RhfJalaliDateField<CredentialDecisionValues> name="issuedAt" fullWidth label={t('ver_issued_at')} />
<RhfJalaliDateField<CredentialDecisionValues>
name="expiresAt"
fullWidth
label={t('ver_expires_at')}
required={expiryRequired}
rules={{
validate: (value) => !expiryRequired || String(value ?? '').trim().length > 0 || t('ver_expiry_required'),
}}
/>
</Stack>
</DialogContent>
<DialogActions sx={{ px: 3, pb: 2 }}>
<AppButton variant="text" color="inherit" onClick={onClose} disabled={decide.isPending}>
{t('cancel')}
</AppButton>
<AppButton
variant="contained"
color="primary"
onClick={handleSubmit(onSubmit)}
disabled={decide.isPending || !formState.isValid}
>
{decide.isPending ? t('saving') : t('save')}
</AppButton>
</DialogActions>
</FormProvider>
</Dialog>
);
}
@@ -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 (
<Stack sx={{ gap: 2.5 }}>
<GreetingHeader />
{/* 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. */}
<PageHeader title={t('title')} />
<NextVisitCard />
<RequestsSection />
<EarningsSection />
@@ -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 (
<Stack direction="row" sx={{ gap: 1.5, alignItems: 'center' }}>
<Skeleton variant="circular" width={44} height={44} />
<Skeleton variant="text" width={180} height={28} />
</Stack>
);
}
return (
<Stack direction="row" sx={{ gap: 1.5, alignItems: 'center', minWidth: 0 }}>
<InitialsAvatar name={displayName} size={44} />
<Stack sx={{ gap: 0.5, minWidth: 0 }}>
<Typography variant="subtitle1" component="h1" noWrap sx={{ fontWeight: 700 }}>
{t('greeting', { name: displayName })}
</Typography>
{verification.isLoading ? null : (
<Box>
<TrustBadge state={ownBadgeState(verification.data)} />
</Box>
)}
</Stack>
</Stack>
);
}
/** The page's one hero action: the next actionable session, with a full-width primary CTA. */
function NextVisitCard() {
const t = useTranslations('dashboard');
@@ -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<BankFormValues>({ 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 ? (
<Stack sx={{ gap: 2 }}>
<TextField
label={t('iban_label')}
value={iban}
onChange={(e) => {
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
/>
<TextField
label={t('holder_label')}
value={holder}
onChange={(e) => {
setHolder(e.target.value);
if (holderError) setHolderError(false);
}}
error={holderError}
helperText={holderError ? t('holder_required') : t('holder_hint')}
fullWidth
/>
<Stack direction="row" sx={{ gap: 1 }}>
<AppButton color="primary" variant="contained" startIcon="bank" onClick={submit} disabled={addAccount.isPending}>
{addAccount.isPending ? t('submitting') : t('submit')}
</AppButton>
{accounts.length > 0 ? (
<AppButton
variant="text"
onClick={() => {
setShowForm(false);
setIban('');
setHolder('');
setIbanError(false);
setHolderError(false);
}}
disabled={addAccount.isPending}
>
{tc('cancel')}
<FormProvider {...form}>
<Stack component="form" noValidate onSubmit={handleSubmit(submit)} sx={{ gap: 2 }}>
<RhfTextField<BankFormValues>
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
/>
<RhfTextField<BankFormValues>
name="holder"
label={t('holder_label')}
helperText={t('holder_hint')}
rules={{ validate: (value) => String(value ?? '').trim().length > 0 || t('holder_required') }}
fullWidth
/>
<Stack direction="row" sx={{ gap: 1 }}>
<AppButton type="submit" color="primary" variant="contained" startIcon="bank" disabled={addAccount.isPending}>
{addAccount.isPending ? t('submitting') : t('submit')}
</AppButton>
) : null}
{accounts.length > 0 ? (
<AppButton
variant="text"
onClick={() => {
setShowForm(false);
reset();
}}
disabled={addAccount.isPending}
>
{tc('cancel')}
</AppButton>
) : null}
</Stack>
</Stack>
</Stack>
</FormProvider>
) : null}
</Box>
);
@@ -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 <AppLoading />;
return <NurseProfileForm initial={profile ?? null} />;
}
/**
* 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<HTMLInputElement>(null);
const [avatarUrl, setAvatarUrl] = useState<string | null>(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<string[]>(
parseSpecializations(initial?.specializationsJson ?? '[]'),
);
const form = useForm<ProfileFormValues>({
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<HTMLInputElement>) => {
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 (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3, maxWidth: 560 }}>
<Box>
<Stack direction="row" sx={{ gap: 1.5, alignItems: 'center', flexWrap: 'wrap' }}>
<Typography variant="h5" component="h1">
{t('title')}
</Typography>
{/* The public trust signal on the nurse's own profile — the same badge f6 reuses in search. */}
<TrustBadge state={badgeState} />
</Stack>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('subtitle')}
</Typography>
</Box>
<FormProvider {...form}>
<Box
component="form"
noValidate
onSubmit={handleSubmit(save)}
sx={{ display: 'flex', flexDirection: 'column', gap: 2.5, maxWidth: CONTENT_MAX_WIDTH, mx: 'auto', width: '100%' }}
>
<PageHeader title={t('title')} subtitle={t('subtitle')} meta={<TrustBadge state={badgeState} />} />
{/* Blocked-until-verified banner — shown until the aggregate is approved (incl. the expired state). */}
{badgeState !== 'verified' ? (
<Paper
elevation={0}
sx={{ p: 2, borderRadius: 'var(--bal-radius-md)', border: '1px solid', borderColor: 'divider', borderInlineStartWidth: 4, borderInlineStartColor: 'var(--bal-warning)' }}
>
<Stack direction="row" sx={{ gap: 1.5, alignItems: 'flex-start' }}>
<AppIcon icon="warning" size={24} color="var(--bal-warning)" />
<Stack sx={{ gap: 1, flexGrow: 1 }}>
<Box>
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
{/* Blocked-until-verified nudge — shown until the aggregate is approved (incl. the expired state). */}
{badgeState !== 'verified' ? (
<AccentCard tone="warning" padding="sm">
<Stack sx={{ gap: 1 }}>
<Stack direction="row" sx={{ gap: 1, alignItems: 'center' }}>
<AppIcon icon="warning" size={20} color="var(--bal-warning)" />
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>
{t('unverified_title')}
</Typography>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('unverified_body')}
</Typography>
</Box>
</Stack>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('unverified_body')}
</Typography>
<AppButton
color="primary"
variant="outlined"
variant="text"
endIcon="forward"
to={`/${locale}${ROUTES.NURSE_VERIFICATION}`}
sx={{ alignSelf: 'flex-start' }}
>
{t('unverified_cta')}
</AppButton>
</Stack>
</AccentCard>
) : null}
<FormSection title={t('section_intro_title')} description={t('section_intro_description')} icon="account">
<Stack direction="row" sx={{ gap: 2, alignItems: 'center' }}>
<Avatar src={avatarUrl ?? undefined} sx={{ width: 64, height: 64, bgcolor: 'var(--bal-primary-soft)' }}>
{avatarUrl ? null : <AppIcon icon="account" size={32} color="var(--bal-primary)" />}
</Avatar>
<Stack sx={{ gap: 0.5, minWidth: 0 }}>
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{t('photo_hint')}
</Typography>
<AppButton
variant="outlined"
color="primary"
startIcon="camera"
onClick={() => fileInputRef.current?.click()}
disabled={uploadAvatar.isPending}
sx={{ alignSelf: 'flex-start' }}
>
{uploadAvatar.isPending ? t('uploading') : t('upload')}
</AppButton>
<input ref={fileInputRef} type="file" accept="image/*" hidden onChange={onFileSelected} />
</Stack>
</Stack>
</Paper>
) : null}
<Stack direction="row" sx={{ gap: 2, alignItems: 'center' }}>
<Avatar src={avatarUrl ?? undefined} sx={{ width: 72, height: 72, bgcolor: 'var(--bal-primary-soft)' }}>
{avatarUrl ? null : <AppIcon icon="account" size={36} color="var(--bal-primary)" />}
</Avatar>
<Stack sx={{ gap: 0.5 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>
{t('photo')}
</Typography>
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{t('photo_hint')}
</Typography>
<AppButton
variant="outlined"
color="primary"
startIcon="camera"
onClick={pickFile}
disabled={uploadAvatar.isPending}
sx={{ mt: 0.5, alignSelf: 'flex-start' }}
>
{uploadAvatar.isPending ? t('uploading') : t('upload')}
<RhfTextField<ProfileFormValues>
name="bio"
label={t('bio')}
helperText={t('bio_hint')}
multiline
minRows={3}
fullWidth
/>
</FormSection>
<FormSection
title={t('section_experience_title')}
description={t('section_experience_description')}
icon="license"
>
<RhfTextField<ProfileFormValues>
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 }}
/>
<Stack direction={{ xs: 'column', sm: 'row' }} sx={{ gap: 2 }}>
<RhfTextField<ProfileFormValues> name="educationLevel" select label={t('education_level_label')} fullWidth>
{EDUCATION_LEVELS.map((code) => (
<MenuItem key={code} value={code}>
{t(`education_level_${code}`)}
</MenuItem>
))}
<MenuItem value={OTHER_CODE}>{t('education_other')}</MenuItem>
</RhfTextField>
<RhfTextField<ProfileFormValues> name="educationField" select label={t('education_field_label')} fullWidth>
{EDUCATION_FIELDS.map((code) => (
<MenuItem key={code} value={code}>
{t(`education_field_${code}`)}
</MenuItem>
))}
<MenuItem value={OTHER_CODE}>{t('education_other')}</MenuItem>
</RhfTextField>
</Stack>
{educationLevel === OTHER_CODE ? (
<RhfTextField<ProfileFormValues>
name="educationLevelOther"
label={t('education_level_other_label')}
rules={{ required: t('education_other_required') }}
fullWidth
/>
) : null}
{educationField === OTHER_CODE ? (
<RhfTextField<ProfileFormValues>
name="educationFieldOther"
label={t('education_field_other_label')}
rules={{ required: t('education_other_required') }}
fullWidth
/>
) : null}
</FormSection>
<FormSection
title={t('specializations_label')}
description={t('section_specializations_description')}
icon="clinical"
optional
optionalLabel={tc('optional')}
>
<RhfChipSelect<ProfileFormValues>
name="specializations"
options={SPECIALTY_PRESETS.map((code) => ({
code,
label: tv.has(`specialty_${code}`) ? tv(`specialty_${code}`) : code,
}))}
allowCustomValues
/>
</FormSection>
<Stack direction="row" sx={{ gap: 1, alignItems: 'center', flexWrap: 'wrap' }}>
<AppButton type="submit" color="primary" variant="contained" disabled={upsert.isPending}>
{upsert.isPending ? tc('saving') : t('save')}
</AppButton>
<AppButton variant="text" color="primary" startIcon="account" to={`/${locale}${ROUTES.NURSE_PROFILE_PREVIEW}`}>
{t('preview_cta')}
</AppButton>
<input ref={fileInputRef} type="file" accept="image/*" hidden onChange={onFileSelected} />
</Stack>
</Stack>
<TextField
label={t('bio')}
value={bio}
onChange={(e) => setBio(e.target.value)}
helperText={t('bio_hint')}
multiline
minRows={3}
fullWidth
/>
<TextField
label={t('years')}
value={years}
onChange={(e) => {
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 }}
/>
<Stack direction={{ xs: 'column', sm: 'row' }} sx={{ gap: 2 }}>
<TextField select label={t('education_level_label')} value={educationLevel} onChange={(e) => setEducationLevel(e.target.value)} fullWidth>
{EDUCATION_LEVELS.map((code) => (
<MenuItem key={code} value={code}>
{t(`education_level_${code}`)}
</MenuItem>
))}
<MenuItem value={OTHER_CODE}>{t('education_other')}</MenuItem>
</TextField>
<TextField select label={t('education_field_label')} value={educationField} onChange={(e) => setEducationField(e.target.value)} fullWidth>
{EDUCATION_FIELDS.map((code) => (
<MenuItem key={code} value={code}>
{t(`education_field_${code}`)}
</MenuItem>
))}
<MenuItem value={OTHER_CODE}>{t('education_other')}</MenuItem>
</TextField>
</Stack>
{educationLevel === OTHER_CODE ? (
<TextField
label={t('education_level_other_label')}
value={educationLevelOther}
onChange={(e) => setEducationLevelOther(e.target.value)}
fullWidth
/>
) : null}
{educationField === OTHER_CODE ? (
<TextField
label={t('education_field_other_label')}
value={educationFieldOther}
onChange={(e) => setEducationFieldOther(e.target.value)}
fullWidth
/>
) : null}
<Stack sx={{ gap: 1 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>
{t('specializations_label')}
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{t('deferred_services')}
</Typography>
<Stack direction="row" sx={{ gap: 1, flexWrap: 'wrap' }}>
{SPECIALTY_PRESETS.map((code) => {
const selected = specializations.includes(code);
return (
<Chip
key={code}
label={tv.has(`specialty_${code}`) ? tv(`specialty_${code}`) : code}
onClick={() => 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)',
}}
/>
);
})}
</Stack>
</Stack>
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{t('deferred_services')}
</Typography>
<AppButton
variant="text"
color="primary"
startIcon="account"
to={`/${locale}${ROUTES.NURSE_PROFILE_PREVIEW}`}
sx={{ alignSelf: 'flex-start' }}
>
{t('preview_cta')}
</AppButton>
<AppButton
color="primary"
variant="contained"
onClick={handleSave}
disabled={upsert.isPending}
sx={{ alignSelf: 'flex-start' }}
>
{upsert.isPending ? tc('saving') : t('save')}
</AppButton>
</Box>
</Box>
</FormProvider>
);
};
@@ -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<number, number>;
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<VariantBuilderProps> = ({ 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<number | null>(initial?.serviceCategoryId ?? null);
const [selectedOptions, setSelectedOptions] = useState<Record<number, number>>({});
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<PriceUnit>(initial?.priceUnit ?? DEFAULT_UNIT);
const [durationStr, setDurationStr] = useState(initial?.sessionCount ? String(initial.sessionCount) : '');
const [displayNameOverride, setDisplayNameOverride] = useState<string | null>(null);
const [priceError, setPriceError] = useState(false);
const [step, setStep] = useState<StepKey>(isEdit ? 'price' : 'category');
const [duplicate, setDuplicate] = useState(false);
const form = useForm<VariantFormValues>({
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<VariantBuilderProps> = ({ 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<StepKey, string> = {
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<VariantBuilderProps> = ({ 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<VariantBuilderProps> = ({ 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<VariantBuilderProps> = ({ 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<VariantBuilderProps> = ({ 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 = (
<Stack sx={{ gap: 2.5 }}>
<TextField
label={t('price_label')}
value={priceToman}
onChange={(event) => {
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
/>
<FormSection title={t('section_recap_title')} description={t('section_recap_description')} icon="category">
<Stack sx={{ gap: 1 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>
{selectedCategory
? pickCatalogName(selectedCategory, locale)
: initial
? pickCatalogName({ nameFa: initial.categoryNameFa, nameEn: initial.categoryNameEn }, locale)
: ''}
</Typography>
{isEdit ? (
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{t('category_locked')}
</Typography>
) : null}
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.75 }}>
{recapChips.length > 0 ? (
recapChips.map((chip) => (
<Chip key={chip.key} size="small" label={chip.label} sx={{ bgcolor: 'var(--bal-primary-soft)' }} />
))
) : (
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{t('summary_none')}
</Typography>
)}
</Box>
</Stack>
</FormSection>
<Stack direction={{ xs: 'column', sm: 'row' }} sx={{ gap: 2 }}>
<TextField
select
label={t('unit_label')}
value={priceUnit}
onChange={(event) => setPriceUnit(event.target.value as PriceUnit)}
fullWidth
>
{PRICE_UNITS.map((unit) => (
<MenuItem key={unit} value={unit}>
{tCatalog(`unit_${unit}`)}
</MenuItem>
))}
</TextField>
<TextField
label={t('duration_label')}
value={durationStr}
onChange={(event) => setDurationStr(digitsOnly(event.target.value).slice(0, MAX_DURATION_DIGITS))}
helperText={t('duration_hint')}
<FormSection title={t('section_price_title')} description={t('price_hint')} icon="earnings">
<RhfTextField<VariantFormValues>
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
/>
</Stack>
{irr ? (
<Stack direction={{ xs: 'column', sm: 'row' }} sx={{ gap: 2 }}>
<RhfTextField<VariantFormValues> name="priceUnit" select label={t('unit_label')} fullWidth>
{PRICE_UNITS.map((unit) => (
<MenuItem key={unit} value={unit}>
{tCatalog(`unit_${unit}`)}
</MenuItem>
))}
</RhfTextField>
<RhfTextField<VariantFormValues>
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
/>
</Stack>
{!sessionCount && irr ? (
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{t('rate_note')}
</Typography>
) : null}
</FormSection>
<FormSection title={t('section_listing_title')} description={t('display_name_hint')} icon="services">
{/* 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. */}
<Controller
control={control}
name="displayNameOverride"
render={({ field }) => (
<TextField
label={t('display_name_label')}
name={field.name}
inputRef={field.ref}
value={field.value ?? autoName}
onChange={(event) => field.onChange(event.target.value)}
onBlur={field.onBlur}
fullWidth
/>
)}
/>
<Stack sx={{ gap: 1 }}>
<Typography variant="caption" sx={{ color: 'text.secondary', fontWeight: 700 }}>
{t('preview_heading')}
</Typography>
{/* 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. */}
<VariantCard variant={previewVariant} interactive={false} />
{!sessionCount ? (
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{t('rate_note')}
</Typography>
) : null}
</Stack>
) : null}
<TextField
label={t('display_name_label')}
value={displayValue}
onChange={(event) => setDisplayNameOverride(event.target.value)}
helperText={t('display_name_hint')}
fullWidth
/>
</FormSection>
{duplicate ? (
<AccentCard tone="warning" padding="sm">
@@ -301,157 +420,70 @@ const VariantBuilder: FunctionComponent<VariantBuilderProps> = ({ initial, onDon
</Stack>
);
// --- Edit mode: locked category + options summary, then the price form ---
if (isEdit) {
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3, maxWidth: 560 }}>
<Typography variant="h5" component="h1">
{t('builder_edit_title')}
</Typography>
<Paper elevation={0} sx={{ p: 2, borderRadius: 'var(--bal-radius-md)', border: '1px solid', borderColor: 'divider' }}>
<Stack sx={{ gap: 1 }}>
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
{pickCatalogName({ nameFa: initial.categoryNameFa, nameEn: initial.categoryNameEn }, locale)}
</Typography>
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{t('category_locked')}
</Typography>
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.75, mt: 0.5 }}>
{initial.options.length > 0 ? (
initial.options.map((option) => (
<Chip
key={option.optionGroupId}
size="small"
label={`${pickCatalogName({ nameFa: option.groupNameFa, nameEn: option.groupNameEn }, locale)}: ${pickCatalogName({ nameFa: option.valueNameFa, nameEn: option.valueNameEn }, locale)}`}
sx={{ bgcolor: 'var(--bal-primary-soft)' }}
/>
))
) : (
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{t('summary_none')}
</Typography>
)}
</Box>
</Stack>
</Paper>
{priceStep}
<Stack direction="row" sx={{ gap: 1, justifyContent: 'flex-end' }}>
<AppButton variant="text" onClick={onCancel} disabled={submitting}>
{tc('cancel')}
</AppButton>
<AppButton color="primary" variant="contained" onClick={submit} disabled={submitting}>
{submitting ? tc('saving') : t('submit_save')}
</AppButton>
</Stack>
</Box>
);
}
// --- Create mode: the 3-step stepper ---
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2, maxWidth: 560 }}>
<Typography variant="h5" component="h1">
{t('builder_add_title')}
</Typography>
<FormProvider {...form}>
<Box
component="form"
noValidate
onSubmit={handleSubmit(submit)}
sx={{ display: 'flex', flexDirection: 'column', gap: 2, maxWidth: CONTENT_MAX_WIDTH, mx: 'auto', width: '100%' }}
>
<PageHeader title={isEdit ? t('builder_edit_title') : t('builder_add_title')} />
<StepperHeader
steps={[t('step_category'), t('step_options'), t('step_price')]}
activeStep={activeStep}
/>
{isEdit ? null : (
<StepperHeader
steps={visibleSteps.map((key) => stepLabels[key])}
activeStep={visibleSteps.indexOf(effectiveStep)}
/>
)}
{activeStep === 0 ? (
<Stack sx={{ gap: 1.5 }}>
<Box>
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
{t('category_title')}
</Typography>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('category_subtitle')}
</Typography>
</Box>
{categoriesQuery.isLoading ? (
<Box sx={{ display: 'grid', gridTemplateColumns: 'repeat(2, 1fr)', gap: 1.5 }}>
{[0, 1, 2, 3].map((key) => (
<Skeleton key={key} variant="rounded" height={116} sx={{ borderRadius: 'var(--bal-radius-md)' }} />
))}
</Box>
) : categoriesQuery.isError ? (
<Stack sx={{ gap: 1, alignItems: 'flex-start' }}>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('categories_error')}
</Typography>
<AppButton variant="outlined" color="primary" onClick={() => categoriesQuery.refetch()}>
{tc('retry')}
</AppButton>
</Stack>
) : (
<Box sx={{ display: 'grid', gridTemplateColumns: 'repeat(2, 1fr)', gap: 1.5 }}>
{categories.map((category) => (
<CategoryTile
key={category.id}
label={pickCatalogName(category, locale)}
iconKey={category.iconKey}
selected={categoryId === category.id}
onClick={() => selectCategory(category.id)}
/>
))}
</Box>
)}
</Stack>
) : null}
{effectiveStep === 'category' ? (
<FormSection title={t('category_title')} description={t('category_subtitle')} icon="category">
{categoriesQuery.isLoading ? (
<Box sx={{ display: 'grid', gridTemplateColumns: 'repeat(2, 1fr)', gap: 1.5 }}>
{[0, 1, 2, 3].map((key) => (
<Skeleton key={key} variant="rounded" height={116} sx={{ borderRadius: 'var(--bal-radius-md)' }} />
))}
</Box>
) : categoriesQuery.isError ? (
<ErrorState message={t('categories_error')} retryLabel={tc('retry')} onRetry={() => categoriesQuery.refetch()} />
) : (
<Box sx={{ display: 'grid', gridTemplateColumns: 'repeat(2, 1fr)', gap: 1.5 }}>
{categories.map((category) => (
<CategoryTile
key={category.id}
label={pickCatalogName(category, locale)}
iconKey={category.iconKey}
selected={categoryId === category.id}
onClick={() => selectCategory(category.id)}
/>
))}
</Box>
)}
</FormSection>
) : null}
{activeStep === 1 ? (
<Stack sx={{ gap: 2 }}>
<Box>
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
{t('options_title')}
</Typography>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('options_subtitle')}
</Typography>
</Box>
{optionGroupsQuery.isLoading ? (
<AppLoading />
) : 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.
<ErrorState
message={t('options_error')}
retryLabel={tc('retry')}
onRetry={() => optionGroupsQuery.refetch()}
/>
) : groups.length === 0 ? (
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('options_none')}
</Typography>
) : (
groups.map((group) => {
const isMissing = optionsError && group.isRequired && selectedOptions[group.id] == null;
return (
{effectiveStep === 'options' ? (
<FormSection title={t('options_title')} description={t('options_subtitle')} icon="tune">
{optionGroupsQuery.isLoading ? (
<AppLoading />
) : 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.
<ErrorState message={t('options_error')} retryLabel={tc('retry')} onRetry={() => optionGroupsQuery.refetch()} />
) : (
groups.map((group) => (
<Stack key={group.id} sx={{ gap: 1 }}>
<Stack direction="row" sx={{ gap: 1, alignItems: 'center' }}>
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>
{pickCatalogName(group, locale)}
</Typography>
{/* The required badge turns red on a blocked advance to point at the unanswered group. */}
<Chip
size="small"
label={group.isRequired ? t('required_badge') : t('optional_badge')}
sx={{
bgcolor: isMissing
? 'var(--bal-error)'
: group.isRequired
? 'var(--bal-primary-soft)'
: 'var(--bal-divider)',
color: isMissing
? 'var(--bal-error-contrast)'
: group.isRequired
? 'var(--bal-primary)'
: 'text.secondary',
bgcolor: group.isRequired ? 'var(--bal-primary-soft)' : 'var(--bal-divider)',
color: group.isRequired ? 'var(--bal-primary)' : 'text.secondary',
fontWeight: 500,
}}
/>
@@ -463,76 +495,64 @@ const VariantBuilder: FunctionComponent<VariantBuilderProps> = ({ initial, onDon
<Chip
key={value.id}
label={pickCatalogName(value, locale)}
onClick={() => 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)}
/>
);
})}
</Stack>
</Stack>
);
})
)}
))
)}
</FormSection>
) : null}
{optionsError && missingRequiredGroups.length > 0 ? (
<Typography variant="body2" sx={{ color: 'var(--bal-error)', fontWeight: 500 }}>
{t('options_incomplete')}
</Typography>
) : null}
</Stack>
) : null}
{effectiveStep === 'price' ? priceStep : null}
{activeStep === 2 ? (
<Stack sx={{ gap: 1.5 }}>
<Box>
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
{t('price_title')}
</Typography>
</Box>
{priceStep}
</Stack>
) : null}
{/* Names what is still missing while the button is disabled, instead of revealing it on tap. */}
{effectiveStep === 'options' && missingRequiredGroups.length > 0 ? (
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{t('options_missing_named', {
groups: missingRequiredGroups.map((group) => pickCatalogName(group, locale)).join('),
})}
</Typography>
) : null}
<Stack direction="row" sx={{ gap: 1, justifyContent: 'space-between', mt: 1 }}>
<AppButton
variant="text"
onClick={activeStep === 0 ? onCancel : () => setActiveStep((step) => step - 1)}
disabled={submitting}
>
{activeStep === 0 ? tc('cancel') : tc('back')}
</AppButton>
<SurfaceCard padding="sm" sx={{ position: 'sticky', bottom: 'var(--bal-chrome-bottom, 0px)', zIndex: 1 }}>
<Stack direction="row" sx={{ gap: 1, justifyContent: 'space-between' }}>
<AppButton
variant="text"
onClick={effectiveStep === 'category' || isEdit ? onCancel : goBack}
disabled={submitting}
>
{effectiveStep === 'category' || isEdit ? tc('cancel') : tc('back')}
</AppButton>
{activeStep === 0 ? (
<AppButton
color="primary"
variant="contained"
onClick={() => setActiveStep(1)}
disabled={categoryId == null}
>
{t('next')}
</AppButton>
) : activeStep === 1 ? (
<AppButton
color="primary"
variant="contained"
onClick={goNextFromOptions}
disabled={optionGroupsQuery.isError}
>
{t('next')}
</AppButton>
) : (
<AppButton color="primary" variant="contained" onClick={submit} disabled={submitting}>
{submitting ? tc('saving') : t('submit_create')}
</AppButton>
)}
</Stack>
</Box>
{effectiveStep === 'price' ? (
<AppButton type="submit" color="primary" variant="contained" disabled={submitting}>
{submitting ? tc('saving') : isEdit ? t('submit_save') : t('submit_create')}
</AppButton>
) : (
<AppButton
color="primary"
variant="contained"
onClick={goNext}
disabled={
effectiveStep === 'category'
? categoryId == null
: optionGroupsQuery.isError || missingRequiredGroups.length > 0
}
>
{t('next')}
</AppButton>
)}
</Stack>
</SurfaceCard>
</Box>
</FormProvider>
);
};
@@ -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<string[]>([]);
const [customSpecialty, setCustomSpecialty] = useState('');
const [issuingAuthority, setIssuingAuthority] = useState('');
const [issuedAt, setIssuedAt] = useState<string | null>(null);
const [expiresAt, setExpiresAt] = useState<string | null>(null);
const [uploadedSteps, setUploadedSteps] = useState<Record<number, boolean>>({});
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 <AppLoading />;
if (!status || manualSteps.length === 0) {
@@ -126,227 +77,293 @@ export default function CredentialsSubmitPage() {
);
}
return <CredentialsForm status={status} manualSteps={manualSteps} />;
}
/**
* 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<Record<number, boolean>>({});
const form = useForm<CredentialsFormValues>({
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 (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3, maxWidth: CONTENT_MAX_WIDTH, mx: 'auto', width: '100%' }}>
<VerificationJourneyHeader group="credentials" />
<FormProvider {...form}>
<Box
component="form"
noValidate
onSubmit={handleSubmit(submit)}
sx={{ display: 'flex', flexDirection: 'column', gap: 2.5, maxWidth: CONTENT_MAX_WIDTH, mx: 'auto', width: '100%' }}
>
<VerificationJourneyHeader group="credentials" />
<Box>
<Typography variant="h6" component="h2" sx={{ fontWeight: 700 }}>
{t('credentials_title')}
</Typography>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('credentials_subtitle')}
</Typography>
</Box>
{editingIno ? (
<TextField
label={t('ino_number_label')}
value={inoNumber}
onChange={(event) => {
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
/>
) : (
<Stack
direction="row"
sx={{ gap: 1, alignItems: 'center', justifyContent: 'space-between', p: 1.5, borderRadius: 'var(--bal-radius-md)', border: '1px solid', borderColor: 'divider' }}
<FormSection title={t('ino_number_label')} description={t('ino_number_hint')} icon="license">
{editingIno ? (
<RhfTextField<CredentialsFormValues>
name="inoNumber"
label={t('ino_number_label')}
rules={{ required: t('ino_number_required') }}
slotProps={{ htmlInput: { dir: 'ltr', style: { textAlign: 'start' } } }}
fullWidth
/>
) : (
<Stack
direction="row"
sx={{ gap: 1, alignItems: 'center', justifyContent: 'space-between', p: 1.5, borderRadius: 'var(--bal-radius-md)', border: '1px solid', borderColor: 'divider' }}
>
<Stack direction="row" sx={{ gap: 1, alignItems: 'center' }}>
<AppIcon icon="verified" size={18} color="var(--bal-success)" />
<Typography variant="body2">{t('ino_number_submitted')}</Typography>
</Stack>
<AppButton variant="text" color="primary" onClick={() => setEditingIno(true)}>
{t('ino_number_change')}
</AppButton>
</Stack>
)}
</FormSection>
{/* 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. */}
<FormSection
title={t('credentials_documents_title')}
description={t('credentials_documents_description')}
icon="document"
status={
<Typography variant="caption" sx={{ color: 'text.secondary', flexShrink: 0 }}>
{t('credentials_documents_count', { done: uploadedCount, total: manualSteps.length })}
</Typography>
}
>
<Stack direction="row" sx={{ gap: 1, alignItems: 'center' }}>
<AppIcon icon="verified" size={18} color="var(--bal-success)" />
<Typography variant="body2">{t('ino_number_submitted')}</Typography>
</Stack>
<AppButton variant="text" color="primary" onClick={() => setEditingIno(true)}>
{t('ino_number_change')}
</AppButton>
</Stack>
)}
{manualSteps.map((step) => (
<DocumentUpload
key={step.code}
label={t.has(stepLabelKey(step.code)) ? t(stepLabelKey(step.code)) : step.displayName}
hint={t.has(stepDescriptionKey(step.code)) ? t(stepDescriptionKey(step.code)) : undefined}
onUpload={uploadToStep(step)}
onUploaded={() => 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. */}
<Stack sx={{ gap: 2 }}>
{manualSteps.map((step) => (
<DocumentUpload
key={step.code}
label={t.has(stepLabelKey(step.code)) ? t(stepLabelKey(step.code)) : step.displayName}
hint={t.has(stepDescriptionKey(step.code)) ? t(stepDescriptionKey(step.code)) : undefined}
onUpload={uploadToStep(step)}
onUploaded={() => 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}
/>
))}
</Stack>
{editingIno ? (
<DocumentUpload label={t('education_label')} hint={t('education_hint')} onUpload={async (file) => ({ name: file.name })} />
) : null}
</FormSection>
{editingIno ? (
<DocumentUpload label={t('education_label')} hint={t('education_hint')} onUpload={async (file) => ({ name: file.name })} />
) : null}
<FormSection
title={t('specialties_label')}
description={t('specialties_hint')}
icon="clinical"
optional
optionalLabel={tc('optional')}
>
{editingIno ? (
<>
<RhfChipSelect<CredentialsFormValues>
name="specialties"
options={SPECIALTY_PRESETS.map((preset) => ({
code: preset,
label: t.has(`specialty_${preset}`) ? t(`specialty_${preset}`) : preset,
}))}
allowCustomValues
/>
<Stack direction="row" sx={{ gap: 1, alignItems: 'flex-start' }}>
<TextField
size="small"
placeholder={t('specialty_add_placeholder')}
value={customSpecialty}
onChange={(event) => setCustomSpecialty(event.target.value)}
onKeyDown={(event) => {
if (event.key === 'Enter') {
event.preventDefault();
addCustomSpecialty();
}
}}
/>
<AppButton variant="outlined" color="primary" startIcon="add" onClick={addCustomSpecialty}>
{t('specialty_add')}
</AppButton>
</Stack>
</>
) : specialties.length > 0 ? (
<Stack direction="row" sx={{ gap: 1, flexWrap: 'wrap' }}>
{specialties.map((value) => (
<Chip
key={value}
label={t.has(`specialty_${value}`) ? t(`specialty_${value}`) : value}
sx={{ fontWeight: 500, backgroundColor: 'var(--bal-primary-soft)', color: 'var(--bal-primary)' }}
/>
))}
</Stack>
) : (
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{t('summary_none')}
</Typography>
)}
</FormSection>
{/* 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 ? (
<FormSection
title={t('registry_details_title')}
description={t('registry_details_description')}
icon="audit"
optional
optionalLabel={tc('optional')}
>
<RhfTextField<CredentialsFormValues> name="issuingAuthority" label={t('issuing_authority_label')} fullWidth />
<Stack direction="row" sx={{ gap: 1.5, flexWrap: 'wrap' }}>
<RhfJalaliDateField<CredentialsFormValues>
name="issuedAt"
label={t('issued_at_label')}
max={expiresAt ?? undefined}
sx={{ flex: 1, minWidth: 160 }}
/>
<RhfJalaliDateField<CredentialsFormValues>
name="expiresAt"
label={t('expires_at_label')}
min={issuedAt ?? undefined}
sx={{ flex: 1, minWidth: 160 }}
/>
</Stack>
</FormSection>
) : issuingAuthority || issuedAt || expiresAt ? (
<FormSection title={t('registry_details_title')} icon="audit">
{issuingAuthority ? <Typography variant="body2">{issuingAuthority}</Typography> : null}
{issuedAt || expiresAt ? (
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{issuedAt ? formatShamsiDate(issuedAt, locale) : ''}
{issuedAt && expiresAt ? ' ' : ''}
{expiresAt ? formatShamsiDate(expiresAt, locale) : ''}
</Typography>
) : null}
</FormSection>
) : null}
<Paper
elevation={0}
sx={{ p: 1.5, borderRadius: 'var(--bal-radius-md)', bgcolor: 'var(--bal-primary-soft)', display: 'flex', gap: 1, alignItems: 'center' }}
>
<AppIcon icon="info" size={18} color="var(--bal-primary)" />
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{t('manual_review_note')}
</Typography>
</Paper>
<Stack sx={{ gap: 1 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>
{t('specialties_label')}
</Typography>
{editingIno ? (
<>
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{t('specialties_hint')}
</Typography>
<Stack direction="row" sx={{ gap: 1, flexWrap: 'wrap' }}>
{SPECIALTY_PRESETS.map((preset) => {
const selected = specialties.includes(preset);
return (
<Chip
key={preset}
label={t.has(`specialty_${preset}`) ? t(`specialty_${preset}`) : preset}
onClick={() => toggleSpecialty(preset)}
icon={selected ? <AppIcon icon="verified" size={16} color="var(--bal-primary-contrast)" /> : 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) => (
<Chip
key={value}
label={value}
onDelete={() => toggleSpecialty(value)}
sx={{ fontWeight: 500, backgroundColor: 'var(--bal-primary)', color: 'var(--bal-primary-contrast)' }}
/>
))}
</Stack>
<Stack direction="row" sx={{ gap: 1, alignItems: 'flex-start' }}>
<TextField
size="small"
placeholder={t('specialty_add_placeholder')}
value={customSpecialty}
onChange={(event) => setCustomSpecialty(event.target.value)}
onKeyDown={(event) => {
if (event.key === 'Enter') {
event.preventDefault();
addCustomSpecialty();
}
}}
/>
<AppButton variant="outlined" color="primary" startIcon="add" onClick={addCustomSpecialty}>
{t('specialty_add')}
{!hasAnyDocument ? (
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{t('credentials_needs_document')}
</Typography>
) : null}
<Stack direction="row" sx={{ gap: 1 }}>
<AppButton
type="submit"
color="primary"
variant="contained"
startIcon="license"
disabled={!hasAnyDocument || submitCredentials.isPending}
>
{submitCredentials.isPending ? t('credentials_submitting') : t('credentials_submit')}
</AppButton>
<AppButton variant="text" color="primary" to={`/${locale}${ROUTES.NURSE_VERIFICATION}`}>
{t('back_to_checklist')}
</AppButton>
</Stack>
</>
) : specialties.length > 0 ? (
<Stack direction="row" sx={{ gap: 1, flexWrap: 'wrap' }}>
{specialties.map((value) => (
<Chip
key={value}
label={t.has(`specialty_${value}`) ? t(`specialty_${value}`) : value}
sx={{ fontWeight: 500, backgroundColor: 'var(--bal-primary-soft)', color: 'var(--bal-primary)' }}
/>
))}
</Stack>
) : (
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{t('summary_none')}
</Typography>
<AppButton color="primary" variant="contained" to={`/${locale}${ROUTES.NURSE_VERIFICATION}`} sx={{ alignSelf: 'flex-start' }}>
{t('back_to_checklist')}
</AppButton>
)}
</Stack>
{/* 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 ? (
<Stack sx={{ gap: 1.5 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>
{t('registry_details_label')}
</Typography>
<TextField
label={t('issuing_authority_label')}
value={issuingAuthority}
onChange={(event) => setIssuingAuthority(event.target.value)}
fullWidth
/>
<Stack direction="row" sx={{ gap: 1.5, flexWrap: 'wrap' }}>
<JalaliDateField
label={t('issued_at_label')}
value={issuedAt}
onChange={setIssuedAt}
max={expiresAt ?? undefined}
sx={{ flex: 1, minWidth: 160 }}
/>
<JalaliDateField
label={t('expires_at_label')}
value={expiresAt}
onChange={setExpiresAt}
min={issuedAt ?? undefined}
sx={{ flex: 1, minWidth: 160 }}
/>
</Stack>
</Stack>
) : issuingAuthority || issuedAt || expiresAt ? (
<Stack sx={{ gap: 0.5 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>
{t('registry_details_label')}
</Typography>
{issuingAuthority ? <Typography variant="body2">{issuingAuthority}</Typography> : null}
{issuedAt || expiresAt ? (
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{issuedAt ? formatShamsiDate(issuedAt, locale) : ''}
{issuedAt && expiresAt ? ' ' : ''}
{expiresAt ? formatShamsiDate(expiresAt, locale) : ''}
</Typography>
) : null}
</Stack>
) : null}
<Paper
elevation={0}
sx={{ p: 1.5, borderRadius: 'var(--bal-radius-md)', bgcolor: 'var(--bal-primary-soft)', display: 'flex', gap: 1, alignItems: 'center' }}
>
<AppIcon icon="info" size={18} color="var(--bal-primary)" />
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{t('manual_review_note')}
</Typography>
</Paper>
{editingIno ? (
<>
{!hasAnyDocument ? (
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{t('credentials_needs_document')}
</Typography>
) : null}
<Stack direction="row" sx={{ gap: 1 }}>
<AppButton color="primary" variant="contained" startIcon="license" onClick={handleSubmit} disabled={!canSubmit}>
{submitCredentials.isPending ? t('credentials_submitting') : t('credentials_submit')}
</AppButton>
<AppButton variant="text" color="primary" to={`/${locale}${ROUTES.NURSE_VERIFICATION}`}>
{t('back_to_checklist')}
</AppButton>
</Stack>
</>
) : (
<AppButton color="primary" variant="contained" to={`/${locale}${ROUTES.NURSE_VERIFICATION}`} sx={{ alignSelf: 'flex-start' }}>
{t('back_to_checklist')}
</AppButton>
)}
</Box>
</Box>
</FormProvider>
);
}
};
@@ -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<SubmitError>(null);
const form = useForm<IdentityFormValues>({
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 (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3, maxWidth: CONTENT_MAX_WIDTH, mx: 'auto', width: '100%' }}>
<VerificationJourneyHeader group="identity" />
<FormProvider {...form}>
<Box
component="form"
noValidate
onSubmit={handleSubmit(submit)}
sx={{ display: 'flex', flexDirection: 'column', gap: 2.5, maxWidth: CONTENT_MAX_WIDTH, mx: 'auto', width: '100%' }}
>
<VerificationJourneyHeader group="identity" />
<Box>
<Typography variant="h6" component="h2" sx={{ fontWeight: 700 }}>
{t('identity_title')}
</Typography>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('identity_subtitle')}
</Typography>
</Box>
<TextField
label={t('national_id_label')}
value={nationalId}
onChange={(event) => {
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
/>
<FormSection title={t('identity_step_number_title')} description={t('national_id_hint')} icon="identity">
<RhfTextField<IdentityFormValues>
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
/>
</FormSection>
<Stack sx={{ gap: 1 }}>
<CaptureGuideFrame variant="card" />
<DocumentUpload
label={t('card_label')}
hint={t('card_hint')}
accept={ACCEPTED_IMAGE_TYPES}
capture="environment"
onUpload={captureLocally}
onUploaded={() => setCardCaptured(true)}
/>
</Stack>
<Stack sx={{ gap: 1 }}>
<CaptureGuideFrame variant="selfie" />
<DocumentUpload
label={t('selfie_label')}
hint={t('selfie_hint')}
accept={ACCEPTED_IMAGE_TYPES}
capture="user"
onUpload={captureLocally}
onUploaded={() => setSelfieCaptured(true)}
/>
</Stack>
<Paper
elevation={0}
sx={{ p: 1.5, borderRadius: 'var(--bal-radius-md)', bgcolor: 'var(--bal-primary-soft)', display: 'flex', gap: 1, alignItems: 'center' }}
>
<AppIcon icon="info" size={18} color="var(--bal-primary)" />
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{t('auto_registry_note')}
</Typography>
</Paper>
{submitError ? (
<AppAlert severity={submitError.key === 'shared_sim' ? 'warning' : 'error'} variant="outlined">
{t(`error_${submitError.key}`)}
</AppAlert>
) : null}
{!cardCaptured ? (
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{t('card_recommended')}
</Typography>
) : null}
<Stack direction="row" sx={{ gap: 1 }}>
<AppButton
color="primary"
variant="contained"
startIcon="identity"
onClick={handleSubmit}
disabled={!canSubmit}
<FormSection
title={t('card_label')}
description={t('card_hint')}
icon="camera"
optional
optionalLabel={t('card_recommended_short')}
status={cardCaptured ? <CapturedMark label={t('capture_done')} /> : undefined}
>
{submitIdentity.isPending ? t('identity_submitting') : t('identity_submit')}
</AppButton>
<AppButton variant="text" color="primary" to={`/${locale}${ROUTES.NURSE_VERIFICATION}`}>
{t('back_to_checklist')}
</AppButton>
</Stack>
</Box>
<CaptureGuideFrame variant="card" />
<DocumentUpload
label={t('card_label')}
hint={t('capture_hint_card')}
accept={ACCEPTED_IMAGE_TYPES}
capture="environment"
onUpload={captureLocally}
onUploaded={() => setValue('cardCaptured', true, { shouldValidate: true })}
/>
</FormSection>
<FormSection
title={t('selfie_label')}
description={t('selfie_hint')}
icon="account"
status={selfieCaptured ? <CapturedMark label={t('capture_done')} /> : <RequiredMark label={t('required_badge')} />}
>
<CaptureGuideFrame variant="selfie" />
<DocumentUpload
label={t('selfie_label')}
hint={t('capture_hint_selfie')}
accept={ACCEPTED_IMAGE_TYPES}
capture="user"
onUpload={captureLocally}
onUploaded={() => setValue('selfieCaptured', true, { shouldValidate: true })}
/>
</FormSection>
<Paper
elevation={0}
sx={{ p: 1.5, borderRadius: 'var(--bal-radius-md)', bgcolor: 'var(--bal-primary-soft)', display: 'flex', gap: 1, alignItems: 'center' }}
>
<AppIcon icon="info" size={18} color="var(--bal-primary)" />
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{t('auto_registry_note')}
</Typography>
</Paper>
{submitError ? (
<AppAlert severity={submitError.key === 'shared_sim' ? 'warning' : 'error'} variant="outlined">
{t(`error_${submitError.key}`)}
</AppAlert>
) : 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 ? (
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{t('identity_needs_selfie')}
</Typography>
) : null}
<Stack direction="row" sx={{ gap: 1 }}>
<AppButton
type="submit"
color="primary"
variant="contained"
startIcon="identity"
disabled={!selfieCaptured || submitIdentity.isPending}
>
{submitIdentity.isPending ? t('identity_submitting') : t('identity_submit')}
</AppButton>
<AppButton variant="text" color="primary" to={`/${locale}${ROUTES.NURSE_VERIFICATION}`}>
{t('back_to_checklist')}
</AppButton>
</Stack>
</Box>
</FormProvider>
);
}
/** A section's "done" marker — the completion cue a wall of uploaders otherwise never gives. */
function CapturedMark({ label }: { label: string }) {
return (
<Stack direction="row" sx={{ gap: 0.5, alignItems: 'center', flexShrink: 0 }}>
<AppIcon icon="verified" size={16} color="var(--bal-success)" />
<Typography variant="caption" sx={{ color: 'var(--bal-success)' }}>
{label}
</Typography>
</Stack>
);
}
function RequiredMark({ label }: { label: string }) {
return (
<Typography variant="caption" sx={{ color: 'var(--bal-warning)', flexShrink: 0 }}>
{label}
</Typography>
);
}
@@ -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 (
<Stack sx={{ alignItems: 'center', gap: 0.75 }}>
<Box
sx={{
position: 'relative',
width: isCard ? 180 : 112,
height: isCard ? 112 : 140,
borderRadius: isCard ? 2 : '50%',
border: '2px dashed var(--bal-divider)',
}}
>
{isCard
? CORNER_POSITIONS.map((pos, index) => (
<Box
key={index}
sx={{ position: 'absolute', width: CORNER_SIZE, height: CORNER_SIZE, borderColor: 'var(--bal-primary)', ...pos }}
/>
))
: null}
</Box>
<Typography variant="caption" sx={{ color: 'text.secondary', textAlign: 'center', maxWidth: 220 }}>
{t(isCard ? 'capture_hint_card' : 'capture_hint_selfie')}
</Typography>
</Stack>
<Box
aria-hidden
sx={{
alignSelf: 'center',
position: 'relative',
width: isCard ? 180 : 112,
height: isCard ? 112 : 140,
borderRadius: isCard ? 2 : '50%',
border: '2px dashed var(--bal-divider)',
}}
>
{isCard
? CORNER_POSITIONS.map((pos, index) => (
<Box
key={index}
sx={{ position: 'absolute', width: CORNER_SIZE, height: CORNER_SIZE, borderColor: 'var(--bal-primary)', ...pos }}
/>
))
: null}
</Box>
);
}
@@ -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<string, boolean>;
}
/**
* 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<Record<string, boolean>>({});
const form = useForm<VisitNoteFormValues>({ 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)' }}
>
<Stack sx={{ gap: 2 }}>
<Stack direction="row" sx={{ alignItems: 'center', gap: 1 }}>
<AppIcon icon="notes" size={20} color="var(--bal-secondary)" />
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
{t('notes_title')}
</Typography>
</Stack>
{record.isLoading || tasks.length > 0 ? (
<Stack sx={{ gap: 0.5 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>
{t('tasks_checklist_title')}
<FormProvider {...form}>
<Stack component="form" noValidate onSubmit={handleSubmit(submit)} sx={{ gap: 2 }}>
<Stack direction="row" sx={{ alignItems: 'center', gap: 1 }}>
<AppIcon icon="notes" size={20} color="var(--bal-secondary)" />
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
{t('notes_title')}
</Typography>
{record.isLoading ? (
<Skeleton variant="rounded" height={80} />
) : (
tasks.map((task) => (
<FormControlLabel
key={task.id}
control={
<Checkbox
checked={Boolean(checked[task.id])}
onChange={(e) => setChecked((c) => ({ ...c, [task.id]: e.target.checked }))}
/>
}
label={task.label}
/>
))
)}
</Stack>
) : null}
<TextField
label={t('note_label')}
placeholder={t('note_placeholder')}
value={note}
onChange={(e) => setNote(e.target.value.slice(0, VISIT_NOTE_MAX_LENGTH))}
multiline
minRows={3}
fullWidth
/>
{record.isLoading || tasks.length > 0 ? (
<Stack sx={{ gap: 0.5 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>
{t('tasks_checklist_title')}
</Typography>
{record.isLoading ? (
<Skeleton variant="rounded" height={80} />
) : (
<RhfControlGroup<VisitNoteFormValues> name="checked">
{({ field }) => {
const ticked = (field.value as Record<string, boolean>) ?? {};
return (
<Stack>
{tasks.map((task) => (
<FormControlLabel
key={task.id}
control={
<Checkbox
checked={Boolean(ticked[task.id])}
onChange={(event) =>
field.onChange({ ...ticked, [task.id]: event.target.checked })
}
/>
}
label={task.label}
/>
))}
</Stack>
);
}}
</RhfControlGroup>
)}
</Stack>
) : null}
<AppButton
variant="contained"
color="secondary"
startIcon="notes"
onClick={submit}
disabled={createNote.isPending || !note.trim()}
sx={{ alignSelf: 'flex-end' }}
>
{createNote.isPending ? tc('saving') : t('note_submit')}
</AppButton>
</Stack>
<RhfTextField<VisitNoteFormValues>
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
/>
<AppButton
type="submit"
variant="contained"
color="secondary"
startIcon="notes"
disabled={createNote.isPending || !note.trim()}
sx={{ alignSelf: 'flex-end' }}
>
{createNote.isPending ? tc('saving') : t('note_submit')}
</AppButton>
</Stack>
</FormProvider>
</Paper>
) : null}
+111 -142
View File
@@ -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<PatientFormProps> = ({
@@ -50,163 +62,120 @@ const PatientForm: FunctionComponent<PatientFormProps> = ({
}) => {
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<Gender | null>(initialGender);
const [conditions, setConditions] = useState<string[]>(initialConditions);
const [relation, setRelation] = useState<Relation | null>(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<PatientFormValues>({
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 (
<Stack sx={{ gap: 2.5 }}>
<Stack direction={{ xs: 'column', sm: 'row' }} sx={{ gap: 2 }}>
<TextField
label={t('first_name_label')}
value={firstName}
onChange={(event) => {
setFirstName(event.target.value);
if (nameError) setNameError(false);
}}
error={nameError}
helperText={nameError ? t('name_required') : undefined}
fullWidth
/>
<TextField
label={t('last_name_label')}
value={lastName}
onChange={(event) => setLastName(event.target.value)}
fullWidth
/>
</Stack>
<TextField
label={t('age_label')}
value={age}
onChange={(event) => {
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 }}
/>
<Stack sx={{ gap: 1 }}>
<FormLabel error={genderError}>{t('gender_label')}</FormLabel>
<GenderToggle
value={gender}
onChange={(next) => {
setGender(next);
if (genderError) setGenderError(false);
}}
maleLabel={t('gender_male')}
femaleLabel={t('gender_female')}
error={genderError}
ariaLabel={t('gender_label')}
/>
{genderError ? (
<Typography variant="caption" sx={{ color: 'var(--bal-error)' }}>
{t('gender_required')}
</Typography>
) : null}
</Stack>
<Stack sx={{ gap: 1 }}>
<FormLabel>{t('conditions_label')}</FormLabel>
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{t('conditions_hint')}
</Typography>
<ConditionChips options={conditionOptions} value={conditions} onChange={setConditions} />
</Stack>
{showRelation ? (
<Stack sx={{ gap: 1 }}>
<FormLabel>{t('relation_title')}</FormLabel>
<RelationSelect
options={relationOptions}
value={relation}
onChange={(code) => setRelation(code as Relation)}
<FormProvider {...form}>
<Stack component="form" noValidate onSubmit={handleSubmit(submit)} sx={{ gap: 2.5 }}>
<Stack direction={{ xs: 'column', sm: 'row' }} sx={{ gap: 2 }}>
<RhfTextField<PatientFormValues>
name="firstName"
label={t('first_name_label')}
rules={{ validate: (value) => String(value ?? '').trim().length > 0 || t('name_required') }}
fullWidth
/>
<RhfTextField<PatientFormValues> name="lastName" label={t('last_name_label')} fullWidth />
</Stack>
) : null}
<Stack direction="row" sx={{ gap: 1, justifyContent: 'flex-end' }}>
{onCancel ? (
<AppButton variant="text" onClick={onCancel} disabled={submitting}>
{cancelLabel}
</AppButton>
) : null}
<AppButton
color="primary"
variant="contained"
onClick={handleSubmit}
disabled={submitting}
<RhfTextField<PatientFormValues>
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 }}
/>
<RhfControlGroup<PatientFormValues>
name="gender"
label={t('gender_label')}
rules={{ validate: (value) => value != null || t('gender_required') }}
>
{submitLabel}
</AppButton>
{({ field, hasError }) => (
<GenderToggle
value={field.value as Gender | null}
onChange={field.onChange}
maleLabel={t('gender_male')}
femaleLabel={t('gender_female')}
error={hasError}
ariaLabel={t('gender_label')}
/>
)}
</RhfControlGroup>
<RhfChipSelect<PatientFormValues>
name="conditions"
label={t('conditions_label')}
hint={t('conditions_hint')}
options={CONDITION_CODES.map((code) => ({ code, label: t(`condition_${code}`) }))}
/>
{showRelation ? (
<RhfControlGroup<PatientFormValues> name="relation" label={t('relation_title')}>
{({ field }) => (
<RelationSelect
options={RELATION_CODES.map((code) => ({ code, label: t(`relation_${code}`) }))}
value={field.value as Relation | null}
onChange={(code) => field.onChange(code as Relation)}
/>
)}
</RhfControlGroup>
) : null}
<Stack direction="row" sx={{ gap: 1, justifyContent: 'flex-end' }}>
{onCancel ? (
<AppButton variant="text" onClick={onCancel} disabled={submitting}>
{cancelLabel}
</AppButton>
) : null}
<AppButton type="submit" color="primary" variant="contained" disabled={submitting}>
{submitLabel}
</AppButton>
</Stack>
</Stack>
</Stack>
</FormProvider>
);
};
+47 -28
View File
@@ -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<RefundPanelProps> = ({ bookingId, ticketId,
const approve = useApproveRefund();
const reject = useRejectRefund();
const [channel, setChannel] = useState<RefundChannel | ''>('');
const [notes, setNotes] = useState('');
const [result, setResult] = useState<AdminRefundResult | null>(null);
const [confirmOpen, setConfirmOpen] = useState(false);
const [rejectOpen, setRejectOpen] = useState(false);
const p = preview.data;
const form = useForm<RefundFormValues>({ 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<RefundPanelProps> = ({ bookingId, ticketId,
<PriceBreakdown rows={rows} totalLabel={t('refund_row_total')} totalAmountIrr={p.amountIrr} />
{!activeResult ? (
<>
<TextField
select
size="small"
label={t('refund_channel')}
helperText={t('refund_channel_hint')}
value={channel || p.refundChannel}
onChange={(e) => setChannel(e.target.value as RefundChannel)}
sx={{ maxWidth: 260 }}
>
{CHANNELS.map((c) => (
<MenuItem key={c} value={c}>
{t(`channel_${c}`)}
</MenuItem>
))}
</TextField>
<FormProvider {...form}>
{/* 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». */}
<Controller
control={control}
name="channel"
render={({ field }) => (
<TextField
select
size="small"
label={t('refund_channel')}
helperText={t('refund_channel_hint')}
name={field.name}
inputRef={field.ref}
value={field.value || p.refundChannel}
onChange={(event) => field.onChange(event.target.value as RefundChannel)}
onBlur={field.onBlur}
sx={{ maxWidth: 260 }}
>
{CHANNELS.map((c) => (
<MenuItem key={c} value={c}>
{t(`channel_${c}`)}
</MenuItem>
))}
</TextField>
)}
/>
{p.expectedCustomerRefundEta ? (
<Alert severity="info" variant="outlined" icon={false}>
@@ -151,19 +177,12 @@ const RefundPanel: FunctionComponent<RefundPanelProps> = ({ bookingId, ticketId,
</Alert>
) : null}
<TextField
size="small"
label={t('refund_notes_ph')}
value={notes}
onChange={(e) => setNotes(e.target.value)}
multiline
minRows={2}
/>
<RhfTextField<RefundFormValues> name="notes" size="small" label={t('refund_notes_ph')} multiline minRows={2} />
<AppButton variant="contained" color="primary" startIcon="refunds" onClick={() => setConfirmOpen(true)} disabled={busy} sx={{ alignSelf: 'flex-start' }}>
{t('refund_initiate')}
</AppButton>
</>
</FormProvider>
) : failed ? (
<>
<Alert severity="error" variant="outlined">
@@ -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<AccentTone, string> = {
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<AccentCardProps> = ({ tone, sx, children, ...rest }) => (
<SurfaceCard
sx={{ borderInlineStart: `${ACCENT_WIDTH} solid ${TONE_VAR[tone]}`, ...sx }}
data-accent-tone={tone}
{...rest}
>
const AccentCard: FunctionComponent<AccentCardProps> = ({ tone, children, ...rest }) => (
<SurfaceCard data-accent-tone={tone} {...rest}>
{children}
</SurfaceCard>
);
@@ -14,6 +14,8 @@ describe('<StickyActionBar/> 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)' });
});
});
@@ -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<StickyActionBarProps> = ({ children }) => (
@@ -18,7 +21,7 @@ const StickyActionBar: FunctionComponent<StickyActionBarProps> = ({ children })
data-sticky-action-bar
sx={{
position: 'sticky',
bottom: 0,
bottom: 'var(--bal-chrome-bottom, 0px)',
zIndex: 1,
mt: 2,
pt: 1.5,
@@ -0,0 +1,35 @@
import { render, screen } from '@testing-library/react';
import { ThemeProvider } from '../../../theme';
import FormSection from './FormSection';
function renderSection(props: Partial<React.ComponentProps<typeof FormSection>> = {}) {
return render(
<ThemeProvider>
<FormSection title="Identity" {...props}>
<input aria-label="national id" />
</FormSection>
</ThemeProvider>,
);
}
describe('<FormSection/> 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: <span>0 of 3</span> });
expect(screen.getByText('Optional')).toBeInTheDocument();
expect(screen.queryByText('0 of 3')).not.toBeInTheDocument();
});
it('shows the status line on a required group', () => {
renderSection({ status: <span>2 of 3</span> });
expect(screen.getByText('2 of 3')).toBeInTheDocument();
});
});
@@ -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<FormSectionProps> = ({
title,
description,
icon,
status,
optional = false,
optionalLabel,
children,
}) => (
<SurfaceCard padding="md" data-form-section={title}>
<Stack sx={{ gap: 2 }}>
<Stack direction="row" sx={{ gap: 1.5, alignItems: 'flex-start' }}>
{icon ? (
<Box
sx={{
flexShrink: 0,
width: 34,
height: 34,
display: 'grid',
placeItems: 'center',
borderRadius: 'var(--bal-radius-sm)',
bgcolor: 'var(--bal-primary-soft)',
}}
>
<AppIcon icon={icon} size={18} color="var(--bal-primary)" aria-hidden="true" />
</Box>
) : null}
<Stack sx={{ gap: 0.25, flexGrow: 1, minWidth: 0 }}>
<Stack direction="row" sx={{ gap: 1, alignItems: 'baseline', justifyContent: 'space-between' }}>
<Typography variant="subtitle1" component="h2" sx={{ fontWeight: 700 }}>
{title}
</Typography>
{optional && optionalLabel ? (
<Typography variant="caption" sx={{ color: 'text.secondary', flexShrink: 0 }}>
{optionalLabel}
</Typography>
) : (
status
)}
</Stack>
{description ? (
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{description}
</Typography>
) : null}
</Stack>
</Stack>
<Stack sx={{ gap: 2 }}>{children}</Stack>
</Stack>
</SurfaceCard>
);
export default FormSection;
@@ -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<MultiValues>({ defaultValues: { specialties: initial } });
return (
<FormProvider {...form}>
<form onSubmit={form.handleSubmit(onSubmit)}>
<RhfChipSelect<MultiValues> name="specialties" label="Specialties" options={OPTIONS} allowCustomValues />
<button type="submit">Save</button>
</form>
</FormProvider>
);
}
function SingleHarness({ onSubmit }: { onSubmit: (values: SingleValues) => void }) {
const form = useForm<SingleValues>({ defaultValues: { relation: null } });
return (
<FormProvider {...form}>
<form onSubmit={form.handleSubmit(onSubmit)}>
<RhfChipSelect<SingleValues> name="relation" options={OPTIONS} multiple={false} />
<button type="submit">Save</button>
</form>
</FormProvider>
);
}
describe('<RhfChipSelect/> component', () => {
it('toggles codes in and out of a multi-select field', async () => {
const user = userEvent.setup();
const onSubmit = jest.fn();
render(
<ThemeProvider>
<MultiHarness onSubmit={onSubmit} />
</ThemeProvider>,
);
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(
<ThemeProvider>
<SingleHarness onSubmit={onSubmit} />
</ThemeProvider>,
);
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(
<ThemeProvider>
<MultiHarness onSubmit={jest.fn()} initial={['wound care']} />
</ThemeProvider>,
);
expect(screen.getByText('wound care')).toBeInTheDocument();
});
});
@@ -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<TFieldValues extends FieldValues> = RhfFieldProps<TFieldValues> & {
/** Already-translated group label. */
label?: string;
/** Already-translated one-line hint under the label. */
hint?: string;
options: ReadonlyArray<ChipOption>;
/** 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 = <TFieldValues extends FieldValues>({
name,
control,
rules,
label,
hint,
options,
multiple = true,
disabled = false,
allowCustomValues = false,
}: RhfChipSelectProps<TFieldValues>) => {
const context = useFormContext<TFieldValues>();
const resolvedControl = control ?? context?.control;
return (
<Controller
name={name}
control={resolvedControl}
rules={rules as RegisterOptions<TFieldValues>}
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 (
<Stack sx={{ gap: 1 }}>
{label ? <FormLabel error={Boolean(fieldState.error)}>{label}</FormLabel> : null}
{hint ? (
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{hint}
</Typography>
) : null}
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1 }}>
{options.map((option) => {
const isSelected = selected.includes(option.code);
return (
<Chip
key={option.code}
label={option.label}
data-code={option.code}
aria-pressed={isSelected}
clickable
disabled={disabled}
color={isSelected ? 'primary' : 'default'}
variant={isSelected ? 'filled' : 'outlined'}
onClick={() => toggle(option.code)}
/>
);
})}
{customCodes.map((code) => (
<Chip
key={code}
label={code}
data-code={code}
disabled={disabled}
color="primary"
variant="filled"
onDelete={() => toggle(code)}
/>
))}
</Box>
{fieldState.error?.message ? <FormHelperText error>{fieldState.error.message}</FormHelperText> : null}
</Stack>
);
}}
/>
);
};
export default RhfChipSelect;
@@ -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<Values>({ mode: 'onTouched', defaultValues: { gender: null } });
return (
<FormProvider {...form}>
<form onSubmit={form.handleSubmit(onSubmit)}>
<RhfControlGroup<Values>
name="gender"
label="Gender"
hint="Required for same-gender matching"
rules={{ validate: (value) => value != null || 'Pick one' }}
>
{({ field, hasError }) => (
<button type="button" data-invalid={hasError} onClick={() => field.onChange('female')}>
Female
</button>
)}
</RhfControlGroup>
<button type="submit">Save</button>
</form>
</FormProvider>
);
}
function renderGroup() {
const onSubmit = jest.fn();
render(
<ThemeProvider>
<Harness onSubmit={onSubmit} />
</ThemeProvider>,
);
return { onSubmit };
}
describe('<RhfControlGroup/> 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 controls 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());
});
});
@@ -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<TFieldValues extends FieldValues> = RhfFieldProps<TFieldValues> & {
/** 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<TFieldValues>; 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 `<Typography>` per screen, which is exactly the pattern that made "is this form
* valid?" un-answerable from one place.
* @component RhfControlGroup
*/
const RhfControlGroup = <TFieldValues extends FieldValues>({
name,
control,
rules,
label,
hint,
children,
}: RhfControlGroupProps<TFieldValues>) => {
const context = useFormContext<TFieldValues>();
const resolvedControl = control ?? context?.control;
return (
<Controller
name={name}
control={resolvedControl}
rules={rules as RegisterOptions<TFieldValues>}
render={({ field, fieldState }) => (
<Stack sx={{ gap: 1 }}>
{label ? <FormLabel error={Boolean(fieldState.error)}>{label}</FormLabel> : null}
{hint ? (
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{hint}
</Typography>
) : null}
{children({ field: field as ControllerRenderProps<TFieldValues>, hasError: Boolean(fieldState.error) })}
{fieldState.error?.message ? <FormHelperText error>{fieldState.error.message}</FormHelperText> : null}
</Stack>
)}
/>
);
};
export default RhfControlGroup;
@@ -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<TFieldValues extends FieldValues> = RhfFieldProps<TFieldValues> &
Omit<JalaliDateFieldProps, 'name' | 'value' | 'onChange' | 'error' | 'helperText'> & {
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 = <TFieldValues extends FieldValues>({
name,
control,
rules,
helperText,
...fieldProps
}: RhfJalaliDateFieldProps<TFieldValues>) => {
const context = useFormContext<TFieldValues>();
const resolvedControl = control ?? context?.control;
return (
<Controller
name={name}
control={resolvedControl}
rules={rules as RegisterOptions<TFieldValues>}
render={({ field, fieldState }) => (
<JalaliDateField
{...fieldProps}
name={field.name}
value={(field.value as string | null) ?? null}
onChange={field.onChange}
error={Boolean(fieldState.error)}
helperText={fieldState.error?.message ?? helperText}
/>
)}
/>
);
};
export default RhfJalaliDateField;
@@ -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<Values>({ mode: 'onTouched', defaultValues: { years: '' } });
return (
<FormProvider {...form}>
<form onSubmit={form.handleSubmit(onSubmit)}>
<RhfTextField<Values>
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' }}
/>
<button type="submit">Save</button>
</form>
</FormProvider>
);
}
function renderField() {
const onSubmit = jest.fn();
render(
<ThemeProvider>
<Harness onSubmit={onSubmit} />
</ThemeProvider>,
);
return { onSubmit };
}
describe('<RhfTextField/> 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();
});
});
@@ -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<TFieldValues extends FieldValues> = RhfFieldProps<TFieldValues> &
Omit<TextFieldProps, 'name' | 'value' | 'onChange' | 'onBlur' | 'error' | 'defaultValue' | 'inputRef'> & {
/**
* 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 = <TFieldValues extends FieldValues>({
name,
control,
rules,
transform,
helperText,
...textFieldProps
}: RhfTextFieldProps<TFieldValues>) => {
const context = useFormContext<TFieldValues>();
const resolvedControl = control ?? context?.control;
return (
<Controller
name={name}
control={resolvedControl}
rules={rules as RegisterOptions<TFieldValues>}
render={({ field, fieldState }) => (
<TextField
{...textFieldProps}
name={field.name}
inputRef={field.ref}
value={field.value ?? ''}
onChange={(event) => 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;
@@ -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';
@@ -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<TFieldValues extends FieldValues> {
/** Dot-path of the field in the form's value shape — type-checked against it. */
name: FieldPath<TFieldValues>;
/** Falls back to the enclosing `FormProvider`'s control when omitted. */
control?: Control<TFieldValues>;
/** Validation rules, colocated with the field they govern rather than in the submit handler. */
rules?: Omit<RegisterOptions<TFieldValues>, 'valueAsNumber' | 'valueAsDate' | 'setValueAs' | 'disabled'>;
}
+2
View File
@@ -25,6 +25,8 @@ import FormDialogShell from './FormDialogShell';
import RouteFadeIn from './RouteFadeIn';
import NavHubList from './NavHubList';
export * from './form';
export {
ErrorBoundary,
AppAlert,
+105 -111
View File
@@ -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<AddressFormProps> = ({ 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<CascadingRegionValue>(() => initialRegion(initial));
const [addressLine, setAddressLine] = useState(initialAddressLine);
const [pin, setPin] = useState<LatLng | null>(() => 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<AddressFormValues>({
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 (
<Stack sx={{ gap: 2.5 }}>
<TextField
label={t('title_label')}
placeholder={t('title_placeholder')}
value={title}
onChange={(event) => {
setTitle(event.target.value);
if (titleError) setTitleError(false);
}}
error={titleError}
helperText={titleError ? t('title_required') : undefined}
fullWidth
/>
<FormProvider {...form}>
<Stack component="form" noValidate onSubmit={handleSubmit(submit)} sx={{ gap: 2.5 }}>
<RhfTextField<AddressFormValues>
name="title"
label={t('title_label')}
placeholder={t('title_placeholder')}
rules={{ validate: (value) => String(value ?? '').trim().length > 0 || t('title_required') }}
fullWidth
/>
<CascadingRegionSelect
value={region}
onChange={(next) => {
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. */}
<RhfControlGroup<AddressFormValues>
name="region"
rules={{ validate: (value) => (value as CascadingRegionValue)?.cityId != null }}
>
{({ field, hasError }) => (
<CascadingRegionSelect
value={field.value as CascadingRegionValue}
onChange={field.onChange}
cityError={hasError}
cityErrorText={t('city_required')}
/>
)}
</RhfControlGroup>
<AddressMapPicker
value={pin}
onChange={(next) => {
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')}
/>
<RhfControlGroup<AddressFormValues> name="pin" rules={{ validate: (value) => value != null }}>
{({ field, hasError }) => (
<AddressMapPicker
value={field.value as LatLng | null}
onChange={field.onChange}
center={cityCentroid(region.cityId)}
helperText={t('map_hint')}
latLabel={t('map_lat')}
lngLabel={t('map_lng')}
error={hasError}
errorText={t('map_required')}
/>
)}
</RhfControlGroup>
<TextField
label={t('line_label')}
value={addressLine}
onChange={(event) => {
setAddressLine(event.target.value);
if (lineError) setLineError(false);
}}
error={lineError}
helperText={lineError ? t('line_required') : t('line_hint')}
multiline
minRows={2}
fullWidth
/>
<RhfTextField<AddressFormValues>
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
/>
<FormControlLabel
control={<Switch checked={isPrimary} onChange={(event) => setIsPrimary(event.target.checked)} />}
label={t('set_primary_toggle')}
/>
<RhfControlGroup<AddressFormValues> name="isPrimary">
{({ field }) => (
<FormControlLabel
control={<Switch checked={Boolean(field.value)} onChange={(event) => field.onChange(event.target.checked)} />}
label={t('set_primary_toggle')}
/>
)}
</RhfControlGroup>
<Stack direction="row" sx={{ gap: 1, justifyContent: 'flex-end' }}>
{onCancel ? (
<AppButton variant="text" onClick={onCancel} disabled={submitting}>
{tc('cancel')}
<Stack direction="row" sx={{ gap: 1, justifyContent: 'flex-end' }}>
{onCancel ? (
<AppButton variant="text" onClick={onCancel} disabled={submitting}>
{tc('cancel')}
</AppButton>
) : null}
<AppButton type="submit" color="primary" variant="contained" disabled={submitting}>
{submitting ? tc('saving') : tc('save')}
</AppButton>
) : null}
<AppButton color="primary" variant="contained" onClick={handleSubmit} disabled={submitting}>
{submitting ? tc('saving') : tc('save')}
</AppButton>
</Stack>
</Stack>
</Stack>
</FormProvider>
);
};
@@ -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('<ContactSupportDialog/> 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(),
@@ -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<ContactSupportDialogProps> = ({
const router = useRouter();
const openTicket = useOpenTicket();
const [category, setCategory] = useState<TicketCategory>(defaultCategory);
const [subject, setSubject] = useState('');
const [body, setBody] = useState('');
const [created, setCreated] = useState<OpenTicketResult | null>(null);
const reset = () => {
setSubject('');
setBody('');
setCreated(null);
setCategory(defaultCategory);
openTicket.reset();
};
const form = useForm<TicketDraft>({
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<ContactSupportDialogProps> = ({
</DialogActions>
</>
) : (
<>
<FormProvider {...form}>
<DialogTitle>{t('new_ticket_title')}</DialogTitle>
<DialogContent>
<Stack sx={{ gap: 2, pt: 1 }}>
<TextField
select
label={t('category_label')}
value={category}
onChange={(event) => setCategory(event.target.value as TicketCategory)}
fullWidth
size="small"
>
{/* A real <form> so Enter submits from any field; the footer button (outside DialogContent)
calls the same handler directly rather than relying on cross-element form association. */}
<Stack component="form" noValidate onSubmit={handleSubmit(submit)} sx={{ gap: 2, pt: 1 }}>
<RhfTextField<TicketDraft> name="category" select label={t('category_label')} fullWidth size="small">
{OPENABLE_CATEGORIES.map((code) => (
<MenuItem key={code} value={code}>
{t(`category_${code}`)}
</MenuItem>
))}
</TextField>
<TextField
label={t('subject_label')}
value={subject}
onChange={(event) => setSubject(event.target.value)}
fullWidth
size="small"
/>
<TextField
</RhfTextField>
<RhfTextField<TicketDraft> name="subject" label={t('subject_label')} fullWidth size="small" />
<RhfTextField<TicketDraft>
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<ContactSupportDialogProps> = ({
<AppButton
variant="contained"
color="primary"
onClick={submit}
onClick={handleSubmit(submit)}
disabled={openTicket.isPending || body.trim().length === 0}
>
{openTicket.isPending ? t('submitting') : t('submit')}
</AppButton>
</DialogActions>
</>
</FormProvider>
)}
</Dialog>
);
@@ -57,7 +57,8 @@ const TicketConversationPanel: FunctionComponent<TicketConversationPanelProps> =
<Box
sx={{
position: 'sticky',
bottom: 0,
// Clears the shell's pinned bottom nav (AppFrame publishes the height); `0px` elsewhere.
bottom: 'var(--bal-chrome-bottom, 0px)',
bgcolor: 'var(--bal-bg-default)',
borderTop: '1px solid',
borderColor: 'divider',
@@ -164,7 +164,15 @@ const TicketMessageList: FunctionComponent<TicketMessageListProps> = ({ ticketId
</Stack>
{showNewMessagePill ? (
<Box sx={{ position: 'sticky', bottom: 8, display: 'flex', justifyContent: 'center', pt: 1 }}>
<Box
sx={{
position: 'sticky',
bottom: 'calc(var(--bal-chrome-bottom, 0px) + 8px)',
display: 'flex',
justifyContent: 'center',
pt: 1,
}}
>
<AppButton
variant="contained"
color="primary"
@@ -57,7 +57,22 @@ const ThemeModeSetting: FunctionComponent = () => {
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) => (
<ToggleButton key={option.mode} value={option.mode}>
+64 -14
View File
@@ -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 `<main>`, 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 `<main>` fills the frame;
* header and footer are pinned over it and reserve their own space through `<main>`'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
* `<main>` does), so the two are visually identical on a phone, where the frame *is* the viewport.
* @layout AppFrame
*/
const AppFrame: FunctionComponent<AppFrameProps> = ({ header, footer, children }) => (
@@ -32,27 +47,49 @@ const AppFrame: FunctionComponent<AppFrameProps> = ({ 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',
}}
>
<Stack
sx={{
position: 'relative',
width: '100%',
maxWidth: APP_FRAME_MAX_WIDTH,
minWidth: 0,
height: '100%',
bgcolor: 'background.default',
// Reads as a hairline seam between app and canvas on a wide window; invisible on a phone,
// where the frame already fills the viewport edge to edge.
borderInline: '1px solid',
borderColor: 'divider',
border: { xs: 'none', sm: '1px solid' },
borderColor: { sm: 'divider' },
borderRadius: { xs: 0, sm: 'var(--bal-radius-lg)' },
boxShadow: { xs: 'none', sm: 'var(--bal-shadow-3)' },
// Load-bearing with the radius above — the pinned header and the scrolling <main> 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 ? (
<Box component="header" sx={{ flexShrink: 0, minWidth: 0 }}>
{header}
<Box
component="header"
sx={{
position: 'absolute',
insetInline: 0,
top: 0,
zIndex: 3,
minWidth: 0,
pointerEvents: 'none',
paddingInline: BAR_INLINE_GUTTER,
paddingBlockStart: `calc(env(safe-area-inset-top) + ${BAR_OUTER_GAP})`,
paddingBlockEnd: BAR_CONTENT_GAP,
}}
>
<Box sx={{ ...FLOATING_BAR_SX, pointerEvents: 'auto', minWidth: 0 }}>{header}</Box>
</Box>
) : null}
@@ -64,6 +101,16 @@ const AppFrame: FunctionComponent<AppFrameProps> = ({ 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<AppFrameProps> = ({ header, footer, children }
</Box>
{footer ? (
<Box component="footer" sx={{ flexShrink: 0, minWidth: 0 }}>
{footer}
<Box
component="footer"
sx={{ position: 'absolute', insetInline: 0, bottom: 0, zIndex: 3, minWidth: 0, pointerEvents: 'none' }}
>
<Box sx={{ pointerEvents: 'auto' }}>{footer}</Box>
</Box>
) : null}
</Stack>
+22 -5
View File
@@ -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<PropsWithChildren> = ({ children }) => {
const t = useTranslations('nav');
const supportUnreadTotal = useSupportUnreadTotal();
const { data: pendingRequests } = useNurseRequestInbox();
const tabs: Array<LinkToPage> = useMemo(
() => [
@@ -25,6 +36,12 @@ const NurseLayout: FunctionComponent<PropsWithChildren> = ({ 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<PropsWithChildren> = ({ children }) => {
badgeCount: supportUnreadTotal ?? undefined,
},
],
[t, supportUnreadTotal]
[t, supportUnreadTotal, pendingRequests]
);
return (
@@ -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('<BottomBar/> component', () => {
@@ -70,9 +73,16 @@ describe('<BottomBar/> 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)');
});
+63 -86
View File
@@ -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<LinkToPage>;
}
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<Props> = ({ 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 <main> (AppFrame), not an
// overlay — content is never hidden underneath it and no screen needs bottom padding for it.
<Box
// edge-to-edge slab. It is pinned over the scrolling <main> by AppFrame, which reserves exactly
// `BOTTOM_NAV_HEIGHT` of bottom padding so no screen's content ever ends up underneath it.
<Stack
component="nav"
direction="row"
sx={{
px: 1.5,
pt: 0.5,
paddingBottom: 'calc(env(safe-area-inset-bottom) + 10px)',
mx: 1.5,
mt: 0.5,
marginBottom: 'calc(env(safe-area-inset-bottom) + 10px)',
p: 0.75,
justifyContent: 'space-around',
alignItems: 'center',
...FLOATING_BAR_SX,
}}
>
<Stack
direction="row"
sx={{
px: 0.75,
py: 1,
gap: 0.25,
bgcolor: 'background.paper',
border: '1px solid',
borderColor: 'divider',
borderRadius: 'var(--bal-radius-pill)',
boxShadow: 'var(--bal-shadow-2)',
}}
>
{items.map((item) => {
const isActive = Boolean(item.path) && item.path === activePath;
return (
<ButtonBase
key={`${item.title}-${item.path}`}
onClick={() => 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 (
<ButtonBase
key={`${item.title}-${item.path}`}
onClick={() => 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)' },
}}
>
<Badge
badgeContent={item.badgeCount ?? 0}
max={99}
invisible={!item.badgeCount}
sx={{ '& .MuiBadge-badge': { bgcolor: 'var(--bal-error)', color: 'var(--bal-error-contrast)' } }}
>
<Badge
badgeContent={item.badgeCount ?? 0}
max={99}
invisible={!item.badgeCount}
sx={{ '& .MuiBadge-badge': { bgcolor: 'var(--bal-error)', color: 'var(--bal-error-contrast)' } }}
>
<Box
sx={{
width: PILL_WIDTH,
height: PILL_HEIGHT,
display: 'grid',
placeItems: 'center',
borderRadius: 'var(--bal-radius-pill)',
bgcolor: isActive ? 'var(--bal-primary-soft)' : 'transparent',
transition: 'background-color var(--bal-motion-fast) var(--bal-easing-standard)',
}}
>
<AppIcon
icon={item.icon}
size={ICON_SIZE}
color={isActive ? 'var(--bal-primary)' : 'var(--bal-text-secondary)'}
strokeWidth={isActive ? 2.1 : 1.75}
/>
</Box>
</Badge>
<Typography
variant="caption"
noWrap
sx={{
maxWidth: '100%',
fontWeight: isActive ? 700 : 400,
color: isActive ? 'var(--bal-primary)' : 'text.secondary',
}}
>
{item.title}
</Typography>
</ButtonBase>
);
})}
</Stack>
</Box>
<AppIcon
icon={item.icon}
size={ICON_SIZE}
color={isActive ? 'var(--bal-primary)' : 'var(--bal-text-secondary)'}
strokeWidth={isActive ? 2.2 : 1.75}
/>
</Badge>
</ButtonBase>
);
})}
</Stack>
);
};
+25 -2
View File
@@ -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;
+29
View File
@@ -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
+4
View File
@@ -0,0 +1,4 @@
.env
.env.local
node_modules/
*.log
+153
View File
@@ -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<SeamOptions>` + `ILogger<T>`,
`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<ISmsSender>(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" "<value>"`), 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.
+125
View File
@@ -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.
+14
View File
@@ -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"
}
}
+70
View File
@@ -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',
};
+147
View File
@@ -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)));
}
+76
View File
@@ -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', {});
}