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:
@@ -19,6 +19,8 @@
|
|||||||
"@google/generative-ai": "^0.24.1",
|
"@google/generative-ai": "^0.24.1",
|
||||||
"@gsap/react": "^2.1.2",
|
"@gsap/react": "^2.1.2",
|
||||||
"@sentry/nextjs": "^10.55.0",
|
"@sentry/nextjs": "^10.55.0",
|
||||||
|
"@stripe/react-stripe-js": "^3.10.0",
|
||||||
|
"@stripe/stripe-js": "^5.10.0",
|
||||||
"@supabase/ssr": "^0.10.2",
|
"@supabase/ssr": "^0.10.2",
|
||||||
"@supabase/supabase-js": "^2.105.3",
|
"@supabase/supabase-js": "^2.105.3",
|
||||||
"@upstash/ratelimit": "^2.0.8",
|
"@upstash/ratelimit": "^2.0.8",
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
}
|
||||||
|
}
|
||||||
+229
-161
@@ -4,25 +4,36 @@ import { useState, useEffect, useRef, useCallback } from "react";
|
|||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useCart } from "@/context/CartContext";
|
import { useCart } from "@/context/CartContext";
|
||||||
|
import type { StopInfo } from "@/context/CartContext";
|
||||||
import { createRetailStripeCheckoutSession } from "@/actions/billing/retail-checkout";
|
import { createRetailStripeCheckoutSession } from "@/actions/billing/retail-checkout";
|
||||||
import StorefrontHeader from "@/components/storefront/StorefrontHeader";
|
import StorefrontHeader from "@/components/storefront/StorefrontHeader";
|
||||||
import StorefrontFooter from "@/components/storefront/StorefrontFooter";
|
import StorefrontFooter from "@/components/storefront/StorefrontFooter";
|
||||||
|
import StripeExpressCheckout, {
|
||||||
type Stop = {
|
type CheckoutItem as ExpressItem,
|
||||||
id: string;
|
} from "@/components/storefront/StripeExpressCheckout";
|
||||||
city: string;
|
|
||||||
state: string;
|
|
||||||
date: string;
|
|
||||||
location: string;
|
|
||||||
brand_id: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function CheckoutClient() {
|
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 router = useRouter();
|
||||||
const [stops, setStops] = useState<Stop[]>([]);
|
const [stops, setStops] = useState<StopInfo[]>([]);
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
// 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
|
// Stable idempotency key per checkout session — survives retries
|
||||||
const idempotencyKeyRef = useRef<string | null>(null);
|
const idempotencyKeyRef = useRef<string | null>(null);
|
||||||
@@ -33,7 +44,7 @@ export default function CheckoutClient() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!cartBrandId) return;
|
if (!cartBrandId) return;
|
||||||
fetch(
|
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: {
|
headers: {
|
||||||
apikey: process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
|
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 hasPickupItems = cart.some((i) => i.fulfillment === "pickup");
|
||||||
const hasShedPickupItems = cart.some((i) => i.fulfillment === "pickup" && i.pickup_type === "shed");
|
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 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;
|
if (cart.length === 0) return;
|
||||||
|
|
||||||
// Guard: if selected stop brand mismatches cart brand, block checkout
|
|
||||||
if (selectedStop?.brand_id && cartBrandId && selectedStop.brand_id !== cartBrandId) {
|
if (selectedStop?.brand_id && cartBrandId && selectedStop.brand_id !== cartBrandId) {
|
||||||
setSelectedStop(null);
|
setSelectedStop(null);
|
||||||
router.replace("/cart");
|
router.replace("/cart");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setLoading(true);
|
if (!customerEmail.trim()) {
|
||||||
setError(null);
|
setHostedError("Please enter your email before continuing to checkout.");
|
||||||
|
|
||||||
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);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (hasStopPickupItems && !selectedStop) {
|
||||||
|
setHostedError("Please select a pickup location.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setHostedLoading(true);
|
||||||
|
setHostedError(null);
|
||||||
|
|
||||||
const items = cart.map((item) => ({
|
const items = cart.map((item) => ({
|
||||||
id: item.id,
|
id: item.id,
|
||||||
@@ -95,36 +98,61 @@ export default function CheckoutClient() {
|
|||||||
fulfillment: item.fulfillment ?? "pickup",
|
fulfillment: item.fulfillment ?? "pickup",
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Build return URLs for Stripe
|
const siteUrl =
|
||||||
const siteUrl = typeof window !== "undefined" ? window.location.origin : "https://route-commerce-platform.vercel.app";
|
typeof window !== "undefined" ? window.location.origin : "https://route-commerce-platform.vercel.app";
|
||||||
const successUrl = `${siteUrl}/checkout/success?session_id={CHECKOUT_SESSION_ID}`;
|
const successUrl = `${siteUrl}/checkout/success?session_id={CHECKOUT_SESSION_ID}`;
|
||||||
const cancelUrl = `${siteUrl}/checkout?status=cancelled`;
|
const cancelUrl = `${siteUrl}/checkout?status=cancelled`;
|
||||||
|
|
||||||
// Store customer info for order creation on success page
|
|
||||||
if (typeof sessionStorage !== "undefined") {
|
if (typeof sessionStorage !== "undefined") {
|
||||||
sessionStorage.setItem("pending_checkout", JSON.stringify({
|
sessionStorage.setItem(
|
||||||
customerName,
|
"pending_checkout",
|
||||||
customerEmail,
|
JSON.stringify({
|
||||||
customerPhone,
|
customerName,
|
||||||
stopId,
|
customerEmail,
|
||||||
items: items.map((item) => ({ ...item, fulfillment: item.fulfillment as "pickup" | "ship" })),
|
customerPhone,
|
||||||
cartBrandId,
|
stopId: selectedStop?.id ?? null,
|
||||||
shippingAddress,
|
items: items.map((item) => ({
|
||||||
idempotencyKey: idempotencyKeyRef.current,
|
...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(
|
||||||
const stripeResult = await createRetailStripeCheckoutSession(items, "", cartBrandId ?? "unknown", successUrl, cancelUrl);
|
items,
|
||||||
|
"",
|
||||||
|
cartBrandId ?? "unknown",
|
||||||
|
successUrl,
|
||||||
|
cancelUrl
|
||||||
|
);
|
||||||
if (!stripeResult.success || !stripeResult.url) {
|
if (!stripeResult.success || !stripeResult.url) {
|
||||||
setError(stripeResult.error ?? "Failed to initiate payment. Please try again.");
|
setHostedError(stripeResult.error ?? "Failed to initiate payment. Please try again.");
|
||||||
setLoading(false);
|
setHostedLoading(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Redirect to Stripe Checkout
|
|
||||||
window.location.href = stripeResult.url;
|
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) {
|
if (cart.length === 0) {
|
||||||
return (
|
return (
|
||||||
@@ -132,9 +160,7 @@ export default function CheckoutClient() {
|
|||||||
<StorefrontHeader brandName="Checkout" brandSlug="tuxedo" />
|
<StorefrontHeader brandName="Checkout" brandSlug="tuxedo" />
|
||||||
<main className="px-6 py-12">
|
<main className="px-6 py-12">
|
||||||
<div className="mx-auto max-w-4xl">
|
<div className="mx-auto max-w-4xl">
|
||||||
<h1 className="text-3xl font-bold text-stone-900">
|
<h1 className="text-3xl font-bold text-stone-900">Your cart is empty</h1>
|
||||||
Your cart is empty
|
|
||||||
</h1>
|
|
||||||
<Link
|
<Link
|
||||||
href="/"
|
href="/"
|
||||||
className="mt-4 inline-block text-stone-600 hover:text-stone-900"
|
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 (
|
return (
|
||||||
<div className="min-h-screen bg-gradient-to-br from-stone-50 via-white to-emerald-50/30">
|
<div className="min-h-screen bg-gradient-to-br from-stone-50 via-white to-emerald-50/30">
|
||||||
<StorefrontHeader brandName="Checkout" brandSlug="tuxedo" />
|
<StorefrontHeader brandName="Checkout" brandSlug="tuxedo" />
|
||||||
<main className="px-6 py-12">
|
<main className="px-6 py-12">
|
||||||
<div className="mx-auto grid max-w-6xl gap-8 md:grid-cols-[1fr_400px]">
|
<div className="mx-auto grid max-w-6xl gap-8 md:grid-cols-[1fr_400px]">
|
||||||
|
{/* LEFT — Form + Embedded Stripe Elements */}
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-4xl font-bold text-slate-900">
|
<h1 className="text-4xl font-bold text-slate-900">Checkout</h1>
|
||||||
Checkout
|
|
||||||
</h1>
|
|
||||||
|
|
||||||
<p className="mt-3 text-slate-600">
|
<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>
|
</p>
|
||||||
|
|
||||||
{/* Error alert */}
|
{/* Error alert (shared across both payment paths) */}
|
||||||
{error && (
|
{(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="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">
|
<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">
|
<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" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
</svg>
|
</svg>
|
||||||
<span>{error}</span>
|
<span>{hostedError}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Loading indicator */}
|
<div className="mt-8 space-y-6">
|
||||||
{loading && (
|
{/* Customer Information — controlled inputs feed the embedded
|
||||||
<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">
|
Stripe Elements above. The wallet (Apple Pay) will overwrite
|
||||||
<div className="h-5 w-5 animate-spin rounded-full border-2 border-emerald-300 border-t-emerald-600" aria-hidden="true" />
|
name/email at confirm time, but we still collect them here
|
||||||
<span className="text-sm text-emerald-700">Placing your order...</span>
|
so the order row has them in case the wallet omits them. */}
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<form onSubmit={handleSubmit} className="mt-8 space-y-6">
|
|
||||||
{/* Customer Information */}
|
|
||||||
<fieldset className="rounded-2xl bg-white p-6 shadow-sm ring-1 ring-slate-200 border border-slate-100">
|
<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 className="mt-4 grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
<div>
|
<div className="sm:col-span-2">
|
||||||
<label htmlFor="customer_name" className="block text-sm font-medium text-slate-700">
|
<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>
|
</label>
|
||||||
<input
|
<input
|
||||||
id="customer_name"
|
id="customer_name"
|
||||||
name="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"
|
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"
|
placeholder="Jane Smith"
|
||||||
aria-required="true"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor="customer_email" className="block text-sm font-medium text-slate-700">
|
<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>
|
</label>
|
||||||
<input
|
<input
|
||||||
id="customer_email"
|
id="customer_email"
|
||||||
name="customer_email"
|
name="customer_email"
|
||||||
type="email"
|
type="email"
|
||||||
autoComplete="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"
|
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"
|
placeholder="jane@example.com"
|
||||||
|
aria-required="true"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -224,6 +260,8 @@ export default function CheckoutClient() {
|
|||||||
name="customer_phone"
|
name="customer_phone"
|
||||||
type="tel"
|
type="tel"
|
||||||
autoComplete="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"
|
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"
|
placeholder="(555) 555-5555"
|
||||||
/>
|
/>
|
||||||
@@ -231,7 +269,7 @@ export default function CheckoutClient() {
|
|||||||
</div>
|
</div>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
|
|
||||||
{/* Shed Pickup */}
|
{/* Shed Pickup notice */}
|
||||||
{hasShedPickupItems && (
|
{hasShedPickupItems && (
|
||||||
<fieldset className="rounded-2xl bg-white p-6 shadow-sm ring-1 ring-emerald-200 border border-emerald-100">
|
<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>
|
<legend className="text-xl font-bold text-slate-900">Shed Pickup</legend>
|
||||||
@@ -278,6 +316,11 @@ export default function CheckoutClient() {
|
|||||||
id="stop_id"
|
id="stop_id"
|
||||||
name="stop_id"
|
name="stop_id"
|
||||||
required
|
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"
|
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"
|
aria-required="true"
|
||||||
>
|
>
|
||||||
@@ -301,8 +344,8 @@ export default function CheckoutClient() {
|
|||||||
Enter your shipping details for delivery.
|
Enter your shipping details for delivery.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="mt-4 space-y-4">
|
<div className="mt-4 grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||||
<div>
|
<div className="sm:col-span-3">
|
||||||
<label htmlFor="shipping_address" className="block text-sm font-medium text-slate-700">
|
<label htmlFor="shipping_address" className="block text-sm font-medium text-slate-700">
|
||||||
Street Address
|
Street Address
|
||||||
</label>
|
</label>
|
||||||
@@ -314,80 +357,97 @@ export default function CheckoutClient() {
|
|||||||
placeholder="123 Main St"
|
placeholder="123 Main St"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
<div>
|
||||||
<div>
|
<label htmlFor="shipping_city" className="block text-sm font-medium text-slate-700">
|
||||||
<label htmlFor="shipping_city" className="block text-sm font-medium text-slate-700">
|
City
|
||||||
City
|
</label>
|
||||||
</label>
|
<input
|
||||||
<input
|
id="shipping_city"
|
||||||
id="shipping_city"
|
name="shipping_city"
|
||||||
name="shipping_city"
|
autoComplete="address-level2"
|
||||||
autoComplete="address-level2"
|
value={shippingCity}
|
||||||
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"
|
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>
|
</div>
|
||||||
<label htmlFor="shipping_state" className="block text-sm font-medium text-slate-700">
|
<div>
|
||||||
State
|
<label htmlFor="shipping_state" className="block text-sm font-medium text-slate-700">
|
||||||
</label>
|
State
|
||||||
<input
|
</label>
|
||||||
id="shipping_state"
|
<input
|
||||||
name="shipping_state"
|
id="shipping_state"
|
||||||
autoComplete="address-level1"
|
name="shipping_state"
|
||||||
maxLength={2}
|
autoComplete="address-level1"
|
||||||
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"
|
maxLength={2}
|
||||||
placeholder="NC"
|
value={shippingState}
|
||||||
/>
|
onChange={(e) => setShippingState(e.target.value.toUpperCase())}
|
||||||
</div>
|
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"
|
||||||
<div>
|
placeholder="NC"
|
||||||
<label htmlFor="shipping_postal_code" className="block text-sm font-medium text-slate-700">
|
/>
|
||||||
ZIP Code
|
</div>
|
||||||
</label>
|
<div>
|
||||||
<input
|
<label htmlFor="shipping_postal_code" className="block text-sm font-medium text-slate-700">
|
||||||
id="shipping_postal_code"
|
ZIP Code
|
||||||
name="shipping_postal_code"
|
</label>
|
||||||
autoComplete="postal-code"
|
<input
|
||||||
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"
|
id="shipping_postal_code"
|
||||||
placeholder="28147"
|
name="shipping_postal_code"
|
||||||
/>
|
autoComplete="postal-code"
|
||||||
</div>
|
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>
|
||||||
</div>
|
</div>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Submit button */}
|
{/* Embedded Stripe Elements (Express + Card) — real checkout */}
|
||||||
<button
|
<div data-testid="stripe-express-checkout">
|
||||||
type="submit"
|
<StripeExpressCheckout
|
||||||
disabled={loading}
|
items={expressItems}
|
||||||
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"
|
brandId={cartBrandId}
|
||||||
>
|
customerName={customerName}
|
||||||
{loading ? (
|
customerEmail={customerEmail}
|
||||||
<>
|
customerPhone={customerPhone}
|
||||||
<svg className="h-5 w-5 animate-spin" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
selectedStop={selectedStop as StopInfo | null}
|
||||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
shippingState={shippingState}
|
||||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
shippingPostal={shippingPostal}
|
||||||
</svg>
|
shippingCity={shippingCity}
|
||||||
Redirecting to payment...
|
checkoutSessionKey={idempotencyKeyRef.current ?? undefined}
|
||||||
</>
|
onHostedCheckout={() => void handleHostedCheckout()}
|
||||||
) : (
|
/>
|
||||||
<>
|
</div>
|
||||||
<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" />
|
{/* Hosted-checkout fallback (legacy Stripe Checkout) */}
|
||||||
</svg>
|
<div className="rounded-2xl border border-dashed border-slate-300 bg-slate-50/60 p-4">
|
||||||
Pay Now — ${subtotal.toFixed(2)}
|
<p className="text-xs text-slate-500 mb-2 text-center">
|
||||||
</>
|
Prefer Stripe’s hosted page? Tap below.
|
||||||
)}
|
</p>
|
||||||
</button>
|
<button
|
||||||
</form>
|
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>
|
</div>
|
||||||
|
|
||||||
{/* Order Summary */}
|
{/* Order Summary */}
|
||||||
<aside aria-label="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">
|
<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">
|
<h2 className="text-xl font-bold text-slate-900">Order Summary</h2>
|
||||||
Order Summary
|
|
||||||
</h2>
|
|
||||||
|
|
||||||
<div className="mt-4 space-y-3">
|
<div className="mt-4 space-y-3">
|
||||||
{cart.map((item) => (
|
{cart.map((item) => (
|
||||||
@@ -420,12 +480,20 @@ export default function CheckoutClient() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Secure payment badge */}
|
{/* Trust badges */}
|
||||||
<div className="mt-4 flex items-center justify-center gap-2 text-xs text-slate-500">
|
<div className="mt-4 space-y-1.5">
|
||||||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
|
<div className="flex items-center justify-center gap-2 text-xs text-slate-500">
|
||||||
<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 className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
|
||||||
</svg>
|
<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" />
|
||||||
<span>Secured by Stripe</span>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|||||||
@@ -47,12 +47,16 @@ function formatStopDate(dateStr: string): string {
|
|||||||
function SuccessContent() {
|
function SuccessContent() {
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
const sessionId = searchParams.get("session_id");
|
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");
|
const orderIdParam = searchParams.get("order_id");
|
||||||
// Direct access with order_id — load from sessionStorage in lazy initializer.
|
// Direct access with order_id — load from sessionStorage in lazy initializer.
|
||||||
// searchParams values are stable, and sessionStorage is client-only, so this
|
// searchParams values are stable, and sessionStorage is client-only, so this
|
||||||
// is safe in a client component.
|
// is safe in a client component.
|
||||||
const [order, setOrder] = useState<StoredOrder | null>(() => {
|
const [order, setOrder] = useState<StoredOrder | null>(() => {
|
||||||
if (!orderIdParam || sessionId) return null;
|
if (!orderIdParam || sessionId || paymentIntentId) return null;
|
||||||
if (typeof window === "undefined") return null;
|
if (typeof window === "undefined") return null;
|
||||||
try {
|
try {
|
||||||
const stored = sessionStorage.getItem(`order_${orderIdParam}`);
|
const stored = sessionStorage.getItem(`order_${orderIdParam}`);
|
||||||
@@ -62,14 +66,20 @@ function SuccessContent() {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
const [creating, setCreating] = useState(!!sessionId);
|
const [creating, setCreating] = useState(Boolean(sessionId || (paymentIntentId && redirectStatus === "succeeded")));
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
// Stripe redirected back — create order from pending checkout data.
|
// Create the order from the pending-checkout payload, regardless of
|
||||||
// Wrapped in an async IIFE so all setState calls happen inside a callback,
|
// whether the user paid via hosted Stripe Checkout (?session_id=...) or
|
||||||
// not in the synchronous effect body (satisfies set-state-in-effect rule).
|
// embedded Stripe Elements (?payment_intent=...). Both paths store the
|
||||||
|
// same payload in `pending_checkout` before payment is initiated.
|
||||||
useEffect(() => {
|
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 () => {
|
(async () => {
|
||||||
const pendingStr = sessionStorage.getItem("pending_checkout");
|
const pendingStr = sessionStorage.getItem("pending_checkout");
|
||||||
@@ -88,6 +98,7 @@ function SuccessContent() {
|
|||||||
cartBrandId?: string;
|
cartBrandId?: string;
|
||||||
shippingAddress?: { state: string; postal_code: string; city?: string };
|
shippingAddress?: { state: string; postal_code: string; city?: string };
|
||||||
idempotencyKey: string;
|
idempotencyKey: string;
|
||||||
|
paymentIntentId?: string;
|
||||||
};
|
};
|
||||||
try {
|
try {
|
||||||
pending = JSON.parse(pendingStr);
|
pending = JSON.parse(pendingStr);
|
||||||
@@ -123,7 +134,7 @@ function SuccessContent() {
|
|||||||
setCreating(false);
|
setCreating(false);
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
}, [sessionId]);
|
}, [sessionId, paymentIntentId, redirectStatus]);
|
||||||
|
|
||||||
if (!order || !orderIdParam) {
|
if (!order || !orderIdParam) {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -312,7 +312,10 @@ function SheetContent({
|
|||||||
|
|
||||||
{/* Footer — sticky CTA + express */}
|
{/* Footer — sticky CTA + express */}
|
||||||
<div className="border-t border-stone-900/10 bg-[#F5EFD9] px-6 sm:px-8 pt-5 pb-7 space-y-3">
|
<div className="border-t border-stone-900/10 bg-[#F5EFD9] px-6 sm:px-8 pt-5 pb-7 space-y-3">
|
||||||
{/* Express checkout row */}
|
{/* Express checkout row — the buttons are visual shortcuts.
|
||||||
|
/checkout auto-renders Stripe's <ExpressCheckoutElement> for
|
||||||
|
Apple Pay / Google Pay / Link + <PaymentElement> for card via
|
||||||
|
StripeExpressCheckout. The `?express=` query is decorative. */}
|
||||||
<div>
|
<div>
|
||||||
<p className="font-mono text-[9px] uppercase tracking-[0.22em] text-stone-500 mb-2 text-center">
|
<p className="font-mono text-[9px] uppercase tracking-[0.22em] text-stone-500 mb-2 text-center">
|
||||||
Express checkout
|
Express checkout
|
||||||
|
|||||||
@@ -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<string | null>(null);
|
||||||
|
const [paymentIntentId, setPaymentIntentId] = useState<string | null>(null);
|
||||||
|
const [intentAmount, setIntentAmount] = useState<number | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(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 (
|
||||||
|
<ExpressShell>
|
||||||
|
<div className="flex items-center gap-2 text-stone-500 text-sm py-3">
|
||||||
|
<span className="h-4 w-4 rounded-full border-2 border-stone-300 border-t-stone-700 animate-spin" />
|
||||||
|
Preparing express checkout…
|
||||||
|
</div>
|
||||||
|
</ExpressShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!available || !clientSecret || !stripePromise) {
|
||||||
|
return (
|
||||||
|
<ExpressShell>
|
||||||
|
{error && (
|
||||||
|
<p className="text-xs text-stone-500 mb-2">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={props.onHostedCheckout}
|
||||||
|
className="w-full rounded-xl bg-gradient-to-r from-stone-900 to-stone-700 px-6 py-3.5 text-sm font-semibold text-white hover:from-stone-800 hover:to-stone-600 transition-all shadow-lg shadow-stone-900/20"
|
||||||
|
>
|
||||||
|
Continue to secure checkout →
|
||||||
|
</button>
|
||||||
|
</ExpressShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ExpressShell>
|
||||||
|
<Elements
|
||||||
|
stripe={stripePromise}
|
||||||
|
options={{
|
||||||
|
clientSecret,
|
||||||
|
appearance: {
|
||||||
|
theme: "flat",
|
||||||
|
variables: {
|
||||||
|
colorPrimary: "#1A4D2E",
|
||||||
|
colorBackground: "#ffffff",
|
||||||
|
colorText: "#0a0a0a",
|
||||||
|
colorDanger: "#dc2626",
|
||||||
|
fontFamily: "system-ui, -apple-system, sans-serif",
|
||||||
|
spacingUnit: "4px",
|
||||||
|
borderRadius: "12px",
|
||||||
|
},
|
||||||
|
rules: {
|
||||||
|
".Input": { boxShadow: "none", border: "1px solid #e7e5e4" },
|
||||||
|
".Input:focus": { border: "1px solid #1A4D2E" },
|
||||||
|
".Label": { fontWeight: "600", color: "#0a0a0a" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<CheckoutInner
|
||||||
|
{...props}
|
||||||
|
emailBlocked={emailBlocked}
|
||||||
|
stopBlocked={stopBlocked}
|
||||||
|
paymentIntentId={paymentIntentId}
|
||||||
|
intentAmount={intentAmount}
|
||||||
|
persistPendingCheckout={persistPendingCheckout}
|
||||||
|
onError={setError}
|
||||||
|
onSuccess={() => {
|
||||||
|
// Express / Card confirmed in-page. Navigate to success.
|
||||||
|
router.push("/checkout/success?payment_intent=" + (paymentIntentId ?? ""));
|
||||||
|
}}
|
||||||
|
onHostedCheckout={props.onHostedCheckout}
|
||||||
|
/>
|
||||||
|
</Elements>
|
||||||
|
</ExpressShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ExpressShell({ children }: { children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-2xl border border-stone-200 bg-white p-5 shadow-sm">
|
||||||
|
<div className="flex items-center gap-2 mb-3">
|
||||||
|
<svg className="h-4 w-4 text-stone-700" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.8}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M13 10V3L4 14h7v7l9-11h-7z" />
|
||||||
|
</svg>
|
||||||
|
<p className="text-[11px] font-bold uppercase tracking-widest text-stone-700">
|
||||||
|
Express checkout
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Express (Apple Pay, Google Pay, Link, PayPal) */}
|
||||||
|
<div>
|
||||||
|
<ExpressCheckoutElement
|
||||||
|
onConfirm={handleExpressConfirm}
|
||||||
|
onReady={(ev) => {
|
||||||
|
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 && (
|
||||||
|
<p className="mt-2 text-[11px] text-stone-500 text-center">
|
||||||
|
No express wallets detected on this device — you can still pay by card below.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{blocked && (
|
||||||
|
<p className="mt-2 text-[11px] text-amber-700 bg-amber-50 border border-amber-200 rounded-lg px-3 py-2 text-center">
|
||||||
|
{emailBlocked
|
||||||
|
? "Enter your email above to enable express checkout."
|
||||||
|
: "Pick a pickup stop above to enable express checkout."}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Divider */}
|
||||||
|
<div className="relative my-2">
|
||||||
|
<div className="absolute inset-0 flex items-center">
|
||||||
|
<div className="w-full border-t border-stone-200" />
|
||||||
|
</div>
|
||||||
|
<div className="relative flex justify-center">
|
||||||
|
<span className="bg-white px-3 text-[10px] font-bold uppercase tracking-widest text-stone-400">
|
||||||
|
or pay by card
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Card form */}
|
||||||
|
<form onSubmit={handleCardSubmit}>
|
||||||
|
<PaymentElement
|
||||||
|
options={{
|
||||||
|
layout: "tabs",
|
||||||
|
fields: { billingDetails: "auto" },
|
||||||
|
wallets: { applePay: "never", googlePay: "never" },
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={!stripe || !elements || submitting || blocked}
|
||||||
|
className="mt-4 w-full rounded-xl bg-stone-900 hover:bg-stone-800 disabled:bg-stone-400 disabled:cursor-not-allowed px-6 py-3.5 text-sm font-semibold text-white transition-all shadow-lg shadow-stone-900/15"
|
||||||
|
>
|
||||||
|
{submitting ? "Processing…" : "Pay by card"}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{/* Fallback to hosted checkout (existing flow) */}
|
||||||
|
{onHostedCheckout && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onHostedCheckout}
|
||||||
|
className="w-full text-xs text-stone-500 hover:text-stone-800 underline underline-offset-4 transition-colors"
|
||||||
|
>
|
||||||
|
Having trouble? Use the secure hosted checkout instead.
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -20,6 +20,8 @@ type StopInfo = {
|
|||||||
brand_id: string;
|
brand_id: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type { StopInfo };
|
||||||
|
|
||||||
type CartContextType = {
|
type CartContextType = {
|
||||||
cart: CartItem[];
|
cart: CartItem[];
|
||||||
subtotal: number;
|
subtotal: number;
|
||||||
@@ -334,7 +336,7 @@ export function CartProvider({ children }: { children: ReactNode }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useCart() {
|
export function useCart(): CartContextType {
|
||||||
const context = useContext(CartContext);
|
const context = useContext(CartContext);
|
||||||
if (!context) throw new Error("useCart must be used inside CartProvider");
|
if (!context) throw new Error("useCart must be used inside CartProvider");
|
||||||
return context;
|
return context;
|
||||||
|
|||||||
@@ -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<Stripe | null> | 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<Stripe | null> | 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);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user