import React, { useState, useMemo, useEffect, useRef, useCallback } from 'react'; import type { CSSProperties } from 'react'; import MapPinSelector from './MapPinSelector'; import AddressForm from './AddressForm'; import PhoneStep from './PhoneStep'; import OtpStep from './OtpStep'; import SavedAddressStep from './SavedAddressStep'; import { createAddress, notifyAddressCompleted, logFunnelEvent, notifySessionAbandoned, fetchSessionOffer, scoreAddressRisk, selectAddress, verifyDigipin, type AddressRiskScore, type SessionOffer, } from '../core/checkout-api'; import type { CheckoutEvent, CheckoutWidgetProps, CompleteAddress } from '../core/types'; import type { SavedAddress } from '../core/checkout-api'; import type { SessionContextPayload } from '../core/checkout-api'; import CodAvailabilityBanner from './CodAvailabilityBanner'; import PrepaidNudge from './PrepaidNudge'; import PaymentStep from './PaymentStep'; import EmailStep from './EmailStep'; import EmailOtpStep from './EmailOtpStep'; import { getWebDeviceContext } from '../core/device'; import { captureUtmFromUrl } from '../core/utm'; import '../styles/checkout.css'; // ─── Types ──────────────────────────────────────────────────────────────────── /** Full step machine — auth gate is phone, email, or none */ type Step = | 'phone' | 'otp' | 'email' | 'email-otp' | 'saved' | 'verify-address' | 'map' | 'form' | 'payment' | 'done'; interface ConfirmedLocation { lat: number; lng: number; digipin: string; } interface AuthState { mobile: string; sessionToken: string; sessionId: string; buyerId: string; } type MobileStepStatus = 'complete' | 'active' | 'pending'; interface MobileStepItem { id: 'auth' | 'address' | 'payment'; label: string; status: MobileStepStatus; } // ─── Success screen ─────────────────────────────────────────────────────────── interface SuccessScreenProps { address: CompleteAddress; onEditAddress: () => void; } const SuccessScreen: React.FC = ({ address, onEditAddress }) => (

Address Saved!

{address.formattedAddress}

