Custom bundle pages

Your product cards. Cinch's cart contract.

Build the view in Liquid, use the published campaign as display data, and bind controls to the headless engine. You can change the presentation without taking ownership of checkout pricing.

Before writing Liquid

Publish the campaign, then enable the Cinch headless core app embed in the Theme Editor. Draft configs and fixed bundles are not custom-builder page inputs.

Implementation flow

  1. Publish a custom-bundle or quantity-break campaign.
  2. Copy its campaign ID into a section setting or resolve it from the published sf_index.
  3. Read shop.metafields.cinch.sfc_<campaignId>.value and emit it as JSON.
  4. Render only eligible, non-excluded, available items and attach the verified controls.
  5. Assign the section to an Online Store 2.0 page or product template.
  6. If customers can edit merged bundles, emit the Liquid cart snapshot below.

A campaign ID setting is the smallest deterministic setup. Automatic placement resolution is possible through sf_index, but a custom page should not silently choose a different campaign when several entries match.

Minimal custom section

This example is deliberately plain. It reads the public config mirror, applies the published paid-item allowlists, renders one selectable control per display snapshot, adds gift controls, and leaves all state transitions and cart writes to Cinch.

sections/cinch-custom-page.liquid
{%- assign campaign_id = section.settings.campaign_id | strip -%}
{%- assign config_key = 'sfc_' | append: campaign_id -%}
{%- assign cfg = shop.metafields.cinch[config_key].value -%}

{%- if cfg -%}
  {%- if cfg.v == 1 -%}
    <script id="cinch-config-{{ section.id }}" type="application/json">
      {{ cfg | json }}
    </script>

    <section
      data-cinch="cinch-config-{{ section.id }}"
      data-bundle-edit-param="bundle"
      aria-labelledby="cinch-heading-{{ section.id }}"
    >
      <h2 id="cinch-heading-{{ section.id }}">
        {{ section.settings.heading | escape }}
      </h2>

      <p data-bundle-out="message" role="status"></p>

      <div>
        {%- for item in cfg.disp -%}
          {%- liquid
            assign is_eligible = false
            if cfg.ev contains item.id
              assign is_eligible = true
            elsif item.pid
              if cfg.ep contains item.pid
                assign is_eligible = true
              endif
            endif
            if cfg.xv contains item.id
              assign is_eligible = false
            endif
          -%}
          {%- if is_eligible -%}
            {%- if item.av -%}
              <article>
                {%- if item.img -%}
                  <img src="{{ item.img | escape }}" alt="" width="400" height="400">
                {%- endif -%}
                <h3>{{ item.t | escape }}</h3>
                <p>{{ item.pm | money }}</p>
                <button
                  type="button"
                  data-bundle-toggle="{{ item.id }}"
                  {%- if item.pid %} data-bundle-product="{{ item.pid }}"{% endif -%}
                  aria-pressed="false"
                >
                  {{ section.settings.select_label | escape }}
                </button>
                <div>
                  <button type="button" data-bundle-dec="{{ item.id }}" aria-label="Decrease {{ item.t | escape }}">−</button>
                  <output data-bundle-out="qty:{{ item.id }}">0</output>
                  <button type="button" data-bundle-inc="{{ item.id }}" aria-label="Increase {{ item.t | escape }}">+</button>
                </div>
              </article>
            {%- endif -%}
          {%- endif -%}
        {%- endfor -%}
      </div>

      <fieldset>
        <legend>{{ section.settings.gift_label | escape }}</legend>
        {%- for item in cfg.disp -%}
          {%- assign is_gift = false -%}
          {%- for tier in cfg.t -%}
            {%- if tier.gv contains item.id -%}
              {%- assign is_gift = true -%}
              {%- break -%}
            {%- endif -%}
          {%- endfor -%}
          {%- if is_gift -%}
            {%- if item.av -%}
              <label>
                <input type="checkbox" data-bundle-gift="{{ item.id }}">
                <span>{{ item.t | escape }}</span>
              </label>
            {%- endif -%}
          {%- endif -%}
        {%- endfor -%}
        <button type="button" data-bundle-gift-reset>
          {{ section.settings.gift_reset_label | escape }}
        </button>
      </fieldset>

      <p>
        <span data-bundle-out="count">0</span>
        {{ section.settings.items_label | escape }}
      </p>
      <button type="button" data-bundle-submit>
        {{ section.settings.submit_label | escape }}
      </button>
      <p data-bundle-out="error" role="alert"></p>
    </section>
  {%- endif -%}
{%- endif -%}

