17 KiB
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 privateMessageCard(checkout/page.tsx, bnpl/page.tsx) andStateCard(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'sMethodStep. Asset-tolerant: dropping in a real logo later touches only the internal registry map, no call-site changes.EscrowExplainer/— wraps the product-mandatedEscrowNotice(never edited) with an optional «چطور کار میکند؟» expander: a 3-step visual (پرداخت ← امانت نزد بالینیار ← آزادسازی پس از تایید پایان ویزیت, grounded inproduct/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:
EngagementSummarynow shows the nurse's avatar + aTrustBadge(verified/unverified off the newnurseVerifiedfield) next to the service/patient/time details. - A prominent
<Money size="xl">total sits above the countdown/breakdown — the same servedtotalIrrPriceBreakdownreconciles 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 nowdisabled={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_payno longer bakes «←»/«→» into the translated string; the button now usesendIcon="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 fromusePaymentOutcome's newtrackingCode(card path) or the settledBnplOrderStatus.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 bareEscrowNotice) and a "what happens next" 2-stepStatusTimeline(اطلاعرسانی به پرستار ← ویزیت و ثبت ورود).- 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-locateduseWalletHistoryRows.tshook merges the two independent seams once, reused by the receipts tab too. Degrades gracefully: either source failing alone still renders the other's rows.
- deep-link to the booking. Card rows come from the new
- «اقساط» — the unchanged f11 D5
WalletInstallmentsbody (own heading/width stripped; it's a section now, not a page). - «استردادها» (
WalletRefunds.tsx) — every refund via the newuseMyRefunds()(REQ-048), each rendered through the existingRefundStatusCardwith a link tobookingRefundStatusPath. - «رسیدها» (
WalletReceipts.tsx) — invoice deep-links derived client-side from the same merged history rows (succeeded + a knownbookingId) — 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/):
BnplPlanCardno longer shows a percent +LinearProgressbar 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 istotalIrr − 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 toplans[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'sstate_initiatingpattern); the prefilled mobile field isreadOnly(normal contrast, screen-reader-reachable) instead ofdisabled.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 nownotFound()-gated outsideNODE_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 deadROUTES.CHECKOUT_GATEWAYconstant (pointed at that already-deleted card harness page) is removed, and the one place still referencing it — the payment mock'sinitiatePayment— now returnsredirectUrl: null(a latent bug: it was building a URL to a page that no longer exists; the checkout page's!redirectUrlbranch 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()anduseBookingDetail(bookingId, 'customer')— no new fields needed. - Payment method, transaction reference, and a seller fiscal-identity block (legal name / economic code /
address) render when
InvoiceDtoserves them (newpaymentMethod/transactionReference/sellerFiscalIdentityfields, REQ-049) —nullon 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,insetInlineStartanchoring, dark→light token flip) are untouched. - Deleted the stale comment claiming fa
common.brandreads «بلینیار» — 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)
- 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 «در حال شروع…». - Complete the mock capture round-trip →
checkout/returnshows «بازگشت از درگاه ✓» → «در انتظار تایید بانک» with an animated current-node pulse and a duration hint — no bare spinner+chip stack. - 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.
- Open «کیفپول» → four tabs. «پرداختها» lists the just-made payment linking to the booking; «اقساط» is
the unchanged installment tracker; «استردادها» shows a
RefundStatusCardafter cancelling a paid booking; «رسیدها» links to the invoice. All four read at the same content width as checkout/invoice. - 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. /fa/bookings/checkout/bnpl/gatewayin 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.npm run checkis green (tsc+eslint, zero errors) andnpm run test:ciis green — 104/104 test suites, 447/447 tests (3 new suites:PaymentStateCard,BnplProviderLogo,EscrowExplainer; existingMoney/PriceBreakdown/BnplPlanCardsuites extended for the new size/behavior).- 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/getPaymentOutcomegained mock-onlynurseAvatarUrl/nurseVerified/trackingCode/paidAt; a newPaymentApi.getPaymentHistoryreads the mock's transaction list.PaymentApi.getInvoice's mock-issued invoices gainedpaymentMethod/transactionReference/sellerFiscalIdentity.RefundsApigainedgetMyRefunds, reading the same in-memory refund storegetRefundByBookinguses.- 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/nurseVerifiedonCheckoutSummaryDto(the C6 identity moment) +trackingCode/paidAtonPaymentOutcomeDto(the confirmation receipt). Extends REQ-016/017. - REQ-047 — a customer payment-transactions list (
GET bookings/payment_historyproposed) for the wallet «پرداختها» tab. - REQ-048 — a customer "all my refunds" list (
GET refunds/myproposed) for the wallet «استردادها» tab — confirmed b11 truly has only by-booking/by-id customer reads. Extends REQ-021. - REQ-049 —
paymentMethod/transactionReference/sellerFiscalIdentityonInvoiceDtofor the fiscal-grade invoice. Extends REQ-018.
- REQ-046 —
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), theMoney/PriceBreakdown/EscrowNotice/InstallmentScheduleRow/BnplPlanCardcomponent lines, and three new component entries (PaymentStateCard,BnplProviderLogo,EscrowExplainer). Theservices/payment/services/refunds/services/bnpldomain-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 onInvoiceDto— 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-snapshottedbuyerNameat issue time. variantName()'s best-effortvariantSnapshotJsonparse is now duplicated a third time (BookingDetailView.tsx, the review page, and this phase's invoice page) — REQ-045 (typedvariantSnapshot) 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
PaymentApihistory + BNPLWalletInstallmentPlandown-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 inuseWalletHistoryRows.tscan retire. - The BNPL card's fee-delta math (
totalIrr − orderAmountIrr) needs the order amount threaded down throughPlanStep/BnplPlanCard— fine today (both wizard steps already have it in scope) but worth folding into a single servedfeeIrrfield 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.