'use client'; import { Suspense, useEffect, useState, useCallback, useRef } from 'react'; import { useRouter, useSearchParams } from 'next/navigation'; import { CdnImage as Image } from '@/ui/shared/cdn-image'; import { Link } from '@/core/lib/navigation'; import type { Checkout, ShippingRate, SetShippingAddressDto, ShippingDestinations, PickupLocation, CheckoutBumpsResponse, CheckoutCustomFieldDefinition, } from 'brainerce'; import { formatPrice } from 'brainerce'; import { getClient } from '@/core/lib/brainerce'; import { useStoreInfo, useCart, useAuth, useRegion } from '@/core/providers/store-provider'; import { useCurrency } from '@/core/lib/use-currency'; import { CheckoutForm } from '@/components/checkout/checkout-form'; import { ShippingStep } from '@/components/checkout/shipping-step'; import { PaymentStep } from '@/components/checkout/payment-step'; import { DeliveryMethodStep } from '@/components/checkout/delivery-method-step'; import { PickupStep } from '@/components/checkout/pickup-step'; import { CustomFieldsStep } from '@/components/checkout/custom-fields-step'; import { TaxDisplay } from '@/components/checkout/tax-display'; import { OrderBumpCard } from '@/components/checkout/order-bump-card'; import { OrderCustomizations } from '@/components/account/order-customizations'; import { CouponInput } from '@/ui/cart/coupon-input'; import { GiftCardInput } from '@/ui/cart/gift-card-input'; import { ReservationCountdown } from '@/ui/cart/reservation-countdown'; import { LoadingSpinner } from '@/ui/shared/loading-spinner'; import { useTranslations } from '@/core/lib/translations'; import { toAddToCartError, type AddToCartError } from '@/core/lib/add-to-cart-error'; import { cn } from '@/core/lib/utils'; import { isValidCheckoutId } from '@/core/lib/safe-redirect'; import { trackBeginCheckout } from '@/core/lib/tracking'; type CheckoutStep = 'method' | 'address' | 'shipping' | 'pickup' | 'custom-fields' | 'payment'; function CheckoutContent() { const searchParams = useSearchParams(); const { storeInfo } = useStoreInfo(); const { cart, refreshCart } = useCart(); const { isLoggedIn } = useAuth(); // ⛔ THE call the region feature exists for. Product reads alone and the // shopper is SHOWN one price and CHARGED another: the checkout records the // region for reporting and for scoping which payment providers are offered. // `undefined` on a store with no regions, and the field is then omitted. const { regionId } = useRegion(); const currency = useCurrency(); const router = useRouter(); const t = useTranslations('checkout'); const tc = useTranslations('common'); const tr = useTranslations('reservation'); const tp = useTranslations('productDetail'); const [step, setStep] = useState('address'); const [checkout, setCheckout] = useState(null); const [placing, setPlacing] = useState(false); const [placeError, setPlaceError] = useState(null); const [shippingRates, setShippingRates] = useState([]); const [selectedRateId, setSelectedRateId] = useState(null); const [loading, setLoading] = useState(false); const [initializing, setInitializing] = useState(true); const [error, setError] = useState(null); const [destinations, setDestinations] = useState(null); const [pickupLocations, setPickupLocations] = useState([]); const [deliveryType, setDeliveryType] = useState<'shipping' | 'pickup'>('shipping'); const [isAllDigital, setIsAllDigital] = useState(false); const [prefillAddress, setPrefillAddress] = useState(null); const [prefillCustomer, setPrefillCustomer] = useState<{ email: string; firstName?: string; lastName?: string; phone?: string; } | null>(null); const [hasSavedAddress, setHasSavedAddress] = useState(false); const [orderBumps, setOrderBumps] = useState(null); const [addedBumpIds, setAddedBumpIds] = useState>(new Set()); const [bumpLoading, setBumpLoading] = useState(null); const [bumpError, setBumpError] = useState(null); const [customFields, setCustomFields] = useState([]); const [customFieldValues, setCustomFieldValues] = useState>({}); const [customFieldsLoading, setCustomFieldsLoading] = useState(false); // ---- Reservation expiry blocks payment ---- // // When the reservation window runs out the held stock goes back on sale, so // the amount and the line items on this page may no longer be deliverable. // Payment is blocked until the shopper revisits the cart. Both pieces of // state key on the reservation's `expiresAt` rather than a bare boolean, so // the block survives a remount of the countdown and the ref stops the cart // refresh from looping. This page does not refetch the checkout, so the // block stands for the rest of the session once it closes. const reservationExpiresAt = checkout?.reservation?.expiresAt ?? null; const handledExpiryRef = useRef(null); const [expiredWindow, setExpiredWindow] = useState(null); const reservationExpired = expiredWindow !== null && expiredWindow === reservationExpiresAt; const handleReservationExpired = useCallback(() => { if (!reservationExpiresAt) return; setExpiredWindow(reservationExpiresAt); if (handledExpiryRef.current === reservationExpiresAt) return; handledExpiryRef.current = reservationExpiresAt; // Keep the header badge honest: the server may have dropped lines that // are no longer purchasable. void refreshCart(); }, [reservationExpiresAt, refreshCart]); // `begin_checkout` — fired once the cart is loaded, guarded by a ref so a // re-render (or the shopper returning from a canceled payment) doesn't // report a second checkout start for the same cart. const beganCheckoutForCartRef = useRef(null); useEffect(() => { if (!cart?.id || !cart.items?.length) return; if (beganCheckoutForCartRef.current === cart.id) return; beganCheckoutForCartRef.current = cart.id; trackBeginCheckout(cart, currency); }, [cart, currency]); /** * Whether gift cards already cover the whole order. * * Read from `providerAmountDue`, which the SERVER computes — never from a * subtraction done here. The total is deliberately unchanged by a gift card, * so any figure derived in the browser would have to re-implement the tender * arithmetic and could disagree with what the backend will actually charge. */ const isFullyCovered = !!checkout && parseFloat(checkout.providerAmountDue ?? checkout.total) <= 0; const handlePlaceFreeOrder = async () => { if (!checkout || placing) return; try { setPlacing(true); setPlaceError(null); await getClient().completeCheckout(checkout.id); // `checkout_id`, NOT the order id. The confirmation page reads // `checkout_id` and every other caller (payment-step, all three of its // redirect paths) sends that. Sending the order id instead meant the page // found no parameter it recognised, made zero requests, and rendered its // "missing payment information" state over an order that had just been // placed and paid for — with the cart still full. // // The order id `completeCheckout` returns is deliberately not used: the // page resolves the order from the checkout, which is also what makes a // reload of the confirmation URL work. router.push(`/order-confirmation?checkout_id=${encodeURIComponent(checkout.id)}`); } catch (err) { // Said out loud. A silent failure here leaves someone staring at a button // that did nothing, holding a card they believe they have already spent. setPlaceError(err instanceof Error ? err.message : t('placeFailed')); setPlacing(false); } }; // Check for returning from canceled payment const canceled = searchParams.get('canceled') === 'true'; const checkoutIdParam = searchParams.get('checkout_id'); const existingCheckoutId = isValidCheckoutId(checkoutIdParam) ? checkoutIdParam : null; // ---- Partial checkout ---- // // The cart page hands the ticked lines over as `?items=id1,id2` and omits // the param entirely for a full cart. Read as a raw string here and resolved // against the live cart inside initCheckout: it is URL input, so it is only // ever used after filtering against ids the cart actually has, and it is // read once at init rather than added to the effect's deps — a change must // not silently create a SECOND checkout for the same cart. const selectedItemsParam = searchParams.get('items'); // Pre-fill address and customer data from profile when logged in useEffect(() => { if (!isLoggedIn) return; getClient() .getCheckoutPrefillData() .then((data) => { if (data.customer) setPrefillCustomer(data.customer); if (data.shippingAddress) { setPrefillAddress(data.shippingAddress); setHasSavedAddress(true); } }) .catch(() => {}); }, [isLoggedIn]); // Initialize or resume checkout (only once) const checkoutInitRef = useRef(false); const cartIdRef = useRef(null); useEffect(() => { // Only init once, or if cart ID actually changed (e.g. cart was replaced) if (!cart?.id) return; if (checkoutInitRef.current && cartIdRef.current === cart.id) return; checkoutInitRef.current = true; cartIdRef.current = cart.id; const initCheckout = async () => { try { setInitializing(true); setError(null); const client = getClient(); // Fetch shipping destinations and pickup locations in parallel client .getShippingDestinations() .then(setDestinations) .catch(() => {}); const locations = await client.getPickupLocations().catch(() => [] as PickupLocation[]); setPickupLocations(locations); // If returning with existing checkout ID, resume it if (existingCheckoutId) { const existing = await client.getCheckout(existingCheckoutId); setCheckout(existing); // Preload custom field definitions and any existing values so the // step indicator and "change options" affordance work on resume. client .getCheckoutCustomFields(existing.id) .then((fields) => { setCustomFields(fields); const existingValues = ( existing as unknown as { customFieldValues?: Record | null; } ).customFieldValues; if (existingValues) setCustomFieldValues(existingValues); }) .catch(() => { setCustomFields([]); }); // Determine step based on checkout state const allDigital = existing.lineItems.every( (i) => (i.product as unknown as { isDownloadable?: boolean }).isDownloadable ); setIsAllDigital(allDigital); if (allDigital) { // Digital products: show contact info step if email not set, else payment setStep(existing.email ? 'payment' : 'address'); } else if (existing.deliveryType === 'pickup' && existing.pickupLocation) { setDeliveryType('pickup'); setStep('payment'); } else if (existing.shippingAddress && existing.shippingRateId) { setStep('payment'); } else if (existing.shippingAddress) { // Fetch shipping rates const rates = await client.getShippingRates(existing.id); setShippingRates(rates); setStep('shipping'); } else if (locations.length > 0) { setStep('method'); } return; } // Create new checkout — cart is always server-side now. // // `selectedItemIds` scopes the checkout to a subset of the cart: after // payment those lines are removed and the rest stay in the still-ACTIVE // cart. Sent ONLY when the shopper picked a strict, non-empty subset — // an empty result (a stale link, or ids from another cart) and a // full-cart selection both fall through to the normal whole-cart call, // because omitting the field is what "check out everything" means. const cartItemIds = new Set(cart.items.map((item) => item.id)); const requestedItemIds = (selectedItemsParam ?? '') .split(',') .map((id) => id.trim()) .filter((id) => cartItemIds.has(id)); const isStrictSubset = requestedItemIds.length > 0 && requestedItemIds.length < cartItemIds.size; const newCheckout = await client.createCheckout({ cartId: cart.id, ...(isStrictSubset ? { selectedItemIds: requestedItemIds } : {}), ...(regionId ? { regionId } : {}), }); setCheckout(newCheckout); // Put the id in the URL so a RELOAD resumes instead of starting over. // // The resume path above already reads `?checkout_id=`, but until now only // a payment provider's return URL ever set it. A shopper who refreshed // lost their address, their shipping choice, and — visibly — any gift // card they had applied, because a fresh checkout cannot see holds that // belong to the abandoned one. The money was never lost, but it looked // lost, and re-entering the same code was correctly refused because the // old hold still reserved it. // // `replaceState`, not router.replace: this is the same page and the same // checkout, so it must not push history, re-render, or move the scroll // position out from under someone mid-form. if (typeof window !== 'undefined') { const url = new URL(window.location.href); url.searchParams.set('checkout_id', newCheckout.id); window.history.replaceState(window.history.state, '', url); } // If all items are downloadable, skip shipping — show contact info step const allDigital = newCheckout.lineItems.every( (i) => (i.product as unknown as { isDownloadable?: boolean }).isDownloadable ); setIsAllDigital(allDigital); if (allDigital) { setStep('address'); return; } // If pickup locations exist, start with delivery method selection if (locations.length > 0) { setStep('method'); } } catch (err) { const message = err instanceof Error ? err.message : t('failedToInitCheckout'); setError(message); } finally { setInitializing(false); } }; initCheckout(); // `regionId` is a dependency because a checkout is created ONCE per cart: // if the shopper switches region on the cart page and lands here, the // effect has to re-run or the checkout is stamped with the region they // just left. }, [cart?.id, existingCheckoutId, regionId]); // Load order bumps when checkout is available useEffect(() => { if (!checkout?.id || storeInfo?.upsell?.checkoutOrderBumpEnabled === false) { setOrderBumps(null); return; } const client = getClient(); client .getCheckoutBumps(checkout.id) .then((data) => { setOrderBumps(data); // Detect already-added bumps from cart if (cart?.items) { const existingBumpIds = new Set(); for (const item of cart.items) { const meta = item.metadata as Record | undefined; if (meta?.isOrderBump && meta?.orderBumpId) { existingBumpIds.add(meta.orderBumpId as string); } } setAddedBumpIds(existingBumpIds); } }) .catch(() => {}); }, [checkout?.id, storeInfo?.upsell?.checkoutOrderBumpEnabled]); // Handle bump toggle async function handleBumpToggle(bumpId: string, add: boolean, variantId?: string) { if (!cart?.id || bumpLoading) return; setBumpError(null); try { setBumpLoading(bumpId); const client = getClient(); if (add) { await client.addOrderBump(cart.id, bumpId, variantId); setAddedBumpIds((prev) => new Set([...prev, bumpId])); } else { await client.removeOrderBump(cart.id, bumpId); setAddedBumpIds((prev) => { const next = new Set(prev); next.delete(bumpId); return next; }); } await refreshCart(); } catch (err) { // A bump is an extra cart line, so it can be refused by the 50-line cap // like any other add. Logging alone left the checkbox springing back with // no explanation, at the least forgiving point in the flow. setBumpError(toAddToCartError(err)); console.error('Failed to toggle order bump:', err); } finally { setBumpLoading(null); } } // Handle shipping address submission async function handleAddressSubmit( address: SetShippingAddressDto, consent: { acceptsMarketing: boolean; saveDetails: boolean } ) { if (!checkout) return; try { setLoading(true); setError(null); const client = getClient(); if (isAllDigital) { // Digital products: set customer info only, skip shipping const updated = await client.setCheckoutCustomer(checkout.id, { email: address.email, firstName: address.firstName, lastName: address.lastName, phone: address.phone, acceptsMarketing: consent.acceptsMarketing, notes: address.notes, }); setCheckout(updated); setStep('payment'); } else { const response = await client.setShippingAddress(checkout.id, address); setCheckout(response.checkout); setShippingRates(response.rates); setStep('shipping'); } // Update marketing preference for logged-in users if (isLoggedIn) { try { await client.updateMyProfile({ acceptsMarketing: consent.acceptsMarketing }); } catch { // non-critical } } // Save address to profile if checkbox was checked and no existing saved address if (isLoggedIn && consent.saveDetails && !hasSavedAddress && !isAllDigital) { try { await client.addMyAddress({ firstName: address.firstName, lastName: address.lastName, line1: address.line1, line2: address.line2, city: address.city, region: address.region, postalCode: address.postalCode, country: address.country, phone: address.phone, isDefault: true, }); } catch { // non-critical } } } catch (err) { const message = err instanceof Error ? err.message : t('failedToSaveAddress'); setError(message); } finally { setLoading(false); } } // After shipping/pickup is set, decide whether to show the custom-fields step // or jump straight to payment. Returns the next step. async function loadCustomFieldsOrSkip(checkoutId: string): Promise { try { const fields = await getClient().getCheckoutCustomFields(checkoutId); setCustomFields(fields); return fields.length > 0 ? 'custom-fields' : 'payment'; } catch { // If the endpoint isn't available or fails, fall through to payment // rather than blocking the customer. setCustomFields([]); return 'payment'; } } // Handle shipping method selection async function handleShippingSelect(rateId: string) { if (!checkout) return; try { setLoading(true); setError(null); setSelectedRateId(rateId); const client = getClient(); const updated = await client.selectShippingMethod(checkout.id, rateId); setCheckout(updated); setStep(await loadCustomFieldsOrSkip(updated.id)); } catch (err) { const message = err instanceof Error ? err.message : t('failedToSelectShipping'); setError(message); } finally { setLoading(false); } } // Submit custom fields async function handleCustomFieldsApply() { if (!checkout) return; try { setCustomFieldsLoading(true); setError(null); const updated = await getClient().setCheckoutCustomFields(checkout.id, customFieldValues); setCheckout(updated); setStep('payment'); } catch (err) { const message = err instanceof Error ? err.message : t('customFieldsFailed'); setError(message); } finally { setCustomFieldsLoading(false); } } // Handle delivery method selection async function handleDeliveryTypeSelect(method: 'shipping' | 'pickup') { if (!checkout) return; try { setLoading(true); setError(null); setDeliveryType(method); const client = getClient(); await client.setDeliveryType(checkout.id, method); if (method === 'shipping') { setStep('address'); } else { setStep('pickup'); } } catch (err) { const message = err instanceof Error ? err.message : t('failedToSetDeliveryMethod'); setError(message); } finally { setLoading(false); } } // Handle pickup location selection async function handlePickupSelect( locationId: string, customerInfo: { email: string; firstName?: string; lastName?: string; phone?: string } ) { if (!checkout) return; try { setLoading(true); setError(null); const client = getClient(); const updated = await client.selectPickupLocation(checkout.id, { pickupRateId: locationId, email: customerInfo.email, firstName: customerInfo.firstName, lastName: customerInfo.lastName, phone: customerInfo.phone, }); setCheckout(updated); setStep(await loadCustomFieldsOrSkip(updated.id)); } catch (err) { const message = err instanceof Error ? err.message : t('failedToSelectPickup'); setError(message); } finally { setLoading(false); } } // Refresh cart after coupon apply/remove. // The checkout totals are updated server-side by applyCheckoutCoupon/removeCheckoutCoupon, // so we re-fetch the checkout to get the updated discountAmount and total. const handleCouponUpdate = useCallback(async () => { await refreshCart(); if (checkout) { try { const client = getClient(); const updated = await client.getCheckout(checkout.id); setCheckout(updated); } catch (err) { console.error('Failed to refresh checkout after coupon update:', err); } } }, [checkout, refreshCart]); if (initializing) { return (
); } // Empty cart if (!cart || cart.items.length === 0) { return (

