Compare commits
3 Commits
70fb0a9202
...
370c1beefa
| Author | SHA1 | Date | |
|---|---|---|---|
| 370c1beefa | |||
| f1cba6cf74 | |||
| 9051bb3e18 |
@@ -28,10 +28,24 @@ Balinyaar is a **trust-first home-nursing marketplace in Iran**. The visual tone
|
|||||||
calm, warm, clinical-but-human — not a cold medical dashboard. Default audience is
|
calm, warm, clinical-but-human — not a cold medical dashboard. Default audience is
|
||||||
Persian (RTL); English is secondary.
|
Persian (RTL); English is secondary.
|
||||||
|
|
||||||
**Logo mark** (`product/balinyaar.html` seed deck): deep-teal square, lowercase
|
**Logo mark** — this skill is the construction source of truth (the original
|
||||||
display glyph in cream, a single terracotta dot. That trio — **teal ground, cream
|
`product/balinyaar.html` seed deck no longer exists in the repo): a deep-teal
|
||||||
text, terracotta accent** — is the whole identity. Use terracotta sparingly as the
|
rounded-square ground (`var(--bal-primary)`), a cream lowercase "b" glyph built
|
||||||
single accent; teal carries everything else.
|
from a stem + a ring bowl (`var(--bal-primary-contrast)`), and a single
|
||||||
|
terracotta dot accent (`var(--bal-secondary)`). That trio — **teal ground, cream
|
||||||
|
glyph, terracotta accent** — is the whole identity. Use terracotta sparingly as
|
||||||
|
the single accent; teal carries everything else. Implemented as two SVGs under
|
||||||
|
`components/common/AppIcon/icons/`:
|
||||||
|
- `LogoMark.tsx` — a monochrome `currentColor` version of just the glyph (no
|
||||||
|
ground square), registered as `ICONS.logo`. Use via `<AppIcon icon="logo">`
|
||||||
|
anywhere an inline, recolorable brand glyph is needed.
|
||||||
|
- `LogoLockup.tsx` — the full-color mark (ground + glyph + dot, token-driven so
|
||||||
|
it tracks the color scheme) for `BrandMark` (auth splash). The wordmark next
|
||||||
|
to it stays real, translated `<Typography>` — never bake locale text into an SVG.
|
||||||
|
- The favicon (`src/app/favicon.ico`) and `public/img/favicon/*.png` are
|
||||||
|
rasterized from the same construction (fixed brand hex, not CSS vars — static
|
||||||
|
binary assets are the one place a literal hex is correct). Regenerate with a
|
||||||
|
`sharp`-based script if the mark ever changes; don't hand-edit the PNGs/ICO.
|
||||||
|
|
||||||
| Role | Light | Dark |
|
| Role | Light | Dark |
|
||||||
|------|-------|------|
|
|------|-------|------|
|
||||||
@@ -67,21 +81,54 @@ Colors exist in **two mirrored places** that must stay in sync. Pick the right o
|
|||||||
- Adding/changing a color means editing `tokens.css` **and** `colors.ts` together (the
|
- Adding/changing a color means editing `tokens.css` **and** `colors.ts` together (the
|
||||||
file headers call out the sync requirement).
|
file headers call out the sync requirement).
|
||||||
|
|
||||||
|
**Beyond color** — `tokens.css` also defines non-palette tokens (`colors.ts` never needs
|
||||||
|
these; they're define-only in CSS):
|
||||||
|
- **Radius** — `--bal-radius-sm` (4px, controls: buttons/inputs), `--bal-radius-md`
|
||||||
|
(10px = `theme.shape.borderRadius`, the house default: cards/paper), `--bal-radius-lg`
|
||||||
|
(16px: dialogs). Reference the token/constant, never invent a new radius.
|
||||||
|
- **Elevation** — `--bal-shadow-1/2/3`, teal-tinted (black-teal in dark mode) shadow
|
||||||
|
steps that back `theme.ts`'s `shadows` array — every MUI elevation (Paper, Dialog,
|
||||||
|
Menu, Popover, AppBar) resolves through these, never MUI's default grey stack.
|
||||||
|
- **Motion** — `--bal-motion-fast/base/slow` (120/200/300ms) + `--bal-easing-standard`.
|
||||||
|
Consumed by the phase-12 app-wide motion pass; use them for any transition you add now.
|
||||||
|
- **Focus** — `--bal-focus-ring`, the 2px ring `MuiCssBaseline`'s global `:focus-visible`
|
||||||
|
override uses. Don't hand-roll a focus style; it's already uniform everywhere.
|
||||||
|
- **Rating** — `--bal-rating` / `--bal-rating-empty` (filled/empty star colors) —
|
||||||
|
`RatingInput` uses these, not `--bal-warning`.
|
||||||
|
- **Trust** — `--bal-trust` / `--bal-trust-soft`, a distinct identity (not
|
||||||
|
primary/success) for verified marks — `TrustBadge` and any future verification UI.
|
||||||
|
- **Money emphasis** — `--bal-money-emphasis`, an AA-contrast-safe color for emphasized
|
||||||
|
money text. `--bal-secondary` (terracotta) fails AA contrast at small sizes on light
|
||||||
|
backgrounds — never use it for money text, use this token instead.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 3. Typography & fonts
|
## 3. Typography & fonts
|
||||||
|
|
||||||
- `shape.borderRadius: 10` (set in `src/theme/theme.ts`) — the house corner radius.
|
- `shape.borderRadius: 10` (set in `src/theme/theme.ts`) — the house corner radius
|
||||||
Don't override per-component unless deliberate; prefer multiples that read as related.
|
(= `--bal-radius-md`). Don't override per-component unless deliberate; the radius
|
||||||
- Buttons: `textTransform: 'none'`, weight 600 (set globally in `typography.ts`). Never
|
*scale* is `--bal-radius-sm` (4, controls) / `-md` (10, cards) / `-lg` (16, dialogs).
|
||||||
|
- **Weight system — never write `fontWeight: 600`.** Mikhak and Space Grotesk both load
|
||||||
|
only 400/500/700 (no 600 face), so a requested 600 silently renders full Bold. Use
|
||||||
|
**700** for headings (`h1`–`h6`) and buttons/strong emphasis, **500** for lighter
|
||||||
|
in-text emphasis (subtitles, row labels, chip text). This is enforced globally in
|
||||||
|
`typography.ts`; match it in any new `sx` you write.
|
||||||
|
- Buttons: `textTransform: 'none'`, weight 700 (set globally in `typography.ts`). Never
|
||||||
re-uppercase button text.
|
re-uppercase button text.
|
||||||
- Headings (`h1`–`h6`) use the display font; `h6` is weight 600, the rest 700.
|
- Persian type scale (`TYPOGRAPHY_RTL`): `letterSpacing: 0` on every variant (Persian is
|
||||||
|
a joined script — tracking breaks glyph connections), body line-height ≥1.7, heading
|
||||||
|
line-height ~1.4–1.5 (room for ascenders/descenders), and `responsiveFontSizes()`
|
||||||
|
wraps both themes (`theme.ts`) so heading sizes scale down on small viewports —
|
||||||
|
don't hand-roll per-breakpoint `fontSize` overrides.
|
||||||
- **Fonts are loaded per-locale in `src/app/[locale]/layout.tsx` only** — Mikhak
|
- **Fonts are loaded per-locale in `src/app/[locale]/layout.tsx` only** — Mikhak
|
||||||
(`--font-mikhak`) for `fa`, system stack for `en` (Space Grotesk `--font-space-grotesk`
|
(`--font-mikhak`) for `fa`, **Space Grotesk** (`--font-space-grotesk`, via
|
||||||
is declared but not yet wired). **Never load a font in a component or page.**
|
`next/font/google`, self-hosted at build time) for `en`. Both `preload: false` with a
|
||||||
|
conditional `.variable` className so neither ships to the other locale.
|
||||||
|
**Never load a font in a component or page.**
|
||||||
- Use `<Typography variant=…>` for text — it inherits the correct direction-aware family
|
- Use `<Typography variant=…>` for text — it inherits the correct direction-aware family
|
||||||
(`TYPOGRAPHY_RTL` = Mikhak everywhere for full Persian glyph coverage; `TYPOGRAPHY_LTR`).
|
(`TYPOGRAPHY_RTL` = Mikhak everywhere for full Persian glyph coverage; `TYPOGRAPHY_LTR`
|
||||||
Import neither directly in components; let the theme apply them.
|
= Space Grotesk headings + system-stack body). Import neither directly in components;
|
||||||
|
let the theme apply them.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -141,13 +188,30 @@ Icons are a **name registry**, not free imports. `src/components/common/AppIcon/
|
|||||||
maps lowercase names → MUI/SVG components. Render with `<AppIcon icon="home" />` or pass
|
maps lowercase names → MUI/SVG components. Render with `<AppIcon icon="home" />` or pass
|
||||||
the name to `AppButton`/`AppIconButton` (`icon="search"`).
|
the name to `AppButton`/`AppIconButton` (`icon="search"`).
|
||||||
|
|
||||||
Currently registered: `default, logo, close, menu, settings, visibilityon,
|
**One visual family: MUI `*Rounded`.** Every registered icon is the `Rounded` variant of
|
||||||
visibilityoff, daynight, night, day, search, info, home, account, signup, login,
|
`@mui/icons-material` (warmer, softer strokes than the old filled/outlined mix — fits
|
||||||
logout, notifications, error`.
|
"clinical-but-human"). When adding an icon, import the `*Rounded` version; don't mix in a
|
||||||
|
Filled/Outlined/Sharp/TwoTone icon next to it. ~90 names are registered today, spanning
|
||||||
|
navigation, catalog, verification, booking, payments, admin, and messaging — read
|
||||||
|
`AppIcon/config.ts` directly for the full list rather than duplicating it here (it drifts
|
||||||
|
too fast for a skill doc to track reliably); the two structural rules below don't.
|
||||||
|
|
||||||
**Need a new icon:** import it into `config.ts`, add a **lowercase** key to `ICONS`, then
|
**`size` actually resizes now.** `AppIcon` drives size via `style.fontSize` (the basis for
|
||||||
reference by that name. Custom SVGs go in `AppIcon/icons/`. An unregistered name logs a
|
MUI SvgIcon's internal `1em` sizing) instead of `width`/`height` attributes, which MUI's
|
||||||
warning and falls back to `default` — never pass a raw MUI icon where a name is expected.
|
own CSS used to beat. `<AppIcon icon="verified" size={48} />` renders 48px — no more
|
||||||
|
silent 24px flattening.
|
||||||
|
|
||||||
|
**Directional icons mirror automatically.** Icons authored for LTR that must flip under
|
||||||
|
RTL (`back`, `chevron_start`) are registered in `AppIcon/config.ts`'s `DIRECTIONAL_ICONS`
|
||||||
|
set. `AppIcon` stamps `data-icon-directional` on those, and one CSS rule
|
||||||
|
(`app/globals.css`) does `[dir='rtl'] [data-icon-directional] { transform: scaleX(-1); }`.
|
||||||
|
Adding a new directional icon is a one-line registry addition — never hand-roll a
|
||||||
|
per-component flip.
|
||||||
|
|
||||||
|
**Need a new icon:** import the `*Rounded` version into `config.ts`, add a **lowercase**
|
||||||
|
key to `ICONS`, then reference by that name. Custom SVGs (the brand mark) go in
|
||||||
|
`AppIcon/icons/`. An unregistered name logs a dev-only warning and falls back to
|
||||||
|
`default` — never pass a raw MUI icon where a name is expected.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,6 @@ NEXT_PUBLIC_PUBLIC_URL = http://localhost:3000
|
|||||||
|
|
||||||
|
|
||||||
# API/Backend basic URL (the Baya server)
|
# API/Backend basic URL (the Baya server)
|
||||||
NEXT_PUBLIC_API_URL = https://localhost:5002
|
NEXT_PUBLIC_API_URL = http://localhost:5002
|
||||||
# NEXT_PUBLIC_API_URL = https://dev-api.domain.com
|
# NEXT_PUBLIC_API_URL = https://dev-api.domain.com
|
||||||
# NEXT_PUBLIC_API_URL = https://api.domain.com
|
# NEXT_PUBLIC_API_URL = https://api.domain.com
|
||||||
@@ -111,22 +111,31 @@ client/
|
|||||||
├── app/
|
├── app/
|
||||||
│ ├── globals.css
|
│ ├── globals.css
|
||||||
│ ├── fonts/ # Local font files (woff2) — Mikhak for fa
|
│ ├── fonts/ # Local font files (woff2) — Mikhak for fa
|
||||||
|
│ ├── global-error.tsx # Special file above [locale] — replaces the root layout on a root-level crash; renders its own <html>, so it CANNOT use next-intl. The one sanctioned static-string exception (minimal, bilingual fa+en).
|
||||||
│ └── [locale]/
|
│ └── [locale]/
|
||||||
│ ├── layout.tsx # ROOT RSC: renders <html lang/dir> + fonts + setRequestLocale + NextIntlClientProvider + ThemeProvider + AuthProvider (seeded via getServerAuthState)
|
│ ├── layout.tsx # ROOT RSC: renders <html lang/dir> + fonts + setRequestLocale + NextIntlClientProvider + ThemeProvider + AuthProvider (seeded via getServerAuthState) + generateMetadata (the '%s | برند' title template)
|
||||||
|
│ ├── error.tsx # Branded, localized error boundary for the whole [locale] segment — reset() retries, a "go home" link escapes
|
||||||
|
│ ├── not-found.tsx # Branded, localized 404 (RSC) — reached via the [...rest] catch-all below
|
||||||
|
│ ├── [...rest]/page.tsx # Catch-all — calls notFound() so any unmatched path under a locale renders not-found.tsx (next-intl's recommended 404 pattern)
|
||||||
│ ├── (private-routes)/
|
│ ├── (private-routes)/
|
||||||
│ │ ├── layout.tsx # 'use client' — wraps PrivateLayout; mounts useSessionRoleSync (hydrates AuthContext roles from /me)
|
│ │ ├── layout.tsx # 'use client' — wraps PrivateLayout; mounts useSessionRoleSync (hydrates AuthContext roles from /me)
|
||||||
|
│ │ ├── _chrome/SidebarShellSkeleton.tsx # Private (`_`-prefixed, not a route) shared loading.tsx skeleton for the 3 sidebar shells (nurse/admin/partner)
|
||||||
│ │ ├── select-role/page.tsx # /select-role — first-use role picker (no public role yet); role router lands here
|
│ │ ├── select-role/page.tsx # /select-role — first-use role picker (no public role yet); role router lands here
|
||||||
│ │ ├── (customer)/ # Customer (family) app — mobile-first, bottom-tab nav; no URL segment
|
│ │ ├── (customer)/ # Customer (family) app — mobile-first, bottom-tab nav; no URL segment
|
||||||
│ │ │ ├── layout.tsx # 'use client' — RoleGuard(expected=customer) → CustomerLayout
|
│ │ │ ├── layout.tsx # 'use client' — RoleGuard(expected=customer) → CustomerLayout
|
||||||
│ │ │ ├── page.tsx # / (A5 home — 'use client'; greeting+avatar, search bar, data-driven category grid, first-login onboarding gate + record/profile nudges)
|
│ │ │ ├── loading.tsx # Route-group loading skeleton (header + search bar + category-tile row + card stack)
|
||||||
|
│ │ │ ├── page.tsx # Thin RSC — generateMetadata (shell.customer_app) + renders HomeScreen
|
||||||
|
│ │ │ ├── HomeScreen.tsx # 'use client' — the actual A5 home body (moved out of page.tsx for the metadata pattern; see "Per-page metadata" below)
|
||||||
│ │ │ ├── search/ # /search — f6 discovery: C1 filter screen (page.tsx: reused category grid + f3 region picker + prominent same-gender facet + Toman price + live-count CTA; useSearchFilters colocated controller) → results/ (C2) → nurse/[nurseId]/ (C3)
|
│ │ │ ├── search/ # /search — f6 discovery: C1 filter screen (page.tsx: reused category grid + f3 region picker + prominent same-gender facet + Toman price + live-count CTA; useSearchFilters colocated controller) → results/ (C2) → nurse/[nurseId]/ (C3)
|
||||||
│ │ │ │ ├── page.tsx # C1 search & filter; reads ?category_id preselect; pushes filter set to C2 as URL query params
|
│ │ │ │ ├── page.tsx # Thin RSC — generateMetadata (search.title) + renders SearchScreen
|
||||||
│ │ │ │ ├── useSearchFilters.ts # C1 colocated filter controller (debounced Toman price → IRR; derives the canonical NurseSearchFilters)
|
│ │ │ │ ├── SearchScreen.tsx # 'use client' — C1 search & filter body; reads ?category_id preselect; pushes filter set to C2 as URL query params
|
||||||
│ │ │ │ ├── results/page.tsx # C2 results — rating-sorted NurseResultCard list; all four states (skeleton/empty-relax/error/populated); load-more; filters live in the URL (the cache key)
|
│ │ │ │ ├── useSearchFilters.ts # C1 colocated filter controller (debounced Toman price → IRR; derives the canonical NurseSearchFilters)
|
||||||
│ │ │ │ └── nurse/[nurseId]/page.tsx # C3 nurse profile — badges (TrustBadge + نظام پرستاری) + attribute chips + a f13 tab strip: «خدمات» (ServicePriceRow list) / «نظرات» (ReviewsPanel — published-only aggregate+count + infinite list via services/reviews); "درخواست رزرو" hands off to /bookings/request (f7)
|
│ │ │ │ ├── results/page.tsx # C2 results — rating-sorted NurseResultCard list; all four states (skeleton/empty-relax/error/populated); load-more; filters live in the URL (the cache key)
|
||||||
|
│ │ │ │ └── nurse/[nurseId]/page.tsx # C3 nurse profile — badges (TrustBadge + نظام پرستاری) + attribute chips + a f13 tab strip: «خدمات» (ServicePriceRow list) / «نظرات» (ReviewsPanel — published-only aggregate+count + infinite list via services/reviews); "درخواست رزرو" hands off to /bookings/request (f7)
|
||||||
│ │ │ ├── onboarding/page.tsx # /onboarding — A3→A4 wizard (relation → first patient)
|
│ │ │ ├── onboarding/page.tsx # /onboarding — A3→A4 wizard (relation → first patient)
|
||||||
│ │ │ ├── bookings/
|
│ │ │ ├── bookings/
|
||||||
│ │ │ │ ├── page.tsx # /bookings — f8 رزروها list (useBookingList('customer')); rows → booking detail
|
│ │ │ │ ├── page.tsx # Thin RSC — generateMetadata (booking.list_title) + renders BookingsScreen
|
||||||
|
│ │ │ │ ├── BookingsScreen.tsx # 'use client' — f8 رزروها list body (useBookingList('customer')); rows → booking detail
|
||||||
│ │ │ │ ├── [id]/page.tsx # /bookings/[id] — f8 customer booking detail (BookingDetailView viewerRole="customer") + f10 cancel/refund entry (CustomerBookingActions) + f13 review entry (LeaveReviewCta: on a completed/closed booking, «ثبت نظر» → review page, flips to a passive "under review" affordance once reviewed — reuses the cached booking + my-review query)
|
│ │ │ │ ├── [id]/page.tsx # /bookings/[id] — f8 customer booking detail (BookingDetailView viewerRole="customer") + f10 cancel/refund entry (CustomerBookingActions) + f13 review entry (LeaveReviewCta: on a completed/closed booking, «ثبت نظر» → review page, flips to a passive "under review" affordance once reviewed — reuses the cached booking + my-review query)
|
||||||
│ │ │ │ ├── request/page.tsx # /bookings/request — f7 C4 request form (patient/variant/address/date/time + first-class caregiver-gender + stage-1 notes); C3 hands off the nurse/variant/required_gender here → creates a request → C5
|
│ │ │ │ ├── request/page.tsx # /bookings/request — f7 C4 request form (patient/variant/address/date/time + first-class caregiver-gender + stage-1 notes); C3 hands off the nurse/variant/required_gender here → creates a request → C5
|
||||||
│ │ │ │ ├── request/[id]/page.tsx # /bookings/request/[id] — f7 C5 awaiting screen: summary card + 3-step tracker + polled status; response countdown → (accept) 30-min payment countdown + checkout CTA / (reject/expire/cancel) terminal cards; converted → booking deep-link (bookingId, REQ-017)
|
│ │ │ │ ├── request/[id]/page.tsx # /bookings/request/[id] — f7 C5 awaiting screen: summary card + 3-step tracker + polled status; response countdown → (accept) 30-min payment countdown + checkout CTA / (reject/expire/cancel) terminal cards; converted → booking deep-link (bookingId, REQ-017)
|
||||||
@@ -155,7 +164,8 @@ client/
|
|||||||
│ │ │ └── notifications/page.tsx # /notifications — f14 notification center (NotificationCenter role="customer"); the TopBar bell deep-links here
|
│ │ │ └── notifications/page.tsx # /notifications — f14 notification center (NotificationCenter role="customer"); the TopBar bell deep-links here
|
||||||
│ │ ├── nurse/ # Nurse app (/nurse/…) — sidebar shell
|
│ │ ├── nurse/ # Nurse app (/nurse/…) — sidebar shell
|
||||||
│ │ │ ├── layout.tsx # 'use client' — RoleGuard(expected=nurse) → NurseLayout
|
│ │ │ ├── layout.tsx # 'use client' — RoleGuard(expected=nurse) → NurseLayout
|
||||||
│ │ │ ├── page.tsx # /nurse (dashboard)
|
│ │ │ ├── loading.tsx # → ../_chrome/SidebarShellSkeleton
|
||||||
|
│ │ │ ├── page.tsx # /nurse (dashboard) — RSC; generateMetadata (nav.dashboard) inline (no split — already a Server Component)
|
||||||
│ │ │ ├── requests/ # /nurse/requests — f7 incoming booking-requests inbox (page.tsx: pending list, per-request countdown + gender chip + notes preview) ↔ requests/[id]/page.tsx detail (only customerNotes + masked city/district; accept/reject-with-reason invalidate inbox+detail)
|
│ │ │ ├── requests/ # /nurse/requests — f7 incoming booking-requests inbox (page.tsx: pending list, per-request countdown + gender chip + notes preview) ↔ requests/[id]/page.tsx detail (only customerNotes + masked city/district; accept/reject-with-reason invalidate inbox+detail)
|
||||||
│ │ │ ├── profile/page.tsx # /nurse/profile — B7 profile bootstrap (avatar+bio+years; unverified placeholder)
|
│ │ │ ├── profile/page.tsx # /nurse/profile — B7 profile bootstrap (avatar+bio+years; unverified placeholder)
|
||||||
│ │ │ ├── services/ # /nurse/services — B7 services half: offerings list ↔ variant builder (page.tsx switches mode; MyServicesList + VariantBuilder + PublishGate co-located; PublishGate is the f5 verification-gated go-live)
|
│ │ │ ├── services/ # /nurse/services — B7 services half: offerings list ↔ variant builder (page.tsx switches mode; MyServicesList + VariantBuilder + PublishGate co-located; PublishGate is the f5 verification-gated go-live)
|
||||||
@@ -174,7 +184,9 @@ client/
|
|||||||
│ │ │ └── notifications/page.tsx # /nurse/notifications — f14 notification center (role="nurse"); the nurse-shell bell deep-links here
|
│ │ │ └── notifications/page.tsx # /nurse/notifications — f14 notification center (role="nurse"); the nurse-shell bell deep-links here
|
||||||
│ │ ├── admin/ # Admin/backoffice (/admin/…) — desktop sidebar shell (f15). Every screen is role-gated via useAdminCapabilities(); the sidebar hides a console the current admin role can't act on (server still enforces).
|
│ │ ├── admin/ # Admin/backoffice (/admin/…) — desktop sidebar shell (f15). Every screen is role-gated via useAdminCapabilities(); the sidebar hides a console the current admin role can't act on (server still enforces).
|
||||||
│ │ │ ├── layout.tsx # 'use client' — RoleGuard(expected=admin) → AdminLayout (capability-gated nav)
|
│ │ │ ├── layout.tsx # 'use client' — RoleGuard(expected=admin) → AdminLayout (capability-gated nav)
|
||||||
│ │ │ ├── page.tsx # /admin — f15 overview landing: a capability-gated grid of console cards
|
│ │ │ ├── loading.tsx # → ../_chrome/SidebarShellSkeleton
|
||||||
|
│ │ │ ├── page.tsx # Thin RSC — generateMetadata (admin.overview_title) + renders AdminOverviewScreen
|
||||||
|
│ │ │ ├── AdminOverviewScreen.tsx # 'use client' — f15 overview landing: a capability-gated grid of console cards
|
||||||
│ │ │ ├── verification/ # /admin/verification — f15 review queue (page.tsx: status-filtered nurse worklist) ↔ [nurseId]/page.tsx per-nurse case (DocumentViewer signed-URL docs, pass/reject+reason per step, structured credential entry, Approve enabled only when all steps pass — client never writes is_verified)
|
│ │ │ ├── verification/ # /admin/verification — f15 review queue (page.tsx: status-filtered nurse worklist) ↔ [nurseId]/page.tsx per-nurse case (DocumentViewer signed-URL docs, pass/reject+reason per step, structured credential entry, Approve enabled only when all steps pass — client never writes is_verified)
|
||||||
│ │ │ ├── tickets/ # /admin/tickets — f15 global ticket queue (page.tsx: filter status/category/referenceCode) ↔ [id]/page.tsx admin thread (AdminMessageBubble renders isInternal notes distinctly; internal-note composer; RefundPanel opens from a refund ticket)
|
│ │ │ ├── tickets/ # /admin/tickets — f15 global ticket queue (page.tsx: filter status/category/referenceCode) ↔ [id]/page.tsx admin thread (AdminMessageBubble renders isInternal notes distinctly; internal-note composer; RefundPanel opens from a refund ticket)
|
||||||
│ │ │ ├── payouts/ # /admin/payouts — f15 batch dashboard (page.tsx: batches + preview-next-batch dialog → run, idempotency-keyed) ↔ [batchId]/page.tsx per-nurse rows + failed-payout retry + transfer-reference reconcile
|
│ │ │ ├── payouts/ # /admin/payouts — f15 batch dashboard (page.tsx: batches + preview-next-batch dialog → run, idempotency-keyed) ↔ [batchId]/page.tsx per-nurse rows + failed-payout retry + transfer-reference reconcile
|
||||||
@@ -189,14 +201,34 @@ client/
|
|||||||
│ │ │ └── notifications/page.tsx # /admin/notifications
|
│ │ │ └── notifications/page.tsx # /admin/notifications
|
||||||
│ │ └── partner/ # Partner-center portal (/partner/…) — a SEPARATE authz scope (f15). A center admin is not a Balinyaar admin; each page resolves the caller's OWN center (useMyPartnerCenter → access-denied on 403/404).
|
│ │ └── partner/ # Partner-center portal (/partner/…) — a SEPARATE authz scope (f15). A center admin is not a Balinyaar admin; each page resolves the caller's OWN center (useMyPartnerCenter → access-denied on 403/404).
|
||||||
│ │ ├── layout.tsx # 'use client' — RoleGuard (no expected role — hydration-only) → PartnerLayout (own partner nav; self-gates via useMyPartnerCenter)
|
│ │ ├── layout.tsx # 'use client' — RoleGuard (no expected role — hydration-only) → PartnerLayout (own partner nav; self-gates via useMyPartnerCenter)
|
||||||
│ │ ├── page.tsx # /partner — center home: onboarding/verification state banner + license fields + is_merchant_of_record indicator
|
│ │ ├── loading.tsx # → ../_chrome/SidebarShellSkeleton
|
||||||
|
│ │ ├── page.tsx # Thin RSC — generateMetadata (partner.home_title) + renders PartnerHomeScreen
|
||||||
|
│ │ ├── PartnerHomeScreen.tsx # 'use client' — center home: onboarding/verification state banner + license fields + is_merchant_of_record indicator
|
||||||
│ │ ├── nurses/page.tsx # /partner/nurses — the center's sponsored nurses (verification badge)
|
│ │ ├── nurses/page.tsx # /partner/nurses — the center's sponsored nurses (verification badge)
|
||||||
│ │ ├── bookings/page.tsx # /partner/bookings — the bookings the center legally covers (read-only summaries)
|
│ │ ├── bookings/page.tsx # /partner/bookings — the bookings the center legally covers (read-only summaries)
|
||||||
│ │ └── settlement/page.tsx # /partner/settlement — rendered ONLY when is_merchant_of_record: per-booking commission invoices (commission/VAT decomposition via PartnerSettlementRow, signed-URL PDF, masked IBAN); non-MoR shows the "settlement via Balinyaar" state
|
│ │ └── settlement/page.tsx # /partner/settlement — rendered ONLY when is_merchant_of_record: per-booking commission invoices (commission/VAT decomposition via PartnerSettlementRow, signed-URL PDF, masked IBAN); non-MoR shows the "settlement via Balinyaar" state
|
||||||
│ └── (public-routes)/
|
│ └── (public-routes)/
|
||||||
│ ├── layout.tsx # 'use client' — wraps PublicLayout
|
│ ├── layout.tsx # 'use client' — wraps PublicLayout
|
||||||
│ └── login/page.tsx # /login — phone-OTP login (A1/A2 customer, B1/B2 nurse switch)
|
│ ├── loading.tsx # Auth-card-shaped skeleton (brand mark + a card-sized block)
|
||||||
|
│ └── login/ # /login — phone-OTP login (A1/A2 customer, B1/B2 nurse switch)
|
||||||
|
│ ├── page.tsx # Thin RSC — generateMetadata (auth.customer_title) + renders LoginScreen
|
||||||
|
│ └── LoginScreen.tsx # 'use client' — the actual LoginFlow body
|
||||||
├── components/ # Shared UI components (each with .test.tsx if imported >1 place)
|
├── components/ # Shared UI components (each with .test.tsx if imported >1 place)
|
||||||
|
│ ├── common/ # Foundational primitives (import from @/components or @/components/common)
|
||||||
|
│ │ ├── AppButton/, AppIconButton/, AppIcon/, AppLink/, AppAlert/, AppLoading/ # house-default MUI wrappers (see frontend-designer skill §4)
|
||||||
|
│ │ ├── ErrorBoundary.tsx # class component wrapping page content in the shell; PRESENTATIONAL — no next-intl import, caller passes title/body/retryLabel (see "Presentational purity" below)
|
||||||
|
│ │ ├── EmptyState/ # icon+title+body+action — the one "nothing here" pattern (replaces hand-rolled dashed-border Paper blocks)
|
||||||
|
│ │ ├── ErrorState/ # message+retryLabel(required)+onRetry — the one "this query failed" pattern; PRESENTATIONAL, no next-intl import (same reason as ErrorBoundary)
|
||||||
|
│ │ ├── QueryStateGate/ # wraps a query's skeleton/error/empty/children branching in the fixed skeleton→error→empty→children order; requires retryLabel
|
||||||
|
│ │ ├── PageHeader/ # title+subtitle+actions+optional back button (backTo/backLabel)
|
||||||
|
│ │ ├── ConfirmDialog/ # promoted from admin/ — required-reason gating + busy-disable, now usable by any actor
|
||||||
|
│ │ ├── SurfaceCard/ # flat Paper wrapper, padding: 'sm'|'md'|'lg'
|
||||||
|
│ │ ├── AccentCard/ # SurfaceCard + tone → 4px borderInlineStart accent (primary/secondary/success/error/warning/info/trust/neutral)
|
||||||
|
│ │ ├── Money/ # <Money amountIrr size tone deduction hideUnit strikethrough> — the one money-rendering primitive (wraps utils/money.ts); 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, RTL-aware keyboard nav
|
||||||
|
│ │ ├── JalaliDateField/ # read-only TextField + Popover wrapping JalaliDatePicker
|
||||||
|
│ │ └── index.tsx # barrel — keep next-intl-importing primitives (Money) below the presentational ones so the poisoning risk stays visible in review
|
||||||
│ ├── PlaceholderScreen/ # Empty-state scaffold for not-yet-built screens
|
│ ├── PlaceholderScreen/ # Empty-state scaffold for not-yet-built screens
|
||||||
│ ├── OtpInput/ # OTP code input (auto-advance, paste, RTL-safe)
|
│ ├── OtpInput/ # OTP code input (auto-advance, paste, RTL-safe)
|
||||||
│ ├── PhoneNumberField/ # Iranian mobile field (digit-normalizing, LTR-in-RTL, maskIranMobile)
|
│ ├── PhoneNumberField/ # Iranian mobile field (digit-normalizing, LTR-in-RTL, maskIranMobile)
|
||||||
@@ -319,16 +351,15 @@ client/
|
|||||||
│ └── auth/ # AuthContext — AuthProvider (server-seeded) + reducer + useAuth
|
│ └── auth/ # AuthContext — AuthProvider (server-seeded) + reducer + useAuth
|
||||||
├── theme/
|
├── theme/
|
||||||
│ ├── ThemeProvider.tsx # MuiThemeProvider wrapper (RTL cache) + ColorSchemeCookieSync
|
│ ├── ThemeProvider.tsx # MuiThemeProvider wrapper (RTL cache) + ColorSchemeCookieSync
|
||||||
│ ├── colors.ts # BRAND, LIGHT_PALETTE, DARK_PALETTE
|
│ ├── colors.ts # BRAND, LIGHT_PALETTE, DARK_PALETTE (incl. success/error/warning/info)
|
||||||
│ ├── light.ts / dark.ts # LIGHT_THEME / DARK_THEME ThemeOptions (consumed by theme.ts)
|
|
||||||
│ ├── direction.ts # getDirection(locale) → 'ltr' | 'rtl'
|
│ ├── direction.ts # getDirection(locale) → 'ltr' | 'rtl'
|
||||||
│ ├── theme.ts # APP_THEME_LTR / APP_THEME_RTL (static, created once)
|
│ ├── theme.ts # APP_THEME_LTR / APP_THEME_RTL (static, created once) — the `components` brand pass + teal-tinted `shadows` array + responsiveFontSizes()
|
||||||
│ ├── tokens.css # CSS custom properties — [data-mui-color-scheme] selectors
|
│ ├── tokens.css # CSS custom properties — [data-mui-color-scheme] selectors + the dark @media fallback (no-flash boot, no script — see "Theme System" below)
|
||||||
│ ├── typography.ts # TYPOGRAPHY_LTR (Space Grotesk) / TYPOGRAPHY_RTL (Mikhak)
|
│ ├── typography.ts # TYPOGRAPHY_LTR (Space Grotesk) / TYPOGRAPHY_RTL (Mikhak) — shared size scale, 500/700 weight system
|
||||||
│ └── index.ts # Public re-exports (ThemeProvider, getDirection, APP_THEME_*) — note: no ColorSchemeScript is exported/rendered today (doc drift below)
|
│ └── index.ts # Public re-exports (ThemeProvider, getDirection, APP_THEME_*)
|
||||||
├── constants/ # App-wide constants (routes.ts w/ actor paths, roles.ts, headers.ts)
|
├── constants/ # App-wide constants (routes.ts w/ actor paths, roles.ts, headers.ts)
|
||||||
├── hooks/ # incl. auth.ts → useIsAuthenticated / useActorRole (role-aware chrome)
|
├── hooks/ # incl. auth.ts → useIsAuthenticated / useActorRole (role-aware chrome)
|
||||||
├── utils/ # incl. money.ts (IRR/Toman, integer-safe) + date.ts (Shamsi display) + toEnglishDigits
|
├── utils/ # incl. money.ts (IRR/Toman, integer-safe) + date.ts (Shamsi display) + number.ts (localeTag/formatNumber/formatRelativeTime/formatClock — the one home for locale-ternary formatting) + toEnglishDigits
|
||||||
└── config.ts
|
└── config.ts
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -355,6 +386,37 @@ client/
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Per-page metadata (the client-page pattern)
|
||||||
|
|
||||||
|
The root layout (`src/app/[locale]/layout.tsx`) exports a locale-aware `generateMetadata` that sets a
|
||||||
|
title template — `'%s | بالینیار'` (fa) / `'%s | Balinyaar'` (en) — plus a default title and description.
|
||||||
|
Any route that wants its own tab title supplies the `%s`: make `page.tsx` a thin RSC (no `'use client'`)
|
||||||
|
that exports `generateMetadata` (or a static `metadata` when the title needs no translation lookup) and
|
||||||
|
renders a co-located `'use client'` body component holding all the page's logic/JSX, named
|
||||||
|
`<PageName>Screen.tsx` (e.g. `HomeScreen.tsx`, `SearchScreen.tsx`) in the same folder — so its existing
|
||||||
|
relative imports keep working unchanged. The screen's returned title composes automatically into the
|
||||||
|
root template; `page.tsx` itself never renders `<title>` or touches `document.title`. Only the 7 landing
|
||||||
|
pages (customer home, `/login`, `/search`, `/bookings`, `/nurse`, `/admin`, `/partner`) have adopted this
|
||||||
|
so far — the rest is deferred to the area phases (3–11).
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import type { Metadata } from 'next';
|
||||||
|
import { getTranslations } from 'next-intl/server';
|
||||||
|
import HomeScreen from './HomeScreen';
|
||||||
|
|
||||||
|
export async function generateMetadata({ params }: { params: Promise<{ locale: string }> }): Promise<Metadata> {
|
||||||
|
const { locale } = await params;
|
||||||
|
const t = await getTranslations({ locale, namespace: 'shell' });
|
||||||
|
return { title: t('customer_app') };
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Page() {
|
||||||
|
return <HomeScreen />;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## i18n (next-intl v4)
|
## i18n (next-intl v4)
|
||||||
|
|
||||||
**Adding translations:**
|
**Adding translations:**
|
||||||
@@ -458,15 +520,42 @@ Rules:
|
|||||||
|
|
||||||
## Theme System
|
## Theme System
|
||||||
|
|
||||||
### How it works (end-to-end, no-flash)
|
### How it works (end-to-end, no-flash — CSS only, no boot script)
|
||||||
|
|
||||||
1. **Request arrives** → `getThemeMode()` reads `'color-scheme'` cookie → returns `{ colorScheme, defaultMode }`
|
The no-flash mechanism is **pure CSS**, matching how every other color decision in this
|
||||||
2. **Root layout** sets `data-mui-color-scheme={colorScheme}` on `<html>` server-side
|
app is made — no inline `<script>`, no `Storage.prototype` patching. Two visitor cases:
|
||||||
3. **`<ColorSchemeScript />`** in `<head>` runs before any paint:
|
|
||||||
- Reads the same cookie, sets `data-mui-color-scheme` (handles edge cases where server attr might differ)
|
**Returning visitor (cookie present):**
|
||||||
- Patches `Storage.prototype` — routes MUI's `localStorage` writes for key `'mode'` to our cookie; reads return `null` so MUI always trusts the `defaultMode` prop
|
1. `getThemeMode()` (`lib/cookies/server.ts`) reads the `'color-scheme'` cookie → returns
|
||||||
4. **`<MuiThemeProvider defaultMode={defaultMode}>`** mounts — uses the server-derived mode, not localStorage
|
`{ colorScheme: 'light'|'dark', defaultMode: colorScheme }`.
|
||||||
5. **`ColorSchemeCookieSync`** in ThemeProvider writes the cookie via `useColorScheme().colorScheme` on mount (safety net for first-visit system mode)
|
2. Root layout sets `data-mui-color-scheme={colorScheme}` on `<html>` server-side.
|
||||||
|
3. `tokens.css`'s explicit `[data-mui-color-scheme='light'|'dark']` blocks match
|
||||||
|
immediately — correct on the very first paint, before any JS runs.
|
||||||
|
|
||||||
|
**First-ever visitor (no cookie yet):**
|
||||||
|
1. `getThemeMode()` returns `{ colorScheme: undefined, defaultMode: 'system' }`.
|
||||||
|
2. Root layout renders `<html>` **without** the `data-mui-color-scheme` attribute at all
|
||||||
|
(`data-mui-color-scheme={undefined}` — React omits it).
|
||||||
|
3. `tokens.css` has a `@media (prefers-color-scheme: dark)` block scoped to
|
||||||
|
`:root:not([data-mui-color-scheme])` — it only applies while the attribute is absent,
|
||||||
|
and paints the OS-preferred scheme immediately, with zero JS.
|
||||||
|
4. Once React hydrates, `<MuiThemeProvider defaultMode="system">` resolves the *same*
|
||||||
|
media query and stamps the attribute itself. The CSS values already match what was
|
||||||
|
painted, so there is nothing to visibly flip.
|
||||||
|
5. `ColorSchemeCookieSync` in `ThemeProvider.tsx` writes the cookie via
|
||||||
|
`useColorScheme().colorScheme` on mount, so the next visit is a "returning visitor".
|
||||||
|
|
||||||
|
**Trade-off, by design:** this covers the dominant visual surface — every `--bal-*` token
|
||||||
|
(page/paper background, text, dividers, all brand colors) — because that's what
|
||||||
|
`tokens.css`'s media-query fallback drives. MUI's own generated `--mui-palette-*`
|
||||||
|
variables (consumed by a bare `color="primary"` fill, e.g. a contained Button, or the
|
||||||
|
default `MuiTabs` indicator) do **not** get the same free fallback — MUI's
|
||||||
|
`colorSchemeSelector` supports either attribute-based *or* `'media'`-based generation,
|
||||||
|
not both at once — so on a cookie-less first visit with OS dark on, a raw MUI-primary
|
||||||
|
fill can very briefly show the light value until hydration (self-corrects same frame;
|
||||||
|
`disableTransitionOnChange` means it snaps, never animates). Prefer sourcing colors from
|
||||||
|
`var(--bal-*)` over `theme.vars.palette.*` in new `styleOverrides` — most of `theme.ts`'s
|
||||||
|
`components` block already does — to keep this gap as small as possible.
|
||||||
|
|
||||||
### Critical MUI v9 rules
|
### Critical MUI v9 rules
|
||||||
|
|
||||||
@@ -484,7 +573,10 @@ The shorthand `'data'` in MUI v9 generates `[data-%s]` → `data-dark=""` / `dat
|
|||||||
In MUI v9's `localStorageManager`, the check is `if (!storageWindow && typeof window !== 'undefined')` — `null` is falsy, so it silently overrides to `window`. This prop is a no-op in browsers. The `Storage.prototype` patch in `ColorSchemeScript` is the correct intercept.
|
In MUI v9's `localStorageManager`, the check is `if (!storageWindow && typeof window !== 'undefined')` — `null` is falsy, so it silently overrides to `window`. This prop is a no-op in browsers. The `Storage.prototype` patch in `ColorSchemeScript` is the correct intercept.
|
||||||
|
|
||||||
**Never use MUI's `InitColorSchemeScript`:**
|
**Never use MUI's `InitColorSchemeScript`:**
|
||||||
It reads from localStorage which diverges from our cookie (especially in 'system' mode). Use `ColorSchemeScript` from `@/theme` instead.
|
It reads from localStorage, which diverges from our cookie (especially in 'system' mode),
|
||||||
|
and it's a script — this app's no-flash boot is CSS-only (see "How it works" above). Don't
|
||||||
|
add any pre-paint script for color scheme; extend the `tokens.css` media-query fallback
|
||||||
|
instead if a new token needs the same first-visit treatment.
|
||||||
|
|
||||||
**MUI v9 localStorage key defaults (different from v5/v6):**
|
**MUI v9 localStorage key defaults (different from v5/v6):**
|
||||||
- Mode key: `'mode'` (was `'mui-mode'`)
|
- Mode key: `'mode'` (was `'mui-mode'`)
|
||||||
@@ -536,18 +628,22 @@ Fonts are loaded **per locale** — the Persian face is never shipped to English
|
|||||||
| Locale | Font | CSS variable | Source | Loaded when |
|
| Locale | Font | CSS variable | Source | Loaded when |
|
||||||
|--------|------|--------------|--------|-------------|
|
|--------|------|--------------|--------|-------------|
|
||||||
| `fa` (RTL) | **Mikhak** | `--font-mikhak` | `next/font/local` — woff2 files in `src/app/fonts/` | only on `fa` routes |
|
| `fa` (RTL) | **Mikhak** | `--font-mikhak` | `next/font/local` — woff2 files in `src/app/fonts/` | only on `fa` routes |
|
||||||
| `en` (LTR) | **Space Grotesk** | `--font-space-grotesk` | (not currently wired — falls back to the system stack) | — |
|
| `en` (LTR) | **Space Grotesk** | `--font-space-grotesk` | `next/font/google` — self-hosted at build time, `preload: false` | only on `en` routes |
|
||||||
|
|
||||||
**Typography exports:**
|
**Typography exports:**
|
||||||
- `TYPOGRAPHY_LTR` — Space Grotesk headings, system font body (used by `APP_THEME_LTR`)
|
- `TYPOGRAPHY_LTR` — Space Grotesk headings, system font body (used by `APP_THEME_LTR`)
|
||||||
- `TYPOGRAPHY_RTL` — Mikhak for all text including body (used by `APP_THEME_RTL`, ensures full Persian glyph coverage)
|
- `TYPOGRAPHY_RTL` — Mikhak for all text including body (used by `APP_THEME_RTL`, ensures full Persian glyph coverage)
|
||||||
- `TYPOGRAPHY` — alias for `TYPOGRAPHY_LTR` (deprecated, prefer the explicit exports)
|
- Both share one size/line-height scale (`SIZE_SCALE` in `typography.ts`), wrapped in
|
||||||
|
`responsiveFontSizes()` (`theme.ts`) for per-breakpoint heading scaling. Weight system:
|
||||||
|
**700** for headings + buttons, **500** for in-text emphasis (subtitles, labels), **400**
|
||||||
|
body — never `600`, neither font loads that weight (see `typography.ts`'s header comment).
|
||||||
|
- There is no `TYPOGRAPHY` alias anymore — import `TYPOGRAPHY_LTR`/`TYPOGRAPHY_RTL` explicitly.
|
||||||
|
|
||||||
**Rules:**
|
**Rules:**
|
||||||
- Mikhak is declared with `preload: false`, and its `.variable` class is attached to `<html>` **only when `locale === 'fa'`**. Both are required: a `next/font` loader called in the root layout would otherwise preload on every route (including `/en`), and `preload: false` ensures the woff2 only downloads when Persian text actually renders.
|
- Both fonts are declared with `preload: false`, and each `.variable` class is attached to `<html>` **only for its own locale** (Mikhak on `fa`, Space Grotesk on `en`) — never both, never neither. A `next/font` loader called in the root layout would otherwise preload on every route, and `preload: false` ensures the font file only downloads when its locale actually renders.
|
||||||
- Font files live in `src/app/fonts/` (not `public/`). next/font/local resolves paths relative to the calling file (`src/app/[locale]/layout.tsx`) at build time.
|
- Mikhak's woff2 files live in `src/app/fonts/` (not `public/`) — next/font/local resolves paths relative to the calling file (`src/app/[locale]/layout.tsx`) at build time. Space Grotesk needs no local files — next/font/google fetches + self-hosts it at build time.
|
||||||
- Never load fonts inside components — all font loading lives in `src/app/[locale]/layout.tsx`.
|
- Never load fonts inside components — all font loading lives in `src/app/[locale]/layout.tsx`.
|
||||||
- To add a new font, add woff2 files to `src/app/fonts/`, declare via `localFont`/`localFont`-equivalent in `src/app/[locale]/layout.tsx`, attach its `.variable` class conditionally on the matching locale, and update `BRAND_FONT_VARIABLE_*` constants in `typography.ts`.
|
- To add a new local font, add woff2 files to `src/app/fonts/`, declare via `localFont` in `src/app/[locale]/layout.tsx`, attach its `.variable` class conditionally on the matching locale, and update the `BRAND_FONT_VARIABLE_*` constants in `typography.ts`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -568,6 +664,27 @@ Do NOT mock MUI components — test against the rendered DOM.
|
|||||||
|
|
||||||
Enforcement: before removing or renaming a shared component, check whether `src/**/*.test.{ts,tsx}` files import it. If so, update or delete those tests too.
|
Enforcement: before removing or renaming a shared component, check whether `src/**/*.test.{ts,tsx}` files import it. If so, update or delete those tests too.
|
||||||
|
|
||||||
|
### Presentational purity in `components/common`
|
||||||
|
|
||||||
|
`next-intl` (and its `use-intl` dependency) ship ESM-only builds. `jest.config.ts` widens
|
||||||
|
`next/jest`'s default `transformIgnorePatterns` (which otherwise treats *all* of
|
||||||
|
`node_modules` as untransformed CommonJS) to allow `next-intl`/`use-intl`/`@formatjs`/
|
||||||
|
`intl-messageformat` through — but that only fixes real, unmocked imports; it doesn't make
|
||||||
|
the dependency free. **Any component at the top of the `@/components/common` barrel that
|
||||||
|
imports `next-intl` at module scope forces every test file that transitively imports the
|
||||||
|
barrel to deal with it**, even tests that never touch translations.
|
||||||
|
|
||||||
|
So: `ErrorBoundary` and `ErrorState` are deliberately **caller-owned** — they take
|
||||||
|
`title`/`body`/`retryLabel`/`message` as required string props instead of calling
|
||||||
|
`useTranslations` internally, specifically so they stay import-safe at the top of the
|
||||||
|
barrel. `QueryStateGate` inherits the same `retryLabel` requirement by composition. `Money`
|
||||||
|
is the sanctioned exception — it already had 30+ call sites depending on its
|
||||||
|
locale-aware API before this was noticed, so the fix went the other way (widen the Jest
|
||||||
|
transform) rather than stripping `next-intl` from it. When adding a new `components/common`
|
||||||
|
primitive: prefer the caller-owned-copy pattern by default, and only reach for
|
||||||
|
`useTranslations` inside it if the component is genuinely leaf-level (nothing else in the
|
||||||
|
barrel needs to stay import-safe around it).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Comments & dead code
|
## Comments & dead code
|
||||||
@@ -794,6 +911,8 @@ dispatchToast('Something went wrong', 'error')
|
|||||||
<NotistackProvider>{children}</NotistackProvider>
|
<NotistackProvider>{children}</NotistackProvider>
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**Every mutation needs an `onError` toast.** Every mutation whose failure is not already surfaced inline or by the fetch layer (401/403/5xx are auto-toasted by `clientFetch`) must have an `onError` toast — a mutation that only handles `onSuccess` is a defect.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Route Constants
|
## Route Constants
|
||||||
|
|||||||
@@ -24,5 +24,23 @@ const customJestConfig: Config = {
|
|||||||
// },
|
// },
|
||||||
};
|
};
|
||||||
|
|
||||||
// createJestConfig is exported this way to ensure that next/jest can load the Next.js config which is async
|
// next-intl (and its use-intl dependency) ship ESM-only builds. next/jest's own
|
||||||
module.exports = createJestConfig(customJestConfig);
|
// `transformIgnorePatterns` already broadly matches all of node_modules (its negative-lookahead
|
||||||
|
// allowlist only carves out a couple of Next.js-internal packages), and Jest's array semantics
|
||||||
|
// are OR-based — a file is ignored if ANY pattern matches, so appending a more permissive pattern
|
||||||
|
// can never "un-ignore" a package an earlier pattern already caught. The array has to be replaced
|
||||||
|
// outright (post-processing the async config next/jest returns), not merged, to let a real
|
||||||
|
// (unmocked) `import ... from 'next-intl'` be transformed instead of failing to parse. Without
|
||||||
|
// this, any shared component that imports next-intl (even just for a sensible default) would
|
||||||
|
// force every test file that transitively imports it via the `@/components` barrel to
|
||||||
|
// `jest.mock('next-intl', …)`, even when that test never touches translations itself.
|
||||||
|
const withTransformableIntl = async () => {
|
||||||
|
const config = await createJestConfig(customJestConfig)();
|
||||||
|
config.transformIgnorePatterns = [
|
||||||
|
'/node_modules/(?!(geist|next/dist/client|next/dist/shared/lib|next/src/client|next/src/shared/lib|next-intl|use-intl|@formatjs|intl-messageformat)/)',
|
||||||
|
'^.+\\.module\\.(css|sass|scss)$',
|
||||||
|
];
|
||||||
|
return config;
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = withTransformableIntl;
|
||||||
|
|||||||
@@ -74,7 +74,8 @@
|
|||||||
"nudge_patient_cta": "Open patients",
|
"nudge_patient_cta": "Open patients",
|
||||||
"nudge_profile_title": "Complete your profile",
|
"nudge_profile_title": "Complete your profile",
|
||||||
"nudge_profile_body": "Add an emergency contact to speed up bookings.",
|
"nudge_profile_body": "Add an emergency contact to speed up bookings.",
|
||||||
"nudge_profile_cta": "Go to profile"
|
"nudge_profile_cta": "Go to profile",
|
||||||
|
"patients_error": "Couldn't load your patients."
|
||||||
},
|
},
|
||||||
"onboarding": {
|
"onboarding": {
|
||||||
"step_relation": "Who for",
|
"step_relation": "Who for",
|
||||||
@@ -123,7 +124,8 @@
|
|||||||
"age_years": "{age} yrs",
|
"age_years": "{age} yrs",
|
||||||
"conditions_none": "No recorded conditions",
|
"conditions_none": "No recorded conditions",
|
||||||
"unavailable": "This patient isn't available to you.",
|
"unavailable": "This patient isn't available to you.",
|
||||||
"open_record": "Open {name}'s record"
|
"open_record": "Open {name}'s record",
|
||||||
|
"load_error": "Couldn't load your patients."
|
||||||
},
|
},
|
||||||
"profile": {
|
"profile": {
|
||||||
"title": "Profile",
|
"title": "Profile",
|
||||||
@@ -141,7 +143,8 @@
|
|||||||
"save": "Save",
|
"save": "Save",
|
||||||
"saved": "Profile saved",
|
"saved": "Profile saved",
|
||||||
"completion_done": "Your profile is complete.",
|
"completion_done": "Your profile is complete.",
|
||||||
"completion_todo": "Complete your profile to speed up bookings."
|
"completion_todo": "Complete your profile to speed up bookings.",
|
||||||
|
"load_error": "Couldn't load your profile."
|
||||||
},
|
},
|
||||||
"nurseProfile": {
|
"nurseProfile": {
|
||||||
"title": "Nurse profile",
|
"title": "Nurse profile",
|
||||||
@@ -159,7 +162,9 @@
|
|||||||
"unverified_title": "Your profile isn't active yet",
|
"unverified_title": "Your profile isn't active yet",
|
||||||
"unverified_body": "Complete identity verification to appear to families and receive bookings.",
|
"unverified_body": "Complete identity verification to appear to families and receive bookings.",
|
||||||
"unverified_cta": "Complete verification",
|
"unverified_cta": "Complete verification",
|
||||||
"deferred_services": "Manage your services & prices in the Services section; available days come in a later step."
|
"deferred_services": "Manage your services & prices in the Services section; available days come in a later step.",
|
||||||
|
"save_error": "Couldn't save your profile. Please try again.",
|
||||||
|
"avatar_upload_error": "Couldn't upload your photo. Please try again."
|
||||||
},
|
},
|
||||||
"bank": {
|
"bank": {
|
||||||
"title": "Bank account",
|
"title": "Bank account",
|
||||||
@@ -325,7 +330,9 @@
|
|||||||
"submit_save": "Save changes",
|
"submit_save": "Save changes",
|
||||||
"create_error": "The service couldn't be saved. Check your inputs and try again.",
|
"create_error": "The service couldn't be saved. Check your inputs and try again.",
|
||||||
"created_toast": "Service added",
|
"created_toast": "Service added",
|
||||||
"saved_toast": "Changes saved"
|
"saved_toast": "Changes saved",
|
||||||
|
"list_error": "Couldn't load your services.",
|
||||||
|
"options_error": "Couldn't load this category's options."
|
||||||
},
|
},
|
||||||
"search": {
|
"search": {
|
||||||
"title": "Find a nurse",
|
"title": "Find a nurse",
|
||||||
@@ -564,7 +571,8 @@
|
|||||||
"list_empty_title": "No bookings yet",
|
"list_empty_title": "No bookings yet",
|
||||||
"list_empty_body": "Once a nurse accepts and you pay, your booking appears here.",
|
"list_empty_body": "Once a nurse accepts and you pay, your booking appears here.",
|
||||||
"list_error": "Couldn't load your bookings.",
|
"list_error": "Couldn't load your bookings.",
|
||||||
"list_total": "Total"
|
"list_total": "Total",
|
||||||
|
"inbox_error": "Couldn't load your requests."
|
||||||
},
|
},
|
||||||
"payment": {
|
"payment": {
|
||||||
"title_checkout": "Confirm & pay",
|
"title_checkout": "Confirm & pay",
|
||||||
@@ -811,7 +819,7 @@
|
|||||||
"acknowledge_label": "I understand the refund and fee shown above.",
|
"acknowledge_label": "I understand the refund and fee shown above.",
|
||||||
"continue_cta": "Continue",
|
"continue_cta": "Continue",
|
||||||
"confirm_title": "Confirm cancellation",
|
"confirm_title": "Confirm cancellation",
|
||||||
"confirm_restate": "You'll be refunded {refund} and a cancellation fee of {fee} applies.",
|
"confirm_restate": "You'll be refunded <refund></refund> and a cancellation fee of <fee></fee> applies.",
|
||||||
"confirm_cta": "Confirm cancellation",
|
"confirm_cta": "Confirm cancellation",
|
||||||
"submitting": "Submitting…",
|
"submitting": "Submitting…",
|
||||||
"not_cancellable_title": "This booking can't be cancelled",
|
"not_cancellable_title": "This booking can't be cancelled",
|
||||||
@@ -1578,5 +1586,13 @@
|
|||||||
"invoice_pdf_error": "Couldn’t open the invoice PDF.",
|
"invoice_pdf_error": "Couldn’t open the invoice PDF.",
|
||||||
"invoice_number": "Invoice #{number}",
|
"invoice_number": "Invoice #{number}",
|
||||||
"settlement_iban": "Settlement IBAN"
|
"settlement_iban": "Settlement IBAN"
|
||||||
|
},
|
||||||
|
"routeChrome": {
|
||||||
|
"error_title": "Something went wrong",
|
||||||
|
"error_body": "An unexpected error occurred. You can try again, or head back home.",
|
||||||
|
"error_retry": "Try again",
|
||||||
|
"not_found_title": "Page not found",
|
||||||
|
"not_found_body": "The page you are looking for does not exist or has moved.",
|
||||||
|
"go_home": "Go home"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -74,7 +74,8 @@
|
|||||||
"nudge_patient_cta": "مشاهدهٔ بیماران",
|
"nudge_patient_cta": "مشاهدهٔ بیماران",
|
||||||
"nudge_profile_title": "تکمیل پروفایل",
|
"nudge_profile_title": "تکمیل پروفایل",
|
||||||
"nudge_profile_body": "برای رزرو سریعتر، تماس اضطراری را اضافه کنید.",
|
"nudge_profile_body": "برای رزرو سریعتر، تماس اضطراری را اضافه کنید.",
|
||||||
"nudge_profile_cta": "رفتن به پروفایل"
|
"nudge_profile_cta": "رفتن به پروفایل",
|
||||||
|
"patients_error": "بیماران شما بارگذاری نشد."
|
||||||
},
|
},
|
||||||
"onboarding": {
|
"onboarding": {
|
||||||
"step_relation": "برای چه کسی",
|
"step_relation": "برای چه کسی",
|
||||||
@@ -123,7 +124,8 @@
|
|||||||
"age_years": "{age} سال",
|
"age_years": "{age} سال",
|
||||||
"conditions_none": "وضعیت خاصی ثبت نشده",
|
"conditions_none": "وضعیت خاصی ثبت نشده",
|
||||||
"unavailable": "این بیمار در دسترس شما نیست.",
|
"unavailable": "این بیمار در دسترس شما نیست.",
|
||||||
"open_record": "مشاهدهٔ پروندهٔ {name}"
|
"open_record": "مشاهدهٔ پروندهٔ {name}",
|
||||||
|
"load_error": "بیماران شما بارگذاری نشد."
|
||||||
},
|
},
|
||||||
"profile": {
|
"profile": {
|
||||||
"title": "پروفایل",
|
"title": "پروفایل",
|
||||||
@@ -141,7 +143,8 @@
|
|||||||
"save": "ذخیره",
|
"save": "ذخیره",
|
||||||
"saved": "پروفایل ذخیره شد",
|
"saved": "پروفایل ذخیره شد",
|
||||||
"completion_done": "پروفایل شما کامل است.",
|
"completion_done": "پروفایل شما کامل است.",
|
||||||
"completion_todo": "برای رزرو سریعتر، پروفایل خود را کامل کنید."
|
"completion_todo": "برای رزرو سریعتر، پروفایل خود را کامل کنید.",
|
||||||
|
"load_error": "پروفایل شما بارگذاری نشد."
|
||||||
},
|
},
|
||||||
"nurseProfile": {
|
"nurseProfile": {
|
||||||
"title": "پروفایل پرستار",
|
"title": "پروفایل پرستار",
|
||||||
@@ -159,7 +162,9 @@
|
|||||||
"unverified_title": "پروفایل شما هنوز فعال نیست",
|
"unverified_title": "پروفایل شما هنوز فعال نیست",
|
||||||
"unverified_body": "برای نمایش به خانوادهها و دریافت رزرو، احراز هویت را تکمیل کنید.",
|
"unverified_body": "برای نمایش به خانوادهها و دریافت رزرو، احراز هویت را تکمیل کنید.",
|
||||||
"unverified_cta": "تکمیل احراز هویت",
|
"unverified_cta": "تکمیل احراز هویت",
|
||||||
"deferred_services": "خدمات و قیمتهای خود را در بخش «خدمات و قیمتها» مدیریت کنید؛ روزهای کاری در مرحله بعد اضافه میشود."
|
"deferred_services": "خدمات و قیمتهای خود را در بخش «خدمات و قیمتها» مدیریت کنید؛ روزهای کاری در مرحله بعد اضافه میشود.",
|
||||||
|
"save_error": "ذخیرهٔ پروفایل انجام نشد. دوباره تلاش کنید.",
|
||||||
|
"avatar_upload_error": "بارگذاری عکس انجام نشد. دوباره تلاش کنید."
|
||||||
},
|
},
|
||||||
"bank": {
|
"bank": {
|
||||||
"title": "حساب بانکی",
|
"title": "حساب بانکی",
|
||||||
@@ -325,7 +330,9 @@
|
|||||||
"submit_save": "ذخیره تغییرات",
|
"submit_save": "ذخیره تغییرات",
|
||||||
"create_error": "ثبت خدمت انجام نشد. ورودیها را بررسی کرده و دوباره تلاش کنید.",
|
"create_error": "ثبت خدمت انجام نشد. ورودیها را بررسی کرده و دوباره تلاش کنید.",
|
||||||
"created_toast": "خدمت اضافه شد",
|
"created_toast": "خدمت اضافه شد",
|
||||||
"saved_toast": "تغییرات ذخیره شد"
|
"saved_toast": "تغییرات ذخیره شد",
|
||||||
|
"list_error": "خدمات شما بارگذاری نشد.",
|
||||||
|
"options_error": "گزینههای این دسته بارگذاری نشد."
|
||||||
},
|
},
|
||||||
"search": {
|
"search": {
|
||||||
"title": "یافتن پرستار",
|
"title": "یافتن پرستار",
|
||||||
@@ -564,7 +571,8 @@
|
|||||||
"list_empty_title": "هنوز رزروی ندارید",
|
"list_empty_title": "هنوز رزروی ندارید",
|
||||||
"list_empty_body": "پس از تایید پرستار و پرداخت، رزرو شما اینجا نمایش داده میشود.",
|
"list_empty_body": "پس از تایید پرستار و پرداخت، رزرو شما اینجا نمایش داده میشود.",
|
||||||
"list_error": "بارگذاری رزروها ممکن نشد.",
|
"list_error": "بارگذاری رزروها ممکن نشد.",
|
||||||
"list_total": "مبلغ کل"
|
"list_total": "مبلغ کل",
|
||||||
|
"inbox_error": "درخواستهای شما بارگذاری نشد."
|
||||||
},
|
},
|
||||||
"payment": {
|
"payment": {
|
||||||
"title_checkout": "تایید و پرداخت",
|
"title_checkout": "تایید و پرداخت",
|
||||||
@@ -811,7 +819,7 @@
|
|||||||
"acknowledge_label": "بازپرداخت و کارمزد نمایشدادهشده را میپذیرم.",
|
"acknowledge_label": "بازپرداخت و کارمزد نمایشدادهشده را میپذیرم.",
|
||||||
"continue_cta": "ادامه",
|
"continue_cta": "ادامه",
|
||||||
"confirm_title": "تایید لغو",
|
"confirm_title": "تایید لغو",
|
||||||
"confirm_restate": "مبلغ {refund} به شما بازپرداخت میشود و کارمزد لغو {fee} کسر میگردد.",
|
"confirm_restate": "مبلغ <refund></refund> به شما بازپرداخت میشود و کارمزد لغو <fee></fee> کسر میگردد.",
|
||||||
"confirm_cta": "تایید لغو",
|
"confirm_cta": "تایید لغو",
|
||||||
"submitting": "در حال ثبت…",
|
"submitting": "در حال ثبت…",
|
||||||
"not_cancellable_title": "این رزرو قابل لغو نیست",
|
"not_cancellable_title": "این رزرو قابل لغو نیست",
|
||||||
@@ -1578,5 +1586,13 @@
|
|||||||
"invoice_pdf_error": "باز کردن فایل فاکتور ناموفق بود.",
|
"invoice_pdf_error": "باز کردن فایل فاکتور ناموفق بود.",
|
||||||
"invoice_number": "فاکتور #{number}",
|
"invoice_number": "فاکتور #{number}",
|
||||||
"settlement_iban": "شبای تسویه"
|
"settlement_iban": "شبای تسویه"
|
||||||
|
},
|
||||||
|
"routeChrome": {
|
||||||
|
"error_title": "مشکلی پیش آمد",
|
||||||
|
"error_body": "خطای غیرمنتظرهای رخ داد. میتوانید دوباره تلاش کنید یا به صفحه اصلی بازگردید.",
|
||||||
|
"error_retry": "تلاش دوباره",
|
||||||
|
"not_found_title": "صفحه پیدا نشد",
|
||||||
|
"not_found_body": "صفحهای که به دنبال آن هستید وجود ندارد یا جابهجا شده است.",
|
||||||
|
"go_home": "بازگشت به خانه"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,9 @@ const withNextIntl = createNextIntlPlugin('./src/i18n/request.ts');
|
|||||||
/** @type {import('next').NextConfig} */
|
/** @type {import('next').NextConfig} */
|
||||||
const nextConfig = {
|
const nextConfig = {
|
||||||
reactStrictMode: true,
|
reactStrictMode: true,
|
||||||
|
turbopack: {
|
||||||
|
root: '.'
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export default withNextIntl(nextConfig);
|
export default withNextIntl(nextConfig);
|
||||||
|
|||||||
@@ -20,6 +20,7 @@
|
|||||||
"@testing-library/dom": "^10.4.1",
|
"@testing-library/dom": "^10.4.1",
|
||||||
"clsx": "latest",
|
"clsx": "latest",
|
||||||
"copy-to-clipboard": "latest",
|
"copy-to-clipboard": "latest",
|
||||||
|
"jalaali-js": "^2.0.0",
|
||||||
"js-cookie": "^3.0.8",
|
"js-cookie": "^3.0.8",
|
||||||
"next": "^16.2.9",
|
"next": "^16.2.9",
|
||||||
"next-intl": "^4.13.0",
|
"next-intl": "^4.13.0",
|
||||||
@@ -8035,6 +8036,15 @@
|
|||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/jalaali-js": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/jalaali-js/-/jalaali-js-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-HkWlwO3KxuYwERP1jsn+5M+QA+EKpIJ+zGesLee5VJNn2d2Melue0uQIvd9C/0QYFR7XVWvfF/uiNEz9Jbr9Hw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/jest": {
|
"node_modules/jest": {
|
||||||
"version": "29.7.0",
|
"version": "29.7.0",
|
||||||
"resolved": "https://registry.npmjs.org/jest/-/jest-29.7.0.tgz",
|
"resolved": "https://registry.npmjs.org/jest/-/jest-29.7.0.tgz",
|
||||||
|
|||||||
@@ -28,6 +28,7 @@
|
|||||||
"@testing-library/dom": "^10.4.1",
|
"@testing-library/dom": "^10.4.1",
|
||||||
"clsx": "latest",
|
"clsx": "latest",
|
||||||
"copy-to-clipboard": "latest",
|
"copy-to-clipboard": "latest",
|
||||||
|
"jalaali-js": "^2.0.0",
|
||||||
"js-cookie": "^3.0.8",
|
"js-cookie": "^3.0.8",
|
||||||
"next": "^16.2.9",
|
"next": "^16.2.9",
|
||||||
"next-intl": "^4.13.0",
|
"next-intl": "^4.13.0",
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 479 B After Width: | Height: | Size: 426 B |
|
Before Width: | Height: | Size: 8.9 KiB After Width: | Height: | Size: 4.2 KiB |
|
Before Width: | Height: | Size: 8.9 KiB After Width: | Height: | Size: 4.7 KiB |
|
Before Width: | Height: | Size: 923 B After Width: | Height: | Size: 764 B |
|
After Width: | Height: | Size: 1.0 KiB |
|
Before Width: | Height: | Size: 31 KiB After Width: | Height: | Size: 17 KiB |
@@ -1,2 +1,8 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 40 40">
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 36 36"><path fill="#D99E82" d="M35.222 33.598c-.647-2.101-1.705-6.059-2.325-7.566-.501-1.216-.969-2.438-1.544-3.014-.575-.575-1.553-.53-2.143.058 0 0-2.469 1.675-3.354 2.783-1.108.882-2.785 3.357-2.785 3.357-.59.59-.635 1.567-.06 2.143.576.575 1.798 1.043 3.015 1.544 1.506.62 5.465 1.676 7.566 2.325.359.11 1.74-1.271 1.63-1.63z"/><path fill="#EA596E" d="M13.643 5.308c1.151 1.151 1.151 3.016 0 4.167l-4.167 4.168c-1.151 1.15-3.018 1.15-4.167 0L1.141 9.475c-1.15-1.151-1.15-3.016 0-4.167l4.167-4.167c1.15-1.151 3.016-1.151 4.167 0l4.168 4.167z"/><path fill="#FFCC4D" d="M31.353 23.018l-4.17 4.17-4.163 4.165L7.392 15.726l8.335-8.334 15.626 15.626z"/><path fill="#292F33" d="M32.078 34.763s2.709 1.489 3.441.757c.732-.732-.765-3.435-.765-3.435s-2.566.048-2.676 2.678z"/><path fill="#CCD6DD" d="M2.183 10.517l8.335-8.335 5.208 5.209-8.334 8.335z"/><path fill="#99AAB5" d="M3.225 11.558l8.334-8.334 1.042 1.042L4.267 12.6zm2.083 2.086l8.335-8.335 1.042 1.042-8.335 8.334z"/></svg>
|
<rect width="40" height="40" rx="10" fill="#1d4a40" />
|
||||||
|
<path fill="#f3efe9" fill-rule="evenodd" clip-rule="evenodd"
|
||||||
|
d="M21,17 A7.5,7.5 0 1,0 21,32 A7.5,7.5 0 1,0 21,17 Z
|
||||||
|
M21,20.8 A3.7,3.7 0 1,0 21,28.2 A3.7,3.7 0 1,0 21,20.8 Z" />
|
||||||
|
<rect x="12.9" y="9" width="4.8" height="22" rx="2.4" fill="#f3efe9" />
|
||||||
|
<circle cx="28.5" cy="10.5" r="2.6" fill="#d98c6a" />
|
||||||
|
</svg>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 1.0 KiB After Width: | Height: | Size: 444 B |
@@ -1,22 +1,19 @@
|
|||||||
{
|
{
|
||||||
"name": "Balinyaar",
|
"name": "Balinyaar",
|
||||||
"short_name": "Balinyaar",
|
"short_name": "Balinyaar",
|
||||||
"description": "Balinyaar web application",
|
"description": "Balinyaar — trust-first home nursing marketplace",
|
||||||
"start_url": ".",
|
"start_url": ".",
|
||||||
"theme_color": "#000000",
|
"theme_color": "#1d4a40",
|
||||||
"background_color": "#ffffff",
|
"background_color": "#faf9f5",
|
||||||
"display": "standalone",
|
"display": "standalone",
|
||||||
"orientation": "portrait",
|
"orientation": "portrait",
|
||||||
"icons": [
|
"icons": [
|
||||||
{
|
{ "src": "favicon.ico?v=2", "sizes": "48x48 32x32 16x16", "type": "image/x-icon" },
|
||||||
"src": "favicon.ico?v=1.0",
|
{ "src": "img/favicon/16x16.png?v=2", "sizes": "16x16", "type": "image/png" },
|
||||||
"sizes": "48x48 32x32 16x16",
|
{ "src": "img/favicon/32x32.png?v=2", "sizes": "32x32", "type": "image/png" },
|
||||||
"type": "image/x-icon"
|
{ "src": "img/favicon/48x48.png?v=2", "sizes": "48x48", "type": "image/png" },
|
||||||
},
|
{ "src": "img/favicon/180x180.png?v=2", "sizes": "180x180", "type": "image/png" },
|
||||||
{ "src": "img/favicon/16x16.png?v=1.0", "sizes": "16x16", "type": "image/png" },
|
{ "src": "img/favicon/192x192.png?v=2", "sizes": "192x192", "type": "image/png" },
|
||||||
{ "src": "img/favicon/32x32.png?v=1.0", "sizes": "32x32", "type": "image/png" },
|
{ "src": "img/favicon/512x512.png?v=2", "sizes": "512x512", "type": "image/png" }
|
||||||
{ "src": "img/favicon/180x180.png?v=1.0", "sizes": "180x180", "type": "image/png" },
|
|
||||||
{ "src": "img/favicon/192x192.png?v=1.0", "sizes": "192x192", "type": "image/png" },
|
|
||||||
{ "src": "img/favicon/512x512.png?v=1.0", "sizes": "512x512", "type": "image/png" }
|
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,198 @@
|
|||||||
|
'use client';
|
||||||
|
import { FormEvent, FunctionComponent, useEffect, useState } from 'react';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import { useLocale, useTranslations } from 'next-intl';
|
||||||
|
import { Avatar, Box, InputAdornment, Paper, Skeleton, Stack, TextField, Typography } from '@mui/material';
|
||||||
|
import { AppButton, AppIcon, AppLoading, CategoryTile, EmptyState, ErrorState } from '@/components';
|
||||||
|
import { ROUTES } from '@/constants';
|
||||||
|
import { useMe } from '@/services/auth';
|
||||||
|
import { usePatients } from '@/services/patients';
|
||||||
|
import { useServiceCategories } from '@/services/catalog';
|
||||||
|
import { pickCatalogName } from '@/services/catalog/names';
|
||||||
|
|
||||||
|
interface NudgeCardProps {
|
||||||
|
icon: string;
|
||||||
|
title: string;
|
||||||
|
body: string;
|
||||||
|
ctaLabel: string;
|
||||||
|
to: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const NudgeCard: FunctionComponent<NudgeCardProps> = ({ icon, title, body, ctaLabel, to }) => (
|
||||||
|
<Paper
|
||||||
|
elevation={0}
|
||||||
|
sx={{ p: 2.5, border: '1px solid', borderColor: 'divider', borderRadius: 2, display: 'flex', gap: 2 }}
|
||||||
|
>
|
||||||
|
<AppIcon icon={icon} size={28} color="var(--bal-primary)" />
|
||||||
|
<Stack sx={{ gap: 1, flexGrow: 1 }}>
|
||||||
|
<Stack sx={{ gap: 0.25 }}>
|
||||||
|
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
|
||||||
|
{title}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||||
|
{body}
|
||||||
|
</Typography>
|
||||||
|
</Stack>
|
||||||
|
<AppButton color="primary" variant="outlined" to={to} sx={{ alignSelf: 'flex-start' }}>
|
||||||
|
{ctaLabel}
|
||||||
|
</AppButton>
|
||||||
|
</Stack>
|
||||||
|
</Paper>
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A5 — the family Home: the front door of the app. Greeting + avatar, the search bar (which hands a
|
||||||
|
* query / chosen `service_category_id` toward the f6 search flow — results are not built here), the
|
||||||
|
* **data-driven** service-category grid (from the cached `services/catalog` reference data), and the
|
||||||
|
* complete-patient-record nudge (derived from the f2 patient cache — no extra fetch).
|
||||||
|
*
|
||||||
|
* First-login gate: a customer with no patients is sent into onboarding (A3). The redirect waits for
|
||||||
|
* a settled list so a post-create refetch never bounces the user back to onboarding.
|
||||||
|
*/
|
||||||
|
export default function HomeScreen() {
|
||||||
|
const t = useTranslations('home');
|
||||||
|
const tc = useTranslations('common');
|
||||||
|
const router = useRouter();
|
||||||
|
const locale = useLocale();
|
||||||
|
|
||||||
|
const { data: me } = useMe();
|
||||||
|
const { data, isError, refetch } = usePatients();
|
||||||
|
|
||||||
|
const isEmpty = data?.total === 0;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isEmpty) router.replace(`/${locale}${ROUTES.ONBOARDING}`);
|
||||||
|
}, [isEmpty, router, locale]);
|
||||||
|
|
||||||
|
if (isError) {
|
||||||
|
return <ErrorState message={t('patients_error')} retryLabel={tc('retry')} onRetry={() => refetch()} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data == null || isEmpty) {
|
||||||
|
return <AppLoading />;
|
||||||
|
}
|
||||||
|
|
||||||
|
const href = (path: string) => `/${locale}${path}`;
|
||||||
|
const profileComplete = me?.hasCustomerProfile ?? false;
|
||||||
|
const firstName = me?.firstName?.trim() || null;
|
||||||
|
const greeting = firstName ? t('greeting_named', { name: firstName }) : t('greeting_plain');
|
||||||
|
const avatarInitial = firstName ? firstName.charAt(0).toUpperCase() : null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
|
||||||
|
<Stack direction="row" sx={{ gap: 1.5, alignItems: 'center' }}>
|
||||||
|
<Avatar sx={{ width: 48, height: 48, bgcolor: 'var(--bal-primary-soft)', color: 'var(--bal-primary)', fontWeight: 700 }}>
|
||||||
|
{avatarInitial ?? <AppIcon icon="account" size={28} color="var(--bal-primary)" />}
|
||||||
|
</Avatar>
|
||||||
|
<Box>
|
||||||
|
<Typography variant="h5" component="h1">
|
||||||
|
{greeting}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||||
|
{t('subtitle')}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
<HomeSearchBar />
|
||||||
|
|
||||||
|
<CategoryGrid onSelect={(categoryId) => router.push(`${href(ROUTES.SEARCH)}?category_id=${categoryId}`)} />
|
||||||
|
|
||||||
|
<NudgeCard
|
||||||
|
icon="patients"
|
||||||
|
title={t('nudge_patient_title')}
|
||||||
|
body={t('nudge_patient_body')}
|
||||||
|
ctaLabel={t('nudge_patient_cta')}
|
||||||
|
to={href(ROUTES.PATIENTS)}
|
||||||
|
/>
|
||||||
|
{!profileComplete ? (
|
||||||
|
<NudgeCard
|
||||||
|
icon="profile"
|
||||||
|
title={t('nudge_profile_title')}
|
||||||
|
body={t('nudge_profile_body')}
|
||||||
|
ctaLabel={t('nudge_profile_cta')}
|
||||||
|
to={href(ROUTES.PROFILE)}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The Home search field. Rendering + query capture live here; **execution is f6** — submitting
|
||||||
|
* navigates toward the (future) search route carrying the typed query. Results/filters are DEFERRED
|
||||||
|
* → frontend-phase-6-b7.
|
||||||
|
*/
|
||||||
|
const HomeSearchBar: FunctionComponent = () => {
|
||||||
|
const t = useTranslations('home');
|
||||||
|
const router = useRouter();
|
||||||
|
const locale = useLocale();
|
||||||
|
const [query, setQuery] = useState('');
|
||||||
|
|
||||||
|
const submit = (event: FormEvent) => {
|
||||||
|
event.preventDefault();
|
||||||
|
const q = query.trim();
|
||||||
|
router.push(`/${locale}${ROUTES.SEARCH}${q ? `?q=${encodeURIComponent(q)}` : ''}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box component="form" onSubmit={submit} role="search">
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
value={query}
|
||||||
|
onChange={(event) => setQuery(event.target.value)}
|
||||||
|
placeholder={t('search_placeholder')}
|
||||||
|
aria-label={t('search_action')}
|
||||||
|
slotProps={{
|
||||||
|
input: {
|
||||||
|
startAdornment: (
|
||||||
|
<InputAdornment position="start">
|
||||||
|
<AppIcon icon="search" size={22} color="var(--bal-text-secondary)" />
|
||||||
|
</InputAdornment>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/** The data-driven service-category grid — one tile per `service_category`, with all four states. */
|
||||||
|
const CategoryGrid: FunctionComponent<{ onSelect: (categoryId: number) => void }> = ({ onSelect }) => {
|
||||||
|
const t = useTranslations('home');
|
||||||
|
const tc = useTranslations('common');
|
||||||
|
const locale = useLocale();
|
||||||
|
const { data, isLoading, isError, refetch } = useServiceCategories();
|
||||||
|
const categories = data?.items ?? [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack sx={{ gap: 1.5 }}>
|
||||||
|
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
|
||||||
|
{t('categories_title')}
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
{isLoading ? (
|
||||||
|
<Box sx={{ display: 'grid', gridTemplateColumns: { xs: 'repeat(2, 1fr)', sm: 'repeat(3, 1fr)' }, gap: 1.5 }}>
|
||||||
|
{[0, 1, 2, 3].map((key) => (
|
||||||
|
<Skeleton key={key} variant="rounded" height={116} sx={{ borderRadius: 2 }} />
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
) : isError ? (
|
||||||
|
<ErrorState message={t('categories_error')} retryLabel={tc('retry')} onRetry={() => refetch()} />
|
||||||
|
) : categories.length === 0 ? (
|
||||||
|
<EmptyState title={t('categories_empty')} />
|
||||||
|
) : (
|
||||||
|
<Box sx={{ display: 'grid', gridTemplateColumns: { xs: 'repeat(2, 1fr)', sm: 'repeat(3, 1fr)' }, gap: 1.5 }}>
|
||||||
|
{categories.map((category) => (
|
||||||
|
<CategoryTile
|
||||||
|
key={category.id}
|
||||||
|
label={pickCatalogName(category, locale)}
|
||||||
|
iconKey={category.iconKey}
|
||||||
|
onClick={() => onSelect(category.id)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -2,18 +2,8 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { useLocale, useTranslations } from 'next-intl';
|
import { useLocale, useTranslations } from 'next-intl';
|
||||||
import { useSnackbar } from 'notistack';
|
import { useSnackbar } from 'notistack';
|
||||||
import {
|
import { Box, Dialog, DialogActions, DialogContent, DialogTitle, Skeleton, Stack, Typography } from '@mui/material';
|
||||||
Box,
|
import { AppButton, EmptyState } from '@/components';
|
||||||
Dialog,
|
|
||||||
DialogActions,
|
|
||||||
DialogContent,
|
|
||||||
DialogTitle,
|
|
||||||
Paper,
|
|
||||||
Skeleton,
|
|
||||||
Stack,
|
|
||||||
Typography,
|
|
||||||
} from '@mui/material';
|
|
||||||
import { AppButton, AppIcon } from '@/components';
|
|
||||||
import { AddressCard, AddressForm } from '@/components/geography';
|
import { AddressCard, AddressForm } from '@/components/geography';
|
||||||
import {
|
import {
|
||||||
useAddresses,
|
useAddresses,
|
||||||
@@ -102,7 +92,7 @@ export default function AddressesPage() {
|
|||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
{!isEmpty ? (
|
{!isEmpty ? (
|
||||||
<AppButton color="primary" variant="contained" startIcon="add" onClick={openAdd} sx={{ m: 0, flexShrink: 0 }}>
|
<AppButton color="primary" variant="contained" startIcon="add" onClick={openAdd} sx={{ flexShrink: 0 }}>
|
||||||
{t('add')}
|
{t('add')}
|
||||||
</AppButton>
|
</AppButton>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -115,31 +105,16 @@ export default function AddressesPage() {
|
|||||||
))}
|
))}
|
||||||
</Stack>
|
</Stack>
|
||||||
) : isEmpty ? (
|
) : isEmpty ? (
|
||||||
<Paper
|
<EmptyState
|
||||||
elevation={0}
|
icon="location"
|
||||||
sx={{
|
title={t('empty_title')}
|
||||||
p: 4,
|
body={t('empty_body')}
|
||||||
textAlign: 'center',
|
action={
|
||||||
border: '1px dashed',
|
<AppButton color="primary" variant="contained" startIcon="add" onClick={openAdd} sx={{ mt: 1 }}>
|
||||||
borderColor: 'divider',
|
{t('add')}
|
||||||
borderRadius: 2,
|
</AppButton>
|
||||||
display: 'flex',
|
}
|
||||||
flexDirection: 'column',
|
/>
|
||||||
alignItems: 'center',
|
|
||||||
gap: 1.5,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<AppIcon icon="location" size={40} color="var(--bal-primary)" />
|
|
||||||
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
|
|
||||||
{t('empty_title')}
|
|
||||||
</Typography>
|
|
||||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
|
||||||
{t('empty_body')}
|
|
||||||
</Typography>
|
|
||||||
<AppButton color="primary" variant="contained" startIcon="add" onClick={openAdd} sx={{ mt: 1 }}>
|
|
||||||
{t('add')}
|
|
||||||
</AppButton>
|
|
||||||
</Paper>
|
|
||||||
) : (
|
) : (
|
||||||
<Stack sx={{ gap: 1.5 }}>
|
<Stack sx={{ gap: 1.5 }}>
|
||||||
{addresses.map((address) => (
|
{addresses.map((address) => (
|
||||||
|
|||||||
@@ -0,0 +1,90 @@
|
|||||||
|
'use client';
|
||||||
|
import { useLocale, useTranslations } from 'next-intl';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import { Box, Paper, Skeleton, Stack, Typography } from '@mui/material';
|
||||||
|
import { AppButton, EmptyState, ErrorState, Money, StatusChip } from '@/components';
|
||||||
|
import { BOOKING_STATUS_KIND } from '@/components/booking/statusKind';
|
||||||
|
import { ROUTES } from '@/constants';
|
||||||
|
import { formatShamsiDate } from '@/utils';
|
||||||
|
import { useBookingList } from '@/services/bookings';
|
||||||
|
import type { BookingListItemDto } from '@/services/bookings/types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Customer رزروها — the "My bookings" list. Reads `useBookingList('customer')`; each row opens the
|
||||||
|
* booking detail (`/bookings/{id}`). This is the customer entry to the f8 booking-detail surface (the C5
|
||||||
|
* `converted` state also lands here). Amounts render in Toman via the money util.
|
||||||
|
*/
|
||||||
|
export default function BookingsScreen() {
|
||||||
|
const t = useTranslations('booking');
|
||||||
|
const { data, isLoading, isError, refetch } = useBookingList('customer');
|
||||||
|
const items = data?.items ?? [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
|
||||||
|
<Box>
|
||||||
|
<Typography variant="h5" component="h1">
|
||||||
|
{t('list_title')}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||||
|
{t('list_subtitle')}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{isLoading ? (
|
||||||
|
<Stack sx={{ gap: 2 }}>
|
||||||
|
{[0, 1].map((key) => (
|
||||||
|
<Skeleton key={key} variant="rounded" height={120} />
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
) : isError ? (
|
||||||
|
<ErrorState message={t('list_error')} retryLabel={t('retry')} onRetry={() => refetch()} />
|
||||||
|
) : items.length === 0 ? (
|
||||||
|
<EmptyState icon="bookings" title={t('list_empty_title')} body={t('list_empty_body')} />
|
||||||
|
) : (
|
||||||
|
<Stack sx={{ gap: 2 }}>
|
||||||
|
{items.map((item) => (
|
||||||
|
<BookingRow key={item.id} item={item} />
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function BookingRow({ item }: { item: BookingListItemDto }) {
|
||||||
|
const t = useTranslations('booking');
|
||||||
|
const locale = useLocale();
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Paper elevation={0} sx={{ p: 2.5, border: '1px solid', borderColor: 'divider', borderRadius: 2 }}>
|
||||||
|
<Stack sx={{ gap: 1.5 }}>
|
||||||
|
<Stack direction="row" sx={{ justifyContent: 'space-between', alignItems: 'flex-start', gap: 1.5, flexWrap: 'wrap' }}>
|
||||||
|
<Stack sx={{ gap: 0.25, minWidth: 0 }}>
|
||||||
|
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
|
||||||
|
{item.counterpartyName}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||||
|
{formatShamsiDate(item.scheduledDate, locale)} · {t('session_count', { count: item.sessionCount })}
|
||||||
|
</Typography>
|
||||||
|
</Stack>
|
||||||
|
<StatusChip status={BOOKING_STATUS_KIND[item.status]} label={t(`bstatus_${item.status}`)} />
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
<Stack direction="row" sx={{ justifyContent: 'space-between', alignItems: 'center', gap: 1.5, flexWrap: 'wrap' }}>
|
||||||
|
<Typography variant="body2" sx={{ fontWeight: 700 }}>
|
||||||
|
{t('list_total')}: <Money amountIrr={item.amountIrr} size="sm" sx={{ fontWeight: 700 }} />
|
||||||
|
</Typography>
|
||||||
|
<AppButton
|
||||||
|
variant="outlined"
|
||||||
|
color="primary"
|
||||||
|
endIcon="bookings"
|
||||||
|
onClick={() => router.push(`/${locale}${ROUTES.BOOKINGS}/${item.id}`)}
|
||||||
|
>
|
||||||
|
{t('view_booking')}
|
||||||
|
</AppButton>
|
||||||
|
</Stack>
|
||||||
|
</Stack>
|
||||||
|
</Paper>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -6,11 +6,11 @@ import { Checkbox, FormControlLabel, MenuItem, Paper, Stack, TextField, Typograp
|
|||||||
import AppButton from '@/components/common/AppButton';
|
import AppButton from '@/components/common/AppButton';
|
||||||
import AppAlert from '@/components/common/AppAlert';
|
import AppAlert from '@/components/common/AppAlert';
|
||||||
import AppLoading from '@/components/common/AppLoading';
|
import AppLoading from '@/components/common/AppLoading';
|
||||||
|
import Money from '@/components/common/Money';
|
||||||
import StepperHeader from '@/components/StepperHeader';
|
import StepperHeader from '@/components/StepperHeader';
|
||||||
import CancellationPolicyDisclosure from '@/components/CancellationPolicyDisclosure';
|
import CancellationPolicyDisclosure from '@/components/CancellationPolicyDisclosure';
|
||||||
import { ApiError } from '@/lib/api/errors';
|
import { ApiError } from '@/lib/api/errors';
|
||||||
import { bookingRefundStatusPath, ROUTES } from '@/constants';
|
import { bookingRefundStatusPath, ROUTES } from '@/constants';
|
||||||
import { formatIrrToToman } from '@/utils';
|
|
||||||
import { useCancelBooking, useCancellationPolicyPreview } from '@/services/refunds';
|
import { useCancelBooking, useCancellationPolicyPreview } from '@/services/refunds';
|
||||||
import type { CancelReasonCategory } from '@/services/refunds/types';
|
import type { CancelReasonCategory } from '@/services/refunds/types';
|
||||||
|
|
||||||
@@ -93,11 +93,10 @@ export default function CancelBookingPage() {
|
|||||||
variant="contained"
|
variant="contained"
|
||||||
color="primary"
|
color="primary"
|
||||||
onClick={() => router.push(`/${locale}${bookingRefundStatusPath(bookingId)}`)}
|
onClick={() => router.push(`/${locale}${bookingRefundStatusPath(bookingId)}`)}
|
||||||
sx={{ m: 0 }}
|
|
||||||
>
|
>
|
||||||
{t('view_refund_status')}
|
{t('view_refund_status')}
|
||||||
</AppButton>
|
</AppButton>
|
||||||
<AppButton variant="text" color="inherit" onClick={() => router.push(bookingHref)} sx={{ m: 0 }}>
|
<AppButton variant="text" color="inherit" onClick={() => router.push(bookingHref)}>
|
||||||
{t('back_to_booking')}
|
{t('back_to_booking')}
|
||||||
</AppButton>
|
</AppButton>
|
||||||
</Stack>
|
</Stack>
|
||||||
@@ -105,9 +104,6 @@ export default function CancelBookingPage() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const refundToman = `${formatIrrToToman(preview.refundAmountIrr, locale)} ${tc('currency_toman')}`;
|
|
||||||
const feeToman = `${formatIrrToToman(preview.feeAmountIrr, locale)} ${tc('currency_toman')}`;
|
|
||||||
|
|
||||||
const submit = () =>
|
const submit = () =>
|
||||||
cancel.mutate(
|
cancel.mutate(
|
||||||
{
|
{
|
||||||
@@ -157,7 +153,7 @@ export default function CancelBookingPage() {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<Stack direction="row" sx={{ gap: 1, justifyContent: 'space-between', flexWrap: 'wrap' }}>
|
<Stack direction="row" sx={{ gap: 1, justifyContent: 'space-between', flexWrap: 'wrap' }}>
|
||||||
<AppButton variant="text" color="inherit" onClick={() => router.push(bookingHref)} sx={{ m: 0 }}>
|
<AppButton variant="text" color="inherit" onClick={() => router.push(bookingHref)}>
|
||||||
{t('back_to_booking')}
|
{t('back_to_booking')}
|
||||||
</AppButton>
|
</AppButton>
|
||||||
<AppButton
|
<AppButton
|
||||||
@@ -165,7 +161,6 @@ export default function CancelBookingPage() {
|
|||||||
color="primary"
|
color="primary"
|
||||||
disabled={!acknowledged}
|
disabled={!acknowledged}
|
||||||
onClick={() => setStep(1)}
|
onClick={() => setStep(1)}
|
||||||
sx={{ m: 0 }}
|
|
||||||
>
|
>
|
||||||
{t('continue_cta')}
|
{t('continue_cta')}
|
||||||
</AppButton>
|
</AppButton>
|
||||||
@@ -177,7 +172,14 @@ export default function CancelBookingPage() {
|
|||||||
<Typography variant="subtitle1" sx={{ fontWeight: 800, mb: 1 }}>
|
<Typography variant="subtitle1" sx={{ fontWeight: 800, mb: 1 }}>
|
||||||
{t('confirm_title')}
|
{t('confirm_title')}
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography variant="body2">{t('confirm_restate', { refund: refundToman, fee: feeToman })}</Typography>
|
<Typography variant="body2" component="div">
|
||||||
|
{t.rich('confirm_restate', {
|
||||||
|
refund: () => (
|
||||||
|
<Money amountIrr={preview.refundAmountIrr} size="sm" sx={{ fontWeight: 700 }} />
|
||||||
|
),
|
||||||
|
fee: () => <Money amountIrr={preview.feeAmountIrr} size="sm" sx={{ fontWeight: 700 }} />,
|
||||||
|
})}
|
||||||
|
</Typography>
|
||||||
</Paper>
|
</Paper>
|
||||||
|
|
||||||
{cancel.isError && (
|
{cancel.isError && (
|
||||||
@@ -192,7 +194,6 @@ export default function CancelBookingPage() {
|
|||||||
color="inherit"
|
color="inherit"
|
||||||
onClick={() => setStep(0)}
|
onClick={() => setStep(0)}
|
||||||
disabled={cancel.isPending}
|
disabled={cancel.isPending}
|
||||||
sx={{ m: 0 }}
|
|
||||||
>
|
>
|
||||||
{tc('back')}
|
{tc('back')}
|
||||||
</AppButton>
|
</AppButton>
|
||||||
@@ -201,7 +202,6 @@ export default function CancelBookingPage() {
|
|||||||
color="error"
|
color="error"
|
||||||
onClick={submit}
|
onClick={submit}
|
||||||
disabled={cancel.isPending}
|
disabled={cancel.isPending}
|
||||||
sx={{ m: 0 }}
|
|
||||||
>
|
>
|
||||||
{cancel.isPending ? t('submitting') : t('confirm_cta')}
|
{cancel.isPending ? t('submitting') : t('confirm_cta')}
|
||||||
</AppButton>
|
</AppButton>
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { Divider, GlobalStyles, Paper, Skeleton, Stack, Typography } from '@mui/
|
|||||||
import { AppButton, AppIcon, PriceBreakdown, StatusChip, type StatusKind } from '@/components';
|
import { AppButton, AppIcon, PriceBreakdown, StatusChip, type StatusKind } from '@/components';
|
||||||
import { ROUTES } from '@/constants';
|
import { ROUTES } from '@/constants';
|
||||||
import { ApiError } from '@/lib/api/errors';
|
import { ApiError } from '@/lib/api/errors';
|
||||||
import { formatShamsiDate, parseIrr } from '@/utils';
|
import { formatShamsiDate, localeTag, parseIrr } from '@/utils';
|
||||||
import { useInvoice } from '@/services/payment';
|
import { useInvoice } from '@/services/payment';
|
||||||
import type { MoadianStatus } from '@/services/payment/types';
|
import type { MoadianStatus } from '@/services/payment/types';
|
||||||
|
|
||||||
@@ -51,7 +51,7 @@ export default function BookingInvoicePage() {
|
|||||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||||
{t('invalid_link_body')}
|
{t('invalid_link_body')}
|
||||||
</Typography>
|
</Typography>
|
||||||
<AppButton variant="contained" onClick={() => router.push(`/${locale}${ROUTES.BOOKINGS}`)} sx={{ m: 0 }}>
|
<AppButton variant="contained" onClick={() => router.push(`/${locale}${ROUTES.BOOKINGS}`)}>
|
||||||
{t('view_booking')}
|
{t('view_booking')}
|
||||||
</AppButton>
|
</AppButton>
|
||||||
</Stack>
|
</Stack>
|
||||||
@@ -82,11 +82,11 @@ export default function BookingInvoicePage() {
|
|||||||
{notIssued ? t('invoice_not_issued_body') : t('error_body')}
|
{notIssued ? t('invoice_not_issued_body') : t('error_body')}
|
||||||
</Typography>
|
</Typography>
|
||||||
{notIssued ? (
|
{notIssued ? (
|
||||||
<AppButton variant="contained" onClick={() => router.push(`/${locale}${ROUTES.BOOKINGS}/${bookingId}`)} sx={{ m: 0 }}>
|
<AppButton variant="contained" onClick={() => router.push(`/${locale}${ROUTES.BOOKINGS}/${bookingId}`)}>
|
||||||
{t('view_booking')}
|
{t('view_booking')}
|
||||||
</AppButton>
|
</AppButton>
|
||||||
) : (
|
) : (
|
||||||
<AppButton variant="contained" onClick={() => refetch()} sx={{ m: 0 }}>
|
<AppButton variant="contained" onClick={() => refetch()}>
|
||||||
{tc('retry')}
|
{tc('retry')}
|
||||||
</AppButton>
|
</AppButton>
|
||||||
)}
|
)}
|
||||||
@@ -118,7 +118,7 @@ export default function BookingInvoicePage() {
|
|||||||
parseIrr(invoice.grossIrr) - parseIrr(invoice.platformCommissionIrr) - parseIrr(invoice.vatIrr)
|
parseIrr(invoice.grossIrr) - parseIrr(invoice.platformCommissionIrr) - parseIrr(invoice.vatIrr)
|
||||||
).toString();
|
).toString();
|
||||||
// maximumFractionDigits: the default (0) would silently round a fractional served rate (e.g. 9.5%).
|
// maximumFractionDigits: the default (0) would silently round a fractional served rate (e.g. 9.5%).
|
||||||
const vatPercent = new Intl.NumberFormat(locale === 'fa' ? 'fa-IR' : 'en-US', {
|
const vatPercent = new Intl.NumberFormat(localeTag(locale), {
|
||||||
style: 'percent',
|
style: 'percent',
|
||||||
maximumFractionDigits: 2,
|
maximumFractionDigits: 2,
|
||||||
}).format(invoice.vatRate);
|
}).format(invoice.vatRate);
|
||||||
@@ -185,15 +185,15 @@ export default function BookingInvoicePage() {
|
|||||||
|
|
||||||
<Stack sx={{ gap: 1 }}>
|
<Stack sx={{ gap: 1 }}>
|
||||||
{invoice.pdfUrl ? (
|
{invoice.pdfUrl ? (
|
||||||
<AppButton color="primary" variant="contained" startIcon="document" href={invoice.pdfUrl} openInNewTab sx={{ m: 0 }}>
|
<AppButton color="primary" variant="contained" startIcon="document" href={invoice.pdfUrl} openInNewTab>
|
||||||
{t('download_invoice')}
|
{t('download_invoice')}
|
||||||
</AppButton>
|
</AppButton>
|
||||||
) : (
|
) : (
|
||||||
<AppButton color="primary" variant="contained" startIcon="document" onClick={handlePrint} sx={{ m: 0 }}>
|
<AppButton color="primary" variant="contained" startIcon="document" onClick={handlePrint}>
|
||||||
{t('print_invoice')}
|
{t('print_invoice')}
|
||||||
</AppButton>
|
</AppButton>
|
||||||
)}
|
)}
|
||||||
<AppButton variant="text" onClick={() => router.push(`/${locale}${ROUTES.BOOKINGS}/${bookingId}`)} sx={{ m: 0 }}>
|
<AppButton variant="text" onClick={() => router.push(`/${locale}${ROUTES.BOOKINGS}/${bookingId}`)}>
|
||||||
{t('view_booking')}
|
{t('view_booking')}
|
||||||
</AppButton>
|
</AppButton>
|
||||||
</Stack>
|
</Stack>
|
||||||
@@ -207,7 +207,7 @@ function MetaRow({ label, value, ltr }: { label: string; value: string; ltr?: bo
|
|||||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||||
{label}
|
{label}
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography variant="body2" sx={{ fontWeight: 600 }} dir={ltr ? 'ltr' : undefined}>
|
<Typography variant="body2" sx={{ fontWeight: 500 }} dir={ltr ? 'ltr' : undefined}>
|
||||||
{value}
|
{value}
|
||||||
</Typography>
|
</Typography>
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|||||||
@@ -63,7 +63,6 @@ function LeaveReviewCta({ bookingId }: { bookingId: number }) {
|
|||||||
color="primary"
|
color="primary"
|
||||||
startIcon="star"
|
startIcon="star"
|
||||||
onClick={() => router.push(`/${locale}${bookingReviewPath(bookingId)}`)}
|
onClick={() => router.push(`/${locale}${bookingReviewPath(bookingId)}`)}
|
||||||
sx={{ m: 0 }}
|
|
||||||
>
|
>
|
||||||
{alreadyReviewed ? (underReview ? t('cta_under_review') : t('cta_view_review')) : t('cta_leave')}
|
{alreadyReviewed ? (underReview ? t('cta_under_review') : t('cta_view_review')) : t('cta_leave')}
|
||||||
</AppButton>
|
</AppButton>
|
||||||
@@ -95,7 +94,6 @@ function CustomerBookingActions({ bookingId }: { bookingId: number }) {
|
|||||||
color="error"
|
color="error"
|
||||||
startIcon="rejected"
|
startIcon="rejected"
|
||||||
onClick={() => router.push(`/${locale}${bookingCancelPath(bookingId)}`)}
|
onClick={() => router.push(`/${locale}${bookingCancelPath(bookingId)}`)}
|
||||||
sx={{ m: 0 }}
|
|
||||||
>
|
>
|
||||||
{t('cancel_booking_cta')}
|
{t('cancel_booking_cta')}
|
||||||
</AppButton>
|
</AppButton>
|
||||||
@@ -114,7 +112,7 @@ function CustomerBookingActions({ bookingId }: { bookingId: number }) {
|
|||||||
variant="text"
|
variant="text"
|
||||||
color="primary"
|
color="primary"
|
||||||
onClick={() => router.push(`/${locale}${bookingRefundStatusPath(bookingId)}`)}
|
onClick={() => router.push(`/${locale}${bookingRefundStatusPath(bookingId)}`)}
|
||||||
sx={{ m: 0, alignSelf: 'flex-start' }}
|
sx={{ alignSelf: 'flex-start' }}
|
||||||
>
|
>
|
||||||
{t('view_refund_status')}
|
{t('view_refund_status')}
|
||||||
</AppButton>
|
</AppButton>
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ export default function RefundStatusPage() {
|
|||||||
variant="text"
|
variant="text"
|
||||||
color="inherit"
|
color="inherit"
|
||||||
onClick={() => router.push(`/${locale}${ROUTES.BOOKINGS}/${bookingId}`)}
|
onClick={() => router.push(`/${locale}${ROUTES.BOOKINGS}/${bookingId}`)}
|
||||||
sx={{ m: 0, alignSelf: 'flex-start' }}
|
sx={{ alignSelf: 'flex-start' }}
|
||||||
>
|
>
|
||||||
{t('back_to_booking')}
|
{t('back_to_booking')}
|
||||||
</AppButton>
|
</AppButton>
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { useLocale, useTranslations } from 'next-intl';
|
|||||||
import { useParams, useRouter } from 'next/navigation';
|
import { useParams, useRouter } from 'next/navigation';
|
||||||
import { useSnackbar } from 'notistack';
|
import { useSnackbar } from 'notistack';
|
||||||
import { Paper, Skeleton, Stack, TextField, Typography } from '@mui/material';
|
import { Paper, Skeleton, Stack, TextField, Typography } from '@mui/material';
|
||||||
import { AppButton, RatingInput, ReviewTagSelector, StatusChip } from '@/components';
|
import { AppButton, EmptyState, RatingInput, ReviewTagSelector, StatusChip } from '@/components';
|
||||||
import type { StatusKind } from '@/components';
|
import type { StatusKind } from '@/components';
|
||||||
import { formatShamsiDate } from '@/utils';
|
import { formatShamsiDate } from '@/utils';
|
||||||
import { useBookingDetail } from '@/services/bookings';
|
import { useBookingDetail } from '@/services/bookings';
|
||||||
@@ -95,7 +95,7 @@ export default function LeaveReviewPage() {
|
|||||||
) : null}
|
) : null}
|
||||||
</Stack>
|
</Stack>
|
||||||
</Paper>
|
</Paper>
|
||||||
<AppButton variant="outlined" color="primary" onClick={() => router.back()} sx={{ m: 0, alignSelf: 'flex-start' }}>
|
<AppButton variant="outlined" color="primary" onClick={() => router.back()} sx={{ alignSelf: 'flex-start' }}>
|
||||||
{tc('back')}
|
{tc('back')}
|
||||||
</AppButton>
|
</AppButton>
|
||||||
</Stack>
|
</Stack>
|
||||||
@@ -119,12 +119,8 @@ export default function LeaveReviewPage() {
|
|||||||
return (
|
return (
|
||||||
<Stack sx={{ gap: 3, maxWidth: 560, mx: 'auto', width: '100%' }}>
|
<Stack sx={{ gap: 3, maxWidth: 560, mx: 'auto', width: '100%' }}>
|
||||||
<PageHeading title={t('not_eligible_title')} />
|
<PageHeading title={t('not_eligible_title')} />
|
||||||
<Paper elevation={0} sx={{ p: 3, textAlign: 'center', border: '1px dashed', borderColor: 'divider', borderRadius: 2 }}>
|
<EmptyState title={t(`reason_${reason}`)} />
|
||||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
<AppButton variant="outlined" color="primary" onClick={() => router.back()} sx={{ alignSelf: 'flex-start' }}>
|
||||||
{t(`reason_${reason}`)}
|
|
||||||
</Typography>
|
|
||||||
</Paper>
|
|
||||||
<AppButton variant="outlined" color="primary" onClick={() => router.back()} sx={{ m: 0, alignSelf: 'flex-start' }}>
|
|
||||||
{tc('back')}
|
{tc('back')}
|
||||||
</AppButton>
|
</AppButton>
|
||||||
</Stack>
|
</Stack>
|
||||||
@@ -167,7 +163,7 @@ export default function LeaveReviewPage() {
|
|||||||
</Stack>
|
</Stack>
|
||||||
|
|
||||||
<Stack direction="row" sx={{ gap: 1, justifyContent: 'flex-end' }}>
|
<Stack direction="row" sx={{ gap: 1, justifyContent: 'flex-end' }}>
|
||||||
<AppButton variant="text" color="primary" onClick={() => router.back()} sx={{ m: 0 }} disabled={createReview.isPending}>
|
<AppButton variant="text" color="primary" onClick={() => router.back()} disabled={createReview.isPending}>
|
||||||
{tc('cancel')}
|
{tc('cancel')}
|
||||||
</AppButton>
|
</AppButton>
|
||||||
<AppButton
|
<AppButton
|
||||||
@@ -176,7 +172,6 @@ export default function LeaveReviewPage() {
|
|||||||
onClick={submit}
|
onClick={submit}
|
||||||
disabled={rating < 1 || createReview.isPending}
|
disabled={rating < 1 || createReview.isPending}
|
||||||
startIcon="star"
|
startIcon="star"
|
||||||
sx={{ m: 0 }}
|
|
||||||
>
|
>
|
||||||
{createReview.isPending ? t('submitting') : t('submit')}
|
{createReview.isPending ? t('submitting') : t('submit')}
|
||||||
</AppButton>
|
</AppButton>
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
'use client';
|
'use client';
|
||||||
import { FunctionComponent, useState } from 'react';
|
import { FunctionComponent, useState } from 'react';
|
||||||
import { useLocale, useTranslations } from 'next-intl';
|
import { useTranslations } from 'next-intl';
|
||||||
import { Checkbox, FormControlLabel, Paper, Stack, TextField, Typography } from '@mui/material';
|
import { Checkbox, FormControlLabel, Paper, Stack, TextField, Typography } from '@mui/material';
|
||||||
import { AppButton, AppIcon, PhoneNumberField } from '@/components';
|
import { AppButton, AppIcon, Money, PhoneNumberField } from '@/components';
|
||||||
import { digitsOnly, formatIrrToToman } from '@/utils';
|
import { digitsOnly } from '@/utils';
|
||||||
import { useCheckEligibility } from '@/services/bnpl';
|
import { useCheckEligibility } from '@/services/bnpl';
|
||||||
import { NATIONAL_ID_LENGTH, NATIONAL_ID_PATTERN } from '@/services/bnpl/constants';
|
import { NATIONAL_ID_LENGTH, NATIONAL_ID_PATTERN } from '@/services/bnpl/constants';
|
||||||
import type { BnplEligibilityResult, ProviderCode } from '@/services/bnpl/types';
|
import type { BnplEligibilityResult, ProviderCode } from '@/services/bnpl/types';
|
||||||
@@ -35,7 +35,6 @@ const EligibilityStep: FunctionComponent<EligibilityStepProps> = ({
|
|||||||
}) => {
|
}) => {
|
||||||
const t = useTranslations('bnpl');
|
const t = useTranslations('bnpl');
|
||||||
const tc = useTranslations('common');
|
const tc = useTranslations('common');
|
||||||
const locale = useLocale();
|
|
||||||
|
|
||||||
const [nationalId, setNationalId] = useState('');
|
const [nationalId, setNationalId] = useState('');
|
||||||
const [consent, setConsent] = useState(false);
|
const [consent, setConsent] = useState(false);
|
||||||
@@ -79,14 +78,12 @@ const EligibilityStep: FunctionComponent<EligibilityStepProps> = ({
|
|||||||
<Typography variant="caption" sx={{ color: 'text.secondary', mt: 0.5 }}>
|
<Typography variant="caption" sx={{ color: 'text.secondary', mt: 0.5 }}>
|
||||||
{t('credit_ceiling_label')}
|
{t('credit_ceiling_label')}
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography variant="h6" sx={{ fontWeight: 800 }}>
|
<Money amountIrr={result.creditCeilingIrr} tone="emphasis" size="lg" />
|
||||||
{formatIrrToToman(result.creditCeilingIrr, locale)} {tc('currency_toman')}
|
|
||||||
</Typography>
|
|
||||||
</>
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
</Stack>
|
</Stack>
|
||||||
</Paper>
|
</Paper>
|
||||||
<AppButton color="secondary" variant="contained" size="large" onClick={() => onApproved(result)} sx={{ m: 0 }}>
|
<AppButton color="secondary" variant="contained" size="large" onClick={() => onApproved(result)}>
|
||||||
{t('approve_continue')}
|
{t('approve_continue')}
|
||||||
</AppButton>
|
</AppButton>
|
||||||
</Stack>
|
</Stack>
|
||||||
@@ -162,11 +159,10 @@ const EligibilityStep: FunctionComponent<EligibilityStepProps> = ({
|
|||||||
size="large"
|
size="large"
|
||||||
disabled={!consent || check.isPending}
|
disabled={!consent || check.isPending}
|
||||||
onClick={handleSubmit}
|
onClick={handleSubmit}
|
||||||
sx={{ m: 0 }}
|
|
||||||
>
|
>
|
||||||
{t('check_eligibility')}
|
{t('check_eligibility')}
|
||||||
</AppButton>
|
</AppButton>
|
||||||
<AppButton variant="text" color="primary" onClick={onPayWithCard} sx={{ m: 0 }}>
|
<AppButton variant="text" color="primary" onClick={onPayWithCard}>
|
||||||
{t('pay_with_card')}
|
{t('pay_with_card')}
|
||||||
</AppButton>
|
</AppButton>
|
||||||
</Stack>
|
</Stack>
|
||||||
@@ -207,11 +203,11 @@ function DeclinedPanel({
|
|||||||
</Paper>
|
</Paper>
|
||||||
<Stack sx={{ gap: 1 }}>
|
<Stack sx={{ gap: 1 }}>
|
||||||
{onRetry && retryLabel ? (
|
{onRetry && retryLabel ? (
|
||||||
<AppButton variant="outlined" color="secondary" onClick={onRetry} sx={{ m: 0 }}>
|
<AppButton variant="outlined" color="secondary" onClick={onRetry}>
|
||||||
{retryLabel}
|
{retryLabel}
|
||||||
</AppButton>
|
</AppButton>
|
||||||
) : null}
|
) : null}
|
||||||
<AppButton color="primary" variant="contained" size="large" onClick={onPayWithCard} sx={{ m: 0 }}>
|
<AppButton color="primary" variant="contained" size="large" onClick={onPayWithCard}>
|
||||||
{cardLabel}
|
{cardLabel}
|
||||||
</AppButton>
|
</AppButton>
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
'use client';
|
'use client';
|
||||||
import { FunctionComponent } from 'react';
|
import { FunctionComponent } from 'react';
|
||||||
import { useLocale, useTranslations } from 'next-intl';
|
import { useTranslations } from 'next-intl';
|
||||||
import { Box, ButtonBase, Paper, Stack, Typography } from '@mui/material';
|
import { Box, ButtonBase, Paper, Stack, Typography } from '@mui/material';
|
||||||
import { AppButton, AppIcon } from '@/components';
|
import { AppButton, AppIcon, EmptyState, Money } from '@/components';
|
||||||
import { formatIrrToToman } from '@/utils';
|
|
||||||
import type { BnplOptions, BnplProvider, ProviderCode } from '@/services/bnpl/types';
|
import type { BnplOptions, BnplProvider, ProviderCode } from '@/services/bnpl/types';
|
||||||
|
|
||||||
interface MethodStepProps {
|
interface MethodStepProps {
|
||||||
@@ -36,8 +35,6 @@ const MethodStep: FunctionComponent<MethodStepProps> = ({
|
|||||||
onPayWithCard,
|
onPayWithCard,
|
||||||
}) => {
|
}) => {
|
||||||
const t = useTranslations('bnpl');
|
const t = useTranslations('bnpl');
|
||||||
const tc = useTranslations('common');
|
|
||||||
const locale = useLocale();
|
|
||||||
const hasProviders = options.providers.length > 0;
|
const hasProviders = options.providers.length > 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -53,9 +50,7 @@ const MethodStep: FunctionComponent<MethodStepProps> = ({
|
|||||||
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
|
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
|
||||||
{t('payable_amount')}
|
{t('payable_amount')}
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography variant="h6" sx={{ fontWeight: 800, mt: 0.5 }}>
|
<Money amountIrr={options.orderAmountIrr} tone="emphasis" size="lg" sx={{ mt: 0.5 }} />
|
||||||
{formatIrrToToman(options.orderAmountIrr, locale)} {tc('currency_toman')}
|
|
||||||
</Typography>
|
|
||||||
</Paper>
|
</Paper>
|
||||||
|
|
||||||
{/* Full-card option — selecting it continues the f9 card flow (C6), which this phase does not rebuild. */}
|
{/* Full-card option — selecting it continues the f9 card flow (C6), which this phase does not rebuild. */}
|
||||||
@@ -109,7 +104,6 @@ const MethodStep: FunctionComponent<MethodStepProps> = ({
|
|||||||
size="large"
|
size="large"
|
||||||
disabled={selectedProvider == null}
|
disabled={selectedProvider == null}
|
||||||
onClick={onContinue}
|
onClick={onContinue}
|
||||||
sx={{ m: 0 }}
|
|
||||||
>
|
>
|
||||||
{selectedProvider
|
{selectedProvider
|
||||||
? t('continue_with', { provider: t(`provider_${selectedProvider}`) })
|
? t('continue_with', { provider: t(`provider_${selectedProvider}`) })
|
||||||
@@ -117,14 +111,7 @@ const MethodStep: FunctionComponent<MethodStepProps> = ({
|
|||||||
</AppButton>
|
</AppButton>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<Paper elevation={0} sx={{ p: 2.5, borderRadius: 2, border: '1px dashed', borderColor: 'divider' }}>
|
<EmptyState title={t('no_providers_title')} body={t('no_providers_body')} />
|
||||||
<Typography variant="body2" sx={{ fontWeight: 700 }}>
|
|
||||||
{t('no_providers_title')}
|
|
||||||
</Typography>
|
|
||||||
<Typography variant="body2" sx={{ color: 'text.secondary', mt: 0.5 }}>
|
|
||||||
{t('no_providers_body')}
|
|
||||||
</Typography>
|
|
||||||
</Paper>
|
|
||||||
)}
|
)}
|
||||||
</Stack>
|
</Stack>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
'use client';
|
'use client';
|
||||||
import { FunctionComponent } from 'react';
|
import { FunctionComponent } from 'react';
|
||||||
import { useLocale, useTranslations } from 'next-intl';
|
import { useTranslations } from 'next-intl';
|
||||||
import { Paper, Stack, Typography } from '@mui/material';
|
import { Paper, Stack, Typography } from '@mui/material';
|
||||||
import { AppButton, BnplPlanCard } from '@/components';
|
import { AppButton, BnplPlanCard, EmptyState, Money } from '@/components';
|
||||||
import { formatIrrToToman } from '@/utils';
|
|
||||||
import type { BnplPlanOption, ProviderCode } from '@/services/bnpl/types';
|
import type { BnplPlanOption, ProviderCode } from '@/services/bnpl/types';
|
||||||
|
|
||||||
interface PlanStepProps {
|
interface PlanStepProps {
|
||||||
@@ -31,20 +30,12 @@ const PlanStep: FunctionComponent<PlanStepProps> = ({
|
|||||||
}) => {
|
}) => {
|
||||||
const t = useTranslations('bnpl');
|
const t = useTranslations('bnpl');
|
||||||
const tc = useTranslations('common');
|
const tc = useTranslations('common');
|
||||||
const locale = useLocale();
|
|
||||||
|
|
||||||
if (plans.length === 0) {
|
if (plans.length === 0) {
|
||||||
return (
|
return (
|
||||||
<Stack sx={{ gap: 2 }}>
|
<Stack sx={{ gap: 2 }}>
|
||||||
<Paper elevation={0} sx={{ p: 2.5, borderRadius: 2, border: '1px dashed', borderColor: 'divider' }}>
|
<EmptyState title={t('no_plans_title')} body={t('no_plans_body')} />
|
||||||
<Typography variant="body2" sx={{ fontWeight: 700 }}>
|
<AppButton variant="outlined" color="primary" onClick={onBack}>
|
||||||
{t('no_plans_title')}
|
|
||||||
</Typography>
|
|
||||||
<Typography variant="body2" sx={{ color: 'text.secondary', mt: 0.5 }}>
|
|
||||||
{t('no_plans_body')}
|
|
||||||
</Typography>
|
|
||||||
</Paper>
|
|
||||||
<AppButton variant="outlined" color="primary" onClick={onBack} sx={{ m: 0 }}>
|
|
||||||
{t('back_to_providers')}
|
{t('back_to_providers')}
|
||||||
</AppButton>
|
</AppButton>
|
||||||
</Stack>
|
</Stack>
|
||||||
@@ -69,9 +60,7 @@ const PlanStep: FunctionComponent<PlanStepProps> = ({
|
|||||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||||
{t('total_amount')}
|
{t('total_amount')}
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography variant="subtitle2" sx={{ fontWeight: 800 }}>
|
<Money amountIrr={shownPlan.totalIrr} tone="emphasis" size="sm" />
|
||||||
{formatIrrToToman(shownPlan.totalIrr, locale)} {tc('currency_toman')}
|
|
||||||
</Typography>
|
|
||||||
</Stack>
|
</Stack>
|
||||||
</Paper>
|
</Paper>
|
||||||
|
|
||||||
@@ -93,11 +82,10 @@ const PlanStep: FunctionComponent<PlanStepProps> = ({
|
|||||||
size="large"
|
size="large"
|
||||||
disabled={selectedPlanId == null}
|
disabled={selectedPlanId == null}
|
||||||
onClick={onContinue}
|
onClick={onContinue}
|
||||||
sx={{ m: 0 }}
|
|
||||||
>
|
>
|
||||||
{t('continue')}
|
{t('continue')}
|
||||||
</AppButton>
|
</AppButton>
|
||||||
<AppButton variant="text" color="primary" onClick={onBack} sx={{ m: 0 }}>
|
<AppButton variant="text" color="primary" onClick={onBack}>
|
||||||
{tc('back')}
|
{tc('back')}
|
||||||
</AppButton>
|
</AppButton>
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|||||||
@@ -82,7 +82,7 @@ const ScheduleStep: FunctionComponent<ScheduleStepProps> = ({
|
|||||||
<AppAlert severity="error" variant="outlined" sx={{ marginY: 0 }}>
|
<AppAlert severity="error" variant="outlined" sx={{ marginY: 0 }}>
|
||||||
{t('error_body')}
|
{t('error_body')}
|
||||||
</AppAlert>
|
</AppAlert>
|
||||||
<AppButton variant="outlined" color="secondary" onClick={() => refetch()} sx={{ m: 0 }}>
|
<AppButton variant="outlined" color="secondary" onClick={() => refetch()}>
|
||||||
{tc('retry')}
|
{tc('retry')}
|
||||||
</AppButton>
|
</AppButton>
|
||||||
</Stack>
|
</Stack>
|
||||||
@@ -146,11 +146,11 @@ const ScheduleStep: FunctionComponent<ScheduleStepProps> = ({
|
|||||||
size="large"
|
size="large"
|
||||||
disabled={!accepted}
|
disabled={!accepted}
|
||||||
onClick={handleConfirm}
|
onClick={handleConfirm}
|
||||||
sx={{ m: 0, py: 1.25 }}
|
sx={{ py: 1.25 }}
|
||||||
>
|
>
|
||||||
{t('pay_down_payment')}
|
{t('pay_down_payment')}
|
||||||
</AppButton>
|
</AppButton>
|
||||||
<AppButton variant="text" color="primary" onClick={onBack} sx={{ m: 0 }}>
|
<AppButton variant="text" color="primary" onClick={onBack}>
|
||||||
{tc('back')}
|
{tc('back')}
|
||||||
</AppButton>
|
</AppButton>
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|||||||
@@ -2,8 +2,8 @@
|
|||||||
import { Suspense } from 'react';
|
import { Suspense } from 'react';
|
||||||
import { useLocale, useTranslations } from 'next-intl';
|
import { useLocale, useTranslations } from 'next-intl';
|
||||||
import { useRouter, useSearchParams } from 'next/navigation';
|
import { useRouter, useSearchParams } from 'next/navigation';
|
||||||
import { Paper, Stack, Typography } from '@mui/material';
|
import { Stack } from '@mui/material';
|
||||||
import { AppButton, AppIcon, AppLoading } from '@/components';
|
import { AppButton, AppLoading, EmptyState } from '@/components';
|
||||||
import { ROUTES } from '@/constants';
|
import { ROUTES } from '@/constants';
|
||||||
import {
|
import {
|
||||||
BNPL_QUERY_OUTCOME,
|
BNPL_QUERY_OUTCOME,
|
||||||
@@ -52,22 +52,20 @@ function BnplGatewayScreen() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Paper elevation={0} sx={{ p: 4, textAlign: 'center', border: '1px dashed', borderColor: 'divider', borderRadius: 2 }}>
|
<EmptyState
|
||||||
<Stack sx={{ gap: 1.5, alignItems: 'center' }}>
|
icon="installments"
|
||||||
<AppIcon icon="installments" size={44} color="var(--bal-secondary)" />
|
title={t('handoff_title')}
|
||||||
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
|
body={t('handoff_body', { provider: providerName })}
|
||||||
{t('handoff_title')}
|
action={
|
||||||
</Typography>
|
<Stack sx={{ gap: 1.5, alignItems: 'center', width: '100%' }}>
|
||||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
<AppButton color="secondary" variant="contained" size="large" onClick={() => returnWith('success')}>
|
||||||
{t('handoff_body', { provider: providerName })}
|
{t('handoff_pay_success')}
|
||||||
</Typography>
|
</AppButton>
|
||||||
<AppButton color="secondary" variant="contained" size="large" onClick={() => returnWith('success')} sx={{ m: 0 }}>
|
<AppButton variant="text" color="error" onClick={() => returnWith('failure')}>
|
||||||
{t('handoff_pay_success')}
|
{t('handoff_pay_fail')}
|
||||||
</AppButton>
|
</AppButton>
|
||||||
<AppButton variant="text" color="error" onClick={() => returnWith('failure')} sx={{ m: 0 }}>
|
</Stack>
|
||||||
{t('handoff_pay_fail')}
|
}
|
||||||
</AppButton>
|
/>
|
||||||
</Stack>
|
|
||||||
</Paper>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -201,7 +201,7 @@ function MessageCard({
|
|||||||
{body}
|
{body}
|
||||||
</Typography>
|
</Typography>
|
||||||
) : null}
|
) : null}
|
||||||
<AppButton variant="contained" color="primary" onClick={onCta} sx={{ m: 0 }}>
|
<AppButton variant="contained" color="primary" onClick={onCta}>
|
||||||
{ctaLabel}
|
{ctaLabel}
|
||||||
</AppButton>
|
</AppButton>
|
||||||
</Paper>
|
</Paper>
|
||||||
|
|||||||
@@ -98,7 +98,7 @@ function BnplReturnScreen() {
|
|||||||
if (!validId) {
|
if (!validId) {
|
||||||
return (
|
return (
|
||||||
<StateCard icon="error" tone="var(--bal-error)" title={t('error_title')}>
|
<StateCard icon="error" tone="var(--bal-error)" title={t('error_title')}>
|
||||||
<AppButton variant="contained" onClick={() => router.replace(`/${locale}${ROUTES.BOOKINGS}`)} sx={{ m: 0 }}>
|
<AppButton variant="contained" onClick={() => router.replace(`/${locale}${ROUTES.BOOKINGS}`)}>
|
||||||
{t('pay_with_card')}
|
{t('pay_with_card')}
|
||||||
</AppButton>
|
</AppButton>
|
||||||
</StateCard>
|
</StateCard>
|
||||||
@@ -113,7 +113,6 @@ function BnplReturnScreen() {
|
|||||||
<AppButton
|
<AppButton
|
||||||
variant="contained"
|
variant="contained"
|
||||||
onClick={() => router.replace(`/${locale}${ROUTES.BOOKING_REQUEST_STATUS}/${requestId}`)}
|
onClick={() => router.replace(`/${locale}${ROUTES.BOOKING_REQUEST_STATUS}/${requestId}`)}
|
||||||
sx={{ m: 0 }}
|
|
||||||
>
|
>
|
||||||
{tp('back_to_request')}
|
{tp('back_to_request')}
|
||||||
</AppButton>
|
</AppButton>
|
||||||
@@ -129,14 +128,12 @@ function BnplReturnScreen() {
|
|||||||
variant="contained"
|
variant="contained"
|
||||||
size="large"
|
size="large"
|
||||||
onClick={() => router.replace(`/${locale}${ROUTES.CHECKOUT_BNPL}?${BNPL_QUERY_REQUEST_ID}=${requestId}`)}
|
onClick={() => router.replace(`/${locale}${ROUTES.CHECKOUT_BNPL}?${BNPL_QUERY_REQUEST_ID}=${requestId}`)}
|
||||||
sx={{ m: 0 }}
|
|
||||||
>
|
>
|
||||||
{t('retry_installments')}
|
{t('retry_installments')}
|
||||||
</AppButton>
|
</AppButton>
|
||||||
<AppButton
|
<AppButton
|
||||||
variant="text"
|
variant="text"
|
||||||
onClick={() => router.replace(`/${locale}${ROUTES.CHECKOUT}?${CHECKOUT_QUERY_REQUEST_ID}=${requestId}`)}
|
onClick={() => router.replace(`/${locale}${ROUTES.CHECKOUT}?${CHECKOUT_QUERY_REQUEST_ID}=${requestId}`)}
|
||||||
sx={{ m: 0 }}
|
|
||||||
>
|
>
|
||||||
{t('pay_with_card')}
|
{t('pay_with_card')}
|
||||||
</AppButton>
|
</AppButton>
|
||||||
@@ -148,7 +145,7 @@ function BnplReturnScreen() {
|
|||||||
return (
|
return (
|
||||||
<StateCard icon="installments" tone="var(--bal-secondary)" title={t('settling_title')} body={t('settling_body')}>
|
<StateCard icon="installments" tone="var(--bal-secondary)" title={t('settling_title')} body={t('settling_body')}>
|
||||||
<CircularProgress color="secondary" size="2.5rem" />
|
<CircularProgress color="secondary" size="2.5rem" />
|
||||||
<AppButton variant="text" disabled={orderQuery.isFetching} onClick={() => orderQuery.refetch()} sx={{ m: 0 }}>
|
<AppButton variant="text" disabled={orderQuery.isFetching} onClick={() => orderQuery.refetch()}>
|
||||||
{t('check_again')}
|
{t('check_again')}
|
||||||
</AppButton>
|
</AppButton>
|
||||||
</StateCard>
|
</StateCard>
|
||||||
|
|||||||
@@ -3,9 +3,8 @@ import { Suspense } from 'react';
|
|||||||
import { useLocale, useTranslations } from 'next-intl';
|
import { useLocale, useTranslations } from 'next-intl';
|
||||||
import { useRouter, useSearchParams } from 'next/navigation';
|
import { useRouter, useSearchParams } from 'next/navigation';
|
||||||
import { Paper, Stack, Typography } from '@mui/material';
|
import { Paper, Stack, Typography } from '@mui/material';
|
||||||
import { AppButton, AppIcon, AppLoading } from '@/components';
|
import { AppButton, AppIcon, AppLoading, Money } from '@/components';
|
||||||
import { bookingInvoicePath, ROUTES } from '@/constants';
|
import { bookingInvoicePath, ROUTES } from '@/constants';
|
||||||
import { formatIrrToToman } from '@/utils';
|
|
||||||
import { useCheckoutSummary } from '@/services/payment';
|
import { useCheckoutSummary } from '@/services/payment';
|
||||||
import { CHECKOUT_QUERY_BOOKING_ID, CHECKOUT_QUERY_REQUEST_ID } from '@/services/payment/constants';
|
import { CHECKOUT_QUERY_BOOKING_ID, CHECKOUT_QUERY_REQUEST_ID } from '@/services/payment/constants';
|
||||||
import {
|
import {
|
||||||
@@ -32,7 +31,6 @@ export default function CheckoutConfirmationPage() {
|
|||||||
|
|
||||||
function ConfirmationScreen() {
|
function ConfirmationScreen() {
|
||||||
const t = useTranslations('payment');
|
const t = useTranslations('payment');
|
||||||
const tc = useTranslations('common');
|
|
||||||
const tBnpl = useTranslations('bnpl');
|
const tBnpl = useTranslations('bnpl');
|
||||||
const locale = useLocale();
|
const locale = useLocale();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -69,14 +67,12 @@ function ConfirmationScreen() {
|
|||||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||||
{t('total_paid_label')}
|
{t('total_paid_label')}
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography variant="h6" sx={{ color: 'var(--bal-secondary)' }}>
|
<Money amountIrr={summary.totalIrr} tone="emphasis" size="lg" />
|
||||||
{formatIrrToToman(summary.totalIrr, locale)} {tc('currency_toman')}
|
|
||||||
</Typography>
|
|
||||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||||
{summary.variantLabel} · {summary.nurseName}
|
{summary.variantLabel} · {summary.nurseName}
|
||||||
</Typography>
|
</Typography>
|
||||||
{isBnpl ? (
|
{isBnpl ? (
|
||||||
<Typography variant="caption" sx={{ color: 'var(--bal-secondary)', fontWeight: 600, mt: 0.5 }}>
|
<Typography variant="caption" sx={{ color: 'var(--bal-secondary)', fontWeight: 500, mt: 0.5 }}>
|
||||||
{tBnpl('paid_via_installments', {
|
{tBnpl('paid_via_installments', {
|
||||||
provider: bnplProvider ? tBnpl(`provider_${bnplProvider}`) : tBnpl('installments_heading'),
|
provider: bnplProvider ? tBnpl(`provider_${bnplProvider}`) : tBnpl('installments_heading'),
|
||||||
})}
|
})}
|
||||||
@@ -94,7 +90,6 @@ function ConfirmationScreen() {
|
|||||||
onClick={() =>
|
onClick={() =>
|
||||||
router.push(`/${locale}${bookingId != null ? `${ROUTES.BOOKINGS}/${bookingId}` : ROUTES.BOOKINGS}`)
|
router.push(`/${locale}${bookingId != null ? `${ROUTES.BOOKINGS}/${bookingId}` : ROUTES.BOOKINGS}`)
|
||||||
}
|
}
|
||||||
sx={{ m: 0 }}
|
|
||||||
>
|
>
|
||||||
{t('view_booking')}
|
{t('view_booking')}
|
||||||
</AppButton>
|
</AppButton>
|
||||||
@@ -104,7 +99,6 @@ function ConfirmationScreen() {
|
|||||||
color="primary"
|
color="primary"
|
||||||
startIcon="document"
|
startIcon="document"
|
||||||
onClick={() => router.push(`/${locale}${bookingInvoicePath(bookingId)}`)}
|
onClick={() => router.push(`/${locale}${bookingInvoicePath(bookingId)}`)}
|
||||||
sx={{ m: 0 }}
|
|
||||||
>
|
>
|
||||||
{t('download_invoice')}
|
{t('download_invoice')}
|
||||||
</AppButton>
|
</AppButton>
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import {
|
|||||||
import AppAlert from '@/components/common/AppAlert';
|
import AppAlert from '@/components/common/AppAlert';
|
||||||
import { ROUTES } from '@/constants';
|
import { ROUTES } from '@/constants';
|
||||||
import { ApiError } from '@/lib/api/errors';
|
import { ApiError } from '@/lib/api/errors';
|
||||||
import { formatShamsiDate } from '@/utils';
|
import { formatShamsiDate, localeTag } from '@/utils';
|
||||||
import { useCheckoutSummary, useInitiatePayment } from '@/services/payment';
|
import { useCheckoutSummary, useInitiatePayment } from '@/services/payment';
|
||||||
import {
|
import {
|
||||||
BNPL_ENABLED,
|
BNPL_ENABLED,
|
||||||
@@ -206,7 +206,7 @@ function CheckoutScreen() {
|
|||||||
size="large"
|
size="large"
|
||||||
disabled={busy}
|
disabled={busy}
|
||||||
onClick={handlePay}
|
onClick={handlePay}
|
||||||
sx={{ m: 0, py: 1.25 }}
|
sx={{ py: 1.25 }}
|
||||||
>
|
>
|
||||||
{initiate.isPending ? t('state_initiating') : initiate.isSuccess ? t('state_redirecting') : t('cta_pay')}
|
{initiate.isPending ? t('state_initiating') : initiate.isSuccess ? t('state_redirecting') : t('cta_pay')}
|
||||||
</AppButton>
|
</AppButton>
|
||||||
@@ -217,7 +217,6 @@ function CheckoutScreen() {
|
|||||||
color="secondary"
|
color="secondary"
|
||||||
startIcon="installments"
|
startIcon="installments"
|
||||||
onClick={() => router.push(`/${locale}${ROUTES.CHECKOUT_BNPL}?${CHECKOUT_QUERY_REQUEST_ID}=${requestId}`)}
|
onClick={() => router.push(`/${locale}${ROUTES.CHECKOUT_BNPL}?${CHECKOUT_QUERY_REQUEST_ID}=${requestId}`)}
|
||||||
sx={{ m: 0 }}
|
|
||||||
>
|
>
|
||||||
{t('bnpl_option')}
|
{t('bnpl_option')}
|
||||||
</AppButton>
|
</AppButton>
|
||||||
@@ -231,7 +230,7 @@ function CheckoutScreen() {
|
|||||||
function EngagementSummary({ summary, locale }: { summary: CheckoutSummaryDto; locale: string }) {
|
function EngagementSummary({ summary, locale }: { summary: CheckoutSummaryDto; locale: string }) {
|
||||||
const start = new Date(`${summary.requestedDate}T${summary.requestedTimeStart}`);
|
const start = new Date(`${summary.requestedDate}T${summary.requestedTimeStart}`);
|
||||||
const end = new Date(`${summary.requestedDate}T${summary.requestedTimeEnd}`);
|
const end = new Date(`${summary.requestedDate}T${summary.requestedTimeEnd}`);
|
||||||
const timeFmt = new Intl.DateTimeFormat(locale === 'fa' ? 'fa-IR' : 'en-US', {
|
const timeFmt = new Intl.DateTimeFormat(localeTag(locale), {
|
||||||
hour: '2-digit',
|
hour: '2-digit',
|
||||||
minute: '2-digit',
|
minute: '2-digit',
|
||||||
});
|
});
|
||||||
@@ -278,7 +277,7 @@ function MessageCard({
|
|||||||
{body}
|
{body}
|
||||||
</Typography>
|
</Typography>
|
||||||
) : null}
|
) : null}
|
||||||
<AppButton variant="contained" color="primary" onClick={onCta} sx={{ m: 0 }}>
|
<AppButton variant="contained" color="primary" onClick={onCta}>
|
||||||
{ctaLabel}
|
{ctaLabel}
|
||||||
</AppButton>
|
</AppButton>
|
||||||
</Paper>
|
</Paper>
|
||||||
|
|||||||
@@ -88,7 +88,7 @@ function ReturnScreen() {
|
|||||||
if (!validId) {
|
if (!validId) {
|
||||||
return (
|
return (
|
||||||
<StateCard icon="error" tone="var(--bal-error)" title={t('error_title')}>
|
<StateCard icon="error" tone="var(--bal-error)" title={t('error_title')}>
|
||||||
<AppButton variant="contained" onClick={() => router.replace(`/${locale}${ROUTES.BOOKINGS}`)} sx={{ m: 0 }}>
|
<AppButton variant="contained" onClick={() => router.replace(`/${locale}${ROUTES.BOOKINGS}`)}>
|
||||||
{t('view_booking')}
|
{t('view_booking')}
|
||||||
</AppButton>
|
</AppButton>
|
||||||
</StateCard>
|
</StateCard>
|
||||||
@@ -101,7 +101,6 @@ function ReturnScreen() {
|
|||||||
<AppButton
|
<AppButton
|
||||||
variant="contained"
|
variant="contained"
|
||||||
onClick={() => router.replace(`/${locale}${ROUTES.BOOKING_REQUEST_STATUS}/${requestId}`)}
|
onClick={() => router.replace(`/${locale}${ROUTES.BOOKING_REQUEST_STATUS}/${requestId}`)}
|
||||||
sx={{ m: 0 }}
|
|
||||||
>
|
>
|
||||||
{t('back_to_request')}
|
{t('back_to_request')}
|
||||||
</AppButton>
|
</AppButton>
|
||||||
@@ -120,14 +119,12 @@ function ReturnScreen() {
|
|||||||
onClick={() =>
|
onClick={() =>
|
||||||
router.replace(`/${locale}${ROUTES.CHECKOUT}?${CHECKOUT_QUERY_REQUEST_ID}=${requestId}`)
|
router.replace(`/${locale}${ROUTES.CHECKOUT}?${CHECKOUT_QUERY_REQUEST_ID}=${requestId}`)
|
||||||
}
|
}
|
||||||
sx={{ m: 0 }}
|
|
||||||
>
|
>
|
||||||
{t('retry_payment')}
|
{t('retry_payment')}
|
||||||
</AppButton>
|
</AppButton>
|
||||||
<AppButton
|
<AppButton
|
||||||
variant="text"
|
variant="text"
|
||||||
onClick={() => router.replace(`/${locale}${ROUTES.BOOKING_REQUEST_STATUS}/${requestId}`)}
|
onClick={() => router.replace(`/${locale}${ROUTES.BOOKING_REQUEST_STATUS}/${requestId}`)}
|
||||||
sx={{ m: 0 }}
|
|
||||||
>
|
>
|
||||||
{t('back_to_request')}
|
{t('back_to_request')}
|
||||||
</AppButton>
|
</AppButton>
|
||||||
@@ -141,7 +138,7 @@ function ReturnScreen() {
|
|||||||
<CircularProgress color="primary" size="2.5rem" />
|
<CircularProgress color="primary" size="2.5rem" />
|
||||||
<PaymentStatusBadge status="pending" />
|
<PaymentStatusBadge status="pending" />
|
||||||
{/* Manual re-check — covers the bounded poll giving up on a very slow callback. */}
|
{/* Manual re-check — covers the bounded poll giving up on a very slow callback. */}
|
||||||
<AppButton variant="text" disabled={outcomeQuery.isFetching} onClick={() => outcomeQuery.refetch()} sx={{ m: 0 }}>
|
<AppButton variant="text" disabled={outcomeQuery.isFetching} onClick={() => outcomeQuery.refetch()}>
|
||||||
{t('check_again')}
|
{t('check_again')}
|
||||||
</AppButton>
|
</AppButton>
|
||||||
</StateCard>
|
</StateCard>
|
||||||
|
|||||||
@@ -1,104 +1,13 @@
|
|||||||
'use client';
|
import type { Metadata } from 'next';
|
||||||
import { useLocale, useTranslations } from 'next-intl';
|
import { getTranslations } from 'next-intl/server';
|
||||||
import { useRouter } from 'next/navigation';
|
import BookingsScreen from './BookingsScreen';
|
||||||
import { Box, Paper, Skeleton, Stack, Typography } from '@mui/material';
|
|
||||||
import { AppButton, AppIcon, StatusChip } from '@/components';
|
|
||||||
import { BOOKING_STATUS_KIND } from '@/components/booking/statusKind';
|
|
||||||
import { ROUTES } from '@/constants';
|
|
||||||
import { formatIrrToToman, formatShamsiDate } from '@/utils';
|
|
||||||
import { useBookingList } from '@/services/bookings';
|
|
||||||
import type { BookingListItemDto } from '@/services/bookings/types';
|
|
||||||
|
|
||||||
/**
|
export async function generateMetadata({ params }: { params: Promise<{ locale: string }> }): Promise<Metadata> {
|
||||||
* Customer رزروها — the "My bookings" list. Reads `useBookingList('customer')`; each row opens the
|
const { locale } = await params;
|
||||||
* booking detail (`/bookings/{id}`). This is the customer entry to the f8 booking-detail surface (the C5
|
const t = await getTranslations({ locale, namespace: 'booking' });
|
||||||
* `converted` state also lands here). Amounts render in Toman via the money util.
|
return { title: t('list_title') };
|
||||||
*/
|
|
||||||
export default function CustomerBookingsPage() {
|
|
||||||
const t = useTranslations('booking');
|
|
||||||
const { data, isLoading, isError } = useBookingList('customer');
|
|
||||||
const items = data?.items ?? [];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
|
|
||||||
<Box>
|
|
||||||
<Typography variant="h5" component="h1">
|
|
||||||
{t('list_title')}
|
|
||||||
</Typography>
|
|
||||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
|
||||||
{t('list_subtitle')}
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
{isLoading ? (
|
|
||||||
<Stack sx={{ gap: 2 }}>
|
|
||||||
{[0, 1].map((key) => (
|
|
||||||
<Skeleton key={key} variant="rounded" height={120} />
|
|
||||||
))}
|
|
||||||
</Stack>
|
|
||||||
) : isError ? (
|
|
||||||
<Paper elevation={0} sx={{ p: 4, textAlign: 'center', border: '1px solid', borderColor: 'divider', borderRadius: 2 }}>
|
|
||||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
|
||||||
{t('list_error')}
|
|
||||||
</Typography>
|
|
||||||
</Paper>
|
|
||||||
) : items.length === 0 ? (
|
|
||||||
<Paper elevation={0} sx={{ p: 4, textAlign: 'center', border: '1px dashed', borderColor: 'divider', borderRadius: 2 }}>
|
|
||||||
<AppIcon icon="bookings" size={40} color="var(--bal-text-secondary)" />
|
|
||||||
<Typography variant="subtitle1" sx={{ fontWeight: 700, mt: 1 }}>
|
|
||||||
{t('list_empty_title')}
|
|
||||||
</Typography>
|
|
||||||
<Typography variant="body2" sx={{ color: 'text.secondary', mt: 0.5 }}>
|
|
||||||
{t('list_empty_body')}
|
|
||||||
</Typography>
|
|
||||||
</Paper>
|
|
||||||
) : (
|
|
||||||
<Stack sx={{ gap: 2 }}>
|
|
||||||
{items.map((item) => (
|
|
||||||
<BookingRow key={item.id} item={item} />
|
|
||||||
))}
|
|
||||||
</Stack>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function BookingRow({ item }: { item: BookingListItemDto }) {
|
export default function Page() {
|
||||||
const t = useTranslations('booking');
|
return <BookingsScreen />;
|
||||||
const tc = useTranslations('common');
|
|
||||||
const locale = useLocale();
|
|
||||||
const router = useRouter();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Paper elevation={0} sx={{ p: 2.5, border: '1px solid', borderColor: 'divider', borderRadius: 2 }}>
|
|
||||||
<Stack sx={{ gap: 1.5 }}>
|
|
||||||
<Stack direction="row" sx={{ justifyContent: 'space-between', alignItems: 'flex-start', gap: 1.5, flexWrap: 'wrap' }}>
|
|
||||||
<Stack sx={{ gap: 0.25, minWidth: 0 }}>
|
|
||||||
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
|
|
||||||
{item.counterpartyName}
|
|
||||||
</Typography>
|
|
||||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
|
||||||
{formatShamsiDate(item.scheduledDate, locale)} · {t('session_count', { count: item.sessionCount })}
|
|
||||||
</Typography>
|
|
||||||
</Stack>
|
|
||||||
<StatusChip status={BOOKING_STATUS_KIND[item.status]} label={t(`bstatus_${item.status}`)} />
|
|
||||||
</Stack>
|
|
||||||
|
|
||||||
<Stack direction="row" sx={{ justifyContent: 'space-between', alignItems: 'center', gap: 1.5, flexWrap: 'wrap' }}>
|
|
||||||
<Typography variant="body2" sx={{ fontWeight: 700 }}>
|
|
||||||
{t('list_total')}: {formatIrrToToman(item.amountIrr, locale)} {tc('currency_toman')}
|
|
||||||
</Typography>
|
|
||||||
<AppButton
|
|
||||||
variant="outlined"
|
|
||||||
color="primary"
|
|
||||||
endIcon="bookings"
|
|
||||||
onClick={() => router.push(`/${locale}${ROUTES.BOOKINGS}/${item.id}`)}
|
|
||||||
sx={{ m: 0 }}
|
|
||||||
>
|
|
||||||
{t('view_booking')}
|
|
||||||
</AppButton>
|
|
||||||
</Stack>
|
|
||||||
</Stack>
|
|
||||||
</Paper>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -185,7 +185,7 @@ export default function BookingRequestStatusPage() {
|
|||||||
size="large"
|
size="large"
|
||||||
endIcon="payment"
|
endIcon="payment"
|
||||||
onClick={() => router.push(`/${locale}${ROUTES.CHECKOUT}?request_id=${request.id}`)}
|
onClick={() => router.push(`/${locale}${ROUTES.CHECKOUT}?request_id=${request.id}`)}
|
||||||
sx={{ m: 0, py: 1.25 }}
|
sx={{ py: 1.25 }}
|
||||||
>
|
>
|
||||||
{t('continue_payment')}
|
{t('continue_payment')}
|
||||||
</AppButton>
|
</AppButton>
|
||||||
@@ -207,7 +207,7 @@ export default function BookingRequestStatusPage() {
|
|||||||
color="error"
|
color="error"
|
||||||
disabled={cancelRequest.isPending}
|
disabled={cancelRequest.isPending}
|
||||||
onClick={() => setConfirmCancel(true)}
|
onClick={() => setConfirmCancel(true)}
|
||||||
sx={{ m: 0, alignSelf: 'center' }}
|
sx={{ alignSelf: 'center' }}
|
||||||
>
|
>
|
||||||
{cancelRequest.isPending ? t('cancelling') : t('cancel_request')}
|
{cancelRequest.isPending ? t('cancelling') : t('cancel_request')}
|
||||||
</AppButton>
|
</AppButton>
|
||||||
@@ -273,7 +273,7 @@ function TerminalCard({
|
|||||||
{body}
|
{body}
|
||||||
</Typography>
|
</Typography>
|
||||||
) : null}
|
) : null}
|
||||||
<AppButton variant="contained" color="primary" onClick={onCta} sx={{ m: 0 }}>
|
<AppButton variant="contained" color="primary" onClick={onCta}>
|
||||||
{ctaLabel}
|
{ctaLabel}
|
||||||
</AppButton>
|
</AppButton>
|
||||||
</Paper>
|
</Paper>
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import {
|
|||||||
ToggleButtonGroup,
|
ToggleButtonGroup,
|
||||||
Typography,
|
Typography,
|
||||||
} from '@mui/material';
|
} from '@mui/material';
|
||||||
import { AppButton, AppIcon, AppLoading, PriceDisplay } from '@/components';
|
import { AppButton, AppLoading, EmptyState, PriceDisplay } from '@/components';
|
||||||
import { AddressMapPicker } from '@/components/geography';
|
import { AddressMapPicker } from '@/components/geography';
|
||||||
import { ROUTES } from '@/constants';
|
import { ROUTES } from '@/constants';
|
||||||
import { ApiError } from '@/lib/api/errors';
|
import { ApiError } from '@/lib/api/errors';
|
||||||
@@ -192,8 +192,16 @@ function BookingRequestForm() {
|
|||||||
icon="search"
|
icon="search"
|
||||||
title={t('missing_nurse_title')}
|
title={t('missing_nurse_title')}
|
||||||
body={t('missing_nurse_body')}
|
body={t('missing_nurse_body')}
|
||||||
ctaLabel={t('missing_nurse_cta')}
|
action={
|
||||||
onCta={() => router.push(`/${locale}${ROUTES.SEARCH}`)}
|
<AppButton
|
||||||
|
variant="contained"
|
||||||
|
color="primary"
|
||||||
|
onClick={() => router.push(`/${locale}${ROUTES.SEARCH}`)}
|
||||||
|
sx={{ m: 0 }}
|
||||||
|
>
|
||||||
|
{t('missing_nurse_cta')}
|
||||||
|
</AppButton>
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -384,7 +392,7 @@ function BookingRequestForm() {
|
|||||||
'& .MuiToggleButton-root': {
|
'& .MuiToggleButton-root': {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
py: 1.25,
|
py: 1.25,
|
||||||
fontWeight: 600,
|
fontWeight: 700,
|
||||||
borderColor: attempted && gender === '' ? 'var(--bal-error)' : undefined,
|
borderColor: attempted && gender === '' ? 'var(--bal-error)' : undefined,
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
@@ -438,7 +446,7 @@ function BookingRequestForm() {
|
|||||||
startIcon="requests"
|
startIcon="requests"
|
||||||
disabled={!requiredChosen || genderMismatch || createRequest.isPending}
|
disabled={!requiredChosen || genderMismatch || createRequest.isPending}
|
||||||
onClick={handleSubmit}
|
onClick={handleSubmit}
|
||||||
sx={{ m: 0, py: 1.5 }}
|
sx={{ py: 1.5 }}
|
||||||
>
|
>
|
||||||
{createRequest.isPending ? t('submitting') : t('submit')}
|
{createRequest.isPending ? t('submitting') : t('submit')}
|
||||||
</AppButton>
|
</AppButton>
|
||||||
@@ -489,49 +497,18 @@ function FieldEmpty({
|
|||||||
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>
|
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>
|
||||||
{label}
|
{label}
|
||||||
</Typography>
|
</Typography>
|
||||||
<Paper elevation={0} sx={{ p: 2, border: '1px dashed', borderColor: 'divider', borderRadius: 2 }}>
|
<EmptyState
|
||||||
<Stack direction="row" sx={{ gap: 1, alignItems: 'center', justifyContent: 'space-between', flexWrap: 'wrap' }}>
|
title={message}
|
||||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
action={
|
||||||
{message}
|
<AppButton variant="outlined" color="primary" startIcon="add" onClick={onCta}>
|
||||||
</Typography>
|
|
||||||
<AppButton variant="outlined" color="primary" startIcon="add" onClick={onCta} sx={{ m: 0 }}>
|
|
||||||
{ctaLabel}
|
{ctaLabel}
|
||||||
</AppButton>
|
</AppButton>
|
||||||
</Stack>
|
}
|
||||||
</Paper>
|
/>
|
||||||
</Stack>
|
</Stack>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function EmptyState({
|
|
||||||
icon,
|
|
||||||
title,
|
|
||||||
body,
|
|
||||||
ctaLabel,
|
|
||||||
onCta,
|
|
||||||
}: {
|
|
||||||
icon: string;
|
|
||||||
title: string;
|
|
||||||
body: string;
|
|
||||||
ctaLabel: string;
|
|
||||||
onCta: () => void;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<Paper elevation={0} sx={{ p: 4, textAlign: 'center', border: '1px dashed', borderColor: 'divider', borderRadius: 2 }}>
|
|
||||||
<AppIcon icon={icon} size={40} color="var(--bal-text-secondary)" />
|
|
||||||
<Typography variant="subtitle1" sx={{ fontWeight: 700, mt: 1, mb: 0.5 }}>
|
|
||||||
{title}
|
|
||||||
</Typography>
|
|
||||||
<Typography variant="body2" sx={{ color: 'text.secondary', mb: 2 }}>
|
|
||||||
{body}
|
|
||||||
</Typography>
|
|
||||||
<AppButton variant="contained" color="primary" onClick={onCta} sx={{ m: 0 }}>
|
|
||||||
{ctaLabel}
|
|
||||||
</AppButton>
|
|
||||||
</Paper>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function FormSkeleton() {
|
function FormSkeleton() {
|
||||||
return (
|
return (
|
||||||
<Stack sx={{ gap: 2.5 }}>
|
<Stack sx={{ gap: 2.5 }}>
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import Skeleton from '@mui/material/Skeleton';
|
||||||
|
import Stack from '@mui/material/Stack';
|
||||||
|
import Box from '@mui/material/Box';
|
||||||
|
import SurfaceCard from '@/components/common/SurfaceCard';
|
||||||
|
import { CONTENT_MAX_WIDTH } from '@/components/config';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The (customer) home shell shape: greeting/avatar + search bar + a category-tile row + a short card
|
||||||
|
* stack — mirrors A5 (`(customer)/page.tsx`) closely enough that switching from skeleton to real content
|
||||||
|
* doesn't jump. `CustomerLayout` (top bar + bottom tabs) is already rendered by the enclosing layout.
|
||||||
|
*/
|
||||||
|
export default function Loading() {
|
||||||
|
return (
|
||||||
|
<Box sx={{ maxWidth: CONTENT_MAX_WIDTH, mx: 'auto', width: '100%' }}>
|
||||||
|
<Stack sx={{ gap: 2.5 }}>
|
||||||
|
<Stack direction="row" sx={{ gap: 1.5, alignItems: 'center' }}>
|
||||||
|
<Skeleton variant="circular" width={48} height={48} />
|
||||||
|
<Skeleton variant="text" width="45%" height={28} />
|
||||||
|
</Stack>
|
||||||
|
<Skeleton variant="rounded" height={48} sx={{ borderRadius: 'var(--bal-radius-sm)' }} />
|
||||||
|
<Stack direction="row" sx={{ gap: 1.5, flexWrap: 'wrap' }}>
|
||||||
|
{[0, 1, 2, 3].map((key) => (
|
||||||
|
<Skeleton key={key} variant="rounded" width={92} height={92} sx={{ borderRadius: 'var(--bal-radius-md)' }} />
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
{[0, 1].map((key) => (
|
||||||
|
<SurfaceCard key={key}>
|
||||||
|
<Stack sx={{ gap: 0.75 }}>
|
||||||
|
<Skeleton variant="text" width="50%" height={22} />
|
||||||
|
<Skeleton variant="text" width="80%" height={18} />
|
||||||
|
</Stack>
|
||||||
|
</SurfaceCard>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -67,7 +67,6 @@ export default function OnboardingPage() {
|
|||||||
fullWidth
|
fullWidth
|
||||||
disabled={!relation}
|
disabled={!relation}
|
||||||
onClick={() => setStep(1)}
|
onClick={() => setStep(1)}
|
||||||
sx={{ m: 0 }}
|
|
||||||
>
|
>
|
||||||
{t('continue')}
|
{t('continue')}
|
||||||
</AppButton>
|
</AppButton>
|
||||||
|
|||||||
@@ -1,210 +1,13 @@
|
|||||||
'use client';
|
import type { Metadata } from 'next';
|
||||||
import { FormEvent, FunctionComponent, useEffect, useState } from 'react';
|
import { getTranslations } from 'next-intl/server';
|
||||||
import { useRouter } from 'next/navigation';
|
import HomeScreen from './HomeScreen';
|
||||||
import { useLocale, useTranslations } from 'next-intl';
|
|
||||||
import { Avatar, Box, InputAdornment, Paper, Skeleton, Stack, TextField, Typography } from '@mui/material';
|
|
||||||
import { AppButton, AppIcon, AppLoading, CategoryTile } from '@/components';
|
|
||||||
import { ROUTES } from '@/constants';
|
|
||||||
import { useMe } from '@/services/auth';
|
|
||||||
import { usePatients } from '@/services/patients';
|
|
||||||
import { useServiceCategories } from '@/services/catalog';
|
|
||||||
import { pickCatalogName } from '@/services/catalog/names';
|
|
||||||
|
|
||||||
interface NudgeCardProps {
|
export async function generateMetadata({ params }: { params: Promise<{ locale: string }> }): Promise<Metadata> {
|
||||||
icon: string;
|
const { locale } = await params;
|
||||||
title: string;
|
const t = await getTranslations({ locale, namespace: 'shell' });
|
||||||
body: string;
|
return { title: t('customer_app') };
|
||||||
ctaLabel: string;
|
|
||||||
to: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const NudgeCard: FunctionComponent<NudgeCardProps> = ({ icon, title, body, ctaLabel, to }) => (
|
export default function Page() {
|
||||||
<Paper
|
return <HomeScreen />;
|
||||||
elevation={0}
|
|
||||||
sx={{ p: 2.5, border: '1px solid', borderColor: 'divider', borderRadius: 2, display: 'flex', gap: 2 }}
|
|
||||||
>
|
|
||||||
<AppIcon icon={icon} size={28} color="var(--bal-primary)" />
|
|
||||||
<Stack sx={{ gap: 1, flexGrow: 1 }}>
|
|
||||||
<Stack sx={{ gap: 0.25 }}>
|
|
||||||
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
|
|
||||||
{title}
|
|
||||||
</Typography>
|
|
||||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
|
||||||
{body}
|
|
||||||
</Typography>
|
|
||||||
</Stack>
|
|
||||||
<AppButton color="primary" variant="outlined" to={to} sx={{ m: 0, alignSelf: 'flex-start' }}>
|
|
||||||
{ctaLabel}
|
|
||||||
</AppButton>
|
|
||||||
</Stack>
|
|
||||||
</Paper>
|
|
||||||
);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A5 — the family Home: the front door of the app. Greeting + avatar, the search bar (which hands a
|
|
||||||
* query / chosen `service_category_id` toward the f6 search flow — results are not built here), the
|
|
||||||
* **data-driven** service-category grid (from the cached `services/catalog` reference data), and the
|
|
||||||
* complete-patient-record nudge (derived from the f2 patient cache — no extra fetch).
|
|
||||||
*
|
|
||||||
* First-login gate: a customer with no patients is sent into onboarding (A3). The redirect waits for
|
|
||||||
* a settled list so a post-create refetch never bounces the user back to onboarding.
|
|
||||||
*/
|
|
||||||
export default function CustomerHomePage() {
|
|
||||||
const t = useTranslations('home');
|
|
||||||
const router = useRouter();
|
|
||||||
const locale = useLocale();
|
|
||||||
|
|
||||||
const { data: me } = useMe();
|
|
||||||
const { data } = usePatients();
|
|
||||||
|
|
||||||
const isEmpty = data?.total === 0;
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (isEmpty) router.replace(`/${locale}${ROUTES.ONBOARDING}`);
|
|
||||||
}, [isEmpty, router, locale]);
|
|
||||||
|
|
||||||
if (data == null || isEmpty) {
|
|
||||||
return <AppLoading />;
|
|
||||||
}
|
|
||||||
|
|
||||||
const href = (path: string) => `/${locale}${path}`;
|
|
||||||
const profileComplete = me?.hasCustomerProfile ?? false;
|
|
||||||
const firstName = me?.firstName?.trim() || null;
|
|
||||||
const greeting = firstName ? t('greeting_named', { name: firstName }) : t('greeting_plain');
|
|
||||||
const avatarInitial = firstName ? firstName.charAt(0).toUpperCase() : null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
|
|
||||||
<Stack direction="row" sx={{ gap: 1.5, alignItems: 'center' }}>
|
|
||||||
<Avatar sx={{ width: 48, height: 48, bgcolor: 'var(--bal-primary-soft)', color: 'var(--bal-primary)', fontWeight: 700 }}>
|
|
||||||
{avatarInitial ?? <AppIcon icon="account" size={28} color="var(--bal-primary)" />}
|
|
||||||
</Avatar>
|
|
||||||
<Box>
|
|
||||||
<Typography variant="h5" component="h1">
|
|
||||||
{greeting}
|
|
||||||
</Typography>
|
|
||||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
|
||||||
{t('subtitle')}
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
</Stack>
|
|
||||||
|
|
||||||
<HomeSearchBar />
|
|
||||||
|
|
||||||
<CategoryGrid onSelect={(categoryId) => router.push(`${href(ROUTES.SEARCH)}?category_id=${categoryId}`)} />
|
|
||||||
|
|
||||||
<NudgeCard
|
|
||||||
icon="patients"
|
|
||||||
title={t('nudge_patient_title')}
|
|
||||||
body={t('nudge_patient_body')}
|
|
||||||
ctaLabel={t('nudge_patient_cta')}
|
|
||||||
to={href(ROUTES.PATIENTS)}
|
|
||||||
/>
|
|
||||||
{!profileComplete ? (
|
|
||||||
<NudgeCard
|
|
||||||
icon="profile"
|
|
||||||
title={t('nudge_profile_title')}
|
|
||||||
body={t('nudge_profile_body')}
|
|
||||||
ctaLabel={t('nudge_profile_cta')}
|
|
||||||
to={href(ROUTES.PROFILE)}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* The Home search field. Rendering + query capture live here; **execution is f6** — submitting
|
|
||||||
* navigates toward the (future) search route carrying the typed query. Results/filters are DEFERRED
|
|
||||||
* → frontend-phase-6-b7.
|
|
||||||
*/
|
|
||||||
const HomeSearchBar: FunctionComponent = () => {
|
|
||||||
const t = useTranslations('home');
|
|
||||||
const router = useRouter();
|
|
||||||
const locale = useLocale();
|
|
||||||
const [query, setQuery] = useState('');
|
|
||||||
|
|
||||||
const submit = (event: FormEvent) => {
|
|
||||||
event.preventDefault();
|
|
||||||
const q = query.trim();
|
|
||||||
router.push(`/${locale}${ROUTES.SEARCH}${q ? `?q=${encodeURIComponent(q)}` : ''}`);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Box component="form" onSubmit={submit} role="search">
|
|
||||||
<TextField
|
|
||||||
fullWidth
|
|
||||||
value={query}
|
|
||||||
onChange={(event) => setQuery(event.target.value)}
|
|
||||||
placeholder={t('search_placeholder')}
|
|
||||||
aria-label={t('search_action')}
|
|
||||||
slotProps={{
|
|
||||||
input: {
|
|
||||||
startAdornment: (
|
|
||||||
<InputAdornment position="start">
|
|
||||||
<AppIcon icon="search" size={22} color="var(--bal-text-secondary)" />
|
|
||||||
</InputAdornment>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
/** The data-driven service-category grid — one tile per `service_category`, with all four states. */
|
|
||||||
const CategoryGrid: FunctionComponent<{ onSelect: (categoryId: number) => void }> = ({ onSelect }) => {
|
|
||||||
const t = useTranslations('home');
|
|
||||||
const tc = useTranslations('common');
|
|
||||||
const locale = useLocale();
|
|
||||||
const { data, isLoading, isError, refetch } = useServiceCategories();
|
|
||||||
const categories = data?.items ?? [];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Stack sx={{ gap: 1.5 }}>
|
|
||||||
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
|
|
||||||
{t('categories_title')}
|
|
||||||
</Typography>
|
|
||||||
|
|
||||||
{isLoading ? (
|
|
||||||
<Box sx={{ display: 'grid', gridTemplateColumns: { xs: 'repeat(2, 1fr)', sm: 'repeat(3, 1fr)' }, gap: 1.5 }}>
|
|
||||||
{[0, 1, 2, 3].map((key) => (
|
|
||||||
<Skeleton key={key} variant="rounded" height={116} sx={{ borderRadius: 2 }} />
|
|
||||||
))}
|
|
||||||
</Box>
|
|
||||||
) : isError ? (
|
|
||||||
<Paper
|
|
||||||
elevation={0}
|
|
||||||
sx={{ p: 3, textAlign: 'center', border: '1px dashed', borderColor: 'divider', borderRadius: 2 }}
|
|
||||||
>
|
|
||||||
<Typography variant="body2" sx={{ color: 'text.secondary', mb: 1 }}>
|
|
||||||
{t('categories_error')}
|
|
||||||
</Typography>
|
|
||||||
<AppButton variant="outlined" color="primary" onClick={() => refetch()} sx={{ m: 0 }}>
|
|
||||||
{tc('retry')}
|
|
||||||
</AppButton>
|
|
||||||
</Paper>
|
|
||||||
) : categories.length === 0 ? (
|
|
||||||
<Paper
|
|
||||||
elevation={0}
|
|
||||||
sx={{ p: 3, textAlign: 'center', border: '1px dashed', borderColor: 'divider', borderRadius: 2 }}
|
|
||||||
>
|
|
||||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
|
||||||
{t('categories_empty')}
|
|
||||||
</Typography>
|
|
||||||
</Paper>
|
|
||||||
) : (
|
|
||||||
<Box sx={{ display: 'grid', gridTemplateColumns: { xs: 'repeat(2, 1fr)', sm: 'repeat(3, 1fr)' }, gap: 1.5 }}>
|
|
||||||
{categories.map((category) => (
|
|
||||||
<CategoryTile
|
|
||||||
key={category.id}
|
|
||||||
label={pickCatalogName(category, locale)}
|
|
||||||
iconKey={category.iconKey}
|
|
||||||
onClick={() => onSelect(category.id)}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
</Stack>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import {
|
|||||||
TextField,
|
TextField,
|
||||||
Typography,
|
Typography,
|
||||||
} from '@mui/material';
|
} from '@mui/material';
|
||||||
import { AppButton, AppIcon, PatientHeader, VisitNoteCard } from '@/components';
|
import { AppButton, AppIcon, EmptyState, PatientHeader, VisitNoteCard } from '@/components';
|
||||||
import { ROUTES } from '@/constants';
|
import { ROUTES } from '@/constants';
|
||||||
import { formatShamsiDate } from '@/utils';
|
import { formatShamsiDate } from '@/utils';
|
||||||
import { usePatient } from '@/services/patients';
|
import { usePatient } from '@/services/patients';
|
||||||
@@ -59,15 +59,7 @@ export default function PatientRecordPage() {
|
|||||||
if (access.data && !access.data.canView) {
|
if (access.data && !access.data.canView) {
|
||||||
return (
|
return (
|
||||||
<Stack sx={{ gap: 3, maxWidth: 640, mx: 'auto', width: '100%' }}>
|
<Stack sx={{ gap: 3, maxWidth: 640, mx: 'auto', width: '100%' }}>
|
||||||
<Paper elevation={0} sx={{ p: 4, textAlign: 'center', border: '1px dashed', borderColor: 'divider', borderRadius: 2 }}>
|
<EmptyState icon="lock" title={t('access_denied_title')} body={t('access_denied_body')} />
|
||||||
<AppIcon icon="lock" size={40} color="var(--bal-text-secondary)" />
|
|
||||||
<Typography variant="subtitle1" sx={{ fontWeight: 700, mt: 1.5, mb: 0.5 }}>
|
|
||||||
{t('access_denied_title')}
|
|
||||||
</Typography>
|
|
||||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
|
||||||
{t('access_denied_body')}
|
|
||||||
</Typography>
|
|
||||||
</Paper>
|
|
||||||
<BackToPatients />
|
<BackToPatients />
|
||||||
</Stack>
|
</Stack>
|
||||||
);
|
);
|
||||||
@@ -78,14 +70,7 @@ export default function PatientRecordPage() {
|
|||||||
if (patient.isError || !patient.data) {
|
if (patient.isError || !patient.data) {
|
||||||
return (
|
return (
|
||||||
<Stack sx={{ gap: 3, maxWidth: 640, mx: 'auto', width: '100%' }}>
|
<Stack sx={{ gap: 3, maxWidth: 640, mx: 'auto', width: '100%' }}>
|
||||||
<Paper elevation={0} sx={{ p: 4, textAlign: 'center', border: '1px dashed', borderColor: 'divider', borderRadius: 2 }}>
|
<EmptyState title={t('not_found_title')} body={t('not_found_body')} />
|
||||||
<Typography variant="subtitle1" sx={{ fontWeight: 700, mb: 0.5 }}>
|
|
||||||
{t('not_found_title')}
|
|
||||||
</Typography>
|
|
||||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
|
||||||
{t('not_found_body')}
|
|
||||||
</Typography>
|
|
||||||
</Paper>
|
|
||||||
<BackToPatients />
|
<BackToPatients />
|
||||||
</Stack>
|
</Stack>
|
||||||
);
|
);
|
||||||
@@ -110,7 +95,7 @@ export default function PatientRecordPage() {
|
|||||||
sx={{ p: 2, borderRadius: 2, backgroundColor: 'var(--bal-primary-soft)', display: 'flex', gap: 1, alignItems: 'center' }}
|
sx={{ p: 2, borderRadius: 2, backgroundColor: 'var(--bal-primary-soft)', display: 'flex', gap: 1, alignItems: 'center' }}
|
||||||
>
|
>
|
||||||
<AppIcon icon="family" size={22} color="var(--bal-primary)" />
|
<AppIcon icon="family" size={22} color="var(--bal-primary)" />
|
||||||
<Typography variant="body2" sx={{ color: 'var(--bal-primary)', fontWeight: 600 }}>
|
<Typography variant="body2" sx={{ color: 'var(--bal-primary)', fontWeight: 500 }}>
|
||||||
{t('ownership_banner')}
|
{t('ownership_banner')}
|
||||||
</Typography>
|
</Typography>
|
||||||
</Paper>
|
</Paper>
|
||||||
@@ -435,13 +420,13 @@ function HistoryTab({ patientId }: { patientId: number }) {
|
|||||||
))}
|
))}
|
||||||
{totalPages > 1 ? (
|
{totalPages > 1 ? (
|
||||||
<Stack direction="row" sx={{ gap: 1, justifyContent: 'center', alignItems: 'center' }}>
|
<Stack direction="row" sx={{ gap: 1, justifyContent: 'center', alignItems: 'center' }}>
|
||||||
<AppButton variant="text" color="primary" disabled={page <= 1 || history.isFetching} onClick={() => setPage((n) => Math.max(1, n - 1))} sx={{ m: 0 }}>
|
<AppButton variant="text" color="primary" disabled={page <= 1 || history.isFetching} onClick={() => setPage((n) => Math.max(1, n - 1))}>
|
||||||
{t('prev')}
|
{t('prev')}
|
||||||
</AppButton>
|
</AppButton>
|
||||||
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
|
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
|
||||||
{t('page_of', { page, total: totalPages })}
|
{t('page_of', { page, total: totalPages })}
|
||||||
</Typography>
|
</Typography>
|
||||||
<AppButton variant="text" color="primary" disabled={page >= totalPages || history.isFetching} onClick={() => setPage((n) => n + 1)} sx={{ m: 0 }}>
|
<AppButton variant="text" color="primary" disabled={page >= totalPages || history.isFetching} onClick={() => setPage((n) => n + 1)}>
|
||||||
{t('next')}
|
{t('next')}
|
||||||
</AppButton>
|
</AppButton>
|
||||||
</Stack>
|
</Stack>
|
||||||
@@ -468,7 +453,7 @@ function SectionShell({
|
|||||||
return (
|
return (
|
||||||
<Stack sx={{ gap: 1.5 }}>
|
<Stack sx={{ gap: 1.5 }}>
|
||||||
{canEdit ? (
|
{canEdit ? (
|
||||||
<AppButton variant="text" color="primary" startIcon="edit" onClick={onEdit} sx={{ m: 0, alignSelf: 'flex-end' }}>
|
<AppButton variant="text" color="primary" startIcon="edit" onClick={onEdit} sx={{ alignSelf: 'flex-end' }}>
|
||||||
{tc('edit')}
|
{tc('edit')}
|
||||||
</AppButton>
|
</AppButton>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -500,14 +485,14 @@ function EditActions({
|
|||||||
const tc = useTranslations('common');
|
const tc = useTranslations('common');
|
||||||
return (
|
return (
|
||||||
<Stack sx={{ gap: 1 }}>
|
<Stack sx={{ gap: 1 }}>
|
||||||
<AppButton variant="outlined" color="primary" startIcon="add" onClick={onAdd} sx={{ m: 0, alignSelf: 'flex-start' }}>
|
<AppButton variant="outlined" color="primary" startIcon="add" onClick={onAdd} sx={{ alignSelf: 'flex-start' }}>
|
||||||
{t('add_item')}
|
{t('add_item')}
|
||||||
</AppButton>
|
</AppButton>
|
||||||
<Stack direction="row" sx={{ gap: 1, justifyContent: 'flex-end' }}>
|
<Stack direction="row" sx={{ gap: 1, justifyContent: 'flex-end' }}>
|
||||||
<AppButton variant="text" color="primary" onClick={onCancel} disabled={saving} sx={{ m: 0 }}>
|
<AppButton variant="text" color="primary" onClick={onCancel} disabled={saving}>
|
||||||
{tc('cancel')}
|
{tc('cancel')}
|
||||||
</AppButton>
|
</AppButton>
|
||||||
<AppButton variant="contained" color="primary" onClick={onSave} disabled={saving || !canSave} sx={{ m: 0 }}>
|
<AppButton variant="contained" color="primary" onClick={onSave} disabled={saving || !canSave}>
|
||||||
{saving ? tc('saving') : tc('save')}
|
{saving ? tc('saving') : tc('save')}
|
||||||
</AppButton>
|
</AppButton>
|
||||||
</Stack>
|
</Stack>
|
||||||
@@ -524,7 +509,7 @@ function BackToPatients() {
|
|||||||
variant="outlined"
|
variant="outlined"
|
||||||
color="primary"
|
color="primary"
|
||||||
onClick={() => router.push(`/${locale}${ROUTES.PATIENTS}`)}
|
onClick={() => router.push(`/${locale}${ROUTES.PATIENTS}`)}
|
||||||
sx={{ m: 0, alignSelf: 'flex-start' }}
|
sx={{ alignSelf: 'flex-start' }}
|
||||||
>
|
>
|
||||||
{t('back_to_patients')}
|
{t('back_to_patients')}
|
||||||
</AppButton>
|
</AppButton>
|
||||||
|
|||||||
@@ -9,12 +9,11 @@ import {
|
|||||||
DialogActions,
|
DialogActions,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
Paper,
|
|
||||||
Skeleton,
|
Skeleton,
|
||||||
Stack,
|
Stack,
|
||||||
Typography,
|
Typography,
|
||||||
} from '@mui/material';
|
} from '@mui/material';
|
||||||
import { AppButton, AppIcon, PatientCard, PatientForm } from '@/components';
|
import { AppButton, EmptyState, ErrorState, PatientCard, PatientForm } from '@/components';
|
||||||
import { patientRecordPath } from '@/constants';
|
import { patientRecordPath } from '@/constants';
|
||||||
import { usePatients, useCreatePatient, useUpdatePatient, useArchivePatient } from '@/services/patients';
|
import { usePatients, useCreatePatient, useUpdatePatient, useArchivePatient } from '@/services/patients';
|
||||||
import { birthDateToAge } from '@/services/patients/age';
|
import { birthDateToAge } from '@/services/patients/age';
|
||||||
@@ -33,7 +32,7 @@ export default function PatientsPage() {
|
|||||||
const locale = useLocale();
|
const locale = useLocale();
|
||||||
const { enqueueSnackbar } = useSnackbar();
|
const { enqueueSnackbar } = useSnackbar();
|
||||||
|
|
||||||
const { data, isLoading } = usePatients();
|
const { data, isLoading, isError, refetch } = usePatients();
|
||||||
const createPatient = useCreatePatient();
|
const createPatient = useCreatePatient();
|
||||||
const updatePatient = useUpdatePatient();
|
const updatePatient = useUpdatePatient();
|
||||||
const archivePatient = useArchivePatient();
|
const archivePatient = useArchivePatient();
|
||||||
@@ -80,7 +79,7 @@ export default function PatientsPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const patients = data?.items ?? [];
|
const patients = data?.items ?? [];
|
||||||
const isEmpty = !isLoading && patients.length === 0;
|
const isEmpty = !isLoading && !isError && patients.length === 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
|
||||||
@@ -93,8 +92,8 @@ export default function PatientsPage() {
|
|||||||
{t('subtitle')}
|
{t('subtitle')}
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
{!isEmpty ? (
|
{!isEmpty && !isError ? (
|
||||||
<AppButton color="primary" variant="contained" startIcon="add" onClick={openAdd} sx={{ m: 0, flexShrink: 0 }}>
|
<AppButton color="primary" variant="contained" startIcon="add" onClick={openAdd} sx={{ flexShrink: 0 }}>
|
||||||
{t('add')}
|
{t('add')}
|
||||||
</AppButton>
|
</AppButton>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -106,32 +105,19 @@ export default function PatientsPage() {
|
|||||||
<Skeleton key={key} variant="rounded" height={96} />
|
<Skeleton key={key} variant="rounded" height={96} />
|
||||||
))}
|
))}
|
||||||
</Stack>
|
</Stack>
|
||||||
|
) : isError ? (
|
||||||
|
<ErrorState message={t('load_error')} retryLabel={tc('retry')} onRetry={() => refetch()} />
|
||||||
) : isEmpty ? (
|
) : isEmpty ? (
|
||||||
<Paper
|
<EmptyState
|
||||||
elevation={0}
|
icon="patients"
|
||||||
sx={{
|
title={t('empty_title')}
|
||||||
p: 4,
|
body={t('empty_body')}
|
||||||
textAlign: 'center',
|
action={
|
||||||
border: '1px dashed',
|
<AppButton color="primary" variant="contained" startIcon="add" onClick={openAdd}>
|
||||||
borderColor: 'divider',
|
{t('add')}
|
||||||
borderRadius: 2,
|
</AppButton>
|
||||||
display: 'flex',
|
}
|
||||||
flexDirection: 'column',
|
/>
|
||||||
alignItems: 'center',
|
|
||||||
gap: 1.5,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<AppIcon icon="patients" size={40} color="var(--bal-primary)" />
|
|
||||||
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
|
|
||||||
{t('empty_title')}
|
|
||||||
</Typography>
|
|
||||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
|
||||||
{t('empty_body')}
|
|
||||||
</Typography>
|
|
||||||
<AppButton color="primary" variant="contained" startIcon="add" onClick={openAdd} sx={{ mt: 1 }}>
|
|
||||||
{t('add')}
|
|
||||||
</AppButton>
|
|
||||||
</Paper>
|
|
||||||
) : (
|
) : (
|
||||||
<Stack sx={{ gap: 1.5 }}>
|
<Stack sx={{ gap: 1.5 }}>
|
||||||
{patients.map((patient) => {
|
{patients.map((patient) => {
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { FunctionComponent, useState } from 'react';
|
|||||||
import { useLocale, useTranslations } from 'next-intl';
|
import { useLocale, useTranslations } from 'next-intl';
|
||||||
import { useSnackbar } from 'notistack';
|
import { useSnackbar } from 'notistack';
|
||||||
import { Box, Divider, MenuItem, Paper, Stack, TextField, Typography } from '@mui/material';
|
import { Box, Divider, MenuItem, Paper, Stack, TextField, Typography } from '@mui/material';
|
||||||
import { AppButton, AppIcon, AppLoading, PhoneNumberField } from '@/components';
|
import { AppButton, AppIcon, AppLoading, ErrorState, PhoneNumberField } from '@/components';
|
||||||
import { isIranianMobile } from '@/components/PhoneNumberField';
|
import { isIranianMobile } from '@/components/PhoneNumberField';
|
||||||
import { ROUTES } from '@/constants';
|
import { ROUTES } from '@/constants';
|
||||||
import { digitsOnly } from '@/utils';
|
import { digitsOnly } from '@/utils';
|
||||||
@@ -13,9 +13,14 @@ import type { CustomerProfile } from '@/services/profiles/types';
|
|||||||
|
|
||||||
/** Customer profile — name, preferred language, and the emergency contact. No national-ID KYC. */
|
/** Customer profile — name, preferred language, and the emergency contact. No national-ID KYC. */
|
||||||
export default function CustomerProfilePage() {
|
export default function CustomerProfilePage() {
|
||||||
const { data: profile, isLoading } = useCustomerProfile();
|
const t = useTranslations('profile');
|
||||||
|
const tc = useTranslations('common');
|
||||||
|
const { data: profile, isLoading, isError, refetch } = useCustomerProfile();
|
||||||
const { data: me } = useMe();
|
const { data: me } = useMe();
|
||||||
if (isLoading) return <AppLoading />;
|
if (isLoading) return <AppLoading />;
|
||||||
|
// The form must never render on a failed fetch — it would otherwise show blank/undefined fields
|
||||||
|
// whose save could overwrite server truth.
|
||||||
|
if (isError) return <ErrorState message={t('load_error')} retryLabel={tc('retry')} onRetry={() => refetch()} />;
|
||||||
// The customer name is owned by `/me` (REQ-007), not `CustomerProfileDto` — prefill it from there so
|
// The customer name is owned by `/me` (REQ-007), not `CustomerProfileDto` — prefill it from there so
|
||||||
// editing the emergency contact never blanks (and re-saves as null) the existing name.
|
// editing the emergency contact never blanks (and re-saves as null) the existing name.
|
||||||
return (
|
return (
|
||||||
@@ -131,7 +136,7 @@ const CustomerProfileForm: FunctionComponent<{
|
|||||||
variant="contained"
|
variant="contained"
|
||||||
onClick={handleSave}
|
onClick={handleSave}
|
||||||
disabled={upsert.isPending}
|
disabled={upsert.isPending}
|
||||||
sx={{ m: 0, alignSelf: 'flex-start' }}
|
sx={{ alignSelf: 'flex-start' }}
|
||||||
>
|
>
|
||||||
{upsert.isPending ? tc('saving') : t('save')}
|
{upsert.isPending ? tc('saving') : t('save')}
|
||||||
</AppButton>
|
</AppButton>
|
||||||
@@ -156,7 +161,7 @@ const CustomerProfileForm: FunctionComponent<{
|
|||||||
variant="outlined"
|
variant="outlined"
|
||||||
startIcon="location"
|
startIcon="location"
|
||||||
to={`/${locale}${ROUTES.ADDRESSES}`}
|
to={`/${locale}${ROUTES.ADDRESSES}`}
|
||||||
sx={{ m: 0, alignSelf: 'flex-start' }}
|
sx={{ alignSelf: 'flex-start' }}
|
||||||
>
|
>
|
||||||
{ta('manage_cta')}
|
{ta('manage_cta')}
|
||||||
</AppButton>
|
</AppButton>
|
||||||
|
|||||||
@@ -0,0 +1,215 @@
|
|||||||
|
'use client';
|
||||||
|
import { Suspense, type FunctionComponent, type ReactNode } from 'react';
|
||||||
|
import { useRouter, useSearchParams } from 'next/navigation';
|
||||||
|
import { useLocale, useTranslations } from 'next-intl';
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
InputAdornment,
|
||||||
|
Skeleton,
|
||||||
|
Stack,
|
||||||
|
TextField,
|
||||||
|
ToggleButton,
|
||||||
|
ToggleButtonGroup,
|
||||||
|
Typography,
|
||||||
|
} from '@mui/material';
|
||||||
|
import { AppButton, AppLoading, CategoryTile, ErrorState } from '@/components';
|
||||||
|
import CascadingRegionSelect from '@/components/geography/CascadingRegionSelect';
|
||||||
|
import { ROUTES } from '@/constants';
|
||||||
|
import { useServiceCategories } from '@/services/catalog';
|
||||||
|
import { pickCatalogName } from '@/services/catalog/names';
|
||||||
|
import { useNurseSearch } from '@/services/search';
|
||||||
|
import { filtersToSearchParams } from '@/services/search/filterParams';
|
||||||
|
import type { NurseGender } from '@/services/search/types';
|
||||||
|
import { useSearchFilters } from './useSearchFilters';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* C1 — Search & filter (جستجو و فیلتر): the discovery entry screen. Pick a care category (reusing the
|
||||||
|
* f4 catalog grid), a city (reusing the f3 cascading region picker; district optional = whole city),
|
||||||
|
* the **prominent same-gender facet**, and an optional Toman price range; a live result count drives the
|
||||||
|
* "مشاهده N پرستار" CTA into C2. Availability (date) is intent-only at MVP — it is carried to booking,
|
||||||
|
* never used to hard-filter results. `useSearchParams` needs a Suspense boundary under static rendering.
|
||||||
|
*/
|
||||||
|
export default function SearchScreen() {
|
||||||
|
return (
|
||||||
|
<Suspense fallback={<AppLoading />}>
|
||||||
|
<SearchFilterScreen />
|
||||||
|
</Suspense>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const GENDER_OPTIONS: readonly (NurseGender | 'any')[] = ['female', 'male', 'any'];
|
||||||
|
|
||||||
|
function SearchFilterScreen() {
|
||||||
|
const t = useTranslations('search');
|
||||||
|
const router = useRouter();
|
||||||
|
const locale = useLocale();
|
||||||
|
const params = useSearchParams();
|
||||||
|
|
||||||
|
const initialCategoryRaw = Number(params.get('category_id'));
|
||||||
|
const initialCategoryId = Number.isInteger(initialCategoryRaw) && initialCategoryRaw > 0 ? initialCategoryRaw : undefined;
|
||||||
|
|
||||||
|
const controller = useSearchFilters(initialCategoryId);
|
||||||
|
const { data, isFetching } = useNurseSearch(controller.filters);
|
||||||
|
const count = data?.total;
|
||||||
|
|
||||||
|
const goToResults = () => {
|
||||||
|
const query = filtersToSearchParams(controller.filters);
|
||||||
|
if (controller.dateIntent) query.set('date', controller.dateIntent);
|
||||||
|
router.push(`/${locale}${ROUTES.SEARCH_RESULTS}?${query.toString()}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const ctaLabel = !controller.isReady
|
||||||
|
? t('cta_choose_category_city')
|
||||||
|
: isFetching || count == null
|
||||||
|
? t('cta_loading')
|
||||||
|
: t('cta_view_results', { count });
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack sx={{ gap: 3 }}>
|
||||||
|
<Box>
|
||||||
|
<Typography variant="h5" component="h1">
|
||||||
|
{t('title')}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||||
|
{t('subtitle')}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<CategorySelect selectedId={controller.categoryId} onSelect={controller.setCategoryId} />
|
||||||
|
|
||||||
|
<FilterSection title={t('section_location')}>
|
||||||
|
<CascadingRegionSelect value={controller.region} onChange={controller.setRegion} includeDistrict />
|
||||||
|
</FilterSection>
|
||||||
|
|
||||||
|
<FilterSection title={t('section_gender')} hint={t('gender_hint')}>
|
||||||
|
<ToggleButtonGroup
|
||||||
|
exclusive
|
||||||
|
fullWidth
|
||||||
|
color="primary"
|
||||||
|
value={controller.gender ?? 'any'}
|
||||||
|
onChange={(_event, value: NurseGender | 'any' | null) => {
|
||||||
|
if (value != null) controller.setGender(value === 'any' ? undefined : value);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{GENDER_OPTIONS.map((option) => (
|
||||||
|
<ToggleButton key={option} value={option} sx={{ fontWeight: 700 }}>
|
||||||
|
{t(`gender_${option}`)}
|
||||||
|
</ToggleButton>
|
||||||
|
))}
|
||||||
|
</ToggleButtonGroup>
|
||||||
|
</FilterSection>
|
||||||
|
|
||||||
|
<FilterSection title={t('section_date')} hint={t('date_hint')}>
|
||||||
|
<TextField
|
||||||
|
type="date"
|
||||||
|
fullWidth
|
||||||
|
value={controller.dateIntent}
|
||||||
|
onChange={(event) => controller.setDateIntent(event.target.value)}
|
||||||
|
slotProps={{ inputLabel: { shrink: true } }}
|
||||||
|
/>
|
||||||
|
</FilterSection>
|
||||||
|
|
||||||
|
<FilterSection title={t('section_price')} hint={t('price_hint')}>
|
||||||
|
<Stack direction="row" sx={{ gap: 2 }}>
|
||||||
|
<PriceField
|
||||||
|
label={t('price_min')}
|
||||||
|
value={controller.priceMinToman}
|
||||||
|
onChange={controller.setPriceMinToman}
|
||||||
|
adornment={t('toman')}
|
||||||
|
/>
|
||||||
|
<PriceField
|
||||||
|
label={t('price_max')}
|
||||||
|
value={controller.priceMaxToman}
|
||||||
|
onChange={controller.setPriceMaxToman}
|
||||||
|
adornment={t('toman')}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
</FilterSection>
|
||||||
|
|
||||||
|
<AppButton
|
||||||
|
color="primary"
|
||||||
|
variant="contained"
|
||||||
|
size="large"
|
||||||
|
disabled={!controller.isReady}
|
||||||
|
onClick={goToResults}
|
||||||
|
startIcon="search"
|
||||||
|
sx={{ py: 1.5 }}
|
||||||
|
>
|
||||||
|
{ctaLabel}
|
||||||
|
</AppButton>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const FilterSection: FunctionComponent<{ title: string; hint?: string; children: ReactNode }> = ({
|
||||||
|
title,
|
||||||
|
hint,
|
||||||
|
children,
|
||||||
|
}) => (
|
||||||
|
<Stack sx={{ gap: 1 }}>
|
||||||
|
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
|
||||||
|
{title}
|
||||||
|
</Typography>
|
||||||
|
{hint ? (
|
||||||
|
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||||
|
{hint}
|
||||||
|
</Typography>
|
||||||
|
) : null}
|
||||||
|
{children}
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
|
||||||
|
const PriceField: FunctionComponent<{
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
onChange: (value: string) => void;
|
||||||
|
adornment: string;
|
||||||
|
}> = ({ label, value, onChange, adornment }) => (
|
||||||
|
<TextField
|
||||||
|
label={label}
|
||||||
|
value={value}
|
||||||
|
onChange={(event) => onChange(event.target.value)}
|
||||||
|
inputMode="numeric"
|
||||||
|
fullWidth
|
||||||
|
slotProps={{
|
||||||
|
input: { endAdornment: <InputAdornment position="end">{adornment}</InputAdornment> },
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
/** The reused f4 category grid (data-driven from the cached catalog reference data), with selection. */
|
||||||
|
const CategorySelect: FunctionComponent<{ selectedId: number | null; onSelect: (id: number) => void }> = ({
|
||||||
|
selectedId,
|
||||||
|
onSelect,
|
||||||
|
}) => {
|
||||||
|
const t = useTranslations('search');
|
||||||
|
const locale = useLocale();
|
||||||
|
const { data, isLoading, isError, refetch } = useServiceCategories();
|
||||||
|
const categories = data?.items ?? [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FilterSection title={t('section_category')}>
|
||||||
|
{isLoading ? (
|
||||||
|
<Box sx={{ display: 'grid', gridTemplateColumns: { xs: 'repeat(2, 1fr)', sm: 'repeat(3, 1fr)' }, gap: 1.5 }}>
|
||||||
|
{[0, 1, 2, 3].map((key) => (
|
||||||
|
<Skeleton key={key} variant="rounded" height={116} sx={{ borderRadius: 2 }} />
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
) : isError ? (
|
||||||
|
<ErrorState message={t('categories_error')} retryLabel={t('retry')} onRetry={() => refetch()} />
|
||||||
|
) : (
|
||||||
|
<Box sx={{ display: 'grid', gridTemplateColumns: { xs: 'repeat(2, 1fr)', sm: 'repeat(3, 1fr)' }, gap: 1.5 }}>
|
||||||
|
{categories.map((category) => (
|
||||||
|
<CategoryTile
|
||||||
|
key={category.id}
|
||||||
|
label={pickCatalogName(category, locale)}
|
||||||
|
iconKey={category.iconKey}
|
||||||
|
selected={category.id === selectedId}
|
||||||
|
onClick={() => onSelect(category.id)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</FilterSection>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -3,10 +3,10 @@ import { useState } from 'react';
|
|||||||
import { useLocale, useTranslations } from 'next-intl';
|
import { useLocale, useTranslations } from 'next-intl';
|
||||||
import { useParams, useRouter, useSearchParams } from 'next/navigation';
|
import { useParams, useRouter, useSearchParams } from 'next/navigation';
|
||||||
import { Avatar, Box, Chip, Paper, Skeleton, Stack, Tab, Tabs, Typography } from '@mui/material';
|
import { Avatar, Box, Chip, Paper, Skeleton, Stack, Tab, Tabs, Typography } from '@mui/material';
|
||||||
import { AppButton, AppIcon, RatingInput, ServicePriceRow, TrustBadge } from '@/components';
|
import { AppButton, AppIcon, EmptyState, ErrorState, RatingInput, ServicePriceRow, TrustBadge } from '@/components';
|
||||||
import { ROUTES } from '@/constants';
|
import { ROUTES } from '@/constants';
|
||||||
import { ApiError } from '@/lib/api/errors';
|
import { ApiError } from '@/lib/api/errors';
|
||||||
import { formatShamsiDate } from '@/utils';
|
import { formatNumber, formatShamsiDate } from '@/utils';
|
||||||
import { useNurseProfile } from '@/services/search';
|
import { useNurseProfile } from '@/services/search';
|
||||||
import type { NurseProfile } from '@/services/search/types';
|
import type { NurseProfile } from '@/services/search/types';
|
||||||
import { useNurseReviews } from '@/services/reviews';
|
import { useNurseReviews } from '@/services/reviews';
|
||||||
@@ -37,23 +37,18 @@ export default function NurseProfilePage() {
|
|||||||
|
|
||||||
if (isError) {
|
if (isError) {
|
||||||
const notFound = error instanceof ApiError && error.status === 404;
|
const notFound = error instanceof ApiError && error.status === 404;
|
||||||
return (
|
return notFound ? (
|
||||||
<Paper elevation={0} sx={{ p: 4, textAlign: 'center', border: '1px dashed', borderColor: 'divider', borderRadius: 2 }}>
|
<EmptyState
|
||||||
<Typography variant="subtitle1" sx={{ fontWeight: 700, mb: 1 }}>
|
title={t('profile_not_found_title')}
|
||||||
{notFound ? t('profile_not_found_title') : t('profile_error_title')}
|
body={t('profile_not_found_body')}
|
||||||
</Typography>
|
action={
|
||||||
<Typography variant="body2" sx={{ color: 'text.secondary', mb: 2 }}>
|
<AppButton variant="outlined" color="primary" onClick={() => router.push(`/${locale}${ROUTES.SEARCH}`)}>
|
||||||
{notFound ? t('profile_not_found_body') : t('profile_error_body')}
|
{t('profile_not_found_cta')}
|
||||||
</Typography>
|
</AppButton>
|
||||||
<AppButton
|
}
|
||||||
variant="outlined"
|
/>
|
||||||
color="primary"
|
) : (
|
||||||
onClick={() => (notFound ? router.push(`/${locale}${ROUTES.SEARCH}`) : refetch())}
|
<ErrorState message={t('profile_error_body')} retryLabel={t('retry')} onRetry={() => refetch()} />
|
||||||
sx={{ m: 0 }}
|
|
||||||
>
|
|
||||||
{notFound ? t('profile_not_found_cta') : t('retry')}
|
|
||||||
</AppButton>
|
|
||||||
</Paper>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -95,7 +90,7 @@ export default function NurseProfilePage() {
|
|||||||
size="large"
|
size="large"
|
||||||
onClick={requestBooking}
|
onClick={requestBooking}
|
||||||
startIcon="bookings"
|
startIcon="bookings"
|
||||||
sx={{ m: 0, py: 1.5 }}
|
sx={{ py: 1.5 }}
|
||||||
>
|
>
|
||||||
{t('request_booking')}
|
{t('request_booking')}
|
||||||
</AppButton>
|
</AppButton>
|
||||||
@@ -107,10 +102,10 @@ function ProfileHeader({ profile }: { profile: NurseProfile }) {
|
|||||||
const t = useTranslations('search');
|
const t = useTranslations('search');
|
||||||
const locale = useLocale();
|
const locale = useLocale();
|
||||||
const name = profile.nurseName.trim() || t('unnamed_nurse');
|
const name = profile.nurseName.trim() || t('unnamed_nurse');
|
||||||
const rating = new Intl.NumberFormat(locale === 'fa' ? 'fa-IR' : 'en-US', {
|
const rating = formatNumber(profile.averageRating, locale, {
|
||||||
minimumFractionDigits: 1,
|
minimumFractionDigits: 1,
|
||||||
maximumFractionDigits: 1,
|
maximumFractionDigits: 1,
|
||||||
}).format(profile.averageRating);
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack sx={{ gap: 1.5 }}>
|
<Stack sx={{ gap: 1.5 }}>
|
||||||
@@ -162,7 +157,7 @@ function AttributeChips({ profile }: { profile: NurseProfile }) {
|
|||||||
const locale = useLocale();
|
const locale = useLocale();
|
||||||
const chips: string[] = [];
|
const chips: string[] = [];
|
||||||
if (profile.yearsExperience != null && profile.yearsExperience > 0) {
|
if (profile.yearsExperience != null && profile.yearsExperience > 0) {
|
||||||
const years = new Intl.NumberFormat(locale === 'fa' ? 'fa-IR' : 'en-US').format(profile.yearsExperience);
|
const years = formatNumber(profile.yearsExperience, locale);
|
||||||
chips.push(t('years_experience', { years }));
|
chips.push(t('years_experience', { years }));
|
||||||
}
|
}
|
||||||
for (const code of profile.attributeChips) {
|
for (const code of profile.attributeChips) {
|
||||||
@@ -225,16 +220,7 @@ function ReviewsPanel({ nurseId }: { nurseId: number }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (isError) {
|
if (isError) {
|
||||||
return (
|
return <ErrorState message={t('load_error')} retryLabel={t('retry')} onRetry={() => refetch()} />;
|
||||||
<Paper elevation={0} sx={{ p: 3, textAlign: 'center', border: '1px dashed', borderColor: 'divider', borderRadius: 2 }}>
|
|
||||||
<Typography variant="body2" sx={{ color: 'text.secondary', mb: 1.5 }}>
|
|
||||||
{t('load_error')}
|
|
||||||
</Typography>
|
|
||||||
<AppButton variant="outlined" color="primary" onClick={() => refetch()} sx={{ m: 0 }}>
|
|
||||||
{t('retry')}
|
|
||||||
</AppButton>
|
|
||||||
</Paper>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const aggregate = data?.pages[0]?.aggregate;
|
const aggregate = data?.pages[0]?.aggregate;
|
||||||
@@ -249,15 +235,15 @@ function ReviewsPanel({ nurseId }: { nurseId: number }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const average = new Intl.NumberFormat(locale === 'fa' ? 'fa-IR' : 'en-US', {
|
const average = formatNumber(aggregate?.averageRating ?? 0, locale, {
|
||||||
minimumFractionDigits: 1,
|
minimumFractionDigits: 1,
|
||||||
maximumFractionDigits: 1,
|
maximumFractionDigits: 1,
|
||||||
}).format(aggregate?.averageRating ?? 0);
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack sx={{ gap: 2 }}>
|
<Stack sx={{ gap: 2 }}>
|
||||||
<Stack direction="row" sx={{ alignItems: 'center', gap: 1, flexWrap: 'wrap' }}>
|
<Stack direction="row" sx={{ alignItems: 'center', gap: 1, flexWrap: 'wrap' }}>
|
||||||
<RatingInput value={Math.round(aggregate?.averageRating ?? 0)} readOnly size={20} ariaLabel={t('rating_label')} />
|
<RatingInput value={aggregate?.averageRating ?? 0} readOnly size={20} ariaLabel={t('rating_label')} />
|
||||||
<Typography variant="h6" component="p" sx={{ fontWeight: 700 }}>
|
<Typography variant="h6" component="p" sx={{ fontWeight: 700 }}>
|
||||||
{average}
|
{average}
|
||||||
</Typography>
|
</Typography>
|
||||||
@@ -278,7 +264,7 @@ function ReviewsPanel({ nurseId }: { nurseId: number }) {
|
|||||||
color="primary"
|
color="primary"
|
||||||
onClick={() => fetchNextPage()}
|
onClick={() => fetchNextPage()}
|
||||||
disabled={isFetchingNextPage}
|
disabled={isFetchingNextPage}
|
||||||
sx={{ m: 0, alignSelf: 'center' }}
|
sx={{ alignSelf: 'center' }}
|
||||||
>
|
>
|
||||||
{isFetchingNextPage ? t('loading') : t('load_more')}
|
{isFetchingNextPage ? t('loading') : t('load_more')}
|
||||||
</AppButton>
|
</AppButton>
|
||||||
|
|||||||
@@ -1,220 +1,13 @@
|
|||||||
'use client';
|
import type { Metadata } from 'next';
|
||||||
import { Suspense, type FunctionComponent, type ReactNode } from 'react';
|
import { getTranslations } from 'next-intl/server';
|
||||||
import { useRouter, useSearchParams } from 'next/navigation';
|
import SearchScreen from './SearchScreen';
|
||||||
import { useLocale, useTranslations } from 'next-intl';
|
|
||||||
import {
|
|
||||||
Box,
|
|
||||||
InputAdornment,
|
|
||||||
Paper,
|
|
||||||
Skeleton,
|
|
||||||
Stack,
|
|
||||||
TextField,
|
|
||||||
ToggleButton,
|
|
||||||
ToggleButtonGroup,
|
|
||||||
Typography,
|
|
||||||
} from '@mui/material';
|
|
||||||
import { AppButton, AppIcon, AppLoading, CategoryTile } from '@/components';
|
|
||||||
import CascadingRegionSelect from '@/components/geography/CascadingRegionSelect';
|
|
||||||
import { ROUTES } from '@/constants';
|
|
||||||
import { useServiceCategories } from '@/services/catalog';
|
|
||||||
import { pickCatalogName } from '@/services/catalog/names';
|
|
||||||
import { useNurseSearch } from '@/services/search';
|
|
||||||
import { filtersToSearchParams } from '@/services/search/filterParams';
|
|
||||||
import type { NurseGender } from '@/services/search/types';
|
|
||||||
import { useSearchFilters } from './useSearchFilters';
|
|
||||||
|
|
||||||
/**
|
export async function generateMetadata({ params }: { params: Promise<{ locale: string }> }): Promise<Metadata> {
|
||||||
* C1 — Search & filter (جستجو و فیلتر): the discovery entry screen. Pick a care category (reusing the
|
const { locale } = await params;
|
||||||
* f4 catalog grid), a city (reusing the f3 cascading region picker; district optional = whole city),
|
const t = await getTranslations({ locale, namespace: 'search' });
|
||||||
* the **prominent same-gender facet**, and an optional Toman price range; a live result count drives the
|
return { title: t('title') };
|
||||||
* "مشاهده N پرستار" CTA into C2. Availability (date) is intent-only at MVP — it is carried to booking,
|
|
||||||
* never used to hard-filter results. `useSearchParams` needs a Suspense boundary under static rendering.
|
|
||||||
*/
|
|
||||||
export default function SearchPage() {
|
|
||||||
return (
|
|
||||||
<Suspense fallback={<AppLoading />}>
|
|
||||||
<SearchFilterScreen />
|
|
||||||
</Suspense>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const GENDER_OPTIONS: readonly (NurseGender | 'any')[] = ['female', 'male', 'any'];
|
export default function Page() {
|
||||||
|
return <SearchScreen />;
|
||||||
function SearchFilterScreen() {
|
|
||||||
const t = useTranslations('search');
|
|
||||||
const router = useRouter();
|
|
||||||
const locale = useLocale();
|
|
||||||
const params = useSearchParams();
|
|
||||||
|
|
||||||
const initialCategoryRaw = Number(params.get('category_id'));
|
|
||||||
const initialCategoryId = Number.isInteger(initialCategoryRaw) && initialCategoryRaw > 0 ? initialCategoryRaw : undefined;
|
|
||||||
|
|
||||||
const controller = useSearchFilters(initialCategoryId);
|
|
||||||
const { data, isFetching } = useNurseSearch(controller.filters);
|
|
||||||
const count = data?.total;
|
|
||||||
|
|
||||||
const goToResults = () => {
|
|
||||||
const query = filtersToSearchParams(controller.filters);
|
|
||||||
if (controller.dateIntent) query.set('date', controller.dateIntent);
|
|
||||||
router.push(`/${locale}${ROUTES.SEARCH_RESULTS}?${query.toString()}`);
|
|
||||||
};
|
|
||||||
|
|
||||||
const ctaLabel = !controller.isReady
|
|
||||||
? t('cta_choose_category_city')
|
|
||||||
: isFetching || count == null
|
|
||||||
? t('cta_loading')
|
|
||||||
: t('cta_view_results', { count });
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Stack sx={{ gap: 3 }}>
|
|
||||||
<Box>
|
|
||||||
<Typography variant="h5" component="h1">
|
|
||||||
{t('title')}
|
|
||||||
</Typography>
|
|
||||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
|
||||||
{t('subtitle')}
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
<CategorySelect selectedId={controller.categoryId} onSelect={controller.setCategoryId} />
|
|
||||||
|
|
||||||
<FilterSection title={t('section_location')}>
|
|
||||||
<CascadingRegionSelect value={controller.region} onChange={controller.setRegion} includeDistrict />
|
|
||||||
</FilterSection>
|
|
||||||
|
|
||||||
<FilterSection title={t('section_gender')} hint={t('gender_hint')}>
|
|
||||||
<ToggleButtonGroup
|
|
||||||
exclusive
|
|
||||||
fullWidth
|
|
||||||
color="primary"
|
|
||||||
value={controller.gender ?? 'any'}
|
|
||||||
onChange={(_event, value: NurseGender | 'any' | null) => {
|
|
||||||
if (value != null) controller.setGender(value === 'any' ? undefined : value);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{GENDER_OPTIONS.map((option) => (
|
|
||||||
<ToggleButton key={option} value={option} sx={{ fontWeight: 700 }}>
|
|
||||||
{t(`gender_${option}`)}
|
|
||||||
</ToggleButton>
|
|
||||||
))}
|
|
||||||
</ToggleButtonGroup>
|
|
||||||
</FilterSection>
|
|
||||||
|
|
||||||
<FilterSection title={t('section_date')} hint={t('date_hint')}>
|
|
||||||
<TextField
|
|
||||||
type="date"
|
|
||||||
fullWidth
|
|
||||||
value={controller.dateIntent}
|
|
||||||
onChange={(event) => controller.setDateIntent(event.target.value)}
|
|
||||||
slotProps={{ inputLabel: { shrink: true } }}
|
|
||||||
/>
|
|
||||||
</FilterSection>
|
|
||||||
|
|
||||||
<FilterSection title={t('section_price')} hint={t('price_hint')}>
|
|
||||||
<Stack direction="row" sx={{ gap: 2 }}>
|
|
||||||
<PriceField
|
|
||||||
label={t('price_min')}
|
|
||||||
value={controller.priceMinToman}
|
|
||||||
onChange={controller.setPriceMinToman}
|
|
||||||
adornment={t('toman')}
|
|
||||||
/>
|
|
||||||
<PriceField
|
|
||||||
label={t('price_max')}
|
|
||||||
value={controller.priceMaxToman}
|
|
||||||
onChange={controller.setPriceMaxToman}
|
|
||||||
adornment={t('toman')}
|
|
||||||
/>
|
|
||||||
</Stack>
|
|
||||||
</FilterSection>
|
|
||||||
|
|
||||||
<AppButton
|
|
||||||
color="primary"
|
|
||||||
variant="contained"
|
|
||||||
size="large"
|
|
||||||
disabled={!controller.isReady}
|
|
||||||
onClick={goToResults}
|
|
||||||
startIcon="search"
|
|
||||||
sx={{ m: 0, py: 1.5 }}
|
|
||||||
>
|
|
||||||
{ctaLabel}
|
|
||||||
</AppButton>
|
|
||||||
</Stack>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const FilterSection: FunctionComponent<{ title: string; hint?: string; children: ReactNode }> = ({
|
|
||||||
title,
|
|
||||||
hint,
|
|
||||||
children,
|
|
||||||
}) => (
|
|
||||||
<Stack sx={{ gap: 1 }}>
|
|
||||||
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
|
|
||||||
{title}
|
|
||||||
</Typography>
|
|
||||||
{hint ? (
|
|
||||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
|
||||||
{hint}
|
|
||||||
</Typography>
|
|
||||||
) : null}
|
|
||||||
{children}
|
|
||||||
</Stack>
|
|
||||||
);
|
|
||||||
|
|
||||||
const PriceField: FunctionComponent<{
|
|
||||||
label: string;
|
|
||||||
value: string;
|
|
||||||
onChange: (value: string) => void;
|
|
||||||
adornment: string;
|
|
||||||
}> = ({ label, value, onChange, adornment }) => (
|
|
||||||
<TextField
|
|
||||||
label={label}
|
|
||||||
value={value}
|
|
||||||
onChange={(event) => onChange(event.target.value)}
|
|
||||||
inputMode="numeric"
|
|
||||||
fullWidth
|
|
||||||
slotProps={{
|
|
||||||
input: { endAdornment: <InputAdornment position="end">{adornment}</InputAdornment> },
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
/** The reused f4 category grid (data-driven from the cached catalog reference data), with selection. */
|
|
||||||
const CategorySelect: FunctionComponent<{ selectedId: number | null; onSelect: (id: number) => void }> = ({
|
|
||||||
selectedId,
|
|
||||||
onSelect,
|
|
||||||
}) => {
|
|
||||||
const t = useTranslations('search');
|
|
||||||
const locale = useLocale();
|
|
||||||
const { data, isLoading, isError } = useServiceCategories();
|
|
||||||
const categories = data?.items ?? [];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<FilterSection title={t('section_category')}>
|
|
||||||
{isLoading ? (
|
|
||||||
<Box sx={{ display: 'grid', gridTemplateColumns: { xs: 'repeat(2, 1fr)', sm: 'repeat(3, 1fr)' }, gap: 1.5 }}>
|
|
||||||
{[0, 1, 2, 3].map((key) => (
|
|
||||||
<Skeleton key={key} variant="rounded" height={116} sx={{ borderRadius: 2 }} />
|
|
||||||
))}
|
|
||||||
</Box>
|
|
||||||
) : isError ? (
|
|
||||||
<Paper elevation={0} sx={{ p: 3, textAlign: 'center', border: '1px dashed', borderColor: 'divider', borderRadius: 2 }}>
|
|
||||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
|
||||||
{t('categories_error')}
|
|
||||||
</Typography>
|
|
||||||
</Paper>
|
|
||||||
) : (
|
|
||||||
<Box sx={{ display: 'grid', gridTemplateColumns: { xs: 'repeat(2, 1fr)', sm: 'repeat(3, 1fr)' }, gap: 1.5 }}>
|
|
||||||
{categories.map((category) => (
|
|
||||||
<CategoryTile
|
|
||||||
key={category.id}
|
|
||||||
label={pickCatalogName(category, locale)}
|
|
||||||
iconKey={category.iconKey}
|
|
||||||
selected={category.id === selectedId}
|
|
||||||
onClick={() => onSelect(category.id)}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
</FilterSection>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|||||||
@@ -2,8 +2,8 @@
|
|||||||
import { Suspense, useCallback, useMemo, useState } from 'react';
|
import { Suspense, useCallback, useMemo, useState } from 'react';
|
||||||
import { useRouter, useSearchParams } from 'next/navigation';
|
import { useRouter, useSearchParams } from 'next/navigation';
|
||||||
import { useLocale, useTranslations } from 'next-intl';
|
import { useLocale, useTranslations } from 'next-intl';
|
||||||
import { Box, MenuItem, Paper, Skeleton, Stack, TextField, Typography } from '@mui/material';
|
import { MenuItem, Stack, TextField, Typography } from '@mui/material';
|
||||||
import { AppButton, AppIcon, AppLoading, NurseResultCard } from '@/components';
|
import { AppButton, AppLoading, EmptyState, ErrorState, NurseResultCard } from '@/components';
|
||||||
import { ROUTES } from '@/constants';
|
import { ROUTES } from '@/constants';
|
||||||
import { useNurseSearch } from '@/services/search';
|
import { useNurseSearch } from '@/services/search';
|
||||||
import { searchParamsToFilters } from '@/services/search/filterParams';
|
import { searchParamsToFilters } from '@/services/search/filterParams';
|
||||||
@@ -27,6 +27,7 @@ export default function SearchResultsPage() {
|
|||||||
|
|
||||||
function ResultsScreen() {
|
function ResultsScreen() {
|
||||||
const t = useTranslations('search');
|
const t = useTranslations('search');
|
||||||
|
const tc = useTranslations('common');
|
||||||
const locale = useLocale();
|
const locale = useLocale();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const params = useSearchParams();
|
const params = useSearchParams();
|
||||||
@@ -72,20 +73,13 @@ function ResultsScreen() {
|
|||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<Stack sx={{ gap: 1.5 }}>
|
<Stack sx={{ gap: 1.5 }}>
|
||||||
{[0, 1, 2, 3].map((key) => (
|
{[0, 1, 2, 3].map((key) => (
|
||||||
<Skeleton key={key} variant="rounded" height={112} sx={{ borderRadius: 2 }} />
|
<NurseResultCard.Skeleton key={key} />
|
||||||
))}
|
))}
|
||||||
</Stack>
|
</Stack>
|
||||||
) : isError ? (
|
) : isError ? (
|
||||||
<Paper elevation={0} sx={{ p: 4, textAlign: 'center', border: '1px dashed', borderColor: 'divider', borderRadius: 2 }}>
|
<ErrorState message={t('results_error')} retryLabel={tc('retry')} onRetry={() => refetch()} />
|
||||||
<Typography variant="body2" sx={{ color: 'text.secondary', mb: 2 }}>
|
|
||||||
{t('results_error')}
|
|
||||||
</Typography>
|
|
||||||
<AppButton variant="outlined" color="primary" onClick={() => refetch()} sx={{ m: 0 }}>
|
|
||||||
{t('retry')}
|
|
||||||
</AppButton>
|
|
||||||
</Paper>
|
|
||||||
) : items.length === 0 ? (
|
) : items.length === 0 ? (
|
||||||
<EmptyState onRelax={backToFilters} />
|
<RelaxFiltersEmptyState onRelax={backToFilters} />
|
||||||
) : (
|
) : (
|
||||||
<Stack sx={{ gap: 1.5 }}>
|
<Stack sx={{ gap: 1.5 }}>
|
||||||
{items.map((nurse) => (
|
{items.map((nurse) => (
|
||||||
@@ -97,7 +91,7 @@ function ResultsScreen() {
|
|||||||
color="primary"
|
color="primary"
|
||||||
onClick={() => setPageSize((size) => size + SEARCH_PAGE_SIZE)}
|
onClick={() => setPageSize((size) => size + SEARCH_PAGE_SIZE)}
|
||||||
disabled={isFetching}
|
disabled={isFetching}
|
||||||
sx={{ m: 0, alignSelf: 'center' }}
|
sx={{ alignSelf: 'center' }}
|
||||||
>
|
>
|
||||||
{t('load_more')}
|
{t('load_more')}
|
||||||
</AppButton>
|
</AppButton>
|
||||||
@@ -109,28 +103,30 @@ function ResultsScreen() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** The "no nurses match → relax your filters" state with concrete, product-aligned suggestions. */
|
/** The "no nurses match → relax your filters" state with concrete, product-aligned suggestions. */
|
||||||
function EmptyState({ onRelax }: { onRelax: () => void }) {
|
function RelaxFiltersEmptyState({ onRelax }: { onRelax: () => void }) {
|
||||||
const t = useTranslations('search');
|
const t = useTranslations('search');
|
||||||
return (
|
return (
|
||||||
<Paper elevation={0} sx={{ p: 4, textAlign: 'center', border: '1px dashed', borderColor: 'divider', borderRadius: 2 }}>
|
<EmptyState
|
||||||
<AppIcon icon="search" size={40} color="var(--bal-text-secondary)" />
|
icon="search"
|
||||||
<Typography variant="subtitle1" sx={{ fontWeight: 700, mt: 1 }}>
|
title={t('empty_title')}
|
||||||
{t('empty_title')}
|
body={
|
||||||
</Typography>
|
<Stack sx={{ gap: 0.5 }}>
|
||||||
<Stack sx={{ gap: 0.5, mt: 1, mb: 2 }}>
|
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
{t('empty_suggest_gender')}
|
||||||
{t('empty_suggest_gender')}
|
</Typography>
|
||||||
</Typography>
|
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
{t('empty_suggest_district')}
|
||||||
{t('empty_suggest_district')}
|
</Typography>
|
||||||
</Typography>
|
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
{t('empty_suggest_city')}
|
||||||
{t('empty_suggest_city')}
|
</Typography>
|
||||||
</Typography>
|
</Stack>
|
||||||
</Stack>
|
}
|
||||||
<AppButton variant="contained" color="primary" onClick={onRelax} startIcon="tune" sx={{ m: 0 }}>
|
action={
|
||||||
{t('empty_cta')}
|
<AppButton variant="contained" color="primary" onClick={onRelax} startIcon="tune">
|
||||||
</AppButton>
|
{t('empty_cta')}
|
||||||
</Paper>
|
</AppButton>
|
||||||
|
}
|
||||||
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,8 +2,8 @@
|
|||||||
import { FunctionComponent } from 'react';
|
import { FunctionComponent } from 'react';
|
||||||
import { useLocale, useTranslations } from 'next-intl';
|
import { useLocale, useTranslations } from 'next-intl';
|
||||||
import { Box, Paper, Skeleton, Stack, Typography } from '@mui/material';
|
import { Box, Paper, Skeleton, Stack, Typography } from '@mui/material';
|
||||||
import { AppButton, AppIcon, InstallmentScheduleRow, PlaceholderScreen } from '@/components';
|
import { AppButton, AppIcon, InstallmentScheduleRow, Money, PlaceholderScreen } from '@/components';
|
||||||
import { formatIrrToToman, formatShamsiDate } from '@/utils';
|
import { formatShamsiDate } from '@/utils';
|
||||||
import { useWalletInstallments } from '@/services/bnpl';
|
import { useWalletInstallments } from '@/services/bnpl';
|
||||||
import type { WalletInstallmentPlan } from '@/services/bnpl/types';
|
import type { WalletInstallmentPlan } from '@/services/bnpl/types';
|
||||||
|
|
||||||
@@ -42,7 +42,7 @@ const WalletInstallments: FunctionComponent = () => {
|
|||||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||||
{t('wallet_error_body')}
|
{t('wallet_error_body')}
|
||||||
</Typography>
|
</Typography>
|
||||||
<AppButton variant="outlined" color="secondary" onClick={() => refetch()} sx={{ m: 0, mt: 1 }}>
|
<AppButton variant="outlined" color="secondary" onClick={() => refetch()} sx={{ mt: 1 }}>
|
||||||
{tc('retry')}
|
{tc('retry')}
|
||||||
</AppButton>
|
</AppButton>
|
||||||
</Stack>
|
</Stack>
|
||||||
@@ -62,7 +62,6 @@ const WalletInstallments: FunctionComponent = () => {
|
|||||||
|
|
||||||
function InstallmentPlanSection({ plan }: { plan: WalletInstallmentPlan }) {
|
function InstallmentPlanSection({ plan }: { plan: WalletInstallmentPlan }) {
|
||||||
const t = useTranslations('bnpl');
|
const t = useTranslations('bnpl');
|
||||||
const tc = useTranslations('common');
|
|
||||||
const locale = useLocale();
|
const locale = useLocale();
|
||||||
const providerName = t(`provider_${plan.providerCode}`);
|
const providerName = t(`provider_${plan.providerCode}`);
|
||||||
|
|
||||||
@@ -81,9 +80,7 @@ function InstallmentPlanSection({ plan }: { plan: WalletInstallmentPlan }) {
|
|||||||
<Typography variant="caption" sx={{ opacity: 0.85 }}>
|
<Typography variant="caption" sx={{ opacity: 0.85 }}>
|
||||||
{t('outstanding_balance')}
|
{t('outstanding_balance')}
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography variant="h5" sx={{ fontWeight: 800, mt: 0.25 }}>
|
<Money amountIrr={plan.outstandingBalanceIrr} size="lg" sx={{ fontWeight: 800, mt: 0.25 }} />
|
||||||
{formatIrrToToman(plan.outstandingBalanceIrr, locale)} {tc('currency_toman')}
|
|
||||||
</Typography>
|
|
||||||
<Typography variant="caption" sx={{ opacity: 0.85, display: 'block', mt: 0.5 }}>
|
<Typography variant="caption" sx={{ opacity: 0.85, display: 'block', mt: 0.5 }}>
|
||||||
{plan.serviceLabel}
|
{plan.serviceLabel}
|
||||||
</Typography>
|
</Typography>
|
||||||
@@ -95,9 +92,7 @@ function InstallmentPlanSection({ plan }: { plan: WalletInstallmentPlan }) {
|
|||||||
{t('next_installment')} · {formatShamsiDate(`${plan.nextDueDate}T00:00:00`, locale)}
|
{t('next_installment')} · {formatShamsiDate(`${plan.nextDueDate}T00:00:00`, locale)}
|
||||||
</Typography>
|
</Typography>
|
||||||
{plan.nextAmountIrr ? (
|
{plan.nextAmountIrr ? (
|
||||||
<Typography variant="subtitle2" sx={{ fontWeight: 800 }}>
|
<Money amountIrr={plan.nextAmountIrr} size="sm" sx={{ fontWeight: 800 }} />
|
||||||
{formatIrrToToman(plan.nextAmountIrr, locale)} {tc('currency_toman')}
|
|
||||||
</Typography>
|
|
||||||
) : null}
|
) : null}
|
||||||
</Box>
|
</Box>
|
||||||
{plan.earlyPayUrl ? (
|
{plan.earlyPayUrl ? (
|
||||||
@@ -106,7 +101,7 @@ function InstallmentPlanSection({ plan }: { plan: WalletInstallmentPlan }) {
|
|||||||
variant="contained"
|
variant="contained"
|
||||||
size="small"
|
size="small"
|
||||||
onClick={handleEarlyPay}
|
onClick={handleEarlyPay}
|
||||||
sx={{ m: 0, flex: 'none' }}
|
sx={{ flex: 'none' }}
|
||||||
>
|
>
|
||||||
{t('early_pay')}
|
{t('early_pay')}
|
||||||
</AppButton>
|
</AppButton>
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import Skeleton from '@mui/material/Skeleton';
|
||||||
|
import Stack from '@mui/material/Stack';
|
||||||
|
import SurfaceCard from '@/components/common/SurfaceCard';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The shared loading skeleton for the sidebar-shell route groups (`nurse`, `admin`, `partner`) — the
|
||||||
|
* `TopBarAndSideBarLayout` chrome (top bar + sidebar) is already rendered by the enclosing `layout.tsx`
|
||||||
|
* by the time this shows, so this only needs to shape the content area: a heading line + a short stack of
|
||||||
|
* generic worklist/detail cards. A private (`_`-prefixed) folder — not a route.
|
||||||
|
*/
|
||||||
|
export default function SidebarShellSkeleton() {
|
||||||
|
return (
|
||||||
|
<Stack sx={{ gap: 2 }}>
|
||||||
|
<Skeleton variant="text" width={220} height={32} />
|
||||||
|
{[0, 1, 2].map((key) => (
|
||||||
|
<SurfaceCard key={key}>
|
||||||
|
<Stack sx={{ gap: 0.75 }}>
|
||||||
|
<Skeleton variant="text" width="40%" height={22} />
|
||||||
|
<Skeleton variant="text" width="70%" height={18} />
|
||||||
|
<Skeleton variant="text" width="55%" height={18} />
|
||||||
|
</Stack>
|
||||||
|
</SurfaceCard>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
'use client';
|
||||||
|
import { useLocale, useTranslations } from 'next-intl';
|
||||||
|
import { Box, Paper, Typography } from '@mui/material';
|
||||||
|
import { AppIcon, AppLink } from '@/components';
|
||||||
|
import { AdminPageHeader } from '@/components/admin';
|
||||||
|
import { useAdminCapabilities } from '@/hooks';
|
||||||
|
import { ROUTES } from '@/constants';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Admin overview landing (f15) — the backoffice home. Renders one **console card** per worklist the current
|
||||||
|
* principal may act on, derived from `useAdminCapabilities()` (a UI hint; the server still enforces every
|
||||||
|
* command's role scope). A `support` admin sees verification/tickets/alerts; a `finance` admin sees
|
||||||
|
* payouts/config; only a `super_admin` sees roles. Each card deep-links into its console.
|
||||||
|
*/
|
||||||
|
export default function AdminOverviewScreen() {
|
||||||
|
const t = useTranslations('admin');
|
||||||
|
const tNav = useTranslations('nav');
|
||||||
|
const locale = useLocale();
|
||||||
|
const caps = useAdminCapabilities();
|
||||||
|
|
||||||
|
// `key` doubles as the `nav` i18n key for the card label.
|
||||||
|
const consoles: { key: string; route: string; icon: string; enabled: boolean }[] = [
|
||||||
|
{ key: 'verification', route: ROUTES.ADMIN_VERIFICATION, icon: 'verification', enabled: caps.canVerify },
|
||||||
|
{ key: 'tickets', route: ROUTES.ADMIN_TICKETS, icon: 'support', enabled: caps.canManageTickets },
|
||||||
|
{ key: 'payouts', route: ROUTES.ADMIN_PAYOUTS, icon: 'earnings', enabled: caps.canPayout },
|
||||||
|
{ key: 'reviews', route: ROUTES.ADMIN_REVIEWS, icon: 'moderation', enabled: caps.canModerate },
|
||||||
|
{ key: 'config', route: ROUTES.ADMIN_CONFIG, icon: 'config', enabled: caps.canConfig },
|
||||||
|
{ key: 'holidays', route: ROUTES.ADMIN_HOLIDAYS, icon: 'calendar', enabled: caps.canConfig },
|
||||||
|
{ key: 'alerts', route: ROUTES.ADMIN_ALERTS, icon: 'alerts', enabled: caps.canManageAlerts },
|
||||||
|
{ key: 'audit', route: ROUTES.ADMIN_AUDIT, icon: 'audit', enabled: caps.canViewAudit },
|
||||||
|
{ key: 'partners', route: ROUTES.ADMIN_PARTNERS, icon: 'partners', enabled: caps.canManagePartners },
|
||||||
|
{ key: 'roles', route: ROUTES.ADMIN_ROLES, icon: 'roles', enabled: caps.canManageRoles },
|
||||||
|
].filter((c) => c.enabled);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
|
||||||
|
<AdminPageHeader title={t('overview_title')} subtitle={t('overview_subtitle')} />
|
||||||
|
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: { xs: '1fr', sm: '1fr 1fr', md: '1fr 1fr 1fr' },
|
||||||
|
gap: 2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{consoles.map((c) => (
|
||||||
|
<AppLink
|
||||||
|
key={c.key}
|
||||||
|
to={`/${locale}${c.route}`}
|
||||||
|
color="inherit"
|
||||||
|
underline="none"
|
||||||
|
sx={{ display: 'block', height: '100%' }}
|
||||||
|
>
|
||||||
|
<Paper
|
||||||
|
elevation={0}
|
||||||
|
sx={{
|
||||||
|
p: 3,
|
||||||
|
height: '100%',
|
||||||
|
border: '1px solid',
|
||||||
|
borderColor: 'divider',
|
||||||
|
borderRadius: 2,
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
alignItems: 'flex-start',
|
||||||
|
gap: 1.5,
|
||||||
|
cursor: 'pointer',
|
||||||
|
transition: 'border-color 150ms ease, box-shadow 150ms ease',
|
||||||
|
'&:hover': { borderColor: 'primary.main', boxShadow: 3 },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<AppIcon icon={c.icon} size={32} color="var(--bal-primary)" />
|
||||||
|
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
|
||||||
|
{tNav(c.key)}
|
||||||
|
</Typography>
|
||||||
|
</Paper>
|
||||||
|
</AppLink>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -76,10 +76,10 @@ export default function AdminAuditPage() {
|
|||||||
onChange={(e) => setDraft((d) => ({ ...d, to: e.target.value || undefined }))}
|
onChange={(e) => setDraft((d) => ({ ...d, to: e.target.value || undefined }))}
|
||||||
slotProps={{ inputLabel: { shrink: true } }}
|
slotProps={{ inputLabel: { shrink: true } }}
|
||||||
/>
|
/>
|
||||||
<AppButton variant="contained" color="primary" onClick={apply} sx={{ m: 0 }}>
|
<AppButton variant="contained" color="primary" onClick={apply}>
|
||||||
{t('apply')}
|
{t('apply')}
|
||||||
</AppButton>
|
</AppButton>
|
||||||
<AppButton variant="text" color="inherit" onClick={clear} sx={{ m: 0 }}>
|
<AppButton variant="text" color="inherit" onClick={clear}>
|
||||||
{t('clear')}
|
{t('clear')}
|
||||||
</AppButton>
|
</AppButton>
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|||||||
@@ -159,15 +159,15 @@ function ConfigEditDialog({ config, onClose }: { config: PlatformConfig; onClose
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<DialogContentText sx={{ mt: 2, color: 'var(--bal-warning)', fontWeight: 600 }}>
|
<DialogContentText sx={{ mt: 2, color: 'var(--bal-warning)', fontWeight: 500 }}>
|
||||||
{t('cfg_save_confirm_body')}
|
{t('cfg_save_confirm_body')}
|
||||||
</DialogContentText>
|
</DialogContentText>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
<DialogActions sx={{ px: 3, pb: 2 }}>
|
<DialogActions sx={{ px: 3, pb: 2 }}>
|
||||||
<AppButton variant="text" color="inherit" onClick={onClose} disabled={update.isPending} sx={{ m: 0 }}>
|
<AppButton variant="text" color="inherit" onClick={onClose} disabled={update.isPending}>
|
||||||
{t('cancel')}
|
{t('cancel')}
|
||||||
</AppButton>
|
</AppButton>
|
||||||
<AppButton variant="contained" color="primary" onClick={onSave} disabled={!!errorKey || update.isPending} sx={{ m: 0 }}>
|
<AppButton variant="contained" color="primary" onClick={onSave} disabled={!!errorKey || update.isPending}>
|
||||||
{update.isPending ? t('saving') : t('save')}
|
{update.isPending ? t('saving') : t('save')}
|
||||||
</AppButton>
|
</AppButton>
|
||||||
</DialogActions>
|
</DialogActions>
|
||||||
@@ -187,7 +187,7 @@ function ConfigHistoryDrawer({ configKey, onClose }: { configKey: string | null;
|
|||||||
<Drawer anchor={anchor} open={configKey != null} onClose={onClose} slotProps={{ paper: { sx: { width: { xs: '100%', sm: 420 }, p: 3 } } }}>
|
<Drawer anchor={anchor} open={configKey != null} onClose={onClose} slotProps={{ paper: { sx: { width: { xs: '100%', sm: 420 }, p: 3 } } }}>
|
||||||
<Stack direction="row" sx={{ justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
|
<Stack direction="row" sx={{ justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
|
||||||
<Typography variant="h6">{configKey ? t('cfg_history_title', { key: configKey }) : ''}</Typography>
|
<Typography variant="h6">{configKey ? t('cfg_history_title', { key: configKey }) : ''}</Typography>
|
||||||
<AppButton variant="text" color="inherit" onClick={onClose} sx={{ m: 0, minWidth: 0 }}>
|
<AppButton variant="text" color="inherit" onClick={onClose} sx={{ minWidth: 0 }}>
|
||||||
<AppIcon icon="close" />
|
<AppIcon icon="close" />
|
||||||
</AppButton>
|
</AppButton>
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ export default function AdminHolidaysPage() {
|
|||||||
key: 'actions',
|
key: 'actions',
|
||||||
header: '',
|
header: '',
|
||||||
render: (h: Holiday) => (
|
render: (h: Holiday) => (
|
||||||
<AppButton variant="text" color="primary" startIcon="edit" onClick={() => setEditing(h)} sx={{ m: 0 }}>
|
<AppButton variant="text" color="primary" startIcon="edit" onClick={() => setEditing(h)}>
|
||||||
{t('cfg_edit')}
|
{t('cfg_edit')}
|
||||||
</AppButton>
|
</AppButton>
|
||||||
),
|
),
|
||||||
@@ -79,7 +79,7 @@ export default function AdminHolidaysPage() {
|
|||||||
subtitle={t('hol_subtitle')}
|
subtitle={t('hol_subtitle')}
|
||||||
actions={
|
actions={
|
||||||
caps.canConfig ? (
|
caps.canConfig ? (
|
||||||
<AppButton variant="contained" color="primary" startIcon="add" onClick={() => setEditing('new')} sx={{ m: 0 }}>
|
<AppButton variant="contained" color="primary" startIcon="add" onClick={() => setEditing('new')}>
|
||||||
{t('hol_add')}
|
{t('hol_add')}
|
||||||
</AppButton>
|
</AppButton>
|
||||||
) : undefined
|
) : undefined
|
||||||
@@ -168,10 +168,10 @@ function HolidayDialog({ holiday, onClose }: { holiday: Holiday | null; onClose:
|
|||||||
</Stack>
|
</Stack>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
<DialogActions sx={{ px: 3, pb: 2 }}>
|
<DialogActions sx={{ px: 3, pb: 2 }}>
|
||||||
<AppButton variant="text" color="inherit" onClick={onClose} disabled={upsert.isPending} sx={{ m: 0 }}>
|
<AppButton variant="text" color="inherit" onClick={onClose} disabled={upsert.isPending}>
|
||||||
{t('cancel')}
|
{t('cancel')}
|
||||||
</AppButton>
|
</AppButton>
|
||||||
<AppButton variant="contained" color="primary" onClick={onSave} disabled={!valid || upsert.isPending} sx={{ m: 0 }}>
|
<AppButton variant="contained" color="primary" onClick={onSave} disabled={!valid || upsert.isPending}>
|
||||||
{upsert.isPending ? t('saving') : t('save')}
|
{upsert.isPending ? t('saving') : t('save')}
|
||||||
</AppButton>
|
</AppButton>
|
||||||
</DialogActions>
|
</DialogActions>
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import SidebarShellSkeleton from '../_chrome/SidebarShellSkeleton';
|
||||||
|
|
||||||
|
export default function Loading() {
|
||||||
|
return <SidebarShellSkeleton />;
|
||||||
|
}
|
||||||
@@ -1,81 +1,13 @@
|
|||||||
'use client';
|
import type { Metadata } from 'next';
|
||||||
import { useLocale, useTranslations } from 'next-intl';
|
import { getTranslations } from 'next-intl/server';
|
||||||
import { Box, Paper, Typography } from '@mui/material';
|
import AdminOverviewScreen from './AdminOverviewScreen';
|
||||||
import { AppIcon, AppLink } from '@/components';
|
|
||||||
import { AdminPageHeader } from '@/components/admin';
|
|
||||||
import { useAdminCapabilities } from '@/hooks';
|
|
||||||
import { ROUTES } from '@/constants';
|
|
||||||
|
|
||||||
/**
|
export async function generateMetadata({ params }: { params: Promise<{ locale: string }> }): Promise<Metadata> {
|
||||||
* Admin overview landing (f15) — the backoffice home. Renders one **console card** per worklist the current
|
const { locale } = await params;
|
||||||
* principal may act on, derived from `useAdminCapabilities()` (a UI hint; the server still enforces every
|
const t = await getTranslations({ locale, namespace: 'admin' });
|
||||||
* command's role scope). A `support` admin sees verification/tickets/alerts; a `finance` admin sees
|
return { title: t('overview_title') };
|
||||||
* payouts/config; only a `super_admin` sees roles. Each card deep-links into its console.
|
}
|
||||||
*/
|
|
||||||
export default function AdminOverviewPage() {
|
export default function Page() {
|
||||||
const t = useTranslations('admin');
|
return <AdminOverviewScreen />;
|
||||||
const tNav = useTranslations('nav');
|
|
||||||
const locale = useLocale();
|
|
||||||
const caps = useAdminCapabilities();
|
|
||||||
|
|
||||||
// `key` doubles as the `nav` i18n key for the card label.
|
|
||||||
const consoles: { key: string; route: string; icon: string; enabled: boolean }[] = [
|
|
||||||
{ key: 'verification', route: ROUTES.ADMIN_VERIFICATION, icon: 'verification', enabled: caps.canVerify },
|
|
||||||
{ key: 'tickets', route: ROUTES.ADMIN_TICKETS, icon: 'support', enabled: caps.canManageTickets },
|
|
||||||
{ key: 'payouts', route: ROUTES.ADMIN_PAYOUTS, icon: 'earnings', enabled: caps.canPayout },
|
|
||||||
{ key: 'reviews', route: ROUTES.ADMIN_REVIEWS, icon: 'moderation', enabled: caps.canModerate },
|
|
||||||
{ key: 'config', route: ROUTES.ADMIN_CONFIG, icon: 'config', enabled: caps.canConfig },
|
|
||||||
{ key: 'holidays', route: ROUTES.ADMIN_HOLIDAYS, icon: 'calendar', enabled: caps.canConfig },
|
|
||||||
{ key: 'alerts', route: ROUTES.ADMIN_ALERTS, icon: 'alerts', enabled: caps.canManageAlerts },
|
|
||||||
{ key: 'audit', route: ROUTES.ADMIN_AUDIT, icon: 'audit', enabled: caps.canViewAudit },
|
|
||||||
{ key: 'partners', route: ROUTES.ADMIN_PARTNERS, icon: 'partners', enabled: caps.canManagePartners },
|
|
||||||
{ key: 'roles', route: ROUTES.ADMIN_ROLES, icon: 'roles', enabled: caps.canManageRoles },
|
|
||||||
].filter((c) => c.enabled);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
|
|
||||||
<AdminPageHeader title={t('overview_title')} subtitle={t('overview_subtitle')} />
|
|
||||||
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
display: 'grid',
|
|
||||||
gridTemplateColumns: { xs: '1fr', sm: '1fr 1fr', md: '1fr 1fr 1fr' },
|
|
||||||
gap: 2,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{consoles.map((c) => (
|
|
||||||
<AppLink
|
|
||||||
key={c.key}
|
|
||||||
to={`/${locale}${c.route}`}
|
|
||||||
color="inherit"
|
|
||||||
underline="none"
|
|
||||||
sx={{ display: 'block', height: '100%' }}
|
|
||||||
>
|
|
||||||
<Paper
|
|
||||||
elevation={0}
|
|
||||||
sx={{
|
|
||||||
p: 3,
|
|
||||||
height: '100%',
|
|
||||||
border: '1px solid',
|
|
||||||
borderColor: 'divider',
|
|
||||||
borderRadius: 2,
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'column',
|
|
||||||
alignItems: 'flex-start',
|
|
||||||
gap: 1.5,
|
|
||||||
cursor: 'pointer',
|
|
||||||
transition: 'border-color 150ms ease, box-shadow 150ms ease',
|
|
||||||
'&:hover': { borderColor: 'primary.main', boxShadow: 3 },
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<AppIcon icon={c.icon} size={32} color="var(--bal-primary)" />
|
|
||||||
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
|
|
||||||
{tNav(c.key)}
|
|
||||||
</Typography>
|
|
||||||
</Paper>
|
|
||||||
</AppLink>
|
|
||||||
))}
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -83,7 +83,7 @@ export default function AdminPartnerCenterDetailPage() {
|
|||||||
color="primary"
|
color="primary"
|
||||||
startIcon="partners"
|
startIcon="partners"
|
||||||
onClick={() => router.push(`/${locale}${ROUTES.ADMIN_PARTNERS}`)}
|
onClick={() => router.push(`/${locale}${ROUTES.ADMIN_PARTNERS}`)}
|
||||||
sx={{ m: 0, alignSelf: 'flex-start' }}
|
sx={{ alignSelf: 'flex-start' }}
|
||||||
>
|
>
|
||||||
{t('back')}
|
{t('back')}
|
||||||
</AppButton>
|
</AppButton>
|
||||||
@@ -162,7 +162,6 @@ export default function AdminPartnerCenterDetailPage() {
|
|||||||
startIcon="verified"
|
startIcon="verified"
|
||||||
onClick={() => setConfirmVerify(true)}
|
onClick={() => setConfirmVerify(true)}
|
||||||
disabled={verify.isPending}
|
disabled={verify.isPending}
|
||||||
sx={{ m: 0 }}
|
|
||||||
>
|
>
|
||||||
{t('partner_verify')}
|
{t('partner_verify')}
|
||||||
</AppButton>
|
</AppButton>
|
||||||
@@ -172,11 +171,10 @@ export default function AdminPartnerCenterDetailPage() {
|
|||||||
color="primary"
|
color="primary"
|
||||||
onClick={() => setActive.mutate(!data.isActive)}
|
onClick={() => setActive.mutate(!data.isActive)}
|
||||||
disabled={setActive.isPending}
|
disabled={setActive.isPending}
|
||||||
sx={{ m: 0 }}
|
|
||||||
>
|
>
|
||||||
{t(data.isActive ? 'partner_suspend' : 'partner_activate')}
|
{t(data.isActive ? 'partner_suspend' : 'partner_activate')}
|
||||||
</AppButton>
|
</AppButton>
|
||||||
<AppButton variant="outlined" color="inherit" startIcon="edit" onClick={() => setEditing(true)} sx={{ m: 0 }}>
|
<AppButton variant="outlined" color="inherit" startIcon="edit" onClick={() => setEditing(true)}>
|
||||||
{t('partner_edit')}
|
{t('partner_edit')}
|
||||||
</AppButton>
|
</AppButton>
|
||||||
</Stack>
|
</Stack>
|
||||||
@@ -211,7 +209,6 @@ export default function AdminPartnerCenterDetailPage() {
|
|||||||
startIcon="delete"
|
startIcon="delete"
|
||||||
onClick={() => onRemove(nurse.nurseProfileId)}
|
onClick={() => onRemove(nurse.nurseProfileId)}
|
||||||
disabled={assignNurse.isPending}
|
disabled={assignNurse.isPending}
|
||||||
sx={{ m: 0 }}
|
|
||||||
>
|
>
|
||||||
{t('partner_unlink_nurse')}
|
{t('partner_unlink_nurse')}
|
||||||
</AppButton>
|
</AppButton>
|
||||||
@@ -239,7 +236,7 @@ export default function AdminPartnerCenterDetailPage() {
|
|||||||
startIcon="assign"
|
startIcon="assign"
|
||||||
onClick={onAssign}
|
onClick={onAssign}
|
||||||
disabled={assignNurse.isPending || Number(assignId) <= 0}
|
disabled={assignNurse.isPending || Number(assignId) <= 0}
|
||||||
sx={{ m: 0, mt: 0.25 }}
|
sx={{ mt: 0.25 }}
|
||||||
>
|
>
|
||||||
{t('partner_assign_nurse')}
|
{t('partner_assign_nurse')}
|
||||||
</AppButton>
|
</AppButton>
|
||||||
@@ -270,7 +267,7 @@ function DetailRow({ label, children }: { label: string; children: ReactNode })
|
|||||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||||
{label}
|
{label}
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography variant="body2" component="div" sx={{ fontWeight: 600, textAlign: 'end' }}>
|
<Typography variant="body2" component="div" sx={{ fontWeight: 500, textAlign: 'end' }}>
|
||||||
{children}
|
{children}
|
||||||
</Typography>
|
</Typography>
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|||||||
@@ -77,7 +77,7 @@ export default function AdminPartnersPage() {
|
|||||||
subtitle={t('partner_subtitle')}
|
subtitle={t('partner_subtitle')}
|
||||||
actions={
|
actions={
|
||||||
caps.canManagePartners ? (
|
caps.canManagePartners ? (
|
||||||
<AppButton variant="contained" color="primary" startIcon="add" onClick={() => setCreating(true)} sx={{ m: 0 }}>
|
<AppButton variant="contained" color="primary" startIcon="add" onClick={() => setCreating(true)}>
|
||||||
{t('partner_create')}
|
{t('partner_create')}
|
||||||
</AppButton>
|
</AppButton>
|
||||||
) : undefined
|
) : undefined
|
||||||
@@ -245,10 +245,10 @@ export function PartnerCenterFormDialog({ center, onClose }: { center: PartnerCe
|
|||||||
</Stack>
|
</Stack>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
<DialogActions sx={{ px: 3, pb: 2 }}>
|
<DialogActions sx={{ px: 3, pb: 2 }}>
|
||||||
<AppButton variant="text" color="inherit" onClick={onClose} disabled={mutation.isPending} sx={{ m: 0 }}>
|
<AppButton variant="text" color="inherit" onClick={onClose} disabled={mutation.isPending}>
|
||||||
{t('cancel')}
|
{t('cancel')}
|
||||||
</AppButton>
|
</AppButton>
|
||||||
<AppButton variant="contained" color="primary" onClick={onSave} disabled={!valid || mutation.isPending} sx={{ m: 0 }}>
|
<AppButton variant="contained" color="primary" onClick={onSave} disabled={!valid || mutation.isPending}>
|
||||||
{mutation.isPending ? t('saving') : t('save')}
|
{mutation.isPending ? t('saving') : t('save')}
|
||||||
</AppButton>
|
</AppButton>
|
||||||
</DialogActions>
|
</DialogActions>
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ export default function AdminPayoutBatchDetailPage() {
|
|||||||
variant="text"
|
variant="text"
|
||||||
color="primary"
|
color="primary"
|
||||||
onClick={() => router.push(`/${locale}${ROUTES.ADMIN_PAYOUTS}`)}
|
onClick={() => router.push(`/${locale}${ROUTES.ADMIN_PAYOUTS}`)}
|
||||||
sx={{ m: 0, alignSelf: 'flex-start' }}
|
sx={{ alignSelf: 'flex-start' }}
|
||||||
>
|
>
|
||||||
{t('back')}
|
{t('back')}
|
||||||
</AppButton>
|
</AppButton>
|
||||||
@@ -92,7 +92,7 @@ export default function AdminPayoutBatchDetailPage() {
|
|||||||
<Chip
|
<Chip
|
||||||
size="small"
|
size="small"
|
||||||
label={t('payout_holiday_shift')}
|
label={t('payout_holiday_shift')}
|
||||||
sx={{ bgcolor: 'var(--bal-warning)', color: 'var(--bal-warning-contrast)', fontWeight: 600 }}
|
sx={{ bgcolor: 'var(--bal-warning)', color: 'var(--bal-warning-contrast)', fontWeight: 500 }}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
</Stack>
|
</Stack>
|
||||||
@@ -226,7 +226,6 @@ const PayoutRowCard: FunctionComponent<{ row: AdminPayoutRow; batchId: number; c
|
|||||||
color="error"
|
color="error"
|
||||||
onClick={() => setRetryOpen(true)}
|
onClick={() => setRetryOpen(true)}
|
||||||
disabled={retry.isPending}
|
disabled={retry.isPending}
|
||||||
sx={{ m: 0 }}
|
|
||||||
>
|
>
|
||||||
{t('payout_retry')}
|
{t('payout_retry')}
|
||||||
</AppButton>
|
</AppButton>
|
||||||
@@ -251,7 +250,6 @@ const PayoutRowCard: FunctionComponent<{ row: AdminPayoutRow; batchId: number; c
|
|||||||
color="primary"
|
color="primary"
|
||||||
onClick={onRecord}
|
onClick={onRecord}
|
||||||
disabled={reference.trim().length === 0 || record.isPending}
|
disabled={reference.trim().length === 0 || record.isPending}
|
||||||
sx={{ m: 0 }}
|
|
||||||
>
|
>
|
||||||
{record.isPending ? t('saving') : t('save')}
|
{record.isPending ? t('saving') : t('save')}
|
||||||
</AppButton>
|
</AppButton>
|
||||||
@@ -279,7 +277,7 @@ const MetaLine: FunctionComponent<{ label: string; children: ReactNode }> = ({ l
|
|||||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||||
{label}
|
{label}
|
||||||
</Typography>
|
</Typography>
|
||||||
<Box sx={{ fontWeight: 600, typography: 'body2' }}>{children}</Box>
|
<Box sx={{ fontWeight: 500, typography: 'body2' }}>{children}</Box>
|
||||||
</Stack>
|
</Stack>
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -288,7 +286,7 @@ const Field: FunctionComponent<{ label: string; children: ReactNode }> = ({ labe
|
|||||||
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
|
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
|
||||||
{label}
|
{label}
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography variant="body2" sx={{ fontWeight: 600 }}>
|
<Typography variant="body2" sx={{ fontWeight: 500 }}>
|
||||||
{children}
|
{children}
|
||||||
</Typography>
|
</Typography>
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ import {
|
|||||||
TextField,
|
TextField,
|
||||||
Typography,
|
Typography,
|
||||||
} from '@mui/material';
|
} from '@mui/material';
|
||||||
import { AppButton, StatusChip } from '@/components';
|
import { AppButton, Money, StatusChip } from '@/components';
|
||||||
import type { StatusKind } from '@/components';
|
import type { StatusKind } from '@/components';
|
||||||
import {
|
import {
|
||||||
AdminDataTable,
|
AdminDataTable,
|
||||||
@@ -30,7 +30,7 @@ import {
|
|||||||
import type { AdminTableColumn } from '@/components/admin';
|
import type { AdminTableColumn } from '@/components/admin';
|
||||||
import { useAdminCapabilities } from '@/hooks';
|
import { useAdminCapabilities } from '@/hooks';
|
||||||
import { adminPayoutBatchPath } from '@/constants';
|
import { adminPayoutBatchPath } from '@/constants';
|
||||||
import { formatIrrToToman, formatShamsiDate } from '@/utils';
|
import { formatShamsiDate } from '@/utils';
|
||||||
import { PAYOUTS_PAGE_SIZE } from '@/services/payouts/constants';
|
import { PAYOUTS_PAGE_SIZE } from '@/services/payouts/constants';
|
||||||
import { usePayoutBatches, usePreviewPayoutBatch, useRunPayoutBatch } from '@/services/payouts';
|
import { usePayoutBatches, usePreviewPayoutBatch, useRunPayoutBatch } from '@/services/payouts';
|
||||||
import type { PayoutBatchStatus, PayoutBatchSummary } from '@/services/payouts/types';
|
import type { PayoutBatchStatus, PayoutBatchSummary } from '@/services/payouts/types';
|
||||||
@@ -63,7 +63,6 @@ const isoDate = (d: Date): string => d.toISOString().slice(0, 10);
|
|||||||
*/
|
*/
|
||||||
export default function AdminPayoutsPage() {
|
export default function AdminPayoutsPage() {
|
||||||
const t = useTranslations('admin');
|
const t = useTranslations('admin');
|
||||||
const tCommon = useTranslations('common');
|
|
||||||
const locale = useLocale();
|
const locale = useLocale();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const caps = useAdminCapabilities();
|
const caps = useAdminCapabilities();
|
||||||
@@ -92,7 +91,7 @@ export default function AdminPayoutsPage() {
|
|||||||
{
|
{
|
||||||
key: 'total',
|
key: 'total',
|
||||||
header: t('payout_col_total'),
|
header: t('payout_col_total'),
|
||||||
render: (b) => `${formatIrrToToman(b.totalAmount, locale)} ${tCommon('currency_toman')}`,
|
render: (b) => <Money amountIrr={b.totalAmount} size="sm" />,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'status',
|
key: 'status',
|
||||||
@@ -109,7 +108,7 @@ export default function AdminPayoutsPage() {
|
|||||||
<Chip
|
<Chip
|
||||||
size="small"
|
size="small"
|
||||||
label={t('payout_holiday_shift')}
|
label={t('payout_holiday_shift')}
|
||||||
sx={{ bgcolor: 'var(--bal-warning)', color: 'var(--bal-warning-contrast)', fontWeight: 600 }}
|
sx={{ bgcolor: 'var(--bal-warning)', color: 'var(--bal-warning-contrast)', fontWeight: 500 }}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
</Stack>
|
</Stack>
|
||||||
@@ -143,7 +142,7 @@ export default function AdminPayoutsPage() {
|
|||||||
))}
|
))}
|
||||||
</TextField>
|
</TextField>
|
||||||
{caps.canPayout ? (
|
{caps.canPayout ? (
|
||||||
<AppButton variant="contained" color="primary" onClick={() => setPreviewOpen(true)} sx={{ m: 0 }}>
|
<AppButton variant="contained" color="primary" onClick={() => setPreviewOpen(true)}>
|
||||||
{t('payout_preview')}
|
{t('payout_preview')}
|
||||||
</AppButton>
|
</AppButton>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -189,7 +188,6 @@ export default function AdminPayoutsPage() {
|
|||||||
*/
|
*/
|
||||||
function PreviewBatchDialog({ canPayout, onClose }: { canPayout: boolean; onClose: () => void }) {
|
function PreviewBatchDialog({ canPayout, onClose }: { canPayout: boolean; onClose: () => void }) {
|
||||||
const t = useTranslations('admin');
|
const t = useTranslations('admin');
|
||||||
const tCommon = useTranslations('common');
|
|
||||||
const locale = useLocale();
|
const locale = useLocale();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { enqueueSnackbar } = useSnackbar();
|
const { enqueueSnackbar } = useSnackbar();
|
||||||
@@ -253,7 +251,6 @@ function PreviewBatchDialog({ canPayout, onClose }: { canPayout: boolean; onClos
|
|||||||
color="primary"
|
color="primary"
|
||||||
onClick={onPreview}
|
onClick={onPreview}
|
||||||
disabled={!periodStart || !periodEnd || preview.isPending}
|
disabled={!periodStart || !periodEnd || preview.isPending}
|
||||||
sx={{ m: 0 }}
|
|
||||||
>
|
>
|
||||||
{t('payout_preview')}
|
{t('payout_preview')}
|
||||||
</AppButton>
|
</AppButton>
|
||||||
@@ -272,7 +269,7 @@ function PreviewBatchDialog({ canPayout, onClose }: { canPayout: boolean; onClos
|
|||||||
<Chip
|
<Chip
|
||||||
size="small"
|
size="small"
|
||||||
label={t('payout_holiday_shift')}
|
label={t('payout_holiday_shift')}
|
||||||
sx={{ bgcolor: 'var(--bal-warning)', color: 'var(--bal-warning-contrast)', fontWeight: 600 }}
|
sx={{ bgcolor: 'var(--bal-warning)', color: 'var(--bal-warning-contrast)', fontWeight: 500 }}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
</Stack>
|
</Stack>
|
||||||
@@ -300,14 +297,14 @@ function PreviewBatchDialog({ canPayout, onClose }: { canPayout: boolean; onClos
|
|||||||
<Chip
|
<Chip
|
||||||
size="small"
|
size="small"
|
||||||
label={t('payout_no_iban')}
|
label={t('payout_no_iban')}
|
||||||
sx={{ bgcolor: 'var(--bal-error)', color: 'var(--bal-error-contrast)', fontWeight: 600 }}
|
sx={{ bgcolor: 'var(--bal-error)', color: 'var(--bal-error-contrast)', fontWeight: 500 }}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
</Stack>
|
</Stack>
|
||||||
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
|
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
|
||||||
{t('payout_col_gross')}: {formatIrrToToman(n.grossEarningsIrr, locale)} ·{' '}
|
{t('payout_col_gross')}: <Money amountIrr={n.grossEarningsIrr} size="sm" hideUnit /> ·{' '}
|
||||||
{t('payout_col_clawback')}: {formatIrrToToman(n.clawbackAppliedIrr, locale)} ·{' '}
|
{t('payout_col_clawback')}: <Money amountIrr={n.clawbackAppliedIrr} size="sm" hideUnit /> ·{' '}
|
||||||
{t('payout_col_net')}: {formatIrrToToman(n.netAmountIrr, locale)} {tCommon('currency_toman')}
|
{t('payout_col_net')}: <Money amountIrr={n.netAmountIrr} size="sm" />
|
||||||
</Typography>
|
</Typography>
|
||||||
</Stack>
|
</Stack>
|
||||||
))}
|
))}
|
||||||
@@ -346,7 +343,7 @@ function PreviewBatchDialog({ canPayout, onClose }: { canPayout: boolean; onClos
|
|||||||
) : null}
|
) : null}
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
<DialogActions sx={{ px: 3, pb: 2 }}>
|
<DialogActions sx={{ px: 3, pb: 2 }}>
|
||||||
<AppButton variant="text" color="inherit" onClick={onClose} disabled={run.isPending} sx={{ m: 0 }}>
|
<AppButton variant="text" color="inherit" onClick={onClose} disabled={run.isPending}>
|
||||||
{t('cancel')}
|
{t('cancel')}
|
||||||
</AppButton>
|
</AppButton>
|
||||||
{canPayout ? (
|
{canPayout ? (
|
||||||
@@ -355,7 +352,6 @@ function PreviewBatchDialog({ canPayout, onClose }: { canPayout: boolean; onClos
|
|||||||
color="primary"
|
color="primary"
|
||||||
onClick={() => setRunConfirmOpen(true)}
|
onClick={() => setRunConfirmOpen(true)}
|
||||||
disabled={!result || run.isPending}
|
disabled={!result || run.isPending}
|
||||||
sx={{ m: 0 }}
|
|
||||||
>
|
>
|
||||||
{t('payout_run')}
|
{t('payout_run')}
|
||||||
</AppButton>
|
</AppButton>
|
||||||
|
|||||||
@@ -182,13 +182,13 @@ function ModerationCard({
|
|||||||
|
|
||||||
{canModerate ? (
|
{canModerate ? (
|
||||||
<Stack direction="row" sx={{ gap: 1, flexWrap: 'wrap' }}>
|
<Stack direction="row" sx={{ gap: 1, flexWrap: 'wrap' }}>
|
||||||
<AppButton variant="contained" color="primary" startIcon="publish" onClick={() => onAct('publish')} sx={{ m: 0 }}>
|
<AppButton variant="contained" color="primary" startIcon="publish" onClick={() => onAct('publish')}>
|
||||||
{t('mod_publish')}
|
{t('mod_publish')}
|
||||||
</AppButton>
|
</AppButton>
|
||||||
<AppButton variant="outlined" color="inherit" onClick={() => onAct('hide')} sx={{ m: 0 }}>
|
<AppButton variant="outlined" color="inherit" onClick={() => onAct('hide')}>
|
||||||
{t('mod_hide')}
|
{t('mod_hide')}
|
||||||
</AppButton>
|
</AppButton>
|
||||||
<AppButton variant="outlined" color="error" onClick={() => onAct('reject')} sx={{ m: 0 }}>
|
<AppButton variant="outlined" color="error" onClick={() => onAct('reject')}>
|
||||||
{t('mod_reject')}
|
{t('mod_reject')}
|
||||||
</AppButton>
|
</AppButton>
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|||||||
@@ -71,7 +71,7 @@ export default function AdminRolesPage() {
|
|||||||
header: '',
|
header: '',
|
||||||
align: 'right',
|
align: 'right',
|
||||||
render: (g: RoleGrant) => (
|
render: (g: RoleGrant) => (
|
||||||
<AppButton variant="text" color="error" startIcon="delete" onClick={() => setRevoking(g)} sx={{ m: 0 }}>
|
<AppButton variant="text" color="error" startIcon="delete" onClick={() => setRevoking(g)}>
|
||||||
{t('role_revoke')}
|
{t('role_revoke')}
|
||||||
</AppButton>
|
</AppButton>
|
||||||
),
|
),
|
||||||
@@ -100,7 +100,7 @@ export default function AdminRolesPage() {
|
|||||||
subtitle={t('role_subtitle')}
|
subtitle={t('role_subtitle')}
|
||||||
actions={
|
actions={
|
||||||
caps.canManageRoles ? (
|
caps.canManageRoles ? (
|
||||||
<AppButton variant="contained" color="primary" startIcon="add" onClick={() => setGranting(true)} sx={{ m: 0 }}>
|
<AppButton variant="contained" color="primary" startIcon="add" onClick={() => setGranting(true)}>
|
||||||
{t('role_grant')}
|
{t('role_grant')}
|
||||||
</AppButton>
|
</AppButton>
|
||||||
) : undefined
|
) : undefined
|
||||||
@@ -193,10 +193,10 @@ function GrantRoleDialog({ onClose }: { onClose: () => void }) {
|
|||||||
</Stack>
|
</Stack>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
<DialogActions sx={{ px: 3, pb: 2 }}>
|
<DialogActions sx={{ px: 3, pb: 2 }}>
|
||||||
<AppButton variant="text" color="inherit" onClick={onClose} disabled={grant.isPending} sx={{ m: 0 }}>
|
<AppButton variant="text" color="inherit" onClick={onClose} disabled={grant.isPending}>
|
||||||
{t('cancel')}
|
{t('cancel')}
|
||||||
</AppButton>
|
</AppButton>
|
||||||
<AppButton variant="contained" color="primary" onClick={onGrant} disabled={!valid || grant.isPending} sx={{ m: 0 }}>
|
<AppButton variant="contained" color="primary" onClick={onGrant} disabled={!valid || grant.isPending}>
|
||||||
{grant.isPending ? t('saving') : t('confirm')}
|
{grant.isPending ? t('saving') : t('confirm')}
|
||||||
</AppButton>
|
</AppButton>
|
||||||
</DialogActions>
|
</DialogActions>
|
||||||
|
|||||||
@@ -88,7 +88,7 @@ export default function AdminTicketThreadPage() {
|
|||||||
variant="text"
|
variant="text"
|
||||||
color="primary"
|
color="primary"
|
||||||
onClick={() => router.push(`/${locale}${ROUTES.ADMIN_TICKETS}`)}
|
onClick={() => router.push(`/${locale}${ROUTES.ADMIN_TICKETS}`)}
|
||||||
sx={{ m: 0, alignSelf: 'flex-start' }}
|
sx={{ alignSelf: 'flex-start' }}
|
||||||
>
|
>
|
||||||
{t('back')}
|
{t('back')}
|
||||||
</AppButton>
|
</AppButton>
|
||||||
@@ -133,7 +133,6 @@ export default function AdminTicketThreadPage() {
|
|||||||
color="primary"
|
color="primary"
|
||||||
startIcon="refunds"
|
startIcon="refunds"
|
||||||
onClick={() => setRefundShown((v) => !v)}
|
onClick={() => setRefundShown((v) => !v)}
|
||||||
sx={{ m: 0 }}
|
|
||||||
>
|
>
|
||||||
{t('refund_open')}
|
{t('refund_open')}
|
||||||
</AppButton>
|
</AppButton>
|
||||||
@@ -184,7 +183,7 @@ export default function AdminTicketThreadPage() {
|
|||||||
startIcon="send"
|
startIcon="send"
|
||||||
onClick={send}
|
onClick={send}
|
||||||
disabled={post.isPending || body.trim().length === 0}
|
disabled={post.isPending || body.trim().length === 0}
|
||||||
sx={{ m: 0, alignSelf: 'flex-start' }}
|
sx={{ alignSelf: 'flex-start' }}
|
||||||
>
|
>
|
||||||
{t('ticket_send')}
|
{t('ticket_send')}
|
||||||
</AppButton>
|
</AppButton>
|
||||||
|
|||||||
@@ -124,10 +124,10 @@ export default function AdminTicketsPage() {
|
|||||||
onChange={(e) => setDraft((d) => ({ ...d, referenceCode: e.target.value || undefined }))}
|
onChange={(e) => setDraft((d) => ({ ...d, referenceCode: e.target.value || undefined }))}
|
||||||
sx={{ minWidth: 220 }}
|
sx={{ minWidth: 220 }}
|
||||||
/>
|
/>
|
||||||
<AppButton variant="contained" color="primary" onClick={apply} sx={{ m: 0 }}>
|
<AppButton variant="contained" color="primary" onClick={apply}>
|
||||||
{t('apply')}
|
{t('apply')}
|
||||||
</AppButton>
|
</AppButton>
|
||||||
<AppButton variant="text" color="inherit" onClick={clear} sx={{ m: 0 }}>
|
<AppButton variant="text" color="inherit" onClick={clear}>
|
||||||
{t('clear')}
|
{t('clear')}
|
||||||
</AppButton>
|
</AppButton>
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|||||||
@@ -104,7 +104,7 @@ export default function AdminVerificationCasePage() {
|
|||||||
return (
|
return (
|
||||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
|
||||||
<Stack sx={{ gap: 1 }}>
|
<Stack sx={{ gap: 1 }}>
|
||||||
<AppButton variant="text" color="primary" onClick={backToQueue} sx={{ m: 0, alignSelf: 'flex-start' }}>
|
<AppButton variant="text" color="primary" onClick={backToQueue} sx={{ alignSelf: 'flex-start' }}>
|
||||||
{t('back')}
|
{t('back')}
|
||||||
</AppButton>
|
</AppButton>
|
||||||
<AdminPageHeader title={t('ver_case_title')} />
|
<AdminPageHeader title={t('ver_case_title')} />
|
||||||
@@ -177,7 +177,6 @@ export default function AdminVerificationCasePage() {
|
|||||||
color="primary"
|
color="primary"
|
||||||
onClick={() => setApproveOpen(true)}
|
onClick={() => setApproveOpen(true)}
|
||||||
disabled={!allPassed || !caps.canVerify}
|
disabled={!allPassed || !caps.canVerify}
|
||||||
sx={{ m: 0 }}
|
|
||||||
>
|
>
|
||||||
{t('ver_approve')}
|
{t('ver_approve')}
|
||||||
</AppButton>
|
</AppButton>
|
||||||
@@ -186,7 +185,6 @@ export default function AdminVerificationCasePage() {
|
|||||||
color="error"
|
color="error"
|
||||||
onClick={() => setRejectOpen(true)}
|
onClick={() => setRejectOpen(true)}
|
||||||
disabled={!caps.canVerify}
|
disabled={!caps.canVerify}
|
||||||
sx={{ m: 0 }}
|
|
||||||
>
|
>
|
||||||
{t('ver_reject_all')}
|
{t('ver_reject_all')}
|
||||||
</AppButton>
|
</AppButton>
|
||||||
@@ -274,7 +272,7 @@ function StepCard({
|
|||||||
</Typography>
|
</Typography>
|
||||||
<StatusChip status={STEP_STATUS_KIND[step.status]} label={t(`step_${step.status}`)} />
|
<StatusChip status={STEP_STATUS_KIND[step.status]} label={t(`step_${step.status}`)} />
|
||||||
{step.isAutomated ? (
|
{step.isAutomated ? (
|
||||||
<Chip size="small" label={t('ver_automated_badge')} sx={{ bgcolor: 'action.hover', fontWeight: 600 }} />
|
<Chip size="small" label={t('ver_automated_badge')} sx={{ bgcolor: 'action.hover', fontWeight: 500 }} />
|
||||||
) : null}
|
) : null}
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|
||||||
@@ -303,7 +301,6 @@ function StepCard({
|
|||||||
color="primary"
|
color="primary"
|
||||||
onClick={() => (isCredentialStep ? setCredentialOpen(true) : onPass())}
|
onClick={() => (isCredentialStep ? setCredentialOpen(true) : onPass())}
|
||||||
disabled={decide.isPending}
|
disabled={decide.isPending}
|
||||||
sx={{ m: 0 }}
|
|
||||||
>
|
>
|
||||||
{t('ver_pass')}
|
{t('ver_pass')}
|
||||||
</AppButton>
|
</AppButton>
|
||||||
@@ -312,7 +309,6 @@ function StepCard({
|
|||||||
color="error"
|
color="error"
|
||||||
onClick={() => setRejectOpen(true)}
|
onClick={() => setRejectOpen(true)}
|
||||||
disabled={decide.isPending}
|
disabled={decide.isPending}
|
||||||
sx={{ m: 0 }}
|
|
||||||
>
|
>
|
||||||
{t('ver_reject')}
|
{t('ver_reject')}
|
||||||
</AppButton>
|
</AppButton>
|
||||||
@@ -434,7 +430,7 @@ function CredentialDialog({
|
|||||||
</Stack>
|
</Stack>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
<DialogActions sx={{ px: 3, pb: 2 }}>
|
<DialogActions sx={{ px: 3, pb: 2 }}>
|
||||||
<AppButton variant="text" color="inherit" onClick={onClose} disabled={decide.isPending} sx={{ m: 0 }}>
|
<AppButton variant="text" color="inherit" onClick={onClose} disabled={decide.isPending}>
|
||||||
{t('cancel')}
|
{t('cancel')}
|
||||||
</AppButton>
|
</AppButton>
|
||||||
<AppButton
|
<AppButton
|
||||||
@@ -442,7 +438,6 @@ function CredentialDialog({
|
|||||||
color="primary"
|
color="primary"
|
||||||
onClick={onSubmit}
|
onClick={onSubmit}
|
||||||
disabled={decide.isPending || expiryMissing}
|
disabled={decide.isPending || expiryMissing}
|
||||||
sx={{ m: 0 }}
|
|
||||||
>
|
>
|
||||||
{decide.isPending ? t('saving') : t('save')}
|
{decide.isPending ? t('saving') : t('save')}
|
||||||
</AppButton>
|
</AppButton>
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ export default function AdminVerificationQueuePage() {
|
|||||||
size="small"
|
size="small"
|
||||||
icon={<AppIcon icon="warning" size={14} color="var(--bal-warning-contrast)" />}
|
icon={<AppIcon icon="warning" size={14} color="var(--bal-warning-contrast)" />}
|
||||||
label={t('ver_expiring_warning')}
|
label={t('ver_expiring_warning')}
|
||||||
sx={{ bgcolor: 'var(--bal-warning)', color: 'var(--bal-warning-contrast)', fontWeight: 600 }}
|
sx={{ bgcolor: 'var(--bal-warning)', color: 'var(--bal-warning-contrast)', fontWeight: 500 }}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|||||||
@@ -2,8 +2,8 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { useTranslations } from 'next-intl';
|
import { useTranslations } from 'next-intl';
|
||||||
import { useSnackbar } from 'notistack';
|
import { useSnackbar } from 'notistack';
|
||||||
import { Box, Paper, Stack, TextField, Typography } from '@mui/material';
|
import { Box, Stack, TextField, Typography } from '@mui/material';
|
||||||
import { AppButton, AppIcon, AppLoading, BankStatusPanel } from '@/components';
|
import { AppButton, AppLoading, BankStatusPanel, EmptyState } from '@/components';
|
||||||
import { useNurseBankAccounts, useAddNurseBankAccount, useSetPrimaryBankAccount } from '@/services/nurse';
|
import { useNurseBankAccounts, useAddNurseBankAccount, useSetPrimaryBankAccount } from '@/services/nurse';
|
||||||
import { isValidSheba } from '@/services/nurse/iban';
|
import { isValidSheba } from '@/services/nurse/iban';
|
||||||
import { deriveBankStatus } from '@/services/nurse/types';
|
import { deriveBankStatus } from '@/services/nurse/types';
|
||||||
@@ -92,7 +92,7 @@ export default function NurseBankPage() {
|
|||||||
onSuccess: () => enqueueSnackbar(t('primary_set'), { variant: 'success' }),
|
onSuccess: () => enqueueSnackbar(t('primary_set'), { variant: 'success' }),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
sx={{ m: 0, alignSelf: 'flex-start' }}
|
sx={{ alignSelf: 'flex-start' }}
|
||||||
>
|
>
|
||||||
{t('make_primary')}
|
{t('make_primary')}
|
||||||
</AppButton>
|
</AppButton>
|
||||||
@@ -102,18 +102,7 @@ export default function NurseBankPage() {
|
|||||||
})}
|
})}
|
||||||
|
|
||||||
{!isLoading && accounts.length === 0 ? (
|
{!isLoading && accounts.length === 0 ? (
|
||||||
<Paper
|
<EmptyState icon="bank" title={t('empty_title')} body={t('empty_body')} />
|
||||||
elevation={0}
|
|
||||||
sx={{ p: 3, textAlign: 'center', border: '1px dashed', borderColor: 'divider', borderRadius: 2, display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 1 }}
|
|
||||||
>
|
|
||||||
<AppIcon icon="bank" size={36} color="var(--bal-primary)" />
|
|
||||||
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
|
|
||||||
{t('empty_title')}
|
|
||||||
</Typography>
|
|
||||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
|
||||||
{t('empty_body')}
|
|
||||||
</Typography>
|
|
||||||
</Paper>
|
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{showFormNow ? (
|
{showFormNow ? (
|
||||||
@@ -147,7 +136,7 @@ export default function NurseBankPage() {
|
|||||||
startIcon="bank"
|
startIcon="bank"
|
||||||
onClick={submit}
|
onClick={submit}
|
||||||
disabled={addAccount.isPending}
|
disabled={addAccount.isPending}
|
||||||
sx={{ m: 0, alignSelf: 'flex-start' }}
|
sx={{ alignSelf: 'flex-start' }}
|
||||||
>
|
>
|
||||||
{addAccount.isPending ? t('submitting') : t('submit')}
|
{addAccount.isPending ? t('submitting') : t('submit')}
|
||||||
</AppButton>
|
</AppButton>
|
||||||
|
|||||||
@@ -154,7 +154,7 @@ export default function NurseCoveragePage() {
|
|||||||
key={area.id}
|
key={area.id}
|
||||||
label={chipLabel(area)}
|
label={chipLabel(area)}
|
||||||
onDelete={() => setRemoveTarget(area)}
|
onDelete={() => setRemoveTarget(area)}
|
||||||
sx={{ bgcolor: 'var(--bal-primary-soft)', fontWeight: 600 }}
|
sx={{ bgcolor: 'var(--bal-primary-soft)', fontWeight: 500 }}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</Box>
|
</Box>
|
||||||
@@ -237,7 +237,7 @@ export default function NurseCoveragePage() {
|
|||||||
startIcon="add"
|
startIcon="add"
|
||||||
onClick={handleAdd}
|
onClick={handleAdd}
|
||||||
disabled={addArea.isPending}
|
disabled={addArea.isPending}
|
||||||
sx={{ m: 0, alignSelf: 'flex-start' }}
|
sx={{ alignSelf: 'flex-start' }}
|
||||||
>
|
>
|
||||||
{addArea.isPending ? t('adding') : t('add')}
|
{addArea.isPending ? t('adding') : t('add')}
|
||||||
</AppButton>
|
</AppButton>
|
||||||
|
|||||||
@@ -3,8 +3,9 @@ import { useMemo, useState } from 'react';
|
|||||||
import { useLocale, useTranslations } from 'next-intl';
|
import { useLocale, useTranslations } from 'next-intl';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import { Box, Collapse, Paper, Skeleton, Stack, Tab, Tabs, Typography } from '@mui/material';
|
import { Box, Collapse, Paper, Skeleton, Stack, Tab, Tabs, Typography } from '@mui/material';
|
||||||
import { AppButton, AppIcon, EarningsBalanceHeader, EarningsRow } from '@/components';
|
import { AppButton, AppIcon, EarningsBalanceHeader, EarningsRow, EmptyState, ErrorState } from '@/components';
|
||||||
import { nurseBookingDetailPath, nursePayoutDetailPath } from '@/constants';
|
import { nurseBookingDetailPath, nursePayoutDetailPath } from '@/constants';
|
||||||
|
import { formatNumber } from '@/utils';
|
||||||
import { PAYOUTS_PAGE_SIZE } from '@/services/payouts/constants';
|
import { PAYOUTS_PAGE_SIZE } from '@/services/payouts/constants';
|
||||||
import { EARNINGS_STATES, type EarningsState } from '@/services/payouts/types';
|
import { EARNINGS_STATES, type EarningsState } from '@/services/payouts/types';
|
||||||
import { useNurseEarnings, useNurseEarningsBalance } from '@/services/payouts';
|
import { useNurseEarnings, useNurseEarningsBalance } from '@/services/payouts';
|
||||||
@@ -58,7 +59,7 @@ export default function NurseEarningsPage() {
|
|||||||
{balance.isLoading ? (
|
{balance.isLoading ? (
|
||||||
<Skeleton variant="rounded" height={200} />
|
<Skeleton variant="rounded" height={200} />
|
||||||
) : balance.isError ? (
|
) : balance.isError ? (
|
||||||
<ErrorPanel message={t('balance_error')} onRetry={() => balance.refetch()} retryLabel={t('retry')} />
|
<ErrorState message={t('balance_error')} retryLabel={t('retry')} onRetry={() => balance.refetch()} />
|
||||||
) : balance.data ? (
|
) : balance.data ? (
|
||||||
<EarningsBalanceHeader summary={balance.data} />
|
<EarningsBalanceHeader summary={balance.data} />
|
||||||
) : null}
|
) : null}
|
||||||
@@ -85,9 +86,9 @@ export default function NurseEarningsPage() {
|
|||||||
))}
|
))}
|
||||||
</Stack>
|
</Stack>
|
||||||
) : earnings.isError ? (
|
) : earnings.isError ? (
|
||||||
<ErrorPanel message={t('list_error')} onRetry={() => earnings.refetch()} retryLabel={t('retry')} />
|
<ErrorState message={t('list_error')} retryLabel={t('retry')} onRetry={() => earnings.refetch()} />
|
||||||
) : items.length === 0 ? (
|
) : items.length === 0 ? (
|
||||||
<EmptyPanel title={t('list_empty_title')} body={t('list_empty_body')} />
|
<EmptyState icon="earnings" title={t('list_empty_title')} body={t('list_empty_body')} />
|
||||||
) : (
|
) : (
|
||||||
<Stack sx={{ gap: 2 }}>
|
<Stack sx={{ gap: 2 }}>
|
||||||
{items.map((item) => (
|
{items.map((item) => (
|
||||||
@@ -150,36 +151,6 @@ function ExplainerCard({ open, onToggle }: { open: boolean; onToggle: () => void
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function EmptyPanel({ title, body }: { title: string; body: string }) {
|
|
||||||
return (
|
|
||||||
<Paper
|
|
||||||
elevation={0}
|
|
||||||
sx={{ p: 4, textAlign: 'center', border: '1px dashed', borderColor: 'divider', borderRadius: 2 }}
|
|
||||||
>
|
|
||||||
<AppIcon icon="earnings" size={40} color="var(--bal-text-secondary)" />
|
|
||||||
<Typography variant="subtitle1" sx={{ fontWeight: 700, mt: 1 }}>
|
|
||||||
{title}
|
|
||||||
</Typography>
|
|
||||||
<Typography variant="body2" sx={{ color: 'text.secondary', mt: 0.5 }}>
|
|
||||||
{body}
|
|
||||||
</Typography>
|
|
||||||
</Paper>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function ErrorPanel({ message, onRetry, retryLabel }: { message: string; onRetry: () => void; retryLabel: string }) {
|
|
||||||
return (
|
|
||||||
<Paper elevation={0} sx={{ p: 4, textAlign: 'center', border: '1px solid', borderColor: 'divider', borderRadius: 2 }}>
|
|
||||||
<Typography variant="body2" sx={{ color: 'text.secondary', mb: 1.5 }}>
|
|
||||||
{message}
|
|
||||||
</Typography>
|
|
||||||
<AppButton variant="outlined" color="primary" startIcon="refresh" onClick={onRetry} sx={{ m: 0 }}>
|
|
||||||
{retryLabel}
|
|
||||||
</AppButton>
|
|
||||||
</Paper>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Prev/next pager — rendered only when there is more than one page. */
|
/** Prev/next pager — rendered only when there is more than one page. */
|
||||||
function Pager({
|
function Pager({
|
||||||
page,
|
page,
|
||||||
@@ -195,17 +166,17 @@ function Pager({
|
|||||||
const t = useTranslations('payouts');
|
const t = useTranslations('payouts');
|
||||||
const locale = useLocale();
|
const locale = useLocale();
|
||||||
if (pageCount <= 1) return null;
|
if (pageCount <= 1) return null;
|
||||||
const fmt = (n: number) => new Intl.NumberFormat(locale === 'fa' ? 'fa-IR' : 'en-US').format(n);
|
const fmt = (n: number) => formatNumber(n, locale);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack direction="row" sx={{ gap: 1, alignItems: 'center', justifyContent: 'center' }}>
|
<Stack direction="row" sx={{ gap: 1, alignItems: 'center', justifyContent: 'center' }}>
|
||||||
<AppButton variant="text" color="primary" onClick={onPrev} disabled={page <= 1} sx={{ m: 0 }}>
|
<AppButton variant="text" color="primary" onClick={onPrev} disabled={page <= 1}>
|
||||||
{t('page_prev')}
|
{t('page_prev')}
|
||||||
</AppButton>
|
</AppButton>
|
||||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||||
{t('page_indicator', { page: fmt(page), total: fmt(pageCount) })}
|
{t('page_indicator', { page: fmt(page), total: fmt(pageCount) })}
|
||||||
</Typography>
|
</Typography>
|
||||||
<AppButton variant="text" color="primary" onClick={onNext} disabled={page >= pageCount} sx={{ m: 0 }}>
|
<AppButton variant="text" color="primary" onClick={onNext} disabled={page >= pageCount}>
|
||||||
{t('page_next')}
|
{t('page_next')}
|
||||||
</AppButton>
|
</AppButton>
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|||||||
@@ -3,10 +3,10 @@ import { FunctionComponent, ReactNode } from 'react';
|
|||||||
import { useParams, useRouter } from 'next/navigation';
|
import { useParams, useRouter } from 'next/navigation';
|
||||||
import { useLocale, useTranslations } from 'next-intl';
|
import { useLocale, useTranslations } from 'next-intl';
|
||||||
import { Box, Divider, Paper, Skeleton, Stack, Typography } from '@mui/material';
|
import { Box, Divider, Paper, Skeleton, Stack, Typography } from '@mui/material';
|
||||||
import { AppButton, AppIcon, PriceBreakdown, StatusChip } from '@/components';
|
import { AppButton, AppIcon, Money, PriceBreakdown, StatusChip } from '@/components';
|
||||||
import type { StatusKind } from '@/components';
|
import type { StatusKind } from '@/components';
|
||||||
import { nurseBookingDetailPath, ROUTES } from '@/constants';
|
import { nurseBookingDetailPath, ROUTES } from '@/constants';
|
||||||
import { formatIrrToToman, formatShamsiDate, parseIrr } from '@/utils';
|
import { formatShamsiDate, parseIrr } from '@/utils';
|
||||||
import { useNursePayoutDetail } from '@/services/payouts';
|
import { useNursePayoutDetail } from '@/services/payouts';
|
||||||
import type { PayoutBatchStatus, PayoutStatus } from '@/services/payouts/types';
|
import type { PayoutBatchStatus, PayoutStatus } from '@/services/payouts/types';
|
||||||
|
|
||||||
@@ -34,7 +34,6 @@ const BATCH_STATUS_KIND: Record<PayoutBatchStatus, StatusKind> = {
|
|||||||
*/
|
*/
|
||||||
export default function NursePayoutDetailPage() {
|
export default function NursePayoutDetailPage() {
|
||||||
const t = useTranslations('payouts');
|
const t = useTranslations('payouts');
|
||||||
const tc = useTranslations('common');
|
|
||||||
const locale = useLocale();
|
const locale = useLocale();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const params = useParams<{ id: string }>();
|
const params = useParams<{ id: string }>();
|
||||||
@@ -50,7 +49,7 @@ export default function NursePayoutDetailPage() {
|
|||||||
color="primary"
|
color="primary"
|
||||||
startIcon="wallet"
|
startIcon="wallet"
|
||||||
onClick={() => router.push(`/${locale}${ROUTES.NURSE_EARNINGS_PAYOUTS}`)}
|
onClick={() => router.push(`/${locale}${ROUTES.NURSE_EARNINGS_PAYOUTS}`)}
|
||||||
sx={{ m: 0, alignSelf: 'flex-start' }}
|
sx={{ alignSelf: 'flex-start' }}
|
||||||
>
|
>
|
||||||
{t('back_to_history')}
|
{t('back_to_history')}
|
||||||
</AppButton>
|
</AppButton>
|
||||||
@@ -159,9 +158,7 @@ export default function NursePayoutDetailPage() {
|
|||||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||||
{t('amount_transferred_label')}
|
{t('amount_transferred_label')}
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography variant="body2" sx={{ fontWeight: 700 }}>
|
<Money amountIrr={data.amountIrr} size="sm" hideUnit sx={{ fontWeight: 700 }} />
|
||||||
{formatIrrToToman(data.amountIrr, locale)}
|
|
||||||
</Typography>
|
|
||||||
</Stack>
|
</Stack>
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|
||||||
@@ -184,16 +181,13 @@ export default function NursePayoutDetailPage() {
|
|||||||
<Typography variant="body2" sx={{ fontWeight: 700 }}>
|
<Typography variant="body2" sx={{ fontWeight: 700 }}>
|
||||||
{t('booking_ref', { id: link.bookingId })}
|
{t('booking_ref', { id: link.bookingId })}
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
|
<Money amountIrr={link.payoutAmountIrr} size="sm" tone="muted" />
|
||||||
{formatIrrToToman(link.payoutAmountIrr, locale)} {tc('currency_toman')}
|
|
||||||
</Typography>
|
|
||||||
</Stack>
|
</Stack>
|
||||||
<AppButton
|
<AppButton
|
||||||
variant="text"
|
variant="text"
|
||||||
color="primary"
|
color="primary"
|
||||||
endIcon="visits"
|
endIcon="visits"
|
||||||
onClick={() => router.push(`/${locale}${nurseBookingDetailPath(link.bookingId)}`)}
|
onClick={() => router.push(`/${locale}${nurseBookingDetailPath(link.bookingId)}`)}
|
||||||
sx={{ m: 0 }}
|
|
||||||
>
|
>
|
||||||
{t('view_booking')}
|
{t('view_booking')}
|
||||||
</AppButton>
|
</AppButton>
|
||||||
@@ -213,7 +207,7 @@ const MetaLine: FunctionComponent<{ label: string; children: ReactNode }> = ({ l
|
|||||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||||
{label}
|
{label}
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography variant="body2" sx={{ fontWeight: 600 }}>
|
<Typography variant="body2" sx={{ fontWeight: 500 }}>
|
||||||
{children}
|
{children}
|
||||||
</Typography>
|
</Typography>
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|||||||
@@ -2,9 +2,10 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { useLocale, useTranslations } from 'next-intl';
|
import { useLocale, useTranslations } from 'next-intl';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import { Box, Paper, Skeleton, Stack, Typography } from '@mui/material';
|
import { Box, Skeleton, Stack, Typography } from '@mui/material';
|
||||||
import { AppButton, AppIcon, PayoutHistoryRow } from '@/components';
|
import { AppButton, EmptyState, ErrorState, PayoutHistoryRow } from '@/components';
|
||||||
import { nursePayoutDetailPath, ROUTES } from '@/constants';
|
import { nursePayoutDetailPath, ROUTES } from '@/constants';
|
||||||
|
import { formatNumber } from '@/utils';
|
||||||
import { PAYOUTS_PAGE_SIZE } from '@/services/payouts/constants';
|
import { PAYOUTS_PAGE_SIZE } from '@/services/payouts/constants';
|
||||||
import { useNursePayoutHistory } from '@/services/payouts';
|
import { useNursePayoutHistory } from '@/services/payouts';
|
||||||
|
|
||||||
@@ -25,7 +26,7 @@ export default function NursePayoutHistoryPage() {
|
|||||||
const items = history.data?.items ?? [];
|
const items = history.data?.items ?? [];
|
||||||
const total = history.data?.total ?? 0;
|
const total = history.data?.total ?? 0;
|
||||||
const pageCount = Math.max(1, Math.ceil(total / PAYOUTS_PAGE_SIZE));
|
const pageCount = Math.max(1, Math.ceil(total / PAYOUTS_PAGE_SIZE));
|
||||||
const fmt = (n: number) => new Intl.NumberFormat(locale === 'fa' ? 'fa-IR' : 'en-US').format(n);
|
const fmt = (n: number) => formatNumber(n, locale);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
|
||||||
@@ -35,7 +36,7 @@ export default function NursePayoutHistoryPage() {
|
|||||||
color="primary"
|
color="primary"
|
||||||
startIcon="earnings"
|
startIcon="earnings"
|
||||||
onClick={() => router.push(`/${locale}${ROUTES.NURSE_EARNINGS}`)}
|
onClick={() => router.push(`/${locale}${ROUTES.NURSE_EARNINGS}`)}
|
||||||
sx={{ m: 0, alignSelf: 'flex-start' }}
|
sx={{ alignSelf: 'flex-start' }}
|
||||||
>
|
>
|
||||||
{t('back_to_earnings')}
|
{t('back_to_earnings')}
|
||||||
</AppButton>
|
</AppButton>
|
||||||
@@ -54,24 +55,9 @@ export default function NursePayoutHistoryPage() {
|
|||||||
))}
|
))}
|
||||||
</Stack>
|
</Stack>
|
||||||
) : history.isError ? (
|
) : history.isError ? (
|
||||||
<Paper elevation={0} sx={{ p: 4, textAlign: 'center', border: '1px solid', borderColor: 'divider', borderRadius: 2 }}>
|
<ErrorState message={t('history_error')} retryLabel={t('retry')} onRetry={() => history.refetch()} />
|
||||||
<Typography variant="body2" sx={{ color: 'text.secondary', mb: 1.5 }}>
|
|
||||||
{t('history_error')}
|
|
||||||
</Typography>
|
|
||||||
<AppButton variant="outlined" color="primary" startIcon="refresh" onClick={() => history.refetch()} sx={{ m: 0 }}>
|
|
||||||
{t('retry')}
|
|
||||||
</AppButton>
|
|
||||||
</Paper>
|
|
||||||
) : items.length === 0 ? (
|
) : items.length === 0 ? (
|
||||||
<Paper elevation={0} sx={{ p: 4, textAlign: 'center', border: '1px dashed', borderColor: 'divider', borderRadius: 2 }}>
|
<EmptyState icon="earnings" title={t('history_empty_title')} body={t('history_empty_body')} />
|
||||||
<AppIcon icon="earnings" size={40} color="var(--bal-text-secondary)" />
|
|
||||||
<Typography variant="subtitle1" sx={{ fontWeight: 700, mt: 1 }}>
|
|
||||||
{t('history_empty_title')}
|
|
||||||
</Typography>
|
|
||||||
<Typography variant="body2" sx={{ color: 'text.secondary', mt: 0.5 }}>
|
|
||||||
{t('history_empty_body')}
|
|
||||||
</Typography>
|
|
||||||
</Paper>
|
|
||||||
) : (
|
) : (
|
||||||
<Stack sx={{ gap: 2 }}>
|
<Stack sx={{ gap: 2 }}>
|
||||||
{items.map((item) => (
|
{items.map((item) => (
|
||||||
@@ -86,13 +72,13 @@ export default function NursePayoutHistoryPage() {
|
|||||||
|
|
||||||
{pageCount > 1 ? (
|
{pageCount > 1 ? (
|
||||||
<Stack direction="row" sx={{ gap: 1, alignItems: 'center', justifyContent: 'center' }}>
|
<Stack direction="row" sx={{ gap: 1, alignItems: 'center', justifyContent: 'center' }}>
|
||||||
<AppButton variant="text" color="primary" onClick={() => setPage((p) => Math.max(1, p - 1))} disabled={page <= 1} sx={{ m: 0 }}>
|
<AppButton variant="text" color="primary" onClick={() => setPage((p) => Math.max(1, p - 1))} disabled={page <= 1}>
|
||||||
{t('page_prev')}
|
{t('page_prev')}
|
||||||
</AppButton>
|
</AppButton>
|
||||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||||
{t('page_indicator', { page: fmt(page), total: fmt(pageCount) })}
|
{t('page_indicator', { page: fmt(page), total: fmt(pageCount) })}
|
||||||
</Typography>
|
</Typography>
|
||||||
<AppButton variant="text" color="primary" onClick={() => setPage((p) => Math.min(pageCount, p + 1))} disabled={page >= pageCount} sx={{ m: 0 }}>
|
<AppButton variant="text" color="primary" onClick={() => setPage((p) => Math.min(pageCount, p + 1))} disabled={page >= pageCount}>
|
||||||
{t('page_next')}
|
{t('page_next')}
|
||||||
</AppButton>
|
</AppButton>
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import SidebarShellSkeleton from '../_chrome/SidebarShellSkeleton';
|
||||||
|
|
||||||
|
export default function Loading() {
|
||||||
|
return <SidebarShellSkeleton />;
|
||||||
|
}
|
||||||
@@ -1,6 +1,13 @@
|
|||||||
|
import type { Metadata } from 'next';
|
||||||
import { getTranslations } from 'next-intl/server';
|
import { getTranslations } from 'next-intl/server';
|
||||||
import { PlaceholderScreen } from '@/components';
|
import { PlaceholderScreen } from '@/components';
|
||||||
|
|
||||||
|
export async function generateMetadata({ params }: { params: Promise<{ locale: string }> }): Promise<Metadata> {
|
||||||
|
const { locale } = await params;
|
||||||
|
const t = await getTranslations({ locale, namespace: 'nav' });
|
||||||
|
return { title: t('dashboard') };
|
||||||
|
}
|
||||||
|
|
||||||
export default async function NurseDashboardPage() {
|
export default async function NurseDashboardPage() {
|
||||||
const t = await getTranslations('nav');
|
const t = await getTranslations('nav');
|
||||||
const tShell = await getTranslations('shell');
|
const tShell = await getTranslations('shell');
|
||||||
|
|||||||
@@ -41,7 +41,10 @@ const NurseProfileForm: FunctionComponent<{ initial: NurseProfile | null }> = ({
|
|||||||
const file = event.target.files?.[0];
|
const file = event.target.files?.[0];
|
||||||
event.target.value = '';
|
event.target.value = '';
|
||||||
if (!file) return;
|
if (!file) return;
|
||||||
uploadAvatar.mutate(file, { onSuccess: (result) => setAvatarUrl(result.url) });
|
uploadAvatar.mutate(file, {
|
||||||
|
onSuccess: (result) => setAvatarUrl(result.url),
|
||||||
|
onError: () => enqueueSnackbar(t('avatar_upload_error'), { variant: 'error' }),
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSave = () => {
|
const handleSave = () => {
|
||||||
@@ -60,7 +63,10 @@ const NurseProfileForm: FunctionComponent<{ initial: NurseProfile | null }> = ({
|
|||||||
specializationsJson: initial?.specializationsJson ?? '[]',
|
specializationsJson: initial?.specializationsJson ?? '[]',
|
||||||
avatarUrl,
|
avatarUrl,
|
||||||
},
|
},
|
||||||
{ onSuccess: () => enqueueSnackbar(t('saved'), { variant: 'success' }) },
|
{
|
||||||
|
onSuccess: () => enqueueSnackbar(t('saved'), { variant: 'success' }),
|
||||||
|
onError: () => enqueueSnackbar(t('save_error'), { variant: 'error' }),
|
||||||
|
},
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -100,7 +106,7 @@ const NurseProfileForm: FunctionComponent<{ initial: NurseProfile | null }> = ({
|
|||||||
color="primary"
|
color="primary"
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
to={`/${locale}${ROUTES.NURSE_VERIFICATION}`}
|
to={`/${locale}${ROUTES.NURSE_VERIFICATION}`}
|
||||||
sx={{ m: 0, alignSelf: 'flex-start' }}
|
sx={{ alignSelf: 'flex-start' }}
|
||||||
>
|
>
|
||||||
{t('unverified_cta')}
|
{t('unverified_cta')}
|
||||||
</AppButton>
|
</AppButton>
|
||||||
@@ -126,7 +132,7 @@ const NurseProfileForm: FunctionComponent<{ initial: NurseProfile | null }> = ({
|
|||||||
startIcon="camera"
|
startIcon="camera"
|
||||||
onClick={pickFile}
|
onClick={pickFile}
|
||||||
disabled={uploadAvatar.isPending}
|
disabled={uploadAvatar.isPending}
|
||||||
sx={{ m: 0, mt: 0.5, alignSelf: 'flex-start' }}
|
sx={{ mt: 0.5, alignSelf: 'flex-start' }}
|
||||||
>
|
>
|
||||||
{uploadAvatar.isPending ? t('uploading') : t('upload')}
|
{uploadAvatar.isPending ? t('uploading') : t('upload')}
|
||||||
</AppButton>
|
</AppButton>
|
||||||
@@ -166,7 +172,7 @@ const NurseProfileForm: FunctionComponent<{ initial: NurseProfile | null }> = ({
|
|||||||
variant="contained"
|
variant="contained"
|
||||||
onClick={handleSave}
|
onClick={handleSave}
|
||||||
disabled={upsert.isPending}
|
disabled={upsert.isPending}
|
||||||
sx={{ m: 0, alignSelf: 'flex-start' }}
|
sx={{ alignSelf: 'flex-start' }}
|
||||||
>
|
>
|
||||||
{upsert.isPending ? tc('saving') : t('save')}
|
{upsert.isPending ? tc('saving') : t('save')}
|
||||||
</AppButton>
|
</AppButton>
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ import {
|
|||||||
import { AppButton, AppIcon, CountdownTimer, PriceDisplay, StatusChip } from '@/components';
|
import { AppButton, AppIcon, CountdownTimer, PriceDisplay, StatusChip } from '@/components';
|
||||||
import { ROUTES } from '@/constants';
|
import { ROUTES } from '@/constants';
|
||||||
import { ApiError } from '@/lib/api/errors';
|
import { ApiError } from '@/lib/api/errors';
|
||||||
import { formatShamsiDate } from '@/utils';
|
import { formatShamsiDate, localeTag } from '@/utils';
|
||||||
import {
|
import {
|
||||||
useAcceptBookingRequest,
|
useAcceptBookingRequest,
|
||||||
useBookingRequest,
|
useBookingRequest,
|
||||||
@@ -66,7 +66,7 @@ export default function NurseRequestDetailPage() {
|
|||||||
<Typography variant="body2" sx={{ color: 'text.secondary', mb: 2 }}>
|
<Typography variant="body2" sx={{ color: 'text.secondary', mb: 2 }}>
|
||||||
{t('not_found_body')}
|
{t('not_found_body')}
|
||||||
</Typography>
|
</Typography>
|
||||||
<AppButton variant="outlined" color="primary" onClick={() => router.push(`/${locale}${ROUTES.NURSE_REQUESTS}`)} sx={{ m: 0 }}>
|
<AppButton variant="outlined" color="primary" onClick={() => router.push(`/${locale}${ROUTES.NURSE_REQUESTS}`)}>
|
||||||
{t('inbox_title')}
|
{t('inbox_title')}
|
||||||
</AppButton>
|
</AppButton>
|
||||||
</Paper>
|
</Paper>
|
||||||
@@ -117,7 +117,7 @@ export default function NurseRequestDetailPage() {
|
|||||||
|
|
||||||
const startDate = new Date(`${request.requestedDate}T${request.requestedTimeStart}`);
|
const startDate = new Date(`${request.requestedDate}T${request.requestedTimeStart}`);
|
||||||
const endDate = new Date(`${request.requestedDate}T${request.requestedTimeEnd}`);
|
const endDate = new Date(`${request.requestedDate}T${request.requestedTimeEnd}`);
|
||||||
const timeFmt = new Intl.DateTimeFormat(locale === 'fa' ? 'fa-IR' : 'en-US', { hour: '2-digit', minute: '2-digit' });
|
const timeFmt = new Intl.DateTimeFormat(localeTag(locale), { hour: '2-digit', minute: '2-digit' });
|
||||||
const whenLabel = `${formatShamsiDate(startDate, locale)} · ${timeFmt.format(startDate)} – ${timeFmt.format(endDate)}`;
|
const whenLabel = `${formatShamsiDate(startDate, locale)} · ${timeFmt.format(startDate)} – ${timeFmt.format(endDate)}`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -132,13 +132,13 @@ export default function NurseRequestDetailPage() {
|
|||||||
<Paper elevation={0} sx={{ p: 2.5, border: '1px solid', borderColor: 'divider', borderRadius: 2 }}>
|
<Paper elevation={0} sx={{ p: 2.5, border: '1px solid', borderColor: 'divider', borderRadius: 2 }}>
|
||||||
<Stack sx={{ gap: 1.5 }}>
|
<Stack sx={{ gap: 1.5 }}>
|
||||||
<DetailRow caption={t('summary_patient')}>
|
<DetailRow caption={t('summary_patient')}>
|
||||||
<Typography variant="body2" sx={{ fontWeight: 600 }}>
|
<Typography variant="body2" sx={{ fontWeight: 500 }}>
|
||||||
{request.patientName}
|
{request.patientName}
|
||||||
</Typography>
|
</Typography>
|
||||||
</DetailRow>
|
</DetailRow>
|
||||||
<DetailRow caption={t('summary_service')}>
|
<DetailRow caption={t('summary_service')}>
|
||||||
<Stack sx={{ gap: 0.25, alignItems: 'flex-end' }}>
|
<Stack sx={{ gap: 0.25, alignItems: 'flex-end' }}>
|
||||||
<Typography variant="body2" sx={{ fontWeight: 600 }}>
|
<Typography variant="body2" sx={{ fontWeight: 500 }}>
|
||||||
{request.variantLabel}
|
{request.variantLabel}
|
||||||
</Typography>
|
</Typography>
|
||||||
{request.variantPrice ? (
|
{request.variantPrice ? (
|
||||||
@@ -147,12 +147,12 @@ export default function NurseRequestDetailPage() {
|
|||||||
</Stack>
|
</Stack>
|
||||||
</DetailRow>
|
</DetailRow>
|
||||||
<DetailRow caption={t('location_label')}>
|
<DetailRow caption={t('location_label')}>
|
||||||
<Typography variant="body2" sx={{ fontWeight: 600, textAlign: 'end' }}>
|
<Typography variant="body2" sx={{ fontWeight: 500, textAlign: 'end' }}>
|
||||||
{location}
|
{location}
|
||||||
</Typography>
|
</Typography>
|
||||||
</DetailRow>
|
</DetailRow>
|
||||||
<DetailRow caption={t('summary_when')}>
|
<DetailRow caption={t('summary_when')}>
|
||||||
<Typography variant="body2" sx={{ fontWeight: 600, textAlign: 'end' }}>
|
<Typography variant="body2" sx={{ fontWeight: 500, textAlign: 'end' }}>
|
||||||
{whenLabel}
|
{whenLabel}
|
||||||
</Typography>
|
</Typography>
|
||||||
</DetailRow>
|
</DetailRow>
|
||||||
@@ -161,7 +161,7 @@ export default function NurseRequestDetailPage() {
|
|||||||
<Chip
|
<Chip
|
||||||
size="small"
|
size="small"
|
||||||
label={t(`gender_${request.requiredCaregiverGender}`)}
|
label={t(`gender_${request.requiredCaregiverGender}`)}
|
||||||
sx={{ bgcolor: 'var(--bal-primary-soft)', color: 'var(--bal-primary)', fontWeight: 600 }}
|
sx={{ bgcolor: 'var(--bal-primary-soft)', color: 'var(--bal-primary)', fontWeight: 500 }}
|
||||||
/>
|
/>
|
||||||
</DetailRow>
|
</DetailRow>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -202,7 +202,7 @@ export default function NurseRequestDetailPage() {
|
|||||||
startIcon="verified"
|
startIcon="verified"
|
||||||
disabled={acceptRequest.isPending}
|
disabled={acceptRequest.isPending}
|
||||||
onClick={handleAccept}
|
onClick={handleAccept}
|
||||||
sx={{ m: 0, flex: 1, py: 1.25 }}
|
sx={{ flex: 1, py: 1.25 }}
|
||||||
>
|
>
|
||||||
{acceptRequest.isPending ? t('accepting') : t('accept')}
|
{acceptRequest.isPending ? t('accepting') : t('accept')}
|
||||||
</AppButton>
|
</AppButton>
|
||||||
@@ -212,7 +212,7 @@ export default function NurseRequestDetailPage() {
|
|||||||
startIcon="rejected"
|
startIcon="rejected"
|
||||||
disabled={acceptRequest.isPending}
|
disabled={acceptRequest.isPending}
|
||||||
onClick={() => setRejectOpen(true)}
|
onClick={() => setRejectOpen(true)}
|
||||||
sx={{ m: 0, flex: 1, py: 1.25 }}
|
sx={{ flex: 1, py: 1.25 }}
|
||||||
>
|
>
|
||||||
{t('reject')}
|
{t('reject')}
|
||||||
</AppButton>
|
</AppButton>
|
||||||
|
|||||||
@@ -2,9 +2,9 @@
|
|||||||
import { useLocale, useTranslations } from 'next-intl';
|
import { useLocale, useTranslations } from 'next-intl';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import { Box, Chip, Paper, Skeleton, Stack, Typography } from '@mui/material';
|
import { Box, Chip, Paper, Skeleton, Stack, Typography } from '@mui/material';
|
||||||
import { AppButton, AppIcon, CountdownTimer } from '@/components';
|
import { AppButton, CountdownTimer, EmptyState, ErrorState } from '@/components';
|
||||||
import { ROUTES } from '@/constants';
|
import { ROUTES } from '@/constants';
|
||||||
import { formatShamsiDate } from '@/utils';
|
import { formatShamsiDate, localeTag } from '@/utils';
|
||||||
import { useNurseRequestInbox } from '@/services/bookingRequests';
|
import { useNurseRequestInbox } from '@/services/bookingRequests';
|
||||||
import type { BookingRequestListItem } from '@/services/bookingRequests/types';
|
import type { BookingRequestListItem } from '@/services/bookingRequests/types';
|
||||||
|
|
||||||
@@ -16,7 +16,8 @@ import type { BookingRequestListItem } from '@/services/bookingRequests/types';
|
|||||||
*/
|
*/
|
||||||
export default function NurseRequestsPage() {
|
export default function NurseRequestsPage() {
|
||||||
const t = useTranslations('booking');
|
const t = useTranslations('booking');
|
||||||
const { data, isLoading } = useNurseRequestInbox();
|
const tc = useTranslations('common');
|
||||||
|
const { data, isLoading, isError, refetch } = useNurseRequestInbox();
|
||||||
const items = data?.items ?? [];
|
const items = data?.items ?? [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -36,13 +37,10 @@ export default function NurseRequestsPage() {
|
|||||||
<Skeleton key={key} variant="rounded" height={140} />
|
<Skeleton key={key} variant="rounded" height={140} />
|
||||||
))}
|
))}
|
||||||
</Stack>
|
</Stack>
|
||||||
|
) : isError ? (
|
||||||
|
<ErrorState message={t('inbox_error')} retryLabel={tc('retry')} onRetry={() => refetch()} />
|
||||||
) : items.length === 0 ? (
|
) : items.length === 0 ? (
|
||||||
<Paper elevation={0} sx={{ p: 4, textAlign: 'center', border: '1px dashed', borderColor: 'divider', borderRadius: 2 }}>
|
<EmptyState icon="requests" title={t('inbox_empty')} />
|
||||||
<AppIcon icon="requests" size={40} color="var(--bal-text-secondary)" />
|
|
||||||
<Typography variant="body2" sx={{ color: 'text.secondary', mt: 1 }}>
|
|
||||||
{t('inbox_empty')}
|
|
||||||
</Typography>
|
|
||||||
</Paper>
|
|
||||||
) : (
|
) : (
|
||||||
<Stack sx={{ gap: 2 }}>
|
<Stack sx={{ gap: 2 }}>
|
||||||
{items.map((item) => (
|
{items.map((item) => (
|
||||||
@@ -61,7 +59,7 @@ function InboxCard({ item }: { item: BookingRequestListItem }) {
|
|||||||
|
|
||||||
const startDate = new Date(`${item.requestedDate}T${item.requestedTimeStart}`);
|
const startDate = new Date(`${item.requestedDate}T${item.requestedTimeStart}`);
|
||||||
const endDate = new Date(`${item.requestedDate}T${item.requestedTimeEnd}`);
|
const endDate = new Date(`${item.requestedDate}T${item.requestedTimeEnd}`);
|
||||||
const timeFmt = new Intl.DateTimeFormat(locale === 'fa' ? 'fa-IR' : 'en-US', { hour: '2-digit', minute: '2-digit' });
|
const timeFmt = new Intl.DateTimeFormat(localeTag(locale), { hour: '2-digit', minute: '2-digit' });
|
||||||
const whenLabel = `${formatShamsiDate(startDate, locale)} · ${timeFmt.format(startDate)} – ${timeFmt.format(endDate)}`;
|
const whenLabel = `${formatShamsiDate(startDate, locale)} · ${timeFmt.format(startDate)} – ${timeFmt.format(endDate)}`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -84,7 +82,7 @@ function InboxCard({ item }: { item: BookingRequestListItem }) {
|
|||||||
<Chip
|
<Chip
|
||||||
size="small"
|
size="small"
|
||||||
label={t('required_gender_chip', { gender: t(`gender_${item.requiredCaregiverGender}`) })}
|
label={t('required_gender_chip', { gender: t(`gender_${item.requiredCaregiverGender}`) })}
|
||||||
sx={{ bgcolor: 'var(--bal-primary-soft)', color: 'var(--bal-primary)', fontWeight: 600 }}
|
sx={{ bgcolor: 'var(--bal-primary-soft)', color: 'var(--bal-primary)', fontWeight: 500 }}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -105,7 +103,7 @@ function InboxCard({ item }: { item: BookingRequestListItem }) {
|
|||||||
color="primary"
|
color="primary"
|
||||||
endIcon="requests"
|
endIcon="requests"
|
||||||
onClick={() => router.push(`/${locale}${ROUTES.NURSE_REQUESTS}/${item.id}`)}
|
onClick={() => router.push(`/${locale}${ROUTES.NURSE_REQUESTS}/${item.id}`)}
|
||||||
sx={{ m: 0, alignSelf: 'flex-start' }}
|
sx={{ alignSelf: 'flex-start' }}
|
||||||
>
|
>
|
||||||
{t('open_detail')}
|
{t('open_detail')}
|
||||||
</AppButton>
|
</AppButton>
|
||||||
|
|||||||
@@ -8,12 +8,11 @@ import {
|
|||||||
DialogActions,
|
DialogActions,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
Paper,
|
|
||||||
Skeleton,
|
Skeleton,
|
||||||
Stack,
|
Stack,
|
||||||
Typography,
|
Typography,
|
||||||
} from '@mui/material';
|
} from '@mui/material';
|
||||||
import { AppButton, AppIcon, VariantCard } from '@/components';
|
import { AppButton, EmptyState, ErrorState, VariantCard } from '@/components';
|
||||||
import { useMyVariants, useSetVariantActive } from '@/services/catalog';
|
import { useMyVariants, useSetVariantActive } from '@/services/catalog';
|
||||||
import type { NurseServiceVariant } from '@/services/catalog/types';
|
import type { NurseServiceVariant } from '@/services/catalog/types';
|
||||||
import PublishGate from './PublishGate';
|
import PublishGate from './PublishGate';
|
||||||
@@ -34,12 +33,12 @@ const MyServicesList: FunctionComponent<MyServicesListProps> = ({ onAdd, onEdit
|
|||||||
const tc = useTranslations('common');
|
const tc = useTranslations('common');
|
||||||
const { enqueueSnackbar } = useSnackbar();
|
const { enqueueSnackbar } = useSnackbar();
|
||||||
|
|
||||||
const { data, isLoading } = useMyVariants();
|
const { data, isLoading, isError, refetch } = useMyVariants();
|
||||||
const setActive = useSetVariantActive();
|
const setActive = useSetVariantActive();
|
||||||
const [deactivateTarget, setDeactivateTarget] = useState<NurseServiceVariant | null>(null);
|
const [deactivateTarget, setDeactivateTarget] = useState<NurseServiceVariant | null>(null);
|
||||||
|
|
||||||
const variants = data?.items ?? [];
|
const variants = data?.items ?? [];
|
||||||
const isEmpty = !isLoading && variants.length === 0;
|
const isEmpty = !isLoading && !isError && variants.length === 0;
|
||||||
|
|
||||||
const toggleActive = (variant: NurseServiceVariant) => {
|
const toggleActive = (variant: NurseServiceVariant) => {
|
||||||
// Deactivating is guarded by a confirm; reactivating is safe, so it fires immediately.
|
// Deactivating is guarded by a confirm; reactivating is safe, so it fires immediately.
|
||||||
@@ -80,8 +79,8 @@ const MyServicesList: FunctionComponent<MyServicesListProps> = ({ onAdd, onEdit
|
|||||||
{t('subtitle')}
|
{t('subtitle')}
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
{!isEmpty && !isLoading ? (
|
{!isEmpty && !isLoading && !isError ? (
|
||||||
<AppButton color="primary" variant="contained" startIcon="add" onClick={onAdd} sx={{ m: 0, flexShrink: 0 }}>
|
<AppButton color="primary" variant="contained" startIcon="add" onClick={onAdd} sx={{ flexShrink: 0 }}>
|
||||||
{t('add')}
|
{t('add')}
|
||||||
</AppButton>
|
</AppButton>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -95,32 +94,19 @@ const MyServicesList: FunctionComponent<MyServicesListProps> = ({ onAdd, onEdit
|
|||||||
<Skeleton key={key} variant="rounded" height={150} sx={{ borderRadius: 2 }} />
|
<Skeleton key={key} variant="rounded" height={150} sx={{ borderRadius: 2 }} />
|
||||||
))}
|
))}
|
||||||
</Stack>
|
</Stack>
|
||||||
|
) : isError ? (
|
||||||
|
<ErrorState message={t('list_error')} retryLabel={tc('retry')} onRetry={() => refetch()} />
|
||||||
) : isEmpty ? (
|
) : isEmpty ? (
|
||||||
<Paper
|
<EmptyState
|
||||||
elevation={0}
|
icon="services"
|
||||||
sx={{
|
title={t('empty_title')}
|
||||||
p: 4,
|
body={t('empty_body')}
|
||||||
textAlign: 'center',
|
action={
|
||||||
border: '1px dashed',
|
<AppButton color="primary" variant="contained" startIcon="add" onClick={onAdd}>
|
||||||
borderColor: 'divider',
|
{t('add')}
|
||||||
borderRadius: 2,
|
</AppButton>
|
||||||
display: 'flex',
|
}
|
||||||
flexDirection: 'column',
|
/>
|
||||||
alignItems: 'center',
|
|
||||||
gap: 1.5,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<AppIcon icon="services" size={40} color="var(--bal-primary)" />
|
|
||||||
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
|
|
||||||
{t('empty_title')}
|
|
||||||
</Typography>
|
|
||||||
<Typography variant="body2" sx={{ color: 'text.secondary', maxWidth: 420 }}>
|
|
||||||
{t('empty_body')}
|
|
||||||
</Typography>
|
|
||||||
<AppButton color="primary" variant="contained" startIcon="add" onClick={onAdd} sx={{ mt: 1 }}>
|
|
||||||
{t('add')}
|
|
||||||
</AppButton>
|
|
||||||
</Paper>
|
|
||||||
) : (
|
) : (
|
||||||
<Stack sx={{ gap: 1.5 }}>
|
<Stack sx={{ gap: 1.5 }}>
|
||||||
{variants.map((variant) => (
|
{variants.map((variant) => (
|
||||||
|
|||||||
@@ -62,7 +62,6 @@ const PublishGate: FunctionComponent = () => {
|
|||||||
startIcon="publish"
|
startIcon="publish"
|
||||||
disabled={!approved}
|
disabled={!approved}
|
||||||
onClick={() => enqueueSnackbar(t('publish_done'), { variant: 'success' })}
|
onClick={() => enqueueSnackbar(t('publish_done'), { variant: 'success' })}
|
||||||
sx={{ m: 0 }}
|
|
||||||
>
|
>
|
||||||
{t('publish_cta')}
|
{t('publish_cta')}
|
||||||
</AppButton>
|
</AppButton>
|
||||||
@@ -71,7 +70,6 @@ const PublishGate: FunctionComponent = () => {
|
|||||||
variant="outlined"
|
variant="outlined"
|
||||||
color="primary"
|
color="primary"
|
||||||
to={`/${locale}${ROUTES.NURSE_VERIFICATION}`}
|
to={`/${locale}${ROUTES.NURSE_VERIFICATION}`}
|
||||||
sx={{ m: 0 }}
|
|
||||||
>
|
>
|
||||||
{t('publish_complete_verification')}
|
{t('publish_complete_verification')}
|
||||||
</AppButton>
|
</AppButton>
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import {
|
|||||||
ToggleButtonGroup,
|
ToggleButtonGroup,
|
||||||
Typography,
|
Typography,
|
||||||
} from '@mui/material';
|
} from '@mui/material';
|
||||||
import { AppButton, AppLoading, CategoryTile, PriceDisplay, StepperHeader } from '@/components';
|
import { AppButton, AppLoading, CategoryTile, ErrorState, PriceDisplay, StepperHeader } from '@/components';
|
||||||
import { ApiError } from '@/lib/api/errors';
|
import { ApiError } from '@/lib/api/errors';
|
||||||
import { digitsOnly, rialToToman, tomanToRial } from '@/utils';
|
import { digitsOnly, rialToToman, tomanToRial } from '@/utils';
|
||||||
import {
|
import {
|
||||||
@@ -260,7 +260,7 @@ const VariantBuilder: FunctionComponent<VariantBuilderProps> = ({ initial, onDon
|
|||||||
borderInlineStartColor: 'var(--bal-warning)',
|
borderInlineStartColor: 'var(--bal-warning)',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Typography variant="body2" sx={{ color: 'var(--bal-warning)', fontWeight: 600 }}>
|
<Typography variant="body2" sx={{ color: 'var(--bal-warning)', fontWeight: 500 }}>
|
||||||
{t('duplicate_warning')}
|
{t('duplicate_warning')}
|
||||||
</Typography>
|
</Typography>
|
||||||
</Paper>
|
</Paper>
|
||||||
@@ -306,10 +306,10 @@ const VariantBuilder: FunctionComponent<VariantBuilderProps> = ({ initial, onDon
|
|||||||
{priceStep}
|
{priceStep}
|
||||||
|
|
||||||
<Stack direction="row" sx={{ gap: 1, justifyContent: 'flex-end' }}>
|
<Stack direction="row" sx={{ gap: 1, justifyContent: 'flex-end' }}>
|
||||||
<AppButton variant="text" onClick={onCancel} disabled={submitting} sx={{ m: 0 }}>
|
<AppButton variant="text" onClick={onCancel} disabled={submitting}>
|
||||||
{tc('cancel')}
|
{tc('cancel')}
|
||||||
</AppButton>
|
</AppButton>
|
||||||
<AppButton color="primary" variant="contained" onClick={submit} disabled={submitting} sx={{ m: 0 }}>
|
<AppButton color="primary" variant="contained" onClick={submit} disabled={submitting}>
|
||||||
{submitting ? tc('saving') : t('submit_save')}
|
{submitting ? tc('saving') : t('submit_save')}
|
||||||
</AppButton>
|
</AppButton>
|
||||||
</Stack>
|
</Stack>
|
||||||
@@ -350,7 +350,7 @@ const VariantBuilder: FunctionComponent<VariantBuilderProps> = ({ initial, onDon
|
|||||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||||
{t('categories_error')}
|
{t('categories_error')}
|
||||||
</Typography>
|
</Typography>
|
||||||
<AppButton variant="outlined" color="primary" onClick={() => categoriesQuery.refetch()} sx={{ m: 0 }}>
|
<AppButton variant="outlined" color="primary" onClick={() => categoriesQuery.refetch()}>
|
||||||
{tc('retry')}
|
{tc('retry')}
|
||||||
</AppButton>
|
</AppButton>
|
||||||
</Stack>
|
</Stack>
|
||||||
@@ -383,6 +383,14 @@ const VariantBuilder: FunctionComponent<VariantBuilderProps> = ({ initial, onDon
|
|||||||
|
|
||||||
{optionGroupsQuery.isLoading ? (
|
{optionGroupsQuery.isLoading ? (
|
||||||
<AppLoading />
|
<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 ? (
|
) : groups.length === 0 ? (
|
||||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||||
{t('options_none')}
|
{t('options_none')}
|
||||||
@@ -411,7 +419,7 @@ const VariantBuilder: FunctionComponent<VariantBuilderProps> = ({ initial, onDon
|
|||||||
: group.isRequired
|
: group.isRequired
|
||||||
? 'var(--bal-primary)'
|
? 'var(--bal-primary)'
|
||||||
: 'text.secondary',
|
: 'text.secondary',
|
||||||
fontWeight: 600,
|
fontWeight: 500,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</Stack>
|
</Stack>
|
||||||
@@ -435,7 +443,7 @@ const VariantBuilder: FunctionComponent<VariantBuilderProps> = ({ initial, onDon
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{optionsError && missingRequiredGroups.length > 0 ? (
|
{optionsError && missingRequiredGroups.length > 0 ? (
|
||||||
<Typography variant="body2" sx={{ color: 'var(--bal-error)', fontWeight: 600 }}>
|
<Typography variant="body2" sx={{ color: 'var(--bal-error)', fontWeight: 500 }}>
|
||||||
{t('options_incomplete')}
|
{t('options_incomplete')}
|
||||||
</Typography>
|
</Typography>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -458,7 +466,6 @@ const VariantBuilder: FunctionComponent<VariantBuilderProps> = ({ initial, onDon
|
|||||||
variant="text"
|
variant="text"
|
||||||
onClick={activeStep === 0 ? onCancel : () => setActiveStep((step) => step - 1)}
|
onClick={activeStep === 0 ? onCancel : () => setActiveStep((step) => step - 1)}
|
||||||
disabled={submitting}
|
disabled={submitting}
|
||||||
sx={{ m: 0 }}
|
|
||||||
>
|
>
|
||||||
{activeStep === 0 ? tc('cancel') : tc('back')}
|
{activeStep === 0 ? tc('cancel') : tc('back')}
|
||||||
</AppButton>
|
</AppButton>
|
||||||
@@ -469,16 +476,20 @@ const VariantBuilder: FunctionComponent<VariantBuilderProps> = ({ initial, onDon
|
|||||||
variant="contained"
|
variant="contained"
|
||||||
onClick={() => setActiveStep(1)}
|
onClick={() => setActiveStep(1)}
|
||||||
disabled={categoryId == null}
|
disabled={categoryId == null}
|
||||||
sx={{ m: 0 }}
|
|
||||||
>
|
>
|
||||||
{t('next')}
|
{t('next')}
|
||||||
</AppButton>
|
</AppButton>
|
||||||
) : activeStep === 1 ? (
|
) : activeStep === 1 ? (
|
||||||
<AppButton color="primary" variant="contained" onClick={goNextFromOptions} sx={{ m: 0 }}>
|
<AppButton
|
||||||
|
color="primary"
|
||||||
|
variant="contained"
|
||||||
|
onClick={goNextFromOptions}
|
||||||
|
disabled={optionGroupsQuery.isError}
|
||||||
|
>
|
||||||
{t('next')}
|
{t('next')}
|
||||||
</AppButton>
|
</AppButton>
|
||||||
) : (
|
) : (
|
||||||
<AppButton color="primary" variant="contained" onClick={submit} disabled={submitting} sx={{ m: 0 }}>
|
<AppButton color="primary" variant="contained" onClick={submit} disabled={submitting}>
|
||||||
{submitting ? tc('saving') : t('submit_create')}
|
{submitting ? tc('saving') : t('submit_create')}
|
||||||
</AppButton>
|
</AppButton>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { FunctionComponent } from 'react';
|
|||||||
import { useLocale, useTranslations } from 'next-intl';
|
import { useLocale, useTranslations } from 'next-intl';
|
||||||
import { Box, LinearProgress, Paper, Stack, Typography } from '@mui/material';
|
import { Box, LinearProgress, Paper, Stack, Typography } from '@mui/material';
|
||||||
import { AppButton, AppIcon, StatusChip } from '@/components';
|
import { AppButton, AppIcon, StatusChip } from '@/components';
|
||||||
|
import { formatNumber } from '@/utils';
|
||||||
import type { VerificationStatus, VerificationStep } from '@/services/verification/types';
|
import type { VerificationStatus, VerificationStep } from '@/services/verification/types';
|
||||||
import {
|
import {
|
||||||
displaySteps,
|
displaySteps,
|
||||||
@@ -46,7 +47,7 @@ const ProgressMeter: FunctionComponent<{ passed: number; total: number }> = ({ p
|
|||||||
const t = useTranslations('verification');
|
const t = useTranslations('verification');
|
||||||
const locale = useLocale();
|
const locale = useLocale();
|
||||||
const percent = total === 0 ? 0 : (passed / total) * 100;
|
const percent = total === 0 ? 0 : (passed / total) * 100;
|
||||||
const format = (value: number) => new Intl.NumberFormat(locale === 'fa' ? 'fa-IR' : 'en-US').format(value);
|
const format = (value: number) => formatNumber(value, locale);
|
||||||
return (
|
return (
|
||||||
<Paper elevation={0} sx={{ p: 2, borderRadius: 2, border: '1px solid', borderColor: 'divider' }}>
|
<Paper elevation={0} sx={{ p: 2, borderRadius: 2, border: '1px solid', borderColor: 'divider' }}>
|
||||||
<Stack direction="row" sx={{ justifyContent: 'space-between', alignItems: 'baseline', mb: 1 }}>
|
<Stack direction="row" sx={{ justifyContent: 'space-between', alignItems: 'baseline', mb: 1 }}>
|
||||||
@@ -126,7 +127,7 @@ const StepRow: FunctionComponent<{ step: VerificationStep; highlighted: boolean
|
|||||||
color="primary"
|
color="primary"
|
||||||
endIcon="edit"
|
endIcon="edit"
|
||||||
to={`/${locale}${route}`}
|
to={`/${locale}${route}`}
|
||||||
sx={{ m: 0, alignSelf: 'flex-start' }}
|
sx={{ alignSelf: 'flex-start' }}
|
||||||
>
|
>
|
||||||
{step.status === 'failed' || step.status === 'expired' ? t('row_fix') : t('row_go')}
|
{step.status === 'failed' || step.status === 'expired' ? t('row_fix') : t('row_go')}
|
||||||
</AppButton>
|
</AppButton>
|
||||||
|
|||||||
@@ -96,7 +96,7 @@ export default function CredentialsSubmitPage() {
|
|||||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||||
{t('credentials_needs_start')}
|
{t('credentials_needs_start')}
|
||||||
</Typography>
|
</Typography>
|
||||||
<AppButton color="primary" variant="contained" to={`/${locale}${ROUTES.NURSE_VERIFICATION}`} sx={{ m: 0, alignSelf: 'flex-start' }}>
|
<AppButton color="primary" variant="contained" to={`/${locale}${ROUTES.NURSE_VERIFICATION}`} sx={{ alignSelf: 'flex-start' }}>
|
||||||
{t('back_to_checklist')}
|
{t('back_to_checklist')}
|
||||||
</AppButton>
|
</AppButton>
|
||||||
</Box>
|
</Box>
|
||||||
@@ -168,7 +168,7 @@ export default function CredentialsSubmitPage() {
|
|||||||
label={t.has(`specialty_${preset}`) ? t(`specialty_${preset}`) : preset}
|
label={t.has(`specialty_${preset}`) ? t(`specialty_${preset}`) : preset}
|
||||||
onClick={() => toggleSpecialty(preset)}
|
onClick={() => toggleSpecialty(preset)}
|
||||||
sx={{
|
sx={{
|
||||||
fontWeight: 600,
|
fontWeight: 500,
|
||||||
backgroundColor: selected ? 'var(--bal-primary)' : 'var(--bal-primary-soft)',
|
backgroundColor: selected ? 'var(--bal-primary)' : 'var(--bal-primary-soft)',
|
||||||
color: selected ? 'var(--bal-primary-contrast)' : 'var(--bal-primary)',
|
color: selected ? 'var(--bal-primary-contrast)' : 'var(--bal-primary)',
|
||||||
}}
|
}}
|
||||||
@@ -182,7 +182,7 @@ export default function CredentialsSubmitPage() {
|
|||||||
key={value}
|
key={value}
|
||||||
label={value}
|
label={value}
|
||||||
onDelete={() => toggleSpecialty(value)}
|
onDelete={() => toggleSpecialty(value)}
|
||||||
sx={{ fontWeight: 600, backgroundColor: 'var(--bal-primary)', color: 'var(--bal-primary-contrast)' }}
|
sx={{ fontWeight: 500, backgroundColor: 'var(--bal-primary)', color: 'var(--bal-primary-contrast)' }}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</Stack>
|
</Stack>
|
||||||
@@ -199,7 +199,7 @@ export default function CredentialsSubmitPage() {
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<AppButton variant="outlined" color="primary" startIcon="add" onClick={addCustomSpecialty} sx={{ m: 0 }}>
|
<AppButton variant="outlined" color="primary" startIcon="add" onClick={addCustomSpecialty}>
|
||||||
{t('specialty_add')}
|
{t('specialty_add')}
|
||||||
</AppButton>
|
</AppButton>
|
||||||
</Stack>
|
</Stack>
|
||||||
@@ -253,11 +253,10 @@ export default function CredentialsSubmitPage() {
|
|||||||
startIcon="license"
|
startIcon="license"
|
||||||
onClick={handleSubmit}
|
onClick={handleSubmit}
|
||||||
disabled={submitCredentials.isPending || !anyUploaded}
|
disabled={submitCredentials.isPending || !anyUploaded}
|
||||||
sx={{ m: 0 }}
|
|
||||||
>
|
>
|
||||||
{submitCredentials.isPending ? t('credentials_submitting') : t('credentials_submit')}
|
{submitCredentials.isPending ? t('credentials_submitting') : t('credentials_submit')}
|
||||||
</AppButton>
|
</AppButton>
|
||||||
<AppButton variant="text" color="primary" to={`/${locale}${ROUTES.NURSE_VERIFICATION}`} sx={{ m: 0 }}>
|
<AppButton variant="text" color="primary" to={`/${locale}${ROUTES.NURSE_VERIFICATION}`}>
|
||||||
{t('back_to_checklist')}
|
{t('back_to_checklist')}
|
||||||
</AppButton>
|
</AppButton>
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|||||||
@@ -143,11 +143,10 @@ export default function IdentitySubmitPage() {
|
|||||||
startIcon="identity"
|
startIcon="identity"
|
||||||
onClick={handleSubmit}
|
onClick={handleSubmit}
|
||||||
disabled={!canSubmit}
|
disabled={!canSubmit}
|
||||||
sx={{ m: 0 }}
|
|
||||||
>
|
>
|
||||||
{submitIdentity.isPending ? t('identity_submitting') : t('identity_submit')}
|
{submitIdentity.isPending ? t('identity_submitting') : t('identity_submit')}
|
||||||
</AppButton>
|
</AppButton>
|
||||||
<AppButton variant="text" color="primary" to={`/${locale}${ROUTES.NURSE_VERIFICATION}`} sx={{ m: 0 }}>
|
<AppButton variant="text" color="primary" to={`/${locale}${ROUTES.NURSE_VERIFICATION}`}>
|
||||||
{t('back_to_checklist')}
|
{t('back_to_checklist')}
|
||||||
</AppButton>
|
</AppButton>
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { useLocale, useTranslations } from 'next-intl';
|
|||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import { useQueryClient } from '@tanstack/react-query';
|
import { useQueryClient } from '@tanstack/react-query';
|
||||||
import { Box, Paper, Skeleton, Stack, Typography } from '@mui/material';
|
import { Box, Paper, Skeleton, Stack, Typography } from '@mui/material';
|
||||||
import { AppAlert, AppButton, AppIcon } from '@/components';
|
import { AppAlert, AppButton, AppIcon, EmptyState } from '@/components';
|
||||||
import { ROUTES } from '@/constants';
|
import { ROUTES } from '@/constants';
|
||||||
import { useStartVerification, useVerificationStatus } from '@/services/verification';
|
import { useStartVerification, useVerificationStatus } from '@/services/verification';
|
||||||
import { verificationKeys } from '@/services/verification/keys';
|
import { verificationKeys } from '@/services/verification/keys';
|
||||||
@@ -65,7 +65,7 @@ export default function NurseVerificationPage() {
|
|||||||
<AppAlert
|
<AppAlert
|
||||||
severity="error"
|
severity="error"
|
||||||
action={
|
action={
|
||||||
<AppButton variant="text" color="inherit" onClick={() => refetch()} sx={{ m: 0 }}>
|
<AppButton variant="text" color="inherit" onClick={() => refetch()}>
|
||||||
{t('retry')}
|
{t('retry')}
|
||||||
</AppButton>
|
</AppButton>
|
||||||
}
|
}
|
||||||
@@ -104,38 +104,23 @@ export default function NurseVerificationPage() {
|
|||||||
function NotStarted({ onStart, pending }: { onStart: () => void; pending: boolean }) {
|
function NotStarted({ onStart, pending }: { onStart: () => void; pending: boolean }) {
|
||||||
const t = useTranslations('verification');
|
const t = useTranslations('verification');
|
||||||
return (
|
return (
|
||||||
<Paper
|
<EmptyState
|
||||||
elevation={0}
|
icon="verification"
|
||||||
sx={{
|
title={t('start_title')}
|
||||||
p: 4,
|
body={t('start_body')}
|
||||||
textAlign: 'center',
|
action={
|
||||||
border: '1px dashed',
|
<AppButton
|
||||||
borderColor: 'divider',
|
color="primary"
|
||||||
borderRadius: 2,
|
variant="contained"
|
||||||
display: 'flex',
|
startIcon="verification"
|
||||||
flexDirection: 'column',
|
onClick={onStart}
|
||||||
alignItems: 'center',
|
disabled={pending}
|
||||||
gap: 1.5,
|
sx={{ mt: 1 }}
|
||||||
}}
|
>
|
||||||
>
|
{pending ? t('starting') : t('start_cta')}
|
||||||
<AppIcon icon="verification" size={44} color="var(--bal-primary)" />
|
</AppButton>
|
||||||
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
|
}
|
||||||
{t('start_title')}
|
/>
|
||||||
</Typography>
|
|
||||||
<Typography variant="body2" sx={{ color: 'text.secondary', maxWidth: 440 }}>
|
|
||||||
{t('start_body')}
|
|
||||||
</Typography>
|
|
||||||
<AppButton
|
|
||||||
color="primary"
|
|
||||||
variant="contained"
|
|
||||||
startIcon="verification"
|
|
||||||
onClick={onStart}
|
|
||||||
disabled={pending}
|
|
||||||
sx={{ mt: 1 }}
|
|
||||||
>
|
|
||||||
{pending ? t('starting') : t('start_cta')}
|
|
||||||
</AppButton>
|
|
||||||
</Paper>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -170,7 +155,7 @@ function Approved({ onPublish }: { onPublish: () => void }) {
|
|||||||
variant="contained"
|
variant="contained"
|
||||||
startIcon="publish"
|
startIcon="publish"
|
||||||
onClick={onPublish}
|
onClick={onPublish}
|
||||||
sx={{ m: 0, alignSelf: 'flex-start' }}
|
sx={{ alignSelf: 'flex-start' }}
|
||||||
>
|
>
|
||||||
{t('approved_cta')}
|
{t('approved_cta')}
|
||||||
</AppButton>
|
</AppButton>
|
||||||
@@ -200,7 +185,7 @@ function ContinueCta({
|
|||||||
const route = nextActionRoute(status);
|
const route = nextActionRoute(status);
|
||||||
if (route == null) return null;
|
if (route == null) return null;
|
||||||
return (
|
return (
|
||||||
<AppButton color="primary" variant="contained" onClick={onContinue} sx={{ m: 0, alignSelf: 'flex-start' }}>
|
<AppButton color="primary" variant="contained" onClick={onContinue} sx={{ alignSelf: 'flex-start' }}>
|
||||||
{index != null ? t('continue_step', { n: index }) : t('continue')}
|
{index != null ? t('continue_step', { n: index }) : t('continue')}
|
||||||
</AppButton>
|
</AppButton>
|
||||||
);
|
);
|
||||||
@@ -209,29 +194,18 @@ function ContinueCta({
|
|||||||
function MockAdminControls({ onApprove, onReject }: { onApprove: () => void; onReject: () => void }) {
|
function MockAdminControls({ onApprove, onReject }: { onApprove: () => void; onReject: () => void }) {
|
||||||
const t = useTranslations('verification');
|
const t = useTranslations('verification');
|
||||||
return (
|
return (
|
||||||
<Paper
|
<EmptyState
|
||||||
elevation={0}
|
title={t('mock_admin_title')}
|
||||||
sx={{
|
action={
|
||||||
p: 2,
|
<Stack direction="row" sx={{ gap: 1, flexWrap: 'wrap', justifyContent: 'center' }}>
|
||||||
borderRadius: 2,
|
<AppButton variant="outlined" color="primary" onClick={onApprove}>
|
||||||
border: '1px dashed',
|
{t('mock_admin_approve')}
|
||||||
borderColor: 'divider',
|
</AppButton>
|
||||||
display: 'flex',
|
<AppButton variant="outlined" color="error" onClick={onReject}>
|
||||||
flexDirection: 'column',
|
{t('mock_admin_reject')}
|
||||||
gap: 1,
|
</AppButton>
|
||||||
}}
|
</Stack>
|
||||||
>
|
}
|
||||||
<Typography variant="caption" sx={{ color: 'text.secondary', fontWeight: 700 }}>
|
/>
|
||||||
{t('mock_admin_title')}
|
|
||||||
</Typography>
|
|
||||||
<Stack direction="row" sx={{ gap: 1, flexWrap: 'wrap' }}>
|
|
||||||
<AppButton variant="outlined" color="primary" onClick={onApprove} sx={{ m: 0 }}>
|
|
||||||
{t('mock_admin_approve')}
|
|
||||||
</AppButton>
|
|
||||||
<AppButton variant="outlined" color="error" onClick={onReject} sx={{ m: 0 }}>
|
|
||||||
{t('mock_admin_reject')}
|
|
||||||
</AppButton>
|
|
||||||
</Stack>
|
|
||||||
</Paper>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ export default function UnderReviewPage() {
|
|||||||
{isApproved ? t('review_approved_body') : t('review_body')}
|
{isApproved ? t('review_approved_body') : t('review_body')}
|
||||||
</Typography>
|
</Typography>
|
||||||
{!isApproved ? (
|
{!isApproved ? (
|
||||||
<Typography variant="body2" sx={{ fontWeight: 600, color: 'var(--bal-warning)' }}>
|
<Typography variant="body2" sx={{ fontWeight: 500, color: 'var(--bal-warning)' }}>
|
||||||
{t('review_eta')}
|
{t('review_eta')}
|
||||||
</Typography>
|
</Typography>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -83,7 +83,7 @@ export default function UnderReviewPage() {
|
|||||||
variant="contained"
|
variant="contained"
|
||||||
startIcon="verification"
|
startIcon="verification"
|
||||||
to={`/${locale}${ROUTES.NURSE_VERIFICATION}`}
|
to={`/${locale}${ROUTES.NURSE_VERIFICATION}`}
|
||||||
sx={{ m: 0, alignSelf: 'flex-start' }}
|
sx={{ alignSelf: 'flex-start' }}
|
||||||
>
|
>
|
||||||
{t('review_view_status')}
|
{t('review_view_status')}
|
||||||
</AppButton>
|
</AppButton>
|
||||||
|
|||||||
@@ -120,7 +120,7 @@ export default function NurseVisitNotesPanel({ bookingId }: { bookingId: number
|
|||||||
startIcon="notes"
|
startIcon="notes"
|
||||||
onClick={submit}
|
onClick={submit}
|
||||||
disabled={createNote.isPending || !note.trim()}
|
disabled={createNote.isPending || !note.trim()}
|
||||||
sx={{ m: 0, alignSelf: 'flex-end' }}
|
sx={{ alignSelf: 'flex-end' }}
|
||||||
>
|
>
|
||||||
{createNote.isPending ? tc('saving') : t('note_submit')}
|
{createNote.isPending ? tc('saving') : t('note_submit')}
|
||||||
</AppButton>
|
</AppButton>
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
'use client';
|
'use client';
|
||||||
import { useLocale, useTranslations } from 'next-intl';
|
import { useLocale, useTranslations } from 'next-intl';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import { Box, Paper, Skeleton, Stack, Typography } from '@mui/material';
|
import { Box, Skeleton, Stack, Typography } from '@mui/material';
|
||||||
import { AppButton, AppIcon } from '@/components';
|
import { AppButton, EmptyState } from '@/components';
|
||||||
import { SessionCard, useEvvController } from '@/components/booking';
|
import { SessionCard, useEvvController } from '@/components/booking';
|
||||||
import { ROUTES } from '@/constants';
|
import { ROUTES } from '@/constants';
|
||||||
import { useSessionEvv, useTodaySessions } from '@/services/bookings';
|
import { useSessionEvv, useTodaySessions } from '@/services/bookings';
|
||||||
@@ -39,12 +39,7 @@ export default function NurseVisitsPage() {
|
|||||||
))}
|
))}
|
||||||
</Stack>
|
</Stack>
|
||||||
) : items.length === 0 ? (
|
) : items.length === 0 ? (
|
||||||
<Paper elevation={0} sx={{ p: 4, textAlign: 'center', border: '1px dashed', borderColor: 'divider', borderRadius: 2 }}>
|
<EmptyState icon="visits" title={t('evv_no_visits')} />
|
||||||
<AppIcon icon="visits" size={40} color="var(--bal-text-secondary)" />
|
|
||||||
<Typography variant="body2" sx={{ color: 'text.secondary', mt: 1 }}>
|
|
||||||
{t('evv_no_visits')}
|
|
||||||
</Typography>
|
|
||||||
</Paper>
|
|
||||||
) : (
|
) : (
|
||||||
<Stack sx={{ gap: 2 }}>
|
<Stack sx={{ gap: 2 }}>
|
||||||
{items.map((item) => (
|
{items.map((item) => (
|
||||||
@@ -89,7 +84,7 @@ function TodayVisitCard({ item, evv }: { item: BookingSessionListItemDto; evv: R
|
|||||||
color="primary"
|
color="primary"
|
||||||
endIcon="bookings"
|
endIcon="bookings"
|
||||||
onClick={() => router.push(`/${locale}${ROUTES.NURSE_VISITS}/${item.bookingId}`)}
|
onClick={() => router.push(`/${locale}${ROUTES.NURSE_VISITS}/${item.bookingId}`)}
|
||||||
sx={{ m: 0, alignSelf: 'flex-start' }}
|
sx={{ alignSelf: 'flex-start' }}
|
||||||
>
|
>
|
||||||
{t('view_booking')}
|
{t('view_booking')}
|
||||||
</AppButton>
|
</AppButton>
|
||||||
|
|||||||
@@ -0,0 +1,130 @@
|
|||||||
|
'use client';
|
||||||
|
import { useTranslations } from 'next-intl';
|
||||||
|
import { Alert, Box, Paper, Skeleton, Stack, Typography } from '@mui/material';
|
||||||
|
import { AdminEmptyState, AdminPageHeader } from '@/components/admin';
|
||||||
|
import { StatusChip } from '@/components';
|
||||||
|
import type { CenterOnboardingState, PartnerCenter } from '@/services/partnerCenter/types';
|
||||||
|
import { useMyPartnerCenter } from '@/services/partnerCenter';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Partner portal home (f15) — the signed-in center admin's **own** center at a glance (a separate authz
|
||||||
|
* scope from /admin; tenancy is server-enforced). `useMyPartnerCenter` doubles as the access gate: a
|
||||||
|
* 403/404 (non-owner / no center) surfaces the non-leaking access-denied state, never any center data.
|
||||||
|
* On success it shows the onboarding banner (draft/pending/suspended), the license block, the
|
||||||
|
* merchant-of-record indicator, and the masked settlement IBAN. Read-only.
|
||||||
|
*/
|
||||||
|
export default function PartnerHomeScreen() {
|
||||||
|
const t = useTranslations('partner');
|
||||||
|
const center = useMyPartnerCenter();
|
||||||
|
|
||||||
|
if (center.isLoading) {
|
||||||
|
return (
|
||||||
|
<Stack sx={{ gap: 3 }}>
|
||||||
|
<Skeleton variant="rounded" height={72} />
|
||||||
|
<Skeleton variant="rounded" height={64} />
|
||||||
|
<Skeleton variant="rounded" height={220} />
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The center query is the portal's access gate — a 403/404 means the caller owns no center.
|
||||||
|
if (center.isError || !center.data) {
|
||||||
|
return <AdminEmptyState icon="lock" title={t('access_denied')} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
const c = center.data;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
|
||||||
|
<AdminPageHeader
|
||||||
|
title={t('home_title')}
|
||||||
|
subtitle={t('home_subtitle')}
|
||||||
|
actions={
|
||||||
|
<StatusChip
|
||||||
|
status={c.isMerchantOfRecord ? 'active' : 'neutral'}
|
||||||
|
label={c.isMerchantOfRecord ? t('is_mor_yes') : t('is_mor_no')}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<OnboardingBanner state={c.onboardingState} />
|
||||||
|
|
||||||
|
<LicenseBlock center={c} />
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Onboarding/verification banner keyed off `onboardingState`. `verified` shows a subtle chip instead of a
|
||||||
|
* banner; every other state shows an MUI `Alert` (draft/suspended → warning, pending → info).
|
||||||
|
*/
|
||||||
|
function OnboardingBanner({ state }: { state: CenterOnboardingState }) {
|
||||||
|
const t = useTranslations('partner');
|
||||||
|
const ta = useTranslations('admin');
|
||||||
|
|
||||||
|
if (state === 'verified') {
|
||||||
|
return (
|
||||||
|
<Box>
|
||||||
|
<StatusChip status="verified" label={ta('center_state_verified')} />
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const banner: Record<Exclude<CenterOnboardingState, 'verified'>, { severity: 'warning' | 'info'; key: string }> = {
|
||||||
|
draft: { severity: 'warning', key: 'state_banner_draft' },
|
||||||
|
pending_verification: { severity: 'info', key: 'state_banner_pending' },
|
||||||
|
suspended: { severity: 'warning', key: 'state_banner_suspended' },
|
||||||
|
};
|
||||||
|
const { severity, key } = banner[state];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Alert severity={severity} sx={{ borderRadius: 2 }}>
|
||||||
|
{t(key)}
|
||||||
|
</Alert>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** License details + merchant-of-record settlement IBAN (masked last-4). Nulls render as an em dash. */
|
||||||
|
function LicenseBlock({ center }: { center: PartnerCenter }) {
|
||||||
|
const t = useTranslations('partner');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Paper elevation={0} sx={{ p: 2.5, borderRadius: 2, border: '1px solid', borderColor: 'divider' }}>
|
||||||
|
<Typography variant="subtitle1" sx={{ fontWeight: 800, mb: 2 }}>
|
||||||
|
{t('license_title')}
|
||||||
|
</Typography>
|
||||||
|
<Stack sx={{ gap: 1.5 }}>
|
||||||
|
<DetailRow label={t('permit')} value={center.mohEstablishmentPermitNo} />
|
||||||
|
<DetailRow label={t('tech_director')} value={center.technicalDirectorLicenseNo} />
|
||||||
|
<DetailRow label={t('enamad')} value={center.enamadCode} />
|
||||||
|
<DetailRow label={t('legal_type')} value={center.legalEntityType} />
|
||||||
|
{center.isMerchantOfRecord ? (
|
||||||
|
<DetailRow label={t('settlement_iban')} value={center.settlementIbanMasked} ltr />
|
||||||
|
) : null}
|
||||||
|
</Stack>
|
||||||
|
</Paper>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One label → value row. `ltr` forces LTR display for latin/numeric values (IBAN) inside an RTL page. */
|
||||||
|
function DetailRow({ label, value, ltr }: { label: string; value: string | null; ltr?: boolean }) {
|
||||||
|
return (
|
||||||
|
<Stack
|
||||||
|
direction="row"
|
||||||
|
sx={{ gap: 2, alignItems: 'baseline', justifyContent: 'space-between', flexWrap: 'wrap' }}
|
||||||
|
>
|
||||||
|
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||||
|
{label}
|
||||||
|
</Typography>
|
||||||
|
{ltr ? (
|
||||||
|
<Typography component="span" dir="ltr" variant="body2" sx={{ fontWeight: 500, fontFamily: 'monospace' }}>
|
||||||
|
{value ?? '—'}
|
||||||
|
</Typography>
|
||||||
|
) : (
|
||||||
|
<Typography variant="body2" sx={{ fontWeight: 500 }}>
|
||||||
|
{value ?? '—'}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import SidebarShellSkeleton from '../_chrome/SidebarShellSkeleton';
|
||||||
|
|
||||||
|
export default function Loading() {
|
||||||
|
return <SidebarShellSkeleton />;
|
||||||
|
}
|
||||||
@@ -1,130 +1,13 @@
|
|||||||
'use client';
|
import type { Metadata } from 'next';
|
||||||
import { useTranslations } from 'next-intl';
|
import { getTranslations } from 'next-intl/server';
|
||||||
import { Alert, Box, Paper, Skeleton, Stack, Typography } from '@mui/material';
|
import PartnerHomeScreen from './PartnerHomeScreen';
|
||||||
import { AdminEmptyState, AdminPageHeader } from '@/components/admin';
|
|
||||||
import { StatusChip } from '@/components';
|
|
||||||
import type { CenterOnboardingState, PartnerCenter } from '@/services/partnerCenter/types';
|
|
||||||
import { useMyPartnerCenter } from '@/services/partnerCenter';
|
|
||||||
|
|
||||||
/**
|
export async function generateMetadata({ params }: { params: Promise<{ locale: string }> }): Promise<Metadata> {
|
||||||
* Partner portal home (f15) — the signed-in center admin's **own** center at a glance (a separate authz
|
const { locale } = await params;
|
||||||
* scope from /admin; tenancy is server-enforced). `useMyPartnerCenter` doubles as the access gate: a
|
const t = await getTranslations({ locale, namespace: 'partner' });
|
||||||
* 403/404 (non-owner / no center) surfaces the non-leaking access-denied state, never any center data.
|
return { title: t('home_title') };
|
||||||
* On success it shows the onboarding banner (draft/pending/suspended), the license block, the
|
|
||||||
* merchant-of-record indicator, and the masked settlement IBAN. Read-only.
|
|
||||||
*/
|
|
||||||
export default function PartnerHomePage() {
|
|
||||||
const t = useTranslations('partner');
|
|
||||||
const center = useMyPartnerCenter();
|
|
||||||
|
|
||||||
if (center.isLoading) {
|
|
||||||
return (
|
|
||||||
<Stack sx={{ gap: 3 }}>
|
|
||||||
<Skeleton variant="rounded" height={72} />
|
|
||||||
<Skeleton variant="rounded" height={64} />
|
|
||||||
<Skeleton variant="rounded" height={220} />
|
|
||||||
</Stack>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// The center query is the portal's access gate — a 403/404 means the caller owns no center.
|
|
||||||
if (center.isError || !center.data) {
|
|
||||||
return <AdminEmptyState icon="lock" title={t('access_denied')} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
const c = center.data;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
|
|
||||||
<AdminPageHeader
|
|
||||||
title={t('home_title')}
|
|
||||||
subtitle={t('home_subtitle')}
|
|
||||||
actions={
|
|
||||||
<StatusChip
|
|
||||||
status={c.isMerchantOfRecord ? 'active' : 'neutral'}
|
|
||||||
label={c.isMerchantOfRecord ? t('is_mor_yes') : t('is_mor_no')}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<OnboardingBanner state={c.onboardingState} />
|
|
||||||
|
|
||||||
<LicenseBlock center={c} />
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
export default function Page() {
|
||||||
* Onboarding/verification banner keyed off `onboardingState`. `verified` shows a subtle chip instead of a
|
return <PartnerHomeScreen />;
|
||||||
* banner; every other state shows an MUI `Alert` (draft/suspended → warning, pending → info).
|
|
||||||
*/
|
|
||||||
function OnboardingBanner({ state }: { state: CenterOnboardingState }) {
|
|
||||||
const t = useTranslations('partner');
|
|
||||||
const ta = useTranslations('admin');
|
|
||||||
|
|
||||||
if (state === 'verified') {
|
|
||||||
return (
|
|
||||||
<Box>
|
|
||||||
<StatusChip status="verified" label={ta('center_state_verified')} />
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const banner: Record<Exclude<CenterOnboardingState, 'verified'>, { severity: 'warning' | 'info'; key: string }> = {
|
|
||||||
draft: { severity: 'warning', key: 'state_banner_draft' },
|
|
||||||
pending_verification: { severity: 'info', key: 'state_banner_pending' },
|
|
||||||
suspended: { severity: 'warning', key: 'state_banner_suspended' },
|
|
||||||
};
|
|
||||||
const { severity, key } = banner[state];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Alert severity={severity} sx={{ borderRadius: 2 }}>
|
|
||||||
{t(key)}
|
|
||||||
</Alert>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** License details + merchant-of-record settlement IBAN (masked last-4). Nulls render as an em dash. */
|
|
||||||
function LicenseBlock({ center }: { center: PartnerCenter }) {
|
|
||||||
const t = useTranslations('partner');
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Paper elevation={0} sx={{ p: 2.5, borderRadius: 2, border: '1px solid', borderColor: 'divider' }}>
|
|
||||||
<Typography variant="subtitle1" sx={{ fontWeight: 800, mb: 2 }}>
|
|
||||||
{t('license_title')}
|
|
||||||
</Typography>
|
|
||||||
<Stack sx={{ gap: 1.5 }}>
|
|
||||||
<DetailRow label={t('permit')} value={center.mohEstablishmentPermitNo} />
|
|
||||||
<DetailRow label={t('tech_director')} value={center.technicalDirectorLicenseNo} />
|
|
||||||
<DetailRow label={t('enamad')} value={center.enamadCode} />
|
|
||||||
<DetailRow label={t('legal_type')} value={center.legalEntityType} />
|
|
||||||
{center.isMerchantOfRecord ? (
|
|
||||||
<DetailRow label={t('settlement_iban')} value={center.settlementIbanMasked} ltr />
|
|
||||||
) : null}
|
|
||||||
</Stack>
|
|
||||||
</Paper>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** One label → value row. `ltr` forces LTR display for latin/numeric values (IBAN) inside an RTL page. */
|
|
||||||
function DetailRow({ label, value, ltr }: { label: string; value: string | null; ltr?: boolean }) {
|
|
||||||
return (
|
|
||||||
<Stack
|
|
||||||
direction="row"
|
|
||||||
sx={{ gap: 2, alignItems: 'baseline', justifyContent: 'space-between', flexWrap: 'wrap' }}
|
|
||||||
>
|
|
||||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
|
||||||
{label}
|
|
||||||
</Typography>
|
|
||||||
{ltr ? (
|
|
||||||
<Typography component="span" dir="ltr" variant="body2" sx={{ fontWeight: 600, fontFamily: 'monospace' }}>
|
|
||||||
{value ?? '—'}
|
|
||||||
</Typography>
|
|
||||||
) : (
|
|
||||||
<Typography variant="body2" sx={{ fontWeight: 600 }}>
|
|
||||||
{value ?? '—'}
|
|
||||||
</Typography>
|
|
||||||
)}
|
|
||||||
</Stack>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -65,7 +65,7 @@ export default function PartnerSettlementPage() {
|
|||||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||||
{t('settlement_iban')}
|
{t('settlement_iban')}
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography component="span" dir="ltr" variant="body2" sx={{ fontWeight: 600, fontFamily: 'monospace' }}>
|
<Typography component="span" dir="ltr" variant="body2" sx={{ fontWeight: 500, fontFamily: 'monospace' }}>
|
||||||
{c.settlementIbanMasked ?? '—'}
|
{c.settlementIbanMasked ?? '—'}
|
||||||
</Typography>
|
</Typography>
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import Skeleton from '@mui/material/Skeleton';
|
||||||
|
import Stack from '@mui/material/Stack';
|
||||||
|
|
||||||
|
/** The auth-card shape (`/login`) — a centered brand mark + a card-sized block. */
|
||||||
|
export default function Loading() {
|
||||||
|
return (
|
||||||
|
<Stack sx={{ alignItems: 'center', justifyContent: 'center', minHeight: '60vh', gap: 2.5 }}>
|
||||||
|
<Skeleton variant="circular" width={56} height={56} />
|
||||||
|
<Skeleton variant="rounded" width={320} height={220} sx={{ maxWidth: '90vw', borderRadius: 'var(--bal-radius-lg)' }} />
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
'use client';
|
||||||
|
import { Suspense } from 'react';
|
||||||
|
import { LoginFlow } from '@/components/auth';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Phone-OTP login (A1/A2 customer, B1/B2 nurse). The Suspense boundary is required because
|
||||||
|
* LoginFlow reads `useSearchParams` (the `?role=nurse` switch).
|
||||||
|
*/
|
||||||
|
export default function LoginScreen() {
|
||||||
|
return (
|
||||||
|
<Suspense>
|
||||||
|
<LoginFlow />
|
||||||
|
</Suspense>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,15 +1,13 @@
|
|||||||
'use client';
|
import type { Metadata } from 'next';
|
||||||
import { Suspense } from 'react';
|
import { getTranslations } from 'next-intl/server';
|
||||||
import { LoginFlow } from '@/components/auth';
|
import LoginScreen from './LoginScreen';
|
||||||
|
|
||||||
/**
|
export async function generateMetadata({ params }: { params: Promise<{ locale: string }> }): Promise<Metadata> {
|
||||||
* Phone-OTP login (A1/A2 customer, B1/B2 nurse). The Suspense boundary is required because
|
const { locale } = await params;
|
||||||
* LoginFlow reads `useSearchParams` (the `?role=nurse` switch).
|
const t = await getTranslations({ locale, namespace: 'auth' });
|
||||||
*/
|
return { title: t('customer_title') };
|
||||||
export default function LoginPage() {
|
}
|
||||||
return (
|
|
||||||
<Suspense>
|
export default function Page() {
|
||||||
<LoginFlow />
|
return <LoginScreen />;
|
||||||
</Suspense>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { notFound } from 'next/navigation';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Catch-all for any path under a locale segment that matches no other route (e.g. `/fa/xyz`). Next.js
|
||||||
|
* only renders `not-found.tsx` automatically for a segment that exists; an arbitrary unmatched path needs
|
||||||
|
* an actual route to land on before it can call `notFound()` — this is that route. See the next-intl
|
||||||
|
* docs' recommended 404 pattern.
|
||||||
|
*/
|
||||||
|
export default function CatchAll(): never {
|
||||||
|
notFound();
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
'use client';
|
||||||
|
import { FunctionComponent, useEffect } from 'react';
|
||||||
|
import { useLocale, useTranslations } from 'next-intl';
|
||||||
|
import { Stack, Typography } from '@mui/material';
|
||||||
|
import { AppButton } from '@/components';
|
||||||
|
import AppIcon from '@/components/common/AppIcon';
|
||||||
|
import BrandMark from '@/components/auth/BrandMark';
|
||||||
|
import { ROUTES } from '@/constants';
|
||||||
|
|
||||||
|
interface ErrorPageProps {
|
||||||
|
error: Error & { digest?: string };
|
||||||
|
reset: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The branded, per-locale error boundary — Next.js mounts this in place of the segment tree below
|
||||||
|
* `[locale]/layout.tsx` whenever a render throws. It sits *inside* the locale layout's providers (next-intl,
|
||||||
|
* theme, auth), so — unlike `global-error.tsx` — it can render fully localized copy. `reset()` re-renders
|
||||||
|
* the segment (the standard recovery path); "go home" is the unconditional escape hatch. The raw error is
|
||||||
|
* logged for diagnostics but never shown to the user (see `ErrorBoundary` for the same rule at the
|
||||||
|
* component-subtree level).
|
||||||
|
*/
|
||||||
|
const ErrorPage: FunctionComponent<ErrorPageProps> = ({ error, reset }) => {
|
||||||
|
const t = useTranslations('routeChrome');
|
||||||
|
const locale = useLocale();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
console.error(error);
|
||||||
|
}, [error]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack
|
||||||
|
sx={{ alignItems: 'center', justifyContent: 'center', minHeight: '70vh', gap: 2, px: 2, textAlign: 'center' }}
|
||||||
|
>
|
||||||
|
<BrandMark />
|
||||||
|
<AppIcon icon="warning" size={40} color="var(--bal-warning)" />
|
||||||
|
<Typography variant="h6" component="h1">
|
||||||
|
{t('error_title')}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" sx={{ color: 'text.secondary', maxWidth: 360 }}>
|
||||||
|
{t('error_body')}
|
||||||
|
</Typography>
|
||||||
|
<Stack direction="row" sx={{ gap: 1.5 }}>
|
||||||
|
<AppButton color="primary" variant="contained" onClick={reset}>
|
||||||
|
{t('error_retry')}
|
||||||
|
</AppButton>
|
||||||
|
<AppButton color="primary" variant="outlined" to={`/${locale}${ROUTES.HOME}`}>
|
||||||
|
{t('go_home')}
|
||||||
|
</AppButton>
|
||||||
|
</Stack>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ErrorPage;
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
import type { ReactNode } from 'react';
|
import type { ReactNode } from 'react';
|
||||||
import type { Metadata, Viewport } from 'next';
|
import type { Metadata, Viewport } from 'next';
|
||||||
import localFont from 'next/font/local';
|
import localFont from 'next/font/local';
|
||||||
import { setRequestLocale, getMessages } from 'next-intl/server';
|
import { Space_Grotesk } from 'next/font/google';
|
||||||
|
import { setRequestLocale, getMessages, getTranslations } from 'next-intl/server';
|
||||||
import { NextIntlClientProvider } from 'next-intl';
|
import { NextIntlClientProvider } from 'next-intl';
|
||||||
import { getThemeMode } from '@/lib/cookies/server';
|
import { getThemeMode } from '@/lib/cookies/server';
|
||||||
import { getServerAuthState } from '@/lib/auth/server';
|
import { getServerAuthState } from '@/lib/auth/server';
|
||||||
@@ -47,15 +48,46 @@ const mikhak = localFont({
|
|||||||
preload: false,
|
preload: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// EN brand font — Space Grotesk. Self-hosted by next/font/google at build
|
||||||
|
// time (no runtime Google Fonts request). Same discipline as Mikhak:
|
||||||
|
// preload: false + conditional className below so it is only fetched on `en`.
|
||||||
|
const spaceGrotesk = Space_Grotesk({
|
||||||
|
subsets: ['latin'],
|
||||||
|
weight: ['400', '500', '700'],
|
||||||
|
display: 'swap',
|
||||||
|
variable: '--font-space-grotesk',
|
||||||
|
preload: false,
|
||||||
|
});
|
||||||
|
|
||||||
export const viewport: Viewport = {
|
export const viewport: Viewport = {
|
||||||
themeColor: BRAND.teal,
|
themeColor: [
|
||||||
|
{ media: '(prefers-color-scheme: light)', color: BRAND.teal },
|
||||||
|
{ media: '(prefers-color-scheme: dark)', color: BRAND.tealDeep },
|
||||||
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export async function generateMetadata({
|
||||||
title: 'Balinyaar | بالینیار',
|
params,
|
||||||
description: 'Balinyaar web application',
|
}: {
|
||||||
manifest: '/site.webmanifest',
|
params: Promise<{ locale: string }>;
|
||||||
};
|
}): Promise<Metadata> {
|
||||||
|
const { locale } = await params;
|
||||||
|
|
||||||
|
const safeLocale = routing.locales.includes(locale as (typeof routing.locales)[number])
|
||||||
|
? locale
|
||||||
|
: routing.defaultLocale;
|
||||||
|
|
||||||
|
const t = await getTranslations({ locale: safeLocale, namespace: 'common' });
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: {
|
||||||
|
template: safeLocale === 'fa' ? '%s | بالینیار' : '%s | Balinyaar',
|
||||||
|
default: safeLocale === 'fa' ? 'بالینیار' : 'Balinyaar',
|
||||||
|
},
|
||||||
|
description: t('brand_tagline'),
|
||||||
|
manifest: '/site.webmanifest',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export default async function LocaleLayout({
|
export default async function LocaleLayout({
|
||||||
children,
|
children,
|
||||||
@@ -77,9 +109,9 @@ export default async function LocaleLayout({
|
|||||||
const authState = await getServerAuthState();
|
const authState = await getServerAuthState();
|
||||||
const dir = getDirection(safeLocale);
|
const dir = getDirection(safeLocale);
|
||||||
|
|
||||||
// Only attach the Mikhak font variable on RTL (Persian) routes so the
|
// Each locale's brand font variable is attached only on its own routes —
|
||||||
// Persian typeface is not loaded for English pages.
|
// Mikhak never ships to /en, Space Grotesk never ships to /fa.
|
||||||
const fontClassName = safeLocale === 'fa' ? mikhak.variable : undefined;
|
const fontClassName = safeLocale === 'fa' ? mikhak.variable : spaceGrotesk.variable;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<html
|
<html
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { getLocale, getTranslations } from 'next-intl/server';
|
||||||
|
import { Stack, Typography } from '@mui/material';
|
||||||
|
import { AppButton } from '@/components';
|
||||||
|
import AppIcon from '@/components/common/AppIcon';
|
||||||
|
import BrandMark from '@/components/auth/BrandMark';
|
||||||
|
import { ROUTES } from '@/constants';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The branded 404 — rendered for any URL under a valid locale segment that matches no route (the
|
||||||
|
* `[...rest]/page.tsx` catch-all calls `notFound()`, which mounts this). A Server Component: nothing here
|
||||||
|
* is interactive besides the "go home" link, so no client boundary is needed.
|
||||||
|
*/
|
||||||
|
export default async function NotFound() {
|
||||||
|
const locale = await getLocale();
|
||||||
|
const t = await getTranslations('routeChrome');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack
|
||||||
|
sx={{ alignItems: 'center', justifyContent: 'center', minHeight: '70vh', gap: 2, px: 2, textAlign: 'center' }}
|
||||||
|
>
|
||||||
|
<BrandMark />
|
||||||
|
<AppIcon icon="search" size={40} color="var(--bal-text-secondary)" />
|
||||||
|
<Typography variant="h6" component="h1">
|
||||||
|
{t('not_found_title')}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" sx={{ color: 'text.secondary', maxWidth: 360 }}>
|
||||||
|
{t('not_found_body')}
|
||||||
|
</Typography>
|
||||||
|
<AppButton color="primary" variant="contained" to={`/${locale}${ROUTES.HOME}`}>
|
||||||
|
{t('go_home')}
|
||||||
|
</AppButton>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 2.3 KiB |
@@ -0,0 +1,67 @@
|
|||||||
|
'use client';
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
|
||||||
|
interface GlobalErrorProps {
|
||||||
|
error: Error & { digest?: string };
|
||||||
|
reset: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* The one sanctioned hard-coded-string exception in this codebase (documented in `client/CLAUDE.md`).
|
||||||
|
* `global-error.tsx` is a special Next.js file that replaces the ENTIRE root layout — including
|
||||||
|
* `[locale]/layout.tsx` — when an error is thrown above or inside that layout itself (e.g. a broken
|
||||||
|
* `NextIntlClientProvider`/`ThemeProvider`). It therefore has no access to next-intl, the theme, or any
|
||||||
|
* other provider, and per Next.js's own contract must render its own `<html>`/`<body>`. Since there is no
|
||||||
|
* reliable way to know the visitor's locale here, the copy is minimal, static, and bilingual (fa + en)
|
||||||
|
* rather than guessing a language. Kept deliberately inline (no MUI, no design tokens) — this file must
|
||||||
|
* render correctly even if the entire component/theme tree is what broke.
|
||||||
|
*/
|
||||||
|
export default function GlobalError({ error, reset }: GlobalErrorProps) {
|
||||||
|
useEffect(() => {
|
||||||
|
console.error(error);
|
||||||
|
}, [error]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<html lang="fa" dir="rtl">
|
||||||
|
<body
|
||||||
|
style={{
|
||||||
|
margin: 0,
|
||||||
|
minHeight: '100vh',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
fontFamily: 'system-ui, sans-serif',
|
||||||
|
background: '#faf9f5',
|
||||||
|
color: '#1b2521',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ textAlign: 'center', maxWidth: 420, padding: 24 }}>
|
||||||
|
<p style={{ fontSize: '1.125rem', fontWeight: 700, margin: '0 0 8px' }}>مشکلی پیش آمد</p>
|
||||||
|
<p style={{ margin: '0 0 4px', color: '#5b655f' }}>لطفاً دوباره تلاش کنید.</p>
|
||||||
|
<p style={{ fontSize: '1rem', fontWeight: 700, margin: '16px 0 8px' }} dir="ltr">
|
||||||
|
Something went wrong
|
||||||
|
</p>
|
||||||
|
<p style={{ margin: '0 0 20px', color: '#5b655f' }} dir="ltr">
|
||||||
|
Please try again.
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={reset}
|
||||||
|
style={{
|
||||||
|
padding: '10px 24px',
|
||||||
|
borderRadius: 4,
|
||||||
|
border: 'none',
|
||||||
|
background: '#1d4a40',
|
||||||
|
color: '#f3efe9',
|
||||||
|
fontWeight: 700,
|
||||||
|
fontSize: '0.9rem',
|
||||||
|
cursor: 'pointer',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
تلاش دوباره / Try again
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -4,14 +4,27 @@
|
|||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
html,
|
/*
|
||||||
body {
|
* AppLink is the only place link affordance (color, underline) is decided —
|
||||||
max-width: 100vw;
|
* this reset keeps a bare <a> (e.g. inside rich text) from inheriting the
|
||||||
overflow-x: hidden;
|
* browser's default blue/underline before AppLink's own styling applies.
|
||||||
max-height: 100vh;
|
*/
|
||||||
}
|
|
||||||
|
|
||||||
a {
|
a {
|
||||||
color: inherit;
|
color: inherit;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
::selection {
|
||||||
|
background-color: var(--bal-primary-soft);
|
||||||
|
color: var(--bal-text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Directional icons (back/chevron_start, …) are authored for LTR and mirror
|
||||||
|
* under RTL. AppIcon sets `data-icon-directional` for every name registered
|
||||||
|
* in AppIcon/config.ts's DIRECTIONAL_ICONS — this is the only flip rule in
|
||||||
|
* the app; never hand-roll a per-component mirror.
|
||||||
|
*/
|
||||||
|
[dir='rtl'] [data-icon-directional] {
|
||||||
|
transform: scaleX(-1);
|
||||||
|
}
|
||||||
|
|||||||