ui phase 6

This commit is contained in:
hamid
2026-07-19 09:49:25 +03:30
parent 4c70d8e424
commit a438edeeaa
54 changed files with 1766 additions and 475 deletions
+24 -21
View File
@@ -138,26 +138,26 @@ client/
│ │ │ │ ├── [id]/page.tsx # /bookings/[id] — f8 customer booking detail (BookingDetailView viewerRole="customer") + f10 cancel/refund entry (CustomerBookingActions) + f13 review entry (LeaveReviewCta: on a completed/closed booking, «ثبت نظر» → review page, flips to a passive "under review" affordance once reviewed — reuses the cached booking + my-review query) │ │ │ │ ├── [id]/page.tsx # /bookings/[id] — f8 customer booking detail (BookingDetailView viewerRole="customer") + f10 cancel/refund entry (CustomerBookingActions) + f13 review entry (LeaveReviewCta: on a completed/closed booking, «ثبت نظر» → review page, flips to a passive "under review" affordance once reviewed — reuses the cached booking + my-review query)
│ │ │ │ ├── request/page.tsx # /bookings/request — C4 request form, ui-phase-5 redesign: sticky nurse-identity bar (avatar/name/rating/TrustBadge/gender) + a «چه اتفاقی می‌افتد؟» 3-step strip (reuses C5's StepperHeader); JalaliDateIntentPicker date + tappable morning/afternoon/evening/custom time-window chips (kills the end≤start error class); touched-on-blur field errors + a disabled-CTA "what's missing" caption (the old submit-gated `attempted` dead code is gone); a compact address row with a «تغییر» toggle back to the select (no more fake-map preview); accepts `patient_id`/`address_id` recovery params from C5's re-request handoff │ │ │ │ ├── request/page.tsx # /bookings/request — C4 request form, ui-phase-5 redesign: sticky nurse-identity bar (avatar/name/rating/TrustBadge/gender) + a «چه اتفاقی می‌افتد؟» 3-step strip (reuses C5's StepperHeader); JalaliDateIntentPicker date + tappable morning/afternoon/evening/custom time-window chips (kills the end≤start error class); touched-on-blur field errors + a disabled-CTA "what's missing" caption (the old submit-gated `attempted` dead code is gone); a compact address row with a «تغییر» toggle back to the select (no more fake-map preview); accepts `patient_id`/`address_id` recovery params from C5's re-request handoff
│ │ │ │ ├── request/[id]/page.tsx # /bookings/request/[id] — C5 tracker, ui-phase-5 redesign: CountdownTimer progress ring (`windowStart=createdAt`) + humanized «حدود N ساعت/دقیقه» framing above the coarse threshold; the cancel-request dialog is now `ConfirmDialog` with the destructive/dismiss labels fixed («نه، نگه دار» vs «بله، انصراف از درخواست» — the old defect had them swapped); rejected/expired terminal cards offer «درخواست دوباره با زمان دیگر» (reopens C4 prefilled) + «پرستاران مشابه» (region+gender-carried search), gated by a keyword heuristic over the freeform `nurseRejectionReason` (REQ-044 proposes a real code); converted → booking deep-link (bookingId, REQ-017) │ │ │ │ ├── request/[id]/page.tsx # /bookings/request/[id] — C5 tracker, ui-phase-5 redesign: CountdownTimer progress ring (`windowStart=createdAt`) + humanized «حدود N ساعت/دقیقه» framing above the coarse threshold; the cancel-request dialog is now `ConfirmDialog` with the destructive/dismiss labels fixed («نه، نگه دار» vs «بله، انصراف از درخواست» — the old defect had them swapped); rejected/expired terminal cards offer «درخواست دوباره با زمان دیگر» (reopens C4 prefilled) + «پرستاران مشابه» (region+gender-carried search), gated by a keyword heuristic over the freeform `nurseRejectionReason` (REQ-044 proposes a real code); converted → booking deep-link (bookingId, REQ-017)
│ │ │ │ ├── [id]/invoice/page.tsx # /bookings/[id]/invoice — f9 commission invoice (b11): number + Shamsi date, reconciling lines with the VAT-on-commission line, read-only مودیان state; pdfUrl download or window.print receipt │ │ │ │ ├── [id]/invoice/page.tsx # /bookings/[id]/invoice — f9 commission invoice (b11), ui-phase-6 fiscal-grade pass: number + Shamsi date + buyer/service/visit-date recap (client-joined off useCustomerProfile + useBookingDetail) + payment method/transaction reference + seller fiscal-identity block (all REQ-049), reconciling lines with the VAT-on-commission line, read-only مودیان state; A4 `@page` print stylesheet + print-only footer; pdfUrl download or window.print receipt
│ │ │ │ ├── [id]/cancel/page.tsx # /bookings/[id]/cancel — f10 cancellation flow, ui-phase-5 added off-ramps above the disclosure («تغییر زمان»/«گفتگو با پشتیبانی» → ContactSupportDialog, category coordination/support) + a one-line nurse-impact note; the reason select no longer pre-defaults to 'changed_mind' (empty until chosen, confirm gated); CancellationPolicyDisclosure unchanged │ │ │ │ ├── [id]/cancel/page.tsx # /bookings/[id]/cancel — f10 cancellation flow, ui-phase-5 added off-ramps above the disclosure («تغییر زمان»/«گفتگو با پشتیبانی» → ContactSupportDialog, category coordination/support) + a one-line nurse-impact note; the reason select no longer pre-defaults to 'changed_mind' (empty until chosen, confirm gated); CancellationPolicyDisclosure unchanged
│ │ │ │ ├── [id]/refund_status/page.tsx # /bookings/[id]/refund_status — f10 customer refund status (RefundStatusCard): pending → on-its-way → completed, BNPL ~710-day ETA, failed=contact-support; polls only while non-terminal │ │ │ │ ├── [id]/refund_status/page.tsx # /bookings/[id]/refund_status — f10 customer refund status (RefundStatusCard): pending → on-its-way → completed, BNPL ~710-day ETA, failed=contact-support; polls only while non-terminal
│ │ │ │ ├── [id]/review/page.tsx # /bookings/[id]/review — f13 leave-a-review (b14), ui-phase-5 added a context recap (service/Shamsi date/nurse avatar off the cached booking) + the moderation-expectation note up front (not only post-submit); `useMyReviewForBooking` now gated `{ enabled: reviewable }` like the detail page; RatingInput + body + ReviewTagSelector; gated on completed/closed + server can_review + 1:1; on submit → persistent "under review" (pending_moderation, never public here); already-reviewed shows the review state, never a 2nd form (services/reviews) │ │ │ │ ├── [id]/review/page.tsx # /bookings/[id]/review — f13 leave-a-review (b14), ui-phase-5 added a context recap (service/Shamsi date/nurse avatar off the cached booking) + the moderation-expectation note up front (not only post-submit); `useMyReviewForBooking` now gated `{ enabled: reviewable }` like the detail page; RatingInput + body + ReviewTagSelector; gated on completed/closed + server can_review + 1:1; on submit → persistent "under review" (pending_moderation, never public here); already-reviewed shows the review state, never a 2nd form (services/reviews)
│ │ │ │ └── checkout/ # f9 checkout flow (C5 accept CTA lands on page.tsx with ?request_id=) │ │ │ │ └── checkout/ # f9 checkout flow (C5 accept CTA lands on page.tsx with ?request_id=), ui-phase-6 trust-forward redesign
│ │ │ │ ├── page.tsx # C6 خلاصه و پرداخت — acceptance badge, served reconciling breakdown (PriceBreakdown), EscrowNotice, payment-window countdown, «ادامه پرداخت ←» (idempotency-key-per-attempt) + «پرداخت اقساطی» → f11 BNPL wizard │ │ │ │ ├── page.tsx # C6 خلاصه و پرداخت — identity moment (nurse avatar+TrustBadge, REQ-046), prominent `<Money size="xl">` total, served reconciling breakdown (PriceBreakdown), EscrowExplainer, safe-area-aware sticky pay bar (StickyActionBar: total+CTA+secure-gateway trust line), both CTAs disable during initiate, explicit «بازگشت به درخواست» text link, un-baked continue arrow (endIcon="forward")
│ │ │ │ ├── return/page.tsx # return-from-gateway — confirm return → pending-callback poll (backoff, stops on terminal) → succeeded (invalidate + hand off) / failed retry / window-expired (the payment mock-gateway harness was removed in refinement-phase-4 when USE_PAYMENT_MOCK flipped; on the real path the PSP redirectUrl is absolute) │ │ │ │ ├── return/page.tsx # return-from-gateway — confirm return → staged 2-node pending progress (StatusTimeline «بازگشت از درگاه ✓ → در انتظار تایید بانک» + duration hint, replacing the old spinner+chip+title stack) → succeeded (invalidate + hand off) / failed retry / window-expired, all via the shared PaymentStateCard (on the real path the PSP redirectUrl is absolute)
│ │ │ │ ├── confirmation/page.tsx # payment success — «مشاهده رزرو» (booking detail) + «دانلود فاکتور» (invoice); REUSED by f11 (?method=bnpl adds «پرداخت‌شده با اقساط» — a settled BNPL order is a card payment net-of-fee) │ │ │ │ ├── confirmation/page.tsx # payment success rebuilt as a receipt (ui-phase-6): copyable LTR کد پیگیری + copy-to-clipboard, Shamsi paid-at, method, booking reference (all REQ-046, hidden gracefully on the real path), EscrowExplainer, a "what happens next" 2-step StatusTimeline, real loading/error states (never a silently missing amount); «مشاهده رزرو» + «دانلود فاکتور»; REUSED by f11 (?method=bnpl reads the settled BnplOrderStatus instead of the payment outcome)
│ │ │ │ └── bnpl/ # f11 BNPL installment checkout (the alternate branch off C6, reached with ?request_id=) │ │ │ │ └── bnpl/ # f11 BNPL installment checkout (the alternate branch off C6, reached with ?request_id=), ui-phase-6 honesty + polish pass
│ │ │ │ ├── page.tsx # D1→D4 stateful wizard (StepperHeader): D1 method/provider · D2 plan · D3 eligibility · D4 schedule+contract → provider handoff; card fall-back → C6 everywhere │ │ │ │ ├── page.tsx # D1→D4 stateful wizard (StepperHeader): D1 method/provider · D2 plan · D3 eligibility · D4 schedule+contract → provider handoff; card fall-back → C6 everywhere; terminal cards via the shared PaymentStateCard
│ │ │ │ ├── MethodStep.tsx # D1 روش پرداخت — payable amount + full-card option + provider option cards (from useBnplOptions, never hardcoded) │ │ │ │ ├── MethodStep.tsx # D1 روش پرداخت — payable amount + full-card option + provider option cards (from useBnplOptions, never hardcoded); provider mark via BnplProviderLogo (replaces the two-letter glyph stand-in)
│ │ │ │ ├── PlanStep.tsx # D2 انتخاب طرح — single-select BnplPlanCard group (served monthly/down-payment) │ │ │ │ ├── PlanStep.tsx # D2 انتخاب طرح — single-select BnplPlanCard group; the «مبلغ کل» header only renders once a plan is selected and **names** it («مبلغ کل با طرح {plan}») + the fee delta, never a silent plans[0] default
│ │ │ │ ├── EligibilityStep.tsx # D3 اعتبارسنجی — کد ملی + prefilled موبایل + consent gate → useCheckEligibility → approved(ceiling)/declined(+card) │ │ │ │ ├── EligibilityStep.tsx # D3 اعتبارسنجی — کد ملی + prefilled موبایل (readOnly presentation, not disabled) + consent gate → useCheckEligibility (in-progress spinner + «در حال استعلام اعتبار…» label) → approved(ceiling)/declined(+card)
│ │ │ │ ├── ScheduleStep.tsx # D4 تایید طرح و قرارداد — served repayment rows (InstallmentScheduleRow) + ownership note + contract-consent gate → useIssueBnplToken handoff │ │ │ │ ├── ScheduleStep.tsx # D4 تایید طرح و قرارداد — served repayment rows (InstallmentScheduleRow) + ownership note + contract-consent gate → useIssueBnplToken handoff
│ │ │ │ ├── gateway/page.tsx # dev provider-handoff harness (TEST HARNESS; mock redirectUrl points here) → return │ │ │ │ ├── gateway/page.tsx # dev provider-handoff harness (TEST HARNESS; mock redirectUrl points here) → return; env-gated `notFound()` outside `development` (ui-phase-6 — was reachable in production builds)
│ │ │ │ └── return/page.tsx # settle (useAcceptBnplSchedule) → invalidate → reused confirmation (?method=bnpl) / retry / card │ │ │ │ └── return/page.tsx # settle (useAcceptBnplSchedule) → invalidate → reused confirmation (?method=bnpl) / retry / card; the invalid-link CTA label now matches its destination (ui-phase-6 fix)
│ │ │ ├── patients/page.tsx # /patients — E1 list/CRUD (add/edit dialog reusing PatientForm, soft-archive); tapping a PatientCard opens the E2 record (f13) │ │ │ ├── patients/page.tsx # /patients — E1 list/CRUD (add/edit dialog reusing PatientForm, soft-archive); tapping a PatientCard opens the E2 record (f13)
│ │ │ ├── patients/[id]/record/page.tsx # /patients/[id]/record — f13 E2 care-record viewer (b14): reused PatientHeader + ownership banner + 4 tabs (داروها/روتین/سوابق/وظایف). Family-owned & patient-scoped — customer edits medications/routine/tasks (useUpdateCareRecord); سوابق = read-only nurse visit-note history (VisitNoteCard); access-denied is a first-class non-leaking state gated BEFORE any clinical fetch (services/patientRecords) │ │ │ ├── patients/[id]/record/page.tsx # /patients/[id]/record — f13 E2 care-record viewer (b14): reused PatientHeader + ownership banner + 4 tabs (داروها/روتین/سوابق/وظایف). Family-owned & patient-scoped — customer edits medications/routine/tasks (useUpdateCareRecord); سوابق = read-only nurse visit-note history (VisitNoteCard); access-denied is a first-class non-leaking state gated BEFORE any clinical fetch (services/patientRecords)
│ │ │ ├── addresses/page.tsx # /addresses — F3 address book (cascading region dropdowns + map-pin picker, set-primary) │ │ │ ├── addresses/page.tsx # /addresses — F3 address book (cascading region dropdowns + map-pin picker, set-primary)
│ │ │ ├── wallet/ # /wallet — f11 D5 پیگیری اقساط (page.tsx = thin shell → WalletInstallments.tsx: provider-reported outstanding balance + due list + early-pay provider hand-off; self-contained for f12 nurse-earnings later) │ │ │ ├── wallet/ # /wallet — ui-phase-6 rebuilt into the customer money hub (page.tsx → WalletScreen.tsx: 4 Tabs, one shared `CONTENT_MAX_WIDTH`, no local width override): «پرداخت‌ها» (WalletPaymentHistory — card `usePaymentHistory` (REQ-047) + BNPL down-payment rows merged via the co-located `useWalletHistoryRows`), «اقساط» (WalletInstallments — the unchanged f11 D5 provider-reported outstanding balance + due list + early-pay hand-off, now section-only, no own heading/width), «استردادها» (WalletRefunds — `useMyRefunds`, REQ-048, one RefundStatusCard per refund), «رسیدها» (WalletReceipts — client-derived invoice deep-links off the same merged history rows, no endpoint)
│ │ │ ├── profile/page.tsx # /profile — customer profile + emergency contact (no national-ID) │ │ │ ├── profile/page.tsx # /profile — customer profile + emergency contact (no national-ID)
│ │ │ ├── support/tickets/ # /support/tickets — f14 "My Tickets" inbox (TicketInboxScreen role="customer") ↔ support/tickets/[id]/page.tsx thread (TicketThreadScreen); thin role-passing wrappers over @/components/messaging │ │ │ ├── support/tickets/ # /support/tickets — f14 "My Tickets" inbox (TicketInboxScreen role="customer") ↔ support/tickets/[id]/page.tsx thread (TicketThreadScreen); thin role-passing wrappers over @/components/messaging
│ │ │ └── notifications/page.tsx # /notifications — f14 notification center (NotificationCenter role="customer"); the TopBar bell deep-links here │ │ │ └── notifications/page.tsx # /notifications — f14 notification center (NotificationCenter role="customer"); the TopBar bell deep-links here
@@ -230,7 +230,7 @@ client/
│ │ ├── ConfirmDialog/ # promoted from admin/ — required-reason gating + busy-disable, now usable by any actor │ │ ├── ConfirmDialog/ # promoted from admin/ — required-reason gating + busy-disable, now usable by any actor
│ │ ├── SurfaceCard/ # flat Paper wrapper, padding: 'sm'|'md'|'lg' │ │ ├── SurfaceCard/ # flat Paper wrapper, padding: 'sm'|'md'|'lg'
│ │ ├── AccentCard/ # SurfaceCard + tone → 4px borderInlineStart accent (primary/secondary/success/error/warning/info/trust/neutral) │ │ ├── AccentCard/ # SurfaceCard + tone → 4px borderInlineStart accent (primary/secondary/success/error/warning/info/trust/neutral)
│ │ ├── Money/ # <Money amountIrr size tone deduction hideUnit strikethrough> — the one money-rendering primitive (wraps utils/money.ts); imports next-intl (see jest.config.ts transformIgnorePatterns note below) │ │ ├── Money/ # <Money amountIrr size tone deduction hideUnit strikethrough> — the one money-rendering primitive (wraps utils/money.ts); size gained `xl` (h4) in ui-phase-6 for the checkout/confirmation prominent-total hero; imports next-intl (see jest.config.ts transformIgnorePatterns note below)
│ │ ├── StatusTimeline/ # ordered TimelineNode[] (completed/current/pending/failed) with animated pulse on current (respects prefers-reduced-motion) │ │ ├── StatusTimeline/ # ordered TimelineNode[] (completed/current/pending/failed) with animated pulse on current (respects prefers-reduced-motion)
│ │ ├── JalaliDatePicker/ # calendarEngine.ts (jalaali-js-backed Jalali↔Gregorian) + grid/chips variants (ui-phase-4: chips variant takes optional `todayLabel`/`tomorrowLabel` overrides — the C1 «امروز»/«فردا» date-intent strip), RTL-aware keyboard nav │ │ ├── JalaliDatePicker/ # calendarEngine.ts (jalaali-js-backed Jalali↔Gregorian) + grid/chips variants (ui-phase-4: chips variant takes optional `todayLabel`/`tomorrowLabel` overrides — the C1 «امروز»/«فردا» date-intent strip), RTL-aware keyboard nav
│ │ ├── JalaliDateField/ # read-only TextField + Popover wrapping JalaliDatePicker │ │ ├── JalaliDateField/ # read-only TextField + Popover wrapping JalaliDatePicker
@@ -259,14 +259,17 @@ client/
│ ├── ServicePriceRow/ # f6 C3 service line: localised name + PriceDisplay (money util + i18n unit label); reused by the booking summary later (tested) │ ├── ServicePriceRow/ # f6 C3 service line: localised name + PriceDisplay (money util + i18n unit label); reused by the booking summary later (tested)
│ ├── CountdownTimer/ # f7 pure presentational countdown to a server-frozen UTC deadline; owns its own 1s tick (only it re-renders), stops + shows elapsed text at zero, locale digits LTR; v2 (ui-phase-1) progress ring (`windowStart`) + urgency tiers + humanized coarse mode — C5's response countdown (ui-phase-5) is the ring's first live consumer (`windowStart=createdAt`) (tested) │ ├── CountdownTimer/ # f7 pure presentational countdown to a server-frozen UTC deadline; owns its own 1s tick (only it re-renders), stops + shows elapsed text at zero, locale digits LTR; v2 (ui-phase-1) progress ring (`windowStart`) + urgency tiers + humanized coarse mode — C5's response countdown (ui-phase-5) is the ring's first live consumer (`windowStart=createdAt`) (tested)
│ ├── BookingRequestSummaryCard/ # f7 engagement summary (nurse+rating, patient, priced service, address, Shamsi time) — shared by C5 + nurse detail + later f8 booking detail; ui-phase-5 bidi-isolated the date·time-range label (`dir="ltr"` span, matching SessionCard's precedent) (tested) │ ├── BookingRequestSummaryCard/ # f7 engagement summary (nurse+rating, patient, priced service, address, Shamsi time) — shared by C5 + nurse detail + later f8 booking detail; ui-phase-5 bidi-isolated the date·time-range label (`dir="ltr"` span, matching SessionCard's precedent) (tested)
│ ├── PriceBreakdown/ # f9 reconciling money breakdown (rows + bold total, all IRR digit-strings via the money util; dev-guard console.errors when rows ≠ total) — C6 + invoice now, f10/f11 refund/BNPL later (tested) │ ├── PriceBreakdown/ # f9 reconciling money breakdown (rows + bold total, all IRR digit-strings via the money util; dev-guard console.errors when rows ≠ total) — C6 + invoice now, f10/f11 refund/BNPL later; ui-phase-6 switched the row amounts to `<Money>` too (every row now carries «تومان», not just the total) (tested)
│ ├── EscrowNotice/ # f9 product-mandated escrow trust callout (verbatim fa copy, --bal-info tone, lock icon) — C6 now, f10/f11 reuse the identical message (tested) │ ├── EscrowNotice/ # f9 product-mandated escrow trust callout (verbatim fa copy, --bal-info tone, lock icon) — the untouchable inner sentence; C6/confirmation now wrap it in `EscrowExplainer` (tested)
│ ├── EscrowExplainer/ # ui-phase-6 — wraps `EscrowNotice` with an optional «چطور کار می‌کند؟» expander: a 3-step visual (پرداخت ← امانت نزد بالین‌یار ← آزادسازی) grounded in product/payments/escrow-ledger.md + the cancellation/refund implication; checkout + confirmation (tested)
│ ├── PaymentStateCard/ # ui-phase-6 — the one terminal/wait-state card (icon/tone/title/body/actions) replacing the four copy-pasted private `MessageCard`/`StateCard` functions across the card + BNPL checkout/return flows (tested)
│ ├── BnplProviderLogo/ # ui-phase-6 — providerCode → bundled SVG (none licensed yet) falling back to a designed tinted-monogram roundel, replacing the two-letter text-glyph stand-in (`DG`/`SP`/…) in D1's MethodStep (tested)
│ ├── PaymentStatusBadge/ # f9 b10 payment status (pending/succeeded/failed) → StatusChip kind + payment.pstatus_* label (tested) │ ├── PaymentStatusBadge/ # f9 b10 payment status (pending/succeeded/failed) → StatusChip kind + payment.pstatus_* label (tested)
│ ├── CancellationPolicyDisclosure/ # f10 pre-confirm cancel disclosure: policy-tier label (off cancellation_policy_code) + refund %/fee % + PriceBreakdown refund-vs-fee split (reconciles) + multi-session refundable/locked breakdown + admin-approval explainer + RefundEtaBanner (tested) │ ├── CancellationPolicyDisclosure/ # f10 pre-confirm cancel disclosure: policy-tier label (off cancellation_policy_code) + refund %/fee % + PriceBreakdown refund-vs-fee split (reconciles) + multi-session refundable/locked breakdown + admin-approval explainer + RefundEtaBanner (tested)
│ ├── RefundStatusCard/ # f10 customer refund view: 3-step stepper (submitted→on-its-way→completed) + refunded amount + optional fee-leg split + masked ref + failed=contact-support (no retry); reused on booking detail + refund-status page (tested) │ ├── RefundStatusCard/ # f10 customer refund view: 3-step stepper (submitted→on-its-way→completed) + refunded amount + optional fee-leg split + masked ref + failed=contact-support (no retry); reused on booking detail + refund-status page (tested)
│ ├── RefundEtaBanner/ # f10 per-channel refund ETA — bnpl_revert surfaces the ~710 business-day window honestly (never instant), psp_card/manual wording; one branch on refund_channel (tested) │ ├── RefundEtaBanner/ # f10 per-channel refund ETA — bnpl_revert surfaces the ~710 business-day window honestly (never instant), psp_card/manual wording; one branch on refund_channel (tested)
│ ├── BnplPlanCard/ # f11 D2 installment-plan option card (terracotta): term/installments + interest-free/fee sub-label + served monthly amount + down-payment indicator; single-select (tested) │ ├── BnplPlanCard/ # f11 D2 installment-plan option card (terracotta): term/installments + served monthly amount, a plain پیش‌پرداخت amount row, and مجموع بازپرداخت with the fee delta vs. paying in full spelled out in Toman (ui-phase-6 replaced the percent-only label + `LinearProgress` bar — a static fact must not look like loading); single-select (tested)
│ ├── InstallmentScheduleRow/ # f11 repayment row: down-payment(«امروز»)/installment + Shamsi due date + served amount + optional provider-reported status chip; reused by D4 schedule + D5 wallet due list (tested) │ ├── InstallmentScheduleRow/ # f11 repayment row: down-payment(«امروز»)/installment + Shamsi due date + served amount (every row carries «تومان» since ui-phase-6) + optional provider-reported status chip; reused by D4 schedule + D5 wallet due list (tested)
│ ├── EarningsBalanceHeader/ # f12 nurse net payable balance + 4-bucket breakdown (pending/eligible/paid/clawback off --bal-{warning,info,success,error}); renders a negative net as an explicit "owed back" state (magnitude only, never a bare minus) (tested) │ ├── EarningsBalanceHeader/ # f12 nurse net payable balance + 4-bucket breakdown (pending/eligible/paid/clawback off --bal-{warning,info,success,error}); renders a negative net as an explicit "owed back" state (magnitude only, never a bare minus) (tested)
│ ├── EarningsRow/ # f12 one earnings item: three-amount «gross commission = your payout» breakdown via PriceBreakdown + one of four visually-distinct state chips + state affordance (pending→display-only dispute-window CountdownTimer, eligible→awaiting-batch, paid→paid_at+ref+payout link, clawback_applied→net explanation); deep-links to /nurse/visits/[id] (tested) │ ├── EarningsRow/ # f12 one earnings item: three-amount «gross commission = your payout» breakdown via PriceBreakdown + one of four visually-distinct state chips + state affordance (pending→display-only dispute-window CountdownTimer, eligible→awaiting-batch, paid→paid_at+ref+payout link, clawback_applied→net explanation); deep-links to /nurse/visits/[id] (tested)
│ ├── PayoutHistoryRow/ # f12 one nurse_payouts row: net transferred + payout-status chip (pending/submitted/paid/failed) + period + masked IBAN (last-4, dir=ltr) + transfer ref + read-only failure banner (no nurse retry) (tested) │ ├── PayoutHistoryRow/ # f12 one nurse_payouts row: net transferred + payout-status chip (pending/submitted/paid/failed) + period + masked IBAN (last-4, dir=ltr) + transfer ref + read-only failure banner (no nurse retry) (tested)
@@ -338,9 +341,9 @@ client/
│ ├── verification/ # F5 nurse trust flow (b6). ONE cached status() query drives B3+B6; every mutation invalidates it. useVerificationStatus/useStartVerification/useSubmitIdentity/useRunBankVerification/useUploadVerificationDocument/useSubmitCredentials/useNurseTrustBadge; seam+mock(primary)+client; validation.ts (national-ID checksum); types export ownBadgeState/publicBadgeState/isApproved │ ├── verification/ # F5 nurse trust flow (b6). ONE cached status() query drives B3+B6; every mutation invalidates it. useVerificationStatus/useStartVerification/useSubmitIdentity/useRunBankVerification/useUploadVerificationDocument/useSubmitCredentials/useNurseTrustBadge; seam+mock(primary)+client; validation.ts (national-ID checksum); types export ownBadgeState/publicBadgeState/isApproved
│ ├── bookingRequests/ # F7 pre-payment request lifecycle (b8). Money-free create→accept/reject/cancel + role-scoped inbox + single get. useCreateBookingRequest/useBookingRequest(polls until terminal)/useNurseRequestInbox/useCustomerRequests/useAccept/useReject/useCancel; seam+mock(PRIMARY, shared in-memory state machine — customer create ↔ nurse inbox ↔ accept flips C5; lazy expiry sweep)+client. Server-frozen UTC deadlines rendered by CountdownTimer (never recomputed); two-stage disclosure (nurse `get(id,'nurse')` masks address); variantPrice client-augmented (REQ-013). Contract-live but mock-primary because inputs (search/patients/addresses) are mock-primary │ ├── bookingRequests/ # F7 pre-payment request lifecycle (b8). Money-free create→accept/reject/cancel + role-scoped inbox + single get. useCreateBookingRequest/useBookingRequest(polls until terminal)/useNurseRequestInbox/useCustomerRequests/useAccept/useReject/useCancel; seam+mock(PRIMARY, shared in-memory state machine — customer create ↔ nurse inbox ↔ accept flips C5; lazy expiry sweep)+client. Server-frozen UTC deadlines rendered by CountdownTimer (never recomputed); two-stage disclosure (nurse `get(id,'nurse')` masks address); variantPrice client-augmented (REQ-013). Contract-live but mock-primary because inputs (search/patients/addresses) are mock-primary
│ ├── bookings/ # F8 post-payment engagement (b9) — the SIBLING of bookingRequests, NOT a rename. useBookingDetail/useBookingSessions(select over detail — sessions are embedded)/useBookingList/useTodaySessions/useSessionEvv/useCareInstructions(enabled-gated)/useCheckInVisit/useCheckOutVisit; seam+mock(PRIMARY, seeded confirmed bookings + sessions + care + EVV state machine)+client(1:1 b9)+serverApi(RSC-prefetch seam, real-path). evv/locationProvider.ts = the ILocationProvider GPS seam (real navigator.geolocation vs mock coords by NEXT_PUBLIC_EVV_MOCK_GPS in_range|out_of_range|denied). Money display-only (gross=commission+payout server-side); timeline=server truth; care read gated to assigned nurse; EVV mismatch/denial advisory (never blocks); EVV mutations invalidate detail+session+today+list │ ├── bookings/ # F8 post-payment engagement (b9) — the SIBLING of bookingRequests, NOT a rename. useBookingDetail/useBookingSessions(select over detail — sessions are embedded)/useBookingList/useTodaySessions/useSessionEvv/useCareInstructions(enabled-gated)/useCheckInVisit/useCheckOutVisit; seam+mock(PRIMARY, seeded confirmed bookings + sessions + care + EVV state machine)+client(1:1 b9)+serverApi(RSC-prefetch seam, real-path). evv/locationProvider.ts = the ILocationProvider GPS seam (real navigator.geolocation vs mock coords by NEXT_PUBLIC_EVV_MOCK_GPS in_range|out_of_range|denied). Money display-only (gross=commission+payout server-side); timeline=server truth; care read gated to assigned nurse; EVV mismatch/denial advisory (never blocks); EVV mutations invalidate detail+session+today+list
│ ├── payment/ # F9 checkout & card capture (b10) + customer invoice read (b11). useCheckoutSummary/useInitiatePayment(caller owns the per-ATTEMPT Idempotency-Key)/useConfirmGatewayReturn/usePaymentOutcome(backoff poll, stops on terminal + bounded attempts)/useInvoice(immutable, long staleTime, 404=not-issued not error); invalidations.ts = the one post-capture cache transition (request detail/lists + bookings lists/detail + summary/outcome — never a blanket refetch); seam+mock(PRIMARY — the conversion trigger bridging the f7↔f8 mock stores: capture converts the request, inserts a confirmed booking, issues the b11-shaped invoice)+client (initiate/invoice = real b10/b11 contract; summary = REQ-016 proposed route; outcome = mapped booking_requests/get, REQ-017). Money = served IRR digit-strings; rows reconcile by construction; a 409 on the money path is benign convergence, never a toast │ ├── payment/ # F9 checkout & card capture (b10) + customer invoice read (b11). useCheckoutSummary/useInitiatePayment(caller owns the per-ATTEMPT Idempotency-Key)/useConfirmGatewayReturn/usePaymentOutcome(backoff poll, stops on terminal + bounded attempts)/useInvoice(immutable, long staleTime, 404=not-issued not error)/usePaymentHistory(ui-phase-6, wallet «پرداخت‌ها», REQ-047); invalidations.ts = the one post-capture cache transition (request detail/lists + bookings lists/detail + summary/outcome — never a blanket refetch); seam+mock(PRIMARY — the conversion trigger bridging the f7↔f8 mock stores: capture converts the request, inserts a confirmed booking, issues the b11-shaped invoice)+client (initiate/invoice = real b10/b11 contract; summary = REQ-016 proposed route; outcome = mapped booking_requests/get, REQ-017). Money = served IRR digit-strings; rows reconcile by construction; a 409 on the money path is benign convergence, never a toast. ui-phase-6 added `nurseAvatarUrl`/`nurseVerified` on `CheckoutSummaryDto` + `trackingCode`/`paidAt` on `PaymentOutcomeDto` (REQ-046) and `paymentMethod`/`transactionReference`/`sellerFiscalIdentity` on `InvoiceDto` (REQ-049) — mock-populated, `null` on the real path until served
│ ├── refunds/ # F10 customer cancellation + refund status (b11). resolveCancellationPolicy/cancelBooking/getRefundByBooking/getRefund. useCancellationPolicyPreview/useCancelBooking/useRefundStatus(polls only while non-terminal); invalidations.ts primes the fresh refund + invalidates booking detail/lists on cancel; seam+mock(PRIMARY — reads the f8 bookings store to resolve tier+per-session refundability, flips the booking cancelled, drives card-immediate/BNPL-processing refunds)+client. Contract is admin-only (REQ-019/020/021 fill the customer cancel command, policy preview, refund-by-booking + decomposition). Money = IRR digit-strings, BigInt; refund %+fee disclosed before confirm; refunds never self-issued │ ├── refunds/ # F10 customer cancellation + refund status (b11). resolveCancellationPolicy/cancelBooking/getRefundByBooking/getRefund/getMyRefunds(ui-phase-6, wallet «استردادها», REQ-048). useCancellationPolicyPreview/useCancelBooking/useRefundStatus(polls only while non-terminal)/useMyRefunds; invalidations.ts primes the fresh refund + invalidates booking detail/lists on cancel; seam+mock(PRIMARY — reads the f8 bookings store to resolve tier+per-session refundability, flips the booking cancelled, drives card-immediate/BNPL-processing refunds)+client. Contract is admin-only (REQ-019/020/021 fill the customer cancel command, policy preview, refund-by-booking + decomposition). Money = IRR digit-strings, BigInt; refund %+fee disclosed before confirm; refunds never self-issued
│ ├── bnpl/ # F11 BNPL installment checkout (b12) — the alternate branch off C6. useBnplOptions/useCheckEligibility/useBnplSchedule/useIssueBnplToken/useAcceptBnplSchedule(invalidates booking+checkout+wallet)/useBnplOrder(bounded backoff poll)/useWalletInstallments; invalidations.ts reuses f9 invalidateAfterPaymentSuccess + the wallet key; seam+mock(PRIMARY)+client. Mock = the settle bridge: reuses the f9 conversion (mockInsertConvertedBooking + mockMarkBookingRequestConverted) — a settled BNPL order is a card payment net-of-fee — and seeds a provider-reported Wallet plan (D5). Contract serves only eligibility/initiate/status; options/schedule/wallet-installments/D3-KYC/customer-bookingId are REQ-022/023/024 gaps mocked behind the seam. Money = served IRR digit-strings (the mock computes plan/schedule with BigInt; components only format). D5 is provider-reported status, NOT a Balinyaar ledger; early-pay hands off to the provider │ ├── bnpl/ # F11 BNPL installment checkout (b12) — the alternate branch off C6. useBnplOptions/useCheckEligibility/useBnplSchedule/useIssueBnplToken/useAcceptBnplSchedule(invalidates booking+checkout+wallet)/useBnplOrder(bounded backoff poll)/useWalletInstallments; invalidations.ts reuses f9 invalidateAfterPaymentSuccess + the wallet key; seam+mock(PRIMARY)+client. Mock = the settle bridge: reuses the f9 conversion (mockInsertConvertedBooking + mockMarkBookingRequestConverted) — a settled BNPL order is a card payment net-of-fee — and seeds a provider-reported Wallet plan (D5). Contract serves only eligibility/initiate/status; options/schedule/wallet-installments/D3-KYC/customer-bookingId are REQ-022/023/024 gaps mocked behind the seam. Money = served IRR digit-strings (the mock computes plan/schedule with BigInt; components only format). D5 is provider-reported status, NOT a Balinyaar ledger; early-pay hands off to the provider. ui-phase-6: no API-shape change, UI-only honesty/polish pass (BnplPlanCard Toman rows + fee delta, PlanStep names the selected plan, BnplProviderLogo, eligibility progress feedback, gateway harness env-gated)
│ ├── payouts/ # F12 nurse earnings & payout history (b13) — read-only, no mutations. useNurseEarningsBalance/useNurseEarnings(state,page)/useNursePayoutHistory(page)/useNursePayoutDetail(id); the state-filter + page are part of the query key (tabs/pages cache separately, keepPreviousData); seam+mock(PRIMARY)+client. b13 serves only GET nurse_payouts/history; the four-bucket earnings summary, per-booking earnings list + money-state, and nurse-readable payout detail (batch context + booking links + failureReason) are REQ-025 gaps mocked behind the seam. EarningsState (pending|eligible|paid|clawback_applied) is a client display model derived server-side; PayoutStatus is the contract's pending|submitted|paid|failed. Money = IRR digit-strings (gross=commission+payout; net=grossclawback; Σ booking-links=grossEarnings); the net payable balance is SIGNED (may be negative "owed back", never clamped); eligibility/dates/amounts are server truth (never computed client-side); the BNPL provider commission never appears (payment-method-invariant). MOCK_SCENARIO toggles the negative-balance demo │ ├── payouts/ # F12 nurse earnings & payout history (b13) — read-only, no mutations. useNurseEarningsBalance/useNurseEarnings(state,page)/useNursePayoutHistory(page)/useNursePayoutDetail(id); the state-filter + page are part of the query key (tabs/pages cache separately, keepPreviousData); seam+mock(PRIMARY)+client. b13 serves only GET nurse_payouts/history; the four-bucket earnings summary, per-booking earnings list + money-state, and nurse-readable payout detail (batch context + booking links + failureReason) are REQ-025 gaps mocked behind the seam. EarningsState (pending|eligible|paid|clawback_applied) is a client display model derived server-side; PayoutStatus is the contract's pending|submitted|paid|failed. Money = IRR digit-strings (gross=commission+payout; net=grossclawback; Σ booking-links=grossEarnings); the net payable balance is SIGNED (may be negative "owed back", never clamped); eligibility/dates/amounts are server truth (never computed client-side); the BNPL provider commission never appears (payment-method-invariant). MOCK_SCENARIO toggles the negative-balance demo
│ ├── reviews/ # F13 moderated reviews (b14). useNurseReviews(infinite, published-only aggregate+list)/useReviewEligibility(bookingId)/useMyReviewForBooking(bookingId)/useCreateReview(invalidates eligibility+myReview, NEVER the public list); seam+mock(PRIMARY)+client. b14 serves submit + GET nurses/{id}/reviews (both mapped 1:1); review-eligibility + my-review-for-booking are REQ-026 gaps and moderation is admin-only (f15), so the mock reads a booking from the shared f8 bookings store (mockGetBookingForReview) to gate on a completed booking, tracks the submission for the persistent "under review" state, seeds a per-nurse published list, and recomputes the aggregate from published (never a stored sum). A pending_moderation review is NEVER injected into a public list/aggregate. Dev-only __mockPublishSubmittedReview stands in for the f15 admin queue. Tag chip labels are i18n keys off REVIEW_TAG_CODES, never off the wire │ ├── reviews/ # F13 moderated reviews (b14). useNurseReviews(infinite, published-only aggregate+list)/useReviewEligibility(bookingId)/useMyReviewForBooking(bookingId)/useCreateReview(invalidates eligibility+myReview, NEVER the public list); seam+mock(PRIMARY)+client. b14 serves submit + GET nurses/{id}/reviews (both mapped 1:1); review-eligibility + my-review-for-booking are REQ-026 gaps and moderation is admin-only (f15), so the mock reads a booking from the shared f8 bookings store (mockGetBookingForReview) to gate on a completed booking, tracks the submission for the persistent "under review" state, seeds a per-nurse published list, and recomputes the aggregate from published (never a stored sum). A pending_moderation review is NEVER injected into a public list/aggregate. Dev-only __mockPublishSubmittedReview stands in for the f15 admin queue. Tag chip labels are i18n keys off REVIEW_TAG_CODES, never off the wire
│ ├── patientRecords/ # F13 continuity-of-care (b14) — patient-scoped, NOT booking-scoped. usePatientCareRecord(family record)/useRecordAccess(gates before any clinical fetch)/usePatientHistory(paged visit-note history)/useUpdateCareRecord(CUSTOMER-only edit → setQueryData)/useCreateVisitNote(NURSE-only append → invalidates history). seam+mock(PRIMARY)+client. The nurse-authored visit-note history/append (getPatientHistory/createVisitNote) are REAL b14 (GET/POST patients/{id}/care_records, mapped 1:1; the append folds the ticked task checklist into the note body); the family-owned editable record (medications/routine/tasks) + the access check have NO backend (REQ-027) and are mocked. Nurse is APPEND-ONLY (never wires useUpdateCareRecord). Access-denied (canView=false / 403) is a first-class non-leaking state; MOCK_FOREIGN_PATIENT_ID=8888 exercises it. Clinical text is never logged/localStorage/query-string │ ├── patientRecords/ # F13 continuity-of-care (b14) — patient-scoped, NOT booking-scoped. usePatientCareRecord(family record)/useRecordAccess(gates before any clinical fetch)/usePatientHistory(paged visit-note history)/useUpdateCareRecord(CUSTOMER-only edit → setQueryData)/useCreateVisitNote(NURSE-only append → invalidates history). seam+mock(PRIMARY)+client. The nurse-authored visit-note history/append (getPatientHistory/createVisitNote) are REAL b14 (GET/POST patients/{id}/care_records, mapped 1:1; the append folds the ticked task checklist into the note body); the family-owned editable record (medications/routine/tasks) + the access check have NO backend (REQ-027) and are mocked. Nurse is APPEND-ONLY (never wires useUpdateCareRecord). Access-denied (canView=false / 403) is a first-class non-leaking state; MOCK_FOREIGN_PATIENT_ID=8888 exercises it. Clinical text is never logged/localStorage/query-string
+51 -9
View File
@@ -650,12 +650,22 @@
"row_vat": "VAT", "row_vat": "VAT",
"row_total": "Total", "row_total": "Total",
"escrow_notice": "The amount is held in escrow with Balinyaar and released after the visit ends", "escrow_notice": "The amount is held in escrow with Balinyaar and released after the visit ends",
"cta_pay": "Continue to payment →", "escrow_explainer_toggle": "How does this work?",
"escrow_step_pay": "Payment",
"escrow_step_hold": "Held in escrow with Balinyaar",
"escrow_step_release": "Released once the visit is confirmed complete",
"escrow_cancellation_note": "If you cancel before the visit, the amount is refunded per the cancellation policy.",
"total_payable_label": "Total to pay",
"secure_gateway_notice": "Secure payment via bank gateway",
"cta_pay": "Continue to payment",
"bnpl_option": "Or pay in installments", "bnpl_option": "Or pay in installments",
"state_initiating": "Starting payment…", "state_initiating": "Starting payment…",
"state_redirecting": "Redirecting to the payment gateway…", "state_redirecting": "Redirecting to the payment gateway…",
"state_pending_title": "Confirming your payment…", "state_pending_title": "Confirming your payment…",
"state_pending_hint": "The gateway is confirming your payment; this usually takes a few moments.", "state_pending_hint": "The gateway is confirming your payment; this usually takes a few moments.",
"stage_returned": "Returned from the gateway",
"stage_confirming": "Awaiting bank confirmation",
"state_pending_duration_hint": "This usually takes less than a minute.",
"check_again": "Check again", "check_again": "Check again",
"state_failed_title": "Payment failed", "state_failed_title": "Payment failed",
"state_failed_hint": "Nothing was charged. You can try again.", "state_failed_hint": "Nothing was charged. You can try again.",
@@ -672,10 +682,31 @@
"view_booking": "View booking", "view_booking": "View booking",
"download_invoice": "Download invoice", "download_invoice": "Download invoice",
"total_paid_label": "Amount paid", "total_paid_label": "Amount paid",
"receipt_tracking_code_label": "Tracking code",
"receipt_paid_at_label": "Paid on",
"receipt_method_label": "Payment method",
"receipt_booking_ref_label": "Booking reference",
"copy_tracking_code": "Copy tracking code",
"tracking_code_copied": "Tracking code copied",
"method_card": "Bank card",
"method_bnpl_provider": "Installments — {provider}",
"next_steps_title": "What happens next",
"next_step_nurse_notified": "The nurse has been notified",
"next_step_visit_checkin": "Visit day check-in",
"view_invoice_cta": "View invoice",
"invoice_title": "Invoice", "invoice_title": "Invoice",
"invoice_number_label": "Invoice number", "invoice_number_label": "Invoice number",
"invoice_issued_at": "Issued on", "invoice_issued_at": "Issued on",
"invoice_buyer_label": "Buyer",
"invoice_service_label": "Service",
"invoice_visit_dates_label": "Visit date",
"invoice_visit_dates_multi": "{date} ({count, plural, one {# visit} other {# visits}})",
"invoice_transaction_ref_label": "Transaction reference",
"invoice_method_bnpl": "Installments",
"invoice_vat_on_commission": "VAT (on the Balinyaar fee)", "invoice_vat_on_commission": "VAT (on the Balinyaar fee)",
"invoice_seller_economic_code_label": "Economic code",
"invoice_footer_reference": "Invoice {number} · issued {date}",
"invoice_footer_moadian": "Moadian reference: {ref}",
"invoice_not_issued_title": "The invoice has not been issued yet", "invoice_not_issued_title": "The invoice has not been issued yet",
"invoice_not_issued_body": "The invoice for this booking will appear here once issued.", "invoice_not_issued_body": "The invoice for this booking will appear here once issued.",
"print_invoice": "Print invoice", "print_invoice": "Print invoice",
@@ -687,11 +718,6 @@
"pstatus_pending": "Awaiting confirmation", "pstatus_pending": "Awaiting confirmation",
"pstatus_succeeded": "Paid", "pstatus_succeeded": "Paid",
"pstatus_failed": "Failed", "pstatus_failed": "Failed",
"gateway_title": "Test payment gateway",
"gateway_hint": "This page stands in for the real gateway in development.",
"gateway_reference_label": "Reference",
"gateway_pay_success": "Pay successfully",
"gateway_pay_fail": "Simulate a failed payment",
"error_title": "Something went wrong", "error_title": "Something went wrong",
"error_body": "We couldn't load the payment summary.", "error_body": "We couldn't load the payment summary.",
"invalid_link_body": "This payment link is not valid.", "invalid_link_body": "This payment link is not valid.",
@@ -942,13 +968,18 @@
"eta_business_days": "About 710 business days", "eta_business_days": "About 710 business days",
"eta_expected_label": "estimated by {date}", "eta_expected_label": "estimated by {date}",
"cancel_booking_cta": "Cancel booking", "cancel_booking_cta": "Cancel booking",
"refund_section_title": "Refund" "refund_section_title": "Refund",
"wallet_empty_title": "No refunds yet",
"wallet_empty_body": "Refunds for cancelled bookings will appear here.",
"wallet_booking_label": "Booking #{id}"
}, },
"bnpl": { "bnpl": {
"title": "Installment payment", "title": "Installment payment",
"ownership_note": "The provider pays Balinyaar the full amount at once and bears 100% of the customer's default risk; installment repayment is between you and the provider.", "ownership_note": "The provider pays Balinyaar the full amount at once and bears 100% of the customer's default risk; installment repayment is between you and the provider.",
"payable_amount": "Payable amount", "payable_amount": "Payable amount",
"total_amount": "Total amount", "total_amount_named": "Total with the {plan} plan",
"total_repayment": "Total repayment",
"plan_fee_amount_suffix": "fee",
"monthly": "Monthly", "monthly": "Monthly",
"continue": "Continue", "continue": "Continue",
"error_title": "Something went wrong", "error_title": "Something went wrong",
@@ -987,6 +1018,7 @@
"mobile_label": "Mobile number", "mobile_label": "Mobile number",
"consent_label": "I agree to a credit and eligibility inquiry by {provider}.", "consent_label": "I agree to a credit and eligibility inquiry by {provider}.",
"check_eligibility": "Check eligibility", "check_eligibility": "Check eligibility",
"checking_eligibility": "Checking eligibility…",
"approved_title": "You're approved", "approved_title": "You're approved",
"credit_ceiling_label": "Available credit ceiling", "credit_ceiling_label": "Available credit ceiling",
"approve_continue": "Confirm & continue", "approve_continue": "Confirm & continue",
@@ -1031,7 +1063,17 @@
"step_provider": "Provider", "step_provider": "Provider",
"step_plan": "Plan", "step_plan": "Plan",
"step_eligibility": "Eligibility", "step_eligibility": "Eligibility",
"step_schedule": "Schedule" "step_schedule": "Schedule",
"wallet_hub_title": "Wallet",
"tab_payments": "Payments",
"tab_installments": "Installments",
"tab_refunds": "Refunds",
"tab_receipts": "Receipts",
"history_empty_title": "No payments yet",
"history_empty_body": "Your card and installment payments will appear here.",
"history_view_booking": "View booking",
"receipts_empty_title": "No receipts yet",
"receipts_empty_body": "Receipts for your completed payments will appear here."
}, },
"payouts": { "payouts": {
"title": "Earnings", "title": "Earnings",
+51 -9
View File
@@ -650,12 +650,22 @@
"row_vat": "مالیات بر ارزش افزوده", "row_vat": "مالیات بر ارزش افزوده",
"row_total": "مبلغ کل", "row_total": "مبلغ کل",
"escrow_notice": "مبلغ به‌صورت امانی نزد بالین‌یار می‌ماند و پس از پایان ویزیت آزاد می‌شود", "escrow_notice": "مبلغ به‌صورت امانی نزد بالین‌یار می‌ماند و پس از پایان ویزیت آزاد می‌شود",
"cta_pay": "ادامه پرداخت ←", "escrow_explainer_toggle": "چطور کار می‌کند؟",
"escrow_step_pay": "پرداخت",
"escrow_step_hold": "امانت نزد بالین‌یار",
"escrow_step_release": "آزادسازی پس از تایید پایان ویزیت",
"escrow_cancellation_note": "در صورت لغو رزرو پیش از انجام ویزیت، مبلغ طبق سیاست لغو بازپرداخت می‌شود.",
"total_payable_label": "مبلغ قابل پرداخت",
"secure_gateway_notice": "پرداخت امن از طریق درگاه بانکی",
"cta_pay": "ادامه پرداخت",
"bnpl_option": "یا پرداخت اقساطی", "bnpl_option": "یا پرداخت اقساطی",
"state_initiating": "در حال آغاز پرداخت…", "state_initiating": "در حال آغاز پرداخت…",
"state_redirecting": "در حال انتقال به درگاه پرداخت…", "state_redirecting": "در حال انتقال به درگاه پرداخت…",
"state_pending_title": "در حال تایید پرداخت…", "state_pending_title": "در حال تایید پرداخت…",
"state_pending_hint": "پرداخت شما نزد درگاه در حال تایید است؛ این مرحله معمولاً چند لحظه طول می‌کشد.", "state_pending_hint": "پرداخت شما نزد درگاه در حال تایید است؛ این مرحله معمولاً چند لحظه طول می‌کشد.",
"stage_returned": "بازگشت از درگاه",
"stage_confirming": "در انتظار تایید بانک",
"state_pending_duration_hint": "معمولاً کمتر از یک دقیقه طول می‌کشد.",
"check_again": "بررسی دوباره", "check_again": "بررسی دوباره",
"state_failed_title": "پرداخت ناموفق بود", "state_failed_title": "پرداخت ناموفق بود",
"state_failed_hint": "مبلغی از حساب شما کسر نشده است. می‌توانید دوباره تلاش کنید.", "state_failed_hint": "مبلغی از حساب شما کسر نشده است. می‌توانید دوباره تلاش کنید.",
@@ -672,10 +682,31 @@
"view_booking": "مشاهده رزرو", "view_booking": "مشاهده رزرو",
"download_invoice": "دانلود فاکتور", "download_invoice": "دانلود فاکتور",
"total_paid_label": "مبلغ پرداخت‌شده", "total_paid_label": "مبلغ پرداخت‌شده",
"receipt_tracking_code_label": "کد پیگیری",
"receipt_paid_at_label": "تاریخ پرداخت",
"receipt_method_label": "روش پرداخت",
"receipt_booking_ref_label": "شماره رزرو",
"copy_tracking_code": "کپی کد پیگیری",
"tracking_code_copied": "کد پیگیری کپی شد",
"method_card": "کارت بانکی",
"method_bnpl_provider": "اقساطی — {provider}",
"next_steps_title": "مراحل بعدی",
"next_step_nurse_notified": "اطلاع‌رسانی به پرستار",
"next_step_visit_checkin": "ویزیت و ثبت ورود",
"view_invoice_cta": "مشاهده فاکتور",
"invoice_title": "فاکتور", "invoice_title": "فاکتور",
"invoice_number_label": "شماره فاکتور", "invoice_number_label": "شماره فاکتور",
"invoice_issued_at": "تاریخ صدور", "invoice_issued_at": "تاریخ صدور",
"invoice_buyer_label": "خریدار",
"invoice_service_label": "خدمت",
"invoice_visit_dates_label": "تاریخ ویزیت",
"invoice_visit_dates_multi": "{date} ({count, plural, one {# ویزیت} other {# ویزیت}})",
"invoice_transaction_ref_label": "کد پیگیری تراکنش",
"invoice_method_bnpl": "اقساطی",
"invoice_vat_on_commission": "مالیات بر ارزش افزوده (بر کارمزد بالین‌یار)", "invoice_vat_on_commission": "مالیات بر ارزش افزوده (بر کارمزد بالین‌یار)",
"invoice_seller_economic_code_label": "کد اقتصادی",
"invoice_footer_reference": "فاکتور {number} · صادرشده در {date}",
"invoice_footer_moadian": "کد مرجع مودیان: {ref}",
"invoice_not_issued_title": "فاکتور هنوز صادر نشده است", "invoice_not_issued_title": "فاکتور هنوز صادر نشده است",
"invoice_not_issued_body": "فاکتور این رزرو پس از صدور در همین‌جا در دسترس خواهد بود.", "invoice_not_issued_body": "فاکتور این رزرو پس از صدور در همین‌جا در دسترس خواهد بود.",
"print_invoice": "چاپ فاکتور", "print_invoice": "چاپ فاکتور",
@@ -687,11 +718,6 @@
"pstatus_pending": "در انتظار تایید", "pstatus_pending": "در انتظار تایید",
"pstatus_succeeded": "موفق", "pstatus_succeeded": "موفق",
"pstatus_failed": "ناموفق", "pstatus_failed": "ناموفق",
"gateway_title": "درگاه پرداخت آزمایشی",
"gateway_hint": "این صفحه در محیط توسعه جایگزین درگاه واقعی است.",
"gateway_reference_label": "کد پیگیری",
"gateway_pay_success": "پرداخت موفق",
"gateway_pay_fail": "شبیه‌سازی پرداخت ناموفق",
"error_title": "مشکلی پیش آمد", "error_title": "مشکلی پیش آمد",
"error_body": "بارگذاری خلاصه پرداخت ممکن نشد.", "error_body": "بارگذاری خلاصه پرداخت ممکن نشد.",
"invalid_link_body": "پیوند پرداخت معتبر نیست.", "invalid_link_body": "پیوند پرداخت معتبر نیست.",
@@ -942,13 +968,18 @@
"eta_business_days": "حدود ۷ تا ۱۰ روز کاری", "eta_business_days": "حدود ۷ تا ۱۰ روز کاری",
"eta_expected_label": "تا حدود {date}", "eta_expected_label": "تا حدود {date}",
"cancel_booking_cta": "لغو رزرو", "cancel_booking_cta": "لغو رزرو",
"refund_section_title": "بازپرداخت" "refund_section_title": "بازپرداخت",
"wallet_empty_title": "استردادی موجود نیست",
"wallet_empty_body": "استرداد رزروهای لغوشده شما اینجا نمایش داده می‌شود.",
"wallet_booking_label": "رزرو شماره {id}"
}, },
"bnpl": { "bnpl": {
"title": "پرداخت اقساطی", "title": "پرداخت اقساطی",
"ownership_note": "ارائه‌دهنده کل مبلغ را یک‌جا به بالین‌یار می‌پردازد و ریسک نکول مشتری کاملاً با اوست؛ بازپرداخت اقساط میان شما و ارائه‌دهنده است.", "ownership_note": "ارائه‌دهنده کل مبلغ را یک‌جا به بالین‌یار می‌پردازد و ریسک نکول مشتری کاملاً با اوست؛ بازپرداخت اقساط میان شما و ارائه‌دهنده است.",
"payable_amount": "مبلغ قابل پرداخت", "payable_amount": "مبلغ قابل پرداخت",
"total_amount": "مبلغ کل", "total_amount_named": "مبلغ کل با طرح {plan}",
"total_repayment": "مجموع بازپرداخت",
"plan_fee_amount_suffix": "کارمزد",
"monthly": "ماهانه", "monthly": "ماهانه",
"continue": "ادامه", "continue": "ادامه",
"error_title": "خطایی رخ داد", "error_title": "خطایی رخ داد",
@@ -987,6 +1018,7 @@
"mobile_label": "شماره موبایل", "mobile_label": "شماره موبایل",
"consent_label": "با استعلام اعتبارسنجی و سابقه اعتباری من توسط {provider} موافقم.", "consent_label": "با استعلام اعتبارسنجی و سابقه اعتباری من توسط {provider} موافقم.",
"check_eligibility": "استعلام اعتبار", "check_eligibility": "استعلام اعتبار",
"checking_eligibility": "در حال استعلام اعتبار…",
"approved_title": "اعتبار شما تایید شد", "approved_title": "اعتبار شما تایید شد",
"credit_ceiling_label": "سقف اعتبار قابل استفاده", "credit_ceiling_label": "سقف اعتبار قابل استفاده",
"approve_continue": "تایید و ادامه", "approve_continue": "تایید و ادامه",
@@ -1031,7 +1063,17 @@
"step_provider": "ارائه‌دهنده", "step_provider": "ارائه‌دهنده",
"step_plan": "طرح", "step_plan": "طرح",
"step_eligibility": "اعتبارسنجی", "step_eligibility": "اعتبارسنجی",
"step_schedule": "جدول" "step_schedule": "جدول",
"wallet_hub_title": "کیف‌پول",
"tab_payments": "پرداخت‌ها",
"tab_installments": "اقساط",
"tab_refunds": "استردادها",
"tab_receipts": "رسیدها",
"history_empty_title": "هنوز پرداختی نداشته‌اید",
"history_empty_body": "پرداخت‌های کارتی و اقساطی شما اینجا نمایش داده می‌شود.",
"history_view_booking": "مشاهده رزرو",
"receipts_empty_title": "رسیدی موجود نیست",
"receipts_empty_body": "رسید پرداخت‌های تکمیل‌شده شما اینجا نمایش داده می‌شود."
}, },
"payouts": { "payouts": {
"title": "درآمدها", "title": "درآمدها",
@@ -1,12 +1,14 @@
'use client'; 'use client';
import { useLocale, useTranslations } from 'next-intl'; import { useLocale, useTranslations } from 'next-intl';
import { useParams, useRouter } from 'next/navigation'; import { useParams, useRouter } from 'next/navigation';
import { Divider, GlobalStyles, Paper, Skeleton, Stack, Typography } from '@mui/material'; import { Box, Divider, GlobalStyles, Paper, Skeleton, Stack, Typography } from '@mui/material';
import { AppButton, AppIcon, PriceBreakdown, StatusChip, type StatusKind } from '@/components'; import { AppButton, AppIcon, PriceBreakdown, StatusChip, type StatusKind } from '@/components';
import { ROUTES } from '@/constants'; import { ROUTES } from '@/constants';
import { ApiError } from '@/lib/api/errors'; import { ApiError } from '@/lib/api/errors';
import { formatShamsiDate, localeTag, parseIrr } from '@/utils'; import { formatShamsiDate, localeTag, parseIrr } from '@/utils';
import { useInvoice } from '@/services/payment'; import { useInvoice } from '@/services/payment';
import { useBookingDetail } from '@/services/bookings';
import { useCustomerProfile } from '@/services/profiles';
import type { MoadianStatus } from '@/services/payment/types'; import type { MoadianStatus } from '@/services/payment/types';
/** The printable region — everything else is hidden by the print rules below. */ /** The printable region — everything else is hidden by the print rules below. */
@@ -20,13 +22,26 @@ const MOADIAN_KIND: Record<MoadianStatus, StatusKind> = {
failed: 'rejected', failed: 'rejected',
}; };
/** Best-effort read of the frozen variant display name from the booking's variant snapshot (mirrors the
* same tolerant parse `BookingDetailView`/the review page use REQ-045 proposes a typed shape). */
function variantName(snapshotJson: string): string | null {
try {
const parsed = JSON.parse(snapshotJson) as { displayName?: string };
return parsed?.displayName ?? null;
} catch {
return null;
}
}
/** /**
* The booking's commission invoice (b11 `GET invoices/{bookingId}`): header (`invoiceNumber`, Shamsi * The booking's commission invoice (b11 `GET invoices/{bookingId}`): header (`invoiceNumber`, Shamsi
* issue date), the reconciling lines with the **VAT-on-commission** line explicitly labelled (product * issue date), a buyer/service/visit-date recap (composed client-side from the customer's own profile +
* rule: VAT is on Balinyaar's commission — the taxable supply — never the nurse's earnings), and the * the booking detail read a UI join, not money math), the reconciling lines with the **VAT-on-commission**
* مودیان state read-only. Downloads the served `pdfUrl` when present; otherwise prints a clean receipt * line explicitly labelled (product rule: VAT is on Balinyaar's commission the taxable supply never the
* (`window.print()` + a print-scoped visibility rule). Every figure via the money util no float math; * nurse's earnings), the payment method + transaction reference, a seller fiscal-identity block, and the
* the service line is the exact integer remainder of served amounts (gross commission VAT). * مودیان state read-only. Downloads the served `pdfUrl` when present; otherwise prints a clean A4 receipt
* (`window.print()` + a print-scoped visibility rule + `@page` sizing). Every figure via the money util
* no float math; the service line is the exact integer remainder of served amounts (gross commission VAT).
*/ */
export default function BookingInvoicePage() { export default function BookingInvoicePage() {
const t = useTranslations('payment'); const t = useTranslations('payment');
@@ -38,6 +53,8 @@ export default function BookingInvoicePage() {
const validId = Number.isInteger(bookingId) && bookingId > 0; const validId = Number.isInteger(bookingId) && bookingId > 0;
const { data: invoice, isLoading, error, refetch } = useInvoice(validId ? bookingId : undefined); const { data: invoice, isLoading, error, refetch } = useInvoice(validId ? bookingId : undefined);
const { data: booking } = useBookingDetail(validId ? bookingId : undefined, 'customer');
const { data: customerProfile } = useCustomerProfile();
// A malformed id can never load — navigation, not a retry (a manual refetch() bypasses `enabled`). // A malformed id can never load — navigation, not a retry (a manual refetch() bypasses `enabled`).
if (!validId) { if (!validId) {
@@ -123,10 +140,21 @@ export default function BookingInvoicePage() {
maximumFractionDigits: 2, maximumFractionDigits: 2,
}).format(invoice.vatRate); }).format(invoice.vatRate);
const buyerName = [customerProfile?.firstName, customerProfile?.lastName].filter(Boolean).join(' ').trim();
const serviceLabel = booking ? variantName(booking.variantSnapshotJson) : null;
const visitDatesLabel = booking
? booking.sessionCount > 1
? t('invoice_visit_dates_multi', { date: formatShamsiDate(booking.scheduledDate, locale), count: booking.sessionCount })
: formatShamsiDate(booking.scheduledDate, locale)
: null;
const methodLabel =
invoice.paymentMethod === 'card' ? t('method_card') : invoice.paymentMethod === 'bnpl' ? t('invoice_method_bnpl') : null;
return ( return (
<Stack sx={{ gap: 2 }}> <Stack sx={{ gap: 2 }}>
<GlobalStyles <GlobalStyles
styles={{ styles={{
'@page': { size: 'A4', margin: '16mm' },
'@media print': { '@media print': {
'body *': { visibility: 'hidden' }, 'body *': { visibility: 'hidden' },
[`.${PRINT_AREA_CLASS}, .${PRINT_AREA_CLASS} *`]: { visibility: 'visible' }, [`.${PRINT_AREA_CLASS}, .${PRINT_AREA_CLASS} *`]: { visibility: 'visible' },
@@ -145,8 +173,6 @@ export default function BookingInvoicePage() {
<Typography variant="h6" component="h1"> <Typography variant="h6" component="h1">
{t('invoice_title')} {t('invoice_title')}
</Typography> </Typography>
{/* The issuer line uses the product spelling «بالینیار» fa `common.brand` currently reads
«بلینیار» (the auth wordmark); a fiscal document must match the product/docs spelling. */}
<Typography variant="subtitle2" sx={{ color: 'var(--bal-primary)', fontWeight: 700 }}> <Typography variant="subtitle2" sx={{ color: 'var(--bal-primary)', fontWeight: 700 }}>
{t('issuer_platform')} {t('issuer_platform')}
</Typography> </Typography>
@@ -156,6 +182,10 @@ export default function BookingInvoicePage() {
<Stack sx={{ gap: 0.75 }}> <Stack sx={{ gap: 0.75 }}>
<MetaRow label={t('invoice_number_label')} value={invoice.invoiceNumber} ltr /> <MetaRow label={t('invoice_number_label')} value={invoice.invoiceNumber} ltr />
<MetaRow label={t('invoice_issued_at')} value={formatShamsiDate(invoice.issuedAt, locale)} /> <MetaRow label={t('invoice_issued_at')} value={formatShamsiDate(invoice.issuedAt, locale)} />
{buyerName ? <MetaRow label={t('invoice_buyer_label')} value={buyerName} /> : null}
{serviceLabel ? <MetaRow label={t('invoice_service_label')} value={serviceLabel} /> : null}
{visitDatesLabel ? <MetaRow label={t('invoice_visit_dates_label')} value={visitDatesLabel} /> : null}
<MetaRow label={t('receipt_booking_ref_label')} value={String(invoice.bookingId)} ltr />
</Stack> </Stack>
<PriceBreakdown <PriceBreakdown
@@ -172,6 +202,15 @@ export default function BookingInvoicePage() {
totalAmountIrr={invoice.grossIrr} totalAmountIrr={invoice.grossIrr}
/> />
{methodLabel || invoice.transactionReference ? (
<Stack sx={{ gap: 0.75 }}>
{methodLabel ? <MetaRow label={t('receipt_method_label')} value={methodLabel} /> : null}
{invoice.transactionReference ? (
<MetaRow label={t('invoice_transaction_ref_label')} value={invoice.transactionReference} ltr />
) : null}
</Stack>
) : null}
{invoice.moadianStatus ? ( {invoice.moadianStatus ? (
<Stack direction="row" sx={{ justifyContent: 'space-between', alignItems: 'center', gap: 2 }}> <Stack direction="row" sx={{ justifyContent: 'space-between', alignItems: 'center', gap: 2 }}>
<Typography variant="body2" sx={{ color: 'text.secondary' }}> <Typography variant="body2" sx={{ color: 'text.secondary' }}>
@@ -180,6 +219,39 @@ export default function BookingInvoicePage() {
<StatusChip status={MOADIAN_KIND[invoice.moadianStatus]} label={t(`moadian_${invoice.moadianStatus}`)} /> <StatusChip status={MOADIAN_KIND[invoice.moadianStatus]} label={t(`moadian_${invoice.moadianStatus}`)} />
</Stack> </Stack>
) : null} ) : null}
{invoice.sellerFiscalIdentity ? (
<>
<Divider />
<Stack sx={{ gap: 0.5 }}>
<Typography variant="caption" sx={{ fontWeight: 700 }}>
{invoice.sellerFiscalIdentity.legalName}
</Typography>
{invoice.sellerFiscalIdentity.economicCode ? (
<Typography variant="caption" sx={{ color: 'text.secondary' }} dir="ltr">
{t('invoice_seller_economic_code_label')}: {invoice.sellerFiscalIdentity.economicCode}
</Typography>
) : null}
{invoice.sellerFiscalIdentity.address ? (
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{invoice.sellerFiscalIdentity.address}
</Typography>
) : null}
</Stack>
</>
) : null}
{/* Print-only document footer — invoice number + issue date (+ مودیان reference when present). */}
<Box sx={{ display: 'none', '@media print': { display: 'block', mt: 2, pt: 1, borderTop: '1px solid', borderColor: 'divider' } }}>
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{t('invoice_footer_reference', { number: invoice.invoiceNumber, date: formatShamsiDate(invoice.issuedAt, locale) })}
</Typography>
{invoice.moadianReferenceNumber ? (
<Typography variant="caption" sx={{ color: 'text.secondary', display: 'block' }} dir="ltr">
{t('invoice_footer_moadian', { ref: invoice.moadianReferenceNumber })}
</Typography>
) : null}
</Box>
</Stack> </Stack>
</Paper> </Paper>
@@ -1,7 +1,7 @@
'use client'; 'use client';
import { FunctionComponent, useState } from 'react'; import { FunctionComponent, useState } from 'react';
import { useTranslations } from 'next-intl'; import { useTranslations } from 'next-intl';
import { Checkbox, FormControlLabel, Paper, Stack, TextField, Typography } from '@mui/material'; import { Checkbox, CircularProgress, FormControlLabel, Paper, Stack, TextField, Typography } from '@mui/material';
import { AppButton, AppIcon, Money, PhoneNumberField } from '@/components'; import { AppButton, AppIcon, Money, PhoneNumberField } from '@/components';
import { digitsOnly } from '@/utils'; import { digitsOnly } from '@/utils';
import { useCheckEligibility } from '@/services/bnpl'; import { useCheckEligibility } from '@/services/bnpl';
@@ -138,7 +138,7 @@ const EligibilityStep: FunctionComponent<EligibilityStepProps> = ({
label={t('mobile_label')} label={t('mobile_label')}
value={sessionMobile} value={sessionMobile}
onChange={() => undefined} onChange={() => undefined}
disabled slotProps={{ input: { readOnly: true } }}
fullWidth fullWidth
/> />
@@ -159,8 +159,9 @@ const EligibilityStep: FunctionComponent<EligibilityStepProps> = ({
size="large" size="large"
disabled={!consent || check.isPending} disabled={!consent || check.isPending}
onClick={handleSubmit} onClick={handleSubmit}
startIcon={check.isPending ? <CircularProgress size={18} color="inherit" /> : undefined}
> >
{t('check_eligibility')} {check.isPending ? t('checking_eligibility') : t('check_eligibility')}
</AppButton> </AppButton>
<AppButton variant="text" color="primary" onClick={onPayWithCard}> <AppButton variant="text" color="primary" onClick={onPayWithCard}>
{t('pay_with_card')} {t('pay_with_card')}
@@ -2,7 +2,7 @@
import { FunctionComponent } from 'react'; import { FunctionComponent } from 'react';
import { useTranslations } from 'next-intl'; import { useTranslations } from 'next-intl';
import { Box, ButtonBase, Paper, Stack, Typography } from '@mui/material'; import { Box, ButtonBase, Paper, Stack, Typography } from '@mui/material';
import { AppButton, AppIcon, EmptyState, Money } from '@/components'; import { AppButton, AppIcon, BnplProviderLogo, EmptyState, Money } from '@/components';
import type { BnplOptions, BnplProvider, ProviderCode } from '@/services/bnpl/types'; import type { BnplOptions, BnplProvider, ProviderCode } from '@/services/bnpl/types';
interface MethodStepProps { interface MethodStepProps {
@@ -13,15 +13,6 @@ interface MethodStepProps {
onPayWithCard: () => void; onPayWithCard: () => void;
} }
/** Two-letter provider glyph for the logo stand-in (real logos land with the provider assets). */
const PROVIDER_GLYPH: Record<ProviderCode, string> = {
digipay: 'DG',
snapppay: 'SP',
balinyaar: 'ب',
tara: 'TA',
torobpay: 'TP',
};
/** /**
* D1 · روش پرداخت the branch off C6. Shows the payable amount, the full-card option (returns to the f9 * D1 · روش پرداخت the branch off C6. Shows the payable amount, the full-card option (returns to the f9
* card flow never rebuilt here), and the installment providers loaded **from the contract/mock** (never * card flow never rebuilt here), and the installment providers loaded **from the contract/mock** (never
@@ -146,22 +137,7 @@ function ProviderOption({
}} }}
> >
<Stack direction="row" sx={{ alignItems: 'center', gap: 1.5 }}> <Stack direction="row" sx={{ alignItems: 'center', gap: 1.5 }}>
<Box <BnplProviderLogo providerCode={provider.providerCode} size={40} />
sx={{
width: 40,
height: 28,
borderRadius: 1,
flex: 'none',
display: 'grid',
placeItems: 'center',
fontWeight: 800,
fontSize: 11,
color: 'var(--bal-secondary-dark)',
backgroundColor: 'var(--bal-secondary-soft)',
}}
>
{PROVIDER_GLYPH[provider.providerCode]}
</Box>
<Stack sx={{ flex: 1, gap: 0.25 }}> <Stack sx={{ flex: 1, gap: 0.25 }}>
<Typography variant="body2" sx={{ fontWeight: 700 }}> <Typography variant="body2" sx={{ fontWeight: 700 }}>
{t(`provider_${provider.providerCode}`)} {t(`provider_${provider.providerCode}`)}
@@ -3,10 +3,13 @@ import { FunctionComponent } from 'react';
import { useTranslations } from 'next-intl'; import { useTranslations } from 'next-intl';
import { Paper, Stack, Typography } from '@mui/material'; import { Paper, Stack, Typography } from '@mui/material';
import { AppButton, BnplPlanCard, EmptyState, Money } from '@/components'; import { AppButton, BnplPlanCard, EmptyState, Money } from '@/components';
import { parseIrr } from '@/utils';
import type { BnplPlanOption, ProviderCode } from '@/services/bnpl/types'; import type { BnplPlanOption, ProviderCode } from '@/services/bnpl/types';
interface PlanStepProps { interface PlanStepProps {
providerCode: ProviderCode; providerCode: ProviderCode;
/** D1's payable gross — the interest-free baseline `BnplPlanCard`'s fee delta compares against. */
orderAmountIrr: string;
plans: BnplPlanOption[]; plans: BnplPlanOption[];
selectedPlanId: string | null; selectedPlanId: string | null;
onSelectPlan: (planId: string) => void; onSelectPlan: (planId: string) => void;
@@ -14,6 +17,13 @@ interface PlanStepProps {
onBack: () => void; onBack: () => void;
} }
/** The same term/installment-count label `BnplPlanCard` shows — reused here to name the header's total. */
function termLabelFor(plan: BnplPlanOption, t: ReturnType<typeof useTranslations>): string {
return plan.termMonths != null
? t('plan_term_months', { months: plan.termMonths })
: t('plan_installments', { count: plan.installmentCount });
}
/** /**
* D2 · انتخاب طرح اقساط the plan selector for the chosen provider. Shows the total amount and the plan * D2 · انتخاب طرح اقساط the plan selector for the chosen provider. Shows the total amount and the plan
* options the contract returned (monthly amount + down-payment %) as a single-select terracotta card group. * options the contract returned (monthly amount + down-payment %) as a single-select terracotta card group.
@@ -22,6 +32,7 @@ interface PlanStepProps {
*/ */
const PlanStep: FunctionComponent<PlanStepProps> = ({ const PlanStep: FunctionComponent<PlanStepProps> = ({
providerCode, providerCode,
orderAmountIrr,
plans, plans,
selectedPlanId, selectedPlanId,
onSelectPlan, onSelectPlan,
@@ -43,8 +54,11 @@ const PlanStep: FunctionComponent<PlanStepProps> = ({
} }
// The plan total is a per-plan served figure (interest-free plans = order gross; fee plans add the fee). // The plan total is a per-plan served figure (interest-free plans = order gross; fee plans add the fee).
// Use the selected plan's total, falling back to the first plan's for the header before any selection. // No default fallback to plans[0] — the header only shows a total once a plan is actually selected, so
const shownPlan = plans.find((p) => p.planId === selectedPlanId) ?? plans[0]; // it never silently morphs before the user has chosen anything.
const shownPlan = plans.find((p) => p.planId === selectedPlanId) ?? null;
const feeIrr = shownPlan ? (parseIrr(shownPlan.totalIrr) - parseIrr(orderAmountIrr)).toString() : null;
const hasFee = shownPlan != null && shownPlan.feePercent > 0 && feeIrr != null && parseIrr(feeIrr) > BigInt(0);
return ( return (
<Stack sx={{ gap: 2 }}> <Stack sx={{ gap: 2 }}>
@@ -52,23 +66,39 @@ const PlanStep: FunctionComponent<PlanStepProps> = ({
{t('plan_title', { provider: t(`provider_${providerCode}`) })} {t('plan_title', { provider: t(`provider_${providerCode}`) })}
</Typography> </Typography>
<Paper {shownPlan ? (
elevation={0} <Paper
sx={{ p: 1.5, borderRadius: 2, border: '1px solid', borderColor: 'divider' }} elevation={0}
> sx={{ p: 1.5, borderRadius: 2, border: '1px solid', borderColor: 'divider' }}
<Stack direction="row" sx={{ justifyContent: 'space-between', alignItems: 'center' }}> >
<Typography variant="body2" sx={{ color: 'text.secondary' }}> <Stack sx={{ gap: 0.5 }}>
{t('total_amount')} <Stack direction="row" sx={{ justifyContent: 'space-between', alignItems: 'center' }}>
</Typography> <Typography variant="body2" sx={{ color: 'text.secondary' }}>
<Money amountIrr={shownPlan.totalIrr} tone="emphasis" size="sm" /> {t('total_amount_named', { plan: termLabelFor(shownPlan, t) })}
</Stack> </Typography>
</Paper> <Money amountIrr={shownPlan.totalIrr} tone="emphasis" size="sm" />
</Stack>
{hasFee && feeIrr != null ? (
<Stack direction="row" sx={{ gap: 0.25, alignItems: 'baseline', justifyContent: 'flex-end' }}>
<Typography variant="caption" sx={{ color: 'var(--bal-money-emphasis)' }}>
+
</Typography>
<Money amountIrr={feeIrr} size="sm" sx={{ color: 'var(--bal-money-emphasis)' }} />
<Typography variant="caption" sx={{ color: 'var(--bal-money-emphasis)' }}>
{t('plan_fee_amount_suffix')}
</Typography>
</Stack>
) : null}
</Stack>
</Paper>
) : null}
<Stack sx={{ gap: 1 }}> <Stack sx={{ gap: 1 }}>
{plans.map((plan) => ( {plans.map((plan) => (
<BnplPlanCard <BnplPlanCard
key={plan.planId} key={plan.planId}
plan={plan} plan={plan}
orderAmountIrr={orderAmountIrr}
selected={selectedPlanId === plan.planId} selected={selectedPlanId === plan.planId}
onSelect={onSelectPlan} onSelect={onSelectPlan}
/> />
@@ -1,7 +1,7 @@
'use client'; 'use client';
import { Suspense } from 'react'; import { Suspense } from 'react';
import { useLocale, useTranslations } from 'next-intl'; import { useLocale, useTranslations } from 'next-intl';
import { useRouter, useSearchParams } from 'next/navigation'; import { notFound, useRouter, useSearchParams } from 'next/navigation';
import { Stack } from '@mui/material'; import { Stack } from '@mui/material';
import { AppButton, AppLoading, EmptyState } from '@/components'; import { AppButton, AppLoading, EmptyState } from '@/components';
import { ROUTES } from '@/constants'; import { ROUTES } from '@/constants';
@@ -22,6 +22,12 @@ import type { BnplHandoffOutcome, ProviderCode } from '@/services/bnpl/types';
* real path the `redirectUrl` is the provider's absolute URL and this page is never reached. * real path the `redirectUrl` is the provider's absolute URL and this page is never reached.
*/ */
export default function BnplGatewayPage() { export default function BnplGatewayPage() {
// A test harness must never be reachable in a production build — mirrors how the card-gateway harness
// was retired (refinement-phase-4). Unlike the card path, BNPL stays mock-primary, so this one is
// env-gated rather than deleted: still reachable in `next dev`, a clean 404 everywhere else.
if (process.env.NODE_ENV !== 'development') {
notFound();
}
return ( return (
<Suspense fallback={<AppLoading />}> <Suspense fallback={<AppLoading />}>
<BnplGatewayScreen /> <BnplGatewayScreen />
@@ -2,8 +2,8 @@
import { Suspense, useState } from 'react'; import { Suspense, useState } from 'react';
import { useLocale, useTranslations } from 'next-intl'; import { useLocale, useTranslations } from 'next-intl';
import { useRouter, useSearchParams } from 'next/navigation'; import { useRouter, useSearchParams } from 'next/navigation';
import { Paper, Skeleton, Stack, Typography } from '@mui/material'; import { Skeleton, Stack, Typography } from '@mui/material';
import { AppButton, AppIcon, AppLoading, StepperHeader } from '@/components'; import { AppButton, AppLoading, PaymentStateCard, StepperHeader } from '@/components';
import { ROUTES } from '@/constants'; import { ROUTES } from '@/constants';
import { useAuth } from '@/context/auth'; import { useAuth } from '@/context/auth';
import { useBnplOptions } from '@/services/bnpl'; import { useBnplOptions } from '@/services/bnpl';
@@ -57,38 +57,47 @@ function BnplCheckoutScreen() {
if (!validId) { if (!validId) {
return ( return (
<MessageCard icon="error" tone="var(--bal-error)" title={t('error_title')} ctaLabel={tb('bd_my_bookings')} onCta={toBookings} /> <PaymentStateCard icon="error" tone="var(--bal-error)" title={t('error_title')}>
<AppButton variant="contained" color="primary" onClick={toBookings}>
{tb('bd_my_bookings')}
</AppButton>
</PaymentStateCard>
); );
} }
if (isError) { if (isError) {
return <MessageCard icon="error" tone="var(--bal-error)" title={t('error_title')} body={t('error_body')} ctaLabel={tc('retry')} onCta={() => refetch()} />; return (
<PaymentStateCard icon="error" tone="var(--bal-error)" title={t('error_title')} body={t('error_body')}>
<AppButton variant="contained" color="primary" onClick={() => refetch()}>
{tc('retry')}
</AppButton>
</PaymentStateCard>
);
} }
if (isLoading || !options) return <WizardSkeleton />; if (isLoading || !options) return <WizardSkeleton />;
// Only an accepted, awaiting-payment request is payable — converge/explain otherwise (mirrors C6). // Only an accepted, awaiting-payment request is payable — converge/explain otherwise (mirrors C6).
if (options.requestStatus === 'converted') { if (options.requestStatus === 'converted') {
return ( return (
<MessageCard <PaymentStateCard icon="verified" tone="var(--bal-success)" title={tp('already_paid_title')} body={tp('already_paid_body')}>
icon="verified" <AppButton variant="contained" color="primary" onClick={toRequest}>
tone="var(--bal-success)" {tb('converted_cta')}
title={tp('already_paid_title')} </AppButton>
body={tp('already_paid_body')} </PaymentStateCard>
ctaLabel={tb('converted_cta')}
onCta={toRequest}
/>
); );
} }
if (options.requestStatus !== 'accepted_awaiting_payment') { if (options.requestStatus !== 'accepted_awaiting_payment') {
const expired = options.requestStatus === 'payment_deadline_expired'; const expired = options.requestStatus === 'payment_deadline_expired';
return ( return (
<MessageCard <PaymentStateCard
icon="pending" icon="pending"
tone="var(--bal-warning)" tone="var(--bal-warning)"
title={expired ? tp('window_expired_title') : tp('not_payable_title')} title={expired ? tp('window_expired_title') : tp('not_payable_title')}
body={expired ? tp('window_expired_body') : undefined} body={expired ? tp('window_expired_body') : undefined}
ctaLabel={t('pay_with_card')} >
onCta={toCard} <AppButton variant="contained" color="primary" onClick={toCard}>
/> {t('pay_with_card')}
</AppButton>
</PaymentStateCard>
); );
} }
@@ -139,6 +148,7 @@ function BnplCheckoutScreen() {
{step === 'plan' && providerCode && activeProvider ? ( {step === 'plan' && providerCode && activeProvider ? (
<PlanStep <PlanStep
providerCode={providerCode} providerCode={providerCode}
orderAmountIrr={options.orderAmountIrr}
plans={activeProvider.plans} plans={activeProvider.plans}
selectedPlanId={planId} selectedPlanId={planId}
onSelectPlan={setPlanId} onSelectPlan={setPlanId}
@@ -175,39 +185,6 @@ function BnplCheckoutScreen() {
); );
} }
function MessageCard({
icon,
tone,
title,
body,
ctaLabel,
onCta,
}: {
icon: string;
tone: string;
title: string;
body?: string;
ctaLabel: string;
onCta: () => void;
}) {
return (
<Paper elevation={0} sx={{ p: 4, textAlign: 'center', border: '1px solid', borderColor: 'divider', borderRadius: 2 }}>
<AppIcon icon={icon} size={44} color={tone} />
<Typography variant="subtitle1" sx={{ fontWeight: 700, mt: 1, mb: body ? 0.5 : 2 }}>
{title}
</Typography>
{body ? (
<Typography variant="body2" sx={{ color: 'text.secondary', mb: 2 }}>
{body}
</Typography>
) : null}
<AppButton variant="contained" color="primary" onClick={onCta}>
{ctaLabel}
</AppButton>
</Paper>
);
}
function WizardSkeleton() { function WizardSkeleton() {
return ( return (
<Stack sx={{ gap: 2 }}> <Stack sx={{ gap: 2 }}>
@@ -1,10 +1,10 @@
'use client'; 'use client';
import { Suspense, useEffect, useRef, type ReactNode } from 'react'; import { Suspense, useEffect, useRef } from 'react';
import { useLocale, useTranslations } from 'next-intl'; import { useLocale, useTranslations } from 'next-intl';
import { useRouter, useSearchParams } from 'next/navigation'; import { useRouter, useSearchParams } from 'next/navigation';
import { useQueryClient } from '@tanstack/react-query'; import { useQueryClient } from '@tanstack/react-query';
import { CircularProgress, Paper, Stack, Typography } from '@mui/material'; import { CircularProgress } from '@mui/material';
import { AppButton, AppIcon, AppLoading } from '@/components'; import { AppButton, AppLoading, PaymentStateCard } from '@/components';
import { ROUTES } from '@/constants'; import { ROUTES } from '@/constants';
import { useAcceptBnplSchedule, useBnplOrder } from '@/services/bnpl'; import { useAcceptBnplSchedule, useBnplOrder } from '@/services/bnpl';
import { invalidateAfterBnplSettlement } from '@/services/bnpl/invalidations'; import { invalidateAfterBnplSettlement } from '@/services/bnpl/invalidations';
@@ -41,6 +41,7 @@ export default function BnplReturnPage() {
function BnplReturnScreen() { function BnplReturnScreen() {
const t = useTranslations('bnpl'); const t = useTranslations('bnpl');
const tp = useTranslations('payment'); const tp = useTranslations('payment');
const tb = useTranslations('booking');
const locale = useLocale(); const locale = useLocale();
const router = useRouter(); const router = useRouter();
const params = useSearchParams(); const params = useSearchParams();
@@ -97,11 +98,13 @@ function BnplReturnScreen() {
if (!validId) { if (!validId) {
return ( return (
<StateCard icon="error" tone="var(--bal-error)" title={t('error_title')}> <PaymentStateCard icon="error" tone="var(--bal-error)" title={t('error_title')}>
{/* No recoverable request id the label must match the destination (the bookings list), never
promise a card-payment action the click can't perform. */}
<AppButton variant="contained" onClick={() => router.replace(`/${locale}${ROUTES.BOOKINGS}`)}> <AppButton variant="contained" onClick={() => router.replace(`/${locale}${ROUTES.BOOKINGS}`)}>
{t('pay_with_card')} {tb('bd_my_bookings')}
</AppButton> </AppButton>
</StateCard> </PaymentStateCard>
); );
} }
@@ -109,20 +112,20 @@ function BnplReturnScreen() {
// The payment window lapsed during the handoff — card payment is impossible now, so route to the // The payment window lapsed during the handoff — card payment is impossible now, so route to the
// request (not the card checkout). Reuse the f9 window-expired copy + the matching back-to-request CTA. // request (not the card checkout). Reuse the f9 window-expired copy + the matching back-to-request CTA.
return ( return (
<StateCard icon="pending" tone="var(--bal-warning)" title={tp('window_expired_title')} body={tp('window_expired_body')}> <PaymentStateCard icon="pending" tone="var(--bal-warning)" title={tp('window_expired_title')} body={tp('window_expired_body')}>
<AppButton <AppButton
variant="contained" variant="contained"
onClick={() => router.replace(`/${locale}${ROUTES.BOOKING_REQUEST_STATUS}/${requestId}`)} onClick={() => router.replace(`/${locale}${ROUTES.BOOKING_REQUEST_STATUS}/${requestId}`)}
> >
{tp('back_to_request')} {tp('back_to_request')}
</AppButton> </AppButton>
</StateCard> </PaymentStateCard>
); );
} }
if (failed) { if (failed) {
return ( return (
<StateCard icon="error" tone="var(--bal-error)" title={t('settle_failed_title')} body={t('settle_failed_body')}> <PaymentStateCard icon="error" tone="var(--bal-error)" title={t('settle_failed_title')} body={t('settle_failed_body')}>
<AppButton <AppButton
color="secondary" color="secondary"
variant="contained" variant="contained"
@@ -137,48 +140,17 @@ function BnplReturnScreen() {
> >
{t('pay_with_card')} {t('pay_with_card')}
</AppButton> </AppButton>
</StateCard> </PaymentStateCard>
); );
} }
// Settle-pending (and the brief succeeded → confirmation hand-off): a calm waiting state. // Settle-pending (and the brief succeeded → confirmation hand-off): a calm waiting state.
return ( return (
<StateCard icon="installments" tone="var(--bal-secondary)" title={t('settling_title')} body={t('settling_body')}> <PaymentStateCard icon="installments" tone="var(--bal-secondary)" title={t('settling_title')} body={t('settling_body')}>
<CircularProgress color="secondary" size="2.5rem" /> <CircularProgress color="secondary" size="2.5rem" />
<AppButton variant="text" disabled={orderQuery.isFetching} onClick={() => orderQuery.refetch()}> <AppButton variant="text" disabled={orderQuery.isFetching} onClick={() => orderQuery.refetch()}>
{t('check_again')} {t('check_again')}
</AppButton> </AppButton>
</StateCard> </PaymentStateCard>
);
}
function StateCard({
icon,
tone,
title,
body,
children,
}: {
icon: string;
tone: string;
title: string;
body?: string;
children?: ReactNode;
}) {
return (
<Paper elevation={0} sx={{ p: 4, textAlign: 'center', border: '1px solid', borderColor: 'divider', borderRadius: 2 }}>
<Stack sx={{ gap: 1.5, alignItems: 'center' }}>
<AppIcon icon={icon} size={44} color={tone} />
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
{title}
</Typography>
{body ? (
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{body}
</Typography>
) : null}
{children}
</Stack>
</Paper>
); );
} }
@@ -1,12 +1,26 @@
'use client'; 'use client';
import { Suspense } from 'react'; import { Suspense, type ReactNode } from 'react';
import { useLocale, useTranslations } from 'next-intl'; import { useLocale, useTranslations } from 'next-intl';
import { useRouter, useSearchParams } from 'next/navigation'; import { useRouter, useSearchParams } from 'next/navigation';
import { Paper, Stack, Typography } from '@mui/material'; import { useSnackbar } from 'notistack';
import { AppButton, AppIcon, AppLoading, Money } from '@/components'; import { Box, Divider, Skeleton, Stack, Typography } from '@mui/material';
import {
AppButton,
AppIcon,
AppIconButton,
AppLoading,
ErrorState,
EscrowExplainer,
Money,
StatusTimeline,
SurfaceCard,
type TimelineNode,
} from '@/components';
import { bookingInvoicePath, ROUTES } from '@/constants'; import { bookingInvoicePath, ROUTES } from '@/constants';
import { useCheckoutSummary } from '@/services/payment'; import { formatShamsiDateTime } from '@/utils';
import { useCheckoutSummary, usePaymentOutcome } from '@/services/payment';
import { CHECKOUT_QUERY_BOOKING_ID, CHECKOUT_QUERY_REQUEST_ID } from '@/services/payment/constants'; import { CHECKOUT_QUERY_BOOKING_ID, CHECKOUT_QUERY_REQUEST_ID } from '@/services/payment/constants';
import { useBnplOrder } from '@/services/bnpl';
import { import {
BNPL_QUERY_PROVIDER, BNPL_QUERY_PROVIDER,
CHECKOUT_METHOD_BNPL, CHECKOUT_METHOD_BNPL,
@@ -14,12 +28,13 @@ import {
} from '@/services/bnpl/constants'; } from '@/services/bnpl/constants';
/** /**
* Post-payment confirmation the booking is now **confirmed** (flipped by cache invalidation on the * Post-payment confirmation a screenshot-worthy receipt (Iranian users screenshot payment receipts): the
* return surface, never a blanket refetch). Links back to the f8 booking detail («مشاهده رزرو») and to * paid total, a copyable LTR کد پیگیری, the Shamsi payment date-time, the payment method, the booking
* the invoice («دانلود فاکتور»). Reused by both the f9 card flow and the f11 BNPL branch: when reached * reference, the escrow reassurance, and a "what happens next" 2-step strip. The booking is now
* with `?method=bnpl` it also renders a «پرداختشده با اقساط» line (a settled BNPL order is, to * **confirmed** (flipped by cache invalidation on the return surface, never a blanket refetch). Reused by
* Balinyaar, a card payment net-of-fee there is no separate BNPL confirmation). Without a `booking_id` * both the f9 card flow and the f11 BNPL branch: reached with `?method=bnpl` it reads the settled BNPL
* (REQ-017/024 unmet on the real path) the deep-links fall back to the bookings list. * order instead of the payment outcome for the tracking reference + paid-at timestamp. Real loading/error
* states a failed fetch must never silently erase the paid amount.
*/ */
export default function CheckoutConfirmationPage() { export default function CheckoutConfirmationPage() {
return ( return (
@@ -31,20 +46,51 @@ export default function CheckoutConfirmationPage() {
function ConfirmationScreen() { function ConfirmationScreen() {
const t = useTranslations('payment'); const t = useTranslations('payment');
const tc = useTranslations('common');
const tBnpl = useTranslations('bnpl'); const tBnpl = useTranslations('bnpl');
const locale = useLocale(); const locale = useLocale();
const router = useRouter(); const router = useRouter();
const params = useSearchParams(); const params = useSearchParams();
const { enqueueSnackbar } = useSnackbar();
const requestId = Number(params.get(CHECKOUT_QUERY_REQUEST_ID)); const requestId = Number(params.get(CHECKOUT_QUERY_REQUEST_ID));
const validRequestId = Number.isInteger(requestId) && requestId > 0;
const bookingIdParam = params.get(CHECKOUT_QUERY_BOOKING_ID); const bookingIdParam = params.get(CHECKOUT_QUERY_BOOKING_ID);
const bookingId = bookingIdParam ? Number(bookingIdParam) : null; const bookingId = bookingIdParam ? Number(bookingIdParam) : null;
const isBnpl = params.get(CHECKOUT_QUERY_METHOD) === CHECKOUT_METHOD_BNPL; const isBnpl = params.get(CHECKOUT_QUERY_METHOD) === CHECKOUT_METHOD_BNPL;
const bnplProvider = params.get(BNPL_QUERY_PROVIDER) ?? ''; const bnplProvider = params.get(BNPL_QUERY_PROVIDER) ?? '';
const { data: summary } = useCheckoutSummary( const {
Number.isInteger(requestId) && requestId > 0 ? requestId : undefined, data: summary,
); isLoading,
isError,
refetch,
} = useCheckoutSummary(validRequestId ? requestId : undefined);
// The receipt reference/timestamp come from whichever leg actually settled this request — the card
// outcome or the BNPL order — never fabricated when the real path hasn't served them yet (REQ-046).
const outcomeQuery = usePaymentOutcome(validRequestId && !isBnpl ? requestId : undefined);
const orderQuery = useBnplOrder(validRequestId && isBnpl ? requestId : undefined);
const trackingCode = isBnpl
? (orderQuery.data?.id != null ? String(orderQuery.data.id) : null)
: (outcomeQuery.data?.trackingCode ?? null);
const paidAt = isBnpl ? (orderQuery.data?.settledAt ?? null) : (outcomeQuery.data?.paidAt ?? null);
const methodLabel = isBnpl
? t('method_bnpl_provider', { provider: bnplProvider ? tBnpl(`provider_${bnplProvider}`) : tBnpl('installments_heading') })
: t('method_card');
const nextStepsNodes: TimelineNode[] = [
{ key: 'nurse_notified', label: t('next_step_nurse_notified'), state: 'completed' },
{ key: 'visit_checkin', label: t('next_step_visit_checkin'), state: 'pending' },
];
const handleCopy = () => {
if (!trackingCode) return;
navigator.clipboard.writeText(trackingCode).then(() => {
enqueueSnackbar(t('tracking_code_copied'), { variant: 'success' });
});
};
return ( return (
<Stack sx={{ gap: 3, alignItems: 'center', textAlign: 'center' }}> <Stack sx={{ gap: 3, alignItems: 'center', textAlign: 'center' }}>
@@ -58,29 +104,62 @@ function ConfirmationScreen() {
</Typography> </Typography>
</Stack> </Stack>
{summary ? ( {isLoading ? (
<Paper <ReceiptSkeleton />
elevation={0} ) : isError || !summary ? (
sx={{ p: 2.5, borderRadius: 2, border: '1px solid', borderColor: 'divider', width: '100%' }} <ErrorState message={t('error_body')} retryLabel={tc('retry')} onRetry={() => refetch()} />
> ) : (
<Stack sx={{ gap: 0.5, alignItems: 'center' }}> <SurfaceCard sx={{ width: '100%' }}>
<Stack sx={{ gap: 1.5, alignItems: 'center' }}>
<Typography variant="body2" sx={{ color: 'text.secondary' }}> <Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('total_paid_label')} {t('total_paid_label')}
</Typography> </Typography>
<Money amountIrr={summary.totalIrr} tone="emphasis" size="lg" /> <Money amountIrr={summary.totalIrr} tone="emphasis" size="xl" sx={{ fontWeight: 800 }} />
<Typography variant="body2" sx={{ color: 'text.secondary' }}> <Typography variant="body2" sx={{ color: 'text.secondary' }}>
{summary.variantLabel} · {summary.nurseName} {summary.variantLabel} · {summary.nurseName}
</Typography> </Typography>
{isBnpl ? (
<Typography variant="caption" sx={{ color: 'var(--bal-secondary)', fontWeight: 500, mt: 0.5 }}> <Divider sx={{ width: '100%', my: 0.5 }} />
{tBnpl('paid_via_installments', {
provider: bnplProvider ? tBnpl(`provider_${bnplProvider}`) : tBnpl('installments_heading'), <Stack sx={{ width: '100%', gap: 1 }}>
})} {trackingCode ? (
</Typography> <ReceiptRow label={t('receipt_tracking_code_label')}>
) : null} <Stack direction="row" sx={{ alignItems: 'center', gap: 0.5 }}>
<Box component="span" dir="ltr" sx={{ fontWeight: 700 }}>
{trackingCode}
</Box>
<AppIconButton icon="copy" size="small" title={t('copy_tracking_code')} onClick={handleCopy} />
</Stack>
</ReceiptRow>
) : null}
{paidAt ? (
<ReceiptRow label={t('receipt_paid_at_label')} value={formatShamsiDateTime(paidAt, locale)} />
) : null}
<ReceiptRow label={t('receipt_method_label')} value={methodLabel} />
{bookingId != null ? (
<ReceiptRow label={t('receipt_booking_ref_label')}>
<Box component="span" dir="ltr" sx={{ fontWeight: 700 }}>
{bookingId}
</Box>
</ReceiptRow>
) : null}
</Stack>
</Stack> </Stack>
</Paper> </SurfaceCard>
) : null} )}
<Stack sx={{ width: '100%' }}>
<EscrowExplainer />
</Stack>
<SurfaceCard sx={{ width: '100%' }}>
<Stack sx={{ gap: 1.5 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>
{t('next_steps_title')}
</Typography>
<StatusTimeline nodes={nextStepsNodes} />
</Stack>
</SurfaceCard>
<Stack sx={{ gap: 1, width: '100%' }}> <Stack sx={{ gap: 1, width: '100%' }}>
<AppButton <AppButton
@@ -107,3 +186,28 @@ function ConfirmationScreen() {
</Stack> </Stack>
); );
} }
function ReceiptRow({ label, value, children }: { label: string; value?: string; children?: ReactNode }) {
return (
<Stack direction="row" sx={{ justifyContent: 'space-between', alignItems: 'center', gap: 2 }}>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{label}
</Typography>
{children ?? (
<Typography variant="body2" sx={{ fontWeight: 700 }}>
{value}
</Typography>
)}
</Stack>
);
}
function ReceiptSkeleton() {
return (
<Stack sx={{ gap: 1.5, width: '100%' }}>
<Skeleton variant="text" width="40%" height={24} sx={{ mx: 'auto' }} />
<Skeleton variant="text" width="60%" height={48} sx={{ mx: 'auto' }} />
<Skeleton variant="rounded" height={140} />
</Stack>
);
}
@@ -2,17 +2,21 @@
import { Suspense, useRef } from 'react'; import { Suspense, useRef } from 'react';
import { useLocale, useTranslations } from 'next-intl'; import { useLocale, useTranslations } from 'next-intl';
import { useRouter, useSearchParams } from 'next/navigation'; import { useRouter, useSearchParams } from 'next/navigation';
import { Paper, Skeleton, Stack, Typography } from '@mui/material'; import { Avatar, Paper, Skeleton, Stack, Typography } from '@mui/material';
import { import {
AppButton, AppButton,
AppIcon, AppIcon,
AppLoading, AppLoading,
CountdownTimer, CountdownTimer,
EscrowNotice, EscrowExplainer,
Money,
PaymentStateCard,
PriceBreakdown, PriceBreakdown,
StatusChip, StatusChip,
TrustBadge,
} from '@/components'; } from '@/components';
import AppAlert from '@/components/common/AppAlert'; import AppAlert from '@/components/common/AppAlert';
import StickyActionBar from '@/components/common/StickyActionBar';
import { ROUTES } from '@/constants'; import { ROUTES } from '@/constants';
import { ApiError } from '@/lib/api/errors'; import { ApiError } from '@/lib/api/errors';
import { formatShamsiDate, localeTag } from '@/utils'; import { formatShamsiDate, localeTag } from '@/utils';
@@ -25,10 +29,11 @@ import {
import type { CheckoutSummaryDto } from '@/services/payment/types'; import type { CheckoutSummaryDto } from '@/services/payment/types';
/** /**
* C6 خلاصه و پرداخت (summary & pay). The acceptance badge, the served & reconciling * C6 خلاصه و پرداخت (summary & pay). The acceptance badge, the identity moment (nurse avatar + verified
* service-cost / commission / VAT / total breakdown, the load-bearing escrow trust notice, and the * badge), a prominent total, the served & reconciling service-cost / commission / VAT / total breakdown,
* «ادامه پرداخت » CTA that initiates the card payment and follows the gateway redirect. Reached from * the load-bearing escrow trust notice, and a safe-area-aware sticky pay bar that initiates the card
* C5's accept CTA with `?request_id=`. `useSearchParams` needs a Suspense boundary. * payment and follows the gateway redirect. Reached from C5's accept CTA with `?request_id=`.
* `useSearchParams` needs a Suspense boundary.
*/ */
export default function CheckoutPage() { export default function CheckoutPage() {
return ( return (
@@ -60,26 +65,20 @@ function CheckoutScreen() {
// `checkout_summary/undefined` (a manual refetch() bypasses the query's `enabled` gate). // `checkout_summary/undefined` (a manual refetch() bypasses the query's `enabled` gate).
if (!validId) { if (!validId) {
return ( return (
<MessageCard <PaymentStateCard icon="error" tone="var(--bal-error)" title={t('error_title')} body={t('invalid_link_body')}>
icon="error" <AppButton variant="contained" color="primary" onClick={() => router.replace(`/${locale}${ROUTES.BOOKINGS}`)}>
tone="var(--bal-error)" {tb('bd_my_bookings')}
title={t('error_title')} </AppButton>
body={t('invalid_link_body')} </PaymentStateCard>
ctaLabel={tb('bd_my_bookings')}
onCta={() => router.replace(`/${locale}${ROUTES.BOOKINGS}`)}
/>
); );
} }
if (isError) { if (isError) {
return ( return (
<MessageCard <PaymentStateCard icon="error" tone="var(--bal-error)" title={t('error_title')} body={t('error_body')}>
icon="error" <AppButton variant="contained" color="primary" onClick={() => refetch()}>
tone="var(--bal-error)" {tc('retry')}
title={t('error_title')} </AppButton>
body={t('error_body')} </PaymentStateCard>
ctaLabel={tc('retry')}
onCta={() => refetch()}
/>
); );
} }
if (isLoading || !summary) return <CheckoutSkeleton />; if (isLoading || !summary) return <CheckoutSkeleton />;
@@ -92,27 +91,35 @@ function CheckoutScreen() {
// Anything other than "awaiting payment" cannot show a pay CTA — converge or explain instead. // Anything other than "awaiting payment" cannot show a pay CTA — converge or explain instead.
if (summary.requestStatus === 'converted') { if (summary.requestStatus === 'converted') {
return ( return (
<MessageCard <PaymentStateCard
icon="verified" icon="verified"
tone="var(--bal-success)" tone="var(--bal-success)"
title={t('already_paid_title')} title={t('already_paid_title')}
body={t('already_paid_body')} body={t('already_paid_body')}
ctaLabel={tb('converted_cta')} >
onCta={() => router.replace(returnUrl())} <AppButton variant="contained" color="primary" onClick={() => router.replace(returnUrl())}>
/> {tb('converted_cta')}
</AppButton>
</PaymentStateCard>
); );
} }
if (summary.requestStatus !== 'accepted_awaiting_payment') { if (summary.requestStatus !== 'accepted_awaiting_payment') {
const expired = summary.requestStatus === 'payment_deadline_expired'; const expired = summary.requestStatus === 'payment_deadline_expired';
return ( return (
<MessageCard <PaymentStateCard
icon="pending" icon="pending"
tone="var(--bal-warning)" tone="var(--bal-warning)"
title={expired ? t('window_expired_title') : t('not_payable_title')} title={expired ? t('window_expired_title') : t('not_payable_title')}
body={expired ? t('window_expired_body') : undefined} body={expired ? t('window_expired_body') : undefined}
ctaLabel={t('back_to_request')} >
onCta={() => router.replace(`/${locale}${ROUTES.BOOKING_REQUEST_STATUS}/${requestId}`)} <AppButton
/> variant="contained"
color="primary"
onClick={() => router.replace(`/${locale}${ROUTES.BOOKING_REQUEST_STATUS}/${requestId}`)}
>
{t('back_to_request')}
</AppButton>
</PaymentStateCard>
); );
} }
@@ -159,10 +166,29 @@ function CheckoutScreen() {
<Typography variant="h6" component="h1"> <Typography variant="h6" component="h1">
{t('title_checkout')} {t('title_checkout')}
</Typography> </Typography>
<AppButton
variant="text"
color="primary"
size="small"
startIcon="chevron_start"
onClick={() => router.push(`/${locale}${ROUTES.BOOKING_REQUEST_STATUS}/${requestId}`)}
sx={{ px: 0.5 }}
>
{t('back_to_request')}
</AppButton>
</Stack> </Stack>
<EngagementSummary summary={summary} locale={locale} /> <EngagementSummary summary={summary} locale={locale} />
{/* The prominent total the single most important figure on a payment screen, never buried in the
breakdown. Same served `totalIrr` PriceBreakdown reconciles below; never recomputed. */}
<Stack sx={{ alignItems: 'center', gap: 0.25 }}>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('total_payable_label')}
</Typography>
<Money amountIrr={summary.totalIrr} tone="emphasis" size="xl" sx={{ fontWeight: 800 }} />
</Stack>
{summary.paymentDeadlineAt ? ( {summary.paymentDeadlineAt ? (
<Paper elevation={0} sx={{ p: 2, borderRadius: 2, border: '1px solid', borderColor: 'divider' }}> <Paper elevation={0} sx={{ p: 2, borderRadius: 2, border: '1px solid', borderColor: 'divider' }}>
<CountdownTimer <CountdownTimer
@@ -191,42 +217,66 @@ function CheckoutScreen() {
totalAmountIrr={summary.totalIrr} totalAmountIrr={summary.totalIrr}
/> />
<EscrowNotice /> <EscrowExplainer />
{inlineError ? ( {/* Spacer so the sticky bar never overlaps the last scrollable content on a short viewport. */}
<AppAlert severity="error" variant="outlined" sx={{ marginY: 0 }}> <Stack sx={{ pb: 1 }} />
{inlineError}
</AppAlert>
) : null}
<Stack sx={{ gap: 1 }}> <StickyActionBar>
<AppButton <Stack sx={{ gap: 1 }}>
color="secondary" {inlineError ? (
variant="contained" <AppAlert severity="error" variant="outlined" sx={{ marginY: 0 }}>
size="large" {inlineError}
disabled={busy} </AppAlert>
onClick={handlePay} ) : null}
sx={{ py: 1.25 }}
> <Stack direction="row" sx={{ alignItems: 'center', justifyContent: 'space-between', gap: 2 }}>
{initiate.isPending ? t('state_initiating') : initiate.isSuccess ? t('state_redirecting') : t('cta_pay')} <Stack sx={{ gap: 0 }}>
</AppButton> <Typography variant="caption" sx={{ color: 'text.secondary' }}>
{/* The f11 BNPL branch (D1): «پرداخت اقساطی» → the installment wizard, reached with `?request_id=`. */} {t('row_total')}
{BNPL_ENABLED ? ( </Typography>
<AppButton <Money amountIrr={summary.totalIrr} tone="emphasis" size="md" sx={{ fontWeight: 800 }} />
variant="outlined" </Stack>
color="secondary" <AppButton
startIcon="installments" color="secondary"
onClick={() => router.push(`/${locale}${ROUTES.CHECKOUT_BNPL}?${CHECKOUT_QUERY_REQUEST_ID}=${requestId}`)} variant="contained"
> size="large"
{t('bnpl_option')} disabled={busy}
</AppButton> onClick={handlePay}
) : null} endIcon="forward"
</Stack> sx={{ py: 1.25, flex: 'none', minWidth: 168 }}
>
{initiate.isPending ? t('state_initiating') : initiate.isSuccess ? t('state_redirecting') : t('cta_pay')}
</AppButton>
</Stack>
<Stack direction="row" sx={{ gap: 0.5, alignItems: 'center', justifyContent: 'center' }}>
<AppIcon icon="lock" size={14} color="var(--bal-text-secondary)" />
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{t('secure_gateway_notice')}
</Typography>
</Stack>
{/* The f11 BNPL branch (D1): «پرداخت اقساطی» → the installment wizard, reached with `?request_id=`. */}
{BNPL_ENABLED ? (
<AppButton
variant="outlined"
color="secondary"
startIcon="installments"
disabled={busy}
onClick={() => router.push(`/${locale}${ROUTES.CHECKOUT_BNPL}?${CHECKOUT_QUERY_REQUEST_ID}=${requestId}`)}
>
{t('bnpl_option')}
</AppButton>
) : null}
</Stack>
</StickyActionBar>
</Stack> </Stack>
); );
} }
/** Nurse/service/schedule mini-summary — page-only composition (C6 needs no address or price-per-unit). */ /** Nurse/service/schedule mini-summary the C6 identity moment: avatar + verified badge answer "who am
* I paying for" at the moment of payment. */
function EngagementSummary({ summary, locale }: { summary: CheckoutSummaryDto; locale: string }) { function EngagementSummary({ summary, locale }: { summary: CheckoutSummaryDto; locale: string }) {
const start = new Date(`${summary.requestedDate}T${summary.requestedTimeStart}`); const start = new Date(`${summary.requestedDate}T${summary.requestedTimeStart}`);
const end = new Date(`${summary.requestedDate}T${summary.requestedTimeEnd}`); const end = new Date(`${summary.requestedDate}T${summary.requestedTimeEnd}`);
@@ -236,54 +286,32 @@ function EngagementSummary({ summary, locale }: { summary: CheckoutSummaryDto; l
}); });
return ( return (
<Paper elevation={0} sx={{ p: 2.5, borderRadius: 2, border: '1px solid', borderColor: 'divider' }}> <Paper elevation={0} sx={{ p: 2.5, borderRadius: 2, border: '1px solid', borderColor: 'divider' }}>
<Stack sx={{ gap: 0.5 }}> <Stack direction="row" sx={{ gap: 1.5, alignItems: 'flex-start' }}>
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}> <Avatar
{summary.variantLabel} src={summary.nurseAvatarUrl ?? undefined}
</Typography> sx={{ width: 48, height: 48, bgcolor: 'var(--bal-primary-soft)', color: 'var(--bal-primary)', fontWeight: 700 }}
<Typography variant="body2" sx={{ color: 'text.secondary' }}> >
{summary.nurseName} · {summary.patientName} {summary.nurseName.trim().charAt(0)}
</Typography> </Avatar>
<Typography variant="body2" sx={{ color: 'text.secondary' }}> <Stack sx={{ gap: 0.5, flex: 1, minWidth: 0 }}>
{formatShamsiDate(start, locale)} · {timeFmt.format(start)} {timeFmt.format(end)} <Stack direction="row" sx={{ gap: 1, alignItems: 'center', flexWrap: 'wrap' }}>
</Typography> <Typography variant="subtitle2" sx={{ fontWeight: 700 }}>
{summary.nurseName}
</Typography>
<TrustBadge state={summary.nurseVerified ? 'verified' : 'unverified'} />
</Stack>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{summary.variantLabel} · {summary.patientName}
</Typography>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{formatShamsiDate(start, locale)} · {timeFmt.format(start)} {timeFmt.format(end)}
</Typography>
</Stack>
</Stack> </Stack>
</Paper> </Paper>
); );
} }
function MessageCard({
icon,
tone,
title,
body,
ctaLabel,
onCta,
}: {
icon: string;
tone: string;
title: string;
body?: string;
ctaLabel: string;
onCta: () => void;
}) {
return (
<Paper elevation={0} sx={{ p: 4, textAlign: 'center', border: '1px solid', borderColor: 'divider', borderRadius: 2 }}>
<AppIcon icon={icon} size={44} color={tone} />
<Typography variant="subtitle1" sx={{ fontWeight: 700, mt: 1, mb: body ? 0.5 : 2 }}>
{title}
</Typography>
{body ? (
<Typography variant="body2" sx={{ color: 'text.secondary', mb: 2 }}>
{body}
</Typography>
) : null}
<AppButton variant="contained" color="primary" onClick={onCta}>
{ctaLabel}
</AppButton>
</Paper>
);
}
function CheckoutSkeleton() { function CheckoutSkeleton() {
return ( return (
<Stack sx={{ gap: 3 }}> <Stack sx={{ gap: 3 }}>
@@ -292,6 +320,7 @@ function CheckoutSkeleton() {
<Skeleton variant="text" width="50%" height={32} /> <Skeleton variant="text" width="50%" height={32} />
</Stack> </Stack>
<Skeleton variant="rounded" height={96} /> <Skeleton variant="rounded" height={96} />
<Skeleton variant="text" width="40%" height={48} sx={{ mx: 'auto' }} />
<Skeleton variant="rounded" height={64} /> <Skeleton variant="rounded" height={64} />
<Skeleton variant="rounded" height={160} /> <Skeleton variant="rounded" height={160} />
<Skeleton variant="rounded" height={56} /> <Skeleton variant="rounded" height={56} />
@@ -1,10 +1,10 @@
'use client'; 'use client';
import { Suspense, useEffect, useRef, type ReactNode } from 'react'; import { Suspense, useEffect, useRef } from 'react';
import { useLocale, useTranslations } from 'next-intl'; import { useLocale, useTranslations } from 'next-intl';
import { useRouter, useSearchParams } from 'next/navigation'; import { useRouter, useSearchParams } from 'next/navigation';
import { useQueryClient } from '@tanstack/react-query'; import { useQueryClient } from '@tanstack/react-query';
import { CircularProgress, Paper, Stack, Typography } from '@mui/material'; import { Paper, Stack, Typography } from '@mui/material';
import { AppButton, AppIcon, AppLoading, PaymentStatusBadge } from '@/components'; import { AppButton, AppLoading, PaymentStateCard, StatusTimeline, type TimelineNode } from '@/components';
import { ROUTES } from '@/constants'; import { ROUTES } from '@/constants';
import { useConfirmGatewayReturn, usePaymentOutcome } from '@/services/payment'; import { useConfirmGatewayReturn, usePaymentOutcome } from '@/services/payment';
import { invalidateAfterPaymentSuccess } from '@/services/payment/invalidations'; import { invalidateAfterPaymentSuccess } from '@/services/payment/invalidations';
@@ -18,10 +18,12 @@ import {
/** /**
* Return-from-gateway surface drives the tail of the checkout state machine: report the return * Return-from-gateway surface drives the tail of the checkout state machine: report the return
* (`useConfirmGatewayReturn`; the capture trigger in the mock, an outcome read on the real path), then a * (`useConfirmGatewayReturn`; the capture trigger in the mock, an outcome read on the real path), then a
* **pending-callback** state backed by the backoff poll ("PSP received ≠ cash in bank" pending is * **pending-callback** state a staged 2-node progress («بازگشت از درگاه » «در انتظار تایید بانک»,
* normal, reflected calmly) until a terminal outcome: succeeded invalidate the booking/request caches * the calm animated `StatusTimeline` `current` pulse) with an expected-duration hint, backed by the
* and hand off to the confirmation screen; failed a retry affordance (a fresh C6 mount = a new attempt * backoff poll ("PSP received ≠ cash in bank" pending is normal, reflected calmly) until a terminal
* with a NEW idempotency key); window lapsed back to the request's terminal card. * outcome: succeeded invalidate the booking/request caches and hand off to the confirmation screen;
* failed a retry affordance (a fresh C6 mount = a new attempt with a NEW idempotency key); window
* lapsed back to the request's terminal card.
*/ */
export default function CheckoutReturnPage() { export default function CheckoutReturnPage() {
return ( return (
@@ -31,6 +33,11 @@ export default function CheckoutReturnPage() {
); );
} }
const PENDING_NODES = (returnedLabel: string, confirmingLabel: string): TimelineNode[] => [
{ key: 'returned', label: returnedLabel, state: 'completed' },
{ key: 'confirming', label: confirmingLabel, state: 'current' },
];
function ReturnScreen() { function ReturnScreen() {
const t = useTranslations('payment'); const t = useTranslations('payment');
const locale = useLocale(); const locale = useLocale();
@@ -87,90 +94,75 @@ function ReturnScreen() {
if (!validId) { if (!validId) {
return ( return (
<StateCard icon="error" tone="var(--bal-error)" title={t('error_title')}> <PaymentStateCard icon="error" tone="var(--bal-error)" title={t('error_title')}>
<AppButton variant="contained" onClick={() => router.replace(`/${locale}${ROUTES.BOOKINGS}`)}> <AppButton variant="contained" onClick={() => router.replace(`/${locale}${ROUTES.BOOKINGS}`)}>
{t('view_booking')} {t('view_booking')}
</AppButton> </AppButton>
</StateCard> </PaymentStateCard>
); );
} }
if (windowExpired) { if (windowExpired) {
return ( return (
<StateCard icon="pending" tone="var(--bal-warning)" title={t('window_expired_title')} body={t('window_expired_body')}> <PaymentStateCard
icon="pending"
tone="var(--bal-warning)"
title={t('window_expired_title')}
body={t('window_expired_body')}
>
<AppButton <AppButton
variant="contained" variant="contained"
onClick={() => router.replace(`/${locale}${ROUTES.BOOKING_REQUEST_STATUS}/${requestId}`)} onClick={() => router.replace(`/${locale}${ROUTES.BOOKING_REQUEST_STATUS}/${requestId}`)}
> >
{t('back_to_request')} {t('back_to_request')}
</AppButton> </AppButton>
</StateCard> </PaymentStateCard>
); );
} }
if (failed) { if (failed) {
return ( return (
<StateCard icon="error" tone="var(--bal-error)" title={t('state_failed_title')} body={t('state_failed_hint')}> <PaymentStateCard icon="error" tone="var(--bal-error)" title={t('state_failed_title')} body={t('state_failed_hint')}>
<PaymentStatusBadge status="failed" /> <Stack sx={{ gap: 1, width: '100%' }}>
<AppButton <AppButton
color="secondary" color="secondary"
variant="contained" variant="contained"
size="large" size="large"
onClick={() => onClick={() =>
router.replace(`/${locale}${ROUTES.CHECKOUT}?${CHECKOUT_QUERY_REQUEST_ID}=${requestId}`) router.replace(`/${locale}${ROUTES.CHECKOUT}?${CHECKOUT_QUERY_REQUEST_ID}=${requestId}`)
} }
> >
{t('retry_payment')} {t('retry_payment')}
</AppButton> </AppButton>
<AppButton <AppButton
variant="text" variant="text"
onClick={() => router.replace(`/${locale}${ROUTES.BOOKING_REQUEST_STATUS}/${requestId}`)} onClick={() => router.replace(`/${locale}${ROUTES.BOOKING_REQUEST_STATUS}/${requestId}`)}
> >
{t('back_to_request')} {t('back_to_request')}
</AppButton> </AppButton>
</StateCard> </Stack>
</PaymentStateCard>
); );
} }
// Pending-callback (and the brief succeeded → confirmation hand-off): a calm waiting state. // Pending-callback (and the brief succeeded → confirmation hand-off): a staged 2-node progress instead
// of a bare spinner+chip+title stack — the flow's calmest, most designed wait state.
return ( return (
<StateCard icon="payment" tone="var(--bal-secondary)" title={t('state_pending_title')} body={t('state_pending_hint')}> <Paper elevation={0} sx={{ p: 4, border: '1px solid', borderColor: 'divider', borderRadius: 2 }}>
<CircularProgress color="primary" size="2.5rem" /> <Stack sx={{ gap: 2.5, alignItems: 'center' }}>
<PaymentStatusBadge status="pending" /> <Typography variant="subtitle1" sx={{ fontWeight: 700, textAlign: 'center' }}>
{/* Manual re-check — covers the bounded poll giving up on a very slow callback. */} {t('state_pending_title')}
<AppButton variant="text" disabled={outcomeQuery.isFetching} onClick={() => outcomeQuery.refetch()}>
{t('check_again')}
</AppButton>
</StateCard>
);
}
function StateCard({
icon,
tone,
title,
body,
children,
}: {
icon: string;
tone: string;
title: string;
body?: string;
children?: ReactNode;
}) {
return (
<Paper elevation={0} sx={{ p: 4, textAlign: 'center', border: '1px solid', borderColor: 'divider', borderRadius: 2 }}>
<Stack sx={{ gap: 1.5, alignItems: 'center' }}>
<AppIcon icon={icon} size={44} color={tone} />
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
{title}
</Typography> </Typography>
{body ? ( <Stack sx={{ alignSelf: 'stretch', maxWidth: 320, mx: 'auto' }}>
<Typography variant="body2" sx={{ color: 'text.secondary' }}> <StatusTimeline nodes={PENDING_NODES(t('stage_returned'), t('stage_confirming'))} />
{body} </Stack>
</Typography> <Typography variant="caption" sx={{ color: 'text.secondary', textAlign: 'center' }}>
) : null} {t('state_pending_duration_hint')}
{children} </Typography>
{/* Manual re-check — covers the bounded poll giving up on a very slow callback. */}
<AppButton variant="text" disabled={outcomeQuery.isFetching} onClick={() => outcomeQuery.refetch()}>
{t('check_again')}
</AppButton>
</Stack> </Stack>
</Paper> </Paper>
); );
@@ -8,12 +8,12 @@ import { useWalletInstallments } from '@/services/bnpl';
import type { WalletInstallmentPlan } from '@/services/bnpl/types'; import type { WalletInstallmentPlan } from '@/services/bnpl/types';
/** /**
* D5 · پیگیری اقساط the Wallet view of active installment plans. It reads `useWalletInstallments` and * D5 · پیگیری اقساط the Wallet «اقساط» section (active installment plans). It reads
* renders **provider-reported** status: an outstanding-balance card (terracotta), the next-installment * `useWalletInstallments` and renders **provider-reported** status: an outstanding-balance card
* date + a provider hand-off «پرداخت زودهنگام» (early-pay is a *provider* action, never a Balinyaar * (terracotta), the next-installment date + a provider hand-off «پرداخت زودهنگام» (early-pay is a
* transaction), the per-installment due list with status chips, and the ownership note (Balinyaar displays, * *provider* action, never a Balinyaar transaction), the per-installment due list with status chips, and
* it does not manage, this schedule). Self-contained under the Wallet route so f12 nurse-earnings content * the ownership note (Balinyaar displays, it does not manage, this schedule). Section body only the
* can land beside it later. * page-level heading + tab strip live in `WalletScreen`.
*/ */
const WalletInstallments: FunctionComponent = () => { const WalletInstallments: FunctionComponent = () => {
const t = useTranslations('bnpl'); const t = useTranslations('bnpl');
@@ -21,11 +21,7 @@ const WalletInstallments: FunctionComponent = () => {
const { data: plans, isLoading, isError, refetch } = useWalletInstallments(); const { data: plans, isLoading, isError, refetch } = useWalletInstallments();
return ( return (
<Stack sx={{ gap: 2, maxWidth: 560, mx: 'auto', width: '100%' }}> <Stack sx={{ gap: 2, width: '100%' }}>
<Typography variant="h6" component="h1">
{t('wallet_title')}
</Typography>
{isLoading ? ( {isLoading ? (
<Stack sx={{ gap: 1.5 }}> <Stack sx={{ gap: 1.5 }}>
<Skeleton variant="rounded" height={128} /> <Skeleton variant="rounded" height={128} />
@@ -0,0 +1,62 @@
'use client';
import { FunctionComponent } from 'react';
import { useLocale, useTranslations } from 'next-intl';
import { Skeleton, Stack, Typography } from '@mui/material';
import { AppLink, EmptyState, ErrorState, Money, PaymentStatusBadge, SurfaceCard } from '@/components';
import { ROUTES } from '@/constants';
import { formatShamsiDateTime } from '@/utils';
import { useWalletHistoryRows } from './useWalletHistoryRows';
/**
* The wallet «پرداختها» section every card + BNPL payment (down-payment) the customer made, newest
* first, with a deep-link to the booking. For a card-paying customer (the default path) this is what
* finally fills the previously permanently-empty Wallet tab.
*/
const WalletPaymentHistory: FunctionComponent = () => {
const t = useTranslations('bnpl');
const tc = useTranslations('common');
const locale = useLocale();
const { rows, isLoading, bothErrored, refetch } = useWalletHistoryRows();
if (isLoading) {
return (
<Stack sx={{ gap: 1.5 }}>
<Skeleton variant="rounded" height={72} />
<Skeleton variant="rounded" height={72} />
</Stack>
);
}
if (bothErrored) {
return <ErrorState message={t('wallet_error_body')} retryLabel={tc('retry')} onRetry={refetch} />;
}
if (rows.length === 0) {
return <EmptyState icon="payment" title={t('history_empty_title')} body={t('history_empty_body')} />;
}
return (
<Stack sx={{ gap: 1.5 }}>
{rows.map((row) => (
<SurfaceCard key={row.key} padding="sm">
<Stack direction="row" sx={{ justifyContent: 'space-between', alignItems: 'center', gap: 2 }}>
<Stack sx={{ gap: 0.25 }}>
<Money amountIrr={row.amountIrr} tone="emphasis" size="sm" sx={{ fontWeight: 700 }} />
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{formatShamsiDateTime(row.createdAt, locale)}
</Typography>
</Stack>
<Stack sx={{ alignItems: 'flex-end', gap: 0.5 }}>
<PaymentStatusBadge status={row.status} />
{row.bookingId != null ? (
<AppLink to={`/${locale}${ROUTES.BOOKINGS}/${row.bookingId}`} sx={{ fontSize: '0.75rem' }}>
{t('history_view_booking')}
</AppLink>
) : null}
</Stack>
</Stack>
</SurfaceCard>
))}
</Stack>
);
};
export default WalletPaymentHistory;
@@ -0,0 +1,66 @@
'use client';
import { FunctionComponent } from 'react';
import { useLocale, useTranslations } from 'next-intl';
import { Skeleton, Stack, Typography } from '@mui/material';
import { AppButton, EmptyState, ErrorState, Money, SurfaceCard } from '@/components';
import { bookingInvoicePath } from '@/constants';
import { formatShamsiDateTime } from '@/utils';
import { useWalletHistoryRows } from './useWalletHistoryRows';
/**
* The wallet «رسیدها» section no receipts endpoint exists; every succeeded, booking-linked payment
* (card or BNPL down-payment) derives its invoice deep-link client-side (a UI join over the same rows the
* «پرداختها» tab renders, filtered to `succeeded` + a known `bookingId` no money math).
*/
const WalletReceipts: FunctionComponent = () => {
const t = useTranslations('bnpl');
const tp = useTranslations('payment');
const tc = useTranslations('common');
const locale = useLocale();
const { rows, isLoading, bothErrored, refetch } = useWalletHistoryRows();
if (isLoading) {
return (
<Stack sx={{ gap: 1.5 }}>
<Skeleton variant="rounded" height={72} />
</Stack>
);
}
if (bothErrored) {
return <ErrorState message={t('wallet_error_body')} retryLabel={tc('retry')} onRetry={refetch} />;
}
const receipts = rows.filter((row) => row.status === 'succeeded' && row.bookingId != null);
if (receipts.length === 0) {
return <EmptyState icon="document" title={t('receipts_empty_title')} body={t('receipts_empty_body')} />;
}
return (
<Stack sx={{ gap: 1.5 }}>
{receipts.map((row) => (
<SurfaceCard key={row.key} padding="sm">
<Stack direction="row" sx={{ justifyContent: 'space-between', alignItems: 'center', gap: 2 }}>
<Stack sx={{ gap: 0.25 }}>
<Money amountIrr={row.amountIrr} tone="emphasis" size="sm" sx={{ fontWeight: 700 }} />
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{formatShamsiDateTime(row.createdAt, locale)}
</Typography>
</Stack>
<AppButton
variant="outlined"
color="primary"
size="small"
startIcon="document"
to={`/${locale}${bookingInvoicePath(row.bookingId as number)}`}
>
{tp('view_invoice_cta')}
</AppButton>
</Stack>
</SurfaceCard>
))}
</Stack>
);
};
export default WalletReceipts;
@@ -0,0 +1,54 @@
'use client';
import { FunctionComponent } from 'react';
import { useLocale, useTranslations } from 'next-intl';
import { Skeleton, Stack, Typography } from '@mui/material';
import { AppLink, EmptyState, ErrorState } from '@/components';
import RefundStatusCard from '@/components/RefundStatusCard';
import { bookingRefundStatusPath } from '@/constants';
import { useMyRefunds } from '@/services/refunds';
/**
* The wallet «استردادها» section every refund the customer owns (REQ-048), each rendered via the shared
* `RefundStatusCard` (step timeline + amount + per-channel ETA) with a link back to its booking.
*/
const WalletRefunds: FunctionComponent = () => {
const t = useTranslations('refunds');
const tw = useTranslations('bnpl');
const tc = useTranslations('common');
const locale = useLocale();
const { data: refunds, isLoading, isError, refetch } = useMyRefunds();
if (isLoading) {
return (
<Stack sx={{ gap: 1.5 }}>
<Skeleton variant="rounded" height={160} />
</Stack>
);
}
if (isError) {
return <ErrorState message={tw('wallet_error_body')} retryLabel={tc('retry')} onRetry={() => refetch()} />;
}
if (!refunds || refunds.length === 0) {
return <EmptyState icon="refunds" title={t('wallet_empty_title')} body={t('wallet_empty_body')} />;
}
return (
<Stack sx={{ gap: 3 }}>
{refunds.map((refund) => (
<Stack key={refund.id} sx={{ gap: 1 }}>
<Stack direction="row" sx={{ justifyContent: 'space-between', alignItems: 'center' }}>
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{t('wallet_booking_label', { id: refund.bookingId })}
</Typography>
<AppLink to={`/${locale}${bookingRefundStatusPath(refund.bookingId)}`} sx={{ fontSize: '0.75rem' }}>
{t('view_refund_status')}
</AppLink>
</Stack>
<RefundStatusCard refund={refund} />
</Stack>
))}
</Stack>
);
};
export default WalletRefunds;
@@ -0,0 +1,57 @@
'use client';
import { FunctionComponent, useState } from 'react';
import { useTranslations } from 'next-intl';
import { Box, Stack, Tab, Tabs } from '@mui/material';
import { AppIcon, PageHeader } from '@/components';
import WalletPaymentHistory from './WalletPaymentHistory';
import WalletInstallments from './WalletInstallments';
import WalletRefunds from './WalletRefunds';
import WalletReceipts from './WalletReceipts';
type WalletTab = 'payments' | 'installments' | 'refunds' | 'receipts';
/**
* /wallet the customer money hub (ui-phase-6). Four sections replace the old installments-only shell so
* a card-paying customer (the default path) finally sees something other than a permanently empty tab:
* «پرداختها» (payment history), «اقساط» (the unchanged f11 D5 installment tracker), «استردادها» (refunds,
* REQ-048), «رسیدها» (client-derived invoice links). All four read at the shell's shared `CONTENT_MAX_WIDTH`
* no local width override.
*/
const WalletScreen: FunctionComponent = () => {
const t = useTranslations('bnpl');
const [tab, setTab] = useState<WalletTab>('payments');
return (
<Stack sx={{ gap: 2 }}>
<PageHeader title={t('wallet_hub_title')} />
<Tabs
value={tab}
onChange={(_event, value: WalletTab) => setTab(value)}
variant="scrollable"
scrollButtons="auto"
allowScrollButtonsMobile
sx={{ borderBottom: '1px solid', borderColor: 'divider' }}
>
<Tab value="payments" label={t('tab_payments')} icon={<AppIcon icon="payment" size={18} />} iconPosition="start" />
<Tab
value="installments"
label={t('tab_installments')}
icon={<AppIcon icon="installments" size={18} />}
iconPosition="start"
/>
<Tab value="refunds" label={t('tab_refunds')} icon={<AppIcon icon="refunds" size={18} />} iconPosition="start" />
<Tab value="receipts" label={t('tab_receipts')} icon={<AppIcon icon="document" size={18} />} iconPosition="start" />
</Tabs>
<Box role="tabpanel">
{tab === 'payments' ? <WalletPaymentHistory /> : null}
{tab === 'installments' ? <WalletInstallments /> : null}
{tab === 'refunds' ? <WalletRefunds /> : null}
{tab === 'receipts' ? <WalletReceipts /> : null}
</Box>
</Stack>
);
};
export default WalletScreen;
@@ -1,10 +1,9 @@
import WalletInstallments from './WalletInstallments'; import WalletScreen from './WalletScreen';
/** /**
* /wallet the customer Wallet tab. Today it hosts the f11 D5 installment-status section (provider-reported, * /wallet — the customer money hub (ui-phase-6): پرداخت‌ها / اقساط / استردادها / رسیدها. Thin route shell;
* self-contained so the f12 nurse-earnings Wallet content can land beside it later). The section is a client * the tabbed body is a client component (TanStack Query).
* component (TanStack Query); this page is the thin route shell.
*/ */
export default function WalletPage() { export default function WalletPage() {
return <WalletInstallments />; return <WalletScreen />;
} }
@@ -0,0 +1,54 @@
import { useMemo } from 'react';
import { usePaymentHistory } from '@/services/payment';
import { useWalletInstallments } from '@/services/bnpl';
import type { PaymentTransactionStatus } from '@/services/payment/types';
export interface WalletHistoryRow {
key: string;
amountIrr: string;
createdAt: string;
status: PaymentTransactionStatus;
bookingId: number | null;
}
/**
* Merges the two independent seams a wallet history/receipt row can come from card transactions
* (`services/payment`, REQ-047) and each settled BNPL plan's own down-payment leg (`services/bnpl`) into
* one newest-first list. Shared by the wallet «پرداختها» and «رسیدها» tabs so the merge logic lives once.
* Degrades gracefully: either source failing alone still renders the other's rows.
*/
export function useWalletHistoryRows() {
const paymentHistory = usePaymentHistory();
const walletInstallments = useWalletInstallments();
const rows = useMemo<WalletHistoryRow[]>(() => {
const cardRows: WalletHistoryRow[] = (paymentHistory.data ?? []).map((row) => ({
key: `card-${row.transactionId}`,
amountIrr: row.amountIrr,
createdAt: row.createdAt,
status: row.status,
bookingId: row.bookingId,
}));
const bnplRows: WalletHistoryRow[] = (walletInstallments.data ?? []).map((plan) => {
const downPayment = plan.installments.find((i) => i.kind === 'down_payment');
return {
key: `bnpl-${plan.bnplTransactionId}`,
amountIrr: downPayment?.amountIrr ?? '0',
createdAt: plan.createdAt,
status: 'succeeded' as const,
bookingId: plan.bookingId,
};
});
return [...cardRows, ...bnplRows].sort((a, b) => b.createdAt.localeCompare(a.createdAt));
}, [paymentHistory.data, walletInstallments.data]);
return {
rows,
isLoading: paymentHistory.isLoading || walletInstallments.isLoading,
bothErrored: paymentHistory.isError && walletInstallments.isError,
refetch: () => {
paymentHistory.refetch();
walletInstallments.refetch();
},
};
}
@@ -12,6 +12,8 @@ jest.mock('next-intl', () => ({
import BnplPlanCard from './BnplPlanCard'; import BnplPlanCard from './BnplPlanCard';
import type { BnplPlanOption } from '@/services/bnpl/types'; import type { BnplPlanOption } from '@/services/bnpl/types';
const ORDER_AMOUNT_IRR = '20000000'; // 2,000,000 Toman — the card-payable gross
const FEE_PLAN: BnplPlanOption = { const FEE_PLAN: BnplPlanOption = {
planId: 'digipay_6m', planId: 'digipay_6m',
termMonths: 6, termMonths: 6,
@@ -20,7 +22,7 @@ const FEE_PLAN: BnplPlanOption = {
downPaymentPercent: 0.2, downPaymentPercent: 0.2,
monthlyAmountIrr: '4040000', // 404,000 Toman monthlyAmountIrr: '4040000', // 404,000 Toman
downPaymentIrr: '4660000', downPaymentIrr: '4660000',
totalIrr: '23300000', totalIrr: '23300000', // 2,330,000 Toman — 330,000 Toman fee vs the 2,000,000 gross
}; };
const INTEREST_FREE_PLAN: BnplPlanOption = { const INTEREST_FREE_PLAN: BnplPlanOption = {
@@ -31,14 +33,14 @@ const INTEREST_FREE_PLAN: BnplPlanOption = {
downPaymentPercent: 0, downPaymentPercent: 0,
monthlyAmountIrr: '5825000', monthlyAmountIrr: '5825000',
downPaymentIrr: '0', downPaymentIrr: '0',
totalIrr: '23300000', totalIrr: '20000000',
}; };
function renderCard(props: Partial<React.ComponentProps<typeof BnplPlanCard>> = {}) { function renderCard(props: Partial<React.ComponentProps<typeof BnplPlanCard>> = {}) {
const onSelect = props.onSelect ?? jest.fn(); const onSelect = props.onSelect ?? jest.fn();
render( render(
<ThemeProvider> <ThemeProvider>
<BnplPlanCard plan={FEE_PLAN} selected={false} onSelect={onSelect} {...props} /> <BnplPlanCard plan={FEE_PLAN} orderAmountIrr={ORDER_AMOUNT_IRR} selected={false} onSelect={onSelect} {...props} />
</ThemeProvider>, </ThemeProvider>,
); );
return { onSelect }; return { onSelect };
@@ -51,18 +53,24 @@ describe('<BnplPlanCard/> component', () => {
expect(screen.getByText('monthly')).toBeInTheDocument(); expect(screen.getByText('monthly')).toBeInTheDocument();
}); });
it('shows the fee sub-label for a fee-bearing plan and the down-payment indicator', () => { it('shows the down payment and total repayment as plain Toman rows, no LinearProgress bar', () => {
renderCard(); renderCard();
// t('plan_fee', { percent: 4 }) — feePercent 0.04 → 4% expect(screen.getByText(/466,000/)).toBeInTheDocument(); // down payment
expect(screen.getByText('plan_fee:{"percent":4}')).toBeInTheDocument(); expect(screen.getByText(/2,330,000/)).toBeInTheDocument(); // total repayment
expect(screen.getByText('down_payment_percent:{"percent":20}')).toBeInTheDocument(); expect(screen.queryByRole('progressbar')).not.toBeInTheDocument();
expect(screen.getByRole('progressbar')).toBeInTheDocument();
}); });
it('shows "interest-free" and no down-payment bar for a 0-fee, 0-down plan', () => { it('shows the fee delta in Toman (total order amount), not a bare percent', () => {
renderCard();
// Anchored so it doesn't also match the "2,330,000" total, which contains "330,000" as a substring.
expect(screen.getByText(/^330,000/)).toBeInTheDocument();
expect(screen.getByText('plan_fee_amount_suffix')).toBeInTheDocument();
});
it('shows "interest-free" and no fee delta for a 0-fee plan', () => {
renderCard({ plan: INTEREST_FREE_PLAN }); renderCard({ plan: INTEREST_FREE_PLAN });
expect(screen.getByText('plan_interest_free')).toBeInTheDocument(); expect(screen.getByText('plan_interest_free')).toBeInTheDocument();
expect(screen.queryByRole('progressbar')).not.toBeInTheDocument(); expect(screen.queryByText('plan_fee_amount_suffix')).not.toBeInTheDocument();
}); });
it('marks the selected card via aria-pressed + data-selected', () => { it('marks the selected card via aria-pressed + data-selected', () => {
@@ -1,35 +1,38 @@
'use client'; 'use client';
import { FunctionComponent } from 'react'; import { FunctionComponent } from 'react';
import { useTranslations } from 'next-intl'; import { useTranslations } from 'next-intl';
import { Box, ButtonBase, LinearProgress, Stack, Typography } from '@mui/material'; import { ButtonBase, Divider, Stack, Typography } from '@mui/material';
import Money from '@/components/common/Money'; import Money from '@/components/common/Money';
import { parseIrr } from '@/utils';
import type { BnplPlanOption } from '@/services/bnpl/types'; import type { BnplPlanOption } from '@/services/bnpl/types';
export interface BnplPlanCardProps { export interface BnplPlanCardProps {
plan: BnplPlanOption; plan: BnplPlanOption;
/** The order's card-payable gross (D1 «مبلغ قابل پرداخت») the interest-free baseline the fee delta
* below compares against. Both figures are served; the delta is their exact BigInt difference, the same
* "exact remainder of served amounts" pattern the invoice page uses never a computed rate. */
orderAmountIrr: string;
selected: boolean; selected: boolean;
onSelect: (planId: string) => void; onSelect: (planId: string) => void;
} }
/** A whole-percent from a 0..1 fraction (display only — never money math). */
const asPercent = (fraction: number): number => Math.round(fraction * 100);
/** /**
* D2 installment-plan option card (terracotta financial accent). Shows the plan term / installment count, * D2 installment-plan option card (terracotta financial accent). Shows the plan term / installment count,
* its interest-free / fee sub-label, the **served** monthly amount (Toman via the money util never * the **served** monthly amount, a plain پیشپرداخت (امروز) amount row, and مجموع بازپرداخت with the fee
* computed here), and a down-payment indicator. Single-select: the selected card gets the terracotta * delta vs. paying in full made explicit in Toman (never a percent-only label, never a `LinearProgress` bar
* `--bal-secondary` border + soft tint. Labels are i18n keys off the served fields; money is display-only. * standing in for a static fact). Single-select: the selected card gets the terracotta `--bal-secondary`
* border + soft tint. Labels are i18n keys off the served fields; money is display-only.
* @component BnplPlanCard * @component BnplPlanCard
*/ */
const BnplPlanCard: FunctionComponent<BnplPlanCardProps> = ({ plan, selected, onSelect }) => { const BnplPlanCard: FunctionComponent<BnplPlanCardProps> = ({ plan, orderAmountIrr, selected, onSelect }) => {
const t = useTranslations('bnpl'); const t = useTranslations('bnpl');
const termLabel = const termLabel =
plan.termMonths != null plan.termMonths != null
? t('plan_term_months', { months: plan.termMonths }) ? t('plan_term_months', { months: plan.termMonths })
: t('plan_installments', { count: plan.installmentCount }); : t('plan_installments', { count: plan.installmentCount });
const feeLabel = plan.feePercent > 0 ? t('plan_fee', { percent: asPercent(plan.feePercent) }) : t('plan_interest_free'); const feeIrr = (parseIrr(plan.totalIrr) - parseIrr(orderAmountIrr)).toString();
const hasDownPayment = plan.downPaymentPercent > 0; const hasFee = plan.feePercent > 0 && parseIrr(feeIrr) > BigInt(0);
return ( return (
<ButtonBase <ButtonBase
@@ -49,19 +52,11 @@ const BnplPlanCard: FunctionComponent<BnplPlanCardProps> = ({ plan, selected, on
backgroundColor: selected ? 'var(--bal-secondary-soft)' : 'transparent', backgroundColor: selected ? 'var(--bal-secondary-soft)' : 'transparent',
}} }}
> >
<Stack sx={{ gap: hasDownPayment ? 1.25 : 0 }}> <Stack sx={{ gap: 1.25 }}>
<Stack direction="row" sx={{ justifyContent: 'space-between', alignItems: 'center', gap: 2 }}> <Stack direction="row" sx={{ justifyContent: 'space-between', alignItems: 'center', gap: 2 }}>
<Stack sx={{ gap: 0.25 }}> <Typography variant="subtitle2" sx={{ fontWeight: 700 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}> {termLabel}
{termLabel} </Typography>
</Typography>
<Typography
variant="caption"
sx={{ color: plan.feePercent > 0 ? 'var(--bal-money-emphasis)' : 'text.secondary' }}
>
{feeLabel}
</Typography>
</Stack>
<Stack sx={{ alignItems: 'flex-end', gap: 0.25 }}> <Stack sx={{ alignItems: 'flex-end', gap: 0.25 }}>
<Money amountIrr={plan.monthlyAmountIrr} tone="emphasis" size="md" sx={{ fontWeight: 800 }} /> <Money amountIrr={plan.monthlyAmountIrr} tone="emphasis" size="md" sx={{ fontWeight: 800 }} />
<Typography variant="caption" sx={{ color: 'text.secondary' }}> <Typography variant="caption" sx={{ color: 'text.secondary' }}>
@@ -70,28 +65,38 @@ const BnplPlanCard: FunctionComponent<BnplPlanCardProps> = ({ plan, selected, on
</Stack> </Stack>
</Stack> </Stack>
{hasDownPayment ? ( <Divider />
<Box>
<Stack direction="row" sx={{ justifyContent: 'space-between', mb: 0.5 }}> <Stack direction="row" sx={{ justifyContent: 'space-between', alignItems: 'center' }}>
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{t('down_payment')}
</Typography>
<Money amountIrr={plan.downPaymentIrr} size="sm" sx={{ fontWeight: 700 }} />
</Stack>
<Stack direction="row" sx={{ justifyContent: 'space-between', alignItems: 'center' }}>
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{t('total_repayment')}
</Typography>
<Stack sx={{ alignItems: 'flex-end', gap: 0.25 }}>
<Money amountIrr={plan.totalIrr} tone="emphasis" size="sm" sx={{ fontWeight: 700 }} />
{hasFee ? (
<Stack direction="row" sx={{ gap: 0.25, alignItems: 'baseline' }}>
<Typography variant="caption" sx={{ color: 'var(--bal-money-emphasis)', fontWeight: 500 }}>
+
</Typography>
<Money amountIrr={feeIrr} size="sm" sx={{ color: 'var(--bal-money-emphasis)', fontWeight: 500 }} />
<Typography variant="caption" sx={{ color: 'var(--bal-money-emphasis)', fontWeight: 500 }}>
{t('plan_fee_amount_suffix')}
</Typography>
</Stack>
) : (
<Typography variant="caption" sx={{ color: 'text.secondary' }}> <Typography variant="caption" sx={{ color: 'text.secondary' }}>
{t('down_payment')} {t('plan_interest_free')}
</Typography> </Typography>
<Typography variant="caption" sx={{ fontWeight: 700 }}> )}
{t('down_payment_percent', { percent: asPercent(plan.downPaymentPercent) })} </Stack>
</Typography> </Stack>
</Stack>
<LinearProgress
variant="determinate"
value={asPercent(plan.downPaymentPercent)}
sx={{
height: 6,
borderRadius: 3,
backgroundColor: 'var(--bal-divider)',
'& .MuiLinearProgress-bar': { backgroundColor: 'var(--bal-secondary)' },
}}
/>
</Box>
) : null}
</Stack> </Stack>
</ButtonBase> </ButtonBase>
); );
@@ -0,0 +1,47 @@
import { render } from '@testing-library/react';
import { ThemeProvider } from '../../theme';
jest.mock('next-intl', () => ({
useTranslations: () => (key: string) => {
const NAMES: Record<string, string> = {
provider_digipay: 'Digipay',
provider_snapppay: 'SnappPay',
};
return NAMES[key] ?? key;
},
}));
import BnplProviderLogo from './BnplProviderLogo';
describe('<BnplProviderLogo/> component', () => {
it('falls back to a tinted monogram roundel when no bundled asset exists', () => {
const { container } = render(
<ThemeProvider>
<BnplProviderLogo providerCode="digipay" />
</ThemeProvider>,
);
const mark = container.querySelector('[data-provider-logo="digipay"]');
expect(mark).toBeInTheDocument();
expect(mark).toHaveTextContent('D');
});
it('derives the monogram from the translated provider name', () => {
const { container } = render(
<ThemeProvider>
<BnplProviderLogo providerCode="snapppay" />
</ThemeProvider>,
);
expect(container.querySelector('[data-provider-logo="snapppay"]')).toHaveTextContent('S');
});
it('sizes the mark from the size prop', () => {
const { container } = render(
<ThemeProvider>
<BnplProviderLogo providerCode="digipay" size={60} />
</ThemeProvider>,
);
const mark = container.querySelector('[data-provider-logo="digipay"]') as HTMLElement;
expect(mark.style.width).toBe('60px');
expect(mark.style.height).toBe('42px');
});
});
@@ -0,0 +1,58 @@
import { ComponentType, FunctionComponent } from 'react';
import { useTranslations } from 'next-intl';
import { Box } from '@mui/material';
import type { ProviderCode } from '@/services/bnpl/types';
export interface BnplProviderLogoProps {
providerCode: ProviderCode;
/** Box width in px — height follows at a 10:7 wordmark ratio. Defaults to the D1 provider-row size. */
size?: number;
}
/**
* Real bundled provider marks empty until licensed assets exist (never fake a provider's logo). A real
* SVG drops in here (`providerCode → ComponentType`) without touching any call-site: `MethodStep` and any
* future provider list keep rendering `<BnplProviderLogo providerCode={…} />` unchanged.
*/
const PROVIDER_LOGO_SVG: Partial<Record<ProviderCode, ComponentType<{ width: number; height: number }>>> = {};
/**
* D1 provider mark a registry component so real logos can drop in without touching call-sites (the
* decision this phase made: no licensed provider assets exist yet, so every provider falls back to a
* *designed* neutral chip a tinted monogram roundel, replacing the old two-letter text-glyph stand-in
* (`DG`/`SP`/) that read as unfinished). The provider's full name is rendered by the caller alongside it
* (unchanged) this component is only the mark.
* @component BnplProviderLogo
*/
const BnplProviderLogo: FunctionComponent<BnplProviderLogoProps> = ({ providerCode, size = 40 }) => {
const t = useTranslations('bnpl');
const height = Math.round(size * 0.7);
const LogoSvg = PROVIDER_LOGO_SVG[providerCode];
if (LogoSvg) return <LogoSvg width={size} height={height} />;
const name = t(`provider_${providerCode}`);
const monogram = name.trim().charAt(0).toUpperCase();
return (
<Box
data-provider-logo={providerCode}
aria-hidden
style={{ width: size, height }}
sx={{
borderRadius: '50%',
flex: 'none',
display: 'grid',
placeItems: 'center',
fontWeight: 800,
fontSize: Math.round(height * 0.55),
color: 'var(--bal-secondary-dark)',
backgroundColor: 'var(--bal-secondary-soft)',
}}
>
{monogram}
</Box>
);
};
export default BnplProviderLogo;
@@ -0,0 +1,2 @@
export { default } from './BnplProviderLogo';
export type { BnplProviderLogoProps } from './BnplProviderLogo';
@@ -0,0 +1,36 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { ThemeProvider } from '../../theme';
jest.mock('next-intl', () => ({
useTranslations: () => (key: string) => key,
}));
import EscrowExplainer from './EscrowExplainer';
describe('<EscrowExplainer/> component', () => {
it('always renders the mandated EscrowNotice', () => {
render(
<ThemeProvider>
<EscrowExplainer />
</ThemeProvider>,
);
expect(screen.getByTestId('escrow-notice')).toBeInTheDocument();
});
it('the 3-step explainer is collapsed by default and expands on toggle', async () => {
const user = userEvent.setup();
render(
<ThemeProvider>
<EscrowExplainer />
</ThemeProvider>,
);
expect(screen.getByText('escrow_step_pay')).not.toBeVisible();
await user.click(screen.getByRole('button', { name: 'escrow_explainer_toggle' }));
expect(screen.getByText('escrow_step_pay')).toBeVisible();
expect(screen.getByText('escrow_step_hold')).toBeVisible();
expect(screen.getByText('escrow_step_release')).toBeVisible();
expect(screen.getByText('escrow_cancellation_note')).toBeVisible();
});
});
@@ -0,0 +1,95 @@
'use client';
import { FunctionComponent, useState } from 'react';
import { useTranslations } from 'next-intl';
import { Box, Collapse, Stack, Typography } from '@mui/material';
import AppIcon from '@/components/common/AppIcon';
import AppButton from '@/components/common/AppButton';
import EscrowNotice from '@/components/EscrowNotice';
interface ExplainerStep {
icon: string;
labelKey: string;
}
const STEPS: ExplainerStep[] = [
{ icon: 'payment', labelKey: 'escrow_step_pay' },
{ icon: 'lock', labelKey: 'escrow_step_hold' },
{ icon: 'verified', labelKey: 'escrow_step_release' },
];
/**
* Wraps the product-mandated `EscrowNotice` (never edited) with an optional «چطور کار میکند؟» expander: a
* 3-step visual (پرداخت امانت نزد بالینیار آزادسازی پس از تایید پایان ویزیت) grounded in
* `product/payments/escrow-ledger.md`, plus the cancellation/refund implication. Used from checkout and the
* confirmation receipt so escrow the platform's whole reason to pay on-platform gets more than one
* alert line at the moment of maximum skepticism.
* @component EscrowExplainer
*/
const EscrowExplainer: FunctionComponent = () => {
const t = useTranslations('payment');
const [open, setOpen] = useState(false);
return (
<Stack sx={{ gap: 1 }}>
<EscrowNotice />
<AppButton
variant="text"
color="primary"
size="small"
onClick={() => setOpen((v) => !v)}
aria-expanded={open}
endIcon={
<Box
component="span"
sx={{ display: 'inline-flex', transition: 'transform 150ms ease', transform: open ? 'rotate(180deg)' : 'none' }}
>
<AppIcon icon="expand" size={18} />
</Box>
}
sx={{ alignSelf: 'flex-start', px: 0.5 }}
>
{t('escrow_explainer_toggle')}
</AppButton>
<Collapse in={open}>
<Stack sx={{ gap: 1.5, p: 2, border: '1px solid', borderColor: 'divider', borderRadius: 2 }}>
<Stack
direction="row"
sx={{ gap: { xs: 1.5, sm: 2 }, alignItems: 'flex-start', flexWrap: 'wrap', justifyContent: 'space-between' }}
>
{STEPS.map((step, index) => (
<Stack key={step.labelKey} direction="row" sx={{ gap: 1, alignItems: 'center', flex: '1 1 auto' }}>
<Stack sx={{ gap: 0.5, alignItems: 'center', minWidth: 72 }}>
<Stack
sx={{
width: 40,
height: 40,
borderRadius: '50%',
alignItems: 'center',
justifyContent: 'center',
bgcolor: 'var(--bal-primary-soft)',
}}
>
<AppIcon icon={step.icon} size={20} color="var(--bal-primary)" />
</Stack>
<Typography variant="caption" sx={{ fontWeight: 700, textAlign: 'center' }}>
{t(step.labelKey)}
</Typography>
</Stack>
{index < STEPS.length - 1 ? (
<Box sx={{ flex: 'none', display: { xs: 'none', sm: 'block' }, pt: 2 }}>
<AppIcon icon="forward" size={16} color="var(--bal-text-secondary)" />
</Box>
) : null}
</Stack>
))}
</Stack>
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{t('escrow_cancellation_note')}
</Typography>
</Stack>
</Collapse>
</Stack>
);
};
export default EscrowExplainer;
@@ -0,0 +1 @@
export { default } from './EscrowExplainer';
@@ -50,7 +50,6 @@ const InstallmentScheduleRow: FunctionComponent<InstallmentScheduleRowProps> = (
<Stack sx={{ alignItems: 'flex-end', gap: 0.5 }}> <Stack sx={{ alignItems: 'flex-end', gap: 0.5 }}>
<Money <Money
amountIrr={row.amountIrr} amountIrr={row.amountIrr}
hideUnit
size="sm" size="sm"
tone={row.kind === 'down_payment' ? 'emphasis' : 'default'} tone={row.kind === 'down_payment' ? 'emphasis' : 'default'}
sx={{ fontWeight: 700 }} sx={{ fontWeight: 700 }}
@@ -0,0 +1,42 @@
import { render, screen } from '@testing-library/react';
import { ThemeProvider } from '../../theme';
import PaymentStateCard from './PaymentStateCard';
describe('<PaymentStateCard/> component', () => {
it('renders the title and icon', () => {
const { container } = render(
<ThemeProvider>
<PaymentStateCard icon="error" tone="var(--bal-error)" title="Payment failed" />
</ThemeProvider>,
);
expect(screen.getByText('Payment failed')).toBeInTheDocument();
expect(container.querySelector('[data-icon="error"]')).toBeInTheDocument();
});
it('renders the body only when provided', () => {
const { rerender } = render(
<ThemeProvider>
<PaymentStateCard icon="pending" tone="var(--bal-warning)" title="Waiting" body="Hang tight" />
</ThemeProvider>,
);
expect(screen.getByText('Hang tight')).toBeInTheDocument();
rerender(
<ThemeProvider>
<PaymentStateCard icon="pending" tone="var(--bal-warning)" title="Waiting" />
</ThemeProvider>,
);
expect(screen.queryByText('Hang tight')).not.toBeInTheDocument();
});
it('renders the actions slot', () => {
render(
<ThemeProvider>
<PaymentStateCard icon="verified" tone="var(--bal-success)" title="Done">
<button type="button">Continue</button>
</PaymentStateCard>
</ThemeProvider>,
);
expect(screen.getByRole('button', { name: 'Continue' })).toBeInTheDocument();
});
});
@@ -0,0 +1,46 @@
import { FunctionComponent, ReactNode } from 'react';
import { Paper, Stack, Typography } from '@mui/material';
import AppIcon from '@/components/common/AppIcon';
export interface PaymentStateCardProps {
/** AppIcon registry name. */
icon: string;
/** A `--bal-*` token (or MUI palette reference) — never a hard-coded hex. */
tone: string;
/** Already-translated title. */
title: string;
/** Already-translated body. */
body?: string;
/** Actions/badges/spinners below the body (e.g. an `AppButton`, a `PaymentStatusBadge`, a `CircularProgress`). */
children?: ReactNode;
}
/**
* The one terminal/wait-state card for the card and BNPL payment flows replaces the four copy-pasted
* private `MessageCard`/`StateCard` functions (`checkout/page.tsx` + `bnpl/page.tsx`,
* `checkout/return/page.tsx` + `bnpl/return/page.tsx`) so the two flows can no longer visually drift.
* Presentational, caller-owned i18n; `children` is the actions slot (a single CTA or a stack of them).
* @component PaymentStateCard
*/
const PaymentStateCard: FunctionComponent<PaymentStateCardProps> = ({ icon, tone, title, body, children }) => (
<Paper
elevation={0}
data-payment-state-card
sx={{ p: 4, textAlign: 'center', border: '1px solid', borderColor: 'divider', borderRadius: 2 }}
>
<Stack sx={{ gap: 1.5, alignItems: 'center' }}>
<AppIcon icon={icon} size={44} color={tone} />
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
{title}
</Typography>
{body ? (
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{body}
</Typography>
) : null}
{children}
</Stack>
</Paper>
);
export default PaymentStateCard;
@@ -0,0 +1,2 @@
export { default } from './PaymentStateCard';
export type { PaymentStateCardProps } from './PaymentStateCard';
@@ -3,9 +3,10 @@ import { render, screen } from '@testing-library/react';
import { ThemeProvider } from '../../theme'; import { ThemeProvider } from '../../theme';
import { formatIrrToToman, parseIrr } from '@/utils'; import { formatIrrToToman, parseIrr } from '@/utils';
// next-intl mocked to echo keys; locale = en so money formats with ASCII digits we can assert on. // next-intl mocked to echo keys (currency_toman resolved so <Money> renders "X Toman"); locale = en so
// money formats with ASCII digits we can assert on.
jest.mock('next-intl', () => ({ jest.mock('next-intl', () => ({
useTranslations: () => (key: string) => key, useTranslations: () => (key: string) => (key === 'currency_toman' ? 'Toman' : key),
useLocale: () => 'en', useLocale: () => 'en',
})); }));
@@ -26,12 +27,14 @@ const ROWS = [
const TOTAL = '45000000'; const TOTAL = '45000000';
describe('<PriceBreakdown/> component', () => { describe('<PriceBreakdown/> component', () => {
it('renders every row label with its Toman-formatted amount', () => { it('renders every row label with its Toman-formatted amount, unit included', () => {
render(<ComponentToTest rows={ROWS} totalLabel="Total" totalAmountIrr={TOTAL} />); render(<ComponentToTest rows={ROWS} totalLabel="Total" totalAmountIrr={TOTAL} />);
for (const row of ROWS) { for (const row of ROWS) {
expect(screen.getByText(row.label)).toBeInTheDocument(); expect(screen.getByText(row.label)).toBeInTheDocument();
expect(screen.getByText(formatIrrToToman(row.amountIrr, 'en'))).toBeInTheDocument(); expect(screen.getByText(new RegExp(formatIrrToToman(row.amountIrr, 'en')))).toBeInTheDocument();
} }
// Every row — not just the total — carries the currency unit (the Toman/Rial ambiguity this guards).
expect(screen.getAllByText(/Toman/).length).toBeGreaterThanOrEqual(ROWS.length + 1);
}); });
it('renders a total equal to the integer sum of the served rows', () => { it('renders a total equal to the integer sum of the served rows', () => {
@@ -1,10 +1,9 @@
'use client'; 'use client';
import { FunctionComponent } from 'react'; import { FunctionComponent } from 'react';
import { useLocale } from 'next-intl';
import { Divider, Stack, Typography } from '@mui/material'; import { Divider, Stack, Typography } from '@mui/material';
import Money from '@/components/common/Money'; import Money from '@/components/common/Money';
import SurfaceCard from '@/components/common/SurfaceCard'; import SurfaceCard from '@/components/common/SurfaceCard';
import { formatIrrToToman, parseIrr } from '@/utils'; import { parseIrr } from '@/utils';
export interface PriceBreakdownRow { export interface PriceBreakdownRow {
/** Stable row key (e.g. `service_cost`) — also exposed as `data-row` for tests/automation. */ /** Stable row key (e.g. `service_cost`) — also exposed as `data-row` for tests/automation. */
@@ -31,8 +30,6 @@ export interface PriceBreakdownProps {
* @component PriceBreakdown * @component PriceBreakdown
*/ */
const PriceBreakdown: FunctionComponent<PriceBreakdownProps> = ({ rows, totalLabel, totalAmountIrr }) => { const PriceBreakdown: FunctionComponent<PriceBreakdownProps> = ({ rows, totalLabel, totalAmountIrr }) => {
const locale = useLocale();
if (process.env.NODE_ENV !== 'production') { if (process.env.NODE_ENV !== 'production') {
const sum = rows.reduce((acc, row) => acc + parseIrr(row.amountIrr), BigInt(0)); const sum = rows.reduce((acc, row) => acc + parseIrr(row.amountIrr), BigInt(0));
if (sum !== parseIrr(totalAmountIrr)) { if (sum !== parseIrr(totalAmountIrr)) {
@@ -50,9 +47,7 @@ const PriceBreakdown: FunctionComponent<PriceBreakdownProps> = ({ rows, totalLab
<Typography variant="body2" sx={{ color: 'text.secondary' }}> <Typography variant="body2" sx={{ color: 'text.secondary' }}>
{row.label} {row.label}
</Typography> </Typography>
<Typography variant="body2" sx={{ fontWeight: 500 }}> <Money amountIrr={row.amountIrr} size="sm" sx={{ fontWeight: 500 }} />
{formatIrrToToman(row.amountIrr, locale)}
</Typography>
</Stack> </Stack>
))} ))}
<Divider /> <Divider />
@@ -36,4 +36,9 @@ describe('<Money/>', () => {
); );
expect(container.querySelector('[data-money][data-deduction="true"]')).toBeInTheDocument(); expect(container.querySelector('[data-money][data-deduction="true"]')).toBeInTheDocument();
}); });
it('renders the xl size as an h4 for the checkout/confirmation prominent-total hero', () => {
wrap(<Money amountIrr="45000000" size="xl" />);
expect(screen.getByText(/4,500,000/).closest('.MuiTypography-h4')).not.toBeNull();
});
}); });
+4 -1
View File
@@ -5,13 +5,16 @@ import Box from '@mui/material/Box';
import Typography, { TypographyProps } from '@mui/material/Typography'; import Typography, { TypographyProps } from '@mui/material/Typography';
import { formatIrrToToman } from '@/utils'; import { formatIrrToToman } from '@/utils';
export type MoneySize = 'sm' | 'md' | 'lg'; export type MoneySize = 'sm' | 'md' | 'lg' | 'xl';
export type MoneyTone = 'default' | 'emphasis' | 'muted' | 'error'; export type MoneyTone = 'default' | 'emphasis' | 'muted' | 'error';
const SIZE_VARIANT: Record<MoneySize, TypographyProps['variant']> = { const SIZE_VARIANT: Record<MoneySize, TypographyProps['variant']> = {
sm: 'body2', sm: 'body2',
md: 'body1', md: 'body1',
lg: 'h6', lg: 'h6',
// The C6/confirmation "prominent total" hero figure — the single most important number on a payment
// screen gets its own weight class, above the existing card/row emphasis.
xl: 'h4',
}; };
// House weight system has no 600 face (see typography.ts) — 700 for the strong tones, 500/400 otherwise. // House weight system has no 600 face (see typography.ts) — 700 for the strong tones, 500/400 otherwise.
+8
View File
@@ -34,6 +34,9 @@ import ReviewTagSelector from './ReviewTagSelector';
import VisitNoteCard from './VisitNoteCard'; import VisitNoteCard from './VisitNoteCard';
import PatientHeader from './PatientHeader'; import PatientHeader from './PatientHeader';
import VerificationPanel from './VerificationPanel'; import VerificationPanel from './VerificationPanel';
import PaymentStateCard from './PaymentStateCard';
import BnplProviderLogo from './BnplProviderLogo';
import EscrowExplainer from './EscrowExplainer';
export { export {
ProfileSummary, ProfileSummary,
@@ -70,6 +73,9 @@ export {
VisitNoteCard, VisitNoteCard,
PatientHeader, PatientHeader,
VerificationPanel, VerificationPanel,
PaymentStateCard,
BnplProviderLogo,
EscrowExplainer,
}; };
export type { PlaceholderScreenProps } from './PlaceholderScreen'; export type { PlaceholderScreenProps } from './PlaceholderScreen';
export type { OtpInputProps } from './OtpInput'; export type { OtpInputProps } from './OtpInput';
@@ -103,3 +109,5 @@ export type { ReviewTagSelectorProps } from './ReviewTagSelector';
export type { VisitNoteCardProps } from './VisitNoteCard'; export type { VisitNoteCardProps } from './VisitNoteCard';
export type { PatientHeaderProps } from './PatientHeader'; export type { PatientHeaderProps } from './PatientHeader';
export type { VerificationPanelProps } from './VerificationPanel'; export type { VerificationPanelProps } from './VerificationPanel';
export type { PaymentStateCardProps } from './PaymentStateCard';
export type { BnplProviderLogoProps } from './BnplProviderLogo';
-2
View File
@@ -23,8 +23,6 @@ export const ROUTES = {
BOOKING_REQUEST_STATUS: '/bookings/request', BOOKING_REQUEST_STATUS: '/bookings/request',
// Checkout (f9) — C6 summary & pay; the C5 accept CTA hands off here with `?request_id=`. // Checkout (f9) — C6 summary & pay; the C5 accept CTA hands off here with `?request_id=`.
CHECKOUT: '/bookings/checkout', CHECKOUT: '/bookings/checkout',
// Dev mock-gateway harness (test-only stand-in for the PSP redirect; the mock initiate points here).
CHECKOUT_GATEWAY: '/bookings/checkout/gateway',
// Return-from-gateway surface — pending-callback poll → succeeded/failed states. // Return-from-gateway surface — pending-callback poll → succeeded/failed states.
CHECKOUT_RETURN: '/bookings/checkout/return', CHECKOUT_RETURN: '/bookings/checkout/return',
// Post-payment success screen — links to the booking detail + invoice. // Post-payment success screen — links to the booking detail + invoice.
@@ -8,6 +8,7 @@ import type {
InitiatePaymentResult, InitiatePaymentResult,
InvoiceDto, InvoiceDto,
PaymentApi, PaymentApi,
PaymentHistoryItem,
PaymentOutcomeDto, PaymentOutcomeDto,
} from '../types'; } from '../types';
@@ -70,10 +71,19 @@ export const paymentClientApi: PaymentApi = {
// REQ-017: the b8 DTO gives no way to reach the converted booking — the confirmation falls back // REQ-017: the b8 DTO gives no way to reach the converted booking — the confirmation falls back
// to the bookings list until the field lands. // to the bookings list until the field lands.
bookingId: null, bookingId: null,
// REQ-046: no client-readable transaction read exists yet — the receipt hides these rows rather
// than render a fabricated tracking code or paid-at timestamp.
trackingCode: null,
paidAt: null,
}; };
return outcome; return outcome;
}, },
getInvoice: async (bookingId: number) => getInvoice: async (bookingId: number) =>
unwrap(await clientFetch<ApiEnvelope<InvoiceDto>>(`${INVOICES}/${bookingId}`)), unwrap(await clientFetch<ApiEnvelope<InvoiceDto>>(`${INVOICES}/${bookingId}`)),
getPaymentHistory: async () =>
// REQ-047 proposed slug — no customer payment-transactions list exists yet; 404s until delivered
// (the wallet «پرداخت‌ها» tab renders its empty state until then).
unwrap(await clientFetch<ApiEnvelope<PaymentHistoryItem[]>>(`${BOOKINGS}/payment_history`)),
}; };
+32 -8
View File
@@ -1,6 +1,5 @@
import { multiplyIrr, parseIrr, sleep } from '@/utils'; import { multiplyIrr, parseIrr, sleep } from '@/utils';
import { ApiError } from '@/lib/api/errors'; import { ApiError } from '@/lib/api/errors';
import { ROUTES } from '@/constants';
import { import {
bookingRequestsMockApi, bookingRequestsMockApi,
mockMarkBookingRequestConverted, mockMarkBookingRequestConverted,
@@ -8,9 +7,8 @@ import {
import { mockInsertConvertedBooking } from '@/services/bookings/apis/mockApi'; import { mockInsertConvertedBooking } from '@/services/bookings/apis/mockApi';
import type { BookingRequestDto } from '@/services/bookingRequests/types'; import type { BookingRequestDto } from '@/services/bookingRequests/types';
import { import {
CHECKOUT_QUERY_REQUEST_ID,
CHECKOUT_QUERY_TRANSACTION_ID,
MOCK_PLATFORM_FEE_RATE, MOCK_PLATFORM_FEE_RATE,
MOCK_SELLER_FISCAL_IDENTITY,
MOCK_VAT_RATE, MOCK_VAT_RATE,
} from '../constants'; } from '../constants';
import type { import type {
@@ -65,6 +63,9 @@ interface MockTransaction {
gatewayReferenceCode: string; gatewayReferenceCode: string;
grossPriceIrr: string; grossPriceIrr: string;
bookingId: number | null; bookingId: number | null;
createdAt: string;
/** Set at capture — the receipt's «تاریخ پرداخت» (REQ-046). */
capturedAt: string | null;
} }
// Module-level stores (one browser session) — the same singleton pattern as the f7/f8 mocks, so the // Module-level stores (one browser session) — the same singleton pattern as the f7/f8 mocks, so the
@@ -93,6 +94,8 @@ function toOutcome(request: BookingRequestDto, transaction: MockTransaction | un
requestStatus: request.status, requestStatus: request.status,
transactionStatus: transaction?.status ?? null, transactionStatus: transaction?.status ?? null,
bookingId: request.bookingId ?? transaction?.bookingId ?? null, bookingId: request.bookingId ?? transaction?.bookingId ?? null,
trackingCode: transaction?.gatewayReferenceCode ?? null,
paidAt: transaction?.capturedAt ?? null,
}; };
} }
@@ -130,6 +133,7 @@ function capture(request: BookingRequestDto, transaction: MockTransaction): Paym
const converted = mockMarkBookingRequestConverted(request.id, booking.id); const converted = mockMarkBookingRequestConverted(request.id, booking.id);
transaction.status = 'succeeded'; transaction.status = 'succeeded';
transaction.bookingId = booking.id; transaction.bookingId = booking.id;
transaction.capturedAt = new Date().toISOString();
invoices[booking.id] = { invoices[booking.id] = {
id: nextInvoiceId, id: nextInvoiceId,
@@ -145,6 +149,9 @@ function capture(request: BookingRequestDto, transaction: MockTransaction): Paym
moadianStatus: 'pending', moadianStatus: 'pending',
pdfUrl: null, pdfUrl: null,
issuedAt: new Date().toISOString(), issuedAt: new Date().toISOString(),
paymentMethod: 'card',
transactionReference: transaction.gatewayReferenceCode,
sellerFiscalIdentity: MOCK_SELLER_FISCAL_IDENTITY,
}; };
nextInvoiceId += 1; nextInvoiceId += 1;
@@ -168,6 +175,11 @@ export const paymentMockApi: PaymentApi = {
bookingRequestId: request.id, bookingRequestId: request.id,
requestStatus: request.status, requestStatus: request.status,
nurseName: request.nurseName, nurseName: request.nurseName,
// The mock stands in for REQ-046 (nurseAvatarUrl/nurseVerified aren't on BookingRequestDto) — every
// seeded request nurse is verified by construction; there is no avatar in the f7 mock store, so the
// C6 identity moment falls back to initials (never a fabricated image URL).
nurseAvatarUrl: null,
nurseVerified: true,
patientName: request.patientName, patientName: request.patientName,
variantLabel: request.variantLabel, variantLabel: request.variantLabel,
variantPriceUnit: request.variantPriceUnit, variantPriceUnit: request.variantPriceUnit,
@@ -213,6 +225,8 @@ export const paymentMockApi: PaymentApi = {
gatewayReferenceCode: `mock-ref-${bookingRequestId}-${idempotencyKey.slice(0, 8)}`, gatewayReferenceCode: `mock-ref-${bookingRequestId}-${idempotencyKey.slice(0, 8)}`,
grossPriceIrr: requestGross(request), grossPriceIrr: requestGross(request),
bookingId: null, bookingId: null,
createdAt: new Date().toISOString(),
capturedAt: null,
}; };
transactions = [transaction, ...transactions]; transactions = [transaction, ...transactions];
return toInitiateResult(transaction); return toInitiateResult(transaction);
@@ -252,16 +266,26 @@ export const paymentMockApi: PaymentApi = {
if (!invoice) throw new ApiError(404, 'Invoice not issued', 'not_issued'); if (!invoice) throw new ApiError(404, 'Invoice not issued', 'not_issued');
return { ...invoice }; return { ...invoice };
}, },
getPaymentHistory: async () => {
await sleep(MOCK_LATENCY_MS);
return transactions.map((t) => ({
transactionId: t.transactionId,
bookingRequestId: t.bookingRequestId,
bookingId: t.bookingId,
status: t.status,
amountIrr: t.grossPriceIrr,
createdAt: t.createdAt,
}));
},
}; };
function toInitiateResult(transaction: MockTransaction): InitiatePaymentResult { function toInitiateResult(transaction: MockTransaction): InitiatePaymentResult {
const query = new URLSearchParams();
query.set(CHECKOUT_QUERY_TRANSACTION_ID, String(transaction.transactionId));
query.set(CHECKOUT_QUERY_REQUEST_ID, String(transaction.bookingRequestId));
return { return {
transactionId: transaction.transactionId, transactionId: transaction.transactionId,
// App-relative (the checkout prepends the locale); the real PSP returns an absolute https URL. // No gateway hop to make — the dev card-gateway harness was retired in refinement-phase-4. The
redirectUrl: `${ROUTES.CHECKOUT_GATEWAY}?${query.toString()}`, // checkout page's `!result.redirectUrl` branch already handles this: it reads the outcome directly.
redirectUrl: null,
gatewayReferenceCode: transaction.gatewayReferenceCode, gatewayReferenceCode: transaction.gatewayReferenceCode,
}; };
} }
+10
View File
@@ -52,3 +52,13 @@ export const CHECKOUT_QUERY_OUTCOME = 'outcome';
*/ */
export const MOCK_PLATFORM_FEE_RATE = 0.12; export const MOCK_PLATFORM_FEE_RATE = 0.12;
export const MOCK_VAT_RATE = 0.1; export const MOCK_VAT_RATE = 0.1;
/**
* Mock-only seller fiscal identity (REQ-049) a fixed platform-level fact (not per-invoice data) the
* real path serves from platform config once registered. Placeholder values, never a real economic code.
*/
export const MOCK_SELLER_FISCAL_IDENTITY = {
legalName: 'شرکت بالین‌یار',
economicCode: null,
address: null,
};
@@ -0,0 +1,17 @@
import { useQuery } from '@tanstack/react-query';
import { paymentApi } from '../apis';
import { paymentKeys } from '../keys';
import { CHECKOUT_SUMMARY_STALE_TIME } from '../constants';
/**
* The customer's card payment history (wallet «پرداختها» tab, REQ-047). BNPL rows are sourced separately
* from `services/bnpl`'s wallet installments the two domains stay independent seams; the wallet screen
* merges them for display.
*/
export function usePaymentHistory() {
return useQuery({
queryKey: paymentKeys.history(),
queryFn: () => paymentApi.getPaymentHistory(),
staleTime: CHECKOUT_SUMMARY_STALE_TIME,
});
}
+1
View File
@@ -7,3 +7,4 @@ export { useInitiatePayment } from './hooks/useInitiatePayment';
export { useConfirmGatewayReturn } from './hooks/useConfirmGatewayReturn'; export { useConfirmGatewayReturn } from './hooks/useConfirmGatewayReturn';
export { usePaymentOutcome } from './hooks/usePaymentOutcome'; export { usePaymentOutcome } from './hooks/usePaymentOutcome';
export { useInvoice } from './hooks/useInvoice'; export { useInvoice } from './hooks/useInvoice';
export { usePaymentHistory } from './hooks/usePaymentHistory';
+1
View File
@@ -11,4 +11,5 @@ export const paymentKeys = {
outcome: (bookingRequestId: number) => [...paymentKeys.outcomes(), bookingRequestId] as const, outcome: (bookingRequestId: number) => [...paymentKeys.outcomes(), bookingRequestId] as const,
invoices: () => [...paymentKeys.all, 'invoice'] as const, invoices: () => [...paymentKeys.all, 'invoice'] as const,
invoice: (bookingId: number) => [...paymentKeys.invoices(), bookingId] as const, invoice: (bookingId: number) => [...paymentKeys.invoices(), bookingId] as const,
history: () => [...paymentKeys.all, 'history'] as const,
}; };
+32
View File
@@ -42,6 +42,9 @@ export interface CheckoutSummaryDto {
/** C6 renders only for `accepted_awaiting_payment`; other statuses get a convergence/terminal card. */ /** C6 renders only for `accepted_awaiting_payment`; other statuses get a convergence/terminal card. */
requestStatus: BookingRequestStatus; requestStatus: BookingRequestStatus;
nurseName: string; nurseName: string;
/** The C6 identity moment (REQ-046) — `null` on the real path until served; the avatar/badge hide gracefully. */
nurseAvatarUrl: string | null;
nurseVerified: boolean;
patientName: string; patientName: string;
variantLabel: string; variantLabel: string;
variantPriceUnit: PriceUnit; variantPriceUnit: PriceUnit;
@@ -107,6 +110,21 @@ export interface PaymentOutcomeDto {
transactionStatus: PaymentTransactionStatus | null; transactionStatus: PaymentTransactionStatus | null;
/** The confirmed booking to link to (client-augmented; `null` on the real path until REQ-017 lands). */ /** The confirmed booking to link to (client-augmented; `null` on the real path until REQ-017 lands). */
bookingId: number | null; bookingId: number | null;
/** کد پیگیری the receipt's copyable reference (REQ-046). `null` on the real path until served; the
* confirmation receipt hides the row rather than render a fabricated code. */
trackingCode: string | null;
/** UTC ISO timestamp of capture (REQ-046) — `null` until served on the real path. */
paidAt: string | null;
}
/** One row of the customer's card/BNPL payment history (REQ-047 — the wallet «پرداخت‌ها» tab). */
export interface PaymentHistoryItem {
transactionId: number;
bookingRequestId: number;
bookingId: number | null;
status: PaymentTransactionStatus;
amountIrr: string;
createdAt: string;
} }
/** succeeded/failed transaction, or a request that left the payable state — nothing left to poll. */ /** succeeded/failed transaction, or a request that left the payable state — nothing left to poll. */
@@ -118,6 +136,13 @@ export function isTerminalPaymentOutcome(outcome: PaymentOutcomeDto): boolean {
); );
} }
/** Seller fiscal-identity block (REQ-049) — a fixed platform-level fact, not per-invoice data. */
export interface InvoiceSellerFiscalIdentity {
legalName: string;
economicCode: string | null;
address: string | null;
}
/** `InvoiceDto` (b11 swagger, `GET invoices/{bookingId}`) — flat totals; VAT is on the commission line only. */ /** `InvoiceDto` (b11 swagger, `GET invoices/{bookingId}`) — flat totals; VAT is on the commission line only. */
export interface InvoiceDto { export interface InvoiceDto {
id: number; id: number;
@@ -135,6 +160,11 @@ export interface InvoiceDto {
moadianStatus: MoadianStatus | null; moadianStatus: MoadianStatus | null;
pdfUrl: string | null; pdfUrl: string | null;
issuedAt: string; issuedAt: string;
/** --- Fiscal-grade fields (REQ-049): `null` on the real path until served — the invoice hides the row. --- */
paymentMethod: 'card' | 'bnpl' | null;
/** Opaque payment/settlement reference — never parsed. */
transactionReference: string | null;
sellerFiscalIdentity: InvoiceSellerFiscalIdentity | null;
} }
/** /**
@@ -147,4 +177,6 @@ export interface PaymentApi {
confirmGatewayReturn(input: ConfirmGatewayReturnInput): Promise<PaymentOutcomeDto>; confirmGatewayReturn(input: ConfirmGatewayReturnInput): Promise<PaymentOutcomeDto>;
getPaymentOutcome(bookingRequestId: number): Promise<PaymentOutcomeDto>; getPaymentOutcome(bookingRequestId: number): Promise<PaymentOutcomeDto>;
getInvoice(bookingId: number): Promise<InvoiceDto>; getInvoice(bookingId: number): Promise<InvoiceDto>;
/** The customer's card payment history (REQ-047 — wallet «پرداخت‌ها»; BNPL rows come from `services/bnpl`). */
getPaymentHistory(): Promise<PaymentHistoryItem[]>;
} }
@@ -98,6 +98,13 @@ export const refundsClientApi: RefundsApi = {
getRefund: async (refundId: number) => getRefund: async (refundId: number) =>
toSummary(unwrap(await clientFetch<ApiEnvelope<RefundStatusWire>>(`${REFUNDS}/${refundId}/status`))), toSummary(unwrap(await clientFetch<ApiEnvelope<RefundStatusWire>>(`${REFUNDS}/${refundId}/status`))),
// REQ-048 proposed slug — no "all my refunds" list exists yet (only by-booking/by-id reads); 404s
// until delivered (the wallet «استردادها» tab renders its empty state until then).
getMyRefunds: async () => {
const wire = unwrap(await clientFetch<ApiEnvelope<RefundStatusWire[]>>(`${REFUNDS}/my`));
return wire.map(toSummary);
},
// REQ-035: refund preview endpoint. b11 computes the fee-leg decomposition only *on create* (there is no // REQ-035: refund preview endpoint. b11 computes the fee-leg decomposition only *on create* (there is no
// read-only preview route), yet the admin console must disclose the split before initiating. Filed as a // read-only preview route), yet the admin console must disclose the split before initiating. Filed as a
// proposed `GET api/v1/admin_refunds/preview?booking_id=&ticket_id=`; the mock serves it today. // proposed `GET api/v1/admin_refunds/preview?booking_id=&ticket_id=`; the mock serves it today.
@@ -356,6 +356,16 @@ export const refundsMockApi: RefundsApi = {
return toRefundSummary(refund); return toRefundSummary(refund);
}, },
getMyRefunds: async () => {
await sleep(MOCK_LATENCY_MS);
const refunds = Object.values(refundsByBooking);
refunds.forEach(advanceRefund);
return refunds
.slice()
.sort((a, b) => (b.createdAt ?? '').localeCompare(a.createdAt ?? ''))
.map(toRefundSummary);
},
// --- Admin refund tooling (ticket-linked; the mock serves the whole console this phase). --- // --- Admin refund tooling (ticket-linked; the mock serves the whole console this phase). ---
getRefundPreview: async (bookingId, _ticketId) => { getRefundPreview: async (bookingId, _ticketId) => {
@@ -0,0 +1,13 @@
import { useQuery } from '@tanstack/react-query';
import { refundsApi } from '../apis';
import { refundKeys } from '../keys';
import { REFUND_STATUS_STALE_TIME } from '../constants';
/** Every refund the customer owns (wallet «استردادها» tab, REQ-048) — newest first. */
export function useMyRefunds() {
return useQuery({
queryKey: refundKeys.mine(),
queryFn: () => refundsApi.getMyRefunds(),
staleTime: REFUND_STATUS_STALE_TIME,
});
}
+1
View File
@@ -5,6 +5,7 @@
export { useCancellationPolicyPreview } from './hooks/useCancellationPolicyPreview'; export { useCancellationPolicyPreview } from './hooks/useCancellationPolicyPreview';
export { useCancelBooking } from './hooks/useCancelBooking'; export { useCancelBooking } from './hooks/useCancelBooking';
export { useRefundStatus } from './hooks/useRefundStatus'; export { useRefundStatus } from './hooks/useRefundStatus';
export { useMyRefunds } from './hooks/useMyRefunds';
// Admin refund tooling (b11 admin_refunds; ticket-linked). // Admin refund tooling (b11 admin_refunds; ticket-linked).
export { useRefundPreview } from './hooks/useRefundPreview'; export { useRefundPreview } from './hooks/useRefundPreview';
+2
View File
@@ -13,6 +13,8 @@ export const refundKeys = {
byBookings: () => [...refundKeys.all, 'by_booking'] as const, byBookings: () => [...refundKeys.all, 'by_booking'] as const,
byBooking: (bookingId: number) => [...refundKeys.byBookings(), bookingId] as const, byBooking: (bookingId: number) => [...refundKeys.byBookings(), bookingId] as const,
mine: () => [...refundKeys.all, 'mine'] as const,
details: () => [...refundKeys.all, 'detail'] as const, details: () => [...refundKeys.all, 'detail'] as const,
detail: (refundId: number) => [...refundKeys.details(), refundId] as const, detail: (refundId: number) => [...refundKeys.details(), refundId] as const,
+2
View File
@@ -250,6 +250,8 @@ export interface RefundsApi {
/** `null` when the booking has no refund (e.g. not cancelled) — a clean empty state, not an error. */ /** `null` when the booking has no refund (e.g. not cancelled) — a clean empty state, not an error. */
getRefundByBooking(bookingId: number): Promise<RefundSummary | null>; getRefundByBooking(bookingId: number): Promise<RefundSummary | null>;
getRefund(refundId: number): Promise<RefundSummary>; getRefund(refundId: number): Promise<RefundSummary>;
/** Every refund the caller owns, newest first (REQ-048 — the wallet «استردادها» tab). */
getMyRefunds(): Promise<RefundSummary[]>;
/* --- Admin refund tooling (b11 admin_refunds; every initiate is ticket-linked). --- */ /* --- Admin refund tooling (b11 admin_refunds; every initiate is ticket-linked). --- */
/** The server's fee-leg decomposition preview for a booking (`ticketId` = the linking ticket, or null). */ /** The server's fee-leg decomposition preview for a booking (`ticketId` = the linking ticket, or null). */
@@ -710,3 +710,69 @@ delivers fixes in its own change. **Frontend never edits backend code to "fix" a
produces the JSON blob today. produces the JSON blob today.
- **Status:** open — the client keeps the defensive multi-key parse until this lands; no user-facing defect - **Status:** open — the client keeps the defensive multi-key parse until this lands; no user-facing defect
today (the address still renders when any recognizable key is present), just an unenforced contract. today (the address still renders when any recognizable key is present), just an unenforced contract.
## REQ-046 — Checkout/receipt enrichment: nurse identity on the summary + a client-readable payment reference — filed by ui-phase-6 — 2026-07-18
- **Need:** Two additions to the checkout/payment surfaces:
1. `nurseAvatarUrl` (string, nullable) and `nurseVerified` (bool) on `CheckoutSummaryDto` — the C6 identity
moment ("who am I paying for").
2. A client-readable **tracking code** and **paid-at timestamp** for a captured payment — either on the
pending-callback poll target or a dedicated confirm-read: `trackingCode` (string, nullable — a
customer-quotable reference) and `paidAt` (UTC ISO, nullable) on `PaymentOutcomeDto`
(`GET booking_requests/get/{id}`-derived today per REQ-017).
- **Why:** C6 now shows the nurse's avatar + a verified badge next to the payable total, and the
confirmation screen is rebuilt as a screenshot-worthy receipt (کد پیگیری in a copyable row + Shamsi
payment date-time) — Iranian users screenshot payment receipts and expect a reference number to quote in
a dispute. Neither field exists on the wire today; the mock supplies both (nurse is always seed-verified,
no avatar in the f7 mock store; `trackingCode` = the PSP `gatewayReferenceCode`, `paidAt` = the capture
timestamp) and the real `paymentClientApi` leaves them `null` — the receipt hides the row rather than
render a fabricated code (extends REQ-016/017).
- **Proposed shape:** `CheckoutSummaryDto { …, nurseAvatarUrl: string|null, nurseVerified: boolean }`;
`PaymentOutcomeDto { …, trackingCode: string|null, paidAt: string|null }`.
- **Status:** open — mock-only until served; real path hides the identity avatar/badge and the tracking
code/paid-at rows gracefully.
## REQ-047 — Customer payment-transactions list (card + BNPL) — filed by ui-phase-6 — 2026-07-18
- **Need:** A customer-scoped read of every payment transaction the caller made — proposed
`GET api/v1/bookings/payment_history` (owner-scoped) →
`[{ transactionId, bookingRequestId, bookingId, status, amountIrr, createdAt }]`.
- **Why:** The wallet's «پرداخت‌ها» tab is the fix for the audit's top finding — the Wallet tab was a
permanently empty installments-only shell for every card-paying customer (the default path; BNPL is
mock-gated). No endpoint serves a card payment history today. The client added `getPaymentHistory` to the
`PaymentApi` seam; the mock returns the in-memory transaction list, the real `paymentClientApi` targets
this proposed route (404s until delivered — the tab renders its empty state until then). BNPL rows are
sourced separately from `services/bnpl`'s wallet installments (REQ-024) and merged client-side for display
— this REQ covers the card half only.
- **Proposed shape:** as above; `status` reuses the existing `PaymentTransactionStatus` enum
(`pending|succeeded|failed`).
- **Status:** open — wallet «پرداخت‌ها» renders the empty state on the real path until delivered.
## REQ-048 — Customer "all my refunds" list — filed by ui-phase-6 — 2026-07-18
- **Need:** A customer-scoped read of every refund the caller owns — proposed `GET api/v1/refunds/my`
(owner-scoped) → the same thin shape `GET refunds/{id}/status` already returns, as an array.
- **Why:** The wallet's «استردادها» tab renders every refund via the shared `RefundStatusCard`. b11's only
customer-facing refund reads are by-booking (REQ-021) and by-id — there's no "list everything I've been
refunded" read, and REQ-021's by-booking route requires already knowing which booking to ask about. The
client added `getMyRefunds` to the `RefundsApi` seam (mock-primary per `USE_REFUNDS_MOCK`, extends
REQ-019/020/021); the real `refundsClientApi` targets this proposed route.
- **Proposed shape:** `GET api/v1/refunds/my``RefundStatusWire[]` (the same per-item shape as
`refunds/{id}/status`).
- **Status:** open — wallet «استردادها» renders the empty state on the real path until delivered.
## REQ-049 — Invoice fiscal fields: payment method, transaction reference, seller fiscal identity — filed by ui-phase-6 — 2026-07-18
- **Need:** Three additions to `InvoiceDto` so the invoice is a document a family can file for
reimbursement/dispute:
1. `paymentMethod: 'card' | 'bnpl' | null`.
2. `transactionReference: string | null` (opaque, the settling payment/BNPL reference).
3. `sellerFiscalIdentity: { legalName, economicCode: string|null, address: string|null } | null` — a
platform-level fact (not per-invoice data), read from wherever the مودیان enrollment config lands.
- **Why:** The invoice page was print-capable but not audit-worthy (no payment method, no transaction
reference, no seller tax/economic identity — the things a real Iranian VAT invoice carries). Buyer name
and the service/visit-date recap are composed client-side today (a UI join over the customer's own
profile + the booking detail read — no money math, per phase §3.5), but payment method/reference and the
seller identity have no client-side source and must be served. The mock populates all three for card
payments (`paymentMethod: 'card'`, `transactionReference` = the gateway reference, a placeholder
`sellerFiscalIdentity`); BNPL-settled invoices and the real path leave them `null` — the invoice hides
those rows rather than render fabricated fiscal data. Extends REQ-018.
- **Proposed shape:** as above.
- **Status:** open — invoice renders the reconciling money breakdown + مودیان status unconditionally; the
three new rows render only when served.
@@ -121,11 +121,11 @@ the frontend can build before the backend phase merges, and swap to the real HTT
| `VerificationApi` | `client/src/services/verification/apis/mockApi.ts` | The whole nurse trust journey (b6). Seeds the six required steps on `start` (idempotent); `runIdentityKyc` passes any well-formed 10-digit id **except** `0000000000` (→ `failed`/`kyc_no_match`, matches backend `MockIdentityKycProvider`); `runShahkarMatch` requires identity passed, fails **shared-SIM** when the bound national id is `1111111111` (→ `failed`/`shared_sim`); `runBankVerification` passes (assumes a primary bank account); `uploadStepDocument` simulates signed-URL PUT progress then moves the step to `in_review` (metadata only); `submitCredentialDetails` validates the INO number. Re-aggregates like the server (`approved` only when every step passes). **Dev-only** `__mockApproveAll()`/`__mockRejectStep(code,reason)` stand in for the deferred (f15) admin review queue so a human can watch `is_verified`/the trust badge/the publish gate flip — reachable from B3/B6 only while the flag is true | `USE_VERIFICATION_MOCK` (`services/verification/constants.ts`, default `true`) | b6 `nurse_verification/*` + `nurses/{id}/trust_badge` are live; set flag `false``verificationClientApi` is wired (action-style routes, camelCase, XHR signed-URL PUT for upload progress + SHA-256 integrity hash). **Caveat:** the real `submitCredentialDetails` no-ops pending REQ-011 (no nurse-facing endpoint for the structured INO/specialties fields yet) — the document uploads it accompanies are contract-backed. No hook/component change | 🟡 | | `VerificationApi` | `client/src/services/verification/apis/mockApi.ts` | The whole nurse trust journey (b6). Seeds the six required steps on `start` (idempotent); `runIdentityKyc` passes any well-formed 10-digit id **except** `0000000000` (→ `failed`/`kyc_no_match`, matches backend `MockIdentityKycProvider`); `runShahkarMatch` requires identity passed, fails **shared-SIM** when the bound national id is `1111111111` (→ `failed`/`shared_sim`); `runBankVerification` passes (assumes a primary bank account); `uploadStepDocument` simulates signed-URL PUT progress then moves the step to `in_review` (metadata only); `submitCredentialDetails` validates the INO number. Re-aggregates like the server (`approved` only when every step passes). **Dev-only** `__mockApproveAll()`/`__mockRejectStep(code,reason)` stand in for the deferred (f15) admin review queue so a human can watch `is_verified`/the trust badge/the publish gate flip — reachable from B3/B6 only while the flag is true | `USE_VERIFICATION_MOCK` (`services/verification/constants.ts`, default `true`) | b6 `nurse_verification/*` + `nurses/{id}/trust_badge` are live; set flag `false``verificationClientApi` is wired (action-style routes, camelCase, XHR signed-URL PUT for upload progress + SHA-256 integrity hash). **Caveat:** the real `submitCredentialDetails` no-ops pending REQ-011 (no nurse-facing endpoint for the structured INO/specialties fields yet) — the document uploads it accompanies are contract-backed. No hook/component change | 🟡 |
| `BookingsApi` | `client/src/services/bookings/apis/mockApi.ts` | The post-payment engagement (b9). Seeds **2 confirmed bookings** (one 3-session multi-day, one single-visit) + `booking_care_instructions` + a per-session **EVV state machine**`checkInVisit` flips the session→`in_progress`/`checked_in` (booking→`in_progress`) and computes the **advisory** `checkInAddressMatch` (haversine vs the seeded address ± `MOCK_EVV_TOLERANCE_METERS`, `null` when GPS was absent); `checkOutVisit` requires an open check-in (**`400 no_open_check_in`** otherwise), completes the session (stamps `payoutEligibleAt`), and completes the booking + opens the dispute window once **all** sessions settle. `getCareInstructions` **404s any viewer but the assigned nurse** (the two-stage-disclosure boundary; the UI `enabled` gate means the customer never even calls it). Money stays IRR digit-strings with `gross = commission + payout` and `Σ visitPayout = payout` | `USE_BOOKINGS_MOCK` (`services/bookings/constants.ts`, default `true`) | b9 `bookings/*` + `booking_sessions/*` are live, but a booking only exists after `bookings/convert` runs on a **paid** request — both upstreams (`bookingRequests` mock, card capture b10) aren't real client-side yet. Once conversion is live, set flag `false``bookingsClientApi` maps the routes 1:1 (+ `bookingsServerApi` for the RSC prefetch). No hook/component change | 🟢 (real, refinement-phase-4) | | `BookingsApi` | `client/src/services/bookings/apis/mockApi.ts` | The post-payment engagement (b9). Seeds **2 confirmed bookings** (one 3-session multi-day, one single-visit) + `booking_care_instructions` + a per-session **EVV state machine**`checkInVisit` flips the session→`in_progress`/`checked_in` (booking→`in_progress`) and computes the **advisory** `checkInAddressMatch` (haversine vs the seeded address ± `MOCK_EVV_TOLERANCE_METERS`, `null` when GPS was absent); `checkOutVisit` requires an open check-in (**`400 no_open_check_in`** otherwise), completes the session (stamps `payoutEligibleAt`), and completes the booking + opens the dispute window once **all** sessions settle. `getCareInstructions` **404s any viewer but the assigned nurse** (the two-stage-disclosure boundary; the UI `enabled` gate means the customer never even calls it). Money stays IRR digit-strings with `gross = commission + payout` and `Σ visitPayout = payout` | `USE_BOOKINGS_MOCK` (`services/bookings/constants.ts`, default `true`) | b9 `bookings/*` + `booking_sessions/*` are live, but a booking only exists after `bookings/convert` runs on a **paid** request — both upstreams (`bookingRequests` mock, card capture b10) aren't real client-side yet. Once conversion is live, set flag `false``bookingsClientApi` maps the routes 1:1 (+ `bookingsServerApi` for the RSC prefetch). No hook/component change | 🟢 (real, refinement-phase-4) |
| `ILocationProvider` | `client/src/services/bookings/evv/locationProvider.ts` | **EVV GPS capture** — the only client seam f8 introduces. `getCurrentPosition()` never rejects (denied/unavailable → `null`, so a GPS problem is **advisory, never a block**). The **real** provider wraps `navigator.geolocation.getCurrentPosition`; the **mock** returns canned coordinates per mode so the in-range / advisory-out-of-range / denied paths are all demoable without a device (the mock `BookingsApi` computes the match against the same seeded reference point) | `NEXT_PUBLIC_EVV_MOCK_GPS` = `in_range` \| `out_of_range` \| `denied` \| `off` (default `in_range` while `USE_BOOKINGS_MOCK`, else `off`) | Set `NEXT_PUBLIC_EVV_MOCK_GPS=off` (or flip `USE_BOOKINGS_MOCK`) → the real `navigator.geolocation` provider is selected. Real **address-match math** stays server-side (backend geocoding seam), not here — this seam only *captures* the position | 🟢 (real, refinement-phase-4) | | `ILocationProvider` | `client/src/services/bookings/evv/locationProvider.ts` | **EVV GPS capture** — the only client seam f8 introduces. `getCurrentPosition()` never rejects (denied/unavailable → `null`, so a GPS problem is **advisory, never a block**). The **real** provider wraps `navigator.geolocation.getCurrentPosition`; the **mock** returns canned coordinates per mode so the in-range / advisory-out-of-range / denied paths are all demoable without a device (the mock `BookingsApi` computes the match against the same seeded reference point) | `NEXT_PUBLIC_EVV_MOCK_GPS` = `in_range` \| `out_of_range` \| `denied` \| `off` (default `in_range` while `USE_BOOKINGS_MOCK`, else `off`) | Set `NEXT_PUBLIC_EVV_MOCK_GPS=off` (or flip `USE_BOOKINGS_MOCK`) → the real `navigator.geolocation` provider is selected. Real **address-match math** stays server-side (backend geocoding seam), not here — this seam only *captures* the position | 🟢 (real, refinement-phase-4) |
| `PaymentApi` | `client/src/services/payment/apis/mockApi.ts` | **The f9 checkout money path** — plays the PSP + webhook roles the client can't reach: `getCheckoutSummary` serves the unserved C6 breakdown (REQ-016; commission-net/VAT/service split via **integer parts-per-10000 BigInt math**, 12% fee / 10% VAT, reconciles to the rial); `initiatePayment` enforces b10 idempotency (same `Idempotency-Key` → same attempt; repeat after capture / lapsed window → **`409`**) and returns a `redirectUrl` into the local mock-gateway harness; `confirmGatewayReturn` on success is the **webhook-confirm stand-in and the missing f7↔f8 bridge** — flips the request `converted` (+ client-augmented `bookingId`, via `mockMarkBookingRequestConverted` in the f7 mock), inserts a **confirmed** booking into the f8 store (`mockInsertConvertedBooking`), and auto-issues the b11-shaped invoice (`moadianStatus: pending`, `pdfUrl: null` so the print path exercises); replayed returns converge idempotently; `getInvoice` 404s until issued | `USE_PAYMENT_MOCK` (`services/payment/constants.ts`, default `true`) | b10 initiate + b11 invoice are live and `paymentClientApi` maps them 1:1 (`Idempotency-Key` header, `GET invoices/{bookingId}`); deliver **REQ-016** (checkout summary — the real client already targets the proposed `booking_requests/checkout_summary/{id}` slug) + **REQ-017** (transaction status / `bookingId`; until then the real outcome poll maps `booking_requests/get` statuses and can't distinguish declined from slow) + **REQ-018** (invoice reachable post-capture), make the upstream `bookingRequests` flow real, then set flag `false`. No hook/component change | 🟢 (real, refinement-phase-4) | | `PaymentApi` | `client/src/services/payment/apis/mockApi.ts` | **The f9 checkout money path** — plays the PSP + webhook roles the client can't reach: `getCheckoutSummary` serves the unserved C6 breakdown (REQ-016; commission-net/VAT/service split via **integer parts-per-10000 BigInt math**, 12% fee / 10% VAT, reconciles to the rial); `initiatePayment` enforces b10 idempotency (same `Idempotency-Key` → same attempt; repeat after capture / lapsed window → **`409`**) and returns a `redirectUrl` into the local mock-gateway harness; `confirmGatewayReturn` on success is the **webhook-confirm stand-in and the missing f7↔f8 bridge** — flips the request `converted` (+ client-augmented `bookingId`, via `mockMarkBookingRequestConverted` in the f7 mock), inserts a **confirmed** booking into the f8 store (`mockInsertConvertedBooking`), and auto-issues the b11-shaped invoice (`moadianStatus: pending`, `pdfUrl: null` so the print path exercises); replayed returns converge idempotently; `getInvoice` 404s until issued. **ui-phase-6:** `initiatePayment`'s `redirectUrl` is now `null` (was a stale pointer to the deleted card-gateway harness page — a latent bug, since the harness itself was already removed in refinement-phase-4; the checkout page's `!redirectUrl` branch already reads the outcome directly, no behavior change); `getCheckoutSummary` adds mock `nurseAvatarUrl: null`/`nurseVerified: true` (REQ-046, the C6 identity moment); the capture path stamps `capturedAt`/`createdAt` on the transaction so `PaymentOutcomeDto` serves `trackingCode`/`paidAt` (REQ-046, the confirmation receipt) and the new `getPaymentHistory` reads the same transaction list (REQ-047, wallet «پرداخت‌ها»); invoice creation adds mock `paymentMethod: 'card'`/`transactionReference`/`sellerFiscalIdentity` (REQ-049, fiscal-grade invoice) | `USE_PAYMENT_MOCK` (`services/payment/constants.ts`, default `true`) | b10 initiate + b11 invoice are live and `paymentClientApi` maps them 1:1 (`Idempotency-Key` header, `GET invoices/{bookingId}`); deliver **REQ-016** (checkout summary — the real client already targets the proposed `booking_requests/checkout_summary/{id}` slug) + **REQ-017** (transaction status / `bookingId`; until then the real outcome poll maps `booking_requests/get` statuses and can't distinguish declined from slow) + **REQ-018** (invoice reachable post-capture) + **REQ-046** (nurse identity + tracking code/paid-at) + **REQ-047** (payment history) + **REQ-049** (invoice fiscal fields), make the upstream `bookingRequests` flow real, then set flag `false`. No hook/component change | 🟢 (real, refinement-phase-4) |
| Mock-gateway page (test harness) | `client/src/app/[locale]/(private-routes)/(customer)/bookings/checkout/gateway/page.tsx` | **Not a product feature** — a dev stand-in for the PSP's hosted payment page so the initiate → redirect → return round-trip is exercisable without a gateway: the mock `redirectUrl` points here, and its success/failure buttons drive both branches of the return surface (`?outcome=success\|failure`). Clearly labelled «درگاه پرداخت آزمایشی», dashed border | _none — only reachable via the mock's `redirectUrl`_ | On the real path b10's `redirectUrl` is the PSP's **absolute** URL (the checkout does a full `window.location.assign` for `http(s)` URLs), so this page is simply never linked; delete it when `USE_PAYMENT_MOCK` retires. The PSP's return deep-link into `/bookings/checkout/return` is backend/PSP config | 🗑 removed in refinement-phase-4 (payment flipped real) | | Mock-gateway page (test harness) | `client/src/app/[locale]/(private-routes)/(customer)/bookings/checkout/gateway/page.tsx` | **Not a product feature** — a dev stand-in for the PSP's hosted payment page so the initiate → redirect → return round-trip is exercisable without a gateway: the mock `redirectUrl` points here, and its success/failure buttons drive both branches of the return surface (`?outcome=success\|failure`). Clearly labelled «درگاه پرداخت آزمایشی», dashed border | _none — only reachable via the mock's `redirectUrl`_ | On the real path b10's `redirectUrl` is the PSP's **absolute** URL (the checkout does a full `window.location.assign` for `http(s)` URLs), so this page is simply never linked; delete it when `USE_PAYMENT_MOCK` retires. The PSP's return deep-link into `/bookings/checkout/return` is backend/PSP config | 🗑 removed in refinement-phase-4 (payment flipped real) |
| `RefundsApi` | `client/src/services/refunds/apis/mockApi.ts` | **The f10 customer cancel + refund surface** b11 doesn't serve (refunds are admin-only; no customer cancel command, no policy preview, no refund-by-booking, no fee-leg decomposition on the customer status → REQ-019/020/021). Reads the shared **f8 bookings store** (`mockGetBookingForRefund`) to resolve the tier by lead time (`free_24h` >24h / `partial_under_24h` <24h / `customer_no_show` started — client-invented codes → i18n keys) and the per-session refundable(un-started)/locked(completed-and-verified) breakdown, decomposing the refund across the two fee legs via **integer parts-per-10000 BigInt math** (`refundAmount + fee = refundableGross` to the rial). `cancelBooking` flips the booking → `cancelled` (`mockMarkBookingCancelled` stamps the b9 snapshot + cancels only un-started sessions) and creates a refund: **card → `succeeded`** immediately (no ETA); **BNPL → `approved`→`processing`→`succeeded`** over status polls with a `expected_customer_refund_eta` ~10 business days out (Fridays skipped) so the ~710-day banner renders. Enforces the outside-policy **`409`** (already-cancelled / nothing-refundable / non-refundable session). Seeds a **`failed`** refund on the cancelled booking 5004 so the contact-support state demos; booking 5002 is pinned to the BNPL channel; booking 5003 (new, mid-engagement) demos the mixed refundable/locked breakdown. Also adds bookings-store seeds 5003/5004 + the two non-seam exports | `USE_REFUNDS_MOCK` (`services/refunds/constants.ts`, default `true`) | Deliver **REQ-019** (customer cancel command — the real `refundsClientApi.cancelBooking` already targets `POST bookings/{id}/cancel`) + **REQ-020** (cancellation-policy preview → `GET bookings/{id}/cancellation_policy`, incl. the canonical `cancellation_policy_code` set) + **REQ-021** (`GET refunds/by_booking/{id}` + the decomposition fields on the customer `refunds/{id}/status`), then set flag `false` — the real client maps the published `refunds/{id}/status` 1:1 and targets the proposed slugs for the rest. No hook/component change | 🟡 | | `RefundsApi` | `client/src/services/refunds/apis/mockApi.ts` | **The f10 customer cancel + refund surface** b11 doesn't serve (refunds are admin-only; no customer cancel command, no policy preview, no refund-by-booking, no fee-leg decomposition on the customer status → REQ-019/020/021). Reads the shared **f8 bookings store** (`mockGetBookingForRefund`) to resolve the tier by lead time (`free_24h` >24h / `partial_under_24h` <24h / `customer_no_show` started — client-invented codes → i18n keys) and the per-session refundable(un-started)/locked(completed-and-verified) breakdown, decomposing the refund across the two fee legs via **integer parts-per-10000 BigInt math** (`refundAmount + fee = refundableGross` to the rial). `cancelBooking` flips the booking → `cancelled` (`mockMarkBookingCancelled` stamps the b9 snapshot + cancels only un-started sessions) and creates a refund: **card → `succeeded`** immediately (no ETA); **BNPL → `approved`→`processing`→`succeeded`** over status polls with a `expected_customer_refund_eta` ~10 business days out (Fridays skipped) so the ~710-day banner renders. Enforces the outside-policy **`409`** (already-cancelled / nothing-refundable / non-refundable session). Seeds a **`failed`** refund on the cancelled booking 5004 so the contact-support state demos; booking 5002 is pinned to the BNPL channel; booking 5003 (new, mid-engagement) demos the mixed refundable/locked breakdown. Also adds bookings-store seeds 5003/5004 + the two non-seam exports. **ui-phase-6:** `getMyRefunds` (REQ-048) returns every in-memory refund newest-first — the wallet «استردادها» tab | `USE_REFUNDS_MOCK` (`services/refunds/constants.ts`, default `true`) | Deliver **REQ-019** (customer cancel command — the real `refundsClientApi.cancelBooking` already targets `POST bookings/{id}/cancel`) + **REQ-020** (cancellation-policy preview → `GET bookings/{id}/cancellation_policy`, incl. the canonical `cancellation_policy_code` set) + **REQ-021** (`GET refunds/by_booking/{id}` + the decomposition fields on the customer `refunds/{id}/status`) + **REQ-048** (`GET refunds/my`), then set flag `false` — the real client maps the published `refunds/{id}/status` 1:1 and targets the proposed slugs for the rest. No hook/component change | 🟡 |
| `BnplApi` | `client/src/services/bnpl/apis/mockApi.ts` | **The f11 BNPL installment checkout (D1D5)** b12 doesn't serve client-side (b12 is order-centric — eligibility/initiate/status/webhook — and **explicitly does not model the repayment schedule**; no provider/plan options, no wallet installment status → REQ-022/023/024). Reads the frozen request gross from the shared **f7 store** and plays the provider: `getBnplOptions` builds the provider set as **data** (دیجی‌پی 3/6/12 · اسنپ‌پی ۴ · اقساط بالین‌یار; per-plan monthly/down-payment/total via **integer parts-per-10000 BigInt math**, never a hardcoded fee in the UI); `checkEligibility` returns `eligible` unless the national-id last digit is `0` (→`not_eligible`) or the order exceeds `MOCK_CREDIT_CEILING_IRR` (→`ceiling_exceeded`) so both declined paths demo; `getBnplSchedule` serves the down-payment + N-installment rows (last absorbs the remainder → rows sum to total); `issueBnplToken` enforces b12 idempotency (same key → same token; repeat after settle / lapsed window → **`409`**) + a `redirectUrl` into the local provider-handoff harness; `acceptBnplSchedule` on success is the **settle stand-in and reuses the f9 conversion bridge** — flips the request `converted` (`mockMarkBookingRequestConverted`), inserts a **confirmed** booking (`mockInsertConvertedBooking`; a settled BNPL order = a card payment net-of-fee, payout invariant to method), and **seeds a provider-reported Wallet plan**; `getWalletInstallments` serves D5 (seeded active دیجی‌پی ۶-ماهه with paid/due-soon/upcoming rows + each settled checkout's plan). Money = served IRR digit-strings end-to-end (components only format) | `USE_BNPL_MOCK` (`services/bnpl/constants.ts`, default `true`) | Deliver **REQ-022** (options + schedule — real `bnplClientApi` targets `checkout_bnpl/options/{id}` + `checkout_bnpl/schedule/{id}`), **REQ-023** (eligibility accepts the D3 national-id/mobile/consent), **REQ-024** (`checkout_bnpl/wallet_installments` provider-reported status + a customer `bookingId` on the settled order), and make the upstream `bookingRequests` flow real, then set flag `false``checkEligibility`/`issueBnplToken`(`Idempotency-Key`)/`getBnplOrder` already map the live b12 routes 1:1; the settle-on-return reads the order (the real settle is the provider webhook). No hook/component change | 🟡 | | `BnplApi` | `client/src/services/bnpl/apis/mockApi.ts` | **The f11 BNPL installment checkout (D1D5)** b12 doesn't serve client-side (b12 is order-centric — eligibility/initiate/status/webhook — and **explicitly does not model the repayment schedule**; no provider/plan options, no wallet installment status → REQ-022/023/024). Reads the frozen request gross from the shared **f7 store** and plays the provider: `getBnplOptions` builds the provider set as **data** (دیجی‌پی 3/6/12 · اسنپ‌پی ۴ · اقساط بالین‌یار; per-plan monthly/down-payment/total via **integer parts-per-10000 BigInt math**, never a hardcoded fee in the UI); `checkEligibility` returns `eligible` unless the national-id last digit is `0` (→`not_eligible`) or the order exceeds `MOCK_CREDIT_CEILING_IRR` (→`ceiling_exceeded`) so both declined paths demo; `getBnplSchedule` serves the down-payment + N-installment rows (last absorbs the remainder → rows sum to total); `issueBnplToken` enforces b12 idempotency (same key → same token; repeat after settle / lapsed window → **`409`**) + a `redirectUrl` into the local provider-handoff harness; `acceptBnplSchedule` on success is the **settle stand-in and reuses the f9 conversion bridge** — flips the request `converted` (`mockMarkBookingRequestConverted`), inserts a **confirmed** booking (`mockInsertConvertedBooking`; a settled BNPL order = a card payment net-of-fee, payout invariant to method), and **seeds a provider-reported Wallet plan**; `getWalletInstallments` serves D5 (seeded active دیجی‌پی ۶-ماهه with paid/due-soon/upcoming rows + each settled checkout's plan). Money = served IRR digit-strings end-to-end (components only format) | `USE_BNPL_MOCK` (`services/bnpl/constants.ts`, default `true`) | Deliver **REQ-022** (options + schedule — real `bnplClientApi` targets `checkout_bnpl/options/{id}` + `checkout_bnpl/schedule/{id}`), **REQ-023** (eligibility accepts the D3 national-id/mobile/consent), **REQ-024** (`checkout_bnpl/wallet_installments` provider-reported status + a customer `bookingId` on the settled order), and make the upstream `bookingRequests` flow real, then set flag `false``checkEligibility`/`issueBnplToken`(`Idempotency-Key`)/`getBnplOrder` already map the live b12 routes 1:1; the settle-on-return reads the order (the real settle is the provider webhook). No hook/component change | 🟡 |
| BNPL provider-handoff harness (test harness) | `client/src/app/[locale]/(private-routes)/(customer)/bookings/checkout/bnpl/gateway/page.tsx` | **Not a product feature** — a dev stand-in for the provider's hosted BNPL page so the initiate → redirect → return round-trip is exercisable without a provider: the mock `redirectUrl` points here, and its pay/cancel buttons drive both branches of the return surface (`?outcome=success\|failure`). Clearly labelled «در حال انتقال به ارائه‌دهنده», dashed border | _none — only reachable via the mock's `redirectUrl`_ | On the real path b12's `redirectUrl` is the provider's **absolute** URL (the wizard does a full `window.location.assign` for `http(s)`), so this page is never linked; delete it when `USE_BNPL_MOCK` retires. The provider's return deep-link into `/bookings/checkout/bnpl/return` is backend/provider config | 🟡 | | BNPL provider-handoff harness (test harness) | `client/src/app/[locale]/(private-routes)/(customer)/bookings/checkout/bnpl/gateway/page.tsx` | **Not a product feature** — a dev stand-in for the provider's hosted BNPL page so the initiate → redirect → return round-trip is exercisable without a provider: the mock `redirectUrl` points here, and its pay/cancel buttons drive both branches of the return surface (`?outcome=success\|failure`). Clearly labelled «در حال انتقال به ارائه‌دهنده», dashed border. **ui-phase-6:** was reachable by direct URL in a production build (the equivalent card-gateway harness was already deleted, this one never got the same guard since BNPL stays mock-primary) — now `notFound()`-gated outside `NODE_ENV=development`, still fully reachable in `next dev` | _none — only reachable via the mock's `redirectUrl`_ | On the real path b12's `redirectUrl` is the provider's **absolute** URL (the wizard does a full `window.location.assign` for `http(s)`), so this page is never linked; delete it when `USE_BNPL_MOCK` retires. The provider's return deep-link into `/bookings/checkout/bnpl/return` is backend/provider config | 🟡 |
| `PayoutsApi` | `client/src/services/payouts/apis/mockApi.ts` | **The f12 nurse earnings surface** b13 doesn't serve read-side for a nurse (b13's only nurse route is `GET nurse_payouts/history`; the four-bucket **earnings summary**, the per-booking **earnings list + money-state**, and a **nurse-readable payout detail** with batch context + booking links are gaps → **REQ-025**). Self-contained, money-correct fixtures exercising **every** UI state: all four earnings states (`pending`/`eligible`/`paid`/`clawback_applied`; booking ids 50015004 align with the f8 bookings-store seeds so "view booking" deep-links land), all four `PayoutStatus` values in history (`pending`/`submitted`/`paid`/`failed`, incl. a `failed` payout with `failureReason: 'invalid_sheba'` for the read-only failure banner), payout **details that reconcile** (`gross clawback = net = amount`, Σ booking-link amounts = `grossEarnings`), and a **signed net balance** computed with BigInt via a `MOCK_SCENARIO` toggle (`standard` = positive; **`clawback_heavy` = negative "owed back"** for phase §7 step 3). Timestamps are relative to `now` so the pending dispute-window countdown always ticks; money stays IRR digit-strings end-to-end (components only format). `getNurseEarnings` filters by `state` + paginates | `USE_PAYOUTS_MOCK` (`services/payouts/constants.ts`, default `true`) + `MOCK_SCENARIO` in `constants.ts` | Deliver **REQ-025** (earnings_balance + earnings list + nurse `nurse_payouts/{id}` detail + `failureReason` on the history DTO), then set flag `false``payoutsClientApi` already maps the live `GET nurse_payouts/history` 1:1 and targets the proposed slugs for the other three. No hook/component change | 🟡 | | `PayoutsApi` | `client/src/services/payouts/apis/mockApi.ts` | **The f12 nurse earnings surface** b13 doesn't serve read-side for a nurse (b13's only nurse route is `GET nurse_payouts/history`; the four-bucket **earnings summary**, the per-booking **earnings list + money-state**, and a **nurse-readable payout detail** with batch context + booking links are gaps → **REQ-025**). Self-contained, money-correct fixtures exercising **every** UI state: all four earnings states (`pending`/`eligible`/`paid`/`clawback_applied`; booking ids 50015004 align with the f8 bookings-store seeds so "view booking" deep-links land), all four `PayoutStatus` values in history (`pending`/`submitted`/`paid`/`failed`, incl. a `failed` payout with `failureReason: 'invalid_sheba'` for the read-only failure banner), payout **details that reconcile** (`gross clawback = net = amount`, Σ booking-link amounts = `grossEarnings`), and a **signed net balance** computed with BigInt via a `MOCK_SCENARIO` toggle (`standard` = positive; **`clawback_heavy` = negative "owed back"** for phase §7 step 3). Timestamps are relative to `now` so the pending dispute-window countdown always ticks; money stays IRR digit-strings end-to-end (components only format). `getNurseEarnings` filters by `state` + paginates | `USE_PAYOUTS_MOCK` (`services/payouts/constants.ts`, default `true`) + `MOCK_SCENARIO` in `constants.ts` | Deliver **REQ-025** (earnings_balance + earnings list + nurse `nurse_payouts/{id}` detail + `failureReason` on the history DTO), then set flag `false``payoutsClientApi` already maps the live `GET nurse_payouts/history` 1:1 and targets the proposed slugs for the other three. No hook/component change | 🟡 |
| `ReviewsApi` | `client/src/services/reviews/apis/mockApi.ts` | **The f13 moderated-review trust loop.** b14 serves the review **submit** (`POST bookings/{id}/review`), the public **nurse reviews** page (`GET nurses/{id}/reviews`), and the tag rollup — those are mapped 1:1 in `reviewsClientApi`. But there is **no review-eligibility read** and **no my-review-for-booking read** (**REQ-026**), and the whole moderation transition (`pending_moderation → published`) is **admin-only (f15)**. The mock reads a booking from the shared **f8 bookings store** (`mockGetBookingForReview`) to gate eligibility on a **completed/closed** booking (aligns with the new completed seed 5005 / nurse 1 / patient 905), tracks the customer's submission as `pending_moderation` so eligibility flips `already_reviewed` + `getMyReviewForBooking` returns the persistent "under review" state, and seeds a **published list per nurse** (nurse 1 has 7 → the profile tab paginates; nurses 5/6 empty → empty state). The aggregate is **recomputed from the published list** (never a stored sum). A submitted review **never** enters any public list. Dev-only `__mockPublishSubmittedReview(bookingId)` stands in for the deferred (f15) admin queue so a human can watch a review appear on the profile. Money-free | `USE_REVIEWS_MOCK` (`services/reviews/constants.ts`, default `true`) | Deliver **REQ-026** (`review_eligibility` + `my_review` reads; confirm masked-author omission), then set flag `false``reviewsClientApi.getNurseReviews`/`createReview` already map the live b14 routes 1:1 and target the two proposed slugs for the gaps. Moderation UI itself is **f15** (admin). No hook/component change | 🟢 (real, refinement-phase-4) | | `ReviewsApi` | `client/src/services/reviews/apis/mockApi.ts` | **The f13 moderated-review trust loop.** b14 serves the review **submit** (`POST bookings/{id}/review`), the public **nurse reviews** page (`GET nurses/{id}/reviews`), and the tag rollup — those are mapped 1:1 in `reviewsClientApi`. But there is **no review-eligibility read** and **no my-review-for-booking read** (**REQ-026**), and the whole moderation transition (`pending_moderation → published`) is **admin-only (f15)**. The mock reads a booking from the shared **f8 bookings store** (`mockGetBookingForReview`) to gate eligibility on a **completed/closed** booking (aligns with the new completed seed 5005 / nurse 1 / patient 905), tracks the customer's submission as `pending_moderation` so eligibility flips `already_reviewed` + `getMyReviewForBooking` returns the persistent "under review" state, and seeds a **published list per nurse** (nurse 1 has 7 → the profile tab paginates; nurses 5/6 empty → empty state). The aggregate is **recomputed from the published list** (never a stored sum). A submitted review **never** enters any public list. Dev-only `__mockPublishSubmittedReview(bookingId)` stands in for the deferred (f15) admin queue so a human can watch a review appear on the profile. Money-free | `USE_REVIEWS_MOCK` (`services/reviews/constants.ts`, default `true`) | Deliver **REQ-026** (`review_eligibility` + `my_review` reads; confirm masked-author omission), then set flag `false``reviewsClientApi.getNurseReviews`/`createReview` already map the live b14 routes 1:1 and target the two proposed slugs for the gaps. Moderation UI itself is **f15** (admin). No hook/component change | 🟢 (real, refinement-phase-4) |
| `PatientRecordsApi` | `client/src/services/patientRecords/apis/mockApi.ts` | **The f13 continuity-of-care surface.** Two very different things: (1) the **nurse-authored visit-note history** (`getPatientHistory`/`createVisitNote`) is **REAL b14** (`GET`/`POST patients/{id}/care_records`), mapped 1:1 in `patientRecordsClientApi` (the append composes the ticked task checklist into the note `body` since the wire has no structured task field); (2) the **family-owned editable record** (medications/routine/tasks — the داروها/روتین/وظایف tabs) and the **access check** have **NO backend at all** (neither the b14 contract nor `data-model/10-reviews-and-records.md` model them → **REQ-027**). The mock is **patient-scoped** and lazily seeds a coherent default per patient: a default family record (customer edits it), a **multi-nurse continuity history** (two prior notes from *different* nurses, proving the history persists across nurse changes; a nurse append prepends to the same patient's history), and a **foreign-patient access-denied** path (`MOCK_FOREIGN_PATIENT_ID = 8888``canView:false` + a `403` on every read) so the non-leaking access-denied card is demoable. Clinical text is fixture data (never logged) | `USE_PATIENT_RECORDS_MOCK` (`services/patientRecords/constants.ts`, default `true`) | Deliver **REQ-027** (family-owned `care_record` GET/PUT + `record_access` + structured `taskResults`), then set flag `false` — the history/append methods already map the real b14 routes; only the family-record/access methods flip. Confirm whether the family-owned record is a real MVP entity | 🟡 | | `PatientRecordsApi` | `client/src/services/patientRecords/apis/mockApi.ts` | **The f13 continuity-of-care surface.** Two very different things: (1) the **nurse-authored visit-note history** (`getPatientHistory`/`createVisitNote`) is **REAL b14** (`GET`/`POST patients/{id}/care_records`), mapped 1:1 in `patientRecordsClientApi` (the append composes the ticked task checklist into the note `body` since the wire has no structured task field); (2) the **family-owned editable record** (medications/routine/tasks — the داروها/روتین/وظایف tabs) and the **access check** have **NO backend at all** (neither the b14 contract nor `data-model/10-reviews-and-records.md` model them → **REQ-027**). The mock is **patient-scoped** and lazily seeds a coherent default per patient: a default family record (customer edits it), a **multi-nurse continuity history** (two prior notes from *different* nurses, proving the history persists across nurse changes; a nurse append prepends to the same patient's history), and a **foreign-patient access-denied** path (`MOCK_FOREIGN_PATIENT_ID = 8888``canView:false` + a `403` on every read) so the non-leaking access-denied card is demoable. Clinical text is fixture data (never logged) | `USE_PATIENT_RECORDS_MOCK` (`services/patientRecords/constants.ts`, default `true`) | Deliver **REQ-027** (family-owned `care_record` GET/PUT + `record_access` + structured `taskResults`), then set flag `false` — the history/append methods already map the real b14 routes; only the family-record/access methods flip. Confirm whether the family-owned record is a real MVP entity | 🟡 |
@@ -0,0 +1,208 @@
# UI Phase 6 — Checkout & Money — Report (2026-07-18)
## What was built
**Three new shared components** (`src/components/`, each tested):
- **`PaymentStateCard/`** — the one terminal/wait-state card (icon/tone/title/body + an actions slot)
replacing the four copy-pasted private `MessageCard` (checkout/page.tsx, bnpl/page.tsx) and `StateCard`
(checkout/return/page.tsx, bnpl/return/page.tsx) functions. All eight call sites now render through it;
the card and BNPL flows can no longer visually drift.
- **`BnplProviderLogo/`** — `providerCode` → a bundled SVG when a real asset exists (none licensed yet,
registry left empty on purpose) falling back to a *designed* tinted-monogram roundel, replacing the old
two-letter text-glyph stand-in (`DG`/`SP`/…) in D1's `MethodStep`. Asset-tolerant: dropping in a real logo
later touches only the internal registry map, no call-site changes.
- **`EscrowExplainer/`** — wraps the product-mandated `EscrowNotice` (never edited) with an optional
«چطور کار می‌کند؟» expander: a 3-step visual (پرداخت ← امانت نزد بالین‌یار ← آزادسازی پس از تایید پایان
ویزیت, grounded in `product/payments/escrow-ledger.md`) plus the cancellation/refund implication. Used on
checkout and the confirmation receipt.
**Minimal foundation extension**: `Money` gained a fourth size, `xl` → MUI `h4`, for the checkout/
confirmation "prominent total" hero figure that phase 1 didn't need. Test added.
**C6 checkout** (`bookings/checkout/page.tsx`) — real hierarchy:
- Identity moment: `EngagementSummary` now shows the nurse's avatar + a `TrustBadge` (verified/unverified
off the new `nurseVerified` field) next to the service/patient/time details.
- A prominent `<Money size="xl">` total sits above the countdown/breakdown — the same served `totalIrr`
`PriceBreakdown` reconciles below, never recomputed.
- A safe-area-aware sticky pay bar (`StickyActionBar`, reusing phase-4's shell-composed safe-area padding —
`layout/` untouched): total + pay CTA co-located, a lock-icon «پرداخت امن از طریق درگاه بانکی» trust line,
and the BNPL branch button — **both** CTAs now `disabled={busy}` during initiate (the race the audit
flagged: only the card button used to disable).
- An explicit «بازگشت به درخواست» text link above the identity card (beyond the shell's header chrome).
- Un-baked the arrow: `cta_pay` no longer bakes «←»/«→» into the translated string; the button now uses
`endIcon="forward"` (already a registered directional icon — mirrors under RTL automatically).
**Confirmation rebuilt as a receipt** (`bookings/checkout/confirmation/page.tsx`):
- A copyable, `dir="ltr"` کد پیگیری row with a copy button (clipboard + a "copied" toast) — sourced from
`usePaymentOutcome`'s new `trackingCode` (card path) or the settled `BnplOrderStatus.id` (BNPL path, no
new field needed).
- Shamsi paid-at (`formatShamsiDateTime`), payment method («کارت بانکی» / «اقساطی — {provider}»), and the
booking reference — every row conditionally rendered so an unserved real-path field just disappears,
never a fabricated value.
- `EscrowExplainer` (was a bare `EscrowNotice`) and a "what happens next" 2-step `StatusTimeline`
(اطلاع‌رسانی به پرستار ← ویزیت و ثبت ورود).
- **Real loading/error states**: the old `{summary ? (...) : null}` (a failed fetch silently erased the
paid amount) is now a skeleton → `ErrorState` (with retry) → receipt sequence.
**checkout/return staged wait state** — the pending-callback branch's spinner+`PaymentStatusBadge`+title
triple is replaced with a 2-node `StatusTimeline` («بازگشت از درگاه ✓» → «در انتظار تایید بانک», reusing the
timeline's existing `current`-state animated pulse for the "calm animated indicator" ask) plus an
expected-duration caption. The manual «بررسی دوباره» escape hatch and the bounded backoff poll are
untouched. The failed/window-expired/invalid-id branches now render through `PaymentStateCard` and no
longer double an icon with a redundant status chip (the failed state's `PaymentStatusBadge` was dropped —
audit's own complaint about the doubled signal).
**Wallet rebuilt as the money hub** (`wallet/`) — `page.tsx` → new `WalletScreen.tsx` (MUI `Tabs`, one
shared `CONTENT_MAX_WIDTH`, no local width override — the old `maxWidth: 560` is gone):
- **«پرداخت‌ها»** (`WalletPaymentHistory.tsx`) — every card + BNPL payment, newest first, `PaymentStatusBadge`
+ deep-link to the booking. Card rows come from the new `usePaymentHistory()` (REQ-047); BNPL rows are
derived from each settled wallet plan's own down-payment leg. A co-located `useWalletHistoryRows.ts` hook
merges the two independent seams once, reused by the receipts tab too. Degrades gracefully: either source
failing alone still renders the other's rows.
- **«اقساط»** — the unchanged f11 D5 `WalletInstallments` body (own heading/width stripped; it's a section
now, not a page).
- **«استردادها»** (`WalletRefunds.tsx`) — every refund via the new `useMyRefunds()` (REQ-048), each rendered
through the existing `RefundStatusCard` with a link to `bookingRefundStatusPath`.
- **«رسیدها»** (`WalletReceipts.tsx`) — invoice deep-links derived client-side from the same merged history
rows (succeeded + a known `bookingId`) — no endpoint, no money math, just a filter + `bookingInvoicePath`.
- A card-paying customer (the default path) now sees payment history instead of a permanently-empty
installments-only tab — the audit's top-severity finding.
**BNPL honesty + polish** (`checkout/bnpl/`):
- `BnplPlanCard` no longer shows a percent + `LinearProgress` bar for the down payment (a static fact
styled as a loading indicator). It now shows three plain Toman rows: پیش‌پرداخت (امروز), قسط ماهانه, and
**مجموع بازپرداخت** with the fee delta spelled out («+۴۵۰٬۰۰۰ تومان کارمزد» in `--bal-money-emphasis`) or
«بدون سود» for interest-free plans. The delta is `totalIrr orderAmountIrr` — the exact BigInt difference
of two already-served amounts (the same "exact remainder of served amounts" pattern the invoice page uses
for its service line), never a computed rate.
- `PlanStep`'s «مبلغ کل» header no longer defaults to `plans[0]` before any selection (the silently-morphing
number the audit flagged) — it renders only once a plan is selected, **names** the plan
(«مبلغ کل با طرح {plan}»), and shows the same fee delta.
- `EligibilityStep`: the credit-check button now swaps to a spinner + «در حال استعلام اعتبار…» while
pending (mirrors C6's `state_initiating` pattern); the prefilled mobile field is `readOnly` (normal
contrast, screen-reader-reachable) instead of `disabled`.
- `bnpl/return`'s invalid-link state CTA was promising a card payment it couldn't perform (labelled
«پرداخت با کارت», navigated to the bookings list) — relabelled «رزروهای من» (`bd_my_bookings`, C6's own
invalid-link pattern) so the label matches the destination; no recoverable request id exists at that
point to route to an actual card checkout instead.
- `checkout/bnpl/gateway/page.tsx` (the dev provider-handoff harness) is now `notFound()`-gated outside
`NODE_ENV=development` — it was reachable by direct URL in a production build. The equivalent card-gateway
harness was already deleted in refinement-phase-4; this one stays (BNPL is still mock-primary) but is no
longer reachable in prod. The dead `ROUTES.CHECKOUT_GATEWAY` constant (pointed at that already-deleted card
harness page) is removed, and the one place still referencing it — the payment mock's
`initiatePayment` — now returns `redirectUrl: null` (a latent bug: it was building a URL to a page that no
longer exists; the checkout page's `!redirectUrl` branch already reads the outcome directly, so behavior
is unaffected).
**Invoice: fiscal-grade** (`bookings/[id]/invoice/page.tsx`):
- Buyer name and a service+visit-date recap are composed **client-side** (a UI join, not money math) from
`useCustomerProfile()` and `useBookingDetail(bookingId, 'customer')` — no new fields needed.
- Payment method, transaction reference, and a seller fiscal-identity block (legal name / economic code /
address) render when `InvoiceDto` serves them (new `paymentMethod`/`transactionReference`/
`sellerFiscalIdentity` fields, REQ-049) — `null` on the real path hides the row rather than fake it.
- An A4 print pass: a `@page { size: A4; margin: 16mm }` rule alongside the existing print-visibility rule,
and a print-only footer (invoice number + issue date + مودیان reference when present) shown only inside
`@media print`. The existing print mechanics (visibility-scoped area, `insetInlineStart` anchoring,
dark→light token flip) are untouched.
- Deleted the stale comment claiming fa `common.brand` reads «بلینیار» — it actually reads «بالین یار»
(plain space); the brand-spelling unification itself stays phase 12's, per this phase's scope note.
**Money-display sweep**: `PriceBreakdown` rows now render through `<Money>` (every row carries «تومان», not
just the total — the exact Toman/Rial ambiguity the audit flagged); `InstallmentScheduleRow`'s `hideUnit`
was dropped so every installment amount carries the unit too.
## What is now testable (and exactly how)
1. Login as a seeded customer with an accepted request → `/fa/bookings/checkout?request_id=…` at 375px: the
nurse's avatar + verified badge sit above a large total figure; the sticky bar (total + «پرداخت») pins
above the bottom nav. Tap «پرداخت» → both CTAs (card + BNPL) disable, label swaps to «در حال شروع…».
2. Complete the mock capture round-trip → `checkout/return` shows «بازگشت از درگاه ✓» → «در انتظار تایید
بانک» with an animated current-node pulse and a duration hint — no bare spinner+chip stack.
3. Land on the confirmation: کد پیگیری renders LTR with a working copy-to-clipboard (toast confirms);
Shamsi paid-at, method, booking reference, and the escrow line are present; tap «چطور کار می‌کند؟» → the
3-step explainer expands. Block the network and reload → skeleton then a retryable error, never a
silently-missing amount.
4. Open «کیف‌پول» → four tabs. «پرداخت‌ها» lists the just-made payment linking to the booking; «اقساط» is
the unchanged installment tracker; «استردادها» shows a `RefundStatusCard` after cancelling a paid
booking; «رسیدها» links to the invoice. All four read at the same content width as checkout/invoice.
5. Back on checkout, tap «پرداخت اقساطی» → provider rows show a tinted monogram (no two-letter glyph); plan
cards show پیش‌پرداخت/قسط ماهانه/مجموع بازپرداخت in Toman with the fee delta on fee plans and no
`LinearProgress`; selecting a plan names the header total; the eligibility check shows a spinner + label
change while pending; an invalid BNPL return link offers «رزروهای من», not a dead "pay with card" promise.
6. `/fa/bookings/checkout/bnpl/gateway` in a production build (`npm run build && npm start`) → 404; `npm run
dev` → still reachable. Open a paid booking's invoice → buyer/service/visit-date/reference rows present
(mock); print preview shows an A4 page with a footer; from dark mode, the print dialog shows paper colors.
7. `npm run check` is green (`tsc` + `eslint`, zero errors) and `npm run test:ci` is green — **104/104 test
suites, 447/447 tests** (3 new suites: `PaymentStateCard`, `BnplProviderLogo`, `EscrowExplainer`; existing
`Money`/`PriceBreakdown`/`BnplPlanCard` suites extended for the new size/behavior).
8. Repeat 15 on `/en` (LTR) and in dark mode: no clipped RTL/LTR islands, tokens resolve in both schemes,
«تومان» (fa) / "Toman" (en) on every amount including breakdown rows and installment amounts.
## What is mocked / waiting on a real service
No new seams — everything stays behind the existing `PaymentApi`/`BnplApi`/`RefundsApi` seams
(`USE_PAYMENT_MOCK=false`/`USE_BNPL_MOCK=true`/`USE_REFUNDS_MOCK=true`, all unchanged). Extended, not
replaced:
- `PaymentApi.getCheckoutSummary`/`getPaymentOutcome` gained mock-only `nurseAvatarUrl`/`nurseVerified`/
`trackingCode`/`paidAt`; a new `PaymentApi.getPaymentHistory` reads the mock's transaction list.
- `PaymentApi.getInvoice`'s mock-issued invoices gained `paymentMethod`/`transactionReference`/
`sellerFiscalIdentity`.
- `RefundsApi` gained `getMyRefunds`, reading the same in-memory refund store `getRefundByBooking` uses.
- The BNPL provider-handoff harness page is now env-gated (see above) — not a seam change.
See `mocks-registry.md`'s updated `PaymentApi`/`RefundsApi`/BNPL-harness rows for the exact deltas and the
"make it real" steps (REQ-046/047/048/049).
## Contracts
- Consumed: `services/payment` (b10/b11), `services/bnpl` (b12), `services/refunds` (b11) — all unchanged
contract-wise; the mock/real seam split is untouched.
- Requested (`dev/shared-working-context/frontend/requests/for-backend.md`, next free numbers 046049):
- **REQ-046**`nurseAvatarUrl`/`nurseVerified` on `CheckoutSummaryDto` (the C6 identity moment) +
`trackingCode`/`paidAt` on `PaymentOutcomeDto` (the confirmation receipt). Extends REQ-016/017.
- **REQ-047** — a customer payment-transactions list (`GET bookings/payment_history` proposed) for the
wallet «پرداخت‌ها» tab.
- **REQ-048** — a customer "all my refunds" list (`GET refunds/my` proposed) for the wallet «استردادها»
tab — confirmed b11 truly has only by-booking/by-id customer reads. Extends REQ-021.
- **REQ-049**`paymentMethod`/`transactionReference`/`sellerFiscalIdentity` on `InvoiceDto` for the
fiscal-grade invoice. Extends REQ-018.
## Docs updated
- `client/CLAUDE.md` "Project Structure": the checkout/return/confirmation/bnpl subtree (identity moment,
sticky bar, staged wait state, receipt fields, provider-logo/eligibility/plan-naming polish, the gateway
harness env-gate), the invoice line (fiscal fields + A4 print), the wallet line (4-tab hub replacing the
installments-only shell), the `Money`/`PriceBreakdown`/`EscrowNotice`/`InstallmentScheduleRow`/
`BnplPlanCard` component lines, and three new component entries (`PaymentStateCard`, `BnplProviderLogo`,
`EscrowExplainer`). The `services/payment`/`services/refunds`/`services/bnpl` domain-summary lines note
the new hooks/DTO fields.
## Follow-ups for later phases
- **Buyer name on the invoice is a live client-side join** (`useCustomerProfile()`), not a snapshot on
`InvoiceDto` — correct for MVP but not strictly immutable (a later profile-name change would reflect on
an old invoice). If invoice immutability becomes a real concern, a future REQ should ask for a
server-snapshotted `buyerName` at issue time.
- **`variantName()`'s best-effort `variantSnapshotJson` parse is now duplicated a third time**
(`BookingDetailView.tsx`, the review page, and this phase's invoice page) — REQ-045 (typed
`variantSnapshot`) would let all three collapse to one read; not extracted to a shared helper this phase
to avoid touching files outside this phase's lane speculatively.
- **Wallet «پرداخت‌ها»/«رسیدها» merge two independent seams client-side** (card `PaymentApi` history + BNPL
`WalletInstallmentPlan` down-payment rows) rather than one unified "customer transactions" read — kept
the domains separate per existing architecture; if a future phase adds a true unified ledger read, the
merge in `useWalletHistoryRows.ts` can retire.
- The BNPL card's fee-delta math (`totalIrr orderAmountIrr`) needs the order amount threaded down through
`PlanStep`/`BnplPlanCard` — fine today (both wizard steps already have it in scope) but worth folding into
a single served `feeIrr` field per plan if REQ-022 (options/schedule) ever lands, so the client stops
doing even this exact-remainder subtraction.
- Real gateway/Shaparak logos near the pay CTA are still DEFERRED (no licensed assets) — `secure_gateway_notice`
(lock icon + text) is the interim trust signal, per the phase's own scope note.
## Memory
Saved a `project`-type memory (`ui_phase_6_checkout_and_money.md`, indexed in `MEMORY.md`) covering: the
`PaymentStateCard`/`BnplProviderLogo`/`EscrowExplainer` extraction decisions, the provider-logo (monogram
roundel, no brand colors) and money-width (one `CONTENT_MAX_WIDTH`) decisions, the fee-delta-as-exact-
remainder pattern, the wallet history/receipts merge-two-seams-client-side decision, the sticky-bar-vs-
bottom-nav layering (reuses `StickyActionBar`'s existing safe-area composition, never re-implemented), the
print-stylesheet `@page` addition, and REQ-046..049 for whichever backend phase next touches b10/b11/b12.