{t('emptyCart')}

{t('emptyCartSubtitle')}

{tc('shopNow')}
); } if (error && !checkout) { return (

{t('errorTitle')}

{error}

{t('returnToCart')}
); } const customFieldsStep = customFields.length > 0 ? [{ key: 'custom-fields' as CheckoutStep, label: t('stepCustomFields') }] : []; const steps: { key: CheckoutStep; label: string }[] = isAllDigital ? [ { key: 'address', label: t('stepContactInfo') }, ...customFieldsStep, { key: 'payment', label: t('stepPayment') }, ] : pickupLocations.length > 0 ? deliveryType === 'pickup' ? [ { key: 'method', label: t('stepMethod') }, { key: 'pickup', label: t('stepPickup') }, ...customFieldsStep, { key: 'payment', label: t('stepPayment') }, ] : [ { key: 'method', label: t('stepMethod') }, { key: 'address', label: t('stepAddress') }, { key: 'shipping', label: t('stepShipping') }, ...customFieldsStep, { key: 'payment', label: t('stepPayment') }, ] : [ { key: 'address', label: t('stepAddress') }, { key: 'shipping', label: t('stepShipping') }, ...customFieldsStep, { key: 'payment', label: t('stepPayment') }, ]; const currentStepIndex = steps.findIndex((s) => s.key === step); return (

{t('title')}

{/* Canceled payment banner */} {canceled && (
{t('paymentCanceledBanner')}
)} {/* Reservation countdown. onExpire is what blocks payment below. */} {checkout?.reservation?.hasReservation && ( )} {/* Expired reservation: payment is off the table until the cart is reviewed, so say so on every step, not only on the payment step. */} {reservationExpired && (

{tr('expiredCheckout')}

{tr('backToCart')}
)} {/* Step indicator */}
{steps.map((s, index) => (
{index > 0 && (
)}
{index < currentStepIndex ? ( ) : ( index + 1 )}
{s.label}
))}
{/* Error banner */} {error && checkout && (
{error}
)}
{/* Main content */}
{/* Delivery Method */} {step === 'method' && (

{t('deliveryMethod')}

)} {/* Address */} {step === 'address' && (

{isAllDigital ? t('contactInfo') : t('shippingAddress')}

{!isAllDigital && pickupLocations.length > 0 && ( )}
)} {/* Step 2: Shipping */} {step === 'shipping' && (

{t('shippingMethod')}

)} {/* Pickup */} {step === 'pickup' && (

{t('pickupLocation')}

)} {/* Custom Fields (optional, between shipping/pickup and payment) */} {step === 'custom-fields' && checkout && (

{t('customFieldsTitle')}

setCustomFieldValues((prev) => ({ ...prev, [key]: value })) } onApply={handleCustomFieldsApply} onUploadFile={(file) => getClient().uploadCustomizationFile(file)} timezone={storeInfo?.timezone} loading={customFieldsLoading} />
)} {/* Payment */} {step === 'payment' && checkout && (

{t('payment')}

{customFields.length > 0 ? ( ) : ( !isAllDigital && ( ) )}
{/* Never mount the payment form on an expired reservation: the stock behind these lines is back on sale, so a charge here can take money for something that cannot ship. */} {reservationExpired ? (

{tr('expired')}

{tr('expiredCheckout')}

{tr('backToCart')}
) : isFullyCovered ? ( /* Nothing left for a provider to charge. Gift cards can cover an order completely, and demanding a card for 0.00 is not a formality — most providers reject a zero-amount charge outright, so the shopper is stuck holding a paid-for order they cannot place. The backend already derives this server-side (`isZeroDue`) and completes without a payment, so the only thing missing was a way to ask. */

{t('fullyCovered')}

{t('fullyCoveredHint')}

{placeError && (

{placeError}

)}
) : ( )}
)}
{/* Order summary sidebar */}

{t('orderSummary')}

{/* Line items */} {checkout?.lineItems && checkout.lineItems.length > 0 ? (
{checkout.lineItems.map((item) => { const imageUrl = item.product.images?.[0]?.url || null; const name = item.variant?.name || item.product.name; const lineTotal = parseFloat(item.unitPrice) * item.quantity; return (
{imageUrl ? ( {name} ) : (
)}

{name}

{tc('qty')} {item.quantity}

{formatPrice(lineTotal, { currency }) as string}
{/* The buyer's own input on this line — engraving text, uploaded photo, picked colour — so it can be checked before paying. `CheckoutLineItem.customizations` is the SDK's resolved label/value/type map, absent on a line with no customization, so a plain order renders exactly as before. */} {item.customizations && ( )}
); })}
) : ( // Fallback to cart items if checkout line items aren't loaded yet cart && (

{cart.items.length} {cart.items.length === 1 ? tc('item') : tc('items')}

) )} {/* Order bumps */} {orderBumps?.bumps && orderBumps.bumps.length > 0 && (

{t('addToYourOrder')}

{orderBumps.bumps.map((bump) => ( ))} {/* Why the bump was refused. Without it the checkbox just springs back and the shopper is left guessing at checkout. */} {bumpError && (

{bumpError === 'CART_FULL' ? tp('cartFull') : tp('addToCartFailed')}

)}
)} {/* Coupon input — show from shipping/pickup step onwards (or immediately if digital) */} {cart && (isAllDigital || step === 'shipping' || step === 'pickup' || step === 'payment') && (
{/* Beside the coupon field, and deliberately NOT inside it. A coupon reduces what the order is worth; a gift card pays for an order still worth the same. They look adjacent because a shopper reaches for them at the same moment, and they behave differently because they are different things. */} {checkout?.id && ( formatPrice(parseFloat(value), { currency }) as string } onUpdate={handleCouponUpdate} /> )}
)} {/* Totals */} {checkout && (() => { // Every row in this column is GROSS, and the tax row below is // informational. That is how a VAT receipt reads, it is what // `TaxDisplay` already says on its own line ("VAT 18% included"), // and it is exactly what the order-history summary renders — one // convention across all three, so a shopper who compares the // checkout against the order they later open sees the same // arithmetic twice. // // This deliberately drops an earlier attempt to show a NET // subtotal for inclusive stores. Two things were wrong with it. // It read `taxBreakdown.subtotal`, which is the net of every // TAXED LINE — shipping included — and then added // `shippingAmount` below, counting shipping twice: a real // checkout printed 54.06 + 9.99 + 7.93 beside a 61.99 total. // And a net subtotal cannot coexist with a tax row labelled // "included": either the shopper adds the tax, contradicting the // label, or they do not, and the column falls short of the total // by the whole VAT. Subtracting the shipping net back out fixes // the arithmetic and leaves the contradiction, so the row that // has to go is the special case itself. // // Gross + gross = total, with the VAT stated beneath it: // 52.00 + 9.99 = 61.99, "VAT 18% included 7.93". const displayedSubtotal = parseFloat(checkout.subtotal); const subtotalLabel = tc('subtotal'); return (
{subtotalLabel} {formatPrice(displayedSubtotal, { currency }) as string}
{(() => { const totalDiscount = parseFloat(checkout.discountAmount); const ruleAmt = parseFloat(checkout.ruleDiscountAmount || '0'); const couponAmt = totalDiscount - ruleAmt; const rules = cart?.appliedDiscounts; if (totalDiscount <= 0) return null; return ( <> {rules && rules.length > 0 ? rules.map((rule) => (
{rule.ruleName} - { formatPrice(parseFloat(rule.discountAmount), { currency, }) as string }
)) : ruleAmt > 0 && (
{tc('generalDiscount')} -{formatPrice(ruleAmt, { currency }) as string}
)} {checkout.couponCode && couponAmt > 0 && (
{tc('couponDiscount')} ({checkout.couponCode}) -{formatPrice(couponAmt, { currency }) as string}
)} {!checkout.couponCode && ruleAmt <= 0 && (!rules || rules.length === 0) && (
{tc('discount')} -{formatPrice(totalDiscount, { currency }) as string}
)} ); })()} {(parseFloat(checkout.shippingAmount) > 0 || checkout.deliveryType === 'pickup') && (
{checkout.deliveryType === 'pickup' ? tc('pickup') : tc('shipping')} {parseFloat(checkout.shippingAmount) === 0 ? tc('free') : (formatPrice(parseFloat(checkout.shippingAmount), { currency, }) as string)}
)} {/* Custom field surcharges (one line per applied surcharge) */} {checkout.appliedSurcharges && checkout.appliedSurcharges.length > 0 && ( <> {checkout.appliedSurcharges.map((s) => (
{s.name} {formatPrice(Number(s.amount), { currency }) as string}
))} )}
{tc('total')} {formatPrice(parseFloat(checkout.total), { currency }) as string}
{/* Gift cards sit BELOW the total, not among the discounts above it. The total is what the order is worth and does not move; what changes is only what the card leaves for the payment provider to charge. Putting this in the discount block would understate the taxable base to the shopper and on the receipt. */} {checkout.tenders && checkout.tenders.length > 0 && ( <> {checkout.tenders.map((tender) => (
{tc('giftCard')} - { formatPrice(parseFloat(tender.amountApplied), { currency, }) as string }
))}
{tc('amountDue')} { formatPrice( parseFloat(checkout.providerAmountDue ?? checkout.total), { currency, } ) as string }
)} {/* FX-at-checkout presentment overlay (see SDK `Checkout.presentment` docs): present only when the region's provider settles in its own currency. Every row above is the store's base currency — this is the one number the payment provider actually charges, so it is always shown, never substituted in place of the rows above. */} {checkout.presentment && (
{tc('amountCharged')} ({checkout.presentment.currency}) { formatPrice(parseFloat(checkout.presentment.total), { currency: checkout.presentment.currency, }) as string }
)}
); })()}
); } export default function CheckoutPage() { return (
} > ); }