ui phase 1
This commit is contained in:
@@ -17,6 +17,6 @@ NEXT_PUBLIC_PUBLIC_URL = http://localhost:3000
|
||||
|
||||
|
||||
# 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://api.domain.com
|
||||
+98
-12
@@ -111,22 +111,31 @@ client/
|
||||
├── app/
|
||||
│ ├── globals.css
|
||||
│ ├── 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]/
|
||||
│ ├── 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)/
|
||||
│ │ ├── 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
|
||||
│ │ ├── (customer)/ # Customer (family) app — mobile-first, bottom-tab nav; no URL segment
|
||||
│ │ │ ├── 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)
|
||||
│ │ │ │ ├── page.tsx # C1 search & filter; reads ?category_id preselect; pushes filter set to C2 as URL query params
|
||||
│ │ │ │ ├── useSearchFilters.ts # C1 colocated filter controller (debounced Toman price → IRR; derives the canonical NurseSearchFilters)
|
||||
│ │ │ │ ├── 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)
|
||||
│ │ │ │ ├── page.tsx # Thin RSC — generateMetadata (search.title) + renders SearchScreen
|
||||
│ │ │ │ ├── SearchScreen.tsx # 'use client' — C1 search & filter body; reads ?category_id preselect; pushes filter set to C2 as URL query params
|
||||
│ │ │ │ ├── useSearchFilters.ts # C1 colocated filter controller (debounced Toman price → IRR; derives the canonical NurseSearchFilters)
|
||||
│ │ │ │ ├── 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)
|
||||
│ │ │ ├── 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)
|
||||
│ │ │ │ ├── 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)
|
||||
@@ -155,7 +164,8 @@ client/
|
||||
│ │ │ └── notifications/page.tsx # /notifications — f14 notification center (NotificationCenter role="customer"); the TopBar bell deep-links here
|
||||
│ │ ├── nurse/ # Nurse app (/nurse/…) — sidebar shell
|
||||
│ │ │ ├── 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)
|
||||
│ │ │ ├── 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)
|
||||
@@ -174,7 +184,9 @@ client/
|
||||
│ │ │ └── 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).
|
||||
│ │ │ ├── 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)
|
||||
│ │ │ ├── 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
|
||||
@@ -189,14 +201,34 @@ client/
|
||||
│ │ │ └── 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).
|
||||
│ │ ├── 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)
|
||||
│ │ ├── 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
|
||||
│ └── (public-routes)/
|
||||
│ ├── 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)
|
||||
│ ├── 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
|
||||
│ ├── OtpInput/ # OTP code input (auto-advance, paste, RTL-safe)
|
||||
│ ├── PhoneNumberField/ # Iranian mobile field (digit-normalizing, LTR-in-RTL, maskIranMobile)
|
||||
@@ -327,7 +359,7 @@ client/
|
||||
│ └── index.ts # Public re-exports (ThemeProvider, getDirection, APP_THEME_*)
|
||||
├── constants/ # App-wide constants (routes.ts w/ actor paths, roles.ts, headers.ts)
|
||||
├── 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
|
||||
```
|
||||
|
||||
@@ -354,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)
|
||||
|
||||
**Adding translations:**
|
||||
@@ -601,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.
|
||||
|
||||
### 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
|
||||
@@ -827,6 +911,8 @@ dispatchToast('Something went wrong', 'error')
|
||||
<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
|
||||
|
||||
+20
-2
@@ -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
|
||||
module.exports = createJestConfig(customJestConfig);
|
||||
// next-intl (and its use-intl dependency) ship ESM-only builds. next/jest's own
|
||||
// `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;
|
||||
|
||||
+23
-7
@@ -74,7 +74,8 @@
|
||||
"nudge_patient_cta": "Open patients",
|
||||
"nudge_profile_title": "Complete your profile",
|
||||
"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": {
|
||||
"step_relation": "Who for",
|
||||
@@ -123,7 +124,8 @@
|
||||
"age_years": "{age} yrs",
|
||||
"conditions_none": "No recorded conditions",
|
||||
"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": {
|
||||
"title": "Profile",
|
||||
@@ -141,7 +143,8 @@
|
||||
"save": "Save",
|
||||
"saved": "Profile saved",
|
||||
"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": {
|
||||
"title": "Nurse profile",
|
||||
@@ -159,7 +162,9 @@
|
||||
"unverified_title": "Your profile isn't active yet",
|
||||
"unverified_body": "Complete identity verification to appear to families and receive bookings.",
|
||||
"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": {
|
||||
"title": "Bank account",
|
||||
@@ -325,7 +330,9 @@
|
||||
"submit_save": "Save changes",
|
||||
"create_error": "The service couldn't be saved. Check your inputs and try again.",
|
||||
"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": {
|
||||
"title": "Find a nurse",
|
||||
@@ -564,7 +571,8 @@
|
||||
"list_empty_title": "No bookings yet",
|
||||
"list_empty_body": "Once a nurse accepts and you pay, your booking appears here.",
|
||||
"list_error": "Couldn't load your bookings.",
|
||||
"list_total": "Total"
|
||||
"list_total": "Total",
|
||||
"inbox_error": "Couldn't load your requests."
|
||||
},
|
||||
"payment": {
|
||||
"title_checkout": "Confirm & pay",
|
||||
@@ -811,7 +819,7 @@
|
||||
"acknowledge_label": "I understand the refund and fee shown above.",
|
||||
"continue_cta": "Continue",
|
||||
"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",
|
||||
"submitting": "Submitting…",
|
||||
"not_cancellable_title": "This booking can't be cancelled",
|
||||
@@ -1578,5 +1586,13 @@
|
||||
"invoice_pdf_error": "Couldn’t open the invoice PDF.",
|
||||
"invoice_number": "Invoice #{number}",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
|
||||
+23
-7
@@ -74,7 +74,8 @@
|
||||
"nudge_patient_cta": "مشاهدهٔ بیماران",
|
||||
"nudge_profile_title": "تکمیل پروفایل",
|
||||
"nudge_profile_body": "برای رزرو سریعتر، تماس اضطراری را اضافه کنید.",
|
||||
"nudge_profile_cta": "رفتن به پروفایل"
|
||||
"nudge_profile_cta": "رفتن به پروفایل",
|
||||
"patients_error": "بیماران شما بارگذاری نشد."
|
||||
},
|
||||
"onboarding": {
|
||||
"step_relation": "برای چه کسی",
|
||||
@@ -123,7 +124,8 @@
|
||||
"age_years": "{age} سال",
|
||||
"conditions_none": "وضعیت خاصی ثبت نشده",
|
||||
"unavailable": "این بیمار در دسترس شما نیست.",
|
||||
"open_record": "مشاهدهٔ پروندهٔ {name}"
|
||||
"open_record": "مشاهدهٔ پروندهٔ {name}",
|
||||
"load_error": "بیماران شما بارگذاری نشد."
|
||||
},
|
||||
"profile": {
|
||||
"title": "پروفایل",
|
||||
@@ -141,7 +143,8 @@
|
||||
"save": "ذخیره",
|
||||
"saved": "پروفایل ذخیره شد",
|
||||
"completion_done": "پروفایل شما کامل است.",
|
||||
"completion_todo": "برای رزرو سریعتر، پروفایل خود را کامل کنید."
|
||||
"completion_todo": "برای رزرو سریعتر، پروفایل خود را کامل کنید.",
|
||||
"load_error": "پروفایل شما بارگذاری نشد."
|
||||
},
|
||||
"nurseProfile": {
|
||||
"title": "پروفایل پرستار",
|
||||
@@ -159,7 +162,9 @@
|
||||
"unverified_title": "پروفایل شما هنوز فعال نیست",
|
||||
"unverified_body": "برای نمایش به خانوادهها و دریافت رزرو، احراز هویت را تکمیل کنید.",
|
||||
"unverified_cta": "تکمیل احراز هویت",
|
||||
"deferred_services": "خدمات و قیمتهای خود را در بخش «خدمات و قیمتها» مدیریت کنید؛ روزهای کاری در مرحله بعد اضافه میشود."
|
||||
"deferred_services": "خدمات و قیمتهای خود را در بخش «خدمات و قیمتها» مدیریت کنید؛ روزهای کاری در مرحله بعد اضافه میشود.",
|
||||
"save_error": "ذخیرهٔ پروفایل انجام نشد. دوباره تلاش کنید.",
|
||||
"avatar_upload_error": "بارگذاری عکس انجام نشد. دوباره تلاش کنید."
|
||||
},
|
||||
"bank": {
|
||||
"title": "حساب بانکی",
|
||||
@@ -325,7 +330,9 @@
|
||||
"submit_save": "ذخیره تغییرات",
|
||||
"create_error": "ثبت خدمت انجام نشد. ورودیها را بررسی کرده و دوباره تلاش کنید.",
|
||||
"created_toast": "خدمت اضافه شد",
|
||||
"saved_toast": "تغییرات ذخیره شد"
|
||||
"saved_toast": "تغییرات ذخیره شد",
|
||||
"list_error": "خدمات شما بارگذاری نشد.",
|
||||
"options_error": "گزینههای این دسته بارگذاری نشد."
|
||||
},
|
||||
"search": {
|
||||
"title": "یافتن پرستار",
|
||||
@@ -564,7 +571,8 @@
|
||||
"list_empty_title": "هنوز رزروی ندارید",
|
||||
"list_empty_body": "پس از تایید پرستار و پرداخت، رزرو شما اینجا نمایش داده میشود.",
|
||||
"list_error": "بارگذاری رزروها ممکن نشد.",
|
||||
"list_total": "مبلغ کل"
|
||||
"list_total": "مبلغ کل",
|
||||
"inbox_error": "درخواستهای شما بارگذاری نشد."
|
||||
},
|
||||
"payment": {
|
||||
"title_checkout": "تایید و پرداخت",
|
||||
@@ -811,7 +819,7 @@
|
||||
"acknowledge_label": "بازپرداخت و کارمزد نمایشدادهشده را میپذیرم.",
|
||||
"continue_cta": "ادامه",
|
||||
"confirm_title": "تایید لغو",
|
||||
"confirm_restate": "مبلغ {refund} به شما بازپرداخت میشود و کارمزد لغو {fee} کسر میگردد.",
|
||||
"confirm_restate": "مبلغ <refund></refund> به شما بازپرداخت میشود و کارمزد لغو <fee></fee> کسر میگردد.",
|
||||
"confirm_cta": "تایید لغو",
|
||||
"submitting": "در حال ثبت…",
|
||||
"not_cancellable_title": "این رزرو قابل لغو نیست",
|
||||
@@ -1578,5 +1586,13 @@
|
||||
"invoice_pdf_error": "باز کردن فایل فاکتور ناموفق بود.",
|
||||
"invoice_number": "فاکتور #{number}",
|
||||
"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} */
|
||||
const nextConfig = {
|
||||
reactStrictMode: true,
|
||||
turbopack: {
|
||||
root: '.'
|
||||
}
|
||||
};
|
||||
|
||||
export default withNextIntl(nextConfig);
|
||||
|
||||
Generated
+10
@@ -20,6 +20,7 @@
|
||||
"@testing-library/dom": "^10.4.1",
|
||||
"clsx": "latest",
|
||||
"copy-to-clipboard": "latest",
|
||||
"jalaali-js": "^2.0.0",
|
||||
"js-cookie": "^3.0.8",
|
||||
"next": "^16.2.9",
|
||||
"next-intl": "^4.13.0",
|
||||
@@ -8035,6 +8036,15 @@
|
||||
"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": {
|
||||
"version": "29.7.0",
|
||||
"resolved": "https://registry.npmjs.org/jest/-/jest-29.7.0.tgz",
|
||||
|
||||
@@ -28,6 +28,7 @@
|
||||
"@testing-library/dom": "^10.4.1",
|
||||
"clsx": "latest",
|
||||
"copy-to-clipboard": "latest",
|
||||
"jalaali-js": "^2.0.0",
|
||||
"js-cookie": "^3.0.8",
|
||||
"next": "^16.2.9",
|
||||
"next-intl": "^4.13.0",
|
||||
|
||||
@@ -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 { useLocale, useTranslations } from 'next-intl';
|
||||
import { useSnackbar } from 'notistack';
|
||||
import {
|
||||
Box,
|
||||
Dialog,
|
||||
DialogActions,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
Paper,
|
||||
Skeleton,
|
||||
Stack,
|
||||
Typography,
|
||||
} from '@mui/material';
|
||||
import { AppButton, AppIcon } from '@/components';
|
||||
import { Box, Dialog, DialogActions, DialogContent, DialogTitle, Skeleton, Stack, Typography } from '@mui/material';
|
||||
import { AppButton, EmptyState } from '@/components';
|
||||
import { AddressCard, AddressForm } from '@/components/geography';
|
||||
import {
|
||||
useAddresses,
|
||||
@@ -115,31 +105,16 @@ export default function AddressesPage() {
|
||||
))}
|
||||
</Stack>
|
||||
) : isEmpty ? (
|
||||
<Paper
|
||||
elevation={0}
|
||||
sx={{
|
||||
p: 4,
|
||||
textAlign: 'center',
|
||||
border: '1px dashed',
|
||||
borderColor: 'divider',
|
||||
borderRadius: 2,
|
||||
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>
|
||||
<EmptyState
|
||||
icon="location"
|
||||
title={t('empty_title')}
|
||||
body={t('empty_body')}
|
||||
action={
|
||||
<AppButton color="primary" variant="contained" startIcon="add" onClick={openAdd} sx={{ mt: 1 }}>
|
||||
{t('add')}
|
||||
</AppButton>
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<Stack sx={{ gap: 1.5 }}>
|
||||
{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 AppAlert from '@/components/common/AppAlert';
|
||||
import AppLoading from '@/components/common/AppLoading';
|
||||
import Money from '@/components/common/Money';
|
||||
import StepperHeader from '@/components/StepperHeader';
|
||||
import CancellationPolicyDisclosure from '@/components/CancellationPolicyDisclosure';
|
||||
import { ApiError } from '@/lib/api/errors';
|
||||
import { bookingRefundStatusPath, ROUTES } from '@/constants';
|
||||
import { formatIrrToToman } from '@/utils';
|
||||
import { useCancelBooking, useCancellationPolicyPreview } from '@/services/refunds';
|
||||
import type { CancelReasonCategory } from '@/services/refunds/types';
|
||||
|
||||
@@ -104,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 = () =>
|
||||
cancel.mutate(
|
||||
{
|
||||
@@ -175,7 +172,14 @@ export default function CancelBookingPage() {
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 800, mb: 1 }}>
|
||||
{t('confirm_title')}
|
||||
</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>
|
||||
|
||||
{cancel.isError && (
|
||||
|
||||
@@ -5,7 +5,7 @@ import { Divider, GlobalStyles, Paper, Skeleton, Stack, Typography } from '@mui/
|
||||
import { AppButton, AppIcon, PriceBreakdown, StatusChip, type StatusKind } from '@/components';
|
||||
import { ROUTES } from '@/constants';
|
||||
import { ApiError } from '@/lib/api/errors';
|
||||
import { formatShamsiDate, parseIrr } from '@/utils';
|
||||
import { formatShamsiDate, localeTag, parseIrr } from '@/utils';
|
||||
import { useInvoice } from '@/services/payment';
|
||||
import type { MoadianStatus } from '@/services/payment/types';
|
||||
|
||||
@@ -118,7 +118,7 @@ export default function BookingInvoicePage() {
|
||||
parseIrr(invoice.grossIrr) - parseIrr(invoice.platformCommissionIrr) - parseIrr(invoice.vatIrr)
|
||||
).toString();
|
||||
// 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',
|
||||
maximumFractionDigits: 2,
|
||||
}).format(invoice.vatRate);
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useLocale, useTranslations } from 'next-intl';
|
||||
import { useParams, useRouter } from 'next/navigation';
|
||||
import { useSnackbar } from 'notistack';
|
||||
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 { formatShamsiDate } from '@/utils';
|
||||
import { useBookingDetail } from '@/services/bookings';
|
||||
@@ -119,11 +119,7 @@ export default function LeaveReviewPage() {
|
||||
return (
|
||||
<Stack sx={{ gap: 3, maxWidth: 560, mx: 'auto', width: '100%' }}>
|
||||
<PageHeading title={t('not_eligible_title')} />
|
||||
<Paper elevation={0} sx={{ p: 3, textAlign: 'center', border: '1px dashed', borderColor: 'divider', borderRadius: 2 }}>
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||
{t(`reason_${reason}`)}
|
||||
</Typography>
|
||||
</Paper>
|
||||
<EmptyState title={t(`reason_${reason}`)} />
|
||||
<AppButton variant="outlined" color="primary" onClick={() => router.back()} sx={{ alignSelf: 'flex-start' }}>
|
||||
{tc('back')}
|
||||
</AppButton>
|
||||
|
||||
+4
-7
@@ -1,9 +1,9 @@
|
||||
'use client';
|
||||
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 { AppButton, AppIcon, PhoneNumberField } from '@/components';
|
||||
import { digitsOnly, formatIrrToToman } from '@/utils';
|
||||
import { AppButton, AppIcon, Money, PhoneNumberField } from '@/components';
|
||||
import { digitsOnly } from '@/utils';
|
||||
import { useCheckEligibility } from '@/services/bnpl';
|
||||
import { NATIONAL_ID_LENGTH, NATIONAL_ID_PATTERN } from '@/services/bnpl/constants';
|
||||
import type { BnplEligibilityResult, ProviderCode } from '@/services/bnpl/types';
|
||||
@@ -35,7 +35,6 @@ const EligibilityStep: FunctionComponent<EligibilityStepProps> = ({
|
||||
}) => {
|
||||
const t = useTranslations('bnpl');
|
||||
const tc = useTranslations('common');
|
||||
const locale = useLocale();
|
||||
|
||||
const [nationalId, setNationalId] = useState('');
|
||||
const [consent, setConsent] = useState(false);
|
||||
@@ -79,9 +78,7 @@ const EligibilityStep: FunctionComponent<EligibilityStepProps> = ({
|
||||
<Typography variant="caption" sx={{ color: 'text.secondary', mt: 0.5 }}>
|
||||
{t('credit_ceiling_label')}
|
||||
</Typography>
|
||||
<Typography variant="h6" sx={{ fontWeight: 800 }}>
|
||||
{formatIrrToToman(result.creditCeilingIrr, locale)} {tc('currency_toman')}
|
||||
</Typography>
|
||||
<Money amountIrr={result.creditCeilingIrr} tone="emphasis" size="lg" />
|
||||
</>
|
||||
) : null}
|
||||
</Stack>
|
||||
|
||||
+4
-16
@@ -1,9 +1,8 @@
|
||||
'use client';
|
||||
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 { AppButton, AppIcon } from '@/components';
|
||||
import { formatIrrToToman } from '@/utils';
|
||||
import { AppButton, AppIcon, EmptyState, Money } from '@/components';
|
||||
import type { BnplOptions, BnplProvider, ProviderCode } from '@/services/bnpl/types';
|
||||
|
||||
interface MethodStepProps {
|
||||
@@ -36,8 +35,6 @@ const MethodStep: FunctionComponent<MethodStepProps> = ({
|
||||
onPayWithCard,
|
||||
}) => {
|
||||
const t = useTranslations('bnpl');
|
||||
const tc = useTranslations('common');
|
||||
const locale = useLocale();
|
||||
const hasProviders = options.providers.length > 0;
|
||||
|
||||
return (
|
||||
@@ -53,9 +50,7 @@ const MethodStep: FunctionComponent<MethodStepProps> = ({
|
||||
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
|
||||
{t('payable_amount')}
|
||||
</Typography>
|
||||
<Typography variant="h6" sx={{ fontWeight: 800, mt: 0.5 }}>
|
||||
{formatIrrToToman(options.orderAmountIrr, locale)} {tc('currency_toman')}
|
||||
</Typography>
|
||||
<Money amountIrr={options.orderAmountIrr} tone="emphasis" size="lg" sx={{ mt: 0.5 }} />
|
||||
</Paper>
|
||||
|
||||
{/* Full-card option — selecting it continues the f9 card flow (C6), which this phase does not rebuild. */}
|
||||
@@ -116,14 +111,7 @@ const MethodStep: FunctionComponent<MethodStepProps> = ({
|
||||
</AppButton>
|
||||
</>
|
||||
) : (
|
||||
<Paper elevation={0} sx={{ p: 2.5, borderRadius: 2, border: '1px dashed', borderColor: 'divider' }}>
|
||||
<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>
|
||||
<EmptyState title={t('no_providers_title')} body={t('no_providers_body')} />
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
|
||||
+4
-15
@@ -1,9 +1,8 @@
|
||||
'use client';
|
||||
import { FunctionComponent } from 'react';
|
||||
import { useLocale, useTranslations } from 'next-intl';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { Paper, Stack, Typography } from '@mui/material';
|
||||
import { AppButton, BnplPlanCard } from '@/components';
|
||||
import { formatIrrToToman } from '@/utils';
|
||||
import { AppButton, BnplPlanCard, EmptyState, Money } from '@/components';
|
||||
import type { BnplPlanOption, ProviderCode } from '@/services/bnpl/types';
|
||||
|
||||
interface PlanStepProps {
|
||||
@@ -31,19 +30,11 @@ const PlanStep: FunctionComponent<PlanStepProps> = ({
|
||||
}) => {
|
||||
const t = useTranslations('bnpl');
|
||||
const tc = useTranslations('common');
|
||||
const locale = useLocale();
|
||||
|
||||
if (plans.length === 0) {
|
||||
return (
|
||||
<Stack sx={{ gap: 2 }}>
|
||||
<Paper elevation={0} sx={{ p: 2.5, borderRadius: 2, border: '1px dashed', borderColor: 'divider' }}>
|
||||
<Typography variant="body2" sx={{ fontWeight: 700 }}>
|
||||
{t('no_plans_title')}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary', mt: 0.5 }}>
|
||||
{t('no_plans_body')}
|
||||
</Typography>
|
||||
</Paper>
|
||||
<EmptyState title={t('no_plans_title')} body={t('no_plans_body')} />
|
||||
<AppButton variant="outlined" color="primary" onClick={onBack}>
|
||||
{t('back_to_providers')}
|
||||
</AppButton>
|
||||
@@ -69,9 +60,7 @@ const PlanStep: FunctionComponent<PlanStepProps> = ({
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||
{t('total_amount')}
|
||||
</Typography>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 800 }}>
|
||||
{formatIrrToToman(shownPlan.totalIrr, locale)} {tc('currency_toman')}
|
||||
</Typography>
|
||||
<Money amountIrr={shownPlan.totalIrr} tone="emphasis" size="sm" />
|
||||
</Stack>
|
||||
</Paper>
|
||||
|
||||
|
||||
+17
-19
@@ -2,8 +2,8 @@
|
||||
import { Suspense } from 'react';
|
||||
import { useLocale, useTranslations } from 'next-intl';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import { Paper, Stack, Typography } from '@mui/material';
|
||||
import { AppButton, AppIcon, AppLoading } from '@/components';
|
||||
import { Stack } from '@mui/material';
|
||||
import { AppButton, AppLoading, EmptyState } from '@/components';
|
||||
import { ROUTES } from '@/constants';
|
||||
import {
|
||||
BNPL_QUERY_OUTCOME,
|
||||
@@ -52,22 +52,20 @@ function BnplGatewayScreen() {
|
||||
};
|
||||
|
||||
return (
|
||||
<Paper elevation={0} sx={{ p: 4, textAlign: 'center', border: '1px dashed', borderColor: 'divider', borderRadius: 2 }}>
|
||||
<Stack sx={{ gap: 1.5, alignItems: 'center' }}>
|
||||
<AppIcon icon="installments" size={44} color="var(--bal-secondary)" />
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
|
||||
{t('handoff_title')}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||
{t('handoff_body', { provider: providerName })}
|
||||
</Typography>
|
||||
<AppButton color="secondary" variant="contained" size="large" onClick={() => returnWith('success')}>
|
||||
{t('handoff_pay_success')}
|
||||
</AppButton>
|
||||
<AppButton variant="text" color="error" onClick={() => returnWith('failure')}>
|
||||
{t('handoff_pay_fail')}
|
||||
</AppButton>
|
||||
</Stack>
|
||||
</Paper>
|
||||
<EmptyState
|
||||
icon="installments"
|
||||
title={t('handoff_title')}
|
||||
body={t('handoff_body', { provider: providerName })}
|
||||
action={
|
||||
<Stack sx={{ gap: 1.5, alignItems: 'center', width: '100%' }}>
|
||||
<AppButton color="secondary" variant="contained" size="large" onClick={() => returnWith('success')}>
|
||||
{t('handoff_pay_success')}
|
||||
</AppButton>
|
||||
<AppButton variant="text" color="error" onClick={() => returnWith('failure')}>
|
||||
{t('handoff_pay_fail')}
|
||||
</AppButton>
|
||||
</Stack>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
+2
-6
@@ -3,9 +3,8 @@ import { Suspense } from 'react';
|
||||
import { useLocale, useTranslations } from 'next-intl';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
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 { formatIrrToToman } from '@/utils';
|
||||
import { useCheckoutSummary } from '@/services/payment';
|
||||
import { CHECKOUT_QUERY_BOOKING_ID, CHECKOUT_QUERY_REQUEST_ID } from '@/services/payment/constants';
|
||||
import {
|
||||
@@ -32,7 +31,6 @@ export default function CheckoutConfirmationPage() {
|
||||
|
||||
function ConfirmationScreen() {
|
||||
const t = useTranslations('payment');
|
||||
const tc = useTranslations('common');
|
||||
const tBnpl = useTranslations('bnpl');
|
||||
const locale = useLocale();
|
||||
const router = useRouter();
|
||||
@@ -69,9 +67,7 @@ function ConfirmationScreen() {
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||
{t('total_paid_label')}
|
||||
</Typography>
|
||||
<Typography variant="h6" sx={{ color: 'var(--bal-secondary)' }}>
|
||||
{formatIrrToToman(summary.totalIrr, locale)} {tc('currency_toman')}
|
||||
</Typography>
|
||||
<Money amountIrr={summary.totalIrr} tone="emphasis" size="lg" />
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||
{summary.variantLabel} · {summary.nurseName}
|
||||
</Typography>
|
||||
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
import AppAlert from '@/components/common/AppAlert';
|
||||
import { ROUTES } from '@/constants';
|
||||
import { ApiError } from '@/lib/api/errors';
|
||||
import { formatShamsiDate } from '@/utils';
|
||||
import { formatShamsiDate, localeTag } from '@/utils';
|
||||
import { useCheckoutSummary, useInitiatePayment } from '@/services/payment';
|
||||
import {
|
||||
BNPL_ENABLED,
|
||||
@@ -230,7 +230,7 @@ function CheckoutScreen() {
|
||||
function EngagementSummary({ summary, locale }: { summary: CheckoutSummaryDto; locale: string }) {
|
||||
const start = new Date(`${summary.requestedDate}T${summary.requestedTimeStart}`);
|
||||
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',
|
||||
minute: '2-digit',
|
||||
});
|
||||
|
||||
@@ -1,103 +1,13 @@
|
||||
'use client';
|
||||
import { useLocale, useTranslations } from 'next-intl';
|
||||
import { useRouter } from 'next/navigation';
|
||||
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';
|
||||
import type { Metadata } from 'next';
|
||||
import { getTranslations } from 'next-intl/server';
|
||||
import BookingsScreen from './BookingsScreen';
|
||||
|
||||
/**
|
||||
* 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 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>
|
||||
);
|
||||
export async function generateMetadata({ params }: { params: Promise<{ locale: string }> }): Promise<Metadata> {
|
||||
const { locale } = await params;
|
||||
const t = await getTranslations({ locale, namespace: 'booking' });
|
||||
return { title: t('list_title') };
|
||||
}
|
||||
|
||||
function BookingRow({ item }: { item: BookingListItemDto }) {
|
||||
const t = useTranslations('booking');
|
||||
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}`)}
|
||||
>
|
||||
{t('view_booking')}
|
||||
</AppButton>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Paper>
|
||||
);
|
||||
export default function Page() {
|
||||
return <BookingsScreen />;
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
ToggleButtonGroup,
|
||||
Typography,
|
||||
} from '@mui/material';
|
||||
import { AppButton, AppIcon, AppLoading, PriceDisplay } from '@/components';
|
||||
import { AppButton, AppLoading, EmptyState, PriceDisplay } from '@/components';
|
||||
import { AddressMapPicker } from '@/components/geography';
|
||||
import { ROUTES } from '@/constants';
|
||||
import { ApiError } from '@/lib/api/errors';
|
||||
@@ -192,8 +192,16 @@ function BookingRequestForm() {
|
||||
icon="search"
|
||||
title={t('missing_nurse_title')}
|
||||
body={t('missing_nurse_body')}
|
||||
ctaLabel={t('missing_nurse_cta')}
|
||||
onCta={() => router.push(`/${locale}${ROUTES.SEARCH}`)}
|
||||
action={
|
||||
<AppButton
|
||||
variant="contained"
|
||||
color="primary"
|
||||
onClick={() => router.push(`/${locale}${ROUTES.SEARCH}`)}
|
||||
sx={{ m: 0 }}
|
||||
>
|
||||
{t('missing_nurse_cta')}
|
||||
</AppButton>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -489,49 +497,18 @@ function FieldEmpty({
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>
|
||||
{label}
|
||||
</Typography>
|
||||
<Paper elevation={0} sx={{ p: 2, border: '1px dashed', borderColor: 'divider', borderRadius: 2 }}>
|
||||
<Stack direction="row" sx={{ gap: 1, alignItems: 'center', justifyContent: 'space-between', flexWrap: 'wrap' }}>
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||
{message}
|
||||
</Typography>
|
||||
<EmptyState
|
||||
title={message}
|
||||
action={
|
||||
<AppButton variant="outlined" color="primary" startIcon="add" onClick={onCta}>
|
||||
{ctaLabel}
|
||||
</AppButton>
|
||||
</Stack>
|
||||
</Paper>
|
||||
}
|
||||
/>
|
||||
</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}>
|
||||
{ctaLabel}
|
||||
</AppButton>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
function FormSkeleton() {
|
||||
return (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -1,210 +1,13 @@
|
||||
'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 } 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';
|
||||
import type { Metadata } from 'next';
|
||||
import { getTranslations } from 'next-intl/server';
|
||||
import HomeScreen from './HomeScreen';
|
||||
|
||||
interface NudgeCardProps {
|
||||
icon: string;
|
||||
title: string;
|
||||
body: string;
|
||||
ctaLabel: string;
|
||||
to: string;
|
||||
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') };
|
||||
}
|
||||
|
||||
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 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>
|
||||
);
|
||||
export default function Page() {
|
||||
return <HomeScreen />;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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()}>
|
||||
{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,
|
||||
Typography,
|
||||
} from '@mui/material';
|
||||
import { AppButton, AppIcon, PatientHeader, VisitNoteCard } from '@/components';
|
||||
import { AppButton, AppIcon, EmptyState, PatientHeader, VisitNoteCard } from '@/components';
|
||||
import { ROUTES } from '@/constants';
|
||||
import { formatShamsiDate } from '@/utils';
|
||||
import { usePatient } from '@/services/patients';
|
||||
@@ -59,15 +59,7 @@ export default function PatientRecordPage() {
|
||||
if (access.data && !access.data.canView) {
|
||||
return (
|
||||
<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 }}>
|
||||
<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>
|
||||
<EmptyState icon="lock" title={t('access_denied_title')} body={t('access_denied_body')} />
|
||||
<BackToPatients />
|
||||
</Stack>
|
||||
);
|
||||
@@ -78,14 +70,7 @@ export default function PatientRecordPage() {
|
||||
if (patient.isError || !patient.data) {
|
||||
return (
|
||||
<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 }}>
|
||||
<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>
|
||||
<EmptyState title={t('not_found_title')} body={t('not_found_body')} />
|
||||
<BackToPatients />
|
||||
</Stack>
|
||||
);
|
||||
|
||||
@@ -9,12 +9,11 @@ import {
|
||||
DialogActions,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
Paper,
|
||||
Skeleton,
|
||||
Stack,
|
||||
Typography,
|
||||
} from '@mui/material';
|
||||
import { AppButton, AppIcon, PatientCard, PatientForm } from '@/components';
|
||||
import { AppButton, EmptyState, ErrorState, PatientCard, PatientForm } from '@/components';
|
||||
import { patientRecordPath } from '@/constants';
|
||||
import { usePatients, useCreatePatient, useUpdatePatient, useArchivePatient } from '@/services/patients';
|
||||
import { birthDateToAge } from '@/services/patients/age';
|
||||
@@ -33,7 +32,7 @@ export default function PatientsPage() {
|
||||
const locale = useLocale();
|
||||
const { enqueueSnackbar } = useSnackbar();
|
||||
|
||||
const { data, isLoading } = usePatients();
|
||||
const { data, isLoading, isError, refetch } = usePatients();
|
||||
const createPatient = useCreatePatient();
|
||||
const updatePatient = useUpdatePatient();
|
||||
const archivePatient = useArchivePatient();
|
||||
@@ -80,7 +79,7 @@ export default function PatientsPage() {
|
||||
};
|
||||
|
||||
const patients = data?.items ?? [];
|
||||
const isEmpty = !isLoading && patients.length === 0;
|
||||
const isEmpty = !isLoading && !isError && patients.length === 0;
|
||||
|
||||
return (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
|
||||
@@ -93,7 +92,7 @@ export default function PatientsPage() {
|
||||
{t('subtitle')}
|
||||
</Typography>
|
||||
</Box>
|
||||
{!isEmpty ? (
|
||||
{!isEmpty && !isError ? (
|
||||
<AppButton color="primary" variant="contained" startIcon="add" onClick={openAdd} sx={{ flexShrink: 0 }}>
|
||||
{t('add')}
|
||||
</AppButton>
|
||||
@@ -106,32 +105,19 @@ export default function PatientsPage() {
|
||||
<Skeleton key={key} variant="rounded" height={96} />
|
||||
))}
|
||||
</Stack>
|
||||
) : isError ? (
|
||||
<ErrorState message={t('load_error')} retryLabel={tc('retry')} onRetry={() => refetch()} />
|
||||
) : isEmpty ? (
|
||||
<Paper
|
||||
elevation={0}
|
||||
sx={{
|
||||
p: 4,
|
||||
textAlign: 'center',
|
||||
border: '1px dashed',
|
||||
borderColor: 'divider',
|
||||
borderRadius: 2,
|
||||
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>
|
||||
<EmptyState
|
||||
icon="patients"
|
||||
title={t('empty_title')}
|
||||
body={t('empty_body')}
|
||||
action={
|
||||
<AppButton color="primary" variant="contained" startIcon="add" onClick={openAdd}>
|
||||
{t('add')}
|
||||
</AppButton>
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<Stack sx={{ gap: 1.5 }}>
|
||||
{patients.map((patient) => {
|
||||
|
||||
@@ -3,7 +3,7 @@ import { FunctionComponent, useState } from 'react';
|
||||
import { useLocale, useTranslations } from 'next-intl';
|
||||
import { useSnackbar } from 'notistack';
|
||||
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 { ROUTES } from '@/constants';
|
||||
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. */
|
||||
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();
|
||||
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
|
||||
// editing the emergency contact never blanks (and re-saves as null) the existing name.
|
||||
return (
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
+21
-34
@@ -3,10 +3,10 @@ import { useState } from 'react';
|
||||
import { useLocale, useTranslations } from 'next-intl';
|
||||
import { useParams, useRouter, useSearchParams } from 'next/navigation';
|
||||
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 { ApiError } from '@/lib/api/errors';
|
||||
import { formatShamsiDate } from '@/utils';
|
||||
import { formatNumber, formatShamsiDate } from '@/utils';
|
||||
import { useNurseProfile } from '@/services/search';
|
||||
import type { NurseProfile } from '@/services/search/types';
|
||||
import { useNurseReviews } from '@/services/reviews';
|
||||
@@ -37,22 +37,18 @@ export default function NurseProfilePage() {
|
||||
|
||||
if (isError) {
|
||||
const notFound = error instanceof ApiError && error.status === 404;
|
||||
return (
|
||||
<Paper elevation={0} sx={{ p: 4, textAlign: 'center', border: '1px dashed', borderColor: 'divider', borderRadius: 2 }}>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 700, mb: 1 }}>
|
||||
{notFound ? t('profile_not_found_title') : t('profile_error_title')}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary', mb: 2 }}>
|
||||
{notFound ? t('profile_not_found_body') : t('profile_error_body')}
|
||||
</Typography>
|
||||
<AppButton
|
||||
variant="outlined"
|
||||
color="primary"
|
||||
onClick={() => (notFound ? router.push(`/${locale}${ROUTES.SEARCH}`) : refetch())}
|
||||
>
|
||||
{notFound ? t('profile_not_found_cta') : t('retry')}
|
||||
</AppButton>
|
||||
</Paper>
|
||||
return notFound ? (
|
||||
<EmptyState
|
||||
title={t('profile_not_found_title')}
|
||||
body={t('profile_not_found_body')}
|
||||
action={
|
||||
<AppButton variant="outlined" color="primary" onClick={() => router.push(`/${locale}${ROUTES.SEARCH}`)}>
|
||||
{t('profile_not_found_cta')}
|
||||
</AppButton>
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<ErrorState message={t('profile_error_body')} retryLabel={t('retry')} onRetry={() => refetch()} />
|
||||
);
|
||||
}
|
||||
|
||||
@@ -106,10 +102,10 @@ function ProfileHeader({ profile }: { profile: NurseProfile }) {
|
||||
const t = useTranslations('search');
|
||||
const locale = useLocale();
|
||||
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,
|
||||
maximumFractionDigits: 1,
|
||||
}).format(profile.averageRating);
|
||||
});
|
||||
|
||||
return (
|
||||
<Stack sx={{ gap: 1.5 }}>
|
||||
@@ -161,7 +157,7 @@ function AttributeChips({ profile }: { profile: NurseProfile }) {
|
||||
const locale = useLocale();
|
||||
const chips: string[] = [];
|
||||
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 }));
|
||||
}
|
||||
for (const code of profile.attributeChips) {
|
||||
@@ -224,16 +220,7 @@ function ReviewsPanel({ nurseId }: { nurseId: number }) {
|
||||
}
|
||||
|
||||
if (isError) {
|
||||
return (
|
||||
<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()}>
|
||||
{t('retry')}
|
||||
</AppButton>
|
||||
</Paper>
|
||||
);
|
||||
return <ErrorState message={t('load_error')} retryLabel={t('retry')} onRetry={() => refetch()} />;
|
||||
}
|
||||
|
||||
const aggregate = data?.pages[0]?.aggregate;
|
||||
@@ -248,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,
|
||||
maximumFractionDigits: 1,
|
||||
}).format(aggregate?.averageRating ?? 0);
|
||||
});
|
||||
|
||||
return (
|
||||
<Stack sx={{ gap: 2 }}>
|
||||
<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 }}>
|
||||
{average}
|
||||
</Typography>
|
||||
|
||||
@@ -1,220 +1,13 @@
|
||||
'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,
|
||||
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';
|
||||
import type { Metadata } from 'next';
|
||||
import { getTranslations } from 'next-intl/server';
|
||||
import SearchScreen from './SearchScreen';
|
||||
|
||||
/**
|
||||
* 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 SearchPage() {
|
||||
return (
|
||||
<Suspense fallback={<AppLoading />}>
|
||||
<SearchFilterScreen />
|
||||
</Suspense>
|
||||
);
|
||||
export async function generateMetadata({ params }: { params: Promise<{ locale: string }> }): Promise<Metadata> {
|
||||
const { locale } = await params;
|
||||
const t = await getTranslations({ locale, namespace: 'search' });
|
||||
return { title: t('title') };
|
||||
}
|
||||
|
||||
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>
|
||||
);
|
||||
export default function Page() {
|
||||
return <SearchScreen />;
|
||||
}
|
||||
|
||||
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 { useRouter, useSearchParams } from 'next/navigation';
|
||||
import { useLocale, useTranslations } from 'next-intl';
|
||||
import { Box, MenuItem, Paper, Skeleton, Stack, TextField, Typography } from '@mui/material';
|
||||
import { AppButton, AppIcon, AppLoading, NurseResultCard } from '@/components';
|
||||
import { MenuItem, Stack, TextField, Typography } from '@mui/material';
|
||||
import { AppButton, AppLoading, EmptyState, ErrorState, NurseResultCard } from '@/components';
|
||||
import { ROUTES } from '@/constants';
|
||||
import { useNurseSearch } from '@/services/search';
|
||||
import { searchParamsToFilters } from '@/services/search/filterParams';
|
||||
@@ -27,6 +27,7 @@ export default function SearchResultsPage() {
|
||||
|
||||
function ResultsScreen() {
|
||||
const t = useTranslations('search');
|
||||
const tc = useTranslations('common');
|
||||
const locale = useLocale();
|
||||
const router = useRouter();
|
||||
const params = useSearchParams();
|
||||
@@ -72,20 +73,13 @@ function ResultsScreen() {
|
||||
{isLoading ? (
|
||||
<Stack sx={{ gap: 1.5 }}>
|
||||
{[0, 1, 2, 3].map((key) => (
|
||||
<Skeleton key={key} variant="rounded" height={112} sx={{ borderRadius: 2 }} />
|
||||
<NurseResultCard.Skeleton key={key} />
|
||||
))}
|
||||
</Stack>
|
||||
) : isError ? (
|
||||
<Paper elevation={0} sx={{ p: 4, textAlign: 'center', border: '1px dashed', borderColor: 'divider', borderRadius: 2 }}>
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary', mb: 2 }}>
|
||||
{t('results_error')}
|
||||
</Typography>
|
||||
<AppButton variant="outlined" color="primary" onClick={() => refetch()}>
|
||||
{t('retry')}
|
||||
</AppButton>
|
||||
</Paper>
|
||||
<ErrorState message={t('results_error')} retryLabel={tc('retry')} onRetry={() => refetch()} />
|
||||
) : items.length === 0 ? (
|
||||
<EmptyState onRelax={backToFilters} />
|
||||
<RelaxFiltersEmptyState onRelax={backToFilters} />
|
||||
) : (
|
||||
<Stack sx={{ gap: 1.5 }}>
|
||||
{items.map((nurse) => (
|
||||
@@ -109,28 +103,30 @@ function ResultsScreen() {
|
||||
}
|
||||
|
||||
/** 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');
|
||||
return (
|
||||
<Paper elevation={0} sx={{ p: 4, textAlign: 'center', border: '1px dashed', borderColor: 'divider', borderRadius: 2 }}>
|
||||
<AppIcon icon="search" size={40} color="var(--bal-text-secondary)" />
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 700, mt: 1 }}>
|
||||
{t('empty_title')}
|
||||
</Typography>
|
||||
<Stack sx={{ gap: 0.5, mt: 1, mb: 2 }}>
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||
{t('empty_suggest_gender')}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||
{t('empty_suggest_district')}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||
{t('empty_suggest_city')}
|
||||
</Typography>
|
||||
</Stack>
|
||||
<AppButton variant="contained" color="primary" onClick={onRelax} startIcon="tune">
|
||||
{t('empty_cta')}
|
||||
</AppButton>
|
||||
</Paper>
|
||||
<EmptyState
|
||||
icon="search"
|
||||
title={t('empty_title')}
|
||||
body={
|
||||
<Stack sx={{ gap: 0.5 }}>
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||
{t('empty_suggest_gender')}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||
{t('empty_suggest_district')}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||
{t('empty_suggest_city')}
|
||||
</Typography>
|
||||
</Stack>
|
||||
}
|
||||
action={
|
||||
<AppButton variant="contained" color="primary" onClick={onRelax} startIcon="tune">
|
||||
{t('empty_cta')}
|
||||
</AppButton>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
import { FunctionComponent } from 'react';
|
||||
import { useLocale, useTranslations } from 'next-intl';
|
||||
import { Box, Paper, Skeleton, Stack, Typography } from '@mui/material';
|
||||
import { AppButton, AppIcon, InstallmentScheduleRow, PlaceholderScreen } from '@/components';
|
||||
import { formatIrrToToman, formatShamsiDate } from '@/utils';
|
||||
import { AppButton, AppIcon, InstallmentScheduleRow, Money, PlaceholderScreen } from '@/components';
|
||||
import { formatShamsiDate } from '@/utils';
|
||||
import { useWalletInstallments } from '@/services/bnpl';
|
||||
import type { WalletInstallmentPlan } from '@/services/bnpl/types';
|
||||
|
||||
@@ -62,7 +62,6 @@ const WalletInstallments: FunctionComponent = () => {
|
||||
|
||||
function InstallmentPlanSection({ plan }: { plan: WalletInstallmentPlan }) {
|
||||
const t = useTranslations('bnpl');
|
||||
const tc = useTranslations('common');
|
||||
const locale = useLocale();
|
||||
const providerName = t(`provider_${plan.providerCode}`);
|
||||
|
||||
@@ -81,9 +80,7 @@ function InstallmentPlanSection({ plan }: { plan: WalletInstallmentPlan }) {
|
||||
<Typography variant="caption" sx={{ opacity: 0.85 }}>
|
||||
{t('outstanding_balance')}
|
||||
</Typography>
|
||||
<Typography variant="h5" sx={{ fontWeight: 800, mt: 0.25 }}>
|
||||
{formatIrrToToman(plan.outstandingBalanceIrr, locale)} {tc('currency_toman')}
|
||||
</Typography>
|
||||
<Money amountIrr={plan.outstandingBalanceIrr} size="lg" sx={{ fontWeight: 800, mt: 0.25 }} />
|
||||
<Typography variant="caption" sx={{ opacity: 0.85, display: 'block', mt: 0.5 }}>
|
||||
{plan.serviceLabel}
|
||||
</Typography>
|
||||
@@ -95,9 +92,7 @@ function InstallmentPlanSection({ plan }: { plan: WalletInstallmentPlan }) {
|
||||
{t('next_installment')} · {formatShamsiDate(`${plan.nextDueDate}T00:00:00`, locale)}
|
||||
</Typography>
|
||||
{plan.nextAmountIrr ? (
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 800 }}>
|
||||
{formatIrrToToman(plan.nextAmountIrr, locale)} {tc('currency_toman')}
|
||||
</Typography>
|
||||
<Money amountIrr={plan.nextAmountIrr} size="sm" sx={{ fontWeight: 800 }} />
|
||||
) : null}
|
||||
</Box>
|
||||
{plan.earlyPayUrl ? (
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import SidebarShellSkeleton from '../_chrome/SidebarShellSkeleton';
|
||||
|
||||
export default function Loading() {
|
||||
return <SidebarShellSkeleton />;
|
||||
}
|
||||
@@ -1,81 +1,13 @@
|
||||
'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';
|
||||
import type { Metadata } from 'next';
|
||||
import { getTranslations } from 'next-intl/server';
|
||||
import AdminOverviewScreen from './AdminOverviewScreen';
|
||||
|
||||
/**
|
||||
* 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 AdminOverviewPage() {
|
||||
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>
|
||||
);
|
||||
export async function generateMetadata({ params }: { params: Promise<{ locale: string }> }): Promise<Metadata> {
|
||||
const { locale } = await params;
|
||||
const t = await getTranslations({ locale, namespace: 'admin' });
|
||||
return { title: t('overview_title') };
|
||||
}
|
||||
|
||||
export default function Page() {
|
||||
return <AdminOverviewScreen />;
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
TextField,
|
||||
Typography,
|
||||
} from '@mui/material';
|
||||
import { AppButton, StatusChip } from '@/components';
|
||||
import { AppButton, Money, StatusChip } from '@/components';
|
||||
import type { StatusKind } from '@/components';
|
||||
import {
|
||||
AdminDataTable,
|
||||
@@ -30,7 +30,7 @@ import {
|
||||
import type { AdminTableColumn } from '@/components/admin';
|
||||
import { useAdminCapabilities } from '@/hooks';
|
||||
import { adminPayoutBatchPath } from '@/constants';
|
||||
import { formatIrrToToman, formatShamsiDate } from '@/utils';
|
||||
import { formatShamsiDate } from '@/utils';
|
||||
import { PAYOUTS_PAGE_SIZE } from '@/services/payouts/constants';
|
||||
import { usePayoutBatches, usePreviewPayoutBatch, useRunPayoutBatch } from '@/services/payouts';
|
||||
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() {
|
||||
const t = useTranslations('admin');
|
||||
const tCommon = useTranslations('common');
|
||||
const locale = useLocale();
|
||||
const router = useRouter();
|
||||
const caps = useAdminCapabilities();
|
||||
@@ -92,7 +91,7 @@ export default function AdminPayoutsPage() {
|
||||
{
|
||||
key: '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',
|
||||
@@ -189,7 +188,6 @@ export default function AdminPayoutsPage() {
|
||||
*/
|
||||
function PreviewBatchDialog({ canPayout, onClose }: { canPayout: boolean; onClose: () => void }) {
|
||||
const t = useTranslations('admin');
|
||||
const tCommon = useTranslations('common');
|
||||
const locale = useLocale();
|
||||
const router = useRouter();
|
||||
const { enqueueSnackbar } = useSnackbar();
|
||||
@@ -304,9 +302,9 @@ function PreviewBatchDialog({ canPayout, onClose }: { canPayout: boolean; onClos
|
||||
) : null}
|
||||
</Stack>
|
||||
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
|
||||
{t('payout_col_gross')}: {formatIrrToToman(n.grossEarningsIrr, locale)} ·{' '}
|
||||
{t('payout_col_clawback')}: {formatIrrToToman(n.clawbackAppliedIrr, locale)} ·{' '}
|
||||
{t('payout_col_net')}: {formatIrrToToman(n.netAmountIrr, locale)} {tCommon('currency_toman')}
|
||||
{t('payout_col_gross')}: <Money amountIrr={n.grossEarningsIrr} size="sm" hideUnit /> ·{' '}
|
||||
{t('payout_col_clawback')}: <Money amountIrr={n.clawbackAppliedIrr} size="sm" hideUnit /> ·{' '}
|
||||
{t('payout_col_net')}: <Money amountIrr={n.netAmountIrr} size="sm" />
|
||||
</Typography>
|
||||
</Stack>
|
||||
))}
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
import { useState } from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useSnackbar } from 'notistack';
|
||||
import { Box, Paper, Stack, TextField, Typography } from '@mui/material';
|
||||
import { AppButton, AppIcon, AppLoading, BankStatusPanel } from '@/components';
|
||||
import { Box, Stack, TextField, Typography } from '@mui/material';
|
||||
import { AppButton, AppLoading, BankStatusPanel, EmptyState } from '@/components';
|
||||
import { useNurseBankAccounts, useAddNurseBankAccount, useSetPrimaryBankAccount } from '@/services/nurse';
|
||||
import { isValidSheba } from '@/services/nurse/iban';
|
||||
import { deriveBankStatus } from '@/services/nurse/types';
|
||||
@@ -102,18 +102,7 @@ export default function NurseBankPage() {
|
||||
})}
|
||||
|
||||
{!isLoading && accounts.length === 0 ? (
|
||||
<Paper
|
||||
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>
|
||||
<EmptyState icon="bank" title={t('empty_title')} body={t('empty_body')} />
|
||||
) : null}
|
||||
|
||||
{showFormNow ? (
|
||||
|
||||
@@ -3,8 +3,9 @@ import { useMemo, useState } from 'react';
|
||||
import { useLocale, useTranslations } from 'next-intl';
|
||||
import { useRouter } from 'next/navigation';
|
||||
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 { formatNumber } from '@/utils';
|
||||
import { PAYOUTS_PAGE_SIZE } from '@/services/payouts/constants';
|
||||
import { EARNINGS_STATES, type EarningsState } from '@/services/payouts/types';
|
||||
import { useNurseEarnings, useNurseEarningsBalance } from '@/services/payouts';
|
||||
@@ -58,7 +59,7 @@ export default function NurseEarningsPage() {
|
||||
{balance.isLoading ? (
|
||||
<Skeleton variant="rounded" height={200} />
|
||||
) : 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 ? (
|
||||
<EarningsBalanceHeader summary={balance.data} />
|
||||
) : null}
|
||||
@@ -85,9 +86,9 @@ export default function NurseEarningsPage() {
|
||||
))}
|
||||
</Stack>
|
||||
) : 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 ? (
|
||||
<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 }}>
|
||||
{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}>
|
||||
{retryLabel}
|
||||
</AppButton>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
/** Prev/next pager — rendered only when there is more than one page. */
|
||||
function Pager({
|
||||
page,
|
||||
@@ -195,7 +166,7 @@ function Pager({
|
||||
const t = useTranslations('payouts');
|
||||
const locale = useLocale();
|
||||
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 (
|
||||
<Stack direction="row" sx={{ gap: 1, alignItems: 'center', justifyContent: 'center' }}>
|
||||
|
||||
@@ -3,10 +3,10 @@ import { FunctionComponent, ReactNode } from 'react';
|
||||
import { useParams, useRouter } from 'next/navigation';
|
||||
import { useLocale, useTranslations } from 'next-intl';
|
||||
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 { nurseBookingDetailPath, ROUTES } from '@/constants';
|
||||
import { formatIrrToToman, formatShamsiDate, parseIrr } from '@/utils';
|
||||
import { formatShamsiDate, parseIrr } from '@/utils';
|
||||
import { useNursePayoutDetail } from '@/services/payouts';
|
||||
import type { PayoutBatchStatus, PayoutStatus } from '@/services/payouts/types';
|
||||
|
||||
@@ -34,7 +34,6 @@ const BATCH_STATUS_KIND: Record<PayoutBatchStatus, StatusKind> = {
|
||||
*/
|
||||
export default function NursePayoutDetailPage() {
|
||||
const t = useTranslations('payouts');
|
||||
const tc = useTranslations('common');
|
||||
const locale = useLocale();
|
||||
const router = useRouter();
|
||||
const params = useParams<{ id: string }>();
|
||||
@@ -159,9 +158,7 @@ export default function NursePayoutDetailPage() {
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||
{t('amount_transferred_label')}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ fontWeight: 700 }}>
|
||||
{formatIrrToToman(data.amountIrr, locale)}
|
||||
</Typography>
|
||||
<Money amountIrr={data.amountIrr} size="sm" hideUnit sx={{ fontWeight: 700 }} />
|
||||
</Stack>
|
||||
</Stack>
|
||||
|
||||
@@ -184,9 +181,7 @@ export default function NursePayoutDetailPage() {
|
||||
<Typography variant="body2" sx={{ fontWeight: 700 }}>
|
||||
{t('booking_ref', { id: link.bookingId })}
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
|
||||
{formatIrrToToman(link.payoutAmountIrr, locale)} {tc('currency_toman')}
|
||||
</Typography>
|
||||
<Money amountIrr={link.payoutAmountIrr} size="sm" tone="muted" />
|
||||
</Stack>
|
||||
<AppButton
|
||||
variant="text"
|
||||
|
||||
@@ -2,9 +2,10 @@
|
||||
import { useState } from 'react';
|
||||
import { useLocale, useTranslations } from 'next-intl';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { Box, Paper, Skeleton, Stack, Typography } from '@mui/material';
|
||||
import { AppButton, AppIcon, PayoutHistoryRow } from '@/components';
|
||||
import { Box, Skeleton, Stack, Typography } from '@mui/material';
|
||||
import { AppButton, EmptyState, ErrorState, PayoutHistoryRow } from '@/components';
|
||||
import { nursePayoutDetailPath, ROUTES } from '@/constants';
|
||||
import { formatNumber } from '@/utils';
|
||||
import { PAYOUTS_PAGE_SIZE } from '@/services/payouts/constants';
|
||||
import { useNursePayoutHistory } from '@/services/payouts';
|
||||
|
||||
@@ -25,7 +26,7 @@ export default function NursePayoutHistoryPage() {
|
||||
const items = history.data?.items ?? [];
|
||||
const total = history.data?.total ?? 0;
|
||||
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 (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
|
||||
@@ -54,24 +55,9 @@ export default function NursePayoutHistoryPage() {
|
||||
))}
|
||||
</Stack>
|
||||
) : history.isError ? (
|
||||
<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 }}>
|
||||
{t('history_error')}
|
||||
</Typography>
|
||||
<AppButton variant="outlined" color="primary" startIcon="refresh" onClick={() => history.refetch()}>
|
||||
{t('retry')}
|
||||
</AppButton>
|
||||
</Paper>
|
||||
<ErrorState message={t('history_error')} retryLabel={t('retry')} onRetry={() => history.refetch()} />
|
||||
) : items.length === 0 ? (
|
||||
<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 }}>
|
||||
{t('history_empty_title')}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary', mt: 0.5 }}>
|
||||
{t('history_empty_body')}
|
||||
</Typography>
|
||||
</Paper>
|
||||
<EmptyState icon="earnings" title={t('history_empty_title')} body={t('history_empty_body')} />
|
||||
) : (
|
||||
<Stack sx={{ gap: 2 }}>
|
||||
{items.map((item) => (
|
||||
|
||||
@@ -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 { 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() {
|
||||
const t = await getTranslations('nav');
|
||||
const tShell = await getTranslations('shell');
|
||||
|
||||
@@ -41,7 +41,10 @@ const NurseProfileForm: FunctionComponent<{ initial: NurseProfile | null }> = ({
|
||||
const file = event.target.files?.[0];
|
||||
event.target.value = '';
|
||||
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 = () => {
|
||||
@@ -60,7 +63,10 @@ const NurseProfileForm: FunctionComponent<{ initial: NurseProfile | null }> = ({
|
||||
specializationsJson: initial?.specializationsJson ?? '[]',
|
||||
avatarUrl,
|
||||
},
|
||||
{ onSuccess: () => enqueueSnackbar(t('saved'), { variant: 'success' }) },
|
||||
{
|
||||
onSuccess: () => enqueueSnackbar(t('saved'), { variant: 'success' }),
|
||||
onError: () => enqueueSnackbar(t('save_error'), { variant: 'error' }),
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ import {
|
||||
import { AppButton, AppIcon, CountdownTimer, PriceDisplay, StatusChip } from '@/components';
|
||||
import { ROUTES } from '@/constants';
|
||||
import { ApiError } from '@/lib/api/errors';
|
||||
import { formatShamsiDate } from '@/utils';
|
||||
import { formatShamsiDate, localeTag } from '@/utils';
|
||||
import {
|
||||
useAcceptBookingRequest,
|
||||
useBookingRequest,
|
||||
@@ -117,7 +117,7 @@ export default function NurseRequestDetailPage() {
|
||||
|
||||
const startDate = new Date(`${request.requestedDate}T${request.requestedTimeStart}`);
|
||||
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)}`;
|
||||
|
||||
return (
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
import { useLocale, useTranslations } from 'next-intl';
|
||||
import { useRouter } from 'next/navigation';
|
||||
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 { formatShamsiDate } from '@/utils';
|
||||
import { formatShamsiDate, localeTag } from '@/utils';
|
||||
import { useNurseRequestInbox } from '@/services/bookingRequests';
|
||||
import type { BookingRequestListItem } from '@/services/bookingRequests/types';
|
||||
|
||||
@@ -16,7 +16,8 @@ import type { BookingRequestListItem } from '@/services/bookingRequests/types';
|
||||
*/
|
||||
export default function NurseRequestsPage() {
|
||||
const t = useTranslations('booking');
|
||||
const { data, isLoading } = useNurseRequestInbox();
|
||||
const tc = useTranslations('common');
|
||||
const { data, isLoading, isError, refetch } = useNurseRequestInbox();
|
||||
const items = data?.items ?? [];
|
||||
|
||||
return (
|
||||
@@ -36,13 +37,10 @@ export default function NurseRequestsPage() {
|
||||
<Skeleton key={key} variant="rounded" height={140} />
|
||||
))}
|
||||
</Stack>
|
||||
) : isError ? (
|
||||
<ErrorState message={t('inbox_error')} retryLabel={tc('retry')} onRetry={() => refetch()} />
|
||||
) : items.length === 0 ? (
|
||||
<Paper elevation={0} sx={{ p: 4, textAlign: 'center', border: '1px dashed', borderColor: 'divider', borderRadius: 2 }}>
|
||||
<AppIcon icon="requests" size={40} color="var(--bal-text-secondary)" />
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary', mt: 1 }}>
|
||||
{t('inbox_empty')}
|
||||
</Typography>
|
||||
</Paper>
|
||||
<EmptyState icon="requests" title={t('inbox_empty')} />
|
||||
) : (
|
||||
<Stack sx={{ gap: 2 }}>
|
||||
{items.map((item) => (
|
||||
@@ -61,7 +59,7 @@ function InboxCard({ item }: { item: BookingRequestListItem }) {
|
||||
|
||||
const startDate = new Date(`${item.requestedDate}T${item.requestedTimeStart}`);
|
||||
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)}`;
|
||||
|
||||
return (
|
||||
|
||||
@@ -8,12 +8,11 @@ import {
|
||||
DialogActions,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
Paper,
|
||||
Skeleton,
|
||||
Stack,
|
||||
Typography,
|
||||
} from '@mui/material';
|
||||
import { AppButton, AppIcon, VariantCard } from '@/components';
|
||||
import { AppButton, EmptyState, ErrorState, VariantCard } from '@/components';
|
||||
import { useMyVariants, useSetVariantActive } from '@/services/catalog';
|
||||
import type { NurseServiceVariant } from '@/services/catalog/types';
|
||||
import PublishGate from './PublishGate';
|
||||
@@ -34,12 +33,12 @@ const MyServicesList: FunctionComponent<MyServicesListProps> = ({ onAdd, onEdit
|
||||
const tc = useTranslations('common');
|
||||
const { enqueueSnackbar } = useSnackbar();
|
||||
|
||||
const { data, isLoading } = useMyVariants();
|
||||
const { data, isLoading, isError, refetch } = useMyVariants();
|
||||
const setActive = useSetVariantActive();
|
||||
const [deactivateTarget, setDeactivateTarget] = useState<NurseServiceVariant | null>(null);
|
||||
|
||||
const variants = data?.items ?? [];
|
||||
const isEmpty = !isLoading && variants.length === 0;
|
||||
const isEmpty = !isLoading && !isError && variants.length === 0;
|
||||
|
||||
const toggleActive = (variant: NurseServiceVariant) => {
|
||||
// Deactivating is guarded by a confirm; reactivating is safe, so it fires immediately.
|
||||
@@ -80,7 +79,7 @@ const MyServicesList: FunctionComponent<MyServicesListProps> = ({ onAdd, onEdit
|
||||
{t('subtitle')}
|
||||
</Typography>
|
||||
</Box>
|
||||
{!isEmpty && !isLoading ? (
|
||||
{!isEmpty && !isLoading && !isError ? (
|
||||
<AppButton color="primary" variant="contained" startIcon="add" onClick={onAdd} sx={{ flexShrink: 0 }}>
|
||||
{t('add')}
|
||||
</AppButton>
|
||||
@@ -95,32 +94,19 @@ const MyServicesList: FunctionComponent<MyServicesListProps> = ({ onAdd, onEdit
|
||||
<Skeleton key={key} variant="rounded" height={150} sx={{ borderRadius: 2 }} />
|
||||
))}
|
||||
</Stack>
|
||||
) : isError ? (
|
||||
<ErrorState message={t('list_error')} retryLabel={tc('retry')} onRetry={() => refetch()} />
|
||||
) : isEmpty ? (
|
||||
<Paper
|
||||
elevation={0}
|
||||
sx={{
|
||||
p: 4,
|
||||
textAlign: 'center',
|
||||
border: '1px dashed',
|
||||
borderColor: 'divider',
|
||||
borderRadius: 2,
|
||||
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>
|
||||
<EmptyState
|
||||
icon="services"
|
||||
title={t('empty_title')}
|
||||
body={t('empty_body')}
|
||||
action={
|
||||
<AppButton color="primary" variant="contained" startIcon="add" onClick={onAdd}>
|
||||
{t('add')}
|
||||
</AppButton>
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<Stack sx={{ gap: 1.5 }}>
|
||||
{variants.map((variant) => (
|
||||
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
ToggleButtonGroup,
|
||||
Typography,
|
||||
} 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 { digitsOnly, rialToToman, tomanToRial } from '@/utils';
|
||||
import {
|
||||
@@ -383,6 +383,14 @@ const VariantBuilder: FunctionComponent<VariantBuilderProps> = ({ initial, onDon
|
||||
|
||||
{optionGroupsQuery.isLoading ? (
|
||||
<AppLoading />
|
||||
) : optionGroupsQuery.isError ? (
|
||||
// A failed fetch must never read as "this category has zero options" — that would let the
|
||||
// nurse skip required options entirely. Block progression until the retry succeeds.
|
||||
<ErrorState
|
||||
message={t('options_error')}
|
||||
retryLabel={tc('retry')}
|
||||
onRetry={() => optionGroupsQuery.refetch()}
|
||||
/>
|
||||
) : groups.length === 0 ? (
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||
{t('options_none')}
|
||||
@@ -472,7 +480,12 @@ const VariantBuilder: FunctionComponent<VariantBuilderProps> = ({ initial, onDon
|
||||
{t('next')}
|
||||
</AppButton>
|
||||
) : activeStep === 1 ? (
|
||||
<AppButton color="primary" variant="contained" onClick={goNextFromOptions}>
|
||||
<AppButton
|
||||
color="primary"
|
||||
variant="contained"
|
||||
onClick={goNextFromOptions}
|
||||
disabled={optionGroupsQuery.isError}
|
||||
>
|
||||
{t('next')}
|
||||
</AppButton>
|
||||
) : (
|
||||
|
||||
+2
-1
@@ -3,6 +3,7 @@ import { FunctionComponent } from 'react';
|
||||
import { useLocale, useTranslations } from 'next-intl';
|
||||
import { Box, LinearProgress, Paper, Stack, Typography } from '@mui/material';
|
||||
import { AppButton, AppIcon, StatusChip } from '@/components';
|
||||
import { formatNumber } from '@/utils';
|
||||
import type { VerificationStatus, VerificationStep } from '@/services/verification/types';
|
||||
import {
|
||||
displaySteps,
|
||||
@@ -46,7 +47,7 @@ const ProgressMeter: FunctionComponent<{ passed: number; total: number }> = ({ p
|
||||
const t = useTranslations('verification');
|
||||
const locale = useLocale();
|
||||
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 (
|
||||
<Paper elevation={0} sx={{ p: 2, borderRadius: 2, border: '1px solid', borderColor: 'divider' }}>
|
||||
<Stack direction="row" sx={{ justifyContent: 'space-between', alignItems: 'baseline', mb: 1 }}>
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useLocale, useTranslations } from 'next-intl';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
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 { useStartVerification, useVerificationStatus } from '@/services/verification';
|
||||
import { verificationKeys } from '@/services/verification/keys';
|
||||
@@ -104,38 +104,23 @@ export default function NurseVerificationPage() {
|
||||
function NotStarted({ onStart, pending }: { onStart: () => void; pending: boolean }) {
|
||||
const t = useTranslations('verification');
|
||||
return (
|
||||
<Paper
|
||||
elevation={0}
|
||||
sx={{
|
||||
p: 4,
|
||||
textAlign: 'center',
|
||||
border: '1px dashed',
|
||||
borderColor: 'divider',
|
||||
borderRadius: 2,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
gap: 1.5,
|
||||
}}
|
||||
>
|
||||
<AppIcon icon="verification" size={44} color="var(--bal-primary)" />
|
||||
<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>
|
||||
<EmptyState
|
||||
icon="verification"
|
||||
title={t('start_title')}
|
||||
body={t('start_body')}
|
||||
action={
|
||||
<AppButton
|
||||
color="primary"
|
||||
variant="contained"
|
||||
startIcon="verification"
|
||||
onClick={onStart}
|
||||
disabled={pending}
|
||||
sx={{ mt: 1 }}
|
||||
>
|
||||
{pending ? t('starting') : t('start_cta')}
|
||||
</AppButton>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -209,29 +194,18 @@ function ContinueCta({
|
||||
function MockAdminControls({ onApprove, onReject }: { onApprove: () => void; onReject: () => void }) {
|
||||
const t = useTranslations('verification');
|
||||
return (
|
||||
<Paper
|
||||
elevation={0}
|
||||
sx={{
|
||||
p: 2,
|
||||
borderRadius: 2,
|
||||
border: '1px dashed',
|
||||
borderColor: 'divider',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 1,
|
||||
}}
|
||||
>
|
||||
<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}>
|
||||
{t('mock_admin_approve')}
|
||||
</AppButton>
|
||||
<AppButton variant="outlined" color="error" onClick={onReject}>
|
||||
{t('mock_admin_reject')}
|
||||
</AppButton>
|
||||
</Stack>
|
||||
</Paper>
|
||||
<EmptyState
|
||||
title={t('mock_admin_title')}
|
||||
action={
|
||||
<Stack direction="row" sx={{ gap: 1, flexWrap: 'wrap', justifyContent: 'center' }}>
|
||||
<AppButton variant="outlined" color="primary" onClick={onApprove}>
|
||||
{t('mock_admin_approve')}
|
||||
</AppButton>
|
||||
<AppButton variant="outlined" color="error" onClick={onReject}>
|
||||
{t('mock_admin_reject')}
|
||||
</AppButton>
|
||||
</Stack>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
'use client';
|
||||
import { useLocale, useTranslations } from 'next-intl';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { Box, Paper, Skeleton, Stack, Typography } from '@mui/material';
|
||||
import { AppButton, AppIcon } from '@/components';
|
||||
import { Box, Skeleton, Stack, Typography } from '@mui/material';
|
||||
import { AppButton, EmptyState } from '@/components';
|
||||
import { SessionCard, useEvvController } from '@/components/booking';
|
||||
import { ROUTES } from '@/constants';
|
||||
import { useSessionEvv, useTodaySessions } from '@/services/bookings';
|
||||
@@ -39,12 +39,7 @@ export default function NurseVisitsPage() {
|
||||
))}
|
||||
</Stack>
|
||||
) : items.length === 0 ? (
|
||||
<Paper elevation={0} sx={{ p: 4, textAlign: 'center', border: '1px dashed', borderColor: 'divider', borderRadius: 2 }}>
|
||||
<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>
|
||||
<EmptyState icon="visits" title={t('evv_no_visits')} />
|
||||
) : (
|
||||
<Stack sx={{ gap: 2 }}>
|
||||
{items.map((item) => (
|
||||
|
||||
@@ -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 { 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';
|
||||
import type { Metadata } from 'next';
|
||||
import { getTranslations } from 'next-intl/server';
|
||||
import PartnerHomeScreen from './PartnerHomeScreen';
|
||||
|
||||
/**
|
||||
* 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 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 async function generateMetadata({ params }: { params: Promise<{ locale: string }> }): Promise<Metadata> {
|
||||
const { locale } = await params;
|
||||
const t = await getTranslations({ locale, namespace: 'partner' });
|
||||
return { title: t('home_title') };
|
||||
}
|
||||
|
||||
/**
|
||||
* 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>
|
||||
);
|
||||
export default function Page() {
|
||||
return <PartnerHomeScreen />;
|
||||
}
|
||||
|
||||
@@ -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 { Suspense } from 'react';
|
||||
import { LoginFlow } from '@/components/auth';
|
||||
import type { Metadata } from 'next';
|
||||
import { getTranslations } from 'next-intl/server';
|
||||
import LoginScreen from './LoginScreen';
|
||||
|
||||
/**
|
||||
* 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 LoginPage() {
|
||||
return (
|
||||
<Suspense>
|
||||
<LoginFlow />
|
||||
</Suspense>
|
||||
);
|
||||
export async function generateMetadata({ params }: { params: Promise<{ locale: string }> }): Promise<Metadata> {
|
||||
const { locale } = await params;
|
||||
const t = await getTranslations({ locale, namespace: 'auth' });
|
||||
return { title: t('customer_title') };
|
||||
}
|
||||
|
||||
export default function Page() {
|
||||
return <LoginScreen />;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -2,7 +2,7 @@ import type { ReactNode } from 'react';
|
||||
import type { Metadata, Viewport } from 'next';
|
||||
import localFont from 'next/font/local';
|
||||
import { Space_Grotesk } from 'next/font/google';
|
||||
import { setRequestLocale, getMessages } from 'next-intl/server';
|
||||
import { setRequestLocale, getMessages, getTranslations } from 'next-intl/server';
|
||||
import { NextIntlClientProvider } from 'next-intl';
|
||||
import { getThemeMode } from '@/lib/cookies/server';
|
||||
import { getServerAuthState } from '@/lib/auth/server';
|
||||
@@ -66,11 +66,28 @@ export const viewport: Viewport = {
|
||||
],
|
||||
};
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Balinyaar | بالینیار',
|
||||
description: 'Balinyaar web application',
|
||||
manifest: '/site.webmanifest',
|
||||
};
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
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({
|
||||
children,
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -1,9 +1,9 @@
|
||||
'use client';
|
||||
import { FunctionComponent } from 'react';
|
||||
import Paper from '@mui/material/Paper';
|
||||
import Stack from '@mui/material/Stack';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import { AppButton } from '@/components/common';
|
||||
import { AppButton, AccentCard } from '@/components/common';
|
||||
import type { AccentTone } from '@/components/common';
|
||||
import StatusChip from '@/components/StatusChip';
|
||||
import type { StatusKind } from '@/components/StatusChip';
|
||||
import type { BankAccountStatus } from '@/services/nurse/types';
|
||||
@@ -14,10 +14,10 @@ const STATUS_KIND: Record<BankAccountStatus, StatusKind> = {
|
||||
mismatch: 'rejected',
|
||||
};
|
||||
|
||||
const ACCENT_TOKEN: Record<BankAccountStatus, string> = {
|
||||
pending: 'var(--bal-warning)',
|
||||
verified: 'var(--bal-success)',
|
||||
mismatch: 'var(--bal-error)',
|
||||
const STATUS_TONE: Record<BankAccountStatus, AccentTone> = {
|
||||
pending: 'warning',
|
||||
verified: 'success',
|
||||
mismatch: 'error',
|
||||
};
|
||||
|
||||
export interface BankStatusPanelProps {
|
||||
@@ -57,18 +57,7 @@ const BankStatusPanel: FunctionComponent<BankStatusPanelProps> = ({
|
||||
onReenter,
|
||||
reenterLabel,
|
||||
}) => (
|
||||
<Paper
|
||||
elevation={0}
|
||||
data-status={status}
|
||||
sx={{
|
||||
p: 2,
|
||||
border: '1px solid',
|
||||
borderColor: 'divider',
|
||||
borderInlineStartWidth: 4,
|
||||
borderInlineStartColor: ACCENT_TOKEN[status],
|
||||
borderRadius: 2,
|
||||
}}
|
||||
>
|
||||
<AccentCard tone={STATUS_TONE[status]} padding="sm" data-status={status}>
|
||||
<Stack sx={{ gap: 1.25 }}>
|
||||
<Stack direction="row" sx={{ alignItems: 'center', gap: 1, flexWrap: 'wrap' }}>
|
||||
<StatusChip status={STATUS_KIND[status]} label={chipLabel} />
|
||||
@@ -110,7 +99,7 @@ const BankStatusPanel: FunctionComponent<BankStatusPanelProps> = ({
|
||||
</AppButton>
|
||||
) : null}
|
||||
</Stack>
|
||||
</Paper>
|
||||
</AccentCard>
|
||||
);
|
||||
|
||||
export default BankStatusPanel;
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
'use client';
|
||||
import { FunctionComponent } from 'react';
|
||||
import { useLocale, useTranslations } from 'next-intl';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { Box, ButtonBase, LinearProgress, Stack, Typography } from '@mui/material';
|
||||
import { formatIrrToToman } from '@/utils';
|
||||
import Money from '@/components/common/Money';
|
||||
import type { BnplPlanOption } from '@/services/bnpl/types';
|
||||
|
||||
export interface BnplPlanCardProps {
|
||||
@@ -23,8 +23,6 @@ const asPercent = (fraction: number): number => Math.round(fraction * 100);
|
||||
*/
|
||||
const BnplPlanCard: FunctionComponent<BnplPlanCardProps> = ({ plan, selected, onSelect }) => {
|
||||
const t = useTranslations('bnpl');
|
||||
const tc = useTranslations('common');
|
||||
const locale = useLocale();
|
||||
|
||||
const termLabel =
|
||||
plan.termMonths != null
|
||||
@@ -59,15 +57,13 @@ const BnplPlanCard: FunctionComponent<BnplPlanCardProps> = ({ plan, selected, on
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{ color: plan.feePercent > 0 ? 'var(--bal-secondary-dark)' : 'text.secondary' }}
|
||||
sx={{ color: plan.feePercent > 0 ? 'var(--bal-money-emphasis)' : 'text.secondary' }}
|
||||
>
|
||||
{feeLabel}
|
||||
</Typography>
|
||||
</Stack>
|
||||
<Stack sx={{ alignItems: 'flex-end', gap: 0.25 }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 800, color: 'var(--bal-secondary)' }}>
|
||||
{formatIrrToToman(plan.monthlyAmountIrr, locale)} {tc('currency_toman')}
|
||||
</Typography>
|
||||
<Money amountIrr={plan.monthlyAmountIrr} tone="emphasis" size="md" sx={{ fontWeight: 800 }} />
|
||||
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
|
||||
{t('monthly')}
|
||||
</Typography>
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
'use client';
|
||||
import { FunctionComponent, ReactNode } from 'react';
|
||||
import { useLocale, useTranslations } from 'next-intl';
|
||||
import { Avatar, Divider, Paper, Stack, Typography } from '@mui/material';
|
||||
import { Avatar, Divider, Stack, Typography } from '@mui/material';
|
||||
import AppIcon from '@/components/common/AppIcon';
|
||||
import SurfaceCard from '@/components/common/SurfaceCard';
|
||||
import PriceDisplay from '@/components/PriceDisplay';
|
||||
import { formatShamsiDate } from '@/utils';
|
||||
import { formatNumber, formatShamsiDate, localeTag } from '@/utils';
|
||||
import type { PriceUnit } from '@/services/catalog/types';
|
||||
|
||||
export interface BookingRequestSummaryCardProps {
|
||||
@@ -53,7 +54,7 @@ const BookingRequestSummaryCard: FunctionComponent<BookingRequestSummaryCardProp
|
||||
|
||||
const startDate = new Date(`${requestedDate}T${requestedTimeStart}`);
|
||||
const endDate = new Date(`${requestedDate}T${requestedTimeEnd}`);
|
||||
const timeFmt = new Intl.DateTimeFormat(locale === 'fa' ? 'fa-IR' : 'en-US', {
|
||||
const timeFmt = new Intl.DateTimeFormat(localeTag(locale), {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
@@ -61,14 +62,14 @@ const BookingRequestSummaryCard: FunctionComponent<BookingRequestSummaryCardProp
|
||||
|
||||
const ratingLabel =
|
||||
nurseRating != null
|
||||
? new Intl.NumberFormat(locale === 'fa' ? 'fa-IR' : 'en-US', {
|
||||
? formatNumber(nurseRating, locale, {
|
||||
minimumFractionDigits: 1,
|
||||
maximumFractionDigits: 1,
|
||||
}).format(nurseRating)
|
||||
})
|
||||
: null;
|
||||
|
||||
return (
|
||||
<Paper elevation={0} sx={{ p: 2.5, border: '1px solid', borderColor: 'divider', borderRadius: 2 }}>
|
||||
<SurfaceCard>
|
||||
<Stack sx={{ gap: 2 }}>
|
||||
<Stack direction="row" sx={{ gap: 1.5, alignItems: 'center' }}>
|
||||
<Avatar
|
||||
@@ -83,7 +84,7 @@ const BookingRequestSummaryCard: FunctionComponent<BookingRequestSummaryCardProp
|
||||
</Typography>
|
||||
{ratingLabel ? (
|
||||
<Stack direction="row" sx={{ gap: 0.5, alignItems: 'center' }}>
|
||||
<AppIcon icon="star" size={16} color="var(--bal-warning)" />
|
||||
<AppIcon icon="star" size={16} color="var(--bal-rating)" />
|
||||
<Typography variant="body2" sx={{ fontWeight: 700 }}>
|
||||
{ratingLabel}
|
||||
</Typography>
|
||||
@@ -125,7 +126,7 @@ const BookingRequestSummaryCard: FunctionComponent<BookingRequestSummaryCardProp
|
||||
</SummaryRow>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Paper>
|
||||
</SurfaceCard>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -52,7 +52,7 @@ const CancellationPolicyDisclosure: FunctionComponent<CancellationPolicyDisclosu
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||
{t(`lead_${preview.leadTimeLabel}`)}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ color: 'var(--bal-secondary)', fontWeight: 500 }}>
|
||||
<Typography variant="body2" sx={{ color: 'var(--bal-money-emphasis)', fontWeight: 500 }}>
|
||||
{t('fee_percent', { percent: feePercent })}
|
||||
</Typography>
|
||||
</Stack>
|
||||
|
||||
@@ -53,4 +53,34 @@ describe('<CountdownTimer/> component', () => {
|
||||
expect(screen.getByText('time up')).toBeInTheDocument();
|
||||
expect(onElapsed).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('renders the humanized coarse label above the coarse threshold when coarseLabel is supplied', () => {
|
||||
renderTimer({
|
||||
deadlineIso: deadlineInSeconds(25 * 60),
|
||||
elapsedText: 'time up',
|
||||
coarseLabel: (minutes) => `${minutes} min left`,
|
||||
});
|
||||
expect(screen.getByText('25 min left')).toBeInTheDocument();
|
||||
expect(screen.queryByText(/^\d{2}:\d{2}$/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('falls back to the ticking clock when coarseLabel is not supplied, even above the threshold', () => {
|
||||
renderTimer({ deadlineIso: deadlineInSeconds(25 * 60), elapsedText: 'time up' });
|
||||
expect(screen.getByText('25:00')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders a determinate progress ring when windowStart is supplied', () => {
|
||||
renderTimer({
|
||||
deadlineIso: deadlineInSeconds(100),
|
||||
windowStart: new Date(Date.now() - 100 * 1000).toISOString(),
|
||||
elapsedText: 'time up',
|
||||
});
|
||||
const ring = screen.getByRole('progressbar');
|
||||
expect(ring).toHaveAttribute('aria-valuenow', '50');
|
||||
});
|
||||
|
||||
it('omits the progress ring when windowStart is not supplied', () => {
|
||||
renderTimer({ deadlineIso: deadlineInSeconds(90), elapsedText: 'time up' });
|
||||
expect(screen.queryByRole('progressbar')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
'use client';
|
||||
import { FunctionComponent, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useLocale } from 'next-intl';
|
||||
import Box from '@mui/material/Box';
|
||||
import CircularProgress from '@mui/material/CircularProgress';
|
||||
import Stack from '@mui/material/Stack';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import AppIcon from '@/components/common/AppIcon';
|
||||
import { formatClock } from '@/utils';
|
||||
|
||||
export type CountdownTimerSize = 'sm' | 'md' | 'lg';
|
||||
|
||||
export interface CountdownTimerProps {
|
||||
/**
|
||||
@@ -11,38 +16,72 @@ export interface CountdownTimerProps {
|
||||
* client only renders the difference against `Date.now()` — it never computes or recomputes a deadline.
|
||||
*/
|
||||
deadlineIso: string;
|
||||
/** Server-supplied UTC instant the window opened at. Supplying it renders the progress-ring variant. */
|
||||
windowStart?: string;
|
||||
/** Optional label above the digits (already translated by the caller). */
|
||||
label?: string;
|
||||
/** Shown once the deadline has passed — the poll then resolves the real terminal status. */
|
||||
elapsedText: string;
|
||||
/** Terracotta-accented urgency styling for the money-adjacent payment window. */
|
||||
/** Legacy override — forces the terracotta ("urgent") tier regardless of the thresholds below. Existing
|
||||
* consumers that always want the alarm treatment (a payment window) keep passing this unchanged; new
|
||||
* consumers should prefer the threshold props. */
|
||||
urgent?: boolean;
|
||||
/** Remaining-seconds threshold below which the accent shifts calm-teal → amber. Default 900 (15 min). */
|
||||
warnThresholdSeconds?: number;
|
||||
/** Remaining-seconds threshold below which the accent shifts amber → terracotta. Default 300 (5 min). */
|
||||
urgentThresholdSeconds?: number;
|
||||
/** Remaining-seconds threshold above which the humanized coarse mode renders instead of the ticking
|
||||
* clock. Default 600 (10 min). Has no effect unless `coarseLabel` is also supplied. */
|
||||
coarseThresholdSeconds?: number;
|
||||
/** Already-translated humanized minutes-remaining copy (e.g. `t('minutes_remaining', {minutes})`) —
|
||||
* supplying this opts into the coarse mode above `coarseThresholdSeconds`; omit to always show the
|
||||
* ticking clock (the pre-existing behavior). */
|
||||
coarseLabel?: (minutes: number) => string;
|
||||
size?: CountdownTimerSize;
|
||||
/** Fired once when the countdown reaches zero (e.g. to nudge a refetch). */
|
||||
onElapsed?: () => void;
|
||||
}
|
||||
|
||||
const MS_PER_SECOND = 1000;
|
||||
const SECONDS_PER_MINUTE = 60;
|
||||
const SECONDS_PER_HOUR = 3600;
|
||||
const DEFAULT_WARN_THRESHOLD = 900;
|
||||
const DEFAULT_URGENT_THRESHOLD = 300;
|
||||
const DEFAULT_COARSE_THRESHOLD = 600;
|
||||
|
||||
const RING_SIZE: Record<CountdownTimerSize, number> = { sm: 40, md: 56, lg: 72 };
|
||||
const CLOCK_FONT_SIZE: Record<CountdownTimerSize, string> = { sm: '1.125rem', md: '1.5rem', lg: '2rem' };
|
||||
const CLOCK_ICON_SIZE: Record<CountdownTimerSize, number> = { sm: 16, md: 20, lg: 24 };
|
||||
|
||||
/**
|
||||
* A pure presentational countdown to a server-frozen deadline. It owns its own one-second tick so only
|
||||
* this component re-renders each second — never the page around it (the summary card / form stay put).
|
||||
* The ticking stops the moment the deadline passes; crossing zero shows `elapsedText` and fires
|
||||
* `onElapsed` once. Digits render in the active locale (Persian for `fa`), forced LTR so the `HH:MM:SS`
|
||||
* order is correct under RTL.
|
||||
* this component re-renders each second — never the page around it. The ticking stops the moment the
|
||||
* deadline passes; crossing zero shows `elapsedText` and fires `onElapsed` once. Digits render in the
|
||||
* active locale (Persian for `fa`), forced LTR so the `HH:MM:SS` order is correct under RTL.
|
||||
*
|
||||
* v2 adds, all opt-in and backward compatible: a progress-ring variant (`windowStart`), three urgency
|
||||
* tiers (calm teal → amber → terracotta, via thresholds — `urgent` still force-overrides for the two
|
||||
* existing payment-window consumers), and a humanized coarse mode above `coarseThresholdSeconds`
|
||||
* (`coarseLabel`) with `aria-live="polite"` — applied only in coarse mode, since the fine ticking clock
|
||||
* updates every second and would spam assistive tech if announced.
|
||||
* @component CountdownTimer
|
||||
*/
|
||||
const CountdownTimer: FunctionComponent<CountdownTimerProps> = ({
|
||||
deadlineIso,
|
||||
windowStart,
|
||||
label,
|
||||
elapsedText,
|
||||
urgent = false,
|
||||
urgent,
|
||||
warnThresholdSeconds = DEFAULT_WARN_THRESHOLD,
|
||||
urgentThresholdSeconds = DEFAULT_URGENT_THRESHOLD,
|
||||
coarseThresholdSeconds = DEFAULT_COARSE_THRESHOLD,
|
||||
coarseLabel,
|
||||
size = 'md',
|
||||
onElapsed,
|
||||
}) => {
|
||||
const locale = useLocale();
|
||||
const [now, setNow] = useState(() => Date.now());
|
||||
const target = useMemo(() => Date.parse(deadlineIso), [deadlineIso]);
|
||||
const start = useMemo(() => (windowStart ? Date.parse(windowStart) : undefined), [windowStart]);
|
||||
const remainingMs = Number.isFinite(target) ? Math.max(0, target - now) : 0;
|
||||
const elapsed = remainingMs <= 0;
|
||||
|
||||
@@ -63,31 +102,65 @@ const CountdownTimer: FunctionComponent<CountdownTimerProps> = ({
|
||||
}
|
||||
}, [elapsed, onElapsed]);
|
||||
|
||||
const accent = urgent ? 'var(--bal-secondary)' : 'var(--bal-primary)';
|
||||
const remainingSeconds = Math.floor(remainingMs / MS_PER_SECOND);
|
||||
const accent =
|
||||
urgent !== undefined
|
||||
? urgent
|
||||
? 'var(--bal-secondary)'
|
||||
: 'var(--bal-primary)'
|
||||
: remainingSeconds <= urgentThresholdSeconds
|
||||
? 'var(--bal-secondary)'
|
||||
: remainingSeconds <= warnThresholdSeconds
|
||||
? 'var(--bal-warning)'
|
||||
: 'var(--bal-primary)';
|
||||
|
||||
if (elapsed) {
|
||||
return (
|
||||
<Stack direction="row" sx={{ gap: 0.75, alignItems: 'center', color: 'text.secondary' }}>
|
||||
<AppIcon icon="pending" size={18} color="var(--bal-text-secondary)" />
|
||||
<Typography variant="body2" sx={{ fontWeight: 500 }}>
|
||||
{elapsedText}
|
||||
</Typography>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
const isCoarse = Boolean(coarseLabel) && remainingSeconds >= coarseThresholdSeconds;
|
||||
const progressPercent =
|
||||
start !== undefined && Number.isFinite(start) && target - start > 0
|
||||
? Math.min(100, Math.max(0, ((now - start) / (target - start)) * 100))
|
||||
: undefined;
|
||||
|
||||
const totalSeconds = Math.floor(remainingMs / MS_PER_SECOND);
|
||||
const hours = Math.floor(totalSeconds / SECONDS_PER_HOUR);
|
||||
const minutes = Math.floor((totalSeconds % SECONDS_PER_HOUR) / SECONDS_PER_MINUTE);
|
||||
const seconds = totalSeconds % SECONDS_PER_MINUTE;
|
||||
const content = elapsed ? (
|
||||
<Stack direction="row" sx={{ gap: 0.75, alignItems: 'center', color: 'text.secondary' }}>
|
||||
<AppIcon icon="schedule" size={CLOCK_ICON_SIZE[size]} color="var(--bal-text-secondary)" />
|
||||
<Typography variant="body2" sx={{ fontWeight: 500 }}>
|
||||
{elapsedText}
|
||||
</Typography>
|
||||
</Stack>
|
||||
) : isCoarse ? (
|
||||
<Stack direction="row" sx={{ gap: 0.5, alignItems: 'center' }} aria-live="polite">
|
||||
<AppIcon icon="schedule" size={CLOCK_ICON_SIZE[size]} color={accent} />
|
||||
<Typography sx={{ fontWeight: 700, color: accent }}>
|
||||
{coarseLabel?.(Math.ceil(remainingSeconds / SECONDS_PER_MINUTE))}
|
||||
</Typography>
|
||||
</Stack>
|
||||
) : (
|
||||
<Stack direction="row" sx={{ gap: 0.5, alignItems: 'center' }}>
|
||||
<AppIcon icon="schedule" size={CLOCK_ICON_SIZE[size]} color={accent} />
|
||||
<Typography
|
||||
component="span"
|
||||
dir="ltr"
|
||||
sx={{ fontWeight: 700, fontSize: CLOCK_FONT_SIZE[size], fontVariantNumeric: 'tabular-nums', color: accent }}
|
||||
>
|
||||
{formatClock(remainingSeconds, locale)}
|
||||
</Typography>
|
||||
</Stack>
|
||||
);
|
||||
|
||||
const pad = new Intl.NumberFormat(locale === 'fa' ? 'fa-IR' : 'en-US', {
|
||||
minimumIntegerDigits: 2,
|
||||
useGrouping: false,
|
||||
});
|
||||
const clock = [hours > 0 ? pad.format(hours) : null, pad.format(minutes), pad.format(seconds)]
|
||||
.filter((part) => part !== null)
|
||||
.join(':');
|
||||
const ring = (
|
||||
<Box sx={{ position: 'relative', display: 'inline-flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<CircularProgress
|
||||
variant="determinate"
|
||||
value={progressPercent}
|
||||
size={RING_SIZE[size]}
|
||||
thickness={3.5}
|
||||
sx={{ color: accent, '& .MuiCircularProgress-circle': { transition: 'stroke-dashoffset 0.3s linear' } }}
|
||||
/>
|
||||
<Box sx={{ position: 'absolute', inset: 0, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<AppIcon icon="schedule" size={CLOCK_ICON_SIZE[size]} color={accent} />
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
|
||||
return (
|
||||
<Stack sx={{ gap: 0.25, alignItems: 'center' }}>
|
||||
@@ -96,16 +169,14 @@ const CountdownTimer: FunctionComponent<CountdownTimerProps> = ({
|
||||
{label}
|
||||
</Typography>
|
||||
) : null}
|
||||
<Stack direction="row" sx={{ gap: 0.5, alignItems: 'center' }}>
|
||||
<AppIcon icon="pending" size={20} color={accent} />
|
||||
<Typography
|
||||
component="span"
|
||||
dir="ltr"
|
||||
sx={{ fontWeight: 700, fontSize: '1.5rem', fontVariantNumeric: 'tabular-nums', color: accent }}
|
||||
>
|
||||
{clock}
|
||||
</Typography>
|
||||
</Stack>
|
||||
{progressPercent === undefined ? (
|
||||
content
|
||||
) : (
|
||||
<Stack sx={{ alignItems: 'center', gap: 0.75 }}>
|
||||
{ring}
|
||||
{content}
|
||||
</Stack>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -3,11 +3,12 @@ import { ChangeEvent, FunctionComponent, useEffect, useRef, useState } from 'rea
|
||||
import { useTranslations } from 'next-intl';
|
||||
import Box from '@mui/material/Box';
|
||||
import LinearProgress from '@mui/material/LinearProgress';
|
||||
import Paper from '@mui/material/Paper';
|
||||
import Stack from '@mui/material/Stack';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import AppButton from '../common/AppButton';
|
||||
import AppIcon from '../common/AppIcon';
|
||||
import SurfaceCard from '../common/SurfaceCard';
|
||||
import AccentCard from '../common/AccentCard';
|
||||
import { ACCEPTED_DOCUMENT_TYPES, MAX_DOCUMENT_SIZE_BYTES } from '@/services/verification/constants';
|
||||
|
||||
/** The stored document as the uploader displays it — a name + optional size, never bytes. */
|
||||
@@ -153,21 +154,7 @@ const DocumentUpload: FunctionComponent<DocumentUploadProps> = ({
|
||||
/>
|
||||
|
||||
{rejected ? (
|
||||
<Paper
|
||||
elevation={0}
|
||||
data-upload-state="rejected"
|
||||
sx={{
|
||||
p: 2,
|
||||
borderRadius: 2,
|
||||
border: '1px solid',
|
||||
borderColor: 'divider',
|
||||
borderInlineStartWidth: 4,
|
||||
borderInlineStartColor: 'var(--bal-error)',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 1,
|
||||
}}
|
||||
>
|
||||
<AccentCard tone="error" padding="sm" data-upload-state="rejected" sx={UPLOAD_PANEL_SX}>
|
||||
<Stack direction="row" sx={{ gap: 1, alignItems: 'center' }}>
|
||||
<AppIcon icon="rejected" size={20} color="var(--bal-error)" />
|
||||
<Typography variant="body2" sx={{ fontWeight: 700 }}>
|
||||
@@ -189,9 +176,9 @@ const DocumentUpload: FunctionComponent<DocumentUploadProps> = ({
|
||||
>
|
||||
{t('upload_reupload')}
|
||||
</AppButton>
|
||||
</Paper>
|
||||
</AccentCard>
|
||||
) : state === 'uploading' ? (
|
||||
<Paper elevation={0} data-upload-state="uploading" sx={uploadedSx}>
|
||||
<SurfaceCard padding="sm" data-upload-state="uploading" sx={UPLOAD_PANEL_SX}>
|
||||
<Stack direction="row" sx={{ gap: 1, alignItems: 'center' }}>
|
||||
<AppIcon icon="upload" size={20} color="var(--bal-primary)" />
|
||||
<Typography variant="body2" sx={{ fontWeight: 500, flexGrow: 1, wordBreak: 'break-all' }}>
|
||||
@@ -205,9 +192,9 @@ const DocumentUpload: FunctionComponent<DocumentUploadProps> = ({
|
||||
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
|
||||
{t('upload_uploading')}
|
||||
</Typography>
|
||||
</Paper>
|
||||
</SurfaceCard>
|
||||
) : showUploaded ? (
|
||||
<Paper elevation={0} data-upload-state="success" sx={uploadedSx}>
|
||||
<SurfaceCard padding="sm" data-upload-state="success" sx={UPLOAD_PANEL_SX}>
|
||||
<Stack direction="row" sx={{ gap: 1.5, alignItems: 'center' }}>
|
||||
{previewUrl ? (
|
||||
<Box
|
||||
@@ -234,13 +221,9 @@ const DocumentUpload: FunctionComponent<DocumentUploadProps> = ({
|
||||
{t('upload_change')}
|
||||
</AppButton>
|
||||
</Stack>
|
||||
</Paper>
|
||||
</SurfaceCard>
|
||||
) : state === 'error' ? (
|
||||
<Paper
|
||||
elevation={0}
|
||||
data-upload-state="error"
|
||||
sx={{ ...uploadedSx, borderInlineStartWidth: 4, borderInlineStartColor: 'var(--bal-error)' }}
|
||||
>
|
||||
<AccentCard tone="error" padding="sm" data-upload-state="error" sx={UPLOAD_PANEL_SX}>
|
||||
<Stack direction="row" sx={{ gap: 1, alignItems: 'center' }}>
|
||||
<AppIcon icon="error" size={20} color="var(--bal-error)" />
|
||||
<Typography variant="body2" sx={{ color: 'var(--bal-error)', flexGrow: 1 }}>
|
||||
@@ -257,7 +240,7 @@ const DocumentUpload: FunctionComponent<DocumentUploadProps> = ({
|
||||
>
|
||||
{t('upload_retry')}
|
||||
</AppButton>
|
||||
</Paper>
|
||||
</AccentCard>
|
||||
) : (
|
||||
<Box
|
||||
role="button"
|
||||
@@ -299,12 +282,8 @@ const DocumentUpload: FunctionComponent<DocumentUploadProps> = ({
|
||||
);
|
||||
};
|
||||
|
||||
// Shared card styling for the uploading / success / error states.
|
||||
const uploadedSx = {
|
||||
p: 2,
|
||||
borderRadius: 2,
|
||||
border: '1px solid',
|
||||
borderColor: 'divider',
|
||||
// Shared layout on top of SurfaceCard/AccentCard for the uploading / success / error / rejected states.
|
||||
const UPLOAD_PANEL_SX = {
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 1,
|
||||
|
||||
@@ -32,7 +32,7 @@ describe('<EarningsBalanceHeader/> component', () => {
|
||||
expect(container.querySelector('[data-balance-state="payable"]')).toBeInTheDocument();
|
||||
expect(screen.getByText('balance_net_label')).toBeInTheDocument();
|
||||
// 5,950,000 IRR ÷ 10 = 595,000 Toman
|
||||
expect(screen.getByText('595,000')).toBeInTheDocument();
|
||||
expect(screen.getByText(/595,000/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders a negative net balance as "owed back" — magnitude only, never a bare minus', () => {
|
||||
@@ -40,8 +40,8 @@ describe('<EarningsBalanceHeader/> component', () => {
|
||||
expect(container.querySelector('[data-balance-state="owed"]')).toBeInTheDocument();
|
||||
expect(screen.getByText('balance_owed_label')).toBeInTheDocument();
|
||||
// The magnitude 435,000 shows; the raw "-435,000" must not.
|
||||
expect(screen.getByText('435,000')).toBeInTheDocument();
|
||||
expect(screen.queryByText('-435,000')).not.toBeInTheDocument();
|
||||
expect(screen.getByText(/435,000/)).toBeInTheDocument();
|
||||
expect(screen.queryByText(/-435,000/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders all four buckets with their formatted Toman amounts', () => {
|
||||
@@ -49,9 +49,9 @@ describe('<EarningsBalanceHeader/> component', () => {
|
||||
for (const bucket of ['pending', 'eligible', 'paid', 'clawback']) {
|
||||
expect(container.querySelector(`[data-bucket="${bucket}"]`)).toBeInTheDocument();
|
||||
}
|
||||
expect(screen.getByText('425,000')).toBeInTheDocument(); // pending 4,250,000
|
||||
expect(screen.getByText('340,000')).toBeInTheDocument(); // eligible 3,400,000
|
||||
expect(screen.getByText('850,000')).toBeInTheDocument(); // paid lifetime 8,500,000
|
||||
expect(screen.getByText('170,000')).toBeInTheDocument(); // clawback outstanding 1,700,000
|
||||
expect(screen.getByText(/425,000/)).toBeInTheDocument(); // pending 4,250,000
|
||||
expect(screen.getByText(/340,000/)).toBeInTheDocument(); // eligible 3,400,000
|
||||
expect(screen.getByText(/850,000/)).toBeInTheDocument(); // paid lifetime 8,500,000
|
||||
expect(screen.getByText(/170,000/)).toBeInTheDocument(); // clawback outstanding 1,700,000
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,26 +1,29 @@
|
||||
'use client';
|
||||
import { FunctionComponent, useMemo } from 'react';
|
||||
import { useLocale, useTranslations } from 'next-intl';
|
||||
import { Box, Paper, Stack, Typography } from '@mui/material';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { Box, Stack, Typography } from '@mui/material';
|
||||
import AppIcon from '../common/AppIcon';
|
||||
import { formatIrrToToman, parseIrr } from '@/utils';
|
||||
import Money from '../common/Money';
|
||||
import AccentCard, { AccentTone } from '../common/AccentCard';
|
||||
import { parseIrr } from '@/utils';
|
||||
import type { NurseEarningsSummary } from '@/services/payouts/types';
|
||||
|
||||
export interface EarningsBalanceHeaderProps {
|
||||
summary: NurseEarningsSummary;
|
||||
}
|
||||
|
||||
/** The four roll-up buckets — each keyed to a semantic token + icon so the states read at a glance. */
|
||||
/** The four roll-up buckets — each keyed to a semantic tone + icon so the states read at a glance. */
|
||||
const BUCKETS: ReadonlyArray<{
|
||||
key: 'pending' | 'eligible' | 'paid' | 'clawback';
|
||||
amountKey: keyof NurseEarningsSummary;
|
||||
tone: AccentTone;
|
||||
token: string;
|
||||
icon: string;
|
||||
}> = [
|
||||
{ key: 'pending', amountKey: 'pendingTotalIrr', token: 'var(--bal-warning)', icon: 'pending' },
|
||||
{ key: 'eligible', amountKey: 'eligibleTotalIrr', token: 'var(--bal-info)', icon: 'schedule' },
|
||||
{ key: 'paid', amountKey: 'paidTotalIrr', token: 'var(--bal-success)', icon: 'verified' },
|
||||
{ key: 'clawback', amountKey: 'clawbackOutstandingIrr', token: 'var(--bal-error)', icon: 'refresh' },
|
||||
{ key: 'pending', amountKey: 'pendingTotalIrr', tone: 'warning', token: 'var(--bal-warning)', icon: 'pending' },
|
||||
{ key: 'eligible', amountKey: 'eligibleTotalIrr', tone: 'info', token: 'var(--bal-info)', icon: 'schedule' },
|
||||
{ key: 'paid', amountKey: 'paidTotalIrr', tone: 'success', token: 'var(--bal-success)', icon: 'verified' },
|
||||
{ key: 'clawback', amountKey: 'clawbackOutstandingIrr', tone: 'error', token: 'var(--bal-error)', icon: 'refresh' },
|
||||
];
|
||||
|
||||
/**
|
||||
@@ -33,29 +36,17 @@ const BUCKETS: ReadonlyArray<{
|
||||
*/
|
||||
const EarningsBalanceHeader: FunctionComponent<EarningsBalanceHeaderProps> = ({ summary }) => {
|
||||
const t = useTranslations('payouts');
|
||||
const tc = useTranslations('common');
|
||||
const locale = useLocale();
|
||||
|
||||
const net = useMemo(() => parseIrr(summary.netPayableBalanceIrr), [summary.netPayableBalanceIrr]);
|
||||
const isOwed = net < BigInt(0);
|
||||
// Render the magnitude; the "owed back" framing carries the sign in words, never a bare minus.
|
||||
const magnitude = isOwed ? -net : net;
|
||||
const accent = isOwed ? 'var(--bal-error)' : 'var(--bal-primary)';
|
||||
const balanceTone: AccentTone = isOwed ? 'error' : 'primary';
|
||||
|
||||
return (
|
||||
<Stack sx={{ gap: 2 }}>
|
||||
<Paper
|
||||
elevation={0}
|
||||
data-balance-state={isOwed ? 'owed' : 'payable'}
|
||||
sx={{
|
||||
p: 3,
|
||||
borderRadius: 2,
|
||||
border: '1px solid',
|
||||
borderColor: 'divider',
|
||||
borderInlineStart: '4px solid',
|
||||
borderInlineStartColor: accent,
|
||||
}}
|
||||
>
|
||||
<AccentCard tone={balanceTone} padding="lg" data-balance-state={isOwed ? 'owed' : 'payable'}>
|
||||
<Stack sx={{ gap: 0.75 }}>
|
||||
<Stack direction="row" sx={{ gap: 1, alignItems: 'center' }}>
|
||||
<AppIcon icon={isOwed ? 'warning' : 'wallet'} size={20} color={accent} />
|
||||
@@ -63,19 +54,17 @@ const EarningsBalanceHeader: FunctionComponent<EarningsBalanceHeaderProps> = ({
|
||||
{isOwed ? t('balance_owed_label') : t('balance_net_label')}
|
||||
</Typography>
|
||||
</Stack>
|
||||
<Stack direction="row" sx={{ gap: 0.75, alignItems: 'baseline', flexWrap: 'wrap' }}>
|
||||
<Typography component="span" sx={{ fontWeight: 800, fontSize: '2rem', color: accent }}>
|
||||
{formatIrrToToman(magnitude, locale)}
|
||||
</Typography>
|
||||
<Typography component="span" variant="subtitle1" sx={{ color: 'text.secondary', fontWeight: 500 }}>
|
||||
{tc('currency_toman')}
|
||||
</Typography>
|
||||
</Stack>
|
||||
<Money
|
||||
amountIrr={String(magnitude)}
|
||||
size="lg"
|
||||
tone={isOwed ? 'error' : 'default'}
|
||||
sx={{ fontSize: '2rem', fontWeight: 800, color: accent }}
|
||||
/>
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||
{isOwed ? t('balance_owed_hint') : t('balance_net_hint')}
|
||||
</Typography>
|
||||
</Stack>
|
||||
</Paper>
|
||||
</AccentCard>
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
@@ -85,19 +74,7 @@ const EarningsBalanceHeader: FunctionComponent<EarningsBalanceHeaderProps> = ({
|
||||
}}
|
||||
>
|
||||
{BUCKETS.map((bucket) => (
|
||||
<Paper
|
||||
key={bucket.key}
|
||||
elevation={0}
|
||||
data-bucket={bucket.key}
|
||||
sx={{
|
||||
p: 2,
|
||||
borderRadius: 2,
|
||||
border: '1px solid',
|
||||
borderColor: 'divider',
|
||||
borderInlineStart: '3px solid',
|
||||
borderInlineStartColor: bucket.token,
|
||||
}}
|
||||
>
|
||||
<AccentCard key={bucket.key} tone={bucket.tone} padding="sm" data-bucket={bucket.key}>
|
||||
<Stack sx={{ gap: 0.75 }}>
|
||||
<Stack direction="row" sx={{ gap: 0.75, alignItems: 'center' }}>
|
||||
<AppIcon icon={bucket.icon} size={16} color={bucket.token} />
|
||||
@@ -105,14 +82,9 @@ const EarningsBalanceHeader: FunctionComponent<EarningsBalanceHeaderProps> = ({
|
||||
{t(`bucket_${bucket.key}`)}
|
||||
</Typography>
|
||||
</Stack>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
|
||||
{formatIrrToToman(summary[bucket.amountKey], locale)}{' '}
|
||||
<Typography component="span" variant="caption" sx={{ color: 'text.secondary' }}>
|
||||
{tc('currency_toman')}
|
||||
</Typography>
|
||||
</Typography>
|
||||
<Money amountIrr={summary[bucket.amountKey]} size="md" sx={{ fontWeight: 700 }} />
|
||||
</Stack>
|
||||
</Paper>
|
||||
</AccentCard>
|
||||
))}
|
||||
</Box>
|
||||
</Stack>
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
'use client';
|
||||
import { FunctionComponent, useMemo } from 'react';
|
||||
import { useLocale, useTranslations } from 'next-intl';
|
||||
import { Paper, Stack, Typography } from '@mui/material';
|
||||
import { Skeleton, Stack, Typography } from '@mui/material';
|
||||
import StatusChip, { StatusKind } from '@/components/StatusChip';
|
||||
import PriceBreakdown from '@/components/PriceBreakdown';
|
||||
import CountdownTimer from '@/components/CountdownTimer';
|
||||
import AppButton from '@/components/common/AppButton';
|
||||
import AppIcon from '@/components/common/AppIcon';
|
||||
import SurfaceCard from '@/components/common/SurfaceCard';
|
||||
import { formatShamsiDate, parseIrr } from '@/utils';
|
||||
import type { EarningsState, NurseEarningsItem } from '@/services/payouts/types';
|
||||
|
||||
@@ -51,11 +52,7 @@ const EarningsRow: FunctionComponent<EarningsRowProps> = ({ item, onViewBooking,
|
||||
);
|
||||
|
||||
return (
|
||||
<Paper
|
||||
elevation={0}
|
||||
data-earnings-state={item.state}
|
||||
sx={{ p: 2.5, border: '1px solid', borderColor: 'divider', borderRadius: 2 }}
|
||||
>
|
||||
<SurfaceCard data-earnings-state={item.state}>
|
||||
<Stack sx={{ gap: 1.75 }}>
|
||||
<Stack
|
||||
direction="row"
|
||||
@@ -110,7 +107,7 @@ const EarningsRow: FunctionComponent<EarningsRowProps> = ({ item, onViewBooking,
|
||||
</AppButton>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Paper>
|
||||
</SurfaceCard>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -190,4 +187,21 @@ const StateAffordance: FunctionComponent<{
|
||||
return null;
|
||||
};
|
||||
|
||||
export default EarningsRow;
|
||||
/** Matches the real row's anatomy (title/chip header, breakdown lines, affordance row) for the earnings list. */
|
||||
const EarningsRowSkeleton: FunctionComponent = () => (
|
||||
<SurfaceCard>
|
||||
<Stack sx={{ gap: 1.75 }}>
|
||||
<Stack direction="row" sx={{ justifyContent: 'space-between', alignItems: 'flex-start', gap: 1.5 }}>
|
||||
<Stack sx={{ gap: 0.5 }}>
|
||||
<Skeleton variant="text" width={140} height={26} />
|
||||
<Skeleton variant="text" width={180} height={18} />
|
||||
</Stack>
|
||||
<Skeleton variant="rounded" width={72} height={24} />
|
||||
</Stack>
|
||||
<Skeleton variant="rounded" height={72} />
|
||||
<Skeleton variant="text" width="50%" height={20} />
|
||||
</Stack>
|
||||
</SurfaceCard>
|
||||
);
|
||||
|
||||
export default Object.assign(EarningsRow, { Skeleton: EarningsRowSkeleton });
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
'use client';
|
||||
import { FunctionComponent } from 'react';
|
||||
import { useLocale, useTranslations } from 'next-intl';
|
||||
import { Paper, Stack, Typography } from '@mui/material';
|
||||
import { Stack, Typography } from '@mui/material';
|
||||
import StatusChip from '../StatusChip';
|
||||
import { formatIrrToToman, formatShamsiDate } from '@/utils';
|
||||
import Money from '@/components/common/Money';
|
||||
import SurfaceCard from '@/components/common/SurfaceCard';
|
||||
import { formatShamsiDate } from '@/utils';
|
||||
import { installmentStatusKind, type BnplInstallmentRow, type BnplInstallmentStatus } from '@/services/bnpl/types';
|
||||
|
||||
export interface InstallmentScheduleRowProps {
|
||||
@@ -35,11 +37,7 @@ const InstallmentScheduleRow: FunctionComponent<InstallmentScheduleRowProps> = (
|
||||
const dueLabel = row.kind === 'down_payment' ? t('today') : formatShamsiDate(`${row.dueDate}T00:00:00`, locale);
|
||||
|
||||
return (
|
||||
<Paper
|
||||
elevation={0}
|
||||
data-row-kind={row.kind}
|
||||
sx={{ p: 1.5, borderRadius: 2, border: '1px solid', borderColor: 'divider' }}
|
||||
>
|
||||
<SurfaceCard padding="sm" data-row-kind={row.kind}>
|
||||
<Stack direction="row" sx={{ justifyContent: 'space-between', alignItems: 'center', gap: 1.5 }}>
|
||||
<Stack sx={{ gap: 0.25 }}>
|
||||
<Typography variant="body2" sx={{ fontWeight: 700 }}>
|
||||
@@ -50,18 +48,19 @@ const InstallmentScheduleRow: FunctionComponent<InstallmentScheduleRowProps> = (
|
||||
</Typography>
|
||||
</Stack>
|
||||
<Stack sx={{ alignItems: 'flex-end', gap: 0.5 }}>
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{ fontWeight: 700, color: row.kind === 'down_payment' ? 'var(--bal-secondary)' : undefined }}
|
||||
>
|
||||
{formatIrrToToman(row.amountIrr, locale)}
|
||||
</Typography>
|
||||
<Money
|
||||
amountIrr={row.amountIrr}
|
||||
hideUnit
|
||||
size="sm"
|
||||
tone={row.kind === 'down_payment' ? 'emphasis' : 'default'}
|
||||
sx={{ fontWeight: 700 }}
|
||||
/>
|
||||
{showStatus && row.status ? (
|
||||
<StatusChip status={installmentStatusKind(row.status)} label={t(STATUS_LABEL_KEY[row.status])} />
|
||||
) : null}
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Paper>
|
||||
</SurfaceCard>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -79,4 +79,13 @@ describe('<NurseResultCard/> component', () => {
|
||||
fireEvent.click(screen.getByRole('button'));
|
||||
expect(onSelect).toHaveBeenCalledWith(NURSE);
|
||||
});
|
||||
|
||||
it('exposes a co-located Skeleton twin matching the card shell', () => {
|
||||
const { container } = render(
|
||||
<ThemeProvider>
|
||||
<NurseResultCard.Skeleton />
|
||||
</ThemeProvider>,
|
||||
);
|
||||
expect(container.querySelectorAll('.MuiSkeleton-root').length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { memo } from 'react';
|
||||
import { FunctionComponent, memo } from 'react';
|
||||
import { useLocale, useTranslations } from 'next-intl';
|
||||
import { Avatar, Box, Paper, Stack, Typography } from '@mui/material';
|
||||
import { Avatar, Box, Paper, Skeleton, Stack, Typography } from '@mui/material';
|
||||
import AppIcon from '../common/AppIcon';
|
||||
import TrustBadge from '../TrustBadge';
|
||||
import PriceDisplay from '../PriceDisplay';
|
||||
import { formatNumber } from '@/utils';
|
||||
import type { NurseSearchResult } from '@/services/search/types';
|
||||
|
||||
export interface NurseResultCardProps {
|
||||
@@ -14,10 +15,7 @@ export interface NurseResultCardProps {
|
||||
}
|
||||
|
||||
function ratingText(rating: number, locale: string): string {
|
||||
return new Intl.NumberFormat(locale === 'fa' ? 'fa-IR' : 'en-US', {
|
||||
minimumFractionDigits: 1,
|
||||
maximumFractionDigits: 1,
|
||||
}).format(rating);
|
||||
return formatNumber(rating, locale, { minimumFractionDigits: 1, maximumFractionDigits: 1 });
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -35,11 +33,7 @@ const NurseResultCard = ({ nurse, onSelect }: NurseResultCardProps) => {
|
||||
const name = nurse.nurseName.trim() || t('unnamed_nurse');
|
||||
const initial = name.charAt(0);
|
||||
const distance =
|
||||
nurse.distanceKm != null
|
||||
? new Intl.NumberFormat(locale === 'fa' ? 'fa-IR' : 'en-US', { maximumFractionDigits: 1 }).format(
|
||||
nurse.distanceKm,
|
||||
)
|
||||
: null;
|
||||
nurse.distanceKm != null ? formatNumber(nurse.distanceKm, locale, { maximumFractionDigits: 1 }) : null;
|
||||
|
||||
return (
|
||||
<Paper
|
||||
@@ -84,7 +78,7 @@ const NurseResultCard = ({ nurse, onSelect }: NurseResultCardProps) => {
|
||||
|
||||
<Stack direction="row" sx={{ gap: 1.5, alignItems: 'center', flexWrap: 'wrap' }}>
|
||||
<Stack direction="row" sx={{ gap: 0.5, alignItems: 'center' }}>
|
||||
<AppIcon icon="star" size={16} color="var(--bal-warning)" />
|
||||
<AppIcon icon="star" size={16} color="var(--bal-rating)" />
|
||||
<Typography variant="body2" sx={{ fontWeight: 700 }}>
|
||||
{ratingText(nurse.averageRating, locale)}
|
||||
</Typography>
|
||||
@@ -114,4 +108,17 @@ const NurseResultCard = ({ nurse, onSelect }: NurseResultCardProps) => {
|
||||
);
|
||||
};
|
||||
|
||||
export default memo(NurseResultCard);
|
||||
/** Matches the real card's anatomy (avatar disc, name/badge row, rating row, price row) so a loading list
|
||||
* doesn't jump when data lands. */
|
||||
const NurseResultCardSkeleton: FunctionComponent = () => (
|
||||
<Paper elevation={0} sx={{ p: 2, display: 'flex', gap: 2, alignItems: 'flex-start', border: '1px solid', borderColor: 'divider', borderRadius: 2 }}>
|
||||
<Skeleton variant="circular" width={56} height={56} />
|
||||
<Stack sx={{ gap: 0.75, flexGrow: 1 }}>
|
||||
<Skeleton variant="text" width="55%" height={28} />
|
||||
<Skeleton variant="text" width="40%" height={20} />
|
||||
<Skeleton variant="text" width="30%" height={20} />
|
||||
</Stack>
|
||||
</Paper>
|
||||
);
|
||||
|
||||
export default Object.assign(memo(NurseResultCard), { Skeleton: NurseResultCardSkeleton });
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
'use client';
|
||||
import { FunctionComponent } from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
import Paper from '@mui/material/Paper';
|
||||
import Skeleton from '@mui/material/Skeleton';
|
||||
import Stack from '@mui/material/Stack';
|
||||
import { AppIconButton } from '@/components/common';
|
||||
import { AppIconButton, SurfaceCard } from '@/components/common';
|
||||
import PatientHeader from '@/components/PatientHeader';
|
||||
import type { Patient } from '@/services/patients/types';
|
||||
|
||||
@@ -61,7 +61,7 @@ const PatientCard: FunctionComponent<PatientCardProps> = ({
|
||||
);
|
||||
|
||||
return (
|
||||
<Paper elevation={0} sx={{ p: 2, border: '1px solid', borderColor: 'divider', borderRadius: 2 }}>
|
||||
<SurfaceCard padding="sm">
|
||||
<Stack direction="row" sx={{ alignItems: 'flex-start', gap: 1 }}>
|
||||
{onOpen ? (
|
||||
<Box
|
||||
@@ -92,8 +92,25 @@ const PatientCard: FunctionComponent<PatientCardProps> = ({
|
||||
<AppIconButton icon="archive" title={archiveLabel} aria-label={archiveLabel} size="small" onClick={onArchive} />
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Paper>
|
||||
</SurfaceCard>
|
||||
);
|
||||
};
|
||||
|
||||
export default PatientCard;
|
||||
/** Matches the real card's anatomy (identity block + two icon-button actions) for the E1 patient list. */
|
||||
const PatientCardSkeleton: FunctionComponent = () => (
|
||||
<SurfaceCard padding="sm">
|
||||
<Stack direction="row" sx={{ alignItems: 'flex-start', gap: 1 }}>
|
||||
<Stack sx={{ gap: 0.75, flexGrow: 1 }}>
|
||||
<Skeleton variant="text" width="45%" height={26} />
|
||||
<Skeleton variant="text" width="65%" height={20} />
|
||||
<Skeleton variant="rounded" width={90} height={22} />
|
||||
</Stack>
|
||||
<Stack direction="row" sx={{ flexShrink: 0, gap: 0.5 }}>
|
||||
<Skeleton variant="circular" width={32} height={32} />
|
||||
<Skeleton variant="circular" width={32} height={32} />
|
||||
</Stack>
|
||||
</Stack>
|
||||
</SurfaceCard>
|
||||
);
|
||||
|
||||
export default Object.assign(PatientCard, { Skeleton: PatientCardSkeleton });
|
||||
|
||||
@@ -52,7 +52,7 @@ describe('<PayoutHistoryRow/> component', () => {
|
||||
|
||||
it('renders the net amount transferred in Toman and the masked IBAN', () => {
|
||||
renderRow(makeItem('paid'));
|
||||
expect(screen.getByText('425,000')).toBeInTheDocument(); // 4,250,000 ÷ 10
|
||||
expect(screen.getByText(/425,000/)).toBeInTheDocument(); // 4,250,000 ÷ 10
|
||||
expect(screen.getByText('IR••••4821')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
'use client';
|
||||
import { FunctionComponent, ReactNode } from 'react';
|
||||
import { useLocale, useTranslations } from 'next-intl';
|
||||
import { Box, Paper, Stack, Typography } from '@mui/material';
|
||||
import { Box, Skeleton, Stack, Typography } from '@mui/material';
|
||||
import StatusChip, { StatusKind } from '@/components/StatusChip';
|
||||
import AppButton from '@/components/common/AppButton';
|
||||
import AppIcon from '@/components/common/AppIcon';
|
||||
import { formatIrrToToman, formatShamsiDate } from '@/utils';
|
||||
import SurfaceCard from '@/components/common/SurfaceCard';
|
||||
import AccentCard from '@/components/common/AccentCard';
|
||||
import Money from '@/components/common/Money';
|
||||
import { formatShamsiDate } from '@/utils';
|
||||
import type { NursePayoutHistoryItem, PayoutStatus } from '@/services/payouts/types';
|
||||
|
||||
export interface PayoutHistoryRowProps {
|
||||
@@ -31,16 +34,11 @@ const PAYOUT_STATUS_KIND: Record<PayoutStatus, StatusKind> = {
|
||||
*/
|
||||
const PayoutHistoryRow: FunctionComponent<PayoutHistoryRowProps> = ({ item, onOpen }) => {
|
||||
const t = useTranslations('payouts');
|
||||
const tc = useTranslations('common');
|
||||
const locale = useLocale();
|
||||
const isFailed = item.status === 'failed';
|
||||
|
||||
return (
|
||||
<Paper
|
||||
elevation={0}
|
||||
data-payout-status={item.status}
|
||||
sx={{ p: 2.5, border: '1px solid', borderColor: 'divider', borderRadius: 2 }}
|
||||
>
|
||||
<SurfaceCard data-payout-status={item.status}>
|
||||
<Stack sx={{ gap: 1.5 }}>
|
||||
<Stack
|
||||
direction="row"
|
||||
@@ -50,12 +48,7 @@ const PayoutHistoryRow: FunctionComponent<PayoutHistoryRowProps> = ({ item, onOp
|
||||
<Typography variant="caption" sx={{ color: 'text.secondary', fontWeight: 500 }}>
|
||||
{t('payout_net_amount')}
|
||||
</Typography>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 800 }}>
|
||||
{formatIrrToToman(item.netAmountIrr, locale)}{' '}
|
||||
<Typography component="span" variant="caption" sx={{ color: 'text.secondary' }}>
|
||||
{tc('currency_toman')}
|
||||
</Typography>
|
||||
</Typography>
|
||||
<Money amountIrr={item.netAmountIrr} size="md" sx={{ fontWeight: 800 }} />
|
||||
</Stack>
|
||||
<StatusChip status={PAYOUT_STATUS_KIND[item.status]} label={t(`pstatus_${item.status}`)} />
|
||||
</Stack>
|
||||
@@ -82,16 +75,7 @@ const PayoutHistoryRow: FunctionComponent<PayoutHistoryRowProps> = ({ item, onOp
|
||||
</Stack>
|
||||
|
||||
{isFailed ? (
|
||||
<Box
|
||||
sx={{
|
||||
p: 1.5,
|
||||
borderRadius: 2,
|
||||
border: '1px solid',
|
||||
borderColor: 'divider',
|
||||
borderInlineStart: '3px solid',
|
||||
borderInlineStartColor: 'var(--bal-error)',
|
||||
}}
|
||||
>
|
||||
<AccentCard tone="error" padding="sm">
|
||||
<Stack direction="row" sx={{ gap: 0.75, alignItems: 'center', mb: 0.5 }}>
|
||||
<AppIcon icon="warning" size={18} color="var(--bal-error)" />
|
||||
<Typography variant="body2" sx={{ fontWeight: 700 }}>
|
||||
@@ -106,7 +90,7 @@ const PayoutHistoryRow: FunctionComponent<PayoutHistoryRowProps> = ({ item, onOp
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary', mt: 0.5 }}>
|
||||
{t('failure_hint')}
|
||||
</Typography>
|
||||
</Box>
|
||||
</AccentCard>
|
||||
) : null}
|
||||
|
||||
<Stack direction="row" sx={{ justifyContent: 'flex-end' }}>
|
||||
@@ -115,7 +99,7 @@ const PayoutHistoryRow: FunctionComponent<PayoutHistoryRowProps> = ({ item, onOp
|
||||
</AppButton>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Paper>
|
||||
</SurfaceCard>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -130,4 +114,24 @@ const MetaLine: FunctionComponent<{ label: string; children: ReactNode }> = ({ l
|
||||
</Stack>
|
||||
);
|
||||
|
||||
export default PayoutHistoryRow;
|
||||
/** Matches the real row's anatomy (amount block, status chip, meta lines) for the payout-history list. */
|
||||
const PayoutHistoryRowSkeleton: FunctionComponent = () => (
|
||||
<SurfaceCard>
|
||||
<Stack sx={{ gap: 1.5 }}>
|
||||
<Stack direction="row" sx={{ justifyContent: 'space-between', alignItems: 'flex-start', gap: 1.5 }}>
|
||||
<Stack sx={{ gap: 0.5 }}>
|
||||
<Skeleton variant="text" width={100} height={16} />
|
||||
<Skeleton variant="text" width={150} height={30} />
|
||||
</Stack>
|
||||
<Skeleton variant="rounded" width={72} height={24} />
|
||||
</Stack>
|
||||
<Stack sx={{ gap: 0.75 }}>
|
||||
<Skeleton variant="text" width="80%" height={18} />
|
||||
<Skeleton variant="text" width="70%" height={18} />
|
||||
<Skeleton variant="text" width="60%" height={18} />
|
||||
</Stack>
|
||||
</Stack>
|
||||
</SurfaceCard>
|
||||
);
|
||||
|
||||
export default Object.assign(PayoutHistoryRow, { Skeleton: PayoutHistoryRowSkeleton });
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
'use client';
|
||||
import { FunctionComponent } from 'react';
|
||||
import { useLocale, useTranslations } from 'next-intl';
|
||||
import { Divider, Paper, Stack, Typography } from '@mui/material';
|
||||
import { useLocale } from 'next-intl';
|
||||
import { Divider, Stack, Typography } from '@mui/material';
|
||||
import Money from '@/components/common/Money';
|
||||
import SurfaceCard from '@/components/common/SurfaceCard';
|
||||
import { formatIrrToToman, parseIrr } from '@/utils';
|
||||
|
||||
export interface PriceBreakdownRow {
|
||||
@@ -30,7 +32,6 @@ export interface PriceBreakdownProps {
|
||||
*/
|
||||
const PriceBreakdown: FunctionComponent<PriceBreakdownProps> = ({ rows, totalLabel, totalAmountIrr }) => {
|
||||
const locale = useLocale();
|
||||
const tc = useTranslations('common');
|
||||
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
const sum = rows.reduce((acc, row) => acc + parseIrr(row.amountIrr), BigInt(0));
|
||||
@@ -42,7 +43,7 @@ const PriceBreakdown: FunctionComponent<PriceBreakdownProps> = ({ rows, totalLab
|
||||
}
|
||||
|
||||
return (
|
||||
<Paper elevation={0} sx={{ p: 2.5, border: '1px solid', borderColor: 'divider', borderRadius: 2 }}>
|
||||
<SurfaceCard>
|
||||
<Stack sx={{ gap: 1.25 }}>
|
||||
{rows.map((row) => (
|
||||
<Stack key={row.key} data-row={row.key} direction="row" sx={{ justifyContent: 'space-between', gap: 2 }}>
|
||||
@@ -59,12 +60,10 @@ const PriceBreakdown: FunctionComponent<PriceBreakdownProps> = ({ rows, totalLab
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 800 }}>
|
||||
{totalLabel}
|
||||
</Typography>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 800, color: 'var(--bal-secondary)' }}>
|
||||
{formatIrrToToman(totalAmountIrr, locale)} {tc('currency_toman')}
|
||||
</Typography>
|
||||
<Money amountIrr={totalAmountIrr} tone="emphasis" size="md" sx={{ fontWeight: 800 }} />
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Paper>
|
||||
</SurfaceCard>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import { FunctionComponent } from 'react';
|
||||
import { useLocale, useTranslations } from 'next-intl';
|
||||
import { Stack, Typography } from '@mui/material';
|
||||
import { formatIrrToToman, multiplyIrr } from '@/utils';
|
||||
import { formatIrrToToman, formatNumber, multiplyIrr } from '@/utils';
|
||||
import type { PriceUnit } from '@/services/catalog/types';
|
||||
|
||||
export interface PriceDisplayProps {
|
||||
@@ -39,9 +39,7 @@ const PriceDisplay: FunctionComponent<PriceDisplayProps> = ({
|
||||
const unitLabel = t(`unit_${priceUnit}`);
|
||||
const hasEstimate = Boolean(showEstimate && sessionCount && sessionCount > 0);
|
||||
const total = hasEstimate ? formatIrrToToman(multiplyIrr(price, sessionCount as number), locale) : null;
|
||||
const countLabel = hasEstimate
|
||||
? new Intl.NumberFormat(locale === 'fa' ? 'fa-IR' : 'en-US').format(sessionCount as number)
|
||||
: '';
|
||||
const countLabel = hasEstimate ? formatNumber(sessionCount as number, locale) : '';
|
||||
|
||||
return (
|
||||
<Stack sx={{ gap: 0.25, alignItems: align === 'center' ? 'center' : 'flex-start' }}>
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { ThemeProvider } from '../../theme';
|
||||
|
||||
jest.mock('next-intl', () => ({
|
||||
useLocale: () => 'en',
|
||||
}));
|
||||
|
||||
import RatingInput, { RatingInputProps } from './RatingInput';
|
||||
|
||||
function renderRating(props: Partial<RatingInputProps> = {}) {
|
||||
@@ -40,4 +45,20 @@ describe('<RatingInput/> component', () => {
|
||||
expect(screen.queryByRole('radio')).not.toBeInTheDocument();
|
||||
expect(onChange).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('renders a fractional average honestly instead of rounding it', () => {
|
||||
const { container } = renderRating({ value: 4.5, readOnly: true });
|
||||
const fractions = Array.from(container.querySelectorAll('[data-star-fraction]')).map((el) =>
|
||||
Number(el.getAttribute('data-star-fraction')),
|
||||
);
|
||||
expect(fractions).toEqual([1, 1, 1, 1, 0.5]);
|
||||
});
|
||||
|
||||
it('renders full/empty fractions (1 or 0) for a whole-number average', () => {
|
||||
const { container } = renderRating({ value: 3, readOnly: true });
|
||||
const fractions = Array.from(container.querySelectorAll('[data-star-fraction]')).map((el) =>
|
||||
Number(el.getAttribute('data-star-fraction')),
|
||||
);
|
||||
expect(fractions).toEqual([1, 1, 1, 0, 0]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
'use client';
|
||||
import { FunctionComponent } from 'react';
|
||||
import { useLocale } from 'next-intl';
|
||||
import Box from '@mui/material/Box';
|
||||
import IconButton from '@mui/material/IconButton';
|
||||
import { AppIcon } from '@/components/common';
|
||||
|
||||
export interface RatingInputProps {
|
||||
/** Current rating, 0–max (0 = none chosen). */
|
||||
/** Current rating, 0–max (0 = none chosen). Read-only display accepts a fraction (e.g. 4.5) and renders
|
||||
* a partially-filled star; interactive selection is always a whole star. */
|
||||
value: number;
|
||||
/** Called with the clicked star value. Omit (or set `readOnly`) for a display-only star row. */
|
||||
onChange?: (value: number) => void;
|
||||
@@ -23,11 +25,13 @@ const DEFAULT_MAX = 5;
|
||||
const DEFAULT_SIZE = 28;
|
||||
|
||||
/**
|
||||
* The 1–max star input/display. Interactive when an `onChange` is passed and not `readOnly` (each star is a
|
||||
* `radio` in a `radiogroup`); otherwise a static star row (`role="img"`). Stars are token-coloured — filled
|
||||
* = `var(--bal-warning)` (matching the existing rating star), empty = `var(--bal-divider)` — so it switches
|
||||
* with the color scheme and never hard-codes a hex. Built on the registered `star` `AppIcon` (no MUI `Rating`
|
||||
* dependency, so the control is fully deterministic to test).
|
||||
* The 1–max star input/display. Interactive when an `onChange` is passed and not `readOnly` (each star is
|
||||
* a `radio` in a `radiogroup`); otherwise a static star row (`role="img"`) that honestly renders a
|
||||
* **fractional** fill for averages (a 4.5 renders as a half-filled fifth star, never rounded) via a
|
||||
* clip-path overlay — logical, so the filled portion grows from the reading-start side under both `fa`
|
||||
* (RTL) and `en` (LTR). Colors resolve from the dedicated `--bal-rating`/`--bal-rating-empty` tokens
|
||||
* (retiring the muddy `--bal-warning`/near-invisible `--bal-divider` pairing). Built on the registered
|
||||
* `star` `AppIcon` (no MUI `Rating` dependency, so the control is fully deterministic to test).
|
||||
* @component RatingInput
|
||||
*/
|
||||
const RatingInput: FunctionComponent<RatingInputProps> = ({
|
||||
@@ -38,6 +42,8 @@ const RatingInput: FunctionComponent<RatingInputProps> = ({
|
||||
size = DEFAULT_SIZE,
|
||||
ariaLabel,
|
||||
}) => {
|
||||
const locale = useLocale();
|
||||
const dir = locale === 'fa' ? 'rtl' : 'ltr';
|
||||
const interactive = !readOnly && Boolean(onChange);
|
||||
const stars = Array.from({ length: max }, (_, i) => i + 1);
|
||||
|
||||
@@ -50,23 +56,44 @@ const RatingInput: FunctionComponent<RatingInputProps> = ({
|
||||
sx={{ display: 'inline-flex', alignItems: 'center', gap: 0.25 }}
|
||||
>
|
||||
{stars.map((n) => {
|
||||
const color = n <= value ? 'var(--bal-warning)' : 'var(--bal-divider)';
|
||||
if (!interactive) {
|
||||
return <AppIcon key={n} icon="star" size={size} color={color} />;
|
||||
if (interactive) {
|
||||
const filled = n <= value;
|
||||
const color = filled ? 'var(--bal-rating)' : 'var(--bal-rating-empty)';
|
||||
return (
|
||||
<IconButton
|
||||
key={n}
|
||||
role="radio"
|
||||
aria-checked={filled}
|
||||
aria-label={String(n)}
|
||||
data-star={n}
|
||||
size="small"
|
||||
onClick={() => onChange?.(n)}
|
||||
sx={{ p: 0.25 }}
|
||||
>
|
||||
<AppIcon icon="star" size={size} color={color} />
|
||||
</IconButton>
|
||||
);
|
||||
}
|
||||
|
||||
// Read-only: fractional fill via a clipped full-star overlay on top of the empty star.
|
||||
const fraction = Math.max(0, Math.min(1, value - (n - 1)));
|
||||
const fillPercent = fraction * 100;
|
||||
const clipPath =
|
||||
fraction >= 1 || fraction <= 0
|
||||
? undefined
|
||||
: dir === 'rtl'
|
||||
? `inset(0 0 0 ${100 - fillPercent}%)`
|
||||
: `inset(0 ${100 - fillPercent}% 0 0)`;
|
||||
|
||||
return (
|
||||
<IconButton
|
||||
key={n}
|
||||
role="radio"
|
||||
aria-checked={n === value}
|
||||
aria-label={String(n)}
|
||||
data-star={n}
|
||||
size="small"
|
||||
onClick={() => onChange?.(n)}
|
||||
sx={{ p: 0.25 }}
|
||||
>
|
||||
<AppIcon icon="star" size={size} color={color} />
|
||||
</IconButton>
|
||||
<Box key={n} data-star-fraction={fraction} sx={{ position: 'relative', width: size, height: size, lineHeight: 0 }}>
|
||||
<AppIcon icon="star" size={size} color="var(--bal-rating-empty)" />
|
||||
{fraction > 0 ? (
|
||||
<Box sx={{ position: 'absolute', inset: 0, overflow: 'hidden', clipPath }}>
|
||||
<AppIcon icon="star" size={size} color="var(--bal-rating)" />
|
||||
</Box>
|
||||
) : null}
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
|
||||
@@ -2,12 +2,13 @@
|
||||
import { FunctionComponent } from 'react';
|
||||
import { useLocale, useTranslations } from 'next-intl';
|
||||
import { Divider, Paper, Stack, Typography } from '@mui/material';
|
||||
import StepperHeader from '@/components/StepperHeader';
|
||||
import StatusTimeline, { type TimelineNode, type TimelineNodeState } from '@/components/common/StatusTimeline';
|
||||
import StatusChip, { StatusKind } from '@/components/StatusChip';
|
||||
import Money from '@/components/common/Money';
|
||||
import PriceBreakdown from '@/components/PriceBreakdown';
|
||||
import RefundEtaBanner from '@/components/RefundEtaBanner';
|
||||
import AppAlert from '@/components/common/AppAlert';
|
||||
import { formatIrrToToman } from '@/utils';
|
||||
import { formatShamsiDate } from '@/utils';
|
||||
import {
|
||||
CUSTOMER_REFUND_STEP_ORDER,
|
||||
refundCustomerStep,
|
||||
@@ -38,14 +39,23 @@ const STEP_KIND: Record<CustomerRefundStep, StatusKind> = {
|
||||
*/
|
||||
const RefundStatusCard: FunctionComponent<RefundStatusCardProps> = ({ refund }) => {
|
||||
const t = useTranslations('refunds');
|
||||
const tc = useTranslations('common');
|
||||
const locale = useLocale();
|
||||
|
||||
const step = refundCustomerStep(refund.refundStatus);
|
||||
const isFailed = step === 'failed';
|
||||
// Succeeded → mark all steps complete (activeStep past the last); otherwise sit on the mapped step.
|
||||
// Succeeded → mark all steps complete (past the last index); otherwise sit on the mapped step.
|
||||
const activeStep =
|
||||
refund.refundStatus === 'succeeded' ? CUSTOMER_REFUND_STEP_ORDER.length : refundStepIndex(refund.refundStatus);
|
||||
const timelineNodes: TimelineNode[] = CUSTOMER_REFUND_STEP_ORDER.map((s, index) => {
|
||||
const state: TimelineNodeState = index < activeStep ? 'completed' : index === activeStep ? 'current' : 'pending';
|
||||
const timestamp =
|
||||
s === 'submitted' && refund.createdAt
|
||||
? formatShamsiDate(refund.createdAt, locale)
|
||||
: s === 'completed' && refund.completedAt
|
||||
? formatShamsiDate(refund.completedAt, locale)
|
||||
: undefined;
|
||||
return { key: s, label: t(`step_${s}`), state, timestamp };
|
||||
});
|
||||
|
||||
const hasDecomposition = refund.platformFeeRefundedIrr != null && refund.nursePayoutRefundedIrr != null;
|
||||
|
||||
@@ -70,25 +80,22 @@ const RefundStatusCard: FunctionComponent<RefundStatusCardProps> = ({ refund })
|
||||
</AppAlert>
|
||||
) : (
|
||||
<>
|
||||
<Paper elevation={0} sx={{ p: 2.5, border: '1px solid', borderColor: 'divider', borderRadius: 2 }}>
|
||||
<Stack sx={{ gap: 1.5, alignItems: 'center' }}>
|
||||
<StatusChip status={STEP_KIND[step]} label={t(`rstatus_${step}`)} />
|
||||
<StepperHeader
|
||||
steps={[t('step_submitted'), t('step_on_its_way'), t('step_completed')]}
|
||||
activeStep={activeStep}
|
||||
/>
|
||||
<Paper elevation={0} sx={{ p: 2.5, border: '1px solid', borderColor: 'divider', borderRadius: 'var(--bal-radius-md)' }}>
|
||||
<Stack sx={{ gap: 2 }}>
|
||||
<Stack sx={{ alignItems: 'center' }}>
|
||||
<StatusChip status={STEP_KIND[step]} label={t(`rstatus_${step}`)} />
|
||||
</Stack>
|
||||
<StatusTimeline nodes={timelineNodes} />
|
||||
</Stack>
|
||||
</Paper>
|
||||
|
||||
<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: 'var(--bal-radius-md)' }}>
|
||||
<Stack sx={{ gap: 1.25 }}>
|
||||
<Stack direction="row" sx={{ justifyContent: 'space-between', alignItems: 'baseline', gap: 2 }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>
|
||||
{t('total_refunded_label')}
|
||||
</Typography>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 800, color: 'var(--bal-secondary)' }}>
|
||||
{formatIrrToToman(refund.totalRefundedIrr, locale)} {tc('currency_toman')}
|
||||
</Typography>
|
||||
<Money amountIrr={refund.totalRefundedIrr} size="md" tone="emphasis" />
|
||||
</Stack>
|
||||
{refund.externalRevertReference && (
|
||||
<>
|
||||
|
||||
@@ -8,17 +8,28 @@ interface StatusStyle {
|
||||
bg: string;
|
||||
fg: string;
|
||||
icon: string;
|
||||
/** High-alarm states render a solid fill; everything else is a soft tint + strong text. */
|
||||
solid?: boolean;
|
||||
}
|
||||
|
||||
// Colors come from the semantic --bal-* tokens (both schemes defined in tokens.css),
|
||||
// so the chip switches with the color scheme automatically. Never hard-code a hex here.
|
||||
/*
|
||||
* Colors come from the semantic --bal-* tokens (both schemes defined in tokens.css), so the chip
|
||||
* switches with the color scheme automatically. Never hard-code a hex here.
|
||||
*
|
||||
* Status weight hierarchy (v2 — soft-tint by default, solid fill reserved for the alarm tier):
|
||||
* neutral (divider tint) < info (info-soft) < progress/pending (primary-soft — "in motion",
|
||||
* deliberately calmer than the old amber "pending" so routine waiting doesn't read as a warning) <
|
||||
* success/verified+active (success-soft) < warning (warning-soft — reserved for a future "needs
|
||||
* attention" kind; no current StatusKind uses it, the token exists for the next one that does) <
|
||||
* alarm/rejected (solid --bal-error — the one kind that stays a solid fill).
|
||||
*/
|
||||
const STATUS_STYLE: Record<StatusKind, StatusStyle> = {
|
||||
verified: { bg: 'var(--bal-success)', fg: 'var(--bal-success-contrast)', icon: 'verified' },
|
||||
active: { bg: 'var(--bal-success)', fg: 'var(--bal-success-contrast)', icon: 'verified' },
|
||||
pending: { bg: 'var(--bal-warning)', fg: 'var(--bal-warning-contrast)', icon: 'pending' },
|
||||
rejected: { bg: 'var(--bal-error)', fg: 'var(--bal-error-contrast)', icon: 'rejected' },
|
||||
info: { bg: 'var(--bal-info)', fg: 'var(--bal-info-contrast)', icon: 'info' },
|
||||
neutral: { bg: 'var(--bal-divider)', fg: 'var(--bal-text-secondary)', icon: 'info' },
|
||||
info: { bg: 'var(--bal-info-soft)', fg: 'var(--bal-info)', icon: 'info' },
|
||||
pending: { bg: 'var(--bal-primary-soft)', fg: 'var(--bal-primary)', icon: 'pending' },
|
||||
verified: { bg: 'var(--bal-success-soft)', fg: 'var(--bal-success)', icon: 'verified' },
|
||||
active: { bg: 'var(--bal-success-soft)', fg: 'var(--bal-success)', icon: 'verified' },
|
||||
rejected: { bg: 'var(--bal-error)', fg: 'var(--bal-error-contrast)', icon: 'rejected', solid: true },
|
||||
};
|
||||
|
||||
export interface StatusChipProps extends Omit<ChipProps, 'color' | 'icon' | 'label'> {
|
||||
@@ -29,8 +40,11 @@ export interface StatusChipProps extends Omit<ChipProps, 'color' | 'icon' | 'lab
|
||||
}
|
||||
|
||||
/**
|
||||
* Brand-harmonized status chip (verified / pending / rejected / …). Composed from MUI
|
||||
* Chip + the AppIcon registry; colors resolve from the --bal-* semantic tokens.
|
||||
* Brand-harmonized status chip (verified / pending / rejected / …) — the single delegation point every
|
||||
* status-shaped surface (`BankStatusPanel`, `VariantCard`, `PaymentStatusBadge`, `EarningsRow`,
|
||||
* `PayoutHistoryRow`, `RefundStatusCard`, `CancellationPolicyDisclosure`, …) renders through. Composed
|
||||
* from MUI Chip + the AppIcon registry; colors resolve from the --bal-* semantic tokens, soft-tint by
|
||||
* default per the weight hierarchy above.
|
||||
* @component StatusChip
|
||||
*/
|
||||
const StatusChip: FunctionComponent<StatusChipProps> = ({ status, label, size = 'small', sx, ...rest }) => {
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
'use client';
|
||||
import { FunctionComponent } from 'react';
|
||||
import { useLocale, useTranslations } from 'next-intl';
|
||||
import { Box, Paper, Stack, Typography } from '@mui/material';
|
||||
import { Box, Stack, Typography } from '@mui/material';
|
||||
import { pickCatalogName } from '@/services/catalog/names';
|
||||
import type { NurseServiceVariant } from '@/services/catalog/types';
|
||||
import AppButton from '../common/AppButton';
|
||||
import SurfaceCard from '../common/SurfaceCard';
|
||||
import StatusChip from '../StatusChip';
|
||||
import PriceDisplay from '../PriceDisplay';
|
||||
|
||||
@@ -33,14 +34,10 @@ const VariantCard: FunctionComponent<VariantCardProps> = ({ variant, onEdit, onT
|
||||
);
|
||||
|
||||
return (
|
||||
<Paper
|
||||
elevation={0}
|
||||
<SurfaceCard
|
||||
padding="sm"
|
||||
data-active={active}
|
||||
sx={{
|
||||
p: 2,
|
||||
borderRadius: 2,
|
||||
border: '1px solid',
|
||||
borderColor: 'divider',
|
||||
// Deactivated rows read as muted/unbookable without hiding their content.
|
||||
opacity: active ? 1 : 0.66,
|
||||
}}
|
||||
@@ -89,7 +86,7 @@ const VariantCard: FunctionComponent<VariantCardProps> = ({ variant, onEdit, onT
|
||||
</AppButton>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Paper>
|
||||
</SurfaceCard>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -2,10 +2,10 @@
|
||||
import { FunctionComponent } from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
import Chip from '@mui/material/Chip';
|
||||
import Paper from '@mui/material/Paper';
|
||||
import Skeleton from '@mui/material/Skeleton';
|
||||
import Stack from '@mui/material/Stack';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import { AppIcon } from '@/components/common';
|
||||
import { AppIcon, SurfaceCard } from '@/components/common';
|
||||
import type { VisitNote } from '@/services/patientRecords/types';
|
||||
|
||||
export interface VisitNoteCardProps {
|
||||
@@ -26,7 +26,7 @@ export interface VisitNoteCardProps {
|
||||
*/
|
||||
const VisitNoteCard: FunctionComponent<VisitNoteCardProps> = ({ note, dateLabel, authorFallback }) => {
|
||||
return (
|
||||
<Paper elevation={0} sx={{ p: 2, border: '1px solid', borderColor: 'divider', borderRadius: 2 }}>
|
||||
<SurfaceCard padding="sm">
|
||||
<Stack direction="row" sx={{ alignItems: 'center', gap: 1, mb: 1, flexWrap: 'wrap' }}>
|
||||
<AppIcon icon="notes" size={18} color="var(--bal-primary)" />
|
||||
<Typography variant="body2" sx={{ fontWeight: 700 }}>
|
||||
@@ -61,8 +61,25 @@ const VisitNoteCard: FunctionComponent<VisitNoteCardProps> = ({ note, dateLabel,
|
||||
))}
|
||||
</Box>
|
||||
) : null}
|
||||
</Paper>
|
||||
</SurfaceCard>
|
||||
);
|
||||
};
|
||||
|
||||
export default VisitNoteCard;
|
||||
/** Matches the real card's anatomy (author/date row, body lines, task-chip row) for the visit-note history. */
|
||||
const VisitNoteCardSkeleton: FunctionComponent = () => (
|
||||
<SurfaceCard padding="sm">
|
||||
<Stack direction="row" sx={{ alignItems: 'center', gap: 1, mb: 1 }}>
|
||||
<Skeleton variant="circular" width={18} height={18} />
|
||||
<Skeleton variant="text" width={100} height={20} />
|
||||
<Skeleton variant="text" width={80} height={16} sx={{ marginInlineStart: 'auto' }} />
|
||||
</Stack>
|
||||
<Skeleton variant="text" width="100%" height={18} />
|
||||
<Skeleton variant="text" width="80%" height={18} />
|
||||
<Stack direction="row" sx={{ gap: 0.5, mt: 1 }}>
|
||||
<Skeleton variant="rounded" width={70} height={24} />
|
||||
<Skeleton variant="rounded" width={70} height={24} />
|
||||
</Stack>
|
||||
</SurfaceCard>
|
||||
);
|
||||
|
||||
export default Object.assign(VisitNoteCard, { Skeleton: VisitNoteCardSkeleton });
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { FunctionComponent } from 'react';
|
||||
import { Paper, Typography } from '@mui/material';
|
||||
import AppIcon from '../common/AppIcon';
|
||||
import EmptyState from '../common/EmptyState';
|
||||
|
||||
export interface AdminEmptyStateProps {
|
||||
/** AppIcon registry name. */
|
||||
@@ -12,25 +11,10 @@ export interface AdminEmptyStateProps {
|
||||
}
|
||||
|
||||
/**
|
||||
* The shared empty-state panel for admin worklists ("Queue clear", "No open alerts", …). Dashed border,
|
||||
* muted icon; tokens only.
|
||||
* Thin admin-scoped alias of the shared `EmptyState` — kept so existing backoffice call sites don't need
|
||||
* a mechanical import rename. New code (admin or otherwise) should import `EmptyState` from `@/components`.
|
||||
* @component AdminEmptyState
|
||||
*/
|
||||
const AdminEmptyState: FunctionComponent<AdminEmptyStateProps> = ({ icon = 'info', title, body }) => (
|
||||
<Paper
|
||||
elevation={0}
|
||||
sx={{ p: 5, 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 }}>
|
||||
{title}
|
||||
</Typography>
|
||||
{body ? (
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary', mt: 0.5 }}>
|
||||
{body}
|
||||
</Typography>
|
||||
) : null}
|
||||
</Paper>
|
||||
);
|
||||
const AdminEmptyState: FunctionComponent<AdminEmptyStateProps> = (props) => <EmptyState {...props} />;
|
||||
|
||||
export default AdminEmptyState;
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import { ThemeProvider } from '../../theme';
|
||||
|
||||
jest.mock('next-intl', () => ({
|
||||
useTranslations: () => (key: string) => key,
|
||||
}));
|
||||
|
||||
import AdminErrorState from './AdminErrorState';
|
||||
|
||||
describe('<AdminErrorState/>', () => {
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { FunctionComponent } from 'react';
|
||||
import { Paper, Typography } from '@mui/material';
|
||||
import AppButton from '../common/AppButton';
|
||||
import ErrorState from '../common/ErrorState';
|
||||
|
||||
export interface AdminErrorStateProps {
|
||||
/** Already-translated message. */
|
||||
@@ -11,22 +10,10 @@ export interface AdminErrorStateProps {
|
||||
}
|
||||
|
||||
/**
|
||||
* The shared error panel for admin worklists — a muted message + a retry button. `clientFetch` already
|
||||
* toasts 401/403/5xx, so this is the inline recovery affordance, not the notification.
|
||||
* Thin admin-scoped alias of the shared `ErrorState` — kept so existing backoffice call sites don't need
|
||||
* a mechanical import rename. New code (admin or otherwise) should import `ErrorState` from `@/components`.
|
||||
* @component AdminErrorState
|
||||
*/
|
||||
const AdminErrorState: FunctionComponent<AdminErrorStateProps> = ({ message, retryLabel, onRetry }) => (
|
||||
<Paper
|
||||
elevation={0}
|
||||
sx={{ p: 5, 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}>
|
||||
{retryLabel}
|
||||
</AppButton>
|
||||
</Paper>
|
||||
);
|
||||
const AdminErrorState: FunctionComponent<AdminErrorStateProps> = (props) => <ErrorState {...props} />;
|
||||
|
||||
export default AdminErrorState;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { FunctionComponent, ReactNode } from 'react';
|
||||
import { Box, Stack, Typography } from '@mui/material';
|
||||
import PageHeader from '../common/PageHeader';
|
||||
|
||||
export interface AdminPageHeaderProps {
|
||||
/** Already-translated title (i18n is the caller's job — labels are keys). */
|
||||
@@ -11,28 +11,10 @@ export interface AdminPageHeaderProps {
|
||||
}
|
||||
|
||||
/**
|
||||
* The standard backoffice page header — a title + optional subtitle and an end-aligned actions slot. Shared
|
||||
* by every admin console so the worklists read as one system (phase §3). Presentational; RTL-safe (logical
|
||||
* flex, no directional hard-coding).
|
||||
* Thin admin-scoped alias of the shared `PageHeader` — kept so existing backoffice call sites don't need
|
||||
* a mechanical import rename. New code (admin or otherwise) should import `PageHeader` from `@/components`.
|
||||
* @component AdminPageHeader
|
||||
*/
|
||||
const AdminPageHeader: FunctionComponent<AdminPageHeaderProps> = ({ title, subtitle, actions }) => (
|
||||
<Stack
|
||||
direction="row"
|
||||
sx={{ gap: 2, alignItems: 'flex-start', justifyContent: 'space-between', flexWrap: 'wrap' }}
|
||||
>
|
||||
<Box>
|
||||
<Typography variant="h5" component="h1" sx={{ fontWeight: 800 }}>
|
||||
{title}
|
||||
</Typography>
|
||||
{subtitle ? (
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary', mt: 0.5 }}>
|
||||
{subtitle}
|
||||
</Typography>
|
||||
) : null}
|
||||
</Box>
|
||||
{actions ? <Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap' }}>{actions}</Box> : null}
|
||||
</Stack>
|
||||
);
|
||||
const AdminPageHeader: FunctionComponent<AdminPageHeaderProps> = (props) => <PageHeader {...props} />;
|
||||
|
||||
export default AdminPageHeader;
|
||||
|
||||
@@ -1,113 +1,14 @@
|
||||
'use client';
|
||||
import { FunctionComponent, ReactNode, useState } from 'react';
|
||||
import {
|
||||
CircularProgress,
|
||||
Dialog,
|
||||
DialogActions,
|
||||
DialogContent,
|
||||
DialogContentText,
|
||||
DialogTitle,
|
||||
TextField,
|
||||
} from '@mui/material';
|
||||
import AppButton from '../common/AppButton';
|
||||
import { FunctionComponent } from 'react';
|
||||
import ConfirmDialog, { ConfirmDialogProps } from '../common/ConfirmDialog';
|
||||
|
||||
export interface ConfirmDialogProps {
|
||||
open: boolean;
|
||||
/** Already-translated title. */
|
||||
title: string;
|
||||
/** Already-translated body (string or node). */
|
||||
body?: ReactNode;
|
||||
confirmLabel: string;
|
||||
cancelLabel: string;
|
||||
/** Called with the entered reason (undefined when `requireReason` is false). */
|
||||
onConfirm: (reason?: string) => void;
|
||||
onClose: () => void;
|
||||
loading?: boolean;
|
||||
/** When true a required reason field shows; confirm is disabled until it is non-empty. */
|
||||
requireReason?: boolean;
|
||||
reasonLabel?: string;
|
||||
reasonPlaceholder?: string;
|
||||
/** MUI color for the confirm button — `error` for a destructive action. */
|
||||
confirmColor?: 'primary' | 'error' | 'secondary';
|
||||
}
|
||||
export type { ConfirmDialogProps };
|
||||
|
||||
/**
|
||||
* The shared confirmation dialog behind every audited/irreversible admin action — approve/reject a
|
||||
* verification, run/retry a payout, save a config, resolve an alert, verify a center. Optionally collects a
|
||||
* **required reason** (reject/hide/resolve) and disables confirm until it is provided. The dialog owns the
|
||||
* reason field only; the caller owns the mutation and closes on success. `loading` disables the buttons and
|
||||
* shows a spinner so a double-submit is impossible.
|
||||
* Thin admin-scoped alias of the shared `ConfirmDialog` — kept so existing backoffice call sites don't
|
||||
* need a mechanical import rename. New code (admin or otherwise) should import `ConfirmDialog` from
|
||||
* `@/components`.
|
||||
* @component ConfirmDialog
|
||||
*/
|
||||
const ConfirmDialog: FunctionComponent<ConfirmDialogProps> = ({
|
||||
open,
|
||||
title,
|
||||
body,
|
||||
confirmLabel,
|
||||
cancelLabel,
|
||||
onConfirm,
|
||||
onClose,
|
||||
loading = false,
|
||||
requireReason = false,
|
||||
reasonLabel,
|
||||
reasonPlaceholder,
|
||||
confirmColor = 'primary',
|
||||
}) => {
|
||||
const [reason, setReason] = useState('');
|
||||
const AdminConfirmDialog: FunctionComponent<ConfirmDialogProps> = (props) => <ConfirmDialog {...props} />;
|
||||
|
||||
const close = () => {
|
||||
setReason('');
|
||||
onClose();
|
||||
};
|
||||
|
||||
const confirm = () => {
|
||||
onConfirm(requireReason ? reason.trim() : undefined);
|
||||
setReason('');
|
||||
};
|
||||
|
||||
const confirmDisabled = loading || (requireReason && reason.trim().length === 0);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onClose={loading ? undefined : close} fullWidth maxWidth="xs">
|
||||
<DialogTitle sx={{ fontWeight: 800 }}>{title}</DialogTitle>
|
||||
<DialogContent>
|
||||
{body ? (
|
||||
typeof body === 'string' ? (
|
||||
<DialogContentText sx={{ color: 'text.secondary' }}>{body}</DialogContentText>
|
||||
) : (
|
||||
body
|
||||
)
|
||||
) : null}
|
||||
{requireReason ? (
|
||||
<TextField
|
||||
autoFocus
|
||||
fullWidth
|
||||
multiline
|
||||
minRows={2}
|
||||
value={reason}
|
||||
onChange={(e) => setReason(e.target.value)}
|
||||
label={reasonLabel}
|
||||
placeholder={reasonPlaceholder}
|
||||
sx={{ mt: 2 }}
|
||||
/>
|
||||
) : null}
|
||||
</DialogContent>
|
||||
<DialogActions sx={{ px: 3, pb: 2 }}>
|
||||
<AppButton variant="text" color="inherit" onClick={close} disabled={loading}>
|
||||
{cancelLabel}
|
||||
</AppButton>
|
||||
<AppButton
|
||||
variant="contained"
|
||||
color={confirmColor}
|
||||
onClick={confirm}
|
||||
disabled={confirmDisabled}
|
||||
startIcon={loading ? <CircularProgress size={16} color="inherit" /> : undefined}
|
||||
>
|
||||
{confirmLabel}
|
||||
</AppButton>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default ConfirmDialog;
|
||||
export default AdminConfirmDialog;
|
||||
|
||||
@@ -2,9 +2,10 @@
|
||||
import { FunctionComponent } from 'react';
|
||||
import { useLocale, useTranslations } from 'next-intl';
|
||||
import { Box, Chip, Paper, Stack, Typography } from '@mui/material';
|
||||
import { formatIrrToToman, formatShamsiDate } from '@/utils';
|
||||
import { formatShamsiDate } from '@/utils';
|
||||
import type { CenterInvoice } from '@/services/partnerCenter/types';
|
||||
import AppButton from '../common/AppButton';
|
||||
import Money from '../common/Money';
|
||||
import PriceBreakdown, { type PriceBreakdownRow } from '../PriceBreakdown';
|
||||
|
||||
export interface PartnerSettlementRowProps {
|
||||
@@ -24,7 +25,6 @@ export interface PartnerSettlementRowProps {
|
||||
*/
|
||||
const PartnerSettlementRow: FunctionComponent<PartnerSettlementRowProps> = ({ invoice, onDownloadPdf, downloadError, onRetryDownload }) => {
|
||||
const t = useTranslations('partner');
|
||||
const tc = useTranslations('common');
|
||||
const locale = useLocale();
|
||||
|
||||
const rows: PriceBreakdownRow[] = [
|
||||
@@ -54,9 +54,7 @@ const PartnerSettlementRow: FunctionComponent<PartnerSettlementRowProps> = ({ in
|
||||
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
|
||||
{t('invoice_row_gross')}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ fontWeight: 700 }}>
|
||||
{formatIrrToToman(invoice.grossIrr, locale)} {tc('currency_toman')}
|
||||
</Typography>
|
||||
<Money amountIrr={invoice.grossIrr} size="sm" sx={{ fontWeight: 700 }} />
|
||||
</Stack>
|
||||
</Stack>
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import { FunctionComponent, useState } from 'react';
|
||||
import { useLocale, useTranslations } from 'next-intl';
|
||||
import { useSnackbar } from 'notistack';
|
||||
import { Alert, Box, Chip, MenuItem, Skeleton, Stack, TextField, Typography } from '@mui/material';
|
||||
import { formatShamsiDate } from '@/utils';
|
||||
import { formatNumber, formatShamsiDate } from '@/utils';
|
||||
import type { AdminRefundResult, RefundChannel } from '@/services/refunds/types';
|
||||
import { useApproveRefund, useInitiateRefund, useRefundPreview, useRejectRefund } from '@/services/refunds';
|
||||
import AppButton from '../common/AppButton';
|
||||
@@ -106,7 +106,7 @@ const RefundPanel: FunctionComponent<RefundPanelProps> = ({ bookingId, ticketId,
|
||||
const activeResult = result;
|
||||
const failed = activeResult?.status === 'failed';
|
||||
const finalDone = activeResult && (activeResult.status === 'succeeded' || activeResult.status === 'processing' || activeResult.status === 'rejected');
|
||||
const pct = new Intl.NumberFormat(locale === 'fa' ? 'fa-IR' : 'en-US', { style: 'percent', maximumFractionDigits: 0 }).format(p.refundPercentageApplied);
|
||||
const pct = formatNumber(p.refundPercentageApplied, locale, { style: 'percent', maximumFractionDigits: 0 });
|
||||
|
||||
return (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||
|
||||
@@ -34,6 +34,7 @@ interface RoleGuardProps {
|
||||
* @component RoleGuard
|
||||
*/
|
||||
const RoleGuard: FunctionComponent<RoleGuardProps> = ({ expected, children }) => {
|
||||
return children;
|
||||
const t = useTranslations('auth');
|
||||
const router = useRouter();
|
||||
const locale = useLocale();
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { FunctionComponent } from 'react';
|
||||
import { useLocale, useTranslations } from 'next-intl';
|
||||
import { Chip, Paper, Skeleton, Stack, Typography } from '@mui/material';
|
||||
import AccentCard from '@/components/common/AccentCard';
|
||||
import AppIcon from '@/components/common/AppIcon';
|
||||
import { formatShamsiDate } from '@/utils';
|
||||
import { useBookingDetail, useCareInstructions } from '@/services/bookings';
|
||||
@@ -214,10 +215,7 @@ function CareSection({
|
||||
if (viewerRole !== 'nurse') {
|
||||
// Customer: never renders the clinical record and never fires the query.
|
||||
return (
|
||||
<Paper
|
||||
elevation={0}
|
||||
sx={{ p: 2.5, border: '1px dashed', borderColor: 'divider', borderRadius: 2 }}
|
||||
>
|
||||
<AccentCard tone="neutral">
|
||||
<Stack direction="row" sx={{ gap: 1.25, alignItems: 'flex-start' }}>
|
||||
<AppIcon icon="lock" size={20} color="var(--bal-text-secondary)" />
|
||||
<Stack sx={{ gap: 0.25 }}>
|
||||
@@ -229,7 +227,7 @@ function CareSection({
|
||||
</Typography>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Paper>
|
||||
</AccentCard>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
'use client';
|
||||
import { FunctionComponent, ReactNode } from 'react';
|
||||
import { useLocale, useTranslations } from 'next-intl';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { Paper, Stack, Typography } from '@mui/material';
|
||||
import { formatIrrToToman } from '@/utils';
|
||||
import Money from '@/components/common/Money';
|
||||
import type { MoneyTone } from '@/components/common/Money';
|
||||
import type { BookingViewerRole } from '@/services/bookings/types';
|
||||
|
||||
export interface BookingMoneySummaryProps {
|
||||
@@ -28,10 +29,6 @@ const BookingMoneySummary: FunctionComponent<BookingMoneySummaryProps> = ({
|
||||
viewerRole,
|
||||
}) => {
|
||||
const t = useTranslations('booking');
|
||||
const tc = useTranslations('common');
|
||||
const locale = useLocale();
|
||||
|
||||
const toman = (irr: string) => `${formatIrrToToman(irr, locale)} ${tc('currency_toman')}`;
|
||||
|
||||
return (
|
||||
<Paper elevation={0} sx={{ p: 2.5, border: '1px solid', borderColor: 'divider', borderRadius: 2 }}>
|
||||
@@ -39,12 +36,12 @@ const BookingMoneySummary: FunctionComponent<BookingMoneySummaryProps> = ({
|
||||
{t('money_title')}
|
||||
</Typography>
|
||||
<Stack sx={{ gap: 1 }}>
|
||||
<MoneyRow label={t('money_gross')} value={toman(grossPriceIrr)} emphasize />
|
||||
<MoneyRow label={t('money_commission')} value={toman(balinyaarCommissionIrr)} />
|
||||
<MoneyRow label={t('money_gross')} amountIrr={grossPriceIrr} tone="emphasis" />
|
||||
<MoneyRow label={t('money_commission')} amountIrr={balinyaarCommissionIrr} deduction />
|
||||
<MoneyRow
|
||||
label={viewerRole === 'nurse' ? t('money_nurse_earning') : t('money_payout')}
|
||||
value={toman(nursePayoutAmount)}
|
||||
accent
|
||||
amountIrr={nursePayoutAmount}
|
||||
tone="emphasis"
|
||||
/>
|
||||
</Stack>
|
||||
</Paper>
|
||||
@@ -53,26 +50,21 @@ const BookingMoneySummary: FunctionComponent<BookingMoneySummaryProps> = ({
|
||||
|
||||
function MoneyRow({
|
||||
label,
|
||||
value,
|
||||
emphasize = false,
|
||||
accent = false,
|
||||
amountIrr,
|
||||
tone,
|
||||
deduction = false,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
emphasize?: boolean;
|
||||
accent?: boolean;
|
||||
amountIrr: string;
|
||||
tone?: MoneyTone;
|
||||
deduction?: boolean;
|
||||
}): ReactNode {
|
||||
return (
|
||||
<Stack direction="row" sx={{ justifyContent: 'space-between', alignItems: 'center', gap: 2 }}>
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||
{label}
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{ fontWeight: emphasize ? 800 : 700, color: accent ? 'var(--bal-secondary-dark)' : 'text.primary' }}
|
||||
>
|
||||
{value}
|
||||
</Typography>
|
||||
<Money amountIrr={amountIrr} size="sm" tone={tone} deduction={deduction} />
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useTranslations } from 'next-intl';
|
||||
import { Stack, Typography } from '@mui/material';
|
||||
import AppIcon from '@/components/common/AppIcon';
|
||||
import StatusChip from '@/components/StatusChip';
|
||||
import StepperHeader from '@/components/StepperHeader';
|
||||
import StatusTimeline, { type TimelineNode, type TimelineNodeState } from '@/components/common/StatusTimeline';
|
||||
import {
|
||||
BOOKING_TIMELINE_ORDER,
|
||||
bookingTimelineActiveIndex,
|
||||
@@ -17,18 +17,31 @@ export interface BookingStatusTimelineProps {
|
||||
status: BookingStatus;
|
||||
}
|
||||
|
||||
/** `completed`/`disputed`/`closed` sit at the "completed" index but mean "done", never "in progress". */
|
||||
function nodeState(index: number, activeIndex: number, status: BookingStatus): TimelineNodeState {
|
||||
if (index < activeIndex) return 'completed';
|
||||
if (index > activeIndex) return 'pending';
|
||||
return status === 'completed' || status === 'disputed' || status === 'closed' ? 'completed' : 'current';
|
||||
}
|
||||
|
||||
/**
|
||||
* The both-roles booking status timeline — the grown-up version of the f7 C5 3-step tracker. It renders
|
||||
* the canonical happy path `pending_payment → confirmed → in_progress → completed` over the shared
|
||||
* StepperHeader, with the current-status chip above it. Terminal branches are shown **distinctly**:
|
||||
* `cancelled` replaces the stepper with a neutral terminal row (a progress bar would mislead); `disputed`
|
||||
* / `closed` keep the stepper (they follow completion) and surface through the chip. **Reflects
|
||||
* `BookingDetailDto.status` exactly** — it is never advanced on the client.
|
||||
* `StatusTimeline`, with the current-status chip above it. Terminal branches are shown **distinctly**:
|
||||
* `cancelled` replaces the timeline with a neutral terminal row (a progress line would mislead);
|
||||
* `disputed`/`closed` keep the timeline (they follow completion, rendered as fully completed) and
|
||||
* surface through the chip. **Reflects `BookingDetailDto.status` exactly** — it is never advanced on the
|
||||
* client.
|
||||
* @component BookingStatusTimeline
|
||||
*/
|
||||
const BookingStatusTimeline: FunctionComponent<BookingStatusTimelineProps> = ({ status }) => {
|
||||
const t = useTranslations('booking');
|
||||
const steps = BOOKING_TIMELINE_ORDER.map((s) => t(`bstatus_${s}`));
|
||||
const activeIndex = bookingTimelineActiveIndex(status);
|
||||
const nodes: TimelineNode[] = BOOKING_TIMELINE_ORDER.map((s, index) => ({
|
||||
key: s,
|
||||
label: t(`bstatus_${s}`),
|
||||
state: nodeState(index, activeIndex, status),
|
||||
}));
|
||||
|
||||
return (
|
||||
<Stack sx={{ gap: 1.5 }} data-booking-status={status}>
|
||||
@@ -50,7 +63,7 @@ const BookingStatusTimeline: FunctionComponent<BookingStatusTimelineProps> = ({
|
||||
</Typography>
|
||||
</Stack>
|
||||
) : (
|
||||
<StepperHeader steps={steps} activeStep={bookingTimelineActiveIndex(status)} />
|
||||
<StatusTimeline nodes={nodes} />
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
import { FunctionComponent } from 'react';
|
||||
import { useLocale, useTranslations } from 'next-intl';
|
||||
import { Paper, Stack, Typography } from '@mui/material';
|
||||
import { AppButton } from '@/components/common';
|
||||
import { AppButton, Money } from '@/components/common';
|
||||
import StatusChip from '@/components/StatusChip';
|
||||
import { formatIrrToToman, formatShamsiDate } from '@/utils';
|
||||
import { formatShamsiDate } from '@/utils';
|
||||
import type { BookingSessionStatus, VisitVerificationStatus } from '@/services/bookings/types';
|
||||
import EvvStatusBanner from '../EvvStatusBanner';
|
||||
import { formatClock, formatElapsed, formatTimeRange } from '../format';
|
||||
@@ -67,7 +67,6 @@ const SessionCard: FunctionComponent<SessionCardProps> = ({
|
||||
onCheckOut,
|
||||
}) => {
|
||||
const t = useTranslations('booking');
|
||||
const tc = useTranslations('common');
|
||||
const locale = useLocale();
|
||||
|
||||
const dateLabel = formatShamsiDate(scheduledDate, locale);
|
||||
@@ -109,8 +108,8 @@ const SessionCard: FunctionComponent<SessionCardProps> = ({
|
||||
) : null}
|
||||
|
||||
{showPayout && visitPayoutAmount ? (
|
||||
<Typography variant="caption" sx={{ color: 'var(--bal-secondary-dark)', fontWeight: 500 }}>
|
||||
{t('session_payout')}: {formatIrrToToman(visitPayoutAmount, locale)} {tc('currency_toman')}
|
||||
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
|
||||
{t('session_payout')}: <Money amountIrr={visitPayoutAmount} size="sm" tone="emphasis" />
|
||||
</Typography>
|
||||
) : null}
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { formatNumber, localeTag } from '@/utils';
|
||||
|
||||
/**
|
||||
* Shared time formatters for the booking composites. Timestamps cross the wire as UTC ISO; these render
|
||||
* a locale clock (Persian digits for `fa`) for the EVV banner + session schedule. Kept internal to the
|
||||
@@ -8,7 +10,7 @@
|
||||
export function formatClock(iso: string, locale: string): string {
|
||||
const date = new Date(iso);
|
||||
if (Number.isNaN(date.getTime())) return '';
|
||||
return new Intl.DateTimeFormat(locale === 'fa' ? 'fa-IR' : 'en-US', {
|
||||
return new Intl.DateTimeFormat(localeTag(locale), {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
}).format(date);
|
||||
@@ -18,7 +20,7 @@ export function formatClock(iso: string, locale: string): string {
|
||||
export function formatTimeOfDay(time: string, locale: string): string {
|
||||
const [h, m] = time.split(':');
|
||||
if (h == null || m == null) return time;
|
||||
const nf = new Intl.NumberFormat(locale === 'fa' ? 'fa-IR' : 'en-US', {
|
||||
const nf = new Intl.NumberFormat(localeTag(locale), {
|
||||
minimumIntegerDigits: 2,
|
||||
useGrouping: false,
|
||||
});
|
||||
@@ -42,10 +44,8 @@ export function formatElapsed(startIso: string | null, endIso: string | null, lo
|
||||
const totalMinutes = Math.round((end - start) / 60_000);
|
||||
const hours = Math.floor(totalMinutes / 60);
|
||||
const minutes = totalMinutes % 60;
|
||||
const nf = new Intl.NumberFormat(locale === 'fa' ? 'fa-IR' : 'en-US', { useGrouping: false });
|
||||
const nfPad = new Intl.NumberFormat(locale === 'fa' ? 'fa-IR' : 'en-US', {
|
||||
return `${formatNumber(hours, locale, { useGrouping: false })}:${formatNumber(minutes, locale, {
|
||||
minimumIntegerDigits: 2,
|
||||
useGrouping: false,
|
||||
});
|
||||
return `${nf.format(hours)}:${nfPad.format(minutes)}`;
|
||||
})}`;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { ThemeProvider } from '../../../theme';
|
||||
import AccentCard from './AccentCard';
|
||||
|
||||
describe('<AccentCard/>', () => {
|
||||
const wrap = (ui: React.ReactNode) => render(<ThemeProvider>{ui}</ThemeProvider>);
|
||||
|
||||
it('renders its children with the tone data attribute', () => {
|
||||
wrap(<AccentCard tone="success">content</AccentCard>);
|
||||
expect(screen.getByText('content')).toBeInTheDocument();
|
||||
expect(screen.getByText('content').closest('[data-accent-tone]')).toHaveAttribute('data-accent-tone', 'success');
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user