diff --git a/CLAUDE.md b/CLAUDE.md
index 3a43690f..d5834897 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -334,8 +334,6 @@ export async function generateStaticParams() {
// For dynamic rendering when needed
export const dynamic = "force-dynamic";
-// or
-export const revalidate = 60;
```
### Metadata API
diff --git a/next.config.ts b/next.config.ts
index 84b3a538..aafb9333 100644
--- a/next.config.ts
+++ b/next.config.ts
@@ -9,6 +9,7 @@ const nextConfig: NextConfig = {
turbopack: {
root: __dirname,
},
+ cacheComponents: true,
images: {
qualities: [25, 50, 75, 85, 100],
dangerouslyAllowLocalIP: true, // Allow localhost images in development
diff --git a/package-lock.json b/package-lock.json
index 7e9ffd9f..69ff42e9 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -10,8 +10,8 @@
"dependencies": {
"@next/third-parties": "^16.1.6",
"@sentry/nextjs": "^10.38.0",
- "@spree/next": "^0.9.0",
- "@spree/sdk": "^0.9.0",
+ "@spree/next": "^0.10.4",
+ "@spree/sdk": "^0.10.0",
"@stripe/react-stripe-js": "^5.6.0",
"@stripe/stripe-js": "^8.7.0",
"class-variance-authority": "^0.7.1",
@@ -5485,20 +5485,20 @@
}
},
"node_modules/@spree/next": {
- "version": "0.9.0",
- "resolved": "https://registry.npmjs.org/@spree/next/-/next-0.9.0.tgz",
- "integrity": "sha512-Qa0+OW3xfRbookWTwev/ZgzUG8xNvrnAtVyoHxEbDy3XHZcWmIw6AMd/ph6QPv7uzx47Y6NhGFwZ4ljkqEn9DA==",
+ "version": "0.10.4",
+ "resolved": "https://registry.npmjs.org/@spree/next/-/next-0.10.4.tgz",
+ "integrity": "sha512-O2WED5CSPrt9XKzzsWmJ1mPlusLFmi1/SvEKUeMV3hEzfTRaaRBW2U0Q6ZjFtqV36zHVVgnwaUkXw6lXeKRQPg==",
"license": "MIT",
"peerDependencies": {
- "@spree/sdk": ">=0.9.0",
+ "@spree/sdk": ">=0.10.0",
"next": ">=15.0.0",
"react": ">=19.0.0"
}
},
"node_modules/@spree/sdk": {
- "version": "0.9.0",
- "resolved": "https://registry.npmjs.org/@spree/sdk/-/sdk-0.9.0.tgz",
- "integrity": "sha512-RWYNyhsVYrevubpeXt5NfWJrc9xr0GG9zj2TrxU5fT6PsZEh4VK8DYlgfG9C/q2Vp6a9p99QiZ5z09bZ9YlW5g==",
+ "version": "0.10.0",
+ "resolved": "https://registry.npmjs.org/@spree/sdk/-/sdk-0.10.0.tgz",
+ "integrity": "sha512-2JZHy7EhBkW4xvTU5Z+btNq22s7yK41O+TBX1ng6TOmeL61S4riY5xneWVSyCEsDInSoUVnU1hp+V2Q0HmndHQ==",
"license": "MIT",
"engines": {
"node": ">=18.0.0"
diff --git a/package.json b/package.json
index 7aae6d40..e4418350 100644
--- a/package.json
+++ b/package.json
@@ -15,8 +15,8 @@
"dependencies": {
"@next/third-parties": "^16.1.6",
"@sentry/nextjs": "^10.38.0",
- "@spree/next": "^0.9.0",
- "@spree/sdk": "^0.9.0",
+ "@spree/next": "^0.10.4",
+ "@spree/sdk": "^0.10.0",
"@stripe/react-stripe-js": "^5.6.0",
"@stripe/stripe-js": "^8.7.0",
"class-variance-authority": "^0.7.1",
diff --git a/src/app/[country]/[locale]/(checkout)/checkout/[id]/page.tsx b/src/app/[country]/[locale]/(checkout)/checkout/[id]/page.tsx
index 2975be45..e9b968e4 100644
--- a/src/app/[country]/[locale]/(checkout)/checkout/[id]/page.tsx
+++ b/src/app/[country]/[locale]/(checkout)/checkout/[id]/page.tsx
@@ -1,22 +1,18 @@
"use client";
-import type {
- Address,
- AddressParams,
- Cart,
- Country,
- Shipment,
-} from "@spree/sdk";
-import { CircleAlert } from "lucide-react";
+import type { Address, AddressParams, Cart, Country } from "@spree/sdk";
+import { CircleAlert, Loader2 } from "lucide-react";
import Link from "next/link";
-import { usePathname, useRouter } from "next/navigation";
+import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { use, useCallback, useEffect, useRef, useState } from "react";
-import { AddressStep } from "@/components/checkout/AddressStep";
+import { AddressSection } from "@/components/checkout/AddressSection";
import { CouponCode } from "@/components/checkout/CouponCode";
-import { DeliveryStep } from "@/components/checkout/DeliveryStep";
-import { OrderSummary } from "@/components/checkout/OrderSummary";
-import { PaymentStep } from "@/components/checkout/PaymentStep";
-import { Alert, AlertDescription } from "@/components/ui/alert";
+import {
+ PaymentSection,
+ type PaymentSectionHandle,
+} from "@/components/checkout/PaymentSection";
+import { ShippingMethodSection } from "@/components/checkout/ShippingMethodSection";
+import { Summary } from "@/components/checkout/Summary";
import { useCheckout } from "@/contexts/CheckoutContext";
import {
trackAddPaymentInfo,
@@ -27,7 +23,6 @@ import { getAddresses, updateAddress } from "@/lib/data/addresses";
import {
applyCouponCode,
getCheckoutOrder,
- nextCheckoutStep,
removeCouponCode,
selectShippingRate,
updateOrderAddresses,
@@ -41,12 +36,6 @@ import {
} from "@/lib/data/payment";
import { extractBasePath } from "@/lib/utils/path";
-const CHECKOUT_STEPS = [
- { id: "address", label: "Shipping" },
- { id: "delivery", label: "Delivery" },
- { id: "payment", label: "Payment" },
-];
-
interface CheckoutPageProps {
params: Promise<{
id: string;
@@ -57,24 +46,24 @@ interface CheckoutPageProps {
// Sidebar summary component
function CheckoutSidebar({
- order,
+ cart,
onApplyCoupon,
onRemoveCoupon,
}: {
- order: Cart;
+ cart: Cart;
onApplyCoupon: (
code: string,
) => Promise<{ success: boolean; error?: string }>;
onRemoveCoupon: (
- promotionId: string,
+ code: string,
) => Promise<{ success: boolean; error?: string }>;
}) {
return (
<>
-
+
@@ -84,73 +73,75 @@ function CheckoutSidebar({
}
export default function CheckoutPage({ params }: CheckoutPageProps) {
- // use() must be called before all other hooks to avoid hook order issues
- const { id: orderId, country: urlCountry } = use(params);
+ const { id: cartId, country: urlCountry } = use(params);
const router = useRouter();
const pathname = usePathname();
+ const searchParams = useSearchParams();
const basePath = extractBasePath(pathname);
const { setSummaryContent } = useCheckout();
- const [order, setOrder] = useState
(null);
- const [shipments, setShipments] = useState([]);
+ // Pick up payment errors from the confirm-payment redirect
+ const paymentError = searchParams.get("payment_error");
+
+ const [cart, setCart] = useState(null);
const [countries, setCountries] = useState([]);
const [savedAddresses, setSavedAddresses] = useState([]);
const [isAuthenticated, setIsAuthenticated] = useState(false);
const [loading, setLoading] = useState(true);
- const [error, setError] = useState(null);
+ const [error, setError] = useState(paymentError);
const [processing, setProcessing] = useState(false);
- const [currentStep, setCurrentStep] = useState("address");
+ const [saving, setSaving] = useState(false);
+ const [sectionErrors, setSectionErrors] = useState>(
+ {},
+ );
- // Use ref to store the current order for stable callback references
- const orderRef = useRef(order);
- orderRef.current = order;
+ const shipments = cart?.shipments ?? [];
- // Guard to fire begin_checkout only once
+ const cartRef = useRef(cart);
+ cartRef.current = cart;
const beginCheckoutFiredRef = useRef(false);
+ const paymentRef = useRef(null);
- // Handle coupon code application - uses ref to avoid stale closures
+ // Handle coupon code application
const handleApplyCoupon = useCallback(async (code: string) => {
- const currentOrder = orderRef.current;
- if (!currentOrder) return { success: false, error: "No order" };
+ const currentOrder = cartRef.current;
+ if (!currentOrder) return { success: false, error: "No cart" };
const result = await applyCouponCode(currentOrder.id, code);
- if (result.success && result.order) {
- setOrder(result.order);
+ if (result.success && result.cart) {
+ setCart(result.cart);
}
return result;
- }, []); // No dependencies - uses ref
+ }, []);
- // Handle coupon code removal
- const handleRemoveCoupon = useCallback(async (promotionId: string) => {
- const currentOrder = orderRef.current;
- if (!currentOrder) return { success: false, error: "No order" };
+ const handleRemoveCoupon = useCallback(async (couponCode: string) => {
+ const currentOrder = cartRef.current;
+ if (!currentOrder) return { success: false, error: "No cart" };
- const result = await removeCouponCode(currentOrder.id, promotionId);
- if (result.success && result.order) {
- setOrder(result.order);
+ const result = await removeCouponCode(currentOrder.id, couponCode);
+ if (result.success && result.cart) {
+ setCart(result.cart);
}
return result;
- }, []); // No dependencies - uses ref
+ }, []);
- // Track order key for sidebar updates (only update when order changes meaningfully)
- const orderKey = order ? `${order.id}-${order.updated_at}` : null;
- const prevOrderKeyRef = useRef(orderKey);
+ // Track cart key for sidebar updates
+ const cartKey = cart ? `${cart.id}-${cart.updated_at}` : null;
+ const prevOrderKeyRef = useRef(cartKey);
- // Update sidebar content when order changes meaningfully
useEffect(() => {
- // Skip if order key hasn't changed
if (
- orderKey === prevOrderKeyRef.current &&
+ cartKey === prevOrderKeyRef.current &&
prevOrderKeyRef.current !== null
) {
return;
}
- prevOrderKeyRef.current = orderKey;
+ prevOrderKeyRef.current = cartKey;
- if (order) {
+ if (cart) {
setSummaryContent(
,
@@ -158,305 +149,336 @@ export default function CheckoutPage({ params }: CheckoutPageProps) {
} else {
setSummaryContent(null);
}
- }, [
- order,
- orderKey,
- setSummaryContent,
- handleApplyCoupon,
- handleRemoveCoupon,
- ]);
-
- // Load order and market-scoped countries
+ }, [cart, cartKey, setSummaryContent, handleApplyCoupon, handleRemoveCoupon]);
+
+ // Load cart and market-scoped countries
const loadOrder = useCallback(async () => {
setLoading(true);
- setError(null);
+ if (!paymentError) setError(null);
try {
- const [orderData, market, addressesData, authStatus] = await Promise.all([
- getCheckoutOrder(orderId),
+ const [cartData, market, addressesData, authStatus] = await Promise.all([
+ getCheckoutOrder(cartId),
resolveMarket(urlCountry).catch(() => null),
getAddresses(),
checkAuth(),
]);
- // Fetch countries scoped to the resolved market
const countriesData = market
? await getMarketCountries(market.id).catch(() => ({
data: [] as Country[],
}))
: { data: [] as Country[] };
- if (!orderData) {
+ if (!cartData) {
setError("Order not found or you don't have access to it.");
setLoading(false);
return;
}
- // Check if order is already complete
- if (orderData.current_step === "complete") {
- router.push(`${basePath}/order-placed/${orderId}`);
+ if (cartData.current_step === "complete") {
+ router.push(`${basePath}/order-placed/${cartId}`);
return;
}
- setOrder(orderData);
+ setCart(cartData);
setCountries(countriesData.data);
setSavedAddresses(addressesData.data);
setIsAuthenticated(authStatus);
- setCurrentStep(orderData.current_step);
if (!beginCheckoutFiredRef.current) {
try {
- trackBeginCheckout(orderData);
+ trackBeginCheckout(cartData);
} catch {
// Analytics should never break checkout flow
}
beginCheckoutFiredRef.current = true;
}
- // Set shipments from order data (already included via getCheckoutOrder)
- if (orderData.completed_steps.includes("address")) {
- setShipments(orderData.shipments || []);
- }
-
- return orderData;
+ return cartData;
} catch {
setError("Failed to load checkout. Please try again.");
return null;
} finally {
setLoading(false);
}
- }, [orderId, urlCountry, basePath, router]);
+ }, [cartId, urlCountry, basePath, router, paymentError]);
useEffect(() => {
loadOrder();
}, [loadOrder]);
- // Handle address submission (shipping address only)
- const handleAddressSubmit = async (addressData: {
- email: string;
- ship_address?: AddressParams;
- ship_address_id?: string;
- }) => {
- if (!order) return;
+ // Handle email blur — persist email as the first backend call
+ const handleEmailBlur = useCallback(async (email: string) => {
+ const currentOrder = cartRef.current;
+ if (!currentOrder || !email.trim()) return;
- setProcessing(true);
- setError(null);
+ // Only persist email if it changed
+ if (email === currentOrder.email) return;
try {
- // Update order with shipping address and email
- const updateResult = await updateOrderAddresses(order.id, {
- email: addressData.email,
- ...(addressData.ship_address && {
- ship_address: addressData.ship_address,
- }),
- ...(addressData.ship_address_id && {
- ship_address_id: addressData.ship_address_id,
- }),
- });
-
- if (!updateResult.success) {
- setError(updateResult.error || "Failed to save address");
- setProcessing(false);
- return;
+ const result = await updateOrderAddresses(currentOrder.id, { email });
+ if (result.success && result.cart) {
+ setCart(result.cart);
}
-
- // Move to next checkout step
- const nextResult = await nextCheckoutStep(order.id);
- if (!nextResult.success) {
- setError(nextResult.error || "Failed to proceed to next step");
- setProcessing(false);
- return;
- }
-
- // Reload order to get updated state
- await loadOrder();
} catch {
- setError("An error occurred. Please try again.");
- } finally {
- setProcessing(false);
+ // Email save failure is not critical — will be caught on "Pay now"
}
- };
+ }, []);
- // Handle shipping rate selection
- const handleShippingRateSelect = async (
- shipmentId: string,
- rateId: string,
- ) => {
- if (!order) return;
+ // Handle auto-save (address + email on blur)
+ const handleAutoSave = useCallback(
+ async (addressData: {
+ email: string;
+ ship_address?: AddressParams;
+ ship_address_id?: string;
+ }) => {
+ const currentOrder = cartRef.current;
+ if (!currentOrder) return;
- setProcessing(true);
- setError(null);
+ setSaving(true);
+ setError(null);
- let trackingOrder: Cart | null = null;
- let trackingRateName: string | undefined;
-
- try {
- const result = await selectShippingRate(order.id, shipmentId, rateId);
- if (!result.success) {
- setError(result.error || "Failed to select shipping rate");
- } else if (result.order) {
- setOrder(result.order);
- setShipments(result.order.shipments || []);
-
- const selectedRate = result.order.shipments
- ?.flatMap((s) => s.shipping_rates || [])
- ?.find((r) => r.id === rateId);
- trackingOrder = result.order;
- trackingRateName = selectedRate?.name;
- }
- } catch {
- setError("An error occurred. Please try again.");
- } finally {
- setProcessing(false);
- }
-
- if (trackingOrder) {
try {
- trackAddShippingInfo(trackingOrder, trackingRateName);
+ const updateResult = await updateOrderAddresses(currentOrder.id, {
+ email: addressData.email,
+ ...(addressData.ship_address && {
+ ship_address: addressData.ship_address,
+ }),
+ ...(addressData.ship_address_id && {
+ ship_address_id: addressData.ship_address_id,
+ }),
+ });
+
+ if (!updateResult.success) {
+ setError(updateResult.error || "Failed to save address");
+ return;
+ }
+
+ if (updateResult.cart) {
+ setCart(updateResult.cart);
+ }
} catch {
- // Analytics should never break checkout flow
+ setError("An error occurred. Please try again.");
+ } finally {
+ setSaving(false);
}
- }
- };
+ },
+ [],
+ );
- // Handle delivery confirmation (advance to payment step)
- const handleDeliveryConfirm = async () => {
- if (!order) return;
+ // Handle shipping rate selection
+ const handleShippingRateSelect = useCallback(
+ async (shipmentId: string, rateId: string) => {
+ const currentOrder = cartRef.current;
+ if (!currentOrder) return;
- setProcessing(true);
- setError(null);
+ setProcessing(true);
+ setError(null);
- try {
- // Move to next checkout step
- const nextResult = await nextCheckoutStep(order.id);
- if (!nextResult.success) {
- setError(nextResult.error || "Failed to proceed");
+ let trackingOrder: Cart | null = null;
+ let trackingRateName: string | undefined;
+
+ try {
+ const result = await selectShippingRate(
+ currentOrder.id,
+ shipmentId,
+ rateId,
+ );
+ if (!result.success) {
+ setError(result.error || "Failed to select shipping rate");
+ } else if (result.cart) {
+ setCart(result.cart);
+
+ const selectedRate = result.cart.shipments
+ ?.flatMap((s) => s.shipping_rates || [])
+ ?.find((r) => r.id === rateId);
+ trackingOrder = result.cart;
+ trackingRateName = selectedRate?.name;
+ }
+ } catch {
+ setError("An error occurred. Please try again.");
+ } finally {
setProcessing(false);
- return;
}
- // Reload order
- await loadOrder();
- } catch {
- setError("An error occurred. Please try again.");
- } finally {
- setProcessing(false);
- }
- };
+ if (trackingOrder) {
+ try {
+ trackAddShippingInfo(trackingOrder, trackingRateName);
+ } catch {
+ // Analytics should never break checkout flow
+ }
+ }
+ },
+ [],
+ );
- // Handle billing address update (called by PaymentStep before gateway confirmation)
- const handleUpdateBillingAddress = async (data: {
- bill_address: AddressParams;
- }): Promise => {
- if (!order) return false;
+ // Handle billing address update (called by PaymentSection before gateway confirmation)
+ const handleUpdateBillingAddress = useCallback(
+ async (data: { bill_address: AddressParams }): Promise => {
+ const currentOrder = cartRef.current;
+ if (!currentOrder) return false;
- setError(null);
+ setError(null);
- try {
- const updateResult = await updateOrderAddresses(order.id, {
- bill_address: data.bill_address,
- });
+ try {
+ const updateResult = await updateOrderAddresses(currentOrder.id, {
+ bill_address: data.bill_address,
+ });
+
+ if (!updateResult.success) {
+ setError(updateResult.error || "Failed to save billing address");
+ return false;
+ }
- if (!updateResult.success) {
- setError(updateResult.error || "Failed to save billing address");
+ return true;
+ } catch {
+ setError("Failed to save billing address. Please try again.");
return false;
}
+ },
+ [],
+ );
- return true;
- } catch {
- setError("Failed to save billing address. Please try again.");
- return false;
- }
- };
-
- // Handle payment completion (called by PaymentStep after Stripe confirms)
- const handlePaymentComplete = async (paymentSessionId: string) => {
- if (!order) return;
-
- setError(null);
-
- try {
- // Complete the payment session on the backend
- const sessionResult = await completeCheckoutPaymentSession(
- order.id,
- paymentSessionId,
- );
+ // Handle payment completion (called by PaymentSection after Stripe confirms)
+ const handlePaymentComplete = useCallback(
+ async (paymentSessionId: string) => {
+ const currentOrder = cartRef.current;
+ if (!currentOrder) return;
- if (!sessionResult.success) {
- setError(sessionResult.error || "Failed to complete payment session");
- setProcessing(false);
- return;
- }
+ setError(null);
try {
- trackAddPaymentInfo(order);
- } catch {
- // Analytics should never break checkout flow
- }
+ const sessionResult = await completeCheckoutPaymentSession(
+ currentOrder.id,
+ paymentSessionId,
+ );
- // Check if the order was already completed by the payment session completion.
- // If not, explicitly complete it.
- const updatedOrder = await getCheckoutOrder(order.id);
+ if (!sessionResult.success) {
+ setError(sessionResult.error || "Failed to complete payment session");
+ setProcessing(false);
+ return;
+ }
- if (!updatedOrder) {
- setError("Order not found after payment. Please contact support.");
- setProcessing(false);
- return;
- }
+ try {
+ trackAddPaymentInfo(currentOrder);
+ } catch {
+ // Analytics should never break checkout flow
+ }
- if (updatedOrder.current_step !== "complete") {
- const completeResult = await completeCheckoutOrder(order.id);
+ // Complete the order — if the backend already completed it during
+ // session completion, completeCheckoutOrder handles 403/422 gracefully.
+ const completeResult = await completeCheckoutOrder(currentOrder.id);
if (!completeResult.success) {
setError(completeResult.error || "Failed to complete order");
setProcessing(false);
return;
}
- }
- // Redirect to order placed page (cart cookie is cleared there)
- router.push(`${basePath}/order-placed/${order.id}`);
- } catch {
- setError("An error occurred. Please try again.");
- setProcessing(false);
- }
- };
+ // Cache the completed order for the thank-you page
+ if (completeResult.order) {
+ const { cacheCompletedOrder } = await import(
+ "@/lib/utils/completed-order-cache"
+ );
+ cacheCompletedOrder(currentOrder.id, completeResult.order);
+ }
+
+ router.push(`${basePath}/order-placed/${currentOrder.id}`);
+ } catch {
+ setError("An error occurred. Please try again.");
+ setProcessing(false);
+ }
+ },
+ [basePath, router],
+ );
// Fetch states for a country
- const fetchStates = async (countryIso: string) => {
+ const fetchStates = useCallback(async (countryIso: string) => {
try {
const country = await getCountry(countryIso);
return country.states || [];
} catch {
return [];
}
- };
+ }, []);
// Update a saved address
- const handleUpdateSavedAddress = async (
- id: string,
- data: AddressParams,
- ): Promise => {
- const result = await updateAddress(id, data);
-
- if (!result.success) {
- throw new Error(result.error || "Failed to update address");
- }
+ const handleUpdateSavedAddress = useCallback(
+ async (id: string, data: AddressParams): Promise => {
+ const result = await updateAddress(id, data);
+
+ if (!result.success) {
+ throw new Error(result.error || "Failed to update address");
+ }
+
+ if (!result.address) {
+ throw new Error("Update succeeded but address payload is missing");
+ }
- if (!result.address) {
- throw new Error("Update succeeded but address payload is missing");
+ return result.address;
+ },
+ [],
+ );
+
+ // Validate and pay — single "Pay now" action
+ const validateAndPay = async () => {
+ if (!cart) return;
+
+ setSectionErrors({});
+ setError(null);
+
+ // Refresh cart to get latest requirements
+ const freshOrder = await getCheckoutOrder(cart.id);
+ if (!freshOrder) {
+ setError("Failed to load cart. Please try again.");
+ return;
}
+ setCart(freshOrder);
- const updatedAddress = result.address;
- setSavedAddresses((prev) =>
- prev.map((addr) => (addr.id === id ? updatedAddress : addr)),
+ // Check requirements — skip "payment" since we handle that via
+ // the PaymentSection imperative submit (payment is created at confirmation time)
+ const prePaymentReqs = (freshOrder.requirements || []).filter(
+ (req) => req.step !== "payment",
);
- return updatedAddress;
- };
+ if (prePaymentReqs.length > 0) {
+ const errorsBySection: Record = {};
+
+ for (const req of prePaymentReqs) {
+ // Map requirement steps to section IDs
+ const sectionId =
+ req.step === "address"
+ ? "address"
+ : req.step === "delivery"
+ ? "shipping"
+ : req.step;
+ if (!errorsBySection[sectionId]) {
+ errorsBySection[sectionId] = [];
+ }
+ errorsBySection[sectionId].push(req.message);
+ }
+
+ setSectionErrors(errorsBySection);
+
+ // Scroll to first error section
+ const firstSection = Object.keys(errorsBySection)[0];
+ const el = document.getElementById(`checkout-section-${firstSection}`);
+ if (el) {
+ el.scrollIntoView({ behavior: "smooth", block: "start" });
+ }
+
+ return;
+ }
+
+ // All requirements met — submit payment
+ if (!paymentRef.current) {
+ setError("Payment is not ready. Please wait and try again.");
+ return;
+ }
- // Navigate back to a previous step
- const goToStep = (step: string) => {
- setCurrentStep(step);
+ setProcessing(true);
+ await paymentRef.current.submit();
+ // PaymentSection handles setProcessing(false) on error internally
};
// Loading state
@@ -474,8 +496,8 @@ export default function CheckoutPage({ params }: CheckoutPageProps) {
);
}
- // Error state
- if (error && !order) {
+ // Error state (no cart loaded)
+ if (error && !cart) {
return (
@@ -492,10 +514,10 @@ export default function CheckoutPage({ params }: CheckoutPageProps) {
);
}
- if (!order) return null;
+ if (!cart) return null;
- // Check if order has items
- if (!order.items || order.items.length === 0) {
+ // Empty cart
+ if (!cart.items || cart.items.length === 0) {
return (
@@ -514,116 +536,80 @@ export default function CheckoutPage({ params }: CheckoutPageProps) {
);
}
- const steps = CHECKOUT_STEPS;
- const currentStepIndex = steps.findIndex((s) => s.id === currentStep);
- const previousStepId =
- currentStepIndex > 0 ? steps[currentStepIndex - 1].id : undefined;
-
return (
- <>
- {/* Header */}
-
-
Checkout
-
-
- {/* Step indicator */}
-
-
-
-
+
{/* Error banner */}
{error && (
-
-
- {error}
-
+
)}
- {/* Main content */}
- {currentStep === "address" && (
-
+
- )}
+
- {currentStep === "delivery" && (
-
+ goToStep(previousStepId) : undefined}
processing={processing}
+ errors={sectionErrors.shipping}
/>
- )}
+
- {currentStep === "payment" && (
-
+ goToStep(previousStepId) : undefined}
processing={processing}
setProcessing={setProcessing}
+ errors={sectionErrors.payment}
/>
- )}
- >
+
+
+ {/* Pay now button — Shopify: black, tall, minimal radius, bold */}
+
+
);
}
diff --git a/src/app/[country]/[locale]/(checkout)/confirm-payment/[id]/page.tsx b/src/app/[country]/[locale]/(checkout)/confirm-payment/[id]/page.tsx
new file mode 100644
index 00000000..5587d679
--- /dev/null
+++ b/src/app/[country]/[locale]/(checkout)/confirm-payment/[id]/page.tsx
@@ -0,0 +1,77 @@
+"use client";
+
+import { Loader2 } from "lucide-react";
+import { usePathname, useRouter, useSearchParams } from "next/navigation";
+import { use, useEffect, useRef } from "react";
+import { confirmPaymentAndCompleteCart } from "@/lib/data/payment";
+import { extractBasePath } from "@/lib/utils/path";
+
+interface ConfirmPaymentPageProps {
+ params: Promise<{
+ id: string;
+ country: string;
+ locale: string;
+ }>;
+}
+
+/**
+ * Intermediate page that offsite payment gateways redirect to.
+ *
+ * When a customer returns from an offsite gateway (e.g. Stripe 3D Secure),
+ * the payment webhook may not have arrived yet. This page:
+ * 1. Tries to complete the payment session (tells Spree to check with the provider)
+ * 2. If successful, completes the order and redirects to order-placed
+ * 3. If failed, redirects back to checkout with an error
+ */
+export default function ConfirmPaymentPage({
+ params,
+}: ConfirmPaymentPageProps) {
+ const { id: cartId } = use(params);
+ const router = useRouter();
+ const pathname = usePathname();
+ const searchParams = useSearchParams();
+ const basePath = extractBasePath(pathname);
+ const attemptedRef = useRef(false);
+
+ useEffect(() => {
+ if (attemptedRef.current) return;
+ attemptedRef.current = true;
+
+ const sessionId = searchParams.get("session");
+
+ async function confirmAndRedirect() {
+ const result = await confirmPaymentAndCompleteCart(
+ cartId,
+ sessionId ?? undefined,
+ );
+
+ if (result.success) {
+ // Cache the completed order for the thank-you page
+ if (result.order) {
+ const { cacheCompletedOrder } = await import(
+ "@/lib/utils/completed-order-cache"
+ );
+ cacheCompletedOrder(cartId, result.order);
+ }
+
+ router.replace(`${basePath}/order-placed/${cartId}`);
+ } else {
+ const errorMessage = encodeURIComponent(
+ result.error || "Payment could not be confirmed. Please try again.",
+ );
+ router.replace(
+ `${basePath}/checkout/${cartId}?payment_error=${errorMessage}`,
+ );
+ }
+ }
+
+ confirmAndRedirect();
+ }, [cartId, searchParams, basePath, router]);
+
+ return (
+
+
+
Confirming your payment...
+
+ );
+}
diff --git a/src/app/[country]/[locale]/(checkout)/confirm-payment/__tests__/page.test.tsx b/src/app/[country]/[locale]/(checkout)/confirm-payment/__tests__/page.test.tsx
new file mode 100644
index 00000000..5f95645a
--- /dev/null
+++ b/src/app/[country]/[locale]/(checkout)/confirm-payment/__tests__/page.test.tsx
@@ -0,0 +1,164 @@
+import { act, render, screen, waitFor } from "@testing-library/react";
+import { Suspense } from "react";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+const mockReplace = vi.fn();
+let mockSearchParams = new URLSearchParams();
+
+vi.mock("next/navigation", () => ({
+ useRouter: () => ({
+ push: vi.fn(),
+ replace: mockReplace,
+ refresh: vi.fn(),
+ back: vi.fn(),
+ forward: vi.fn(),
+ prefetch: vi.fn(),
+ }),
+ usePathname: () => "/us/en/confirm-payment/cart-1",
+ useSearchParams: () => mockSearchParams,
+}));
+
+vi.mock("@/lib/data/payment", () => ({
+ confirmPaymentAndCompleteCart: vi.fn(),
+}));
+
+vi.mock("@/lib/utils/path", () => ({
+ extractBasePath: (path: string) => {
+ const match = path.match(/^\/[^/]+\/[^/]+/);
+ return match ? match[0] : "";
+ },
+}));
+
+import { confirmPaymentAndCompleteCart } from "@/lib/data/payment";
+import ConfirmPaymentPage from "../[id]/page";
+
+const mockConfirm = vi.mocked(confirmPaymentAndCompleteCart);
+
+function renderPage(params = { id: "cart-1", country: "us", locale: "en" }) {
+ const resolvedParams = Promise.resolve(params);
+ return render(
+ suspense-fallback}>
+
+ ,
+ );
+}
+
+describe("ConfirmPaymentPage", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ mockSearchParams = new URLSearchParams();
+ });
+
+ it("shows loading spinner", async () => {
+ mockConfirm.mockReturnValue(new Promise(() => {})); // never resolves
+
+ await act(async () => {
+ renderPage();
+ });
+
+ expect(screen.getByText("Confirming your payment...")).toBeInTheDocument();
+ });
+
+ it("redirects to order-placed on success", async () => {
+ mockSearchParams.set("session", "session-1");
+ mockConfirm.mockResolvedValue({
+ success: true as const,
+ order: { id: "cart-1" },
+ });
+
+ await act(async () => {
+ renderPage();
+ });
+
+ await waitFor(() => {
+ expect(mockConfirm).toHaveBeenCalledWith("cart-1", "session-1");
+ expect(mockReplace).toHaveBeenCalledWith("/us/en/order-placed/cart-1");
+ });
+ });
+
+ it("redirects to checkout with error on failure", async () => {
+ mockSearchParams.set("session", "session-1");
+ mockConfirm.mockResolvedValue({
+ success: false as const,
+ error: "Payment declined",
+ });
+
+ await act(async () => {
+ renderPage();
+ });
+
+ await waitFor(() => {
+ expect(mockReplace).toHaveBeenCalledWith(
+ "/us/en/checkout/cart-1?payment_error=Payment%20declined",
+ );
+ });
+ });
+
+ it("passes undefined session when no query param", async () => {
+ mockConfirm.mockResolvedValue({
+ success: true as const,
+ order: { id: "cart-1" },
+ });
+
+ await act(async () => {
+ renderPage();
+ });
+
+ await waitFor(() => {
+ expect(mockConfirm).toHaveBeenCalledWith("cart-1", undefined);
+ expect(mockReplace).toHaveBeenCalledWith("/us/en/order-placed/cart-1");
+ });
+ });
+
+ it("uses fallback error message when none provided", async () => {
+ mockSearchParams.set("session", "session-1");
+ mockConfirm.mockResolvedValue({
+ success: false as const,
+ error: "",
+ });
+
+ await act(async () => {
+ renderPage();
+ });
+
+ await waitFor(() => {
+ expect(mockReplace).toHaveBeenCalledWith(
+ expect.stringContaining(
+ "payment_error=Payment%20could%20not%20be%20confirmed",
+ ),
+ );
+ });
+ });
+
+ it("only calls confirm once (idempotent)", async () => {
+ mockSearchParams.set("session", "session-1");
+ mockConfirm.mockResolvedValue({
+ success: true as const,
+ order: { id: "cart-1" },
+ });
+
+ let result: ReturnType;
+ await act(async () => {
+ result = renderPage();
+ });
+
+ // Re-render to simulate React strict mode double-effect
+ await act(async () => {
+ result!.rerender(
+ suspense-fallback}>
+
+ ,
+ );
+ });
+
+ await waitFor(() => {
+ expect(mockConfirm).toHaveBeenCalledTimes(1);
+ });
+ });
+});
diff --git a/src/app/[country]/[locale]/(checkout)/layout.tsx b/src/app/[country]/[locale]/(checkout)/layout.tsx
index ba1f59d3..81b88338 100644
--- a/src/app/[country]/[locale]/(checkout)/layout.tsx
+++ b/src/app/[country]/[locale]/(checkout)/layout.tsx
@@ -41,7 +41,7 @@ function CheckoutFooter() {
const currentYear = new Date().getFullYear();
return (
-