DigiPin {address.digipin}
); // ─── Step indicator ─────────────────────────────────────────────────────────── type AuthMode = 'phone' | 'email' | 'none'; function stepToIndex(step: Step, mode: AuthMode, withPayment: boolean): number { const normalizedStep = step === 'verify-address' ? 'map' : step; if (mode === 'none') { if (withPayment) { const order: Step[] = ['map', 'form', 'payment', 'done']; const idx = order.indexOf(normalizedStep); return idx === -1 ? 0 : idx; } return normalizedStep === 'map' ? 0 : normalizedStep === 'form' ? 1 : 2; } const order: Step[] = mode === 'email' ? withPayment ? ['email', 'email-otp', 'saved', 'map', 'form', 'payment', 'done'] : ['email', 'email-otp', 'saved', 'map', 'form', 'done'] : withPayment ? ['phone', 'otp', 'saved', 'map', 'form', 'payment', 'done'] : ['phone', 'otp', 'saved', 'map', 'form', 'done']; const idx = order.indexOf(normalizedStep); return idx === -1 ? 0 : idx; } function totalSteps(mode: AuthMode, withPayment: boolean): number { if (mode === 'none') return withPayment ? 3 : 2; return withPayment ? 6 : 5; } /** Merge merchant `brandColor` into CSS variables consumed by checkout.css */ function mergeBrandRootStyle( base: CSSProperties | undefined, brandColor: string | undefined ): CSSProperties | undefined { const trimmed = brandColor?.trim(); if (!trimmed) return base; const vars: CSSProperties = { ['--qr-primary' as string]: trimmed, ['--qr-primary-dark' as string]: trimmed, ['--qr-border-focus' as string]: trimmed, ['--qr-primary-light' as string]: `color-mix(in srgb, ${trimmed} 18%, white)`, }; return base ? { ...base, ...vars } : vars; } function savedAddressToComplete(saved: SavedAddress): CompleteAddress { return { digipin: saved.digipin!, lat: saved.lat!, lng: saved.lng!, state: saved.state, district: saved.district, division: '', locality: saved.locality, pincode: saved.pincode, delivery: saved.delivery_status, country: 'India', flatNumber: saved.flat_number, floorNumber: saved.floor_number, buildingName: saved.building_name, streetName: saved.street_name, formattedAddress: [ saved.flat_number, saved.building_name, saved.street_name, saved.locality, saved.district, saved.state, saved.pincode, ] .filter(Boolean) .join(', '), label: (saved.label === 'work' || saved.label === 'other' ? saved.label : 'home') as CompleteAddress['label'], labelText: saved.label_text?.trim() || undefined, addressId: saved.id, }; } // ─── Main Widget ────────────────────────────────────────────────────────────── /** * QuantaRoute Checkout Widget * * Default (enablePhoneAuth = false): * map → form → done * * With phone auth (enablePhoneAuth = true): * phone → otp → saved → map → form → done * (buyer can skip phone auth to go straight to map) */ const CheckoutWidget: React.FC = ({ apiKey, apiBaseUrl = 'https://api.quantaroute.com', onComplete, onError, defaultLat, defaultLng, theme = 'light', className = '', style, mapHeight = 'min(52dvh, 420px)', title = 'Add Delivery Address', mobilePresentation = 'auto', mobileStepLabels, indiaBoundaryUrl, enableSearch = true, mapStyle, embedMode, enablePhoneAuth = false, merchantId = '', supabaseFunctionBaseUrl = '', brandColor, logoUrl, headerBanner, onCheckoutEvent, enableCod = false, enableServiceabilityCheck = false, enableAddressIntelligence = false, showIndiaPostDeliveryWarning = false, serviceabilityBlockCheckout, codCartTotalPaise, codSessionToken = '', enableEmailAuth = false, variant, utmParams: utmParamsProp, enablePayment = false, paymentAmountPaise, onPaymentComplete, enablePrepaidNudge = false, prepaidNudgeCopy, prepaidNudgeCtaUrl, registerController, orderConfirmationUrl, }) => { const authMode: AuthMode = enablePhoneAuth ? 'phone' : enableEmailAuth ? 'email' : 'none'; const initialStep: Step = authMode === 'phone' ? 'phone' : authMode === 'email' ? 'email' : 'map'; const withPayment = Boolean(enablePayment && paymentAmountPaise && supabaseFunctionBaseUrl); const [step, setStep] = useState(initialStep); const [confirmedLocation, setConfirmedLocation] = useState(null); const [completedAddress, setCompletedAddress] = useState(null); const [auth, setAuth] = useState(null); const [emailPending, setEmailPending] = useState(''); const [emailCodeReused, setEmailCodeReused] = useState(false); const [codAllowed, setCodAllowed] = useState(false); const [sessionOffer, setSessionOffer] = useState(null); const [addressRisk, setAddressRisk] = useState(null); const [paymentMethod, setPaymentMethod] = useState(null); const [verifyAddress, setVerifyAddress] = useState(null); const [paymentBackStep, setPaymentBackStep] = useState('form'); const stepRef = useRef(step); const utmParams = useMemo( () => utmParamsProp ?? captureUtmFromUrl(), [utmParamsProp] ); const deviceCtx = useMemo(() => getWebDeviceContext(), []); const sessionContext: SessionContextPayload = useMemo( () => ({ utm_source: utmParams.utm_source, utm_medium: utmParams.utm_medium, utm_campaign: utmParams.utm_campaign, device_type: deviceCtx.deviceType, platform: deviceCtx.platform, user_agent: deviceCtx.userAgent, experiment_variant: variant, }), [utmParams, deviceCtx, variant] ); const checkoutConfig = { functionBaseUrl: supabaseFunctionBaseUrl, merchantId, }; const codSession = auth?.sessionToken || codSessionToken; useEffect(() => { stepRef.current = step; }, [step]); const authChannel = (): CheckoutEvent['authChannel'] => { if (authMode === 'phone') return 'phone'; if (authMode === 'email') return 'email'; return 'none'; }; /** Fire client + server analytics without blocking the UI. */ const emit = useCallback( (partial: Omit) => { const event: CheckoutEvent = { ...partial, sessionId: partial.sessionId ?? auth?.sessionId, buyerId: partial.buyerId ?? auth?.buyerId, authChannel: partial.authChannel ?? authChannel(), deviceType: partial.deviceType ?? deviceCtx.deviceType, platform: partial.platform ?? deviceCtx.platform, userAgent: partial.userAgent ?? deviceCtx.userAgent, experimentVariant: partial.experimentVariant ?? variant, timestamp: new Date().toISOString(), }; if (onCheckoutEvent) { try { onCheckoutEvent(event); } catch { // Never let analytics crash the checkout } } if (merchantId && supabaseFunctionBaseUrl) { logFunnelEvent( checkoutConfig, { event_type: event.type, step: event.step, buyer_id: event.buyerId, auth_channel: event.authChannel, device_type: event.deviceType, platform: event.platform, user_agent: event.userAgent, payment_method: event.paymentMethod, experiment_variant: event.experimentVariant, }, auth?.sessionToken ).catch(() => {/* silent */}); } }, [ auth, authMode, deviceCtx, variant, onCheckoutEvent, merchantId, supabaseFunctionBaseUrl, checkoutConfig, ] ); // Explicit abandon on tab close / navigate away useEffect(() => { if (!merchantId || !supabaseFunctionBaseUrl) return; const onLeave = () => { if (stepRef.current === 'done' || !auth?.sessionToken) return; notifySessionAbandoned(checkoutConfig, auth.sessionToken, stepRef.current).catch(() => {}); emit({ type: 'session_abandoned', step: stepRef.current }); }; window.addEventListener('pagehide', onLeave); return () => window.removeEventListener('pagehide', onLeave); }, [auth?.sessionToken, merchantId, supabaseFunctionBaseUrl, checkoutConfig, emit]); // Fetch UTM-matched offer after auth useEffect(() => { if (!auth?.sessionToken || !merchantId || !supabaseFunctionBaseUrl) return; fetchSessionOffer(checkoutConfig, auth.sessionToken) .then(setSessionOffer) .catch(() => setSessionOffer(null)); }, [auth?.sessionToken, merchantId, supabaseFunctionBaseUrl, checkoutConfig]); const rootStyle = useMemo( () => mergeBrandRootStyle(style as CSSProperties | undefined, brandColor), [style, brandColor] ); // ── Phone step callbacks ────────────────────────────────────────────────── const handleOtpSent = (mobile: string) => { setAuth((prev) => ({ mobile, sessionToken: '', sessionId: '', buyerId: prev?.buyerId ?? '' })); emit({ type: 'otp_sent', step: 'otp', mobile }); setStep('otp'); }; const handleVerified = (sessionToken: string, buyerId: string, sessionId?: string) => { setAuth({ mobile: auth?.mobile ?? '', sessionToken, sessionId: sessionId ?? '', buyerId }); emit({ type: 'phone_verified', step: 'saved', buyerId, sessionId }); setStep('saved'); }; const handleSkipAuth = () => { emit({ type: 'auth_skipped', step: 'map' }); setStep('map'); }; // ── Email step callbacks ────────────────────────────────────────────────── const handleEmailOtpSent = (sentEmail: string, info?: { reused?: boolean }) => { setEmailPending(sentEmail); setEmailCodeReused(Boolean(info?.reused)); emit({ type: 'email_otp_sent', step: 'email-otp', mobile: sentEmail }); setStep('email-otp'); }; const handleEmailVerified = (sessionToken: string, buyerId: string, sessionId?: string) => { setAuth({ mobile: emailPending, sessionToken, sessionId: sessionId ?? '', buyerId }); emit({ type: 'email_verified', step: 'saved', buyerId, sessionId }); setStep('saved'); }; // ── Saved addresses callbacks ───────────────────────────────────────────── const handleSelectSavedAddress = async (saved: SavedAddress) => { if (!auth?.sessionToken) return; try { await selectAddress(checkoutConfig, auth.sessionToken, saved.id); } catch (err: unknown) { onError?.(err instanceof Error ? err : new Error('Failed to select address.')); return; } if (!saved.digipin) { setVerifyAddress(saved); emit({ type: 'saved_address_selected', step: 'verify-address', buyerId: auth.buyerId, }); setStep('verify-address'); return; } emit({ type: 'saved_address_selected', step: withPayment ? 'payment' : 'done', buyerId: auth.buyerId, digipin: saved.digipin, }); notifyAddressCompleted(checkoutConfig, auth.sessionToken, { pincode: saved.pincode, digipin: saved.digipin, state: saved.state, district: saved.district, locality: saved.locality, }).catch(() => {/* silent */}); await finishAddress(savedAddressToComplete(saved), { skipCreate: true, paymentBackStep: 'saved' }); }; const handlePaymentComplete = ({ paymentMethod, razorpayOrderId, razorpayPaymentId }: { paymentMethod: string; razorpayOrderId?: string; razorpayPaymentId?: string; }) => { setPaymentMethod(paymentMethod); emit({ type: 'payment_method_selected', step: 'done', paymentMethod }); setStep('done'); if (completedAddress) onComplete(completedAddress); onPaymentComplete?.({ paymentMethod, sessionId: auth?.sessionId, razorpayOrderId, razorpayPaymentId, }); }; // ── Map step callbacks ──────────────────────────────────────────────────── const handleLocationConfirm = (lat: number, lng: number, digipin: string) => { setConfirmedLocation({ lat, lng, digipin }); emit({ type: 'location_confirmed', step: 'form', digipin, buyerId: auth?.buyerId }); setStep('form'); }; // ── Form step callbacks ─────────────────────────────────────────────────── const finishAddress = async ( address: CompleteAddress, options?: { skipCreate?: boolean; paymentBackStep?: Step } ) => { let addressForCompletion: CompleteAddress = address; setCompletedAddress(addressForCompletion); setPaymentBackStep(options?.paymentBackStep ?? 'form'); if (auth?.sessionToken) { notifyAddressCompleted(checkoutConfig, auth.sessionToken, { pincode: address.pincode, digipin: address.digipin, state: address.state, district: address.district, locality: address.locality, }).catch(() => {/* silent */}); if (!options?.skipCreate && !address.addressId) { try { const saved = await createAddress(checkoutConfig, auth.sessionToken, { digipin: address.digipin, lat: address.lat, lng: address.lng, state: address.state, district: address.district, locality: address.locality, pincode: address.pincode, delivery_status: address.delivery, flat_number: address.flatNumber, floor_number: address.floorNumber, building_name: address.buildingName, street_name: address.streetName, full_address_json: address as unknown as Record, label: address.label ?? 'home', label_text: address.label === 'other' ? (address.labelText?.trim() || null) : null, is_default: false, }); addressForCompletion = { ...addressForCompletion, addressId: saved.id }; setCompletedAddress(addressForCompletion); } catch { // Silent } } } if (enableAddressIntelligence && merchantId && supabaseFunctionBaseUrl && codSession) { try { const risk = await scoreAddressRisk(checkoutConfig, codSession, { address_id: addressForCompletion.addressId, digipin: addressForCompletion.digipin, lat: addressForCompletion.lat, lng: addressForCompletion.lng, state: addressForCompletion.state, district: addressForCompletion.district, locality: addressForCompletion.locality, pincode: addressForCompletion.pincode, delivery_status: addressForCompletion.delivery, flat_number: addressForCompletion.flatNumber, floor_number: addressForCompletion.floorNumber, building_name: addressForCompletion.buildingName, street_name: addressForCompletion.streetName, }); setAddressRisk(risk); addressForCompletion = { ...addressForCompletion, addressRisk: risk }; setCompletedAddress(addressForCompletion); emit({ type: 'address_risk_scored', step: withPayment ? 'payment' : 'done', digipin: addressForCompletion.digipin, buyerId: auth?.buyerId, addressRiskLevel: risk.level, addressRiskScore: risk.score, }); } catch { setAddressRisk(null); } } else { setAddressRisk(null); } emit({ type: 'address_completed', step: withPayment ? 'payment' : 'done', digipin: addressForCompletion.digipin, buyerId: auth?.buyerId, }); if (withPayment && codSession) { setStep('payment'); } else if (withPayment && !codSession) { onError?.(new Error('enablePayment requires phone/email auth or a codSessionToken prop.')); setStep('done'); onComplete(addressForCompletion); return; } else { setStep('done'); onComplete(addressForCompletion); } }; const handleAddressComplete = async (address: CompleteAddress) => { if (verifyAddress && auth?.sessionToken) { try { await verifyDigipin(checkoutConfig, auth.sessionToken, verifyAddress.id, { digipin: address.digipin, lat: address.lat, lng: address.lng, state: address.state, district: address.district, locality: address.locality, pincode: address.pincode, delivery_status: address.delivery, flat_number: address.flatNumber, floor_number: address.floorNumber, building_name: address.buildingName, street_name: address.streetName, }); const complete: CompleteAddress = { ...address, addressId: verifyAddress.id }; setVerifyAddress(null); await finishAddress(complete, { skipCreate: true }); } catch (err: unknown) { onError?.(err instanceof Error ? err : new Error('Failed to verify delivery pin.')); } return; } await finishAddress(address); }; const handleBack = (): boolean => { if (step === 'form') { setStep(verifyAddress ? 'verify-address' : 'map'); return true; } if (step === 'verify-address') { setVerifyAddress(null); setConfirmedLocation(null); setStep('saved'); return true; } if (step === 'map' && auth?.sessionToken) { setStep('saved'); return true; } if (step === 'email-otp') { setStep('email'); return true; } if (step === 'otp') { setStep('phone'); return true; } if (step === 'payment') { setStep(paymentBackStep); return true; } return false; }; useEffect(() => { if (!registerController) return; registerController({ goBack: () => { const current = stepRef.current; if (current === 'form') { setStep(verifyAddress ? 'verify-address' : 'map'); return true; } if (current === 'verify-address') { setVerifyAddress(null); setConfirmedLocation(null); setStep('saved'); return true; } if (current === 'map' && auth?.sessionToken) { setStep('saved'); return true; } if (current === 'email-otp') { setStep('email'); return true; } if (current === 'otp') { setStep('phone'); return true; } if (current === 'payment') { setStep(paymentBackStep); return true; } return false; }, getStep: () => stepRef.current, }); }, [registerController, auth?.sessionToken, verifyAddress, paymentBackStep]); const handleEditAddress = () => { emit({ type: 'widget_reset', step: initialStep }); setStep(initialStep); setConfirmedLocation(null); setCompletedAddress(null); setAddressRisk(null); setVerifyAddress(null); }; // ── Emit "step viewed" event on every step change ──────────────────────── useEffect(() => { // OTP steps fire send events in handlers; only emit step-viewed here. const viewedMap: Partial> = { phone: 'phone_step_viewed', email: 'email_step_viewed', saved: 'saved_addresses_viewed', 'verify-address': 'verify_address_step_viewed', map: 'map_step_viewed', form: 'form_step_viewed', payment: 'payment_step_viewed', }; const eventType = viewedMap[step]; if (!eventType) return; emit({ type: eventType, step, buyerId: auth?.buyerId }); // eslint-disable-next-line react-hooks/exhaustive-deps }, [step]); useEffect(() => { if (!enableEmailAuth || authMode !== 'email') return; if (merchantId && supabaseFunctionBaseUrl) return; onError?.( new Error( 'enableEmailAuth requires merchantId and supabaseFunctionBaseUrl on CheckoutWidget.' ) ); }, [enableEmailAuth, authMode, merchantId, supabaseFunctionBaseUrl, onError]); const currentIndex = stepToIndex(step, authMode, withPayment); const total = totalSteps(authMode, withPayment); const activeMobileStep: MobileStepItem['id'] = step === 'payment' ? 'payment' : step === 'phone' || step === 'otp' || step === 'email' || step === 'email-otp' ? 'auth' : 'address'; const authStepLabel = mobileStepLabels?.auth ?? (authMode === 'email' ? 'Email' : 'Mobile'); const mobileSteps: MobileStepItem[] = [ ...(authMode === 'none' ? [] : [{ id: 'auth' as const, label: authStepLabel, status: (activeMobileStep === 'auth' ? 'active' : 'complete') as MobileStepStatus, }]), { id: 'address', label: mobileStepLabels?.address ?? 'Address', status: (activeMobileStep === 'address' ? 'active' : activeMobileStep === 'payment' ? 'complete' : 'pending') as MobileStepStatus, }, ...(withPayment ? [{ id: 'payment' as const, label: mobileStepLabels?.payment ?? 'Pay', status: (activeMobileStep === 'payment' ? 'active' : 'pending') as MobileStepStatus, }] : []), ]; const renderProgress = () => { const dots = Array.from({ length: total + 1 }, (_, i) => i); return (
{dots.map((i) => (
= i ? ' qr-progress__dot--active' : ''}`} /> {i < total && (
i ? ' qr-progress__line--active' : ''}`} /> )} ))}
); }; const renderMobileStepper = () => (
{mobileSteps.map((item, index) => (
{item.label}
{index < mobileSteps.length - 1 ? (
))}
); // ── Render ──────────────────────────────────────────────────────────────── return (
{/* Widget header */} {step !== 'done' && ( <>
{logoUrl ? ( ) : ( )} {title}
{renderProgress()}
{renderMobileStepper()}
{headerBanner ? (
{headerBanner}
) : null} {sessionOffer ? (
{sessionOffer.banner_copy} {sessionOffer.banner_cta_url ? ( <> {' '} Learn more ) : null}
) : null} )} {/* ── Email step ── */} {step === 'email' && enableEmailAuth && ( )} {/* ── Email OTP step ── */} {step === 'email-otp' && enableEmailAuth && emailPending && ( )} {/* ── Phone step ── */} {step === 'phone' && enablePhoneAuth && ( )} {/* ── OTP step ── */} {step === 'otp' && auth && ( )} {/* ── Saved addresses step ── */} {step === 'saved' && auth?.sessionToken && ( { setVerifyAddress(null); setConfirmedLocation(null); setStep('map'); }} /> )} {/* ── Verify address (lazy DigiPin) ── */} {step === 'verify-address' && verifyAddress && ( <>
Confirm your delivery pin to complete this order
)} {/* ── Map step ── */} {step === 'map' && ( )} {/* ── Form step ── */} {step === 'form' && confirmedLocation && ( )} {/* ── Payment step ── */} {step === 'payment' && withPayment && codSession && paymentAmountPaise && ( emit({ type, step: 'payment', paymentMethod, buyerId: auth?.buyerId }) } orderConfirmationUrl={orderConfirmationUrl} /> )} {/* ── Done ── */} {step === 'done' && completedAddress && ( <> {paymentMethod ? (

Paid via {paymentMethod === 'cod' ? 'Cash on Delivery' : 'online payment'}

) : null} {enableCod && merchantId && supabaseFunctionBaseUrl && codSession ? ( <> {enablePrepaidNudge && codAllowed ? ( ) : null} ) : null} )} {/* Powered-by footer */}
Powered by QuantaRoute 🇮🇳
); }; export default CheckoutWidget;