Present for Craft CMS

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.

More in Commerce

Pairs well with Present

For the storefront side of Craft Commerce. Checkout, subscriptions, bundles, loyalty, B2B and the product feeds that bring shoppers in.