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} - +
+

+ + {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 ( -