From 015b1cf7b562d0f51519d04667a815a5a791905a Mon Sep 17 00:00:00 2001 From: default Date: Thu, 4 Jun 2026 18:38:21 +0000 Subject: [PATCH] feat(checkout): real Stripe Express + Elements on /checkout - Add @stripe/stripe-js + @stripe/react-stripe-js - New src/lib/stripe-client.ts: cached loadStripe helper - New src/actions/billing/retail-payment-intent.ts: server action that creates a PaymentIntent with automatic_payment_methods - New src/components/storefront/StripeExpressCheckout.tsx: embedded ExpressCheckoutElement (Apple Pay, Google Pay, Link, PayPal) + PaymentElement (card) + hosted-checkout fallback - /checkout form is now controlled; StripeExpressCheckout reads name/email/stop from form state and confirms in-page via stripe.confirmPayment - /checkout/success handles both ?session_id= and ?payment_intent= so embedded + hosted flows both land on the same order-creation page using the pending_checkout sessionStorage payload - Export StopInfo from CartContext and select 'time' on the stops fetch so the local Stop shape matches the context's StopInfo - QuickCartSheet express buttons are visual shortcuts only; /checkout auto-renders the real Apple Pay / Google Pay buttons Requires NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY for the embedded path; if missing the embedded section falls back to the hosted Stripe Checkout button. npx tsc --noEmit passes. --- package.json | 2 + src/actions/billing/retail-payment-intent.ts | 123 +++++ src/app/checkout/CheckoutClient.tsx | 392 ++++++++------ src/app/checkout/success/page.tsx | 25 +- src/components/storefront/QuickCartSheet.tsx | 5 +- .../storefront/StripeExpressCheckout.tsx | 504 ++++++++++++++++++ src/context/CartContext.tsx | 4 +- src/lib/stripe-client.ts | 35 ++ 8 files changed, 919 insertions(+), 171 deletions(-) create mode 100644 src/actions/billing/retail-payment-intent.ts create mode 100644 src/components/storefront/StripeExpressCheckout.tsx create mode 100644 src/lib/stripe-client.ts diff --git a/package.json b/package.json index f093578..c721700 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,8 @@ "@google/generative-ai": "^0.24.1", "@gsap/react": "^2.1.2", "@sentry/nextjs": "^10.55.0", + "@stripe/react-stripe-js": "^3.10.0", + "@stripe/stripe-js": "^5.10.0", "@supabase/ssr": "^0.10.2", "@supabase/supabase-js": "^2.105.3", "@upstash/ratelimit": "^2.0.8", diff --git a/src/actions/billing/retail-payment-intent.ts b/src/actions/billing/retail-payment-intent.ts new file mode 100644 index 0000000..90511b9 --- /dev/null +++ b/src/actions/billing/retail-payment-intent.ts @@ -0,0 +1,123 @@ +"use server"; + +import { svcHeaders } from "@/lib/svc-headers"; + +/** + * Creates a Stripe PaymentIntent for the supplied cart so the browser + * can confirm the payment with embedded Stripe Elements (Apple Pay / + * Google Pay / card) without redirecting to a hosted page. + * + * Mirrors `createRetailStripeCheckoutSession` in `retail-checkout.ts`: + * the PaymentIntent is the embedded equivalent of the Checkout Session. + * Order creation itself still happens on `/checkout/success` so the + * webhooks and order pipeline don't need to know which path the buyer + * used. + */ +type LineItem = { + id: string; + name: string; + price: number; + quantity: number; +}; + +type CustomerInfo = { + name?: string; + email?: string; +}; + +type CreatePaymentIntentResult = + | { success: true; clientSecret: string; paymentIntentId: string; amount: number } + | { success: false; error: string }; + +export async function createRetailPaymentIntent( + items: LineItem[], + customer: CustomerInfo, + brandId: string | null, + stopId: string | null, + shippingAddress?: { state?: string; postal_code?: string; city?: string } | null +): Promise { + const stripeKey = process.env.STRIPE_SECRET_KEY; + if (!stripeKey) { + return { success: false, error: "Stripe not configured on this server." }; + } + + if (!Array.isArray(items) || items.length === 0) { + return { success: false, error: "Cart is empty." }; + } + + const Stripe = (await import("stripe")).default; + const stripe = new Stripe(stripeKey, { apiVersion: "2026-04-22.dahlia" as any }); + + // Compute the subtotal in cents. We don't compute sales tax here — + // Stripe's `automatic_tax` would be ideal but requires address collection + // client-side; for this single-product corn box the price is tax-inclusive + // (see Tuxedo Corn product card) so we treat the line total as final. + const amount = items.reduce((sum, item) => { + const qty = Math.max(1, Math.floor(item.quantity || 1)); + const unit = Math.max(0, Math.round(Number(item.price) * 100)); + return sum + unit * qty; + }, 0); + + if (amount <= 0) { + return { success: false, error: "Cart total must be greater than $0." }; + } + + // Pull the brand name for Stripe receipts + metadata + const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL; + const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY; + let brandName = "Route Commerce"; + if (supabaseUrl && supabaseKey && brandId) { + try { + const brandRes = await fetch( + `${supabaseUrl}/rest/v1/brands?id=eq.${brandId}&select=name`, + { headers: { ...svcHeaders(supabaseKey) } } + ); + const brands = (await brandRes.json()) as Array<{ name: string }>; + if (brands?.[0]?.name) brandName = brands[0].name; + } catch { + // ignore — use default + } + } + + // Build a short human-readable description for the Stripe dashboard + const description = items + .slice(0, 3) + .map((i) => `${i.quantity}× ${i.name}`) + .join(", "); + + try { + const intent = await stripe.paymentIntents.create({ + amount, + currency: "usd", + automatic_payment_methods: { enabled: true }, + receipt_email: customer.email || undefined, + description, + metadata: { + brand_id: brandId ?? "unknown", + brand_name: brandName, + stop_id: stopId ?? "", + customer_name: customer.name ?? "", + customer_email: customer.email ?? "", + shipping_state: shippingAddress?.state ?? "", + shipping_postal_code: shippingAddress?.postal_code ?? "", + shipping_city: shippingAddress?.city ?? "", + item_count: String(items.reduce((s, i) => s + i.quantity, 0)), + source: "storefront_express", + }, + }); + + if (!intent.client_secret) { + return { success: false, error: "Stripe did not return a client secret." }; + } + + return { + success: true, + clientSecret: intent.client_secret, + paymentIntentId: intent.id, + amount: intent.amount, + }; + } catch (err) { + const message = err instanceof Error ? err.message : "Failed to create payment intent."; + return { success: false, error: message }; + } +} diff --git a/src/app/checkout/CheckoutClient.tsx b/src/app/checkout/CheckoutClient.tsx index 41c87c0..d126fae 100644 --- a/src/app/checkout/CheckoutClient.tsx +++ b/src/app/checkout/CheckoutClient.tsx @@ -4,25 +4,36 @@ import { useState, useEffect, useRef, useCallback } from "react"; import { useRouter } from "next/navigation"; import Link from "next/link"; import { useCart } from "@/context/CartContext"; +import type { StopInfo } from "@/context/CartContext"; import { createRetailStripeCheckoutSession } from "@/actions/billing/retail-checkout"; import StorefrontHeader from "@/components/storefront/StorefrontHeader"; import StorefrontFooter from "@/components/storefront/StorefrontFooter"; - -type Stop = { - id: string; - city: string; - state: string; - date: string; - location: string; - brand_id: string; -}; +import StripeExpressCheckout, { + type CheckoutItem as ExpressItem, +} from "@/components/storefront/StripeExpressCheckout"; export default function CheckoutClient() { - const { cart, subtotal, selectedStop, setSelectedStop, clearCart, cartBrandId } = useCart(); + const cartContext = useCart(); + const cart = cartContext.cart; + const subtotal = cartContext.subtotal; + const selectedStop: StopInfo | null = cartContext.selectedStop; + const setSelectedStop = cartContext.setSelectedStop; + const cartBrandId = cartContext.cartBrandId; const router = useRouter(); - const [stops, setStops] = useState([]); - const [loading, setLoading] = useState(false); - const [error, setError] = useState(null); + const [stops, setStops] = useState([]); + + // Controlled form state — passed down to StripeExpressCheckout so the + // Express + Card paths know who the buyer is and where to ship / pick up. + const [customerName, setCustomerName] = useState(""); + const [customerEmail, setCustomerEmail] = useState(""); + const [customerPhone, setCustomerPhone] = useState(""); + const [shippingState, setShippingState] = useState(""); + const [shippingPostal, setShippingPostal] = useState(""); + const [shippingCity, setShippingCity] = useState(""); + + // Hosted-checkout (legacy Stripe Checkout) fallback state + const [hostedLoading, setHostedLoading] = useState(false); + const [hostedError, setHostedError] = useState(null); // Stable idempotency key per checkout session — survives retries const idempotencyKeyRef = useRef(null); @@ -33,7 +44,7 @@ export default function CheckoutClient() { useEffect(() => { if (!cartBrandId) return; fetch( - `${process.env.NEXT_PUBLIC_SUPABASE_URL}/rest/v1/stops?active=eq.true&brand_id=eq.${cartBrandId}&select=id,city,state,date,location,brand_id&order=date`, + `${process.env.NEXT_PUBLIC_SUPABASE_URL}/rest/v1/stops?active=eq.true&brand_id=eq.${cartBrandId}&select=id,city,state,date,time,location,brand_id&order=date`, { headers: { apikey: process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!, @@ -47,45 +58,37 @@ export default function CheckoutClient() { const hasPickupItems = cart.some((i) => i.fulfillment === "pickup"); const hasShedPickupItems = cart.some((i) => i.fulfillment === "pickup" && i.pickup_type === "shed"); - const hasStopPickupItems = cart.some((i) => i.fulfillment === "pickup" && i.pickup_type !== "shed"); + const hasStopPickupItems = cart.some( + (i) => i.fulfillment === "pickup" && i.pickup_type !== "shed" + ); const hasShipItems = cart.some((i) => i.fulfillment === "ship"); - const handleSubmit = useCallback(async (e: React.FormEvent) => { - e.preventDefault(); + /** + * Legacy "Use secure hosted checkout" fallback. Creates a Stripe + * Checkout Session and redirects — this is the path used when the + * embedded Stripe Elements can't load (no publishable key, browser + * incompatibility, etc.) or when the buyer prefers Stripe's hosted page. + */ + const handleHostedCheckout = useCallback(async () => { if (cart.length === 0) return; - // Guard: if selected stop brand mismatches cart brand, block checkout if (selectedStop?.brand_id && cartBrandId && selectedStop.brand_id !== cartBrandId) { setSelectedStop(null); router.replace("/cart"); return; } - setLoading(true); - setError(null); - - const formData = new FormData(e.currentTarget); - const customerName = formData.get("customer_name") as string; - const customerEmail = formData.get("customer_email") as string; - const customerPhone = formData.get("customer_phone") as string; - const stopId = (selectedStop?.id ?? formData.get("stop_id") as string | null) as string | null; - - // Shipping address for tax calculation (ship items only) - let shippingAddress; - if (hasShipItems) { - const shippingPostal = formData.get("shipping_postal_code") as string; - const shippingState = formData.get("shipping_state") as string; - const shippingCity = formData.get("shipping_city") as string; - if (shippingState) { - shippingAddress = { state: shippingState, postal_code: shippingPostal, city: shippingCity }; - } - } - - if (hasStopPickupItems && !stopId) { - setError("Please select a pickup location."); - setLoading(false); + if (!customerEmail.trim()) { + setHostedError("Please enter your email before continuing to checkout."); return; } + if (hasStopPickupItems && !selectedStop) { + setHostedError("Please select a pickup location."); + return; + } + + setHostedLoading(true); + setHostedError(null); const items = cart.map((item) => ({ id: item.id, @@ -95,36 +98,61 @@ export default function CheckoutClient() { fulfillment: item.fulfillment ?? "pickup", })); - // Build return URLs for Stripe - const siteUrl = typeof window !== "undefined" ? window.location.origin : "https://route-commerce-platform.vercel.app"; + const siteUrl = + typeof window !== "undefined" ? window.location.origin : "https://route-commerce-platform.vercel.app"; const successUrl = `${siteUrl}/checkout/success?session_id={CHECKOUT_SESSION_ID}`; const cancelUrl = `${siteUrl}/checkout?status=cancelled`; - // Store customer info for order creation on success page if (typeof sessionStorage !== "undefined") { - sessionStorage.setItem("pending_checkout", JSON.stringify({ - customerName, - customerEmail, - customerPhone, - stopId, - items: items.map((item) => ({ ...item, fulfillment: item.fulfillment as "pickup" | "ship" })), - cartBrandId, - shippingAddress, - idempotencyKey: idempotencyKeyRef.current, - })); + sessionStorage.setItem( + "pending_checkout", + JSON.stringify({ + customerName, + customerEmail, + customerPhone, + stopId: selectedStop?.id ?? null, + items: items.map((item) => ({ + ...item, + fulfillment: item.fulfillment as "pickup" | "ship", + })), + cartBrandId, + shippingAddress: hasShipItems + ? { state: shippingState, postal_code: shippingPostal, city: shippingCity } + : undefined, + idempotencyKey: idempotencyKeyRef.current, + }) + ); } - // Create Stripe Checkout session first - const stripeResult = await createRetailStripeCheckoutSession(items, "", cartBrandId ?? "unknown", successUrl, cancelUrl); + const stripeResult = await createRetailStripeCheckoutSession( + items, + "", + cartBrandId ?? "unknown", + successUrl, + cancelUrl + ); if (!stripeResult.success || !stripeResult.url) { - setError(stripeResult.error ?? "Failed to initiate payment. Please try again."); - setLoading(false); + setHostedError(stripeResult.error ?? "Failed to initiate payment. Please try again."); + setHostedLoading(false); return; } - // Redirect to Stripe Checkout window.location.href = stripeResult.url; - }, [cart, selectedStop, cartBrandId, hasShipItems, hasStopPickupItems, router, setSelectedStop]); + }, [ + cart, + customerName, + customerEmail, + customerPhone, + selectedStop, + shippingState, + shippingPostal, + shippingCity, + cartBrandId, + hasShipItems, + hasStopPickupItems, + router, + setSelectedStop, + ]); if (cart.length === 0) { return ( @@ -132,9 +160,7 @@ export default function CheckoutClient() {
-

- Your cart is empty -

+

Your cart is empty

({ + id: item.id, + name: item.name, + price: item.price, + quantity: item.quantity, + fulfillment: (item.fulfillment ?? "pickup") as "pickup" | "ship", + })); + return (
+ {/* LEFT — Form + Embedded Stripe Elements */}
-

- Checkout -

- +

Checkout

- Complete your order details. + Tap to pay with Apple Pay, Google Pay, or card. Your details stay secure — we never see them.

- {/* Error alert */} - {error && ( + {/* Error alert (shared across both payment paths) */} + {(hostedError) && (
- {error} + {hostedError}
)} - {/* Loading indicator */} - {loading && ( -
- - )} - -
- {/* Customer Information */} +
+ {/* Customer Information — controlled inputs feed the embedded + Stripe Elements above. The wallet (Apple Pay) will overwrite + name/email at confirm time, but we still collect them here + so the order row has them in case the wallet omits them. */}
- Customer Information + Your details +

+ Used for the order confirmation. Apple Pay / Google Pay will fill these in for you. +

-
-
+
+
setCustomerName(e.target.value)} className="mt-1 w-full rounded-xl border border-slate-300 px-4 py-3 outline-none focus:border-emerald-500 focus:ring-2 focus:ring-emerald-500/20 transition-all" placeholder="Jane Smith" - aria-required="true" />
setCustomerEmail(e.target.value)} className="mt-1 w-full rounded-xl border border-slate-300 px-4 py-3 outline-none focus:border-emerald-500 focus:ring-2 focus:ring-emerald-500/20 transition-all" placeholder="jane@example.com" + aria-required="true" />
@@ -224,6 +260,8 @@ export default function CheckoutClient() { name="customer_phone" type="tel" autoComplete="tel" + value={customerPhone} + onChange={(e) => setCustomerPhone(e.target.value)} className="mt-1 w-full rounded-xl border border-slate-300 px-4 py-3 outline-none focus:border-emerald-500 focus:ring-2 focus:ring-emerald-500/20 transition-all" placeholder="(555) 555-5555" /> @@ -231,7 +269,7 @@ export default function CheckoutClient() {
- {/* Shed Pickup */} + {/* Shed Pickup notice */} {hasShedPickupItems && (
Shed Pickup @@ -278,6 +316,11 @@ export default function CheckoutClient() { id="stop_id" name="stop_id" required + value={(selectedStop as StopInfo | null)?.id ?? ""} + onChange={(e) => { + const found = stops.find((s) => s.id === e.target.value) ?? null; + setSelectedStop(found as StopInfo | null); + }} className="w-full rounded-xl border border-slate-300 px-4 py-3 outline-none focus:border-emerald-500 focus:ring-2 focus:ring-emerald-500/20 transition-all appearance-none bg-white" aria-required="true" > @@ -301,8 +344,8 @@ export default function CheckoutClient() { Enter your shipping details for delivery.

-
-
+
+
@@ -314,80 +357,97 @@ export default function CheckoutClient() { placeholder="123 Main St" />
-
-
- - -
-
- - -
-
- - -
+
+ + setShippingCity(e.target.value)} + className="mt-1 w-full rounded-xl border border-slate-300 px-4 py-3 outline-none focus:border-emerald-500 focus:ring-2 focus:ring-emerald-500/20 transition-all" + /> +
+
+ + setShippingState(e.target.value.toUpperCase())} + className="mt-1 w-full rounded-xl border border-slate-300 px-4 py-3 outline-none focus:border-emerald-500 focus:ring-2 focus:ring-emerald-500/20 transition-all uppercase" + placeholder="NC" + /> +
+
+ + setShippingPostal(e.target.value)} + className="mt-1 w-full rounded-xl border border-slate-300 px-4 py-3 outline-none focus:border-emerald-500 focus:ring-2 focus:ring-emerald-500/20 transition-all" + placeholder="28147" + />
)} - {/* Submit button */} - - + {/* Embedded Stripe Elements (Express + Card) — real checkout */} +
+ void handleHostedCheckout()} + /> +
+ + {/* Hosted-checkout fallback (legacy Stripe Checkout) */} +
+

+ Prefer Stripe’s hosted page? Tap below. +

+ +
+
{/* Order Summary */} @@ -435,4 +503,4 @@ export default function CheckoutClient() {
); -} \ No newline at end of file +} diff --git a/src/app/checkout/success/page.tsx b/src/app/checkout/success/page.tsx index 88dbb16..8f3ec56 100644 --- a/src/app/checkout/success/page.tsx +++ b/src/app/checkout/success/page.tsx @@ -47,12 +47,16 @@ function formatStopDate(dateStr: string): string { function SuccessContent() { const searchParams = useSearchParams(); const sessionId = searchParams.get("session_id"); + // Embedded Stripe Elements path — we navigated here programmatically + // with ?payment_intent=pi_... after `stripe.confirmPayment` resolved. + const paymentIntentId = searchParams.get("payment_intent"); + const redirectStatus = searchParams.get("redirect_status"); const orderIdParam = searchParams.get("order_id"); // Direct access with order_id — load from sessionStorage in lazy initializer. // searchParams values are stable, and sessionStorage is client-only, so this // is safe in a client component. const [order, setOrder] = useState(() => { - if (!orderIdParam || sessionId) return null; + if (!orderIdParam || sessionId || paymentIntentId) return null; if (typeof window === "undefined") return null; try { const stored = sessionStorage.getItem(`order_${orderIdParam}`); @@ -62,14 +66,20 @@ function SuccessContent() { return null; } }); - const [creating, setCreating] = useState(!!sessionId); + const [creating, setCreating] = useState(Boolean(sessionId || (paymentIntentId && redirectStatus === "succeeded"))); const [error, setError] = useState(null); - // Stripe redirected back — create order from pending checkout data. - // Wrapped in an async IIFE so all setState calls happen inside a callback, - // not in the synchronous effect body (satisfies set-state-in-effect rule). + // Create the order from the pending-checkout payload, regardless of + // whether the user paid via hosted Stripe Checkout (?session_id=...) or + // embedded Stripe Elements (?payment_intent=...). Both paths store the + // same payload in `pending_checkout` before payment is initiated. useEffect(() => { - if (!sessionId) return; + if (!sessionId && !paymentIntentId) return; + if (paymentIntentId && redirectStatus && redirectStatus !== "succeeded") { + setError("Payment was not completed. Please try again."); + setCreating(false); + return; + } (async () => { const pendingStr = sessionStorage.getItem("pending_checkout"); @@ -88,6 +98,7 @@ function SuccessContent() { cartBrandId?: string; shippingAddress?: { state: string; postal_code: string; city?: string }; idempotencyKey: string; + paymentIntentId?: string; }; try { pending = JSON.parse(pendingStr); @@ -123,7 +134,7 @@ function SuccessContent() { setCreating(false); } })(); - }, [sessionId]); + }, [sessionId, paymentIntentId, redirectStatus]); if (!order || !orderIdParam) { return ( diff --git a/src/components/storefront/QuickCartSheet.tsx b/src/components/storefront/QuickCartSheet.tsx index 5ea67e2..7fd9960 100644 --- a/src/components/storefront/QuickCartSheet.tsx +++ b/src/components/storefront/QuickCartSheet.tsx @@ -312,7 +312,10 @@ function SheetContent({ {/* Footer — sticky CTA + express */}
- {/* Express checkout row */} + {/* Express checkout row — the buttons are visual shortcuts. + /checkout auto-renders Stripe's for + Apple Pay / Google Pay / Link + for card via + StripeExpressCheckout. The `?express=` query is decorative. */}

Express checkout diff --git a/src/components/storefront/StripeExpressCheckout.tsx b/src/components/storefront/StripeExpressCheckout.tsx new file mode 100644 index 0000000..497392a --- /dev/null +++ b/src/components/storefront/StripeExpressCheckout.tsx @@ -0,0 +1,504 @@ +"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 && ( + + )} +
+ ); +} diff --git a/src/context/CartContext.tsx b/src/context/CartContext.tsx index f0552c3..d882b51 100644 --- a/src/context/CartContext.tsx +++ b/src/context/CartContext.tsx @@ -20,6 +20,8 @@ type StopInfo = { brand_id: string; }; +export type { StopInfo }; + type CartContextType = { cart: CartItem[]; subtotal: number; @@ -334,7 +336,7 @@ export function CartProvider({ children }: { children: ReactNode }) { ); } -export function useCart() { +export function useCart(): CartContextType { const context = useContext(CartContext); if (!context) throw new Error("useCart must be used inside CartProvider"); return context; diff --git a/src/lib/stripe-client.ts b/src/lib/stripe-client.ts new file mode 100644 index 0000000..7086dba --- /dev/null +++ b/src/lib/stripe-client.ts @@ -0,0 +1,35 @@ +/** + * Browser-only Stripe.js loader. + * + * Reads `NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY` and returns a cached + * `loadStripe` promise. Never call this from a server component — it + * pulls in `@stripe/stripe-js` which depends on `window`. + * + * The published key is safe to ship to the browser; the secret key + * never leaves the server (see `src/actions/billing/retail-checkout.ts`). + */ +"use client"; + +import { loadStripe, type Stripe } from "@stripe/stripe-js"; + +let stripePromise: Promise | null = null; + +/** + * Returns the cached Stripe.js promise, creating it on the first call. + * Returns `null` if the publishable key is not configured — callers + * should fall back to the hosted Stripe Checkout flow in that case. + */ +export function getStripe(): Promise | null { + if (typeof window === "undefined") return null; + const key = process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY; + if (!key) return null; + if (!stripePromise) { + stripePromise = loadStripe(key); + } + return stripePromise; +} + +/** Synchronous check — useful for deciding whether to render Stripe Elements. */ +export function hasStripePublishableKey(): boolean { + return Boolean(process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY); +}