# Product page: customize add to cart (/themes/product-add-to-cart)

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`](/themes/sdk/reference/product). A theme can redraw every
pixel of the buy box and still call that hook.

## How the product page is composed [#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 [#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:

```json
{ "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`](/themes/sdk/reference/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:

```tsx title="src/blocks/add-to-cart.tsx"
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` [#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               |

> Always add through `useProductBuy().add()`. On the product page it runs the page's own add in
> one place: the cart change, the personalization, gift card and bundle the shopper filled in,
> the pending and error states, and the analytics events. A theme that calls `useCart().add()` on
> the product page skips tracking and bundle logic.

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 [#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     |

```tsx
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 [#a-minimal-custom-buy-box]

Overriding `buy-buttons` replaces the whole row and its children:

```tsx title="src/blocks/buy-buttons.tsx"
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]

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:

```tsx title="src/blocks/variant-picker.tsx"
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 [#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-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 [#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:

```tsx title="src/sections/product-information.tsx"
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 `Slot`s, and the page stays editable in the
Customizer.

## Lighter touches [#lighter-touches]

- **Tokens only**: the default buy box is built on `--primary`, `--card`, `--border`, `--muted`
  and `--radius`. The Customizer's colours and `tokens.css` restyle 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.above` and `product.below` app slots
  above and below the product template, so installed apps add banners or content there without
  the theme placing anything.

## What the platform keeps [#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 [#next]

- [Store forms](/themes/store-forms): render the store's published forms.
- [product reference](/themes/sdk/reference/product): every buy box part and hook.
