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:
2026-06-04 18:38:21 +00:00
parent b2aa53f274
commit 015b1cf7b5
8 changed files with 919 additions and 171 deletions
@@ -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<CreatePaymentIntentResult> {
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 };
}
}