{% schema %}
{
  "name": "Cinch custom bundle",
  "settings": [
    { "type": "text", "id": "campaign_id", "label": "Campaign ID" },
    { "type": "text", "id": "heading", "label": "Heading", "default": "Build your bundle" },
    { "type": "text", "id": "select_label", "label": "Select label", "default": "Select" },
    { "type": "text", "id": "gift_label", "label": "Gift heading", "default": "Choose your gifts" },
    { "type": "text", "id": "gift_reset_label", "label": "Gift reset label", "default": "Reset gifts" },
    { "type": "text", "id": "items_label", "label": "Items label", "default": "items selected" },
    { "type": "text", "id": "submit_label", "label": "Submit label", "default": "Add bundle to cart" }
  ],
  "presets": [{ "name": "Cinch custom bundle" }]
}
{% endschema %}

What to style or replace

  • Group sibling variants into one product card using item.pid and item.o.
  • Use item.img, item.bdg, item.rt, and item.rc as optional display data.
  • Render cfg.sec in order for a guided build-a-box view.
  • Use data-bundle-out values or bundle:change for a custom summary.
  • Keep unavailable variants out of clickable controls, even though checkout still validates them.

You may loop a live collection instead of cfg.disp when you need full product objects. Still intersect every variant with ev, ep, and xv, and keep the numeric IDs on the controls.

Gift behavior

Gift IDs come from each step's gv array. The engine chooses the current step, applies preselection from pre, and enforces gm,g0, g1, chg, and few. A disabled checkbox is state, not proof that a gift is out of stock.

Do not offer duplicate gift quantities. The current storefront state stores unique gift variant IDs and submits each selected gift with quantity one.

Support cart editing

The example container uses data-bundle-edit-param="bundle". A cart link can send the line's _cinch_bundle_id back to this page through?bundle=.... Use the page object's localized URL and applyurl_encode to the ID.

For merged bundles, AJAX /cart.js exposes the parent but not its components. Liquid does expose line.item_components, so emit this snapshot on the editing page.

Merged-bundle edit snapshot
<script type="application/json" data-bundle-cart>
  [
  {%- assign first = true -%}
  {%- for line in cart.items -%}
    {%- if line.item_components.size == 0 -%}{%- continue -%}{%- endif -%}
    {%- assign bundle_id = line.properties._cinch_bundle_id -%}
    {%- if bundle_id == blank -%}{%- continue -%}{%- endif -%}
    {%- unless first -%},{%- endunless -%}
    {%- assign first = false -%}
    {
      "bundleId": {{ bundle_id | json }},
      "campaignId": {{ line.properties._cinch_campaign_id | json }},
      "key": {{ line.key | json }},
      "components": [
        {%- for component in line.item_components -%}
          {
            "variantId": {{ component.variant.id | json }},
            "productId": {{ component.product.id | json }},
            "quantity": {{ component.quantity | json }},
            "role": {{ component.properties._cinch_role | default: '' | json }}
          }
          {%- unless forloop.last -%},{%- endunless -%}
        {%- endfor -%}
      ]
    }
  {%- endfor -%}
  ]
</script>

When the snapshot matches the page's campaign, Cinch replays paid quantities and gifts through current limits. The bundle:prefill event reports adropped count for units that can no longer be carried over.

Keep the cart boundary intact

Do not call /cart/add.js for a bundle yourself and do not create_cinch_* properties. The engine makes one atomic add and stamps the checksum and role data the Function validates.

A custom display can under-offer or block a valid selection if its UI is wrong. It cannot safely broaden eligibility or grant a price. Invalid hints receive regular pricing at checkout.

Test before assigning the page

  • Zero items, the first valid step, the highest step, and the 99-unit line cap.
  • Every product option move, including a destination already holding quantity.
  • Optional, required, preselected, locked, unavailable, and reset gift states.
  • Every build-a-box section minimum and maximum in source order.
  • Cart add success/error events and the theme's cart drawer refresh.
  • Edit prefill, dropped items, removal failure, replacement failure, and best-effort restore.
  • Presentment-currency display while remembering checkout remains authoritative.
  • Theme Editor section reloads; the engine remounts on shopify:section:load.