"use client"; import { useEffect, useMemo, useState } from "react"; import { useRouter } from "next/navigation"; import { Elements, ExpressCheckoutElement, PaymentElement, useElements, useStripe } from "@stripe/react-stripe-js"; import type { StripeExpressCheckoutElementConfirmEvent } from "@stripe/stripe-js"; import { getStripe, hasStripePublishableKey } from "@/lib/stripe-client"; import { createRetailPaymentIntent } from "@/actions/billing/retail-payment-intent"; type Stop = { id: string; city: string; state: string; date: string; time?: string; location: string; brand_id: string; }; export type CheckoutItem = { id: string; name: string; price: string; // dollars, with or without $ sign quantity: number; fulfillment?: "pickup" | "ship"; }; type Props = { items: CheckoutItem[]; brandId: string | null; customerName: string; customerEmail: string; customerPhone: string; selectedStop: Stop | null; /** Required shipping address fields (only used when items include ship fulfillment). */ shippingState?: string; shippingPostal?: string; shippingCity?: string; /** Called after the user submits via the form submit button (hosted checkout fallback). */ onHostedCheckout?: () => void; /** Stable id used to group intents for the same logical checkout (UUID per page load). */ checkoutSessionKey?: string; }; /** * Renders the embedded Stripe Elements (Express Checkout + Payment Element) * on top of the existing hosted Stripe Checkout fallback. * * Express Checkout Element is the primary CTA — it surfaces Apple Pay / * Google Pay / Link / PayPal buttons automatically based on the user's * browser and wallet availability. Tapping one opens the wallet's * payment sheet, confirms the PaymentIntent in-page, and we navigate * to /checkout/success to create the order. * * If Stripe.js can't load (no publishable key, network error, etc.), * the component renders a graceful error and the parent falls back to * the hosted Stripe Checkout button. */ export default function StripeExpressCheckout(props: Props) { const router = useRouter(); const [clientSecret, setClientSecret] = useState(null); const [paymentIntentId, setPaymentIntentId] = useState(null); const [intentAmount, setIntentAmount] = useState(null); const [error, setError] = useState(null); const [loading, setLoading] = useState(true); const [available, setAvailable] = useState(false); const hasShip = props.items.some((i) => i.fulfillment === "ship"); const hasPickup = props.items.some( (i) => (i.fulfillment ?? "pickup") === "pickup" && (i as { pickup_type?: string }).pickup_type !== "shed" ); // Block express checkout if pickup items exist but no stop is selected const stopBlocked = hasPickup && !props.selectedStop; const emailBlocked = !props.customerEmail.trim(); // Stable key for grouping intents by checkout session (in case of retry) const sessionKey = useMemo( () => props.checkoutSessionKey ?? `${Date.now()}-${Math.random().toString(36).slice(2)}`, [props.checkoutSessionKey] ); useEffect(() => { let cancelled = false; setLoading(true); setError(null); if (!hasStripePublishableKey()) { setAvailable(false); setError("Stripe publishable key not configured. Use the secure checkout button below."); setLoading(false); return; } if (props.items.length === 0) { setLoading(false); return; } if (stopBlocked) { setLoading(false); return; } (async () => { const result = await createRetailPaymentIntent( props.items.map((i) => ({ id: i.id, name: i.name, price: Number(String(i.price).replace(/[$,]/g, "")) || 0, quantity: i.quantity, })), { name: props.customerName, email: props.customerEmail, }, props.brandId, props.selectedStop?.id ?? null, hasShip ? { state: props.shippingState, postal_code: props.shippingPostal, city: props.shippingCity, } : null ); if (cancelled) return; if (result.success) { setClientSecret(result.clientSecret); setPaymentIntentId(result.paymentIntentId); setIntentAmount(result.amount); setAvailable(true); setError(null); } else { setAvailable(false); setError(result.error); } setLoading(false); })(); return () => { cancelled = true; }; // Re-fetch when the inputs that affect the PaymentIntent change. // eslint-disable-next-line react-hooks/exhaustive-deps }, [ sessionKey, props.brandId, props.selectedStop?.id, props.customerName, props.customerEmail, hasShip ? `${props.shippingState}|${props.shippingPostal}|${props.shippingCity}` : "", stopBlocked, ]); // Persist the pending-checkout payload to sessionStorage so the // success page can create the order. The wallet may overwrite the // name/email (Apple Pay provides them), so we read from the PaymentIntent // metadata on the success page where appropriate. function persistPendingCheckout(intentId: string) { if (typeof sessionStorage === "undefined") return; sessionStorage.setItem( "pending_checkout", JSON.stringify({ customerName: props.customerName, customerEmail: props.customerEmail, customerPhone: props.customerPhone, stopId: props.selectedStop?.id ?? null, items: props.items.map((i) => ({ id: i.id, name: i.name, price: Number(String(i.price).replace(/[$,]/g, "")) || 0, quantity: i.quantity, fulfillment: (i.fulfillment ?? "pickup") as "pickup" | "ship", })), cartBrandId: props.brandId, shippingAddress: hasShip ? { state: props.shippingState, postal_code: props.shippingPostal, city: props.shippingCity, } : undefined, idempotencyKey: sessionKey, paymentIntentId: intentId, paymentIntentAmount: intentAmount, }) ); } // Stripe.js loader promise const stripePromise = useMemo(() => getStripe(), []); if (loading) { return (
Preparing express checkout…
); } if (!available || !clientSecret || !stripePromise) { return ( {error && (

{error}

)}
); } return ( { // Express / Card confirmed in-page. Navigate to success. router.push("/checkout/success?payment_intent=" + (paymentIntentId ?? "")); }} onHostedCheckout={props.onHostedCheckout} /> ); } function ExpressShell({ children }: { children: React.ReactNode }) { return (

Express checkout

{children}
); } type InnerProps = Props & { emailBlocked: boolean; stopBlocked: boolean; paymentIntentId: string | null; intentAmount: number | null; persistPendingCheckout: (intentId: string) => void; onError: (msg: string) => void; onSuccess: () => void; }; function CheckoutInner({ items, brandId, customerName, customerEmail, customerPhone, selectedStop, shippingState, shippingPostal, shippingCity, emailBlocked, stopBlocked, paymentIntentId, persistPendingCheckout, onError, onSuccess, onHostedCheckout, }: InnerProps) { const stripe = useStripe(); const elements = useElements(); const [submitting, setSubmitting] = useState(false); const [expressReady, setExpressReady] = useState(false); const blocked = emailBlocked || stopBlocked; const siteUrl = typeof window !== "undefined" ? window.location.origin : "https://route-commerce-platform.vercel.app"; async function handleExpressConfirm(event: StripeExpressCheckoutElementConfirmEvent) { if (blocked) { event.paymentFailed({ reason: 'fail' }); onError( emailBlocked ? "Enter your email above before paying." : "Pick a pickup stop before paying." ); return; } if (!stripe || !elements) { event.paymentFailed({ reason: 'fail' }); onError("Stripe is not ready yet. Please try again."); return; } // Persist the pending checkout payload so the success page can // create the order. We do this BEFORE confirmPayment because the // wallet may navigate away briefly during the payment sheet. if (paymentIntentId) persistPendingCheckout(paymentIntentId); const { error: confirmError } = await stripe.confirmPayment({ elements, confirmParams: { return_url: `${siteUrl}/checkout/success?source=express`, payment_method_data: { billing_details: { name: customerName || undefined, email: customerEmail || undefined, phone: customerPhone || undefined, address: shippingState ? { state: shippingState, postal_code: shippingPostal, city: shippingCity, country: "US", } : undefined, }, }, }, }); if (confirmError) { event.paymentFailed({ reason: 'fail' }); onError(confirmError.message ?? "Payment failed. Please try again."); } else { // payment confirmed in-page — wallet closes its sheet automatically. onSuccess(); } } async function handleCardSubmit(e: React.FormEvent) { e.preventDefault(); if (!stripe || !elements) { onError("Stripe is not ready yet. Please try again."); return; } if (blocked) { onError( emailBlocked ? "Enter your email above before paying." : "Pick a pickup stop before paying." ); return; } setSubmitting(true); if (paymentIntentId) persistPendingCheckout(paymentIntentId); const { error: submitError } = await elements.submit(); if (submitError) { onError(submitError.message ?? "Form is incomplete."); setSubmitting(false); return; } const { error: confirmError } = await stripe.confirmPayment({ elements, confirmParams: { return_url: `${siteUrl}/checkout/success?source=card`, payment_method_data: { billing_details: { name: customerName || undefined, email: customerEmail || undefined, phone: customerPhone || undefined, address: shippingState ? { state: shippingState, postal_code: shippingPostal, city: shippingCity, country: "US", } : undefined, }, }, }, }); if (confirmError) { onError(confirmError.message ?? "Payment failed."); setSubmitting(false); return; } onSuccess(); } return (
{/* Express (Apple Pay, Google Pay, Link, PayPal) */}
{ const m = ev.availablePaymentMethods; const any = !!m && (m.applePay || m.googlePay || m.link || m.paypal || m.amazonPay); setExpressReady(any); }} options={{ buttonType: { applePay: "buy", googlePay: "buy" }, buttonTheme: { applePay: "black", googlePay: "black" }, paymentMethodOrder: ["apple_pay", "google_pay", "link", "paypal"], layout: { maxColumns: 3, maxRows: 1 }, }} /> {!expressReady && !blocked && (

No express wallets detected on this device — you can still pay by card below.

)} {blocked && (

{emailBlocked ? "Enter your email above to enable express checkout." : "Pick a pickup stop above to enable express checkout."}

)}
{/* Divider */}
or pay by card
{/* Card form */}
{/* Fallback to hosted checkout (existing flow) */} {onHostedCheckout && ( )}
); }