diff --git a/client/CLAUDE.md b/client/CLAUDE.md index 6da8ef4..54286e2 100644 --- a/client/CLAUDE.md +++ b/client/CLAUDE.md @@ -134,13 +134,21 @@ client/ │ │ │ │ ├── [id]/cancel/page.tsx # /bookings/[id]/cancel — f10 cancellation flow: policy-fee disclosure (CancellationPolicyDisclosure) + reason + acknowledge → confirm → useCancelBooking → refund status │ │ │ │ ├── [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 │ │ │ │ └── checkout/ # f9 checkout flow (C5 accept CTA lands on page.tsx with ?request_id=) - │ │ │ │ ├── page.tsx # C6 خلاصه و پرداخت — acceptance badge, served reconciling breakdown (PriceBreakdown), EscrowNotice, payment-window countdown, «ادامه پرداخت ←» (idempotency-key-per-attempt) + disabled BNPL seam (f11) + │ │ │ │ ├── page.tsx # C6 خلاصه و پرداخت — acceptance badge, served reconciling breakdown (PriceBreakdown), EscrowNotice, payment-window countdown, «ادامه پرداخت ←» (idempotency-key-per-attempt) + «پرداخت اقساطی» → f11 BNPL wizard │ │ │ │ ├── gateway/page.tsx # dev mock-gateway page — TEST HARNESS standing in for the PSP redirect (mock redirectUrl points here; success/failure buttons drive both return branches) │ │ │ │ ├── return/page.tsx # return-from-gateway — confirm return → pending-callback poll (backoff, stops on terminal) → succeeded (invalidate + hand off) / failed retry / window-expired - │ │ │ │ └── confirmation/page.tsx # payment success — «مشاهده رزرو» (booking detail) + «دانلود فاکتور» (invoice) + │ │ │ │ ├── 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) + │ │ │ │ └── bnpl/ # f11 BNPL installment checkout (the alternate branch off C6, reached with ?request_id=) + │ │ │ │ ├── page.tsx # D1→D4 stateful wizard (StepperHeader): D1 method/provider · D2 plan · D3 eligibility · D4 schedule+contract → provider handoff; card fall-back → C6 everywhere + │ │ │ │ ├── MethodStep.tsx # D1 روش پرداخت — payable amount + full-card option + provider option cards (from useBnplOptions, never hardcoded) + │ │ │ │ ├── PlanStep.tsx # D2 انتخاب طرح — single-select BnplPlanCard group (served monthly/down-payment) + │ │ │ │ ├── EligibilityStep.tsx # D3 اعتبارسنجی — کد ملی + prefilled موبایل + consent gate → useCheckEligibility → approved(ceiling)/declined(+card) + │ │ │ │ ├── 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 + │ │ │ │ └── return/page.tsx # settle (useAcceptBnplSchedule) → invalidate → reused confirmation (?method=bnpl) / retry / card │ │ │ ├── patients/page.tsx # /patients — E1 list/CRUD (add/edit dialog reusing PatientForm, soft-archive) │ │ │ ├── addresses/page.tsx # /addresses — F3 address book (cascading region dropdowns + map-pin picker, set-primary) - │ │ │ ├── wallet/page.tsx # /wallet + │ │ │ ├── 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) │ │ │ └── profile/page.tsx # /profile — customer profile + emergency contact (no national-ID) │ │ ├── nurse/ # Nurse app (/nurse/…) — sidebar shell │ │ │ ├── layout.tsx # 'use client' — wraps NurseLayout @@ -193,6 +201,8 @@ client/ │ ├── 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) │ ├── 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) + │ ├── 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) │ ├── booking/ # f8 post-payment engagement composites (import from @/components/booking). BookingDetailView (both-roles smart container, role-conditioned EVV+gated care), BookingStatusTimeline (server-truth 7-status timeline over StepperHeader), SessionList→SessionCard (per-session schedule/status/EVV CTA), EvvStatusBanner (advisory in/out-of-range/no-gps), CareInstructionsCard (decrypted clinical read), BookingMoneySummary (gross/commission/payout display-only); useEvvController (GPS-capture + check-in/out orchestration), format.ts + statusKind.ts helpers. Each composite tested; the BookingDetailView test proves the customer never fires the care query (two-stage-disclosure gate) │ ├── geography/ # F3 geo composites: CascadingRegionSelect, AddressMapPicker (map-pin stand-in), AddressForm, AddressCard (each tested) │ └── auth/ # Auth-flow composites: LoginFlow, PhoneStep, OtpStep, RoleRouter, SelectRole, AuthCard, BrandMark, AuthSplash, useCountdown @@ -249,6 +259,7 @@ client/ │ ├── 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 │ ├── 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 + │ ├── 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 │ └── {domain}/ │ ├── types.ts # Request/response types + the domain's Api interface (the seam) │ ├── keys.ts # React Query key factory (hierarchical) @@ -346,11 +357,11 @@ async function MyServerComponent() { - `'verification'` — the f5 nurse trust flow: B3/B4/B5/B6 copy, per-step labels + status labels (keyed off code, never derived), the DocumentUpload state chrome, TrustBadge labels, the honesty-sensitive manual-vs-auto copy, the publish-gate + shared-SIM/mismatch messages - `'payment'` — the f9 checkout & invoice surface: C6 labels (breakdown rows هزینه خدمت/کارمزد بالین‌یار/مالیات/مبلغ کل, the **verbatim escrow copy** `escrow_notice`, «ادامه پرداخت ←», the BNPL seam), the card-flow states (initiating/redirecting/pending/failed/expired/already-paid), the confirmation + invoice screens (VAT-on-commission line, مودیان `moadian_*` states), `pstatus_*` transaction-status labels, and the dev mock-gateway harness copy; consumed by the checkout pages, the invoice page, `EscrowNotice`, and `PaymentStatusBadge` - `'refunds'` — the f10 customer cancellation + refund-status surface: policy-tier labels keyed off `cancellation_policy_code` (`policy_*`), the lead-time + refund %/fee % disclosure, the refund-vs-fee breakdown rows, the multi-session refundable/locked reasons (`reason_*`), the admin-approval explainer, the three refund-status step + chip labels (`step_*`/`rstatus_*`), the per-channel ETA copy (`eta_*` — `bnpl_revert` 7–10-business-day window / `psp_card` / `manual`), and the failed/contact-support copy; consumed by the cancel + refund-status pages and `CancellationPolicyDisclosure`/`RefundStatusCard`/`RefundEtaBanner` +- `'bnpl'` — the f11 BNPL installment checkout (D1–D5): the ownership-truth copy (`ownership_note`/`contract_note`/`provider_owned_note`/`paid_via_installments` — the agreement is customer↔provider, provider-financed, Balinyaar paid in full), provider names/taglines keyed off `provider_{code}`, the method/plan/eligibility/schedule labels, ICU-`number` plan params (`plan_term_months`/`plan_installments`/`plan_fee`/`down_payment_percent`/`installment_n` — Persian digits on `fa`), the declined/error copy + card fall-back, the D5 wallet outstanding-balance/due-list/`status_*` labels, and the handoff/settle states; consumed by the D1–D4 wizard + gateway/return pages, `WalletInstallments`, the reused confirmation, and `BnplPlanCard`/`InstallmentScheduleRow` - `'auth'` — the phone-OTP login flow, role router, and SelectRole screen (`common.brand`/`brand_tagline` for the wordmark) **Namespace conventions for the phases to come** (seed each when its feature lands, in both locale -files): `onboarding`, `verification`, `search`, `booking`, `payment`, `bnpl`, `reviews`, -`notifications`, `admin`. Keep top-level keys as namespaces and both files in sync. +files): `reviews`, `notifications`, `admin`. Keep top-level keys as namespaces and both files in sync. **Never hard-code UI strings in English.** Any user-visible text must have a translation key in both locale files. diff --git a/client/messages/en.json b/client/messages/en.json index d93d796..e6cee83 100644 --- a/client/messages/en.json +++ b/client/messages/en.json @@ -839,5 +839,94 @@ "eta_expected_label": "estimated by {date}", "cancel_booking_cta": "Cancel booking", "refund_section_title": "Refund" + }, + "bnpl": { + "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.", + "payable_amount": "Payable amount", + "total_amount": "Total amount", + "monthly": "Monthly", + "continue": "Continue", + "error_title": "Something went wrong", + "error_body": "We couldn't load the installment options. Please try again.", + "provider_digipay": "Digipay", + "provider_snapppay": "SnappPay", + "provider_balinyaar": "Balinyaar Installments", + "provider_tara": "Tara", + "provider_torobpay": "TorobPay", + "provider_tagline_digipay": "3 to 12 installments", + "provider_tagline_snapppay": "4 interest-free installments", + "provider_tagline_balinyaar": "In-house plan", + "provider_tagline_tara": "Installment plan", + "provider_tagline_torobpay": "Installment plan", + "method_title": "Payment method", + "method_card": "Pay in full (bank card)", + "method_card_hint": "One-time payment, instant booking confirmation", + "installments_heading": "Pay in installments", + "continue_with": "Continue with {provider}", + "no_providers_title": "Installments unavailable", + "no_providers_body": "Installment payment isn't available for this booking right now. You can pay by card.", + "plan_title": "{provider} installment plan", + "plan_term_months": "{months, number} months", + "plan_installments": "{count, number} installments", + "plan_interest_free": "Interest-free", + "plan_fee": "{percent, number}% fee", + "down_payment": "Down payment", + "down_payment_percent": "{percent, number}% down payment", + "no_plans_title": "No plans available", + "no_plans_body": "This provider has no installment plans for this booking.", + "back_to_providers": "Choose another provider", + "eligibility_title": "Credit eligibility", + "national_id_label": "National ID", + "national_id_placeholder": "10-digit national ID", + "national_id_invalid": "National ID must be 10 digits.", + "mobile_label": "Mobile number", + "consent_label": "I agree to a credit and eligibility inquiry by {provider}.", + "check_eligibility": "Check eligibility", + "approved_title": "You're approved", + "credit_ceiling_label": "Available credit ceiling", + "approve_continue": "Confirm & continue", + "declined_not_eligible_title": "Not approved", + "declined_not_eligible_body": "Unfortunately the provider didn't approve the required credit.", + "declined_ceiling_title": "Credit ceiling exceeded", + "declined_ceiling_body": "This booking's total exceeds your available credit.", + "pay_with_card": "Pay with card", + "eligibility_error": "The inquiry failed. Try again, or pay by card.", + "schedule_title": "Repayment schedule", + "today": "Today", + "installment_n": "Installment {n, number}", + "contract_note": "The installment agreement is between you and {provider}; Balinyaar is paid the full booking amount up-front.", + "contract_consent": "I have read and accept the installment terms and contract.", + "pay_down_payment": "Confirm & pay down payment", + "redirecting": "Redirecting to {provider}…", + "handoff_title": "Redirecting to the provider", + "handoff_body": "Complete the installment agreement with {provider} to confirm your booking.", + "handoff_pay_success": "Pay down payment (demo)", + "handoff_pay_fail": "Cancel", + "settling_title": "Confirming your installment plan", + "settling_body": "The provider is settling your order. This won't take long.", + "settle_failed_title": "Installment payment didn't complete", + "settle_failed_body": "The provider didn't complete the transaction. You can retry or pay by card.", + "retry_installments": "Retry installments", + "check_again": "Check again", + "wallet_title": "Wallet & installments", + "outstanding_balance": "Outstanding installment balance", + "next_installment": "Next installment", + "early_pay": "Pay early", + "due_dates": "Due dates", + "status_paid": "Paid", + "status_due_soon": "Due soon", + "status_upcoming": "Upcoming", + "status_overdue": "Overdue", + "provider_owned_note": "Your installment status is recorded and managed by {provider}, not Balinyaar.", + "wallet_empty_title": "No active installment plan", + "wallet_empty_body": "If you pay for a booking in installments, your installment status will appear here.", + "wallet_error_title": "Installment status unavailable", + "wallet_error_body": "We couldn't reach the provider's installment status. Please try again.", + "paid_via_installments": "Paid in installments via {provider}", + "step_provider": "Provider", + "step_plan": "Plan", + "step_eligibility": "Eligibility", + "step_schedule": "Schedule" } } diff --git a/client/messages/fa.json b/client/messages/fa.json index 76aa772..ed36890 100644 --- a/client/messages/fa.json +++ b/client/messages/fa.json @@ -839,5 +839,94 @@ "eta_expected_label": "تا حدود {date}", "cancel_booking_cta": "لغو رزرو", "refund_section_title": "بازپرداخت" + }, + "bnpl": { + "title": "پرداخت اقساطی", + "ownership_note": "ارائه‌دهنده کل مبلغ را یک‌جا به بالین‌یار می‌پردازد و ریسک نکول مشتری کاملاً با اوست؛ بازپرداخت اقساط میان شما و ارائه‌دهنده است.", + "payable_amount": "مبلغ قابل پرداخت", + "total_amount": "مبلغ کل", + "monthly": "ماهانه", + "continue": "ادامه", + "error_title": "خطایی رخ داد", + "error_body": "بارگذاری گزینه‌های اقساط ممکن نشد. دوباره تلاش کنید.", + "provider_digipay": "دیجی‌پی", + "provider_snapppay": "اسنپ‌پی", + "provider_balinyaar": "اقساط بالین‌یار", + "provider_tara": "تارا", + "provider_torobpay": "ترب‌پی", + "provider_tagline_digipay": "۳ تا ۱۲ قسط", + "provider_tagline_snapppay": "۴ قسط بدون سود", + "provider_tagline_balinyaar": "طرح داخلی", + "provider_tagline_tara": "طرح اقساطی", + "provider_tagline_torobpay": "طرح اقساطی", + "method_title": "روش پرداخت", + "method_card": "پرداخت کامل (کارت بانکی)", + "method_card_hint": "پرداخت یک‌جا و تایید فوری رزرو", + "installments_heading": "پرداخت اقساطی", + "continue_with": "ادامه با {provider}", + "no_providers_title": "اقساط در دسترس نیست", + "no_providers_body": "در حال حاضر پرداخت اقساطی برای این رزرو ممکن نیست. می‌توانید با کارت پرداخت کنید.", + "plan_title": "طرح اقساط {provider}", + "plan_term_months": "{months, number} ماهه", + "plan_installments": "{count, number} قسط", + "plan_interest_free": "بدون سود", + "plan_fee": "کارمزد {percent, number}٪", + "down_payment": "پیش‌پرداخت", + "down_payment_percent": "پیش‌پرداخت {percent, number}٪", + "no_plans_title": "طرحی موجود نیست", + "no_plans_body": "این ارائه‌دهنده برای این رزرو طرح اقساطی ندارد.", + "back_to_providers": "انتخاب ارائه‌دهنده دیگر", + "eligibility_title": "اعتبارسنجی", + "national_id_label": "کد ملی", + "national_id_placeholder": "کد ملی ۱۰ رقمی", + "national_id_invalid": "کد ملی باید ۱۰ رقم باشد.", + "mobile_label": "شماره موبایل", + "consent_label": "با استعلام اعتبارسنجی و سابقه اعتباری من توسط {provider} موافقم.", + "check_eligibility": "استعلام اعتبار", + "approved_title": "اعتبار شما تایید شد", + "credit_ceiling_label": "سقف اعتبار قابل استفاده", + "approve_continue": "تایید و ادامه", + "declined_not_eligible_title": "اعتبارسنجی تایید نشد", + "declined_not_eligible_body": "متأسفانه ارائه‌دهنده اعتبار لازم را تایید نکرد.", + "declined_ceiling_title": "سقف اعتبار کافی نیست", + "declined_ceiling_body": "مبلغ این رزرو از سقف اعتبار قابل استفاده شما بیشتر است.", + "pay_with_card": "پرداخت با کارت", + "eligibility_error": "استعلام ناموفق بود. دوباره تلاش کنید یا با کارت پرداخت کنید.", + "schedule_title": "جدول بازپرداخت", + "today": "امروز", + "installment_n": "قسط {n, number}", + "contract_note": "قرارداد اقساط میان شما و {provider} است؛ بالین‌یار مبلغ کامل رزرو را یک‌جا دریافت می‌کند.", + "contract_consent": "شرایط و قرارداد اقساط را خوانده‌ام و می‌پذیرم.", + "pay_down_payment": "تایید نهایی و پرداخت پیش‌پرداخت", + "redirecting": "در حال انتقال به {provider}…", + "handoff_title": "در حال انتقال به ارائه‌دهنده", + "handoff_body": "برای تایید رزرو، قرارداد اقساط را با {provider} تکمیل کنید.", + "handoff_pay_success": "پرداخت پیش‌پرداخت (نمایشی)", + "handoff_pay_fail": "انصراف", + "settling_title": "در حال تایید طرح اقساط شما", + "settling_body": "ارائه‌دهنده در حال تسویه سفارش است. طولی نمی‌کشد.", + "settle_failed_title": "پرداخت اقساطی کامل نشد", + "settle_failed_body": "ارائه‌دهنده تراکنش را کامل نکرد. می‌توانید دوباره تلاش کنید یا با کارت پرداخت کنید.", + "retry_installments": "تلاش دوباره برای اقساط", + "check_again": "بررسی دوباره", + "wallet_title": "کیف‌پول و اقساط", + "outstanding_balance": "مانده بدهی اقساط", + "next_installment": "قسط بعدی", + "early_pay": "پرداخت زودهنگام", + "due_dates": "سررسیدها", + "status_paid": "پرداخت‌شده", + "status_due_soon": "سررسید نزدیک", + "status_upcoming": "آینده", + "status_overdue": "معوق", + "provider_owned_note": "وضعیت اقساط نزد {provider} ثبت و مدیریت می‌شود، نه بالین‌یار.", + "wallet_empty_title": "طرح اقساط فعالی ندارید", + "wallet_empty_body": "اگر رزروی را اقساطی پرداخت کنید، وضعیت اقساط اینجا نمایش داده می‌شود.", + "wallet_error_title": "وضعیت اقساط در دسترس نیست", + "wallet_error_body": "دسترسی به وضعیت اقساط ارائه‌دهنده ممکن نشد. دوباره تلاش کنید.", + "paid_via_installments": "پرداخت‌شده به‌صورت اقساطی از طریق {provider}", + "step_provider": "ارائه‌دهنده", + "step_plan": "طرح", + "step_eligibility": "اعتبارسنجی", + "step_schedule": "جدول" } } diff --git a/client/src/app/[locale]/(private-routes)/(customer)/bookings/checkout/bnpl/EligibilityStep.tsx b/client/src/app/[locale]/(private-routes)/(customer)/bookings/checkout/bnpl/EligibilityStep.tsx new file mode 100644 index 0000000..9310c05 --- /dev/null +++ b/client/src/app/[locale]/(private-routes)/(customer)/bookings/checkout/bnpl/EligibilityStep.tsx @@ -0,0 +1,222 @@ +'use client'; +import { FunctionComponent, useState } from 'react'; +import { useLocale, useTranslations } from 'next-intl'; +import { Checkbox, FormControlLabel, Paper, Stack, TextField, Typography } from '@mui/material'; +import { AppButton, AppIcon, PhoneNumberField } from '@/components'; +import { digitsOnly, formatIrrToToman } from '@/utils'; +import { useCheckEligibility } from '@/services/bnpl'; +import { NATIONAL_ID_LENGTH, NATIONAL_ID_PATTERN } from '@/services/bnpl/constants'; +import type { BnplEligibilityResult, ProviderCode } from '@/services/bnpl/types'; + +interface EligibilityStepProps { + bookingRequestId: number; + providerCode: ProviderCode; + /** Mobile prefilled from the session (may be empty if unknown). */ + sessionMobile: string; + /** A prior approval to re-show on back-navigation from D4 (so the approved panel survives, not the form). */ + initialResult?: BnplEligibilityResult | null; + onApproved: (result: BnplEligibilityResult) => void; + onPayWithCard: () => void; +} + +/** + * D3 · اعتبارسنجی — the provider credit check. کد ملی (client-side format only — the real check is the + * provider's), موبایل (prefilled from the session, read-only), and a consent checkbox that **gates** the + * submit. On approval → the credit ceiling + «تایید و ادامه» → D4. On decline / ceiling-exceeded → the + * declined panel + a card fall-back (never a dead end). The verdict is surfaced, never pre-judged. + */ +const EligibilityStep: FunctionComponent = ({ + bookingRequestId, + providerCode, + sessionMobile, + initialResult, + onApproved, + onPayWithCard, +}) => { + const t = useTranslations('bnpl'); + const tc = useTranslations('common'); + const locale = useLocale(); + + const [nationalId, setNationalId] = useState(''); + const [consent, setConsent] = useState(false); + const [submitted, setSubmitted] = useState(false); + const check = useCheckEligibility(); + // A fresh check wins; otherwise re-show a prior approval carried back from D4. + const result = check.data ?? initialResult ?? undefined; + + const nationalIdValid = NATIONAL_ID_PATTERN.test(nationalId); + const nationalIdError = submitted && !nationalIdValid; + const providerName = t(`provider_${providerCode}`); + + const handleSubmit = () => { + setSubmitted(true); + if (!nationalIdValid || !consent) return; + check.mutate({ bookingRequestId, providerCode, nationalId, mobile: sessionMobile, consent }); + }; + + // Approved — show the ceiling + advance. + if (result?.isEligible) { + return ( + + + + + + {t('approved_title')} + + {result.creditCeilingIrr ? ( + <> + + {t('credit_ceiling_label')} + + + {formatIrrToToman(result.creditCeilingIrr, locale)} {tc('currency_toman')} + + + ) : null} + + + onApproved(result)} sx={{ m: 0 }}> + {t('approve_continue')} + + + ); + } + + // Declined (not_eligible / ceiling_exceeded) — a clear panel + the card fall-back. + if (result && !result.isEligible) { + const ceiling = result.eligibilityStatus === 'ceiling_exceeded'; + return ( + + ); + } + + // Error / timeout — retry or fall back to card. + if (check.isError) { + return ( + + ); + } + + return ( + + + {t('eligibility_title')} + + + setNationalId(digitsOnly(e.target.value).slice(0, NATIONAL_ID_LENGTH))} + error={nationalIdError} + helperText={nationalIdError ? t('national_id_invalid') : undefined} + slotProps={{ htmlInput: { dir: 'ltr', inputMode: 'numeric', maxLength: NATIONAL_ID_LENGTH, style: { textAlign: 'start' } } }} + fullWidth + /> + + undefined} + disabled + fullWidth + /> + + setConsent(e.target.checked)} color="secondary" />} + label={ + + {t('consent_label', { provider: providerName })} + + } + sx={{ alignItems: 'flex-start', m: 0 }} + /> + + + + {t('check_eligibility')} + + + {t('pay_with_card')} + + + + ); +}; + +function DeclinedPanel({ + title, + body, + cardLabel, + onPayWithCard, + onRetry, + retryLabel, +}: { + title: string; + body: string; + cardLabel: string; + onPayWithCard: () => void; + onRetry?: () => void; + retryLabel?: string; +}) { + return ( + + + + + + {title} + + + {body} + + + + + {onRetry && retryLabel ? ( + + {retryLabel} + + ) : null} + + {cardLabel} + + + + ); +} + +export default EligibilityStep; diff --git a/client/src/app/[locale]/(private-routes)/(customer)/bookings/checkout/bnpl/MethodStep.tsx b/client/src/app/[locale]/(private-routes)/(customer)/bookings/checkout/bnpl/MethodStep.tsx new file mode 100644 index 0000000..66212bb --- /dev/null +++ b/client/src/app/[locale]/(private-routes)/(customer)/bookings/checkout/bnpl/MethodStep.tsx @@ -0,0 +1,206 @@ +'use client'; +import { FunctionComponent } from 'react'; +import { useLocale, useTranslations } from 'next-intl'; +import { Box, ButtonBase, Paper, Stack, Typography } from '@mui/material'; +import { AppButton, AppIcon } from '@/components'; +import { formatIrrToToman } from '@/utils'; +import type { BnplOptions, BnplProvider, ProviderCode } from '@/services/bnpl/types'; + +interface MethodStepProps { + options: BnplOptions; + selectedProvider: ProviderCode | null; + onSelectProvider: (code: ProviderCode) => void; + onContinue: () => void; + onPayWithCard: () => void; +} + +/** Two-letter provider glyph for the logo stand-in (real logos land with the provider assets). */ +const PROVIDER_GLYPH: Record = { + 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 + * card flow — never rebuilt here), and the installment providers loaded **from the contract/mock** (never + * hardcoded). Primary action «ادامه با {provider}». Empty provider set → only the card option. + */ +const MethodStep: FunctionComponent = ({ + options, + selectedProvider, + onSelectProvider, + onContinue, + onPayWithCard, +}) => { + const t = useTranslations('bnpl'); + const tc = useTranslations('common'); + const locale = useLocale(); + const hasProviders = options.providers.length > 0; + + return ( + + + {t('method_title')} + + + + + {t('payable_amount')} + + + {formatIrrToToman(options.orderAmountIrr, locale)} {tc('currency_toman')} + + + + {/* Full-card option — selecting it continues the f9 card flow (C6), which this phase does not rebuild. */} + + + + + + {t('method_card')} + + + {t('method_card_hint')} + + + + + + {hasProviders ? ( + <> + + {t('installments_heading')} + + {/* Ownership disclosure at the point of choice: the provider finances & owns the repayment. */} + + {t('ownership_note')} + + + {options.providers.map((provider) => ( + onSelectProvider(provider.providerCode)} + /> + ))} + + + {selectedProvider + ? t('continue_with', { provider: t(`provider_${selectedProvider}`) }) + : t('continue')} + + + ) : ( + + + {t('no_providers_title')} + + + {t('no_providers_body')} + + + )} + + ); +}; + +function ProviderOption({ + provider, + selected, + onSelect, +}: { + provider: BnplProvider; + selected: boolean; + onSelect: () => void; +}) { + const t = useTranslations('bnpl'); + return ( + + + + {PROVIDER_GLYPH[provider.providerCode]} + + + + {t(`provider_${provider.providerCode}`)} + + + {t(`provider_tagline_${provider.providerCode}`)} + + + {/* Decorative radio indicator — the whole card is the ButtonBase; a real here would nest + interactive content inside a