Product page: customize add to cart
How the product page's buy box is built from blocks, and how a theme restyles or re-renders the add-to-cart area without touching commerce logic.
On this page
The add-to-cart area on the product page (quantity, the add button, express wallets, the
pre-order line and Notify me) is the buy box. Like the rest of the product page it is built
from blocks. The platform keeps the commerce behaviour behind it (adding to the cart,
analytics events, wallet payments, bundles, error handling) behind one hook, useProductBuy
from @poltura/theme-sdk/product. A theme can redraw every
pixel of the buy box and still call that hook.
How the product page is composed
The product template is one product-information section with three regions, named by
productRegions from @poltura/theme-sdk/product: media (the gallery column), details
(beside the gallery) and below (the full-width area under both). The default theme's
pages/product.json fills them with these blocks, and adds a related-products section under
it:
| Block type | Region | What it renders |
|---|---|---|
product-gallery | media | The image gallery |
breadcrumb | details | Breadcrumb trail |
product-vendor | details | Brand or maker |
product-title | details | Title |
product-rating | details | Review score |
price | details | Price, compare-at price, discount, unit price, VAT note |
variant-picker | details | Option / variant selection |
inventory | details | In stock, running low, sold out, pre-order states |
buy-buttons | details | The buy box: quantity, add-to-cart, wallets, product-wishlist |
delivery-info | details | When it ships and arrives, with each carrier and price |
product-highlights | details | Highlighted features |
product-details | details | Platform, region, coverage |
product-addons | details | Add-on products, each with its own add to cart |
product-tabs | below | Description, specifications and the store's other tabs |
product-safety | below | The manufacturer's details and product safety information |
product-reviews | below | Customer reviews and the average rating |
The delivery box
delivery-info draws the delivery promise of the chosen variant: the order deadline (a countdown
once it is under three hours away, the clock time before), the connected carriers with their day
and price for the visitor's country, the free shipping threshold and a country picker. Everything
comes from the store's delivery time settings, the carrier apps and the product's lead times, so a
theme writes no delivery promise of its own. Its settings are style (compact or card),
show_dispatch, show_carriers, carrier_limit (1 to 6), show_free_shipping, show_country,
an optional instant_label heading and an optional note line under the box. The shipped themes
set only style. Place the block under the buy buttons:
{ "id": "delivery", "type": "delivery-info", "slot": "details", "settings": { "style": "card" } }Merchants reorder, add, hide and edit blocks in the Customizer. The gallery, title, price,
variant picker, buy buttons and product safety blocks are static: a merchant can restyle them
but cannot move or delete them. The variant picker, the buy buttons and product safety cannot be
hidden either, and neither can the add-to-cart button inside the buy buttons. The gallery,
title and price can be hidden.
Themes replace a platform block's markup with overrideBlock from
@poltura/theme-sdk/elements, exported from a file under
src/blocks. The platform keeps the block's schema, inspector and data, and draws the block's
frame (merchant style, anchor, Customizer selection) itself. Settings arrive typed:
import {
overrideBlock,
type ElementRenderProps,
type PlatformBlockSettings,
} from '@poltura/theme-sdk/elements';
import { AddToCartButton, useProductBuy, type ProductBuy } from '@poltura/theme-sdk/product';
type AddToCartSettings = PlatformBlockSettings<'add-to-cart'>;
function labelFor(buy: ProductBuy, settings: AddToCartSettings): string {
const status = (buy.variant ?? buy.product).availability.status;
const custom = buy.isAdding
? settings.adding_label
: status === 'preorder'
? settings.preorder_label
: status === 'comingSoon'
? settings.coming_soon_label
: status === 'unavailable'
? settings.sold_out_label
: settings.label;
return custom?.trim() || buy.ctaLabel;
}
function AddToCart({ settings }: ElementRenderProps<AddToCartSettings>) {
const buy = useProductBuy();
return (
<div className='min-w-0 flex-1'>
<AddToCartButton
label={labelFor(buy, settings)}
busy={buy.isAdding}
disabled={!buy.canAdd || buy.isAdding}
error={buy.error}
fullWidth={settings.full_width}
onAdd={() => void buy.add()}
/>
</div>
);
}
export const addToCart = overrideBlock('add-to-cart', AddToCart);The same pattern applies to quantity, wallets, price, inventory and buy-buttons: one
file per block type under src/blocks, exporting the overrideBlock result.
The buy hook: useProductBuy
Every part of the buy box reads one shared state from useProductBuy(). It works on the
product page, under a product card and inside a ProductScope, and throws anywhere else.
| Field | Meaning |
|---|---|
product | The product being bought |
variant | The chosen variant, or null while the option choice matches none |
quantity, setQuantity(n) | The quantity to add |
canAdd | Whether the chosen variant can be added now; disable the button when false |
isAdding | Pending state for the button |
error | Why the last add failed, in the page language, or null |
add() | The action. Call this, nothing else |
ctaLabel | The button text for the state: add to cart, pre-order, coming soon or sold out |
wallet.visible | Whether WalletButtons has express wallets to show here |
wholesale | A wholesale buyer's tier prices for the chosen variant (WholesaleTiers), null otherwise |
personalization, giftCard | The personalization texts and gift card details on the product page, null elsewhere |
bundle | The bundle offered with the product and the shopper's choice in it, or null |
The SDK ships the parts the platform buy box is built from, all in @poltura/theme-sdk/product:
| Part | What it draws |
|---|---|
AddToCartButton | The add button with the last error under it |
QuantityStepper | Minus, field and plus, keeping a typed amount within the min, step and max you pass |
WalletButtons | Apple Pay, Google Pay and the other express wallets; nothing off the product page |
WholesaleTiers | A wholesale buyer's tier price, list price and quantity breaks |
PersonalizationFields, GiftCardFields | The texts a shopper fills in, and a gift card's amount, recipient and date |
BundleSelector | The bundle offer and its choices |
AvailabilityBadge | "Pre-order", "Coming soon" or "Sold out"; nothing for plain stock states |
NotifyMe | The stock-notice form for a variant that cannot be bought now |
add() adds buy.quantity, so a buy box of your own binds QuantityStepper to buy.quantity
and buy.setQuantity. The SDK does not give a theme the variant's order rule (minimum, step,
maximum), so your stepper enforces only the limits you pass it.
Pre-orders, coming soon and notify me
A variant can be on pre-order: bought now, delivered from an expected date. Its state is
availability on the variant (and, as the best state of all its variants, on the product), a
union keyed by status:
status | Extra fields | purchasable |
|---|---|---|
inStock | quantity (when the store shows stock levels) | always |
backorder | none | always |
preorder | expected, remaining, message | while the pre-order has room |
comingSoon | opensAt, expected | never before orders open |
unavailable | reason: outOfStock, preorderFull, paused, restricted | never |
Every state carries label, the status in the page language. expected.label is the promise
already written for the page ("Q4 2026", "Date to be confirmed"), so a theme never formats the
expected date itself.
| State | Button | Line under the price | Tile badge |
|---|---|---|---|
preorder | "Pre-order", enabled | expected date + message | "Pre-order" |
comingSoon | "Coming soon", disabled | opening date | "Coming soon" |
unavailable (preorderFull) | "Sold out", disabled | "Pre-order sold out" | "Sold out" |
backorder, inStock | unchanged | unchanged | unchanged |
import { useFormat } from '@poltura/theme-sdk/i18n';
import { useProductBuy } from '@poltura/theme-sdk/product';
export function PreorderNote() {
const { variant } = useProductBuy();
const format = useFormat();
const availability = variant?.availability;
if (availability?.status === 'preorder') {
return (
<div className='flex flex-col gap-1 text-sm text-muted-foreground'>
<p>{availability.expected.label}</p>
{availability.message ? <p>{availability.message}</p> : null}
</div>
);
}
if (availability?.status === 'comingSoon') {
return <p className='text-sm text-muted-foreground'>{format.date(availability.opensAt)}</p>;
}
return null;
}Label settings of the buy box blocks (declare per-theme defaults in pages/product.json):
| Block | Setting | Meaning |
|---|---|---|
add-to-cart | preorder_label | Button text of a variant on pre-order |
add-to-cart | coming_soon_label | Disabled button text before pre-orders open |
add-to-cart | sold_out_label | Disabled button text of a variant that cannot be bought |
inventory | backorder_label | Stock line of a variant that ships later |
inventory | show_preorder_remaining | Show "Only N left" (default off; needs the store's "Show remaining") |
Notify me. When the store offers stock notices and the chosen variant cannot be bought yet,
the buy-buttons block draws NotifyMe under the buttons. To place it elsewhere, render
<NotifyMe variantId={variant.id} reason='outOfStock' /> (or comingSoon, preorderFull). It
handles its own request, captcha, consent text and states. useNotifyMe backs a form of your
own, so a theme never calls the stock notice API itself.
On the product page the buy-buttons block also draws the price gate in place of the buttons when
prices are hidden from the visitor, and the pre-order date and terms, so a page whose price or
stock block is hidden still tells the shopper why they cannot buy.
For product tiles, <AvailabilityBadge availability={product.availability} /> renders the
"Pre-order", "Coming soon" and "Sold out" badge, and variant.availability is the full state for a
variant outside the buy box. Read availability, never stock levels: a variant on an open
pre-order can be ordered with no stock, and after release it stays closed until the pre-orders
are fulfilled.
A minimal custom buy box
Overriding buy-buttons replaces the whole row and its children:
import {
overrideBlock,
type ElementRenderProps,
type PlatformBlockSettings,
} from '@poltura/theme-sdk/elements';
import {
AddToCartButton,
NotifyMe,
QuantityStepper,
WalletButtons,
useProductBuy,
type Availability,
type NotifyMeReason,
} from '@poltura/theme-sdk/product';
import { Price, PriceGate, usePriceAccess } from '@poltura/theme-sdk/price';
import { Card, cn } from '@poltura/theme-sdk/ui';
function notifyReason(availability: Availability | null): NotifyMeReason | null {
if (availability?.status === 'comingSoon') return 'comingSoon';
if (availability?.status !== 'unavailable') return null;
if (availability.reason === 'outOfStock' || availability.reason === 'preorderFull') {
return availability.reason;
}
return null;
}
function BuyButtons({ settings }: ElementRenderProps<PlatformBlockSettings<'buy-buttons'>>) {
const buy = useProductBuy();
const access = usePriceAccess();
const reason = notifyReason(buy.variant?.availability ?? null);
if (!access.visible) {
return (
<div className='text-base font-semibold'>
<PriceGate className='underline underline-offset-2' />
</div>
);
}
return (
<Card className='flex flex-col gap-4 rounded-xl p-6'>
<Price price={(buy.variant ?? buy.product).price} className='text-4xl font-black' />
<div className={cn('flex gap-2', settings.layout === 'stacked' ? 'flex-col' : 'flex-row')}>
<QuantityStepper value={buy.quantity} onChange={buy.setQuantity} />
<AddToCartButton
label={buy.ctaLabel}
busy={buy.isAdding}
disabled={!buy.canAdd || buy.isAdding}
error={buy.error}
fullWidth
onAdd={() => void buy.add()}
/>
</div>
{buy.wallet.visible ? <WalletButtons /> : null}
{buy.variant && reason ? <NotifyMe variantId={buy.variant.id} reason={reason} /> : null}
</Card>
);
}
export const buyButtons = overrideBlock('buy-buttons', BuyButtons);A buy-buttons override draws no personalization, gift card or bundle fields unless it places
PersonalizationFields, GiftCardFields and BundleSelector itself. It also replaces the
merchant's quantity, add-to-cart, wallets and product-wishlist blocks inside it. Save in
the code editor to see it in the editor's Preview, and publish to put it live.
The variant picker
The variant picker follows the same pattern under its own type. The platform holds the selection and shares it with every block of the product. Keep no selection state of your own, or the picker, the buy box and the variant analytics event disagree:
import {
overrideBlock,
type ElementRenderProps,
type PlatformBlockSettings,
} from '@poltura/theme-sdk/elements';
import { VariantOption, VariantOptionValue, useVariantPicker } from '@poltura/theme-sdk/product';
function Picker({ settings }: ElementRenderProps<PlatformBlockSettings<'variant-picker'>>) {
const picker = useVariantPicker();
if (picker.options.length === 0) return null;
return (
<div className='flex flex-col gap-4'>
{picker.options.map((option) => (
<VariantOption
key={option.id}
option={option}
display={settings.style === 'dropdown' ? 'dropdown' : option.display}
showTitle={settings.show_option_name}
>
{option.values.map((value) => (
<VariantOptionValue
key={value.id}
value={value}
onSelect={() => picker.select(option.id, value.id)}
/>
))}
</VariantOption>
))}
</div>
);
}
export const variantPicker = overrideBlock('variant-picker', Picker);Each value's selectable says whether choosing it can still lead to a variant that can be
bought, and availability is the state of the variant it completes. Use them for disabled and
dimmed options. <VariantPicker /> is the complete platform picker, for when you only need to
restyle it through classes.
Moving it: regions and the section
Restyling a block changes its pixels. To put the buy box somewhere else entirely, try these in order:
Merchants can already move blocks
Merchants drag blocks between the media, details and below regions in the Customizer's
block tree. The section's settings already swap the columns (gallery_position), set the media
width (media_width) and keep the details column in view (sticky_details). Check whether the
layout you want is a drag away before you write code.
Own the section layout
For a different arrangement, override the product-information section and place the regions
yourself. The default theme does this in src/sections/product-information.tsx. A shorter
version:
import {
Slot,
overrideSection,
type ElementRenderProps,
type PlatformSectionSettings,
} from '@poltura/theme-sdk/elements';
import { BundleSelector, productRegions } from '@poltura/theme-sdk/product';
import { cn } from '@poltura/theme-sdk/ui';
function ProductInformation({
settings,
}: ElementRenderProps<PlatformSectionSettings<'product-information'>>) {
return (
<div className='bg-background'>
<div className='container mx-auto flex flex-col gap-10 px-4 py-8'>
<div className='grid grid-cols-1 gap-10 lg:grid-cols-2'>
<Slot
name={productRegions.media}
className={cn('flex min-w-0 flex-col gap-4', settings.gallery_position === 'right' && 'lg:order-2')}
/>
<Slot name={productRegions.details} className='flex min-w-0 flex-col gap-5' />
</div>
<BundleSelector placement='below' />
<Slot name={productRegions.below} className='flex min-w-0 flex-col gap-8' />
</div>
</div>
);
}
export const productInformation = overrideSection('product-information', ProductInformation);The merchant's blocks keep rendering inside your Slots, and the page stays editable in the
Customizer.
Lighter touches
- Tokens only: the default buy box is built on
--primary,--card,--border,--mutedand--radius. The Customizer's colours andtokens.cssrestyle it without code. - Copy: merchants edit the button labels in the Customizer. A theme changes the translated
defaults by overriding platform text keys in its
texts/<language>.json. - Around the page: the platform draws the
product.aboveandproduct.belowapp slots above and below the product template, so installed apps add banners or content there without the theme placing anything.
What the platform keeps
The cart change behind add(), wallet payments, bundle pricing, availability and the analytics
events stay with the platform. Your block owns the markup, and useProductBuy owns the money
path. That split is what lets a redesigned buy box keep working through platform updates.
Next
- Store forms: render the store's published forms.
- product reference: every buy box part and hook.