diff --git a/client/.env.development b/client/.env.development index 0db0a4c..f0db0f9 100644 --- a/client/.env.development +++ b/client/.env.development @@ -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 \ No newline at end of file diff --git a/client/CLAUDE.md b/client/CLAUDE.md index 9ed8951..dec11dd 100644 --- a/client/CLAUDE.md +++ b/client/CLAUDE.md @@ -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 , so it CANNOT use next-intl. The one sanctioned static-string exception (minimal, bilingual fa+en). │ └── [locale]/ - │ ├── layout.tsx # ROOT RSC: renders + fonts + setRequestLocale + NextIntlClientProvider + ThemeProvider + AuthProvider (seeded via getServerAuthState) + │ ├── layout.tsx # ROOT RSC: renders + 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/ # — 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 +`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 `` 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 diff --git a/client/jest.config.ts b/client/jest.config.ts index bb0de34..da93329 100644 --- a/client/jest.config.ts +++ b/client/jest.config.ts @@ -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; diff --git a/client/messages/en.json b/client/messages/en.json index e2fb649..a087d2d 100644 --- a/client/messages/en.json +++ b/client/messages/en.json @@ -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" } } diff --git a/client/messages/fa.json b/client/messages/fa.json index 28d1f8d..08b7558 100644 --- a/client/messages/fa.json +++ b/client/messages/fa.json @@ -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": "بازگشت به خانه" } } diff --git a/client/next.config.mjs b/client/next.config.mjs index f584ef0..5442187 100644 --- a/client/next.config.mjs +++ b/client/next.config.mjs @@ -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); diff --git a/client/package-lock.json b/client/package-lock.json index bcd0387..1b448fe 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -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", diff --git a/client/package.json b/client/package.json index 8963fbc..873704b 100644 --- a/client/package.json +++ b/client/package.json @@ -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", diff --git a/client/src/app/[locale]/(private-routes)/(customer)/HomeScreen.tsx b/client/src/app/[locale]/(private-routes)/(customer)/HomeScreen.tsx new file mode 100644 index 0000000..4f7f73a --- /dev/null +++ b/client/src/app/[locale]/(private-routes)/(customer)/HomeScreen.tsx @@ -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> + ); +}; diff --git a/client/src/app/[locale]/(private-routes)/(customer)/addresses/page.tsx b/client/src/app/[locale]/(private-routes)/(customer)/addresses/page.tsx index 6fc1478..879a767 100644 --- a/client/src/app/[locale]/(private-routes)/(customer)/addresses/page.tsx +++ b/client/src/app/[locale]/(private-routes)/(customer)/addresses/page.tsx @@ -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) => ( diff --git a/client/src/app/[locale]/(private-routes)/(customer)/bookings/BookingsScreen.tsx b/client/src/app/[locale]/(private-routes)/(customer)/bookings/BookingsScreen.tsx new file mode 100644 index 0000000..7cb7277 --- /dev/null +++ b/client/src/app/[locale]/(private-routes)/(customer)/bookings/BookingsScreen.tsx @@ -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> + ); +} diff --git a/client/src/app/[locale]/(private-routes)/(customer)/bookings/[id]/cancel/page.tsx b/client/src/app/[locale]/(private-routes)/(customer)/bookings/[id]/cancel/page.tsx index f8bc96f..b3f609f 100644 --- a/client/src/app/[locale]/(private-routes)/(customer)/bookings/[id]/cancel/page.tsx +++ b/client/src/app/[locale]/(private-routes)/(customer)/bookings/[id]/cancel/page.tsx @@ -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 && ( diff --git a/client/src/app/[locale]/(private-routes)/(customer)/bookings/[id]/invoice/page.tsx b/client/src/app/[locale]/(private-routes)/(customer)/bookings/[id]/invoice/page.tsx index cc50748..85ae484 100644 --- a/client/src/app/[locale]/(private-routes)/(customer)/bookings/[id]/invoice/page.tsx +++ b/client/src/app/[locale]/(private-routes)/(customer)/bookings/[id]/invoice/page.tsx @@ -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); diff --git a/client/src/app/[locale]/(private-routes)/(customer)/bookings/[id]/review/page.tsx b/client/src/app/[locale]/(private-routes)/(customer)/bookings/[id]/review/page.tsx index 1dd4f04..97fabd7 100644 --- a/client/src/app/[locale]/(private-routes)/(customer)/bookings/[id]/review/page.tsx +++ b/client/src/app/[locale]/(private-routes)/(customer)/bookings/[id]/review/page.tsx @@ -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> diff --git a/client/src/app/[locale]/(private-routes)/(customer)/bookings/checkout/bnpl/EligibilityStep.tsx b/client/src/app/[locale]/(private-routes)/(customer)/bookings/checkout/bnpl/EligibilityStep.tsx index c7b1fbb..1310ca9 100644 --- a/client/src/app/[locale]/(private-routes)/(customer)/bookings/checkout/bnpl/EligibilityStep.tsx +++ b/client/src/app/[locale]/(private-routes)/(customer)/bookings/checkout/bnpl/EligibilityStep.tsx @@ -1,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> diff --git a/client/src/app/[locale]/(private-routes)/(customer)/bookings/checkout/bnpl/MethodStep.tsx b/client/src/app/[locale]/(private-routes)/(customer)/bookings/checkout/bnpl/MethodStep.tsx index 9a05827..82fdde9 100644 --- a/client/src/app/[locale]/(private-routes)/(customer)/bookings/checkout/bnpl/MethodStep.tsx +++ b/client/src/app/[locale]/(private-routes)/(customer)/bookings/checkout/bnpl/MethodStep.tsx @@ -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> ); diff --git a/client/src/app/[locale]/(private-routes)/(customer)/bookings/checkout/bnpl/PlanStep.tsx b/client/src/app/[locale]/(private-routes)/(customer)/bookings/checkout/bnpl/PlanStep.tsx index ffa9c81..d50a5ff 100644 --- a/client/src/app/[locale]/(private-routes)/(customer)/bookings/checkout/bnpl/PlanStep.tsx +++ b/client/src/app/[locale]/(private-routes)/(customer)/bookings/checkout/bnpl/PlanStep.tsx @@ -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> diff --git a/client/src/app/[locale]/(private-routes)/(customer)/bookings/checkout/bnpl/gateway/page.tsx b/client/src/app/[locale]/(private-routes)/(customer)/bookings/checkout/bnpl/gateway/page.tsx index 79d4b52..de89823 100644 --- a/client/src/app/[locale]/(private-routes)/(customer)/bookings/checkout/bnpl/gateway/page.tsx +++ b/client/src/app/[locale]/(private-routes)/(customer)/bookings/checkout/bnpl/gateway/page.tsx @@ -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> + } + /> ); } diff --git a/client/src/app/[locale]/(private-routes)/(customer)/bookings/checkout/confirmation/page.tsx b/client/src/app/[locale]/(private-routes)/(customer)/bookings/checkout/confirmation/page.tsx index 22c6657..c1ea023 100644 --- a/client/src/app/[locale]/(private-routes)/(customer)/bookings/checkout/confirmation/page.tsx +++ b/client/src/app/[locale]/(private-routes)/(customer)/bookings/checkout/confirmation/page.tsx @@ -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> diff --git a/client/src/app/[locale]/(private-routes)/(customer)/bookings/checkout/page.tsx b/client/src/app/[locale]/(private-routes)/(customer)/bookings/checkout/page.tsx index 79d70b1..c9335bd 100644 --- a/client/src/app/[locale]/(private-routes)/(customer)/bookings/checkout/page.tsx +++ b/client/src/app/[locale]/(private-routes)/(customer)/bookings/checkout/page.tsx @@ -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', }); diff --git a/client/src/app/[locale]/(private-routes)/(customer)/bookings/page.tsx b/client/src/app/[locale]/(private-routes)/(customer)/bookings/page.tsx index d1f8687..cda6b17 100644 --- a/client/src/app/[locale]/(private-routes)/(customer)/bookings/page.tsx +++ b/client/src/app/[locale]/(private-routes)/(customer)/bookings/page.tsx @@ -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 />; } diff --git a/client/src/app/[locale]/(private-routes)/(customer)/bookings/request/page.tsx b/client/src/app/[locale]/(private-routes)/(customer)/bookings/request/page.tsx index d068865..0697d4d 100644 --- a/client/src/app/[locale]/(private-routes)/(customer)/bookings/request/page.tsx +++ b/client/src/app/[locale]/(private-routes)/(customer)/bookings/request/page.tsx @@ -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 }}> diff --git a/client/src/app/[locale]/(private-routes)/(customer)/loading.tsx b/client/src/app/[locale]/(private-routes)/(customer)/loading.tsx new file mode 100644 index 0000000..01ec39d --- /dev/null +++ b/client/src/app/[locale]/(private-routes)/(customer)/loading.tsx @@ -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> + ); +} diff --git a/client/src/app/[locale]/(private-routes)/(customer)/page.tsx b/client/src/app/[locale]/(private-routes)/(customer)/page.tsx index 5257508..7fcc643 100644 --- a/client/src/app/[locale]/(private-routes)/(customer)/page.tsx +++ b/client/src/app/[locale]/(private-routes)/(customer)/page.tsx @@ -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> - ); -}; diff --git a/client/src/app/[locale]/(private-routes)/(customer)/patients/[id]/record/page.tsx b/client/src/app/[locale]/(private-routes)/(customer)/patients/[id]/record/page.tsx index bfdb386..83b5bfb 100644 --- a/client/src/app/[locale]/(private-routes)/(customer)/patients/[id]/record/page.tsx +++ b/client/src/app/[locale]/(private-routes)/(customer)/patients/[id]/record/page.tsx @@ -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> ); diff --git a/client/src/app/[locale]/(private-routes)/(customer)/patients/page.tsx b/client/src/app/[locale]/(private-routes)/(customer)/patients/page.tsx index 665615d..b420bcb 100644 --- a/client/src/app/[locale]/(private-routes)/(customer)/patients/page.tsx +++ b/client/src/app/[locale]/(private-routes)/(customer)/patients/page.tsx @@ -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) => { diff --git a/client/src/app/[locale]/(private-routes)/(customer)/profile/page.tsx b/client/src/app/[locale]/(private-routes)/(customer)/profile/page.tsx index 4033546..76adf01 100644 --- a/client/src/app/[locale]/(private-routes)/(customer)/profile/page.tsx +++ b/client/src/app/[locale]/(private-routes)/(customer)/profile/page.tsx @@ -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 ( diff --git a/client/src/app/[locale]/(private-routes)/(customer)/search/SearchScreen.tsx b/client/src/app/[locale]/(private-routes)/(customer)/search/SearchScreen.tsx new file mode 100644 index 0000000..8448230 --- /dev/null +++ b/client/src/app/[locale]/(private-routes)/(customer)/search/SearchScreen.tsx @@ -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> + ); +}; diff --git a/client/src/app/[locale]/(private-routes)/(customer)/search/nurse/[nurseId]/page.tsx b/client/src/app/[locale]/(private-routes)/(customer)/search/nurse/[nurseId]/page.tsx index c91da08..741c37e 100644 --- a/client/src/app/[locale]/(private-routes)/(customer)/search/nurse/[nurseId]/page.tsx +++ b/client/src/app/[locale]/(private-routes)/(customer)/search/nurse/[nurseId]/page.tsx @@ -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> diff --git a/client/src/app/[locale]/(private-routes)/(customer)/search/page.tsx b/client/src/app/[locale]/(private-routes)/(customer)/search/page.tsx index db54852..4b011bc 100644 --- a/client/src/app/[locale]/(private-routes)/(customer)/search/page.tsx +++ b/client/src/app/[locale]/(private-routes)/(customer)/search/page.tsx @@ -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> - ); -}; diff --git a/client/src/app/[locale]/(private-routes)/(customer)/search/results/page.tsx b/client/src/app/[locale]/(private-routes)/(customer)/search/results/page.tsx index e8ddb91..cc6fae6 100644 --- a/client/src/app/[locale]/(private-routes)/(customer)/search/results/page.tsx +++ b/client/src/app/[locale]/(private-routes)/(customer)/search/results/page.tsx @@ -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> + } + /> ); } diff --git a/client/src/app/[locale]/(private-routes)/(customer)/wallet/WalletInstallments.tsx b/client/src/app/[locale]/(private-routes)/(customer)/wallet/WalletInstallments.tsx index ff40795..12e35d8 100644 --- a/client/src/app/[locale]/(private-routes)/(customer)/wallet/WalletInstallments.tsx +++ b/client/src/app/[locale]/(private-routes)/(customer)/wallet/WalletInstallments.tsx @@ -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 ? ( diff --git a/client/src/app/[locale]/(private-routes)/_chrome/SidebarShellSkeleton.tsx b/client/src/app/[locale]/(private-routes)/_chrome/SidebarShellSkeleton.tsx new file mode 100644 index 0000000..b9347b0 --- /dev/null +++ b/client/src/app/[locale]/(private-routes)/_chrome/SidebarShellSkeleton.tsx @@ -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> + ); +} diff --git a/client/src/app/[locale]/(private-routes)/admin/AdminOverviewScreen.tsx b/client/src/app/[locale]/(private-routes)/admin/AdminOverviewScreen.tsx new file mode 100644 index 0000000..a707063 --- /dev/null +++ b/client/src/app/[locale]/(private-routes)/admin/AdminOverviewScreen.tsx @@ -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> + ); +} diff --git a/client/src/app/[locale]/(private-routes)/admin/loading.tsx b/client/src/app/[locale]/(private-routes)/admin/loading.tsx new file mode 100644 index 0000000..569f058 --- /dev/null +++ b/client/src/app/[locale]/(private-routes)/admin/loading.tsx @@ -0,0 +1,5 @@ +import SidebarShellSkeleton from '../_chrome/SidebarShellSkeleton'; + +export default function Loading() { + return <SidebarShellSkeleton />; +} diff --git a/client/src/app/[locale]/(private-routes)/admin/page.tsx b/client/src/app/[locale]/(private-routes)/admin/page.tsx index a20ceaf..da597b7 100644 --- a/client/src/app/[locale]/(private-routes)/admin/page.tsx +++ b/client/src/app/[locale]/(private-routes)/admin/page.tsx @@ -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 />; } diff --git a/client/src/app/[locale]/(private-routes)/admin/payouts/page.tsx b/client/src/app/[locale]/(private-routes)/admin/payouts/page.tsx index 3ce81dd..b40812e 100644 --- a/client/src/app/[locale]/(private-routes)/admin/payouts/page.tsx +++ b/client/src/app/[locale]/(private-routes)/admin/payouts/page.tsx @@ -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> ))} diff --git a/client/src/app/[locale]/(private-routes)/nurse/bank/page.tsx b/client/src/app/[locale]/(private-routes)/nurse/bank/page.tsx index fd88849..fb1a7a2 100644 --- a/client/src/app/[locale]/(private-routes)/nurse/bank/page.tsx +++ b/client/src/app/[locale]/(private-routes)/nurse/bank/page.tsx @@ -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 ? ( diff --git a/client/src/app/[locale]/(private-routes)/nurse/earnings/page.tsx b/client/src/app/[locale]/(private-routes)/nurse/earnings/page.tsx index 2c022cb..bf132e8 100644 --- a/client/src/app/[locale]/(private-routes)/nurse/earnings/page.tsx +++ b/client/src/app/[locale]/(private-routes)/nurse/earnings/page.tsx @@ -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' }}> diff --git a/client/src/app/[locale]/(private-routes)/nurse/earnings/payouts/[id]/page.tsx b/client/src/app/[locale]/(private-routes)/nurse/earnings/payouts/[id]/page.tsx index f8da36e..6c75408 100644 --- a/client/src/app/[locale]/(private-routes)/nurse/earnings/payouts/[id]/page.tsx +++ b/client/src/app/[locale]/(private-routes)/nurse/earnings/payouts/[id]/page.tsx @@ -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" diff --git a/client/src/app/[locale]/(private-routes)/nurse/earnings/payouts/page.tsx b/client/src/app/[locale]/(private-routes)/nurse/earnings/payouts/page.tsx index 13ae148..f92ce70 100644 --- a/client/src/app/[locale]/(private-routes)/nurse/earnings/payouts/page.tsx +++ b/client/src/app/[locale]/(private-routes)/nurse/earnings/payouts/page.tsx @@ -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) => ( diff --git a/client/src/app/[locale]/(private-routes)/nurse/loading.tsx b/client/src/app/[locale]/(private-routes)/nurse/loading.tsx new file mode 100644 index 0000000..569f058 --- /dev/null +++ b/client/src/app/[locale]/(private-routes)/nurse/loading.tsx @@ -0,0 +1,5 @@ +import SidebarShellSkeleton from '../_chrome/SidebarShellSkeleton'; + +export default function Loading() { + return <SidebarShellSkeleton />; +} diff --git a/client/src/app/[locale]/(private-routes)/nurse/page.tsx b/client/src/app/[locale]/(private-routes)/nurse/page.tsx index 5460f1f..37e503b 100644 --- a/client/src/app/[locale]/(private-routes)/nurse/page.tsx +++ b/client/src/app/[locale]/(private-routes)/nurse/page.tsx @@ -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'); diff --git a/client/src/app/[locale]/(private-routes)/nurse/profile/page.tsx b/client/src/app/[locale]/(private-routes)/nurse/profile/page.tsx index b41bd6f..2f887f4 100644 --- a/client/src/app/[locale]/(private-routes)/nurse/profile/page.tsx +++ b/client/src/app/[locale]/(private-routes)/nurse/profile/page.tsx @@ -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' }), + }, ); }; diff --git a/client/src/app/[locale]/(private-routes)/nurse/requests/[id]/page.tsx b/client/src/app/[locale]/(private-routes)/nurse/requests/[id]/page.tsx index 66cfe98..0e38679 100644 --- a/client/src/app/[locale]/(private-routes)/nurse/requests/[id]/page.tsx +++ b/client/src/app/[locale]/(private-routes)/nurse/requests/[id]/page.tsx @@ -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 ( diff --git a/client/src/app/[locale]/(private-routes)/nurse/requests/page.tsx b/client/src/app/[locale]/(private-routes)/nurse/requests/page.tsx index e6ad240..a3761af 100644 --- a/client/src/app/[locale]/(private-routes)/nurse/requests/page.tsx +++ b/client/src/app/[locale]/(private-routes)/nurse/requests/page.tsx @@ -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 ( diff --git a/client/src/app/[locale]/(private-routes)/nurse/services/MyServicesList.tsx b/client/src/app/[locale]/(private-routes)/nurse/services/MyServicesList.tsx index dc4205e..b82ba1e 100644 --- a/client/src/app/[locale]/(private-routes)/nurse/services/MyServicesList.tsx +++ b/client/src/app/[locale]/(private-routes)/nurse/services/MyServicesList.tsx @@ -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) => ( diff --git a/client/src/app/[locale]/(private-routes)/nurse/services/VariantBuilder.tsx b/client/src/app/[locale]/(private-routes)/nurse/services/VariantBuilder.tsx index f2d5879..944e455 100644 --- a/client/src/app/[locale]/(private-routes)/nurse/services/VariantBuilder.tsx +++ b/client/src/app/[locale]/(private-routes)/nurse/services/VariantBuilder.tsx @@ -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> ) : ( diff --git a/client/src/app/[locale]/(private-routes)/nurse/verification/VerificationChecklist.tsx b/client/src/app/[locale]/(private-routes)/nurse/verification/VerificationChecklist.tsx index 8306cb6..62e8a2e 100644 --- a/client/src/app/[locale]/(private-routes)/nurse/verification/VerificationChecklist.tsx +++ b/client/src/app/[locale]/(private-routes)/nurse/verification/VerificationChecklist.tsx @@ -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 }}> diff --git a/client/src/app/[locale]/(private-routes)/nurse/verification/page.tsx b/client/src/app/[locale]/(private-routes)/nurse/verification/page.tsx index f6bfaf4..c947c2c 100644 --- a/client/src/app/[locale]/(private-routes)/nurse/verification/page.tsx +++ b/client/src/app/[locale]/(private-routes)/nurse/verification/page.tsx @@ -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> + } + /> ); } diff --git a/client/src/app/[locale]/(private-routes)/nurse/visits/page.tsx b/client/src/app/[locale]/(private-routes)/nurse/visits/page.tsx index b6ecc9d..aa17a4a 100644 --- a/client/src/app/[locale]/(private-routes)/nurse/visits/page.tsx +++ b/client/src/app/[locale]/(private-routes)/nurse/visits/page.tsx @@ -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) => ( diff --git a/client/src/app/[locale]/(private-routes)/partner/PartnerHomeScreen.tsx b/client/src/app/[locale]/(private-routes)/partner/PartnerHomeScreen.tsx new file mode 100644 index 0000000..87ff4a3 --- /dev/null +++ b/client/src/app/[locale]/(private-routes)/partner/PartnerHomeScreen.tsx @@ -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> + ); +} diff --git a/client/src/app/[locale]/(private-routes)/partner/loading.tsx b/client/src/app/[locale]/(private-routes)/partner/loading.tsx new file mode 100644 index 0000000..569f058 --- /dev/null +++ b/client/src/app/[locale]/(private-routes)/partner/loading.tsx @@ -0,0 +1,5 @@ +import SidebarShellSkeleton from '../_chrome/SidebarShellSkeleton'; + +export default function Loading() { + return <SidebarShellSkeleton />; +} diff --git a/client/src/app/[locale]/(private-routes)/partner/page.tsx b/client/src/app/[locale]/(private-routes)/partner/page.tsx index 3a22272..809c581 100644 --- a/client/src/app/[locale]/(private-routes)/partner/page.tsx +++ b/client/src/app/[locale]/(private-routes)/partner/page.tsx @@ -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 />; } diff --git a/client/src/app/[locale]/(public-routes)/loading.tsx b/client/src/app/[locale]/(public-routes)/loading.tsx new file mode 100644 index 0000000..e25a703 --- /dev/null +++ b/client/src/app/[locale]/(public-routes)/loading.tsx @@ -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> + ); +} diff --git a/client/src/app/[locale]/(public-routes)/login/LoginScreen.tsx b/client/src/app/[locale]/(public-routes)/login/LoginScreen.tsx new file mode 100644 index 0000000..06f8e44 --- /dev/null +++ b/client/src/app/[locale]/(public-routes)/login/LoginScreen.tsx @@ -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> + ); +} diff --git a/client/src/app/[locale]/(public-routes)/login/page.tsx b/client/src/app/[locale]/(public-routes)/login/page.tsx index 84df22f..6f116e5 100644 --- a/client/src/app/[locale]/(public-routes)/login/page.tsx +++ b/client/src/app/[locale]/(public-routes)/login/page.tsx @@ -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 />; } diff --git a/client/src/app/[locale]/[...rest]/page.tsx b/client/src/app/[locale]/[...rest]/page.tsx new file mode 100644 index 0000000..6791f1e --- /dev/null +++ b/client/src/app/[locale]/[...rest]/page.tsx @@ -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(); +} diff --git a/client/src/app/[locale]/error.tsx b/client/src/app/[locale]/error.tsx new file mode 100644 index 0000000..9b79a8a --- /dev/null +++ b/client/src/app/[locale]/error.tsx @@ -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; diff --git a/client/src/app/[locale]/layout.tsx b/client/src/app/[locale]/layout.tsx index f7468b1..78553c4 100644 --- a/client/src/app/[locale]/layout.tsx +++ b/client/src/app/[locale]/layout.tsx @@ -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, diff --git a/client/src/app/[locale]/not-found.tsx b/client/src/app/[locale]/not-found.tsx new file mode 100644 index 0000000..0cbe135 --- /dev/null +++ b/client/src/app/[locale]/not-found.tsx @@ -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> + ); +} diff --git a/client/src/app/global-error.tsx b/client/src/app/global-error.tsx new file mode 100644 index 0000000..0e2933d --- /dev/null +++ b/client/src/app/global-error.tsx @@ -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> + ); +} diff --git a/client/src/components/BankStatusPanel/BankStatusPanel.tsx b/client/src/components/BankStatusPanel/BankStatusPanel.tsx index ce74db5..2595502 100644 --- a/client/src/components/BankStatusPanel/BankStatusPanel.tsx +++ b/client/src/components/BankStatusPanel/BankStatusPanel.tsx @@ -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; diff --git a/client/src/components/BnplPlanCard/BnplPlanCard.tsx b/client/src/components/BnplPlanCard/BnplPlanCard.tsx index d8e9632..b55af35 100644 --- a/client/src/components/BnplPlanCard/BnplPlanCard.tsx +++ b/client/src/components/BnplPlanCard/BnplPlanCard.tsx @@ -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> diff --git a/client/src/components/BookingRequestSummaryCard/BookingRequestSummaryCard.tsx b/client/src/components/BookingRequestSummaryCard/BookingRequestSummaryCard.tsx index 3b2dc38..6570169 100644 --- a/client/src/components/BookingRequestSummaryCard/BookingRequestSummaryCard.tsx +++ b/client/src/components/BookingRequestSummaryCard/BookingRequestSummaryCard.tsx @@ -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> ); }; diff --git a/client/src/components/CancellationPolicyDisclosure/CancellationPolicyDisclosure.tsx b/client/src/components/CancellationPolicyDisclosure/CancellationPolicyDisclosure.tsx index 86978b0..67893df 100644 --- a/client/src/components/CancellationPolicyDisclosure/CancellationPolicyDisclosure.tsx +++ b/client/src/components/CancellationPolicyDisclosure/CancellationPolicyDisclosure.tsx @@ -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> diff --git a/client/src/components/CountdownTimer/CountdownTimer.test.tsx b/client/src/components/CountdownTimer/CountdownTimer.test.tsx index 8727b6f..8558e9a 100644 --- a/client/src/components/CountdownTimer/CountdownTimer.test.tsx +++ b/client/src/components/CountdownTimer/CountdownTimer.test.tsx @@ -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(); + }); }); diff --git a/client/src/components/CountdownTimer/CountdownTimer.tsx b/client/src/components/CountdownTimer/CountdownTimer.tsx index 40a5336..95cec92 100644 --- a/client/src/components/CountdownTimer/CountdownTimer.tsx +++ b/client/src/components/CountdownTimer/CountdownTimer.tsx @@ -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> ); }; diff --git a/client/src/components/DocumentUpload/DocumentUpload.tsx b/client/src/components/DocumentUpload/DocumentUpload.tsx index 8388bbe..b36deab 100644 --- a/client/src/components/DocumentUpload/DocumentUpload.tsx +++ b/client/src/components/DocumentUpload/DocumentUpload.tsx @@ -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, diff --git a/client/src/components/EarningsBalanceHeader/EarningsBalanceHeader.test.tsx b/client/src/components/EarningsBalanceHeader/EarningsBalanceHeader.test.tsx index 44857ba..b952604 100644 --- a/client/src/components/EarningsBalanceHeader/EarningsBalanceHeader.test.tsx +++ b/client/src/components/EarningsBalanceHeader/EarningsBalanceHeader.test.tsx @@ -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 }); }); diff --git a/client/src/components/EarningsBalanceHeader/EarningsBalanceHeader.tsx b/client/src/components/EarningsBalanceHeader/EarningsBalanceHeader.tsx index e8355b9..04650c3 100644 --- a/client/src/components/EarningsBalanceHeader/EarningsBalanceHeader.tsx +++ b/client/src/components/EarningsBalanceHeader/EarningsBalanceHeader.tsx @@ -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> diff --git a/client/src/components/EarningsRow/EarningsRow.tsx b/client/src/components/EarningsRow/EarningsRow.tsx index 1300936..635f037 100644 --- a/client/src/components/EarningsRow/EarningsRow.tsx +++ b/client/src/components/EarningsRow/EarningsRow.tsx @@ -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 }); diff --git a/client/src/components/InstallmentScheduleRow/InstallmentScheduleRow.tsx b/client/src/components/InstallmentScheduleRow/InstallmentScheduleRow.tsx index fda80e0..8effd52 100644 --- a/client/src/components/InstallmentScheduleRow/InstallmentScheduleRow.tsx +++ b/client/src/components/InstallmentScheduleRow/InstallmentScheduleRow.tsx @@ -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> ); }; diff --git a/client/src/components/NurseResultCard/NurseResultCard.test.tsx b/client/src/components/NurseResultCard/NurseResultCard.test.tsx index ebae434..eb0305e 100644 --- a/client/src/components/NurseResultCard/NurseResultCard.test.tsx +++ b/client/src/components/NurseResultCard/NurseResultCard.test.tsx @@ -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); + }); }); diff --git a/client/src/components/NurseResultCard/NurseResultCard.tsx b/client/src/components/NurseResultCard/NurseResultCard.tsx index ab79e8d..106802c 100644 --- a/client/src/components/NurseResultCard/NurseResultCard.tsx +++ b/client/src/components/NurseResultCard/NurseResultCard.tsx @@ -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 }); diff --git a/client/src/components/PatientCard/PatientCard.tsx b/client/src/components/PatientCard/PatientCard.tsx index 298c8f5..48133e7 100644 --- a/client/src/components/PatientCard/PatientCard.tsx +++ b/client/src/components/PatientCard/PatientCard.tsx @@ -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 }); diff --git a/client/src/components/PayoutHistoryRow/PayoutHistoryRow.test.tsx b/client/src/components/PayoutHistoryRow/PayoutHistoryRow.test.tsx index eaef34a..7ba3759 100644 --- a/client/src/components/PayoutHistoryRow/PayoutHistoryRow.test.tsx +++ b/client/src/components/PayoutHistoryRow/PayoutHistoryRow.test.tsx @@ -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(); }); diff --git a/client/src/components/PayoutHistoryRow/PayoutHistoryRow.tsx b/client/src/components/PayoutHistoryRow/PayoutHistoryRow.tsx index 0d7ce47..b8811dc 100644 --- a/client/src/components/PayoutHistoryRow/PayoutHistoryRow.tsx +++ b/client/src/components/PayoutHistoryRow/PayoutHistoryRow.tsx @@ -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 }); diff --git a/client/src/components/PriceBreakdown/PriceBreakdown.tsx b/client/src/components/PriceBreakdown/PriceBreakdown.tsx index 0c01ce8..5ba5354 100644 --- a/client/src/components/PriceBreakdown/PriceBreakdown.tsx +++ b/client/src/components/PriceBreakdown/PriceBreakdown.tsx @@ -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> ); }; diff --git a/client/src/components/PriceDisplay/PriceDisplay.tsx b/client/src/components/PriceDisplay/PriceDisplay.tsx index bae3074..c8d2fc1 100644 --- a/client/src/components/PriceDisplay/PriceDisplay.tsx +++ b/client/src/components/PriceDisplay/PriceDisplay.tsx @@ -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' }}> diff --git a/client/src/components/RatingInput/RatingInput.test.tsx b/client/src/components/RatingInput/RatingInput.test.tsx index 2223322..933e5ed 100644 --- a/client/src/components/RatingInput/RatingInput.test.tsx +++ b/client/src/components/RatingInput/RatingInput.test.tsx @@ -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]); + }); }); diff --git a/client/src/components/RatingInput/RatingInput.tsx b/client/src/components/RatingInput/RatingInput.tsx index 226e27d..1dc1a3e 100644 --- a/client/src/components/RatingInput/RatingInput.tsx +++ b/client/src/components/RatingInput/RatingInput.tsx @@ -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> diff --git a/client/src/components/RefundStatusCard/RefundStatusCard.tsx b/client/src/components/RefundStatusCard/RefundStatusCard.tsx index 072dd7b..af3646a 100644 --- a/client/src/components/RefundStatusCard/RefundStatusCard.tsx +++ b/client/src/components/RefundStatusCard/RefundStatusCard.tsx @@ -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 && ( <> diff --git a/client/src/components/StatusChip/StatusChip.tsx b/client/src/components/StatusChip/StatusChip.tsx index 850b328..e1b01ec 100644 --- a/client/src/components/StatusChip/StatusChip.tsx +++ b/client/src/components/StatusChip/StatusChip.tsx @@ -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 }) => { diff --git a/client/src/components/VariantCard/VariantCard.tsx b/client/src/components/VariantCard/VariantCard.tsx index 40bb610..e1e2ddc 100644 --- a/client/src/components/VariantCard/VariantCard.tsx +++ b/client/src/components/VariantCard/VariantCard.tsx @@ -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> ); }; diff --git a/client/src/components/VisitNoteCard/VisitNoteCard.tsx b/client/src/components/VisitNoteCard/VisitNoteCard.tsx index 3430265..d35d7c1 100644 --- a/client/src/components/VisitNoteCard/VisitNoteCard.tsx +++ b/client/src/components/VisitNoteCard/VisitNoteCard.tsx @@ -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 }); diff --git a/client/src/components/admin/AdminEmptyState.tsx b/client/src/components/admin/AdminEmptyState.tsx index 52f7653..b44b809 100644 --- a/client/src/components/admin/AdminEmptyState.tsx +++ b/client/src/components/admin/AdminEmptyState.tsx @@ -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; diff --git a/client/src/components/admin/AdminErrorState.test.tsx b/client/src/components/admin/AdminErrorState.test.tsx index 8a58ac8..cf698d9 100644 --- a/client/src/components/admin/AdminErrorState.test.tsx +++ b/client/src/components/admin/AdminErrorState.test.tsx @@ -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/>', () => { diff --git a/client/src/components/admin/AdminErrorState.tsx b/client/src/components/admin/AdminErrorState.tsx index 9c24ed5..aaec35d 100644 --- a/client/src/components/admin/AdminErrorState.tsx +++ b/client/src/components/admin/AdminErrorState.tsx @@ -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; diff --git a/client/src/components/admin/AdminPageHeader.tsx b/client/src/components/admin/AdminPageHeader.tsx index 76b0279..26fd09f 100644 --- a/client/src/components/admin/AdminPageHeader.tsx +++ b/client/src/components/admin/AdminPageHeader.tsx @@ -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; diff --git a/client/src/components/admin/ConfirmDialog.tsx b/client/src/components/admin/ConfirmDialog.tsx index 4b157e9..4a85795 100644 --- a/client/src/components/admin/ConfirmDialog.tsx +++ b/client/src/components/admin/ConfirmDialog.tsx @@ -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; diff --git a/client/src/components/admin/PartnerSettlementRow.tsx b/client/src/components/admin/PartnerSettlementRow.tsx index 24a03bd..4570f60 100644 --- a/client/src/components/admin/PartnerSettlementRow.tsx +++ b/client/src/components/admin/PartnerSettlementRow.tsx @@ -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> diff --git a/client/src/components/admin/RefundPanel.tsx b/client/src/components/admin/RefundPanel.tsx index c19e25a..26518cc 100644 --- a/client/src/components/admin/RefundPanel.tsx +++ b/client/src/components/admin/RefundPanel.tsx @@ -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 }}> diff --git a/client/src/components/auth/RoleGuard.tsx b/client/src/components/auth/RoleGuard.tsx index a9d4366..6f706e0 100644 --- a/client/src/components/auth/RoleGuard.tsx +++ b/client/src/components/auth/RoleGuard.tsx @@ -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(); diff --git a/client/src/components/booking/BookingDetailView/BookingDetailView.tsx b/client/src/components/booking/BookingDetailView/BookingDetailView.tsx index ca8e557..b11b57f 100644 --- a/client/src/components/booking/BookingDetailView/BookingDetailView.tsx +++ b/client/src/components/booking/BookingDetailView/BookingDetailView.tsx @@ -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> ); } diff --git a/client/src/components/booking/BookingMoneySummary/BookingMoneySummary.tsx b/client/src/components/booking/BookingMoneySummary/BookingMoneySummary.tsx index 27dff07..fc0275e 100644 --- a/client/src/components/booking/BookingMoneySummary/BookingMoneySummary.tsx +++ b/client/src/components/booking/BookingMoneySummary/BookingMoneySummary.tsx @@ -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> ); } diff --git a/client/src/components/booking/BookingStatusTimeline/BookingStatusTimeline.tsx b/client/src/components/booking/BookingStatusTimeline/BookingStatusTimeline.tsx index 7d24096..26ef4ed 100644 --- a/client/src/components/booking/BookingStatusTimeline/BookingStatusTimeline.tsx +++ b/client/src/components/booking/BookingStatusTimeline/BookingStatusTimeline.tsx @@ -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> ); diff --git a/client/src/components/booking/SessionCard/SessionCard.tsx b/client/src/components/booking/SessionCard/SessionCard.tsx index e71a8bd..401d0c6 100644 --- a/client/src/components/booking/SessionCard/SessionCard.tsx +++ b/client/src/components/booking/SessionCard/SessionCard.tsx @@ -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} diff --git a/client/src/components/booking/format.ts b/client/src/components/booking/format.ts index c773720..9f87ee2 100644 --- a/client/src/components/booking/format.ts +++ b/client/src/components/booking/format.ts @@ -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)}`; + })}`; } diff --git a/client/src/components/common/AccentCard/AccentCard.test.tsx b/client/src/components/common/AccentCard/AccentCard.test.tsx new file mode 100644 index 0000000..afb087e --- /dev/null +++ b/client/src/components/common/AccentCard/AccentCard.test.tsx @@ -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'); + }); +}); diff --git a/client/src/components/common/AccentCard/AccentCard.tsx b/client/src/components/common/AccentCard/AccentCard.tsx new file mode 100644 index 0000000..e896d18 --- /dev/null +++ b/client/src/components/common/AccentCard/AccentCard.tsx @@ -0,0 +1,41 @@ +import { FunctionComponent } from 'react'; +import SurfaceCard, { SurfaceCardProps } from '../SurfaceCard'; + +export type AccentTone = 'primary' | 'secondary' | 'success' | 'error' | 'warning' | 'info' | 'trust' | 'neutral'; + +const ACCENT_WIDTH = '4px'; + +const TONE_VAR: Record<AccentTone, string> = { + primary: 'var(--bal-primary)', + secondary: 'var(--bal-secondary)', + success: 'var(--bal-success)', + error: 'var(--bal-error)', + warning: 'var(--bal-warning)', + info: 'var(--bal-info)', + trust: 'var(--bal-trust)', + neutral: 'var(--bal-text-secondary)', +}; + +export interface AccentCardProps extends SurfaceCardProps { + /** Semantic tone driving the `borderInlineStart` accent stripe — never a raw color. */ + tone: AccentTone; +} + +/** + * `SurfaceCard` plus a `borderInlineStart` accent stripe at one standardized width (4px — this app + * previously drifted between 3px and 4px across `EarningsBalanceHeader`/`PayoutHistoryRow` vs + * `BankStatusPanel`/`DocumentUpload`). Logical property, so the stripe sits on the correct edge under + * RTL automatically. Used for stateful panels (bank ownership, upload state, a nurse's earnings header). + * @component AccentCard + */ +const AccentCard: FunctionComponent<AccentCardProps> = ({ tone, sx, children, ...rest }) => ( + <SurfaceCard + sx={{ borderInlineStart: `${ACCENT_WIDTH} solid ${TONE_VAR[tone]}`, ...sx }} + data-accent-tone={tone} + {...rest} + > + {children} + </SurfaceCard> +); + +export default AccentCard; diff --git a/client/src/components/common/AccentCard/index.tsx b/client/src/components/common/AccentCard/index.tsx new file mode 100644 index 0000000..07d9ebb --- /dev/null +++ b/client/src/components/common/AccentCard/index.tsx @@ -0,0 +1,4 @@ +import AccentCard from './AccentCard'; + +export default AccentCard; +export type { AccentCardProps, AccentTone } from './AccentCard'; diff --git a/client/src/components/common/AppIcon/config.ts b/client/src/components/common/AppIcon/config.ts index e3d3e4a..8f885a4 100644 --- a/client/src/components/common/AppIcon/config.ts +++ b/client/src/components/common/AppIcon/config.ts @@ -93,8 +93,9 @@ import DownloadIcon from '@mui/icons-material/FileDownloadRounded'; import ExpandIcon from '@mui/icons-material/ExpandMoreRounded'; import ExternalIcon from '@mui/icons-material/OpenInNewRounded'; import AssignIcon from '@mui/icons-material/AssignmentIndRounded'; -// Cross-cutting affordances: back navigation, share/copy, attachments +// Cross-cutting affordances: back/forward navigation, share/copy, attachments import BackIcon from '@mui/icons-material/ArrowBackRounded'; +import ForwardIcon from '@mui/icons-material/ArrowForwardRounded'; import ShareIcon from '@mui/icons-material/ShareRounded'; import CopyIcon from '@mui/icons-material/ContentCopyRounded'; import AttachmentIcon from '@mui/icons-material/AttachFileRounded'; @@ -197,6 +198,7 @@ export const ICONS /* Note: Setting type disables property autocomplete :( was - assign: AssignIcon, back: BackIcon, chevron_start: BackIcon, + forward: ForwardIcon, share: ShareIcon, copy: CopyIcon, attachment: AttachmentIcon, @@ -207,4 +209,4 @@ export const ICONS /* Note: Setting type disables property autocomplete :( was - * chevrons pointing "start"). AppIcon applies the flip via a `data-icon-directional` * attribute + the single CSS rule in globals.css — add a name here, nothing else. */ -export const DIRECTIONAL_ICONS = new Set<IconName>(['back', 'chevron_start']); +export const DIRECTIONAL_ICONS = new Set<IconName>(['back', 'chevron_start', 'forward']); diff --git a/client/src/components/common/AppLoading/AppLoading.test.tsx b/client/src/components/common/AppLoading/AppLoading.test.tsx new file mode 100644 index 0000000..020f3e9 --- /dev/null +++ b/client/src/components/common/AppLoading/AppLoading.test.tsx @@ -0,0 +1,24 @@ +import { render } from '@testing-library/react'; +import { ThemeProvider } from '../../../theme'; +import AppLoading from './AppLoading'; + +describe('<AppLoading/>', () => { + it('renders a status region with the brand mark', () => { + const { container } = render( + <ThemeProvider> + <AppLoading /> + </ThemeProvider>, + ); + expect(container.querySelector('[role="status"]')).toBeInTheDocument(); + expect(container.querySelector('[data-icon="logo"]')).toBeInTheDocument(); + }); + + it('forwards sx overrides to the root', () => { + const { container } = render( + <ThemeProvider> + <AppLoading data-testid="loading-root" /> + </ThemeProvider>, + ); + expect(container.querySelector('[data-testid="loading-root"]')).toBeInTheDocument(); + }); +}); diff --git a/client/src/components/common/AppLoading/AppLoading.tsx b/client/src/components/common/AppLoading/AppLoading.tsx index d0d38c7..93a5990 100644 --- a/client/src/components/common/AppLoading/AppLoading.tsx +++ b/client/src/components/common/AppLoading/AppLoading.tsx @@ -1,36 +1,36 @@ +'use client' import { FunctionComponent } from 'react'; -import { CircularProgress, CircularProgressProps, LinearProgress, Stack, StackProps } from '@mui/material'; -import { APP_LOADING_COLOR, APP_LOADING_SIZE, APP_LOADING_TYPE } from '@/components/config'; +import { keyframes } from '@emotion/react'; +import Stack, { StackProps } from '@mui/material/Stack'; +import AppIcon from '../AppIcon'; -interface Props extends StackProps { - color?: CircularProgressProps['color']; - size?: number | string; - type?: 'circular' | 'linear'; - value?: number; -} +export type AppLoadingProps = StackProps; + +const breathe = keyframes` + 0%, 100% { opacity: 0.55; transform: scale(0.92); } + 50% { opacity: 1; transform: scale(1); } +`; /** - * Renders MI circular progress centered inside Stack + * The branded full-page loading splash — the Balinyaar mark with a subtle breathing motion, respecting + * `prefers-reduced-motion` (the animation only applies under the `no-preference` media query; a + * reduced-motion user sees a static, still-legible mark). Reserved for **true full-page waits** (the auth + * splash, a payment-gateway return, a route-level `Suspense` fallback) — inline/list loading should reach + * for a shaped skeleton (a co-located `<X.Skeleton />` twin) instead, never a bare spinner. * @component AppLoading - * @prop {string} [size] - size of the progress component. Numbers means pixels, string can be '2.5rem' */ -const AppLoading: FunctionComponent<Props> = ({ - color = APP_LOADING_COLOR, - size = APP_LOADING_SIZE, - type = APP_LOADING_TYPE, - value, - ...restOfProps -}) => { - const alignItems = type === 'linear' ? undefined : 'center'; - return ( - <Stack sx={{ my: 2, alignItems }} {...restOfProps}> - {type === 'linear' ? ( - <LinearProgress color={color} value={value} /> - ) : ( - <CircularProgress color={color} size={size} value={value} /> - )} +const AppLoading: FunctionComponent<AppLoadingProps> = ({ sx, ...rest }) => ( + <Stack role="status" sx={{ my: 4, alignItems: 'center', justifyContent: 'center', ...sx }} {...rest}> + <Stack + sx={{ + '@media (prefers-reduced-motion: no-preference)': { + animation: `${breathe} 1.6s ease-in-out infinite`, + }, + }} + > + <AppIcon icon="logo" size={48} color="var(--bal-primary)" /> </Stack> - ); -}; + </Stack> +); export default AppLoading; diff --git a/client/src/components/common/ConfirmDialog/ConfirmDialog.test.tsx b/client/src/components/common/ConfirmDialog/ConfirmDialog.test.tsx new file mode 100644 index 0000000..4149497 --- /dev/null +++ b/client/src/components/common/ConfirmDialog/ConfirmDialog.test.tsx @@ -0,0 +1,44 @@ +import { render, screen, fireEvent } from '@testing-library/react'; +import { ThemeProvider } from '../../../theme'; +import ConfirmDialog from './ConfirmDialog'; + +describe('<ConfirmDialog/>', () => { + const wrap = (ui: React.ReactNode) => render(<ThemeProvider>{ui}</ThemeProvider>); + const base = { + title: 'Run batch?', + confirmLabel: 'Run', + cancelLabel: 'Cancel', + onClose: jest.fn(), + }; + + it('renders the title and body when open', () => { + wrap(<ConfirmDialog open body="This moves money" onConfirm={jest.fn()} {...base} />); + expect(screen.getByText('Run batch?')).toBeInTheDocument(); + expect(screen.getByText('This moves money')).toBeInTheDocument(); + }); + + it('calls onConfirm with no reason for a plain confirm', () => { + const onConfirm = jest.fn(); + wrap(<ConfirmDialog open onConfirm={onConfirm} {...base} />); + fireEvent.click(screen.getByRole('button', { name: 'Run' })); + expect(onConfirm).toHaveBeenCalledWith(undefined); + }); + + it('disables confirm until a required reason is entered, then passes it', () => { + const onConfirm = jest.fn(); + wrap(<ConfirmDialog open requireReason reasonLabel="Reason" onConfirm={onConfirm} {...base} />); + const confirmBtn = screen.getByRole('button', { name: 'Run' }); + expect(confirmBtn).toBeDisabled(); + fireEvent.change(screen.getByLabelText('Reason'), { target: { value: 'bad docs' } }); + expect(confirmBtn).not.toBeDisabled(); + fireEvent.click(confirmBtn); + expect(onConfirm).toHaveBeenCalledWith('bad docs'); + }); + + it('calls onClose from cancel', () => { + const onClose = jest.fn(); + wrap(<ConfirmDialog open onConfirm={jest.fn()} {...base} onClose={onClose} />); + fireEvent.click(screen.getByRole('button', { name: 'Cancel' })); + expect(onClose).toHaveBeenCalledTimes(1); + }); +}); diff --git a/client/src/components/common/ConfirmDialog/ConfirmDialog.tsx b/client/src/components/common/ConfirmDialog/ConfirmDialog.tsx new file mode 100644 index 0000000..1df1b64 --- /dev/null +++ b/client/src/components/common/ConfirmDialog/ConfirmDialog.tsx @@ -0,0 +1,114 @@ +'use client'; +import { FunctionComponent, ReactNode, useState } from 'react'; +import { + CircularProgress, + Dialog, + DialogActions, + DialogContent, + DialogContentText, + DialogTitle, + TextField, +} from '@mui/material'; +import AppButton from '../AppButton'; + +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'; +} + +/** + * The shared confirmation dialog behind every audited/irreversible action — approve/reject a + * verification, run/retry a payout, save a config, resolve an alert, archive a patient, delete an + * address. 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. Promoted from + * `components/admin` (kept there as a thin alias) — the contract is unchanged. + * @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 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: 700 }}>{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; diff --git a/client/src/components/common/ConfirmDialog/index.tsx b/client/src/components/common/ConfirmDialog/index.tsx new file mode 100644 index 0000000..fdf33be --- /dev/null +++ b/client/src/components/common/ConfirmDialog/index.tsx @@ -0,0 +1,4 @@ +import ConfirmDialog from './ConfirmDialog'; + +export default ConfirmDialog; +export type { ConfirmDialogProps } from './ConfirmDialog'; diff --git a/client/src/components/common/EmptyState/EmptyState.test.tsx b/client/src/components/common/EmptyState/EmptyState.test.tsx new file mode 100644 index 0000000..27eecc2 --- /dev/null +++ b/client/src/components/common/EmptyState/EmptyState.test.tsx @@ -0,0 +1,23 @@ +import { render, screen } from '@testing-library/react'; +import { ThemeProvider } from '../../../theme'; +import EmptyState from './EmptyState'; + +describe('<EmptyState/>', () => { + const wrap = (ui: React.ReactNode) => render(<ThemeProvider>{ui}</ThemeProvider>); + + it('renders the title and body', () => { + wrap(<EmptyState title="Nothing here" body="Add your first patient" />); + expect(screen.getByText('Nothing here')).toBeInTheDocument(); + expect(screen.getByText('Add your first patient')).toBeInTheDocument(); + }); + + it('omits the body when not given', () => { + wrap(<EmptyState title="Empty" />); + expect(screen.getByText('Empty')).toBeInTheDocument(); + }); + + it('renders the action slot when provided', () => { + wrap(<EmptyState title="Empty" action={<button>Add</button>} />); + expect(screen.getByRole('button', { name: 'Add' })).toBeInTheDocument(); + }); +}); diff --git a/client/src/components/common/EmptyState/EmptyState.tsx b/client/src/components/common/EmptyState/EmptyState.tsx new file mode 100644 index 0000000..361791a --- /dev/null +++ b/client/src/components/common/EmptyState/EmptyState.tsx @@ -0,0 +1,68 @@ +import { FunctionComponent, ReactNode } from 'react'; +import Paper from '@mui/material/Paper'; +import Stack from '@mui/material/Stack'; +import Typography from '@mui/material/Typography'; +import AppIcon from '../AppIcon'; + +export interface EmptyStateProps { + /** AppIcon registry name — already-translated copy is the caller's job, the icon is decorative. */ + icon?: string; + /** Already-translated title. */ + title: string; + /** Already-translated body — a string for the common case, or a node for richer copy (e.g. a list of + * suggestions). */ + body?: ReactNode; + /** Optional call-to-action (e.g. an `AppButton`). */ + action?: ReactNode; +} + +/** + * The calm, branded "nothing here" panel — replaces the dashed-border `Paper` blocks hand-rolled across + * the app. A flat card (phase-0 surface/radius tokens) with a soft-primary icon roundel, never alarming. + * Presentational + caller-owned i18n; the illustration set (per-domain artwork) is DEFERRED to phase 12. + * @component EmptyState + */ +const EmptyState: FunctionComponent<EmptyStateProps> = ({ icon = 'info', title, body, action }) => ( + <Paper + elevation={0} + data-empty-state + sx={{ + p: { xs: 3, sm: 5 }, + textAlign: 'center', + border: '1px solid', + borderColor: 'divider', + borderRadius: 'var(--bal-radius-md)', + bgcolor: 'var(--bal-bg-paper)', + }} + > + <Stack sx={{ alignItems: 'center', gap: 1.5 }}> + <Stack + sx={{ + width: 56, + height: 56, + borderRadius: '50%', + alignItems: 'center', + justifyContent: 'center', + bgcolor: 'var(--bal-primary-soft)', + }} + > + <AppIcon icon={icon} size={28} color="var(--bal-primary)" /> + </Stack> + <Typography variant="subtitle1" sx={{ fontWeight: 700 }}> + {title} + </Typography> + {body ? ( + typeof body === 'string' ? ( + <Typography variant="body2" sx={{ color: 'text.secondary', maxWidth: 360 }}> + {body} + </Typography> + ) : ( + <Stack sx={{ color: 'text.secondary', maxWidth: 360 }}>{body}</Stack> + ) + ) : null} + {action ? <Stack sx={{ mt: 0.5 }}>{action}</Stack> : null} + </Stack> + </Paper> +); + +export default EmptyState; diff --git a/client/src/components/common/EmptyState/index.tsx b/client/src/components/common/EmptyState/index.tsx new file mode 100644 index 0000000..eec5154 --- /dev/null +++ b/client/src/components/common/EmptyState/index.tsx @@ -0,0 +1,4 @@ +import EmptyState from './EmptyState'; + +export default EmptyState; +export type { EmptyStateProps } from './EmptyState'; diff --git a/client/src/components/common/ErrorBoundary.test.tsx b/client/src/components/common/ErrorBoundary.test.tsx new file mode 100644 index 0000000..57e7f32 --- /dev/null +++ b/client/src/components/common/ErrorBoundary.test.tsx @@ -0,0 +1,62 @@ +import { render, screen, fireEvent } from '@testing-library/react'; +import { ThemeProvider } from '../../theme'; +import ErrorBoundary from './ErrorBoundary'; + +function Bomb({ shouldThrow }: { shouldThrow: boolean }) { + if (shouldThrow) throw new Error('boom'); + return <div>safe content</div>; +} + +const COPY = { title: 'Something went wrong', body: 'Please try again.', retryLabel: 'Retry' }; + +describe('<ErrorBoundary/>', () => { + const wrap = (ui: React.ReactNode) => render(<ThemeProvider>{ui}</ThemeProvider>); + + it('renders children when nothing throws', () => { + wrap( + <ErrorBoundary name="Test" {...COPY}> + <Bomb shouldThrow={false} /> + </ErrorBoundary>, + ); + expect(screen.getByText('safe content')).toBeInTheDocument(); + }); + + it('renders the branded fallback with a retry affordance when a child throws', () => { + const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); + wrap( + <ErrorBoundary name="Test" {...COPY}> + <Bomb shouldThrow={true} /> + </ErrorBoundary>, + ); + expect(screen.getByText('Something went wrong')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Retry' })).toBeInTheDocument(); + errorSpy.mockRestore(); + }); + + it('re-renders children after retry is clicked (state resets)', () => { + const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); + const { rerender } = render( + <ThemeProvider> + <ErrorBoundary name="Test" {...COPY}> + <Bomb shouldThrow={true} /> + </ErrorBoundary> + </ThemeProvider>, + ); + expect(screen.getByText('Something went wrong')).toBeInTheDocument(); + + // Swap in a child that no longer throws BEFORE retrying — the boundary's `hasError` state + // only clears on retry, and re-rendering while it's still true keeps showing the fallback + // (it doesn't re-invoke the previously-thrown children). Retry re-renders `this.props.children` + // with whatever was most recently passed down, so the non-throwing swap must land first. + rerender( + <ThemeProvider> + <ErrorBoundary name="Test" {...COPY}> + <Bomb shouldThrow={false} /> + </ErrorBoundary> + </ThemeProvider>, + ); + fireEvent.click(screen.getByRole('button', { name: 'Retry' })); + expect(screen.getByText('safe content')).toBeInTheDocument(); + errorSpy.mockRestore(); + }); +}); diff --git a/client/src/components/common/ErrorBoundary.tsx b/client/src/components/common/ErrorBoundary.tsx index f5d907b..8091fee 100644 --- a/client/src/components/common/ErrorBoundary.tsx +++ b/client/src/components/common/ErrorBoundary.tsx @@ -1,9 +1,20 @@ 'use client'; import { Component, ErrorInfo, ReactNode } from 'react'; +import { Stack, Typography } from '@mui/material'; +import AppButton from './AppButton'; +import AppIcon from './AppIcon'; -interface Props { +export interface ErrorBoundaryProps { children: ReactNode; + /** Diagnostic-only segment name (console log label) — never shown to the user. */ name: string; + /** Already-translated fallback copy — kept presentational/caller-owned (like every other shared + * primitive in this kit) so this file never imports next-intl; it sits at the top of the `common` + * barrel, so an eager i18n dependency here would drag `next-intl` into every test that imports + * anything from `@/components`, mocked or not. */ + title: string; + body: string; + retryLabel: string; } interface State { @@ -13,47 +24,63 @@ interface State { } /** - * Error boundary wrapper to save Application parts from falling + * Error boundary wrapping a subtree of the app (a shell's content area). Renders a branded fallback with + * a retry affordance (re-mounts the children by clearing `hasError`); the raw error + component stack are + * **logged, never shown**, in production — only a development build renders the dev-only `<details>` + * block. * @component ErrorBoundary - * @param {string} [props.name] - name of the wrapped segment, "Error Boundary" by default */ -class ErrorBoundary extends Component<Props, State> { - static defaultProps = { - name: 'Error Boundary', - }; - - constructor(props: Props) { +class ErrorBoundary extends Component<ErrorBoundaryProps, State> { + constructor(props: ErrorBoundaryProps) { super(props); this.state = { hasError: false }; } static getDerivedStateFromError(error: Error) { - // The next render will show the Error UI - return { hasError: true }; + return { hasError: true, error }; } componentDidCatch(error: Error, errorInfo: ErrorInfo) { - // Save information to help render Error UI this.setState({ error, errorInfo }); - // TODO: Add log error messages to an error reporting service here + console.error(`[ErrorBoundary:${this.props.name}]`, error, errorInfo.componentStack); } + handleRetry = () => { + this.setState({ hasError: false, error: undefined, errorInfo: undefined }); + }; + render() { if (this.state.hasError) { - // Error UI rendering + const { error, errorInfo } = this.state; return ( - <div> - <h2>{this.props.name} - Something went wrong</h2> - <details style={{ whiteSpace: 'pre-wrap' }}> - {this.state?.error?.toString()} - <br /> - {this.state?.errorInfo?.componentStack} - </details> - </div> + <Stack sx={{ alignItems: 'center', justifyContent: 'center', gap: 1.5, py: 6, px: 2, textAlign: 'center' }}> + <AppIcon icon="warning" size={36} color="var(--bal-warning)" /> + <Typography variant="subtitle1" sx={{ fontWeight: 700 }}> + {this.props.title} + </Typography> + <Typography variant="body2" sx={{ color: 'text.secondary', maxWidth: 360 }}> + {this.props.body} + </Typography> + <AppButton color="primary" variant="outlined" onClick={this.handleRetry}> + {this.props.retryLabel} + </AppButton> + {process.env.NODE_ENV !== 'production' && (error || errorInfo) ? ( + <Typography + component="details" + variant="caption" + sx={{ color: 'text.secondary', maxWidth: 480, textAlign: 'start', whiteSpace: 'pre-wrap' }} + > + <Typography component="summary" variant="caption" sx={{ cursor: 'pointer' }}> + Dev-only error detail + </Typography> + {error?.toString()} + {'\n'} + {errorInfo?.componentStack} + </Typography> + ) : null} + </Stack> ); } - - // Normal UI rendering return this.props.children; } } diff --git a/client/src/components/common/ErrorState/ErrorState.test.tsx b/client/src/components/common/ErrorState/ErrorState.test.tsx new file mode 100644 index 0000000..1d0cfa2 --- /dev/null +++ b/client/src/components/common/ErrorState/ErrorState.test.tsx @@ -0,0 +1,20 @@ +import { render, screen, fireEvent } from '@testing-library/react'; +import { ThemeProvider } from '../../../theme'; +import ErrorState from './ErrorState'; + +describe('<ErrorState/>', () => { + const wrap = (ui: React.ReactNode) => render(<ThemeProvider>{ui}</ThemeProvider>); + + it('renders the message and calls onRetry on click', () => { + const onRetry = jest.fn(); + wrap(<ErrorState message="Couldn't load" retryLabel="Retry" onRetry={onRetry} />); + expect(screen.getByText("Couldn't load")).toBeInTheDocument(); + fireEvent.click(screen.getByRole('button')); + expect(onRetry).toHaveBeenCalledTimes(1); + }); + + it('renders the caller-supplied retry label', () => { + wrap(<ErrorState message="Failed" retryLabel="Try again" onRetry={jest.fn()} />); + expect(screen.getByRole('button', { name: 'Try again' })).toBeInTheDocument(); + }); +}); diff --git a/client/src/components/common/ErrorState/ErrorState.tsx b/client/src/components/common/ErrorState/ErrorState.tsx new file mode 100644 index 0000000..467232b --- /dev/null +++ b/client/src/components/common/ErrorState/ErrorState.tsx @@ -0,0 +1,63 @@ +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 '../AppButton'; +import AppIcon from '../AppIcon'; + +export interface ErrorStateProps { + /** Already-translated message — page-specific copy stays the caller's job. */ + message: string; + /** Already-translated retry label (e.g. `t('common.retry')`) — kept caller-owned rather than a + * component-internal default so this file never imports next-intl: it sits at the top of the + * `common` barrel, and an eager i18n dependency there would drag `next-intl` into every test that + * imports anything from `@/components`, mocked or not (see `ErrorBoundary` for the same rule). */ + retryLabel: string; + /** Required — the convention this component exists to enforce: an error is never a dead end. */ + onRetry: () => void; +} + +/** + * The calm, branded "something went wrong" panel with a **required** retry affordance — the fix for the + * false-empty class of defect (a failed query rendering as "no data" instead of an error). `onRetry` is + * mandatory by the type; there is no way to render this component without a way back. Presentational, + * caller-owned i18n throughout — everything is already-translated copy. + * @component ErrorState + */ +const ErrorState: FunctionComponent<ErrorStateProps> = ({ message, retryLabel, onRetry }) => ( + <Paper + elevation={0} + data-error-state + sx={{ + p: { xs: 3, sm: 5 }, + textAlign: 'center', + border: '1px solid', + borderColor: 'divider', + borderRadius: 'var(--bal-radius-md)', + bgcolor: 'var(--bal-bg-paper)', + }} + > + <Stack sx={{ alignItems: 'center', gap: 1.5 }}> + <Stack + sx={{ + width: 56, + height: 56, + borderRadius: '50%', + alignItems: 'center', + justifyContent: 'center', + bgcolor: 'var(--bal-error-soft)', + }} + > + <AppIcon icon="error" size={28} color="var(--bal-error)" /> + </Stack> + <Typography variant="body2" sx={{ color: 'text.secondary', maxWidth: 360 }}> + {message} + </Typography> + <AppButton variant="outlined" color="primary" startIcon="refresh" onClick={onRetry}> + {retryLabel} + </AppButton> + </Stack> + </Paper> +); + +export default ErrorState; diff --git a/client/src/components/common/ErrorState/index.tsx b/client/src/components/common/ErrorState/index.tsx new file mode 100644 index 0000000..d6cd181 --- /dev/null +++ b/client/src/components/common/ErrorState/index.tsx @@ -0,0 +1,4 @@ +import ErrorState from './ErrorState'; + +export default ErrorState; +export type { ErrorStateProps } from './ErrorState'; diff --git a/client/src/components/common/JalaliDateField/JalaliDateField.test.tsx b/client/src/components/common/JalaliDateField/JalaliDateField.test.tsx new file mode 100644 index 0000000..3d3a055 --- /dev/null +++ b/client/src/components/common/JalaliDateField/JalaliDateField.test.tsx @@ -0,0 +1,28 @@ +import { render, screen, fireEvent } from '@testing-library/react'; +import { ThemeProvider } from '../../../theme'; + +jest.mock('next-intl', () => ({ + useLocale: () => 'en', +})); + +import JalaliDateField from './JalaliDateField'; + +describe('<JalaliDateField/>', () => { + const wrap = (ui: React.ReactNode) => render(<ThemeProvider>{ui}</ThemeProvider>); + + it('renders a read-only input showing the formatted value', () => { + wrap(<JalaliDateField value="2026-07-17" onChange={jest.fn()} label="Date" />); + const input = screen.getByLabelText('Date') as HTMLInputElement; + expect(input).toHaveAttribute('readOnly'); + expect(input.value).not.toBe(''); + }); + + it('opens the picker popover on click and emits the picked ISO date', () => { + const onChange = jest.fn(); + wrap(<JalaliDateField value="2026-07-15" onChange={onChange} label="Date" />); + fireEvent.click(screen.getByLabelText('Date')); + expect(screen.getByText('July 2026')).toBeInTheDocument(); + fireEvent.click(screen.getByText('20')); + expect(onChange).toHaveBeenCalledWith('2026-07-20'); + }); +}); diff --git a/client/src/components/common/JalaliDateField/JalaliDateField.tsx b/client/src/components/common/JalaliDateField/JalaliDateField.tsx new file mode 100644 index 0000000..03e97d0 --- /dev/null +++ b/client/src/components/common/JalaliDateField/JalaliDateField.tsx @@ -0,0 +1,91 @@ +'use client'; +import { FunctionComponent, MouseEvent, useState } from 'react'; +import { useLocale } from 'next-intl'; +import Popover from '@mui/material/Popover'; +import TextField, { TextFieldProps } from '@mui/material/TextField'; +import Box from '@mui/material/Box'; +import { formatShamsiDate } from '@/utils'; +import AppIcon from '../AppIcon'; +import JalaliDatePicker from '../JalaliDatePicker'; + +export interface JalaliDateFieldProps + extends Omit<TextFieldProps, 'value' | 'onChange' | 'select' | 'type' | 'InputProps'> { + /** The selected date as a wire ISO (Gregorian) `YYYY-MM-DD` string, or `null` for no selection. */ + value: string | null; + /** Fired with the wire ISO (Gregorian) date of the day the user picked. */ + onChange: (iso: string) => void; + min?: string; + max?: string; + /** Already-translated accessible labels forwarded to the picker's month-navigation buttons. */ + prevMonthLabel?: string; + nextMonthLabel?: string; +} + +/** + * A read-only text field that opens `JalaliDatePicker` (grid variant) in a popover — the replacement for + * every native `<input type="date">` in the app (which only ever rendered the Gregorian calendar). Display + * is Shamsi on `fa` / Gregorian on `en` via the same `utils/date.ts` formatter used everywhere else in the + * app; the emitted value is always ISO Gregorian. + * @component JalaliDateField + */ +const JalaliDateField: FunctionComponent<JalaliDateFieldProps> = ({ + value, + onChange, + min, + max, + prevMonthLabel, + nextMonthLabel, + ...textFieldProps +}) => { + const locale = useLocale(); + const [anchorEl, setAnchorEl] = useState<HTMLElement | null>(null); + // MUI Popover's anchor/transform origins are physical (left/right), not logical — resolve the reading + // "start" edge from the locale ourselves so the popover opens on the correct side under RTL. + const startEdge = locale === 'fa' ? 'right' : 'left'; + + const open = (event: MouseEvent<HTMLDivElement>) => setAnchorEl(event.currentTarget); + const close = () => setAnchorEl(null); + + const displayValue = value ? formatShamsiDate(value, locale) : ''; + + return ( + <> + <TextField + {...textFieldProps} + value={displayValue} + onClick={open} + slotProps={{ + input: { + readOnly: true, + endAdornment: <AppIcon icon="calendar" size={20} color="var(--bal-text-secondary)" />, + sx: { cursor: 'pointer' }, + }, + htmlInput: { readOnly: true, style: { cursor: 'pointer' } }, + }} + /> + <Popover + open={Boolean(anchorEl)} + anchorEl={anchorEl} + onClose={close} + anchorOrigin={{ vertical: 'bottom', horizontal: startEdge }} + transformOrigin={{ vertical: 'top', horizontal: startEdge }} + > + <Box sx={{ p: 2, width: 320 }}> + <JalaliDatePicker + value={value} + min={min} + max={max} + prevMonthLabel={prevMonthLabel} + nextMonthLabel={nextMonthLabel} + onChange={(iso) => { + onChange(iso); + close(); + }} + /> + </Box> + </Popover> + </> + ); +}; + +export default JalaliDateField; diff --git a/client/src/components/common/JalaliDateField/index.tsx b/client/src/components/common/JalaliDateField/index.tsx new file mode 100644 index 0000000..7fc175f --- /dev/null +++ b/client/src/components/common/JalaliDateField/index.tsx @@ -0,0 +1,4 @@ +import JalaliDateField from './JalaliDateField'; + +export default JalaliDateField; +export type { JalaliDateFieldProps } from './JalaliDateField'; diff --git a/client/src/components/common/JalaliDatePicker/JalaliDatePicker.test.tsx b/client/src/components/common/JalaliDatePicker/JalaliDatePicker.test.tsx new file mode 100644 index 0000000..326246b --- /dev/null +++ b/client/src/components/common/JalaliDatePicker/JalaliDatePicker.test.tsx @@ -0,0 +1,57 @@ +import { render, screen, fireEvent } from '@testing-library/react'; +import { ThemeProvider } from '../../../theme'; + +jest.mock('next-intl', () => ({ + useLocale: () => 'en', +})); + +import JalaliDatePicker from './JalaliDatePicker'; +import { gregorianEngine, todayIso } from './calendarEngine'; + +describe('<JalaliDatePicker/> — grid variant', () => { + const wrap = (ui: React.ReactNode) => render(<ThemeProvider>{ui}</ThemeProvider>); + + it('emits the ISO Gregorian date of the clicked day', () => { + const onChange = jest.fn(); + const value = '2026-07-01'; + wrap(<JalaliDatePicker value={value} onChange={onChange} />); + fireEvent.click(screen.getByText('15')); + expect(onChange).toHaveBeenCalledWith('2026-07-15'); + }); + + it('marks today distinctly', () => { + const { container } = wrap(<JalaliDatePicker value={null} onChange={jest.fn()} />); + expect(container.querySelector(`[data-day="${todayIso()}"][data-today="true"]`)).toBeInTheDocument(); + }); + + it('disables days outside the min/max range', () => { + const cursorMonth = gregorianEngine.fromIso('2026-07-15'); + const min = gregorianEngine.toIso(cursorMonth.year, cursorMonth.month, 10); + wrap(<JalaliDatePicker value="2026-07-15" min={min} onChange={jest.fn()} />); + const early = screen.getByText('5'); + expect(early.closest('button')).toBeDisabled(); + }); + + it('navigates to the next month', () => { + const { container } = wrap(<JalaliDatePicker value="2026-07-15" onChange={jest.fn()} />); + expect(screen.getByText('July 2026')).toBeInTheDocument(); + const nextButton = container.querySelector('[data-icon="forward"]')?.closest('button'); + expect(nextButton).not.toBeNull(); + fireEvent.click(nextButton as HTMLButtonElement); + expect(screen.getByText('August 2026')).toBeInTheDocument(); + }); +}); + +describe('<JalaliDatePicker/> — chips variant', () => { + const wrap = (ui: React.ReactNode) => render(<ThemeProvider>{ui}</ThemeProvider>); + + it('renders chipDayCount tappable chips starting today and emits ISO on click', () => { + const onChange = jest.fn(); + const { container } = wrap(<JalaliDatePicker variant="chips" value={null} onChange={onChange} chipDayCount={5} />); + const chips = container.querySelectorAll('[data-day]'); + expect(chips.length).toBe(5); + expect(chips[0]).toHaveAttribute('data-day', todayIso()); + fireEvent.click(chips[1]); + expect(onChange).toHaveBeenCalledTimes(1); + }); +}); diff --git a/client/src/components/common/JalaliDatePicker/JalaliDatePicker.tsx b/client/src/components/common/JalaliDatePicker/JalaliDatePicker.tsx new file mode 100644 index 0000000..693c24b --- /dev/null +++ b/client/src/components/common/JalaliDatePicker/JalaliDatePicker.tsx @@ -0,0 +1,211 @@ +'use client'; +import { FunctionComponent, KeyboardEvent, useMemo, useRef, useState } from 'react'; +import { useLocale } from 'next-intl'; +import Box from '@mui/material/Box'; +import Stack from '@mui/material/Stack'; +import Typography from '@mui/material/Typography'; +import AppIconButton from '../AppIconButton'; +import { formatNumber, localeTag } from '@/utils'; +import { addDaysIso, engineForLocale, todayIso, type CalendarCursor } from './calendarEngine'; + +export type JalaliDatePickerVariant = 'grid' | 'chips'; + +export interface JalaliDatePickerProps { + /** The selected date as a wire ISO (Gregorian) `YYYY-MM-DD` string, or `null` for no selection. */ + value: string | null; + /** Fired with the wire ISO (Gregorian) date of the day the user picked. */ + onChange: (iso: string) => void; + /** Inclusive ISO lower bound. */ + min?: string; + /** Inclusive ISO upper bound. */ + max?: string; + /** `grid` (default) — a month calendar with navigation. `chips` — a horizontal day-chip strip for near + * dates (the C4 booking-form shape), starting from `min` (or today) and running `chipDayCount` days. */ + variant?: JalaliDatePickerVariant; + chipDayCount?: number; + /** Already-translated accessible label for the previous/next-month buttons. */ + prevMonthLabel?: string; + nextMonthLabel?: string; +} + +const GRID_COLUMNS = 7; + +/** + * Shamsi-native date selection. Renders Jalaali on `fa`, plain Gregorian on `en` — always **emits and + * accepts ISO Gregorian** (the wire never sees a Jalaali date; display-only, per the money-and-types + * convention already established for Shamsi dates in `utils/date.ts`). See `calendarEngine.ts` for the + * arithmetic decision. Keyboard-navigable (arrow keys roving across the grid, Enter/Space to pick — RTL + * flips left/right automatically), `min`/`max` bound both variants. + * @component JalaliDatePicker + */ +const JalaliDatePicker: FunctionComponent<JalaliDatePickerProps> = ({ + value, + onChange, + min, + max, + variant = 'grid', + chipDayCount = 14, + prevMonthLabel, + nextMonthLabel, +}) => { + const locale = useLocale(); + const engine = useMemo(() => engineForLocale(locale), [locale]); + const dir = locale === 'fa' ? 'rtl' : 'ltr'; + + const initialCursor = useMemo<CalendarCursor>(() => { + const base = value ?? min ?? todayIso(); + const { year, month } = engine.fromIso(base); + return { year, month }; + }, [value, min, engine]); + const [cursor, setCursor] = useState<CalendarCursor>(initialCursor); + const cellRefs = useRef<Array<HTMLButtonElement | null>>([]); + + if (variant === 'chips') { + const startIso = min && min > todayIso() ? min : todayIso(); + const days = Array.from({ length: chipDayCount }, (_, i) => addDaysIso(startIso, i)); + return ( + <Stack + direction="row" + dir={dir} + data-jalali-picker="chips" + sx={{ gap: 1, overflowX: 'auto', pb: 0.5, WebkitOverflowScrolling: 'touch' }} + > + {days.map((dayIso) => { + const disabled = Boolean(max && dayIso > max); + const selected = value === dayIso; + const cell = engine.fromIso(dayIso); + const weekday = new Intl.DateTimeFormat(localeTag(locale), { weekday: 'short' }).format( + new Date(`${dayIso}T00:00:00`), + ); + return ( + <Box + key={dayIso} + component="button" + type="button" + disabled={disabled} + onClick={() => onChange(dayIso)} + data-day={dayIso} + data-selected={selected ? 'true' : undefined} + sx={{ + flexShrink: 0, + minWidth: 56, + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + gap: 0.25, + px: 1.5, + py: 1, + borderRadius: 'var(--bal-radius-sm)', + border: '1px solid', + borderColor: selected ? 'var(--bal-primary)' : 'var(--bal-divider)', + bgcolor: selected ? 'var(--bal-primary-soft)' : 'transparent', + color: disabled ? 'var(--bal-text-secondary)' : 'var(--bal-text-primary)', + cursor: disabled ? 'not-allowed' : 'pointer', + opacity: disabled ? 0.5 : 1, + }} + > + <Typography variant="caption" sx={{ color: 'text.secondary' }}> + {weekday} + </Typography> + <Typography variant="body2" sx={{ fontWeight: selected ? 700 : 500 }}> + {formatNumber(cell.day, locale)} + </Typography> + </Box> + ); + })} + </Stack> + ); + } + + const daysInMonth = engine.daysInMonth(cursor.year, cursor.month); + const offset = engine.firstWeekdayOffset(cursor.year, cursor.month); + const cells: Array<{ iso: string; day: number } | null> = [ + ...Array.from({ length: offset }, () => null), + ...Array.from({ length: daysInMonth }, (_, i) => { + const day = i + 1; + return { iso: engine.toIso(cursor.year, cursor.month, day), day }; + }), + ]; + const todayIsoValue = todayIso(); + + const goToMonth = (delta: number) => setCursor((c) => engine.addMonths(c, delta)); + + const focusCell = (index: number) => { + const el = cellRefs.current[index]; + if (el && !el.disabled) el.focus(); + }; + + const handleKeyDown = (event: KeyboardEvent<HTMLButtonElement>, index: number) => { + const deltas: Record<string, number> = { + ArrowRight: dir === 'rtl' ? -1 : 1, + ArrowLeft: dir === 'rtl' ? 1 : -1, + ArrowDown: GRID_COLUMNS, + ArrowUp: -GRID_COLUMNS, + }; + const delta = deltas[event.key]; + if (delta === undefined) return; + event.preventDefault(); + focusCell(index + delta); + }; + + return ( + <Stack dir={dir} data-jalali-picker="grid" sx={{ gap: 1.5, width: '100%' }}> + <Stack direction="row" sx={{ alignItems: 'center', justifyContent: 'space-between' }}> + <AppIconButton icon="back" title={prevMonthLabel} onClick={() => goToMonth(-1)} iconProps={{ size: 18 }} /> + <Typography variant="subtitle2" sx={{ fontWeight: 700 }}> + {engine.monthYearLabel(cursor, locale === 'fa' ? 'fa-IR-u-ca-persian' : 'en-US')} + </Typography> + <AppIconButton icon="forward" title={nextMonthLabel} onClick={() => goToMonth(1)} iconProps={{ size: 18 }} /> + </Stack> + <Box sx={{ display: 'grid', gridTemplateColumns: `repeat(${GRID_COLUMNS}, 1fr)`, gap: 0.5 }}> + {engine.weekdayLabels(localeTag(locale)).map((label, i) => ( + <Typography key={i} variant="caption" align="center" sx={{ color: 'text.secondary', fontWeight: 500 }}> + {label} + </Typography> + ))} + {cells.map((cell, index) => { + if (!cell) return <Box key={`pad-${index}`} />; + const disabled = Boolean((min && cell.iso < min) || (max && cell.iso > max)); + const selected = value === cell.iso; + const isToday = cell.iso === todayIsoValue; + return ( + <Box + key={cell.iso} + component="button" + type="button" + ref={(el: HTMLElement | null) => { + cellRefs.current[index] = el as HTMLButtonElement | null; + }} + disabled={disabled} + tabIndex={selected || (!value && isToday) ? 0 : -1} + onClick={() => onChange(cell.iso)} + onKeyDown={(e) => handleKeyDown(e, index)} + data-day={cell.iso} + data-selected={selected ? 'true' : undefined} + data-today={isToday ? 'true' : undefined} + sx={{ + aspectRatio: '1 / 1', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + borderRadius: '50%', + border: isToday && !selected ? '1px solid var(--bal-primary)' : '1px solid transparent', + bgcolor: selected ? 'var(--bal-primary)' : 'transparent', + color: selected ? 'var(--bal-primary-contrast)' : disabled ? 'var(--bal-text-secondary)' : 'var(--bal-text-primary)', + fontWeight: selected ? 700 : 500, + cursor: disabled ? 'not-allowed' : 'pointer', + opacity: disabled ? 0.4 : 1, + fontSize: '0.875rem', + '&:hover': disabled ? undefined : { bgcolor: selected ? 'var(--bal-primary)' : 'var(--bal-primary-soft)' }, + }} + > + {formatNumber(cell.day, locale)} + </Box> + ); + })} + </Box> + </Stack> + ); +}; + +export default JalaliDatePicker; diff --git a/client/src/components/common/JalaliDatePicker/calendarEngine.test.ts b/client/src/components/common/JalaliDatePicker/calendarEngine.test.ts new file mode 100644 index 0000000..6892311 --- /dev/null +++ b/client/src/components/common/JalaliDatePicker/calendarEngine.test.ts @@ -0,0 +1,45 @@ +import { jalaliEngine, gregorianEngine, addDaysIso } from './calendarEngine'; + +describe('jalaliEngine', () => { + it('converts a known Gregorian ISO date to Jalaali', () => { + // Reference pair documented by jalaali-js itself. + expect(jalaliEngine.fromIso('2016-04-11')).toEqual({ year: 1395, month: 1, day: 23 }); + }); + + it('round-trips Jalaali -> ISO -> Jalaali', () => { + const iso = jalaliEngine.toIso(1404, 5, 15); + expect(jalaliEngine.fromIso(iso)).toEqual({ year: 1404, month: 5, day: 15 }); + }); + + it('knows Esfand length across a leap and a non-leap year', () => { + expect(jalaliEngine.daysInMonth(1394, 12)).toBe(29); + expect(jalaliEngine.daysInMonth(1395, 12)).toBe(30); + }); + + it('adds months across a year boundary', () => { + expect(jalaliEngine.addMonths({ year: 1404, month: 11 }, 2)).toEqual({ year: 1405, month: 1 }); + expect(jalaliEngine.addMonths({ year: 1404, month: 2 }, -3)).toEqual({ year: 1403, month: 11 }); + }); +}); + +describe('gregorianEngine', () => { + it('is the identity conversion', () => { + expect(gregorianEngine.fromIso('2026-07-17')).toEqual({ year: 2026, month: 7, day: 17 }); + expect(gregorianEngine.toIso(2026, 7, 17)).toBe('2026-07-17'); + }); + + it('knows February length across a leap and a non-leap year', () => { + expect(gregorianEngine.daysInMonth(2023, 2)).toBe(28); + expect(gregorianEngine.daysInMonth(2024, 2)).toBe(29); + }); +}); + +describe('addDaysIso', () => { + it('adds days across a month boundary', () => { + expect(addDaysIso('2026-07-30', 3)).toBe('2026-08-02'); + }); + + it('subtracts days across a year boundary', () => { + expect(addDaysIso('2026-01-01', -1)).toBe('2025-12-31'); + }); +}); diff --git a/client/src/components/common/JalaliDatePicker/calendarEngine.ts b/client/src/components/common/JalaliDatePicker/calendarEngine.ts new file mode 100644 index 0000000..4824053 --- /dev/null +++ b/client/src/components/common/JalaliDatePicker/calendarEngine.ts @@ -0,0 +1,149 @@ +/** + * The arithmetic layer behind `JalaliDatePicker`/`JalaliDateField`. Two small, pure "calendar engines" + * (Jalaali for `fa`, plain Gregorian for `en`) share one shape so the picker's grid/navigation logic is + * calendar-agnostic; both always emit/accept **ISO Gregorian** `YYYY-MM-DD` — the wire never sees Jalaali. + * + * Arithmetic decision: `Intl` (`fa-IR-u-ca-persian`, already used by `utils/date.ts`) is enough to + * *display* a Shamsi date, but has no reverse direction — there is no way to ask Intl "what Gregorian + * date is Jalaali 1404/5/15", which is exactly what a date *picker* (as opposed to a date *label*) needs + * for month navigation and emitting the selected day. `jalaali-js` (MIT, zero runtime deps, ~9KB + * unminified / low single-digit KB gzipped — see the phase report) supplies that reverse conversion + * (`toGregorian`) plus month-length/leap-year math, so it's used here instead of hand-rolling the + * Borkowski algorithm. Intl is still used for locale-correct month/weekday *names* — jalaali-js only + * supplies numbers. + */ +import { toJalaali, toGregorian, jalaaliMonthLength } from 'jalaali-js'; + +export interface CalendarCursor { + year: number; + month: number; // 1-based +} + +export interface CalendarEngine { + today(): CalendarCursor & { day: number }; + /** Parses a wire ISO (Gregorian) date into this calendar's year/month/day. */ + fromIso(iso: string): CalendarCursor & { day: number }; + /** Emits the wire ISO (Gregorian) date for a day in this calendar. */ + toIso(year: number, month: number, day: number): string; + daysInMonth(year: number, month: number): number; + addMonths(cursor: CalendarCursor, delta: number): CalendarCursor; + /** 0-based offset of day 1 within the week grid (this engine's week-start convention). */ + firstWeekdayOffset(year: number, month: number): number; + monthYearLabel(cursor: CalendarCursor, locale: string): string; + /** 7 short weekday labels, in this engine's week-start order. */ + weekdayLabels(locale: string): string[]; +} + +function pad2(n: number): string { + return String(n).padStart(2, '0'); +} + +function isoOf(y: number, m: number, d: number): string { + return `${y}-${pad2(m)}-${pad2(d)}`; +} + +function parseIso(iso: string): { y: number; m: number; d: number } { + const [y, m, d] = iso.split('-').map(Number); + return { y, m, d }; +} + +/** Weekday of a Gregorian y/m/d as a UTC-anchored computation — immune to the runtime's local timezone. */ +function utcWeekday(y: number, m: number, d: number): number { + return new Date(Date.UTC(y, m - 1, d)).getUTCDay(); // 0 = Sunday … 6 = Saturday +} + +function addMonthsGeneric(cursor: CalendarCursor, delta: number): CalendarCursor { + const total = cursor.year * 12 + (cursor.month - 1) + delta; + const year = Math.floor(total / 12); + const month = ((total % 12) + 12) % 12; + return { year, month: month + 1 }; +} + +export const jalaliEngine: CalendarEngine = { + today() { + const { jy, jm, jd } = toJalaali(new Date()); + return { year: jy, month: jm, day: jd }; + }, + fromIso(iso) { + const { y, m, d } = parseIso(iso); + const { jy, jm, jd } = toJalaali(y, m, d); + return { year: jy, month: jm, day: jd }; + }, + toIso(year, month, day) { + const { gy, gm, gd } = toGregorian(year, month, day); + return isoOf(gy, gm, gd); + }, + daysInMonth(year, month) { + return jalaaliMonthLength(year, month); + }, + addMonths: addMonthsGeneric, + firstWeekdayOffset(year, month) { + const { gy, gm, gd } = toGregorian(year, month, 1); + // Jalaali weeks start Saturday; utcWeekday is Sunday-based, so shift by one. + return (utcWeekday(gy, gm, gd) + 1) % 7; + }, + monthYearLabel(cursor, locale) { + const { gy, gm, gd } = toGregorian(cursor.year, cursor.month, 1); + return new Intl.DateTimeFormat(locale, { month: 'long', year: 'numeric' }).format(new Date(gy, gm - 1, gd)); + }, + weekdayLabels(locale) { + // A known Saturday (2024-01-06 is a Saturday) anchors the fa week-start order. + const anchor = new Date(2024, 0, 6); + return Array.from({ length: 7 }, (_, i) => { + const d = new Date(anchor); + d.setDate(anchor.getDate() + i); + return new Intl.DateTimeFormat(locale, { weekday: 'short' }).format(d); + }); + }, +}; + +export const gregorianEngine: CalendarEngine = { + today() { + const now = new Date(); + return { year: now.getFullYear(), month: now.getMonth() + 1, day: now.getDate() }; + }, + fromIso(iso) { + const { y, m, d } = parseIso(iso); + return { year: y, month: m, day: d }; + }, + toIso: isoOf, + daysInMonth(year, month) { + return new Date(year, month, 0).getDate(); + }, + addMonths: addMonthsGeneric, + firstWeekdayOffset(year, month) { + return utcWeekday(year, month, 1); // Gregorian week starts Sunday — no shift needed. + }, + monthYearLabel(cursor, locale) { + return new Intl.DateTimeFormat(locale, { month: 'long', year: 'numeric' }).format( + new Date(cursor.year, cursor.month - 1, 1), + ); + }, + weekdayLabels(locale) { + // A known Sunday (2024-01-07) anchors the en week-start order. + const anchor = new Date(2024, 0, 7); + return Array.from({ length: 7 }, (_, i) => { + const d = new Date(anchor); + d.setDate(anchor.getDate() + i); + return new Intl.DateTimeFormat(locale, { weekday: 'short' }).format(d); + }); + }, +}; + +/** Selects the display calendar for the app locale — `fa` gets Jalaali, everything else Gregorian. */ +export function engineForLocale(locale: string): CalendarEngine { + return locale === 'fa' ? jalaliEngine : gregorianEngine; +} + +/** Adds whole days to an ISO (Gregorian) date via UTC arithmetic — DST-safe, calendar-agnostic. */ +export function addDaysIso(iso: string, days: number): string { + const { y, m, d } = parseIso(iso); + const next = new Date(Date.UTC(y, m - 1, d) + days * 86_400_000); + return isoOf(next.getUTCFullYear(), next.getUTCMonth() + 1, next.getUTCDate()); +} + +/** Today's date as a wire ISO string, from the local wall clock. */ +export function todayIso(): string { + const now = new Date(); + return isoOf(now.getFullYear(), now.getMonth() + 1, now.getDate()); +} diff --git a/client/src/components/common/JalaliDatePicker/index.tsx b/client/src/components/common/JalaliDatePicker/index.tsx new file mode 100644 index 0000000..c906586 --- /dev/null +++ b/client/src/components/common/JalaliDatePicker/index.tsx @@ -0,0 +1,6 @@ +import JalaliDatePicker from './JalaliDatePicker'; + +export default JalaliDatePicker; +export type { JalaliDatePickerProps, JalaliDatePickerVariant } from './JalaliDatePicker'; +export { engineForLocale, jalaliEngine, gregorianEngine, addDaysIso, todayIso } from './calendarEngine'; +export type { CalendarEngine, CalendarCursor } from './calendarEngine'; diff --git a/client/src/components/common/Money/Money.test.tsx b/client/src/components/common/Money/Money.test.tsx new file mode 100644 index 0000000..a0c55bb --- /dev/null +++ b/client/src/components/common/Money/Money.test.tsx @@ -0,0 +1,39 @@ +import { render, screen } from '@testing-library/react'; +import { ThemeProvider } from '../../../theme'; + +jest.mock('next-intl', () => ({ + useTranslations: () => (key: string) => (key === 'currency_toman' ? 'Toman' : key), + useLocale: () => 'en', +})); + +import Money from './Money'; + +describe('<Money/>', () => { + const wrap = (ui: React.ReactNode) => render(<ThemeProvider>{ui}</ThemeProvider>); + + it('renders the Toman-formatted amount with the unit label', () => { + wrap(<Money amountIrr="45000000" />); + expect(screen.getByText(/4,500,000/)).toBeInTheDocument(); + expect(screen.getByText(/Toman/)).toBeInTheDocument(); + }); + + it('hides the unit label when hideUnit is set', () => { + wrap(<Money amountIrr="45000000" hideUnit />); + expect(screen.queryByText(/Toman/)).not.toBeInTheDocument(); + }); + + it('prefixes an explicit minus sign for a deduction, held with the digits', () => { + wrap(<Money amountIrr="45000000" deduction />); + const el = screen.getByText((_, node) => node?.textContent === '−4,500,000'); + expect(el).toHaveAttribute('dir', 'ltr'); + }); + + it('marks the root with data-deduction when a deduction', () => { + const { container } = render( + <ThemeProvider> + <Money amountIrr="1000" deduction /> + </ThemeProvider>, + ); + expect(container.querySelector('[data-money][data-deduction="true"]')).toBeInTheDocument(); + }); +}); diff --git a/client/src/components/common/Money/Money.tsx b/client/src/components/common/Money/Money.tsx new file mode 100644 index 0000000..0cde38e --- /dev/null +++ b/client/src/components/common/Money/Money.tsx @@ -0,0 +1,92 @@ +'use client'; +import { FunctionComponent } from 'react'; +import { useLocale, useTranslations } from 'next-intl'; +import Box from '@mui/material/Box'; +import Typography, { TypographyProps } from '@mui/material/Typography'; +import { formatIrrToToman } from '@/utils'; + +export type MoneySize = 'sm' | 'md' | 'lg'; +export type MoneyTone = 'default' | 'emphasis' | 'muted' | 'error'; + +const SIZE_VARIANT: Record<MoneySize, TypographyProps['variant']> = { + sm: 'body2', + md: 'body1', + lg: 'h6', +}; + +// House weight system has no 600 face (see typography.ts) — 700 for the strong tones, 500/400 otherwise. +const TONE_STYLE: Record<MoneyTone, { color: string; fontWeight: 400 | 500 | 700 }> = { + default: { color: 'inherit', fontWeight: 500 }, + emphasis: { color: 'var(--bal-money-emphasis)', fontWeight: 700 }, + muted: { color: 'var(--bal-text-secondary)', fontWeight: 400 }, + error: { color: 'var(--bal-error)', fontWeight: 700 }, +}; + +export interface MoneyProps extends Omit<TypographyProps, 'children' | 'color'> { + /** A served IRR digit-string, straight off the wire. `<Money>` only formats — it never computes. */ + amountIrr: string; + size?: MoneySize; + tone?: MoneyTone; + /** Marks the amount as a deduction (a commission, a fee): prefixes an explicit `−` sign, held together + * with the digits in a forced `dir="ltr"` run so the sign never gets swallowed inside RTL text — never + * relies on a bare Unicode minus floating in a Persian paragraph. Defaults `tone` to `muted` unless the + * caller overrides it. */ + deduction?: boolean; + /** Hides the "تومان"/"Toman" unit label — the bare grouped number only. */ + hideUnit?: boolean; + /** Renders the amount with a line-through (e.g. a superseded price next to a discounted one). */ + strikethrough?: boolean; +} + +/** + * The single display primitive for every served IRR amount: formats via `utils/money.ts` (BigInt, + * Toman + Persian digits) and never computes a figure itself. Replaces the 24 hand-assembled + * `formatIrrToToman(x) + t('currency_toman')` call sites — including the ones that colored the total + * terracotta (`--bal-secondary` fails AA contrast for small text; emphasis uses `--bal-money-emphasis` + * instead, see the frontend-designer skill). + * @component Money + */ +const Money: FunctionComponent<MoneyProps> = ({ + amountIrr, + size = 'md', + tone, + deduction = false, + hideUnit = false, + strikethrough = false, + sx, + ...rest +}) => { + const locale = useLocale(); + const tc = useTranslations('common'); + const effectiveTone = tone ?? (deduction ? 'muted' : 'default'); + const { color, fontWeight } = TONE_STYLE[effectiveTone]; + const formatted = formatIrrToToman(amountIrr, locale); + + return ( + <Typography + variant={SIZE_VARIANT[size]} + component="span" + data-money + data-deduction={deduction ? 'true' : undefined} + sx={{ + color, + fontWeight, + textDecoration: strikethrough ? 'line-through' : undefined, + ...sx, + }} + {...rest} + > + {deduction ? ( + <Box component="span" dir="ltr" sx={{ display: 'inline-block' }}> + {'−'} + {formatted} + </Box> + ) : ( + formatted + )} + {hideUnit ? null : ` ${tc('currency_toman')}`} + </Typography> + ); +}; + +export default Money; diff --git a/client/src/components/common/Money/index.tsx b/client/src/components/common/Money/index.tsx new file mode 100644 index 0000000..b112220 --- /dev/null +++ b/client/src/components/common/Money/index.tsx @@ -0,0 +1,4 @@ +import Money from './Money'; + +export default Money; +export type { MoneyProps, MoneySize, MoneyTone } from './Money'; diff --git a/client/src/components/common/PageHeader/PageHeader.test.tsx b/client/src/components/common/PageHeader/PageHeader.test.tsx new file mode 100644 index 0000000..c3e32e1 --- /dev/null +++ b/client/src/components/common/PageHeader/PageHeader.test.tsx @@ -0,0 +1,28 @@ +import { render, screen } from '@testing-library/react'; +import { ThemeProvider } from '../../../theme'; +import PageHeader from './PageHeader'; + +describe('<PageHeader/>', () => { + const wrap = (ui: React.ReactNode) => render(<ThemeProvider>{ui}</ThemeProvider>); + + it('renders the title and subtitle', () => { + wrap(<PageHeader title="Patients" subtitle="Manage your family" />); + expect(screen.getByRole('heading', { name: 'Patients' })).toBeInTheDocument(); + expect(screen.getByText('Manage your family')).toBeInTheDocument(); + }); + + it('renders the actions slot when provided', () => { + wrap(<PageHeader title="T" actions={<button>Do</button>} />); + expect(screen.getByRole('button', { name: 'Do' })).toBeInTheDocument(); + }); + + it('omits the back button when backTo is not given', () => { + wrap(<PageHeader title="T" />); + expect(screen.queryByRole('link')).not.toBeInTheDocument(); + }); + + it('renders a back link when backTo is given', () => { + wrap(<PageHeader title="T" backTo="/patients" backLabel="Back" />); + expect(screen.getByRole('link')).toHaveAttribute('href', '/patients'); + }); +}); diff --git a/client/src/components/common/PageHeader/PageHeader.tsx b/client/src/components/common/PageHeader/PageHeader.tsx new file mode 100644 index 0000000..6612e40 --- /dev/null +++ b/client/src/components/common/PageHeader/PageHeader.tsx @@ -0,0 +1,48 @@ +import { FunctionComponent, ReactNode } from 'react'; +import Box from '@mui/material/Box'; +import Stack from '@mui/material/Stack'; +import Typography from '@mui/material/Typography'; +import AppIconButton from '../AppIconButton'; + +export interface PageHeaderProps { + /** Already-translated title (i18n is the caller's job — labels are keys). */ + title: string; + /** Optional already-translated subtitle. */ + subtitle?: string; + /** Optional action node (a button / filter) rendered end-aligned on desktop, wrapping on mobile. */ + actions?: ReactNode; + /** Optional back-navigation target — renders an RTL-flippable chevron before the title. */ + backTo?: string; + /** Already-translated accessible label for the back button (required when `backTo` is set). */ + backLabel?: string; +} + +/** + * The standard page header — title + optional subtitle, an end-aligned actions slot, and an optional + * back affordance. The generalized, promoted form of `AdminPageHeader` (kept as a thin alias); every area + * page hand-rolling its own `h5`/`h1` + subtitle block should adopt this instead. Presentational, + * caller-owned i18n, RTL-safe (logical flex, the `back` icon mirrors automatically under `dir="rtl"`). + * @component PageHeader + */ +const PageHeader: FunctionComponent<PageHeaderProps> = ({ title, subtitle, actions, backTo, backLabel }) => ( + <Stack direction="row" sx={{ gap: 2, alignItems: 'flex-start', justifyContent: 'space-between', flexWrap: 'wrap' }}> + <Stack direction="row" sx={{ gap: 1, alignItems: 'flex-start' }}> + {backTo ? ( + <AppIconButton icon="back" to={backTo} title={backLabel} sx={{ mt: 0.25 }} iconProps={{ size: 20 }} /> + ) : null} + <Box> + <Typography variant="h5" component="h1" sx={{ fontWeight: 700 }}> + {title} + </Typography> + {subtitle ? ( + <Typography variant="body2" sx={{ color: 'text.secondary', mt: 0.5 }}> + {subtitle} + </Typography> + ) : null} + </Box> + </Stack> + {actions ? <Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap' }}>{actions}</Box> : null} + </Stack> +); + +export default PageHeader; diff --git a/client/src/components/common/PageHeader/index.tsx b/client/src/components/common/PageHeader/index.tsx new file mode 100644 index 0000000..9e517d7 --- /dev/null +++ b/client/src/components/common/PageHeader/index.tsx @@ -0,0 +1,4 @@ +import PageHeader from './PageHeader'; + +export default PageHeader; +export type { PageHeaderProps } from './PageHeader'; diff --git a/client/src/components/common/QueryStateGate/QueryStateGate.test.tsx b/client/src/components/common/QueryStateGate/QueryStateGate.test.tsx new file mode 100644 index 0000000..cca5305 --- /dev/null +++ b/client/src/components/common/QueryStateGate/QueryStateGate.test.tsx @@ -0,0 +1,51 @@ +import { render, screen } from '@testing-library/react'; +import { ThemeProvider } from '../../../theme'; +import QueryStateGate from './QueryStateGate'; + +describe('<QueryStateGate/>', () => { + const wrap = (ui: React.ReactNode) => render(<ThemeProvider>{ui}</ThemeProvider>); + const base = { + onRetry: jest.fn(), + errorMessage: 'Failed', + retryLabel: 'Retry', + skeleton: <div>SKELETON</div>, + empty: <div>EMPTY</div>, + }; + + it('renders the skeleton while loading, even if isError/isEmpty are also true', () => { + wrap( + <QueryStateGate {...base} isLoading isError isEmpty> + <div>DATA</div> + </QueryStateGate>, + ); + expect(screen.getByText('SKELETON')).toBeInTheDocument(); + }); + + it('renders the error state before checking isEmpty — an error is never an empty', () => { + wrap( + <QueryStateGate {...base} isLoading={false} isError isEmpty> + <div>DATA</div> + </QueryStateGate>, + ); + expect(screen.getByText('Failed')).toBeInTheDocument(); + expect(screen.queryByText('EMPTY')).not.toBeInTheDocument(); + }); + + it('renders empty when not loading/erroring but isEmpty', () => { + wrap( + <QueryStateGate {...base} isLoading={false} isError={false} isEmpty> + <div>DATA</div> + </QueryStateGate>, + ); + expect(screen.getByText('EMPTY')).toBeInTheDocument(); + }); + + it('renders children when data is present', () => { + wrap( + <QueryStateGate {...base} isLoading={false} isError={false} isEmpty={false}> + <div>DATA</div> + </QueryStateGate>, + ); + expect(screen.getByText('DATA')).toBeInTheDocument(); + }); +}); diff --git a/client/src/components/common/QueryStateGate/QueryStateGate.tsx b/client/src/components/common/QueryStateGate/QueryStateGate.tsx new file mode 100644 index 0000000..b70a048 --- /dev/null +++ b/client/src/components/common/QueryStateGate/QueryStateGate.tsx @@ -0,0 +1,45 @@ +import { FunctionComponent, ReactNode } from 'react'; +import ErrorState from '../ErrorState'; + +export interface QueryStateGateProps { + isLoading: boolean; + isError: boolean; + onRetry: () => void; + isEmpty: boolean; + /** Already-translated, page-specific error copy — passed straight to `ErrorState`. */ + errorMessage: string; + /** Already-translated retry label — passed straight to `ErrorState` (e.g. `t('common.retry')`). */ + retryLabel: string; + /** Rendered while `isLoading` — pass a shaped skeleton, not a bare spinner. */ + skeleton: ReactNode; + /** Rendered when the query succeeded but returned nothing. */ + empty: ReactNode; + children: ReactNode; +} + +/** + * The convention this phase exists to enforce, as one component: **an errored query must never render + * as empty**. Branches in a fixed order — skeleton → error (with a mandatory retry, via `ErrorState`) → + * empty → data — so a page can't accidentally check `isEmpty` before `isError` and show "no results" for + * a network failure. Pages with naturally inline branching (infinite lists with `fetchNextPage`) may + * follow the same order by hand instead of reaching for this component; the convention is what matters. + * @component QueryStateGate + */ +const QueryStateGate: FunctionComponent<QueryStateGateProps> = ({ + isLoading, + isError, + onRetry, + isEmpty, + errorMessage, + retryLabel, + skeleton, + empty, + children, +}) => { + if (isLoading) return <>{skeleton}</>; + if (isError) return <ErrorState message={errorMessage} retryLabel={retryLabel} onRetry={onRetry} />; + if (isEmpty) return <>{empty}</>; + return <>{children}</>; +}; + +export default QueryStateGate; diff --git a/client/src/components/common/QueryStateGate/index.tsx b/client/src/components/common/QueryStateGate/index.tsx new file mode 100644 index 0000000..bdaeda4 --- /dev/null +++ b/client/src/components/common/QueryStateGate/index.tsx @@ -0,0 +1,4 @@ +import QueryStateGate from './QueryStateGate'; + +export default QueryStateGate; +export type { QueryStateGateProps } from './QueryStateGate'; diff --git a/client/src/components/common/StatusTimeline/StatusTimeline.test.tsx b/client/src/components/common/StatusTimeline/StatusTimeline.test.tsx new file mode 100644 index 0000000..289e309 --- /dev/null +++ b/client/src/components/common/StatusTimeline/StatusTimeline.test.tsx @@ -0,0 +1,35 @@ +import { render, screen } from '@testing-library/react'; +import { ThemeProvider } from '../../../theme'; +import StatusTimeline, { TimelineNode } from './StatusTimeline'; + +describe('<StatusTimeline/>', () => { + const wrap = (ui: React.ReactNode) => render(<ThemeProvider>{ui}</ThemeProvider>); + + const nodes: TimelineNode[] = [ + { key: 'submitted', label: 'Submitted', state: 'completed', timestamp: 'Jul 1' }, + { key: 'on_its_way', label: 'On its way', state: 'current', note: 'Bank transfer' }, + { key: 'completed', label: 'Completed', state: 'pending' }, + ]; + + it('renders every node label, timestamp, and note', () => { + wrap(<StatusTimeline nodes={nodes} />); + expect(screen.getByText('Submitted')).toBeInTheDocument(); + expect(screen.getByText('Jul 1')).toBeInTheDocument(); + expect(screen.getByText('On its way')).toBeInTheDocument(); + expect(screen.getByText('Bank transfer')).toBeInTheDocument(); + expect(screen.getByText('Completed')).toBeInTheDocument(); + }); + + it('exposes each node state via a data attribute', () => { + const { container } = render(<StatusTimeline nodes={nodes} />); + expect(container.querySelector('[data-node="submitted"][data-node-state="completed"]')).toBeInTheDocument(); + expect(container.querySelector('[data-node="on_its_way"][data-node-state="current"]')).toBeInTheDocument(); + }); + + it('renders a failed node distinctly', () => { + const { container } = render( + <StatusTimeline nodes={[{ key: 'failed', label: 'Failed', state: 'failed' }]} />, + ); + expect(container.querySelector('[data-node-state="failed"]')).toBeInTheDocument(); + }); +}); diff --git a/client/src/components/common/StatusTimeline/StatusTimeline.tsx b/client/src/components/common/StatusTimeline/StatusTimeline.tsx new file mode 100644 index 0000000..dd4735d --- /dev/null +++ b/client/src/components/common/StatusTimeline/StatusTimeline.tsx @@ -0,0 +1,127 @@ +'use client' +import { FunctionComponent } from 'react'; +import { keyframes } from '@emotion/react'; +import Box from '@mui/material/Box'; +import Stack from '@mui/material/Stack'; +import Typography from '@mui/material/Typography'; +import AppIcon from '../AppIcon'; + +export type TimelineNodeState = 'completed' | 'current' | 'pending' | 'failed'; + +export interface TimelineNode { + /** Stable key — also exposed as `data-node` for tests. */ + key: string; + /** Already-translated label. */ + label: string; + /** Already-translated, formatted timestamp (e.g. a Shamsi date/time). */ + timestamp?: string; + /** Already-translated freeform note (a channel, a reference). */ + note?: string; + state: TimelineNodeState; +} + +export interface StatusTimelineProps { + nodes: TimelineNode[]; +} + +const pulse = keyframes` + 0%, 100% { box-shadow: 0 0 0 0 var(--bal-primary-soft); } + 50% { box-shadow: 0 0 0 6px var(--bal-primary-soft); } +`; + +const DOT_SIZE = 22; + +function dotStyle(state: TimelineNodeState) { + switch (state) { + case 'completed': + return { bg: 'var(--bal-primary)', border: 'var(--bal-primary)' }; + case 'current': + return { bg: 'var(--bal-bg-paper)', border: 'var(--bal-primary)' }; + case 'failed': + return { bg: 'var(--bal-error)', border: 'var(--bal-error)' }; + case 'pending': + default: + return { bg: 'var(--bal-bg-paper)', border: 'var(--bal-divider)' }; + } +} + +/** + * A designed vertical status timeline: node = label + optional timestamp + optional note, with four + * states — `completed` (filled), `current` (an animated ring, respecting `prefers-reduced-motion`), + * `pending` (outlined), and `failed` (a distinct terminal marker, never styled as if progress continues + * past it). Replaces `StepperHeader` misused as a status display (the refund progress card, the booking + * lifecycle) — `StepperHeader` itself stays reserved for real multi-step wizards. + * @component StatusTimeline + */ +const StatusTimeline: FunctionComponent<StatusTimelineProps> = ({ nodes }) => ( + <Stack data-status-timeline sx={{ gap: 0 }}> + {nodes.map((node, index) => { + const isLast = index === nodes.length - 1; + const { bg, border } = dotStyle(node.state); + return ( + <Stack key={node.key} direction="row" sx={{ gap: 1.5 }} data-node={node.key} data-node-state={node.state}> + <Stack sx={{ alignItems: 'center', width: DOT_SIZE }}> + <Box + sx={{ + width: DOT_SIZE, + height: DOT_SIZE, + borderRadius: '50%', + flexShrink: 0, + bgcolor: bg, + border: '2px solid', + borderColor: border, + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + ...(node.state === 'current' && { + '@media (prefers-reduced-motion: no-preference)': { + animation: `${pulse} 1.6s ease-in-out infinite`, + }, + }), + }} + > + {node.state === 'completed' && <AppIcon icon="verified" size={14} color="var(--bal-primary-contrast)" />} + {node.state === 'current' && ( + <Box sx={{ width: 8, height: 8, borderRadius: '50%', bgcolor: 'var(--bal-primary)' }} /> + )} + {node.state === 'failed' && <AppIcon icon="close" size={14} color="var(--bal-error-contrast)" />} + </Box> + {!isLast && ( + <Box + sx={{ + width: 2, + flex: 1, + minHeight: 28, + bgcolor: node.state === 'completed' ? 'var(--bal-primary)' : 'var(--bal-divider)', + }} + /> + )} + </Stack> + <Stack sx={{ pb: isLast ? 0 : 2.5, gap: 0.25, pt: 0.25 }}> + <Typography + variant="body2" + sx={{ + fontWeight: node.state === 'current' ? 700 : 500, + color: node.state === 'pending' ? 'text.secondary' : 'text.primary', + }} + > + {node.label} + </Typography> + {node.timestamp ? ( + <Typography variant="caption" sx={{ color: 'text.secondary' }}> + {node.timestamp} + </Typography> + ) : null} + {node.note ? ( + <Typography variant="caption" sx={{ color: 'text.secondary' }}> + {node.note} + </Typography> + ) : null} + </Stack> + </Stack> + ); + })} + </Stack> +); + +export default StatusTimeline; diff --git a/client/src/components/common/StatusTimeline/index.tsx b/client/src/components/common/StatusTimeline/index.tsx new file mode 100644 index 0000000..f426441 --- /dev/null +++ b/client/src/components/common/StatusTimeline/index.tsx @@ -0,0 +1,4 @@ +import StatusTimeline from './StatusTimeline'; + +export default StatusTimeline; +export type { StatusTimelineProps, TimelineNode, TimelineNodeState } from './StatusTimeline'; diff --git a/client/src/components/common/SurfaceCard/SurfaceCard.test.tsx b/client/src/components/common/SurfaceCard/SurfaceCard.test.tsx new file mode 100644 index 0000000..70943dd --- /dev/null +++ b/client/src/components/common/SurfaceCard/SurfaceCard.test.tsx @@ -0,0 +1,17 @@ +import { render, screen } from '@testing-library/react'; +import { ThemeProvider } from '../../../theme'; +import SurfaceCard from './SurfaceCard'; + +describe('<SurfaceCard/>', () => { + const wrap = (ui: React.ReactNode) => render(<ThemeProvider>{ui}</ThemeProvider>); + + it('renders its children', () => { + wrap(<SurfaceCard>content</SurfaceCard>); + expect(screen.getByText('content')).toBeInTheDocument(); + }); + + it('passes through data attributes and other Paper props', () => { + wrap(<SurfaceCard data-testid="card">content</SurfaceCard>); + expect(screen.getByTestId('card')).toBeInTheDocument(); + }); +}); diff --git a/client/src/components/common/SurfaceCard/SurfaceCard.tsx b/client/src/components/common/SurfaceCard/SurfaceCard.tsx new file mode 100644 index 0000000..bc741fd --- /dev/null +++ b/client/src/components/common/SurfaceCard/SurfaceCard.tsx @@ -0,0 +1,40 @@ +import { FunctionComponent } from 'react'; +import Paper, { PaperProps } from '@mui/material/Paper'; + +export type SurfaceCardPadding = 'sm' | 'md' | 'lg'; + +const PADDING_SCALE: Record<SurfaceCardPadding, number> = { + sm: 2, + md: 2.5, + lg: 3, +}; + +export interface SurfaceCardProps extends Omit<PaperProps, 'elevation' | 'variant'> { + /** Padding scale — `sm`≈16px / `md`≈20px / `lg`≈24px (theme spacing units 2/2.5/3). Default `md`. */ + padding?: SurfaceCardPadding; +} + +/** + * The de-facto card anatomy this app already hand-rolls ~12 times, codified once: a flat `Paper` + * (`elevation={0}`), a 1px `divider` border, the house radius, and one padding scale. Every plain card + * (`EarningsRow`, `PatientCard`, `VariantCard`, `PriceBreakdown`, …) should compose this instead of + * re-declaring the same `sx`. Purely a visual shell — no state, no i18n. + * @component SurfaceCard + */ +const SurfaceCard: FunctionComponent<SurfaceCardProps> = ({ padding = 'md', sx, children, ...rest }) => ( + <Paper + elevation={0} + sx={{ + p: PADDING_SCALE[padding], + border: '1px solid', + borderColor: 'divider', + borderRadius: 'var(--bal-radius-md)', + ...sx, + }} + {...rest} + > + {children} + </Paper> +); + +export default SurfaceCard; diff --git a/client/src/components/common/SurfaceCard/index.tsx b/client/src/components/common/SurfaceCard/index.tsx new file mode 100644 index 0000000..bcdefa9 --- /dev/null +++ b/client/src/components/common/SurfaceCard/index.tsx @@ -0,0 +1,4 @@ +import SurfaceCard from './SurfaceCard'; + +export default SurfaceCard; +export type { SurfaceCardProps, SurfaceCardPadding } from './SurfaceCard'; diff --git a/client/src/components/common/index.tsx b/client/src/components/common/index.tsx index b3e1996..5fde5f1 100644 --- a/client/src/components/common/index.tsx +++ b/client/src/components/common/index.tsx @@ -5,5 +5,46 @@ import AppIconButton from './AppIconButton'; import AppLink from './AppLink'; import AppLoading from './AppLoading'; import ErrorBoundary from './ErrorBoundary'; +import EmptyState from './EmptyState'; +import ErrorState from './ErrorState'; +import QueryStateGate from './QueryStateGate'; +import PageHeader from './PageHeader'; +import ConfirmDialog from './ConfirmDialog'; +import SurfaceCard from './SurfaceCard'; +import AccentCard from './AccentCard'; +import Money from './Money'; +import StatusTimeline from './StatusTimeline'; +import JalaliDatePicker from './JalaliDatePicker'; +import JalaliDateField from './JalaliDateField'; -export { ErrorBoundary, AppAlert, AppButton, AppIcon, AppIconButton, AppLink, AppLoading }; +export { + ErrorBoundary, + AppAlert, + AppButton, + AppIcon, + AppIconButton, + AppLink, + AppLoading, + EmptyState, + ErrorState, + QueryStateGate, + PageHeader, + ConfirmDialog, + SurfaceCard, + AccentCard, + Money, + StatusTimeline, + JalaliDatePicker, + JalaliDateField, +}; +export type { EmptyStateProps } from './EmptyState'; +export type { ErrorStateProps } from './ErrorState'; +export type { QueryStateGateProps } from './QueryStateGate'; +export type { PageHeaderProps } from './PageHeader'; +export type { ConfirmDialogProps } from './ConfirmDialog'; +export type { SurfaceCardProps } from './SurfaceCard'; +export type { AccentCardProps, AccentTone } from './AccentCard'; +export type { MoneyProps } from './Money'; +export type { StatusTimelineProps, TimelineNode, TimelineNodeState } from './StatusTimeline'; +export type { JalaliDatePickerProps } from './JalaliDatePicker'; +export type { JalaliDateFieldProps } from './JalaliDateField'; diff --git a/client/src/components/config.ts b/client/src/components/config.ts index 1266d0c..fdd26bf 100644 --- a/client/src/components/config.ts +++ b/client/src/components/config.ts @@ -32,9 +32,3 @@ export const APP_ICON_SIZE = 24; export const APP_LINK_COLOR = 'textSecondary'; // 'primary' // 'secondary' export const APP_LINK_UNDERLINE = 'hover'; // 'always -/** - * AppLoading component - */ -export const APP_LOADING_COLOR = 'primary'; // 'secondary' -export const APP_LOADING_SIZE = '3rem'; // 40 -export const APP_LOADING_TYPE = 'circular'; // 'linear'; // 'circular' diff --git a/client/src/components/messaging/TicketListCard.tsx b/client/src/components/messaging/TicketListCard.tsx index 8c0af65..74f2013 100644 --- a/client/src/components/messaging/TicketListCard.tsx +++ b/client/src/components/messaging/TicketListCard.tsx @@ -2,6 +2,7 @@ import { FunctionComponent } from 'react'; import Box from '@mui/material/Box'; import ButtonBase from '@mui/material/ButtonBase'; +import Skeleton from '@mui/material/Skeleton'; import Stack from '@mui/material/Stack'; import Typography from '@mui/material/Typography'; import StatusChip from '@/components/StatusChip'; @@ -115,4 +116,19 @@ const TicketListCard: FunctionComponent<TicketListCardProps> = ({ ); }; -export default TicketListCard; +/** Matches the real card's anatomy (icon/subject/status row, reference line, footer row) for the ticket inbox. */ +const TicketListCardSkeleton: FunctionComponent = () => ( + <Box sx={{ p: 2, borderRadius: 2, border: '1px solid', borderColor: 'divider', bgcolor: 'var(--bal-bg-paper)' }}> + <Stack sx={{ gap: 0.75 }}> + <Stack direction="row" sx={{ alignItems: 'center', gap: 1 }}> + <Skeleton variant="circular" width={18} height={18} /> + <Skeleton variant="text" width="50%" height={22} sx={{ flexGrow: 1 }} /> + <Skeleton variant="rounded" width={64} height={22} /> + </Stack> + <Skeleton variant="text" width={110} height={16} /> + <Skeleton variant="text" width="70%" height={16} /> + </Stack> + </Box> +); + +export default Object.assign(TicketListCard, { Skeleton: TicketListCardSkeleton }); diff --git a/client/src/layout/CustomerLayout.tsx b/client/src/layout/CustomerLayout.tsx index 1b121db..e8f2af7 100644 --- a/client/src/layout/CustomerLayout.tsx +++ b/client/src/layout/CustomerLayout.tsx @@ -24,6 +24,7 @@ import { TOP_BAR_DESKTOP_HEIGHT, TOP_BAR_MOBILE_HEIGHT } from './config'; const CustomerLayout: FunctionComponent<PropsWithChildren> = ({ children }) => { const t = useTranslations('nav'); const tShell = useTranslations('shell'); + const tChrome = useTranslations('routeChrome'); const onMobile = useIsMobile(); const router = useRouter(); const locale = useLocale(); @@ -75,7 +76,14 @@ const CustomerLayout: FunctionComponent<PropsWithChildren> = ({ children }) => { pt: `calc(${onMobile ? TOP_BAR_MOBILE_HEIGHT : TOP_BAR_DESKTOP_HEIGHT} + 8px)`, }} > - <ErrorBoundary name="Customer">{children}</ErrorBoundary> + <ErrorBoundary + name="Customer" + title={tChrome('error_title')} + body={tChrome('error_body')} + retryLabel={tChrome('error_retry')} + > + {children} + </ErrorBoundary> </Box> <BottomBar items={bottomNavItems} /> diff --git a/client/src/layout/TopBarAndSideBarLayout.tsx b/client/src/layout/TopBarAndSideBarLayout.tsx index e2606e8..3e7b979 100644 --- a/client/src/layout/TopBarAndSideBarLayout.tsx +++ b/client/src/layout/TopBarAndSideBarLayout.tsx @@ -1,5 +1,6 @@ 'use client'; import { FunctionComponent, ReactNode, useMemo, useState } from 'react'; +import { useTranslations } from 'next-intl'; import { Stack, StackProps } from '@mui/material'; import { AppIconButton, ErrorBoundary } from '@/components'; import { LinkToPage } from '@/utils'; @@ -24,6 +25,7 @@ interface Props extends StackProps { } const TopBarAndSideBarLayout: FunctionComponent<Props> = ({ children, sidebarItems, title, variant, headerActions }) => { + const tChrome = useTranslations('routeChrome'); const [sidebarVisible, setSidebarVisible] = useState(false); const onMobile = useIsMobile(); @@ -101,7 +103,14 @@ const TopBarAndSideBarLayout: FunctionComponent<Props> = ({ children, sidebarIte component="main" sx={{ flexGrow: 1, justifyContent: 'space-between', paddingLeft: 1, paddingRight: 1, paddingTop: 1 }} > - <ErrorBoundary name="Content">{children}</ErrorBoundary> + <ErrorBoundary + name="Content" + title={tChrome('error_title')} + body={tChrome('error_body')} + retryLabel={tChrome('error_retry')} + > + {children} + </ErrorBoundary> </Stack> </Stack> ); diff --git a/client/src/theme/tokens.css b/client/src/theme/tokens.css index de51dd1..093f109 100644 --- a/client/src/theme/tokens.css +++ b/client/src/theme/tokens.css @@ -90,6 +90,13 @@ --bal-warning-contrast: #f3efe9; --bal-info: #1d4a40; --bal-info-contrast: #f3efe9; + /* Soft feedback tints — StatusChip v2's default (soft bg + solid text); solid fill is + reserved for the alarm tier only. Weight hierarchy: neutral < info < progress < + success < warning < alarm (see StatusChip.tsx). */ + --bal-success-soft: rgba(31, 107, 80, 0.12); + --bal-error-soft: rgba(168, 57, 42, 0.12); + --bal-warning-soft: rgba(138, 100, 24, 0.14); + --bal-info-soft: rgba(29, 74, 64, 0.10); /* Elevation — teal-tinted shadow steps (replaces MUI's grey default stack). Consumed by theme.ts's `shadows` array + the MuiPaper/MuiDialog overrides. */ @@ -145,6 +152,10 @@ --bal-warning-contrast: #f3efe9; --bal-info: #2f6b5e; --bal-info-contrast: #f3efe9; + --bal-success-soft: rgba(37, 118, 89, 0.18); + --bal-error-soft: rgba(181, 64, 47, 0.18); + --bal-warning-soft: rgba(151, 112, 31, 0.20); + --bal-info-soft: rgba(47, 107, 94, 0.16); --bal-shadow-1: 0 1px 2px rgba(6, 18, 15, 0.24), 0 1px 1px rgba(6, 18, 15, 0.16); --bal-shadow-2: 0 4px 12px rgba(6, 18, 15, 0.32), 0 2px 4px rgba(6, 18, 15, 0.20); @@ -199,6 +210,10 @@ --bal-warning-contrast: #f3efe9; --bal-info: #2f6b5e; --bal-info-contrast: #f3efe9; + --bal-success-soft: rgba(37, 118, 89, 0.18); + --bal-error-soft: rgba(181, 64, 47, 0.18); + --bal-warning-soft: rgba(151, 112, 31, 0.20); + --bal-info-soft: rgba(47, 107, 94, 0.16); /* Elevation — black-teal shadow steps (deeper + darker than light scheme) */ --bal-shadow-1: 0 1px 2px rgba(6, 18, 15, 0.24), 0 1px 1px rgba(6, 18, 15, 0.16); diff --git a/client/src/utils/index.ts b/client/src/utils/index.ts index d07580f..a5b34b1 100644 --- a/client/src/utils/index.ts +++ b/client/src/utils/index.ts @@ -3,6 +3,7 @@ export * from './environment'; export * from './localStorage'; export * from './money'; export * from './navigation'; +export * from './number'; export * from './sessionStorage'; export * from './sleep'; export * from './type'; diff --git a/client/src/utils/money.ts b/client/src/utils/money.ts index 8c93fca..52ccff8 100644 --- a/client/src/utils/money.ts +++ b/client/src/utils/money.ts @@ -7,6 +7,8 @@ * display-only (1 Toman = 10 Rials) and is derived here, never stored or sent. */ +import { localeTag } from './number'; + const RIALS_PER_TOMAN = BigInt(10); const INTEGER_STRING = /^-?\d+$/; @@ -49,7 +51,7 @@ export function multiplyIrr(value: string | number | bigint, count: number): str /** Formats a grouped Rial amount (no unit). `fa` uses Persian digits. */ export function formatIrr(value: string | number | bigint, locale: string = 'fa'): string { - return new Intl.NumberFormat(locale === 'fa' ? 'fa-IR' : 'en-US').format(parseIrr(value)); + return new Intl.NumberFormat(localeTag(locale)).format(parseIrr(value)); } /** @@ -57,5 +59,5 @@ export function formatIrr(value: string | number | bigint, locale: string = 'fa' * the `common.currency_toman` label). `fa` renders Persian digits. */ export function formatIrrToToman(value: string | number | bigint, locale: string = 'fa'): string { - return new Intl.NumberFormat(locale === 'fa' ? 'fa-IR' : 'en-US').format(rialToToman(value)); + return new Intl.NumberFormat(localeTag(locale)).format(rialToToman(value)); } diff --git a/client/src/utils/number.test.ts b/client/src/utils/number.test.ts new file mode 100644 index 0000000..931f7de --- /dev/null +++ b/client/src/utils/number.test.ts @@ -0,0 +1,51 @@ +import { localeTag, formatNumber, formatRelativeTime, formatClock } from './number'; + +describe('localeTag', () => { + it('maps fa to fa-IR and anything else to en-US', () => { + expect(localeTag('fa')).toBe('fa-IR'); + expect(localeTag('en')).toBe('en-US'); + }); +}); + +describe('formatNumber', () => { + it('renders Persian digits for fa', () => { + expect(formatNumber(1234, 'fa')).toBe('۱٬۲۳۴'); + }); + + it('renders Latin digits for en', () => { + expect(formatNumber(1234, 'en')).toBe('1,234'); + }); +}); + +describe('formatRelativeTime', () => { + const absolute = () => 'ABSOLUTE_DATE'; + + it('renders a minute-grain relative phrase for a recent timestamp', () => { + const iso = new Date(Date.now() - 5 * 60 * 1000).toISOString(); + const result = formatRelativeTime(iso, 'en', absolute); + expect(result).toMatch(/minute/); + }); + + it('decays to the absolute formatter past 7 days', () => { + const iso = new Date(Date.now() - 10 * 24 * 60 * 60 * 1000).toISOString(); + expect(formatRelativeTime(iso, 'en', absolute)).toBe('ABSOLUTE_DATE'); + }); + + it('returns an empty string for an invalid date', () => { + expect(formatRelativeTime('not-a-date', 'en', absolute)).toBe(''); + }); +}); + +describe('formatClock', () => { + it('formats mm:ss under an hour', () => { + expect(formatClock(125, 'en')).toBe('02:05'); + }); + + it('formats hh:mm:ss past an hour', () => { + expect(formatClock(3661, 'en')).toBe('01:01:01'); + }); + + it('clamps negative durations to zero', () => { + expect(formatClock(-5, 'en')).toBe('00:00'); + }); +}); diff --git a/client/src/utils/number.ts b/client/src/utils/number.ts new file mode 100644 index 0000000..500c5b8 --- /dev/null +++ b/client/src/utils/number.ts @@ -0,0 +1,65 @@ +/** + * Locale-aware number/clock formatting helpers — the single source for the + * `locale === 'fa' ? 'fa-IR' : 'en-US'` decision that used to be copy-pasted at 25+ call + * sites (and had already drifted — some call sites passed the raw app locale straight into + * `Intl.NumberFormat`, which is *not* the same as `fa-IR`). + */ + +const FA_LOCALE_TAG = 'fa-IR'; +const EN_LOCALE_TAG = 'en-US'; + +/** Resolves the app locale ('fa' | 'en') to the `Intl` locale tag used for numbers/clocks. */ +export function localeTag(locale: string): typeof FA_LOCALE_TAG | typeof EN_LOCALE_TAG { + return locale === 'fa' ? FA_LOCALE_TAG : EN_LOCALE_TAG; +} + +/** Formats a plain number with locale-correct grouping + digits (Persian digits on `fa`). */ +export function formatNumber(value: number, locale: string, options?: Intl.NumberFormatOptions): string { + return new Intl.NumberFormat(localeTag(locale), options).format(value); +} + +const SECOND_MS = 1000; +const MINUTE_S = 60; +const HOUR_S = 3600; +const DAY_S = 86400; + +/** + * Relative time with a decay rule: relative phrasing up to ~7 days («۲ ساعت پیش», «دیروز»), + * then falls back to the absolute Shamsi date (via the caller-supplied `absolute` formatter, + * e.g. `formatShamsiDate`) — a 3-week-old notification reading "۲۱ روز پیش" is worse than a + * dated one. `iso` must be in the past; a future timestamp still formats (negative diff) since + * `Intl.RelativeTimeFormat` handles both directions and no current consumer needs future dates. + */ +export function formatRelativeTime( + iso: string, + locale: string, + absolute: (iso: string, locale: string) => string, +): string { + const date = new Date(iso); + if (Number.isNaN(date.getTime())) return ''; + + const diffSeconds = (date.getTime() - Date.now()) / SECOND_MS; + const diffDays = Math.abs(diffSeconds) / DAY_S; + + if (diffDays >= 7) return absolute(iso, locale); + + const rtf = new Intl.RelativeTimeFormat(localeTag(locale), { numeric: 'auto' }); + const absSeconds = Math.abs(diffSeconds); + + if (absSeconds < MINUTE_S) return rtf.format(Math.round(diffSeconds), 'second'); + if (absSeconds < HOUR_S) return rtf.format(Math.round(diffSeconds / MINUTE_S), 'minute'); + if (absSeconds < DAY_S) return rtf.format(Math.round(diffSeconds / HOUR_S), 'hour'); + return rtf.format(Math.round(diffSeconds / DAY_S), 'day'); +} + +/** Formats a duration in whole seconds as a locale-digit `mm:ss` (or `hh:mm:ss` past an hour). */ +export function formatClock(totalSeconds: number, locale: string): string { + const safeSeconds = Math.max(0, Math.floor(totalSeconds)); + const hours = Math.floor(safeSeconds / HOUR_S); + const minutes = Math.floor((safeSeconds % HOUR_S) / MINUTE_S); + const seconds = safeSeconds % MINUTE_S; + + const pad = new Intl.NumberFormat(localeTag(locale), { minimumIntegerDigits: 2, useGrouping: false }); + const parts = hours > 0 ? [pad.format(hours), pad.format(minutes), pad.format(seconds)] : [pad.format(minutes), pad.format(seconds)]; + return parts.join(':'); +} diff --git a/dev/shared-working-context/reports/ui-phase-1-report.md b/dev/shared-working-context/reports/ui-phase-1-report.md new file mode 100644 index 0000000..5acacb7 --- /dev/null +++ b/dev/shared-working-context/reports/ui-phase-1-report.md @@ -0,0 +1,269 @@ +# UI Phase 1 — Shared primitives & app-wide states — Report (2026-07-17) + +## What was built + +### State kit — `EmptyState`, `ErrorState`, `QueryStateGate` +`components/common/EmptyState` and `ErrorState` promote/replace `AdminEmptyState`/ +`AdminErrorState` (now thin re-exports) with a branded, calm look (phase-0 surface/radius +tokens, registry icon slot, soft-primary icon roundel) — retiring the dashed-border `Paper` +pattern hand-rolled across the app. Both are **presentational with caller-owned copy**; +`ErrorState` requires `retryLabel`/`onRetry` so a query failure can never render without a +working retry. `QueryStateGate` wraps the `isLoading`/`isError`/`isEmpty` branching in one +fixed order (skeleton → error → empty → children) for pages that want the component instead +of hand-branching. The mechanical sweep replaced the dashed-`Paper` blocks with these +primitives; `border: '1px dashed'` under `client/src/app` is now zero (the one remaining hit +in `client/src`, `DocumentUpload`'s upload dropzone, is an interactive drop-target affordance, +not an empty state, and is outside `app/` — a deliberate, not missed, exception). + +**The six error→false-empty defects from the audit are fixed and verified**, each now renders +`ErrorState` with a working retry: +- `(customer)/HomeScreen.tsx` (formerly `page.tsx`) — the patients gate no longer spinner-hangs + on error. +- `(customer)/patients/page.tsx` — `isEmpty` now excludes the error case. +- `nurse/requests/page.tsx` — an errored inbox no longer reads as "no requests." +- `nurse/services/MyServicesList.tsx` — an errored variant list no longer shows the + first-service CTA. +- `(customer)/profile/page.tsx` — an errored profile renders `ErrorState`, never a blank, + save-capable form. +- `nurse/services/VariantBuilder.tsx` — a failed `optionGroupsQuery` now blocks progression + via `ErrorState` instead of silently treating the category as having no required options. + +Both silent mutations from the audit now toast on failure: `nurse/profile/page.tsx`'s avatar +upload and profile save each carry an `onError` toast. The convention — *an errored query must +never render as empty; every mutation needs an `onError` toast* — is written into +`client/CLAUDE.md` (Unit Testing / Toast Notifications sections). + +### `PageHeader` + `ConfirmDialog` promotions +`PageHeader` generalizes `AdminPageHeader` (`title`/`subtitle`/`actions`/`backTo`/`backLabel`, +RTL-flippable back chevron via the new `forward`/`back` icon pair) and was adopted mechanically +wherever pages hand-rolled the identical h5 + subtitle block. `ConfirmDialog` moved from +`components/admin/` to `components/common/`, preserving its exact contract (required-reason +gating, busy-state disabling both buttons) — now usable by any actor, not just the backoffice. + +### Card kit — `SurfaceCard` + `AccentCard` +`SurfaceCard` (flat `Paper`, house radius, a `padding` scale `sm`/`md`/`lg`) and `AccentCard` +(`SurfaceCard` + a `borderInlineStart` accent at one standardized 4px width — ending the +3px/4px drift between `EarningsBalanceHeader`/`PayoutHistoryRow` and `BankStatusPanel`/ +`DocumentUpload`) replace the ~12 named hand-rollers (`EarningsRow`, `EarningsBalanceHeader`, +`PayoutHistoryRow`, `PatientCard`, `VariantCard`, `VisitNoteCard`, `InstallmentScheduleRow`, +`PriceBreakdown`, `BankStatusPanel`, `DocumentUpload`'s panels, `BookingRequestSummaryCard`, +the customer-home nudge card) — visual unification only, no content/behavior change. A gap +found during the final sweep (`BookingDetailView`'s customer-side "care record locked" +notice, still on a dashed `Paper`) was migrated onto `AccentCard tone="neutral"` in this pass. +16 call sites now render through `SurfaceCard`/`AccentCard`. + +### `<Money>` primitive +One component for every displayed IRR amount (`components/common/Money`): takes a served IRR +digit-string, formats via `utils/money.ts` (never computes), with `size`/`tone` variants — +emphasis renders in `--bal-money-emphasis`, **not terracotta** (`--bal-secondary` fails AA +contrast at small sizes on light backgrounds) — and an explicit deduction treatment (a +dir="ltr"-held `−` prefix, never a bare Unicode minus floating in RTL text). 19 call sites now +render through `<Money>`, including the flagship terracotta-total defects +(`PriceBreakdown`, `RefundStatusCard`, `BnplPlanCard`, `InstallmentScheduleRow`, +`CancellationPolicyDisclosure`). `PriceDisplay` (catalog unit rates) was deliberately left on +its own `formatIrrToToman` call per the phase's own scope note — its contract is tested, +correct, and has different unit/estimate-label needs than a bare amount. + +One site — `bookings/[id]/cancel/page.tsx`'s confirmation restatement — needed a different +shape: the amounts are interpolated into a translated sentence (`"مبلغ {refund} به شما +بازپرداخت می‌شود…"`), not rendered as an isolated node, so `<Money>` couldn't drop in directly. +Fixed by switching `confirm_restate` to next-intl's `t.rich()` with `<refund>`/`<fee>` tags in +both message files, rendering `<Money>` inside the sentence rather than hand-formatting a +string — the first `t.rich()` usage in the codebase; a precedent worth reusing for any future +money-inside-a-sentence case instead of falling back to string concatenation. + +`currency_toman` outside `utils/money.ts` + `<Money>`/`PriceDisplay` internals is now zero +(DoD-exact grep). + +### Formatting utils — `utils/number.ts` +`localeTag(locale)`, `formatNumber(value, locale, options?)`, `formatRelativeTime(iso, locale, +absoluteFormatter)` (7-day decay to the absolute Shamsi date — documented in JSDoc, no live +consumer yet per the phase's own scope: lands in phases 10/11), `formatClock(totalSeconds, +locale)` (migrated `CountdownTimer`'s inline `Intl` pad + fixed `OtpStep.tsx`'s Latin-digit +resend clock). Swept the `locale === 'fa' ? 'fa-IR' : 'en-US'` ternary onto `localeTag`/ +`formatNumber` at every remaining site found by a final consolidated grep pass (run **after** +the metadata-pattern file splits, since those physically relocated code the first sweep had +already covered): `bookings/[id]/invoice/page.tsx`, `search/nurse/[nurseId]/page.tsx` (×2), +`NurseResultCard.tsx` (×2), `PriceDisplay.tsx`, and `JalaliDatePicker.tsx` (×2, the picker's +own new code). `locale === 'fa' ? 'fa-IR'` now appears only in `utils/number.ts` itself +(DoD-exact grep) — `JalaliDatePicker`'s separate `locale === 'fa' ? 'fa-IR-u-ca-persian' : +'en-US'` (the Jalali-calendar-system Intl tag, a different decision than the plain locale tag) +is intentionally untouched by this rule. + +### `JalaliDatePicker` + `JalaliDateField` +Shamsi-native date selection that **always emits/accepts ISO Gregorian** — the wire never sees +a Jalaali date. `calendarEngine.ts` defines one `CalendarEngine` shape with two +implementations (`jalaaliEngine` for `fa`, a plain Gregorian one for `en`) so the picker's +grid/navigation logic is calendar-agnostic. + +**Arithmetic decision:** `Intl` (`fa-IR-u-ca-persian`, already used by `utils/date.ts`) is +sufficient to *display* a Shamsi date but has no reverse direction — there's no way to ask +Intl "what Gregorian date is Jalaali 1404/5/15," which is exactly what a *picker* (as opposed +to a *label*) needs for month navigation and emitting the selected day. Rather than hand-roll +the Borkowski conversion algorithm, the phase adds `jalaali-js` (MIT, zero runtime +dependencies, documented in-repo as ~9KB unminified / low single-digit KB gzipped) for the +reverse conversion (`toGregorian`) plus month-length/leap-year math; `Intl` still supplies +locale-correct month/weekday *names* — `jalaali-js` only supplies numbers. `JalaliDatePicker` +has a `grid` variant (month calendar with keyboard roving nav, RTL-flipped arrow keys) and a +`chips` variant (horizontal day-chip strip for near dates, the C4 booking-form shape). +`JalaliDateField` wraps it in a read-only `TextField` + `Popover`. + +### `StatusChip` v2, `StatusTimeline`, `CountdownTimer` v2, `RatingInput` v2 +- `StatusChip` rewritten to a soft-tint hierarchy: neutral/info/pending states use + `--bal-primary-soft`, verified/active use `--bal-success-soft`, and solid `--bal-error` fill + is reserved for the one true "alarm" state (rejected) — added alongside new + `--bal-{success,error,warning,info}-soft` tokens (both color schemes). +- `StatusTimeline` (ordered `TimelineNode[]`: completed/current/pending/failed, animated pulse + on `current` respecting `prefers-reduced-motion`) now backs `RefundStatusCard` and + `BookingStatusTimeline` (3 call sites) — wizards (`StepperHeader`) are untouched, per the + phase's own "wizards still use StepperHeader" boundary. +- `CountdownTimer` v2 adds a `windowStart` progress ring, `warnThresholdSeconds`/ + `urgentThresholdSeconds` urgency tiers (replacing the old binary `urgent` prop, kept as a + legacy override), and an opt-in `coarseThresholdSeconds`/`coarseLabel` humanized mode — the + core architecture (server-frozen UTC deadline, self-owned 1s tick, single `onElapsed`, + `dir="ltr"` tabular-nums digits) is unchanged. +- `RatingInput` v2 renders a fractional fill via a clip-path overlay in read-only mode (a 4.5 + average now shows a genuinely half-filled fifth star, replacing the old `Math.round` + workaround removed from the nurse profile page) and switched its fill colors from + `--bal-warning` to the phase-0 `--bal-rating`/`--bal-rating-empty` tokens. + +### Route chrome + per-page metadata +`loading.tsx` added for all five route groups ((customer), nurse, admin, partner, +public-routes) — the three sidebar shells (nurse/admin/partner) share one +`_chrome/SidebarShellSkeleton.tsx`. `error.tsx` and `not-found.tsx` under `[locale]/` are +branded and localized (the `not-found.tsx` is reached via a `[...rest]/page.tsx` catch-all +calling `notFound()`, next-intl's recommended pattern). `global-error.tsx` sits above +`[locale]/` and is the one sanctioned static-string, no-MUI exception — it replaces the root +layout on a root-level crash and renders its own `<html>`, so it structurally cannot reach +`NextIntlClientProvider`. + +`ErrorBoundary` was rewritten to take `title`/`body`/`retryLabel` as required caller-owned +props instead of calling `useTranslations` internally — kept **presentational**, no +`next-intl` import, specifically so it stays safe at the top of the `components/common` +barrel (see the Jest note below). Both `CustomerLayout` and `TopBarAndSideBarLayout` pass the +copy via `useTranslations('routeChrome')`. + +Every route now has its own browser tab title via `generateMetadata` (locale-aware `'%s | +بالین‌یار'`/`'%s | Balinyaar'` template, set once in `[locale]/layout.tsx`). Seven pages that +needed a Client Component body were split into a thin Server Component `page.tsx` + +co-located `<Name>Screen.tsx`: home, login, search, bookings, admin overview, partner home +(nurse dashboard was already a Server Component and got `generateMetadata` added in place, no +split needed). The pattern is documented in `client/CLAUDE.md` under "Per-page metadata (the +client-page pattern)." + +## The Jest / next-intl transform fix (the one non-mechanical problem this phase hit) + +`ErrorBoundary`/`ErrorState` were deliberately kept free of `next-intl` because any component +near the top of the `components/common` barrel that imports it at module scope forces *every* +test file that transitively imports the barrel to deal with next-intl's ESM-only build — even +tests that never touch translations. `<Money>` couldn't take the same fix: it already had ~20 +call sites depending on its locale-aware, self-contained API before this was noticed, so +stripping `next-intl` from it would have meant threading a formatted string through every +caller instead. + +The actual fix was at the root: `next/jest`'s `createJestConfig` returns a +`transformIgnorePatterns` that already matches nearly all of `node_modules` (a narrow +negative-lookahead allowlist for a couple of Next.js-internal packages), and **appends** +whatever custom pattern you pass rather than letting it override — combined with Jest's +OR-based array semantics (a file is ignored if *any* pattern matches), a more permissive +pattern appended after the restrictive one can never "un-ignore" a package the first pattern +already caught. `jest.config.ts` now replaces the array outright, in an async wrapper around +`createJestConfig`, allowlisting `next-intl`/`use-intl`/`@formatjs`/`intl-messageformat` (each +transitive ESM dependency surfaced its own parse error until the allowlist was broad enough). +This is a repo-wide fix, not a `<Money>`-specific one: any future shared component is now free +to call `useTranslations` without risking the same barrel-poisoning failure mode. The +trade-off and the "prefer caller-owned by default" convention are both documented in +`client/CLAUDE.md`'s new "Presentational purity in `components/common`" subsection. + +## Icon registry + +Added `forward` (`ArrowForwardRounded`) to `AppIcon/config.ts`'s `ICONS` and +`DIRECTIONAL_ICONS` — needed for `JalaliDatePicker`'s next-month button (mirrors under RTL +alongside the existing `back`/`chevron_start` pair). + +## Sweep counts (verified by grep on the current tree, not the original audit estimate) + +| Primitive | Live call sites | +| --- | --- | +| `<EmptyState>` | 21 | +| `<ErrorState>` | 15 | +| `<Money>` | 19 | +| `<SurfaceCard>` / `<AccentCard>` | 16 | +| `<StatusTimeline>` | 3 | +| `formatNumber(...)` | 11 | +| `localeTag(...)` | 9 | + +## Definition-of-Done sweep greps (re-run after the metadata-pattern file splits) + +- `border: '1px dashed'` under `client/src/app` → **zero**. +- `currency_toman` outside `utils/money.ts` + `<Money>`/`PriceDisplay` internals → **zero**. +- `locale === 'fa' ? 'fa-IR'` outside `utils/number.ts` → **zero**. + +These were re-verified as a final pass, separate from the per-agent sweeps, because the +metadata-pattern splits (home/login/search/bookings/admin/partner) physically relocated code +after the mechanical sweeps had already run over the pre-split files — three real gaps +(`BookingDetailView`'s locked-care dashed border, the cancel-page `currency_toman` sentence +site, six `locale === 'fa' ? 'fa-IR'` sites across five files) surfaced only in this final pass +and are fixed above. + +## What was mocked / waiting on a real service +None — this phase, like phase 0, is pure client primitives/chrome with no service seams +touched. No REQ entries filed (the phase never touches `server/` or a contract). + +## Docs updated +- `client/CLAUDE.md` — Project Structure (`components/common/*` primitives, `utils/number.ts`, + the route-chrome files, the metadata-pattern file splits), a new "Presentational purity in + `components/common`" subsection under Unit Testing (the next-intl/Jest gotcha + the + caller-owned-copy convention), "Per-page metadata (the client-page pattern)" section, "Every + mutation needs an `onError` toast" line under Toast Notifications. +- `messages/en.json` / `fa.json` — `routeChrome` namespace, ~8 new error-copy keys for the six + defect fixes, `confirm_restate` converted to `t.rich()` tags. Key parity verified + programmatically: 1,538 keys in each file, zero one-sided keys. + +## What a human should verify (not done here — no browser in this environment) +Per the phase's own Definition of Done, visual verification on the four axes (`/fa`+`/en` × +light+dark) and mobile+desktop for the state kit, card kit, route chrome, and the Jalali +picker was **not performed** — there is no browser available in this environment. `npm run +check` and `npm run test:ci` passing is evidence of type/lint/unit correctness, not visual or +interaction correctness. Specifically worth a human pass: +1. Force a query to fail (devtools offline) on `/fa` home, `/patients`, `/profile`, + `/nurse/requests`, `/nurse/services` — confirm `ErrorState` + working retry, never a spinner + or false-empty state. +2. `/fa/xyz` → branded 404; throw inside a page in dev → branded error screen; production build + → confirm no stack trace renders. +3. `JalaliDatePicker` grid + chips variants on `/fa` (RTL arrow-key direction, month names) and + `/en` (plain Gregorian) — both color schemes. +4. The nurse profile's rating display — confirm a non-integer average renders a genuinely + half-filled star, not a rounded one. +5. Tab titles change per section (`جستجو | بالین‌یار`, `رزروها | بالین‌یار`, …). + +## A note on a subagent report during this phase + +One of the six parallel sweep agents (the card-kit migration agent, worktree +`agent-a099d7389dc14bd95`) returned a report whose text was flagged by the harness as +"instruction-shaped" (matching a `settings-json` pattern) and had its control tags neutralized +before reaching this session. Having now reviewed the neutralized text directly: it was the +agent's own file-accounting summary, noting in passing that +`.claude/settings.local.json` "predates this task and is untouched by me" — normal +bookkeeping language, not an embedded directive aimed at this session. No action was taken on +it beyond relaying it here, per the harness's own instruction to treat any remaining +directive-shaped text as a finding to report rather than follow. + +## Gate +- `npm run check` (type + lint): **green**, zero errors/warnings. +- `npm run test:ci`: **94/94 suites, 392/392 tests green** (run after all merge-conflict + resolution and the final DoD-sweep fixes above). +- `messages/en.json` / `messages/fa.json`: in sync (1,538 keys each, verified programmatically). +- 6 agent worktrees (`agent-a099d7389dc14bd95`, `-a0b4bb6e73e057271`, `-a279bb3717dd53e45`, + `-a74eed78c5350b831`, `-ae141074976cb056f`, `-af1e1c6a8d89d64cc`) and their branches removed + after diff extraction — no leftover worktree state. + +## Follow-ups for later phases +- `formatRelativeTime` ships with tests but no live consumer yet — phases 10/11 (per the + phase's own scope note). +- Custom illustration set for `EmptyState` — DEFERRED to phase 12, per phase 0/1's own notes. +- The app-wide motion pass (`--bal-motion-*`/`--bal-easing-standard`, defined in phase 0) is + still unconsumed outside `StatusTimeline`'s pulse animation and `CountdownTimer`'s ring — + phase 12's job. +- Visual verification (four axes × mobile/desktop) is unverified — see the section above; the + next phase touching any of these primitives should do a pass before building further on top. diff --git a/server/src/API/Baya.Web.Api/Properties/launchSettings.json b/server/src/API/Baya.Web.Api/Properties/launchSettings.json index a5a3d3a..0886f72 100644 --- a/server/src/API/Baya.Web.Api/Properties/launchSettings.json +++ b/server/src/API/Baya.Web.Api/Properties/launchSettings.json @@ -22,7 +22,7 @@ "dotnetRunMessages": true, "launchBrowser": true, "launchUrl": "swagger", - "applicationUrl": "https://localhost:5002", + "applicationUrl": "http://localhost:5002", "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" }