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.
This commit is contained in:
+230
-162
@@ -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<Stop[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [stops, setStops] = useState<StopInfo[]>([]);
|
||||
|
||||
// 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<string | null>(null);
|
||||
|
||||
// Stable idempotency key per checkout session — survives retries
|
||||
const idempotencyKeyRef = useRef<string | null>(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<HTMLFormElement>) => {
|
||||
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() {
|
||||
<StorefrontHeader brandName="Checkout" brandSlug="tuxedo" />
|
||||
<main className="px-6 py-12">
|
||||
<div className="mx-auto max-w-4xl">
|
||||
<h1 className="text-3xl font-bold text-stone-900">
|
||||
Your cart is empty
|
||||
</h1>
|
||||
<h1 className="text-3xl font-bold text-stone-900">Your cart is empty</h1>
|
||||
<Link
|
||||
href="/"
|
||||
className="mt-4 inline-block text-stone-600 hover:text-stone-900"
|
||||
@@ -147,71 +173,81 @@ export default function CheckoutClient() {
|
||||
);
|
||||
}
|
||||
|
||||
// Map cart → StripeExpressCheckout item shape
|
||||
const expressItems: ExpressItem[] = cart.map((item) => ({
|
||||
id: item.id,
|
||||
name: item.name,
|
||||
price: item.price,
|
||||
quantity: item.quantity,
|
||||
fulfillment: (item.fulfillment ?? "pickup") as "pickup" | "ship",
|
||||
}));
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-stone-50 via-white to-emerald-50/30">
|
||||
<StorefrontHeader brandName="Checkout" brandSlug="tuxedo" />
|
||||
<main className="px-6 py-12">
|
||||
<div className="mx-auto grid max-w-6xl gap-8 md:grid-cols-[1fr_400px]">
|
||||
{/* LEFT — Form + Embedded Stripe Elements */}
|
||||
<div>
|
||||
<h1 className="text-4xl font-bold text-slate-900">
|
||||
Checkout
|
||||
</h1>
|
||||
|
||||
<h1 className="text-4xl font-bold text-slate-900">Checkout</h1>
|
||||
<p className="mt-3 text-slate-600">
|
||||
Complete your order details.
|
||||
Tap to pay with Apple Pay, Google Pay, or card. Your details stay secure — we never see them.
|
||||
</p>
|
||||
|
||||
{/* Error alert */}
|
||||
{error && (
|
||||
{/* Error alert (shared across both payment paths) */}
|
||||
{(hostedError) && (
|
||||
<div className="mt-6 rounded-xl bg-red-50 border border-red-200 p-4 text-red-700 text-sm shadow-sm" role="alert">
|
||||
<div className="flex items-start gap-3">
|
||||
<svg className="h-5 w-5 text-red-500 mt-0.5 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<span>{error}</span>
|
||||
<span>{hostedError}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Loading indicator */}
|
||||
{loading && (
|
||||
<div className="mt-6 flex items-center gap-3 rounded-xl bg-emerald-50 border border-emerald-200 p-4" role="status" aria-live="polite">
|
||||
<div className="h-5 w-5 animate-spin rounded-full border-2 border-emerald-300 border-t-emerald-600" aria-hidden="true" />
|
||||
<span className="text-sm text-emerald-700">Placing your order...</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit} className="mt-8 space-y-6">
|
||||
{/* Customer Information */}
|
||||
<div className="mt-8 space-y-6">
|
||||
{/* 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. */}
|
||||
<fieldset className="rounded-2xl bg-white p-6 shadow-sm ring-1 ring-slate-200 border border-slate-100">
|
||||
<legend className="text-xl font-bold text-slate-900">Customer Information</legend>
|
||||
<legend className="text-xl font-bold text-slate-900">Your details</legend>
|
||||
<p className="mt-1 text-xs text-slate-500">
|
||||
Used for the order confirmation. Apple Pay / Google Pay will fill these in for you.
|
||||
</p>
|
||||
|
||||
<div className="mt-4 space-y-4">
|
||||
<div>
|
||||
<div className="mt-4 grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div className="sm:col-span-2">
|
||||
<label htmlFor="customer_name" className="block text-sm font-medium text-slate-700">
|
||||
Full Name <span className="text-red-500" aria-hidden="true">*</span>
|
||||
Full Name
|
||||
</label>
|
||||
<input
|
||||
id="customer_name"
|
||||
name="customer_name"
|
||||
required
|
||||
autoComplete="name"
|
||||
value={customerName}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="customer_email" className="block text-sm font-medium text-slate-700">
|
||||
Email <span className="text-slate-400 text-xs">(optional)</span>
|
||||
Email <span className="text-red-500" aria-hidden="true">*</span>
|
||||
</label>
|
||||
<input
|
||||
id="customer_email"
|
||||
name="customer_email"
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
required
|
||||
value={customerEmail}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -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() {
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
{/* Shed Pickup */}
|
||||
{/* Shed Pickup notice */}
|
||||
{hasShedPickupItems && (
|
||||
<fieldset className="rounded-2xl bg-white p-6 shadow-sm ring-1 ring-emerald-200 border border-emerald-100">
|
||||
<legend className="text-xl font-bold text-slate-900">Shed Pickup</legend>
|
||||
@@ -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.
|
||||
</p>
|
||||
|
||||
<div className="mt-4 space-y-4">
|
||||
<div>
|
||||
<div className="mt-4 grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||
<div className="sm:col-span-3">
|
||||
<label htmlFor="shipping_address" className="block text-sm font-medium text-slate-700">
|
||||
Street Address
|
||||
</label>
|
||||
@@ -314,80 +357,97 @@ export default function CheckoutClient() {
|
||||
placeholder="123 Main St"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||
<div>
|
||||
<label htmlFor="shipping_city" className="block text-sm font-medium text-slate-700">
|
||||
City
|
||||
</label>
|
||||
<input
|
||||
id="shipping_city"
|
||||
name="shipping_city"
|
||||
autoComplete="address-level2"
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="shipping_state" className="block text-sm font-medium text-slate-700">
|
||||
State
|
||||
</label>
|
||||
<input
|
||||
id="shipping_state"
|
||||
name="shipping_state"
|
||||
autoComplete="address-level1"
|
||||
maxLength={2}
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="shipping_postal_code" className="block text-sm font-medium text-slate-700">
|
||||
ZIP Code
|
||||
</label>
|
||||
<input
|
||||
id="shipping_postal_code"
|
||||
name="shipping_postal_code"
|
||||
autoComplete="postal-code"
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="shipping_city" className="block text-sm font-medium text-slate-700">
|
||||
City
|
||||
</label>
|
||||
<input
|
||||
id="shipping_city"
|
||||
name="shipping_city"
|
||||
autoComplete="address-level2"
|
||||
value={shippingCity}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="shipping_state" className="block text-sm font-medium text-slate-700">
|
||||
State
|
||||
</label>
|
||||
<input
|
||||
id="shipping_state"
|
||||
name="shipping_state"
|
||||
autoComplete="address-level1"
|
||||
maxLength={2}
|
||||
value={shippingState}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="shipping_postal_code" className="block text-sm font-medium text-slate-700">
|
||||
ZIP Code
|
||||
</label>
|
||||
<input
|
||||
id="shipping_postal_code"
|
||||
name="shipping_postal_code"
|
||||
autoComplete="postal-code"
|
||||
value={shippingPostal}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</fieldset>
|
||||
)}
|
||||
|
||||
{/* Submit button */}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full rounded-xl bg-gradient-to-r from-emerald-600 to-emerald-500 hover:from-emerald-500 hover:to-emerald-400 px-6 py-4 text-lg font-semibold text-white disabled:opacity-50 disabled:cursor-not-allowed transition-all shadow-lg shadow-emerald-500/25 hover:shadow-emerald-500/30 hover:-translate-y-0.5 flex items-center justify-center gap-2"
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
<svg className="h-5 w-5 animate-spin" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
||||
</svg>
|
||||
Redirecting to payment...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
|
||||
</svg>
|
||||
Pay Now — ${subtotal.toFixed(2)}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
{/* Embedded Stripe Elements (Express + Card) — real checkout */}
|
||||
<div data-testid="stripe-express-checkout">
|
||||
<StripeExpressCheckout
|
||||
items={expressItems}
|
||||
brandId={cartBrandId}
|
||||
customerName={customerName}
|
||||
customerEmail={customerEmail}
|
||||
customerPhone={customerPhone}
|
||||
selectedStop={selectedStop as StopInfo | null}
|
||||
shippingState={shippingState}
|
||||
shippingPostal={shippingPostal}
|
||||
shippingCity={shippingCity}
|
||||
checkoutSessionKey={idempotencyKeyRef.current ?? undefined}
|
||||
onHostedCheckout={() => void handleHostedCheckout()}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Hosted-checkout fallback (legacy Stripe Checkout) */}
|
||||
<div className="rounded-2xl border border-dashed border-slate-300 bg-slate-50/60 p-4">
|
||||
<p className="text-xs text-slate-500 mb-2 text-center">
|
||||
Prefer Stripe’s hosted page? Tap below.
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleHostedCheckout()}
|
||||
disabled={hostedLoading}
|
||||
className="w-full rounded-xl border border-slate-300 bg-white px-6 py-3 text-sm font-semibold text-slate-800 hover:bg-slate-50 disabled:opacity-50 disabled:cursor-not-allowed transition-all flex items-center justify-center gap-2"
|
||||
>
|
||||
{hostedLoading ? (
|
||||
<>
|
||||
<span className="h-4 w-4 rounded-full border-2 border-slate-300 border-t-slate-700 animate-spin" />
|
||||
Redirecting to Stripe…
|
||||
</>
|
||||
) : (
|
||||
<>Use secure hosted checkout →</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Order Summary */}
|
||||
<aside aria-label="Order summary">
|
||||
<div className="rounded-2xl bg-white p-6 shadow-sm ring-1 ring-slate-200 border border-slate-100 sticky top-6">
|
||||
<h2 className="text-xl font-bold text-slate-900">
|
||||
Order Summary
|
||||
</h2>
|
||||
<h2 className="text-xl font-bold text-slate-900">Order Summary</h2>
|
||||
|
||||
<div className="mt-4 space-y-3">
|
||||
{cart.map((item) => (
|
||||
@@ -420,12 +480,20 @@ export default function CheckoutClient() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Secure payment badge */}
|
||||
<div className="mt-4 flex items-center justify-center gap-2 text-xs text-slate-500">
|
||||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
|
||||
</svg>
|
||||
<span>Secured by Stripe</span>
|
||||
{/* Trust badges */}
|
||||
<div className="mt-4 space-y-1.5">
|
||||
<div className="flex items-center justify-center gap-2 text-xs text-slate-500">
|
||||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
|
||||
</svg>
|
||||
<span>Secured by Stripe</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-center gap-2 text-xs text-slate-500">
|
||||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
|
||||
</svg>
|
||||
<span>100% Sweetness Guarantee</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
@@ -435,4 +503,4 @@ export default function CheckoutClient() {
|
||||
<StorefrontFooter brandName="Tuxedo Corn" brandSlug="tuxedo" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<StoredOrder | null>(() => {
|
||||
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<string | null>(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 (
|
||||
|
||||
Reference in New Issue
Block a user