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
- Publish a custom-bundle or quantity-break campaign.
- Copy its campaign ID into a section setting or resolve it from the published
sf_index. - Read
shop.metafields.cinch.sfc_<campaignId>.valueand emit it as JSON. - Render only eligible, non-excluded, available items and attach the verified controls.
- Assign the section to an Online Store 2.0 page or product template.
- 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.
{%- 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.pidanditem.o. - Use
item.img,item.bdg,item.rt, anditem.rcas optional display data. - Render
cfg.secin order for a guided build-a-box view. - Use
data-bundle-outvalues orbundle:changefor 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.
<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.