Files
route-commerce/src/app/checkout/success/page.tsx
T
tyler 015b1cf7b5 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.
2026-06-04 18:38:21 +00:00

325 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { useEffect, Suspense, useState } from "react";
import { useSearchParams } from "next/navigation";
import Link from "next/link";
import { createOrder } from "@/actions/checkout";
type OrderItem = {
product_id: string;
product_name: string;
quantity: number;
price: number;
fulfillment: string;
};
type StoredOrder = {
id: string;
customer_name: string;
customer_email: string;
subtotal: number;
status: string;
stop_city: string;
stop_state: string;
stop_date: string;
stop_time: string | null;
stop_location: string;
items: OrderItem[];
};
function shortId(id: string) {
return id.slice(0, 8).toUpperCase();
}
function formatStopDate(dateStr: string): string {
try {
return new Date(dateStr + "T00:00:00").toLocaleDateString("en-US", {
weekday: "long",
month: "long",
day: "numeric",
year: "numeric",
});
} catch {
return dateStr;
}
}
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<StoredOrder | null>(() => {
if (!orderIdParam || sessionId || paymentIntentId) return null;
if (typeof window === "undefined") return null;
try {
const stored = sessionStorage.getItem(`order_${orderIdParam}`);
if (!stored) return null;
return JSON.parse(stored) as StoredOrder;
} catch {
return null;
}
});
const [creating, setCreating] = useState(Boolean(sessionId || (paymentIntentId && redirectStatus === "succeeded")));
const [error, setError] = useState<string | null>(null);
// 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 && !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");
if (!pendingStr) {
setError("No pending order found. Please start checkout again.");
setCreating(false);
return;
}
let pending: {
customerName: string;
customerEmail: string;
customerPhone: string;
stopId: string | null;
items: Array<{ id: string; name: string; price: number; quantity: number; fulfillment: "pickup" | "ship" }>;
cartBrandId?: string;
shippingAddress?: { state: string; postal_code: string; city?: string };
idempotencyKey: string;
paymentIntentId?: string;
};
try {
pending = JSON.parse(pendingStr);
} catch {
setError("Failed to read checkout data. Please start again.");
setCreating(false);
return;
}
try {
const result = await createOrder(
pending.idempotencyKey,
pending.customerName,
pending.customerEmail,
pending.customerPhone,
pending.stopId,
pending.items,
pending.cartBrandId,
pending.shippingAddress
);
if (!result.success) {
setError(result.error ?? "Failed to create order");
setCreating(false);
return;
}
sessionStorage.setItem(`order_${result.order.id}`, JSON.stringify(result.order));
sessionStorage.removeItem("pending_checkout");
sessionStorage.removeItem("cart");
setOrder(result.order);
setCreating(false);
} catch {
setError("Failed to create order. Please contact support.");
setCreating(false);
}
})();
}, [sessionId, paymentIntentId, redirectStatus]);
if (!order || !orderIdParam) {
return (
<div className="rounded-2xl bg-white p-8 shadow-sm ring-1 ring-slate-200 text-center">
{creating ? (
<>
<div className="h-12 w-12 mx-auto rounded-full border-4 border-slate-200 border-t-slate-900 animate-spin" />
<p className="mt-4 text-slate-600">Confirming your payment and creating order...</p>
</>
) : error ? (
<>
<p className="text-red-600 font-medium">{error}</p>
<Link href="/" className="mt-4 inline-block text-stone-600 hover:text-slate-900">
Back to Storefront
</Link>
</>
) : (
<>
<p className="text-slate-500">Order not found</p>
<Link href="/" className="mt-4 inline-block text-stone-600 hover:text-slate-900">
Back to Storefront
</Link>
</>
)}
</div>
);
}
const pickupItems = order.items.filter((i) => i.fulfillment === "pickup");
const shipItems = order.items.filter((i) => i.fulfillment === "ship");
const hasPickup = pickupItems.length > 0;
const hasShip = shipItems.length > 0;
const isMixed = hasPickup && hasShip;
const stopLabel = [order.stop_city, order.stop_state].filter(Boolean).join(", ");
return (
<div className="mx-auto max-w-2xl">
{/* Header */}
<div className="rounded-2xl bg-white p-8 shadow-sm ring-1 ring-stone-200 text-center">
<svg className="mx-auto h-12 w-12 text-green-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<h1 className="mt-6 text-3xl font-bold text-stone-900">
{hasPickup ? "Your pickup order has been placed!" : "Your order has been placed!"}
</h1>
<p className="mt-2 text-base text-stone-500">
Order #{shortId(order.id)} · {order.customer_name}
</p>
</div>
{/* Pickup details */}
{hasPickup && (
<div className="mt-4 rounded-2xl bg-white p-6 shadow-sm ring-1 ring-stone-200">
<h2 className="text-lg font-bold text-stone-900 mb-1">
<svg className="inline h-5 w-5 mr-1.5 text-stone-600" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}><path strokeLinecap="round" strokeLinejoin="round" d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4" /></svg>
Pickup Details
</h2>
{stopLabel && (
<p className="font-semibold text-stone-800">{stopLabel}</p>
)}
{order.stop_date && (
<p className="mt-1 text-stone-600 text-stone-500">
{formatStopDate(order.stop_date)}
{order.stop_time ? ` · ${order.stop_time}` : ""}
</p>
)}
{order.stop_location && (
<p className="mt-1 text-sm text-stone-500">{order.stop_location}</p>
)}
{isMixed && (
<p className="mt-3 text-sm text-stone-500 italic">
See pickup items in the list below.
</p>
)}
{!isMixed && (
<p className="mt-3 text-sm text-stone-500">
Bring your order number or name when you pick up.
</p>
)}
</div>
)}
{/* Shipping details */}
{hasShip && (
<div className="mt-4 rounded-2xl bg-white p-6 shadow-sm ring-1 ring-stone-200">
<h2 className="text-lg font-bold text-stone-900 mb-1">
<svg className="inline h-5 w-5 mr-1.5 text-stone-600" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}><path strokeLinecap="round" strokeLinejoin="round" d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" /></svg>
Shipping
</h2>
<p className="text-sm text-stone-500">
Tracking information will be sent when your order ships.
</p>
</div>
)}
{/* Order items */}
<div className="mt-4 rounded-2xl bg-white p-6 shadow-sm ring-1 ring-stone-200">
<h2 className="text-lg font-bold text-stone-900 mb-4">Order Summary</h2>
{isMixed && hasPickup && (
<div className="mb-4">
<p className="text-sm font-semibold text-stone-500 uppercase tracking-wide mb-2">Pickup Items</p>
<ul className="space-y-2">
{pickupItems.map((item, i) => (
<li key={i} className="flex justify-between text-sm">
<span className="text-stone-700">
{item.quantity > 1 && (
<span className="font-semibold">{item.quantity}× </span>
)}
{item.product_name}
</span>
<span className="text-stone-500">
${(item.price * item.quantity).toFixed(2)}
</span>
</li>
))}
</ul>
</div>
)}
{isMixed && hasShip && (
<div className="mb-4 border-t border-stone-200 pt-4">
<p className="text-sm font-semibold text-stone-500 uppercase tracking-wide mb-2">Shipping Items</p>
<ul className="space-y-2">
{shipItems.map((item, i) => (
<li key={i} className="flex justify-between text-sm">
<span className="text-stone-700">
{item.quantity > 1 && (
<span className="font-semibold">{item.quantity}× </span>
)}
{item.product_name}
</span>
<span className="text-stone-500">
${(item.price * item.quantity).toFixed(2)}
</span>
</li>
))}
</ul>
</div>
)}
{!isMixed && (
<ul className="space-y-2">
{order.items.map((item, i) => (
<li key={i} className="flex justify-between text-sm">
<span className="text-stone-700">
{item.quantity > 1 && (
<span className="font-semibold">{item.quantity}× </span>
)}
{item.product_name}
</span>
<span className="text-stone-500">
${(item.price * item.quantity).toFixed(2)}
</span>
</li>
))}
</ul>
)}
<div className="mt-4 border-t border-slate-200 border-stone-200 pt-4 flex justify-between font-bold text-stone-900">
<span>Total</span>
<span>${order.subtotal.toFixed(2)}</span>
</div>
</div>
{/* Back link */}
<div className="mt-6 text-center">
<Link
href="/"
className="rounded-xl bg-slate-900 bg-stone-800 px-6 py-3 font-medium text-white text-stone-800 hover:bg-slate-800 dark:hover:bg-slate-700"
>
Back to Storefront
</Link>
</div>
</div>
);
}
export default function CheckoutSuccessPage() {
return (
<main className="min-h-screen bg-stone-50 px-6 py-12">
<Suspense fallback={<div className="text-center text-stone-500">Loading...</div>}>
<SuccessContent />
</Suspense>
</main>
);
}