ui phase 6
This commit is contained in:
+24
-21
@@ -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 ~7–10-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 ~7–10-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 ~7–10 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 ~7–10 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=gross−clawback; Σ 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=gross−clawback; Σ 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
@@ -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 7–10 business days",
|
"eta_business_days": "About 7–10 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
@@ -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>
|
||||||
|
|
||||||
|
|||||||
+4
-3
@@ -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
-26
@@ -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}`)}
|
||||||
|
|||||||
+43
-13
@@ -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}
|
||||||
/>
|
/>
|
||||||
|
|||||||
+7
-1
@@ -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 />
|
||||||
|
|||||||
+26
-49
@@ -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 }}>
|
||||||
|
|||||||
+15
-43
@@ -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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+133
-29
@@ -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} />
|
||||||
|
|||||||
+59
-67
@@ -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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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`)),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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';
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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';
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|
||||||
|
|||||||
@@ -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 ~7–10-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 ~7–10-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 (D1–D5)** 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 (D1–D5)** 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 5001–5004 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 5001–5004 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 1–5 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 046–049):
|
||||||
|
- **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.
|
||||||
Reference in New Issue
Block a user