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.