The box builder
A drop-in box builder, in one tag:
{{ craft.present.builder(bundle) }}
That renders pickers for every slot, a live total, live stock, min/max enforcement and an add-to-cart button. It has no dependencies — not jQuery, not the control panel's stack — because it goes out on a public storefront.
Options
{{ craft.present.builder(bundle, {
class: 'my-builder',
id: 'gift-box',
action: 'present/cart/add',
}) }}
Styling
Everything is a flat class name reachable with a single-class selector, so any of it can be replaced
without !important. Colours come from custom properties:
.present-builder {
--present-accent: #b3593f;
--present-border: #e7e2d9;
--present-muted: #7a736a;
--present-radius: 2px;
}
The class names: .present-builder, .present-slot, .present-slot__header, .present-slot__name,
.present-slot__description, .present-slot__rule, .present-slot__options, .present-option
(--chosen, --out), .present-option__image, .present-option__name, .present-option__price,
.present-option__stock, .present-option__stepper, .present-stepper__button,
.present-summary, .present-summary__total, .present-summary__savings,
.present-summary__problems, .present-summary__errors, .present-summary__add.
To ship your own CSS instead, turn off Register the builder's CSS and JS in the settings and
bundle src/web/assets/builder/dist/present-builder.js yourself.
Events
document.addEventListener('present:added', (event) => {
// event.detail is the JSON response: { success, message, cart, bundle }
});
Building your own
The builder reads the same payload the JSON endpoint serves, so a custom front end can read it too:
<div id="builder" data-config="{{ craft.present.configJson(bundle) }}"></div>
or fetch it:
const res = await fetch('/present/bundle/1234');
const bundle = await res.json();
Pass a selection to have the server price and validate it:
/present/bundle/1234?selection[fillings][43]=2
The payload
{
"id": 1234,
"title": "Gift Box",
"pricingMode": "fixed",
"basePrice": 40.0,
"minItems": null,
"maxItems": 6,
"availability": { "isAvailable": true, "maxBuildable": 12, "unlimited": false, "reason": null },
"slots": [
{
"handle": "fillings",
"name": "Fillings",
"qtyMode": "range",
"min": 1, "max": 3,
"allowRepeats": true,
"showPrices": true,
"options": [
{
"id": 43,
"description": "Earl Grey",
"listPrice": 5.0,
"price": 4.0, // slot discount already applied
"discount": 20.0,
"stock": 4, // null means untracked, which is not zero
"inStock": true,
"image": "/…"
}
]
}
],
"configuration": { "isValid": true, "errors": [], "price": 40.0, "savings": 5.0, "items": [ … ] }
}
stock: null means unlimited, and a front end has to be able to tell "plenty" from "we do not
track this".
The endpoint is anonymous and GET-only: it exposes exactly what a shop's own product pages already
do. It sends Cache-Control: no-store, because a cached bundle payload promises boxes that are
already gone.
Nothing the browser computes is trusted
The builder prices a box client-side so the total updates without a round trip, but the server re-validates and re-prices from scratch on every add. A stale page or a tampered payload is rejected there. Both sides read the same payload, which is why they agree.