Skip to content

Mix & match

The "build your own bundle" widget renders the Mix & Match bundles the Storesynk Shopify app

The “build your own bundle” widget renders the Mix & Match bundles the Storesynk Shopify app synced: PUBLIC_READ $app:mix_match metaobjects (title, status, presentation, sections, discount, date window, section product pools), referenced from each participating product’s mix_match metafield and read with the browser-safe public token. Only active bundles inside their date window render (cap: 5 per product). It rides its own on-demand mix-match.js chunk, loaded when a <storesynk-mix-match> is on the page; nothing to configure.

Two resolution modes: inside a <storesynk-product> the widget reads the page product’s mix_match metafield (the normal PDP placement); with a handle="…" attribute it looks the one metaobject up directly (type app--<id>--mix_match) for a standalone bundle-builder page, with no product ancestor required. Handle mode is client-fetch only in v1 (no SSR fill).

Namespace is required configuration (identical to bundles): app-namespace on <storesynk-store> (recommended) or a per-widget namespace attribute; neither, and the widget stays [ss-empty] (SSK-211).

All prices are advisory. The quote is integer-cent math shared with the app’s Discount Function, which applies the real discount at checkout from the _ss_mm / _ss_mm_group / _ss_mm_section line attributes the add button stamps. A bundle whose fixed_amount / set_price discount was recorded in a different currency than the storefront presents is skipped at render time entirely: never a wrong price.

State flow (one-way): item controls dispatch a single bubbling sf:mix-match-pick (detail = { action: 'add' | 'increase' | 'decrease' | 'remove', sectionId, pick }); <mix-match-offer> runs the pure reducer and re-provides its context. <toggle-section> dispatches sf:section-toggle (detail = { sectionId }), handled by the section list. Nothing below the offer mutates selection state directly.

  • Purpose: the widget root. Resolves the live bundles (product metafield, or one metaobject by handle), clones its template per live bundle, and feeds each clone’s offer via provideLive().
  • Attributes:
    • namespace : string = "", overrides the store-level app-namespace for this widget alone.
    • handle : string = "", a direct metaobject lookup for a standalone builder page (no storesynk-product ancestor needed; SSK-102 applies only without it).
  • Required ancestor / context: storesynk-product (SSK-102, unless handle is set) + storeContext.
  • Children / template rule: TEMPLATE-CLONE; its first element child is the per-bundle template and must contain a mix-match-offer (SSK-107).
  • State: [ss-loading] during the fetch; [ss-empty] when nothing is live (or no namespace). Hide it via storesynk-mix-match[ss-empty] { display: none }.
  • Purpose: one bundle. Owns the customer’s selection (a pure reducer over sf:mix-match-pick) and provides the mix-match context (entry, sections, sectionStates, selection, quote, complete, presentation) to everything inside.
  • Attributes: none. Required ancestor: storesynk-mix-match (SSK-106).
  • State: two bundles on one page select independently (state lives per clone).
  • Purpose: the bundle’s sections. Clones its section template per section, attaches a per-clone section context, and owns the accordion: the first unmet section opens by default; when the open section’s min becomes met it auto-collapses and the next unmet section opens; a manual <toggle-section> overrides that until the next met-transition.
  • Attributes: none. Required ancestor / context: mix-match-offer (SSK-105).
  • Children / template rule: TEMPLATE-CLONE; the first element child must contain a mix-match-section (SSK-107). Selection updates refresh clones in place.
  • Purpose: one section, the styleable wrapper the list stamps state on: [ss-open] while its panel is expanded, [ss-met] once its min requirement is met.
  • Attributes: none. Required ancestor: mix-match-section-list (SSK-106).
  • Purpose: the section’s disclosure header (APG disclosure pattern). The focusable control (nest a native <button>; else the host upgrades itself) carries aria-expanded + aria-controls pointing at the section’s mix-match-item-list panel. Click / Enter / Space dispatch sf:section-toggle up to the list.
  • Attributes: none. Required ancestor: mix-match-section (SSK-105).
  • Purpose: the section’s product pool, and its accordion panel (the engine assigns its deterministic id and toggles native hidden while collapsed, so do not force display on it unconditionally in CSS). Clones its pool-card template per pool product; each card is a full storesynk-product fed via provideProduct(), so the standard displayers AND the variant machinery (change-option, sf:variant-change) work exactly as on bundle member cards, variant truncation included ([ss-variants-truncated]). Each card also gets a per-card item context (the pick for its currently selected variant + the section’s capacity state) and [ss-selected] while picked.
  • Attributes: none. Required ancestor: mix-match-section (SSK-105).
  • Children / template rule: TEMPLATE-CLONE; the first element child must contain a storesynk-product (SSK-107). The pool card is a sanctioned storesynk-product nesting (no SSK-103) with no handle/product-id (no SSK-202).

add-mix-match-item / increase-mix-match-item / decrease-mix-match-item / remove-mix-match-item / show-item-quantity

Section titled “add-mix-match-item / increase-mix-match-item / decrease-mix-match-item / remove-mix-match-item / show-item-quantity”
  • Purpose: the per-card controls. add-mix-match-item reads the card’s currently selected variant at click time and dispatches an add pick (quantity 1). The steppers drive the pick for the card’s current variant: increase (+1, clamped at the section max), decrease (at 1 the pick is removed), remove (delete). show-item-quantity is the stepper readout (hidden + [ss-empty] while unpicked).
  • Attributes: none authored. The steppers are icon-only: the engine deliberately does not self-label them, so you MUST give each an aria-label (on the element or its nested <button>), e.g. aria-label="Increase quantity" (SSK-501).
  • Required ancestor: inside the pool-card template within mix-match-item-list (SSK-105).
  • State: add-mix-match-item mirrors the card’s availability ([ss-out-of-stock] / [ss-unavailable], aria-disabled on the control) and gets [ss-max] + aria-disabled while the section is full (as does increase-mix-match-item); decrease / remove are aria-disabled while the card is unpicked.
  • Purpose: the picked-items strip (summary-bar thumbnails). Clones its first-child pick template per selection entry, each clone with a per-pick context.
  • Attributes: max : number, caps the rendered thumbnails; pair with show-selection-overflow (kept AFTER the template, inside the list) for a “+N” indicator.
  • Required ancestor / context: mix-match-offer (SSK-105). Children / template rule: TEMPLATE-CLONE (first element child; SSK-107).
  • State: [ss-empty] while nothing is picked.

show-pick-image / show-pick-quantity / show-selection-overflow

Section titled “show-pick-image / show-pick-quantity / show-selection-overflow”
  • Purpose: per-pick leaves. The image fills a nested <img> (created when absent, alt = the product title); the quantity renders “×N” (hidden + [ss-empty] at 1); show-selection-overflow renders “+N” for picks the list’s max hid (hidden + [ss-empty] when none).
  • Required ancestor: mix-match-selection-list (SSK-105).

show-mix-match-title / show-mix-match-total / show-mix-match-original-total / show-mix-match-savings / show-mix-match-tier-hint / show-mix-match-free-shipping

Section titled “show-mix-match-title / show-mix-match-total / show-mix-match-original-total / show-mix-match-savings / show-mix-match-tier-hint / show-mix-match-free-shipping”
  • Purpose: the bundle-level summary displayers: the title; the discounted total of the current selection; the pre-discount total (a struck “was” price); the savings; the tiered “add N more” upsell hint; and a free-shipping call-out revealed only while the applied tier is free_shipping (its content is yours, never rewritten).
  • Attributes: show-mix-match-tier-hint takes text (placeholders {remaining} and {discount}; default “Add {remaining} more to unlock {discount}”). Money formats via the store’s formatMoney, always.
  • Required ancestor / context: mix-match-offer (SSK-105) + storeContext for money.
  • State: total/original/savings hide (native hidden + [ss-empty]) while nothing is picked / there are no positive savings; the hint hides in single mode or when no next tier exists.

show-section-title / show-section-image / show-section-requirement / show-section-count / show-section-progress

Section titled “show-section-title / show-section-image / show-section-requirement / show-section-count / show-section-progress”
  • Purpose: per-section leaves: the heading; the section image (fills a nested <img>, decorative alt=""); the requirement copy (defaults “Add N items” / “Add up to M items” / “Add N–M items”; a text attribute overrides with {min} / {max} placeholders); the live picked count (hidden at zero); and the progress bar, an ARIA progressbar (aria-valuenow/aria-valuemax toward the requirement) that also sets a --ss-progress custom property from 0 to 1 for the visual fill.
  • Required ancestor: mix-match-section (SSK-105).
  • Purpose: adds the whole selection in ONE multi-line Shopify.actions.updateCart call, one line per pick stamped with the _ss_mm (app entry id), _ss_mm_group (fresh per-click nonce, so two adds stay distinct groups), and _ss_mm_section attributes the app’s Discount Function groups on. Nest a native <button> (else the host upgrades itself).
  • Required ancestor / context: mix-match-offer (SSK-105).
  • Events / state: emits sf:add-to-cart on success (the drawer’s open-on-add works); [ss-incomplete] + aria-disabled until every section’s count is inside its [min, max]; [ss-loading] during the add.

Complete authoring example (accordion + steppers + summary bar)

Section titled “Complete authoring example (accordion + steppers + summary bar)”
<storesynk-mix-match>
<mix-match-offer>
<header class="mm-head">
<show-mix-match-title></show-mix-match-title>
<show-mix-match-tier-hint class="mm-hint"></show-mix-match-tier-hint>
</header>
<mix-match-section-list>
<mix-match-section>
<toggle-section>
<button type="button">
<show-section-image class="mm-sec-img"></show-section-image>
<show-section-title></show-section-title>
<show-section-requirement class="mm-req"></show-section-requirement>
<show-section-count class="mm-count"></show-section-count>
</button>
</toggle-section>
<show-section-progress class="mm-progress"><i class="mm-progress-fill"></i></show-section-progress>
<mix-match-item-list class="mm-pool">
<storesynk-product>
<div class="mm-card">
<show-image class="mm-card-img"><img alt="" /></show-image>
<show-title class="mm-card-title"></show-title>
<show-price></show-price>
<change-option group="1"><select><option>Choose</option></select></change-option>
<add-mix-match-item class="mm-add"><button type="button">Add</button></add-mix-match-item>
<div class="mm-stepper">
<decrease-mix-match-item><button type="button" aria-label="Decrease quantity">&minus;</button></decrease-mix-match-item>
<show-item-quantity></show-item-quantity>
<increase-mix-match-item><button type="button" aria-label="Increase quantity">+</button></increase-mix-match-item>
<remove-mix-match-item><button type="button" aria-label="Remove item">&times;</button></remove-mix-match-item>
</div>
</div>
</storesynk-product>
</mix-match-item-list>
</mix-match-section>
</mix-match-section-list>
<footer class="mm-summary">
<mix-match-selection-list max="4" class="mm-picks">
<span class="mm-pick">
<show-pick-image></show-pick-image>
<show-pick-quantity class="mm-pick-qty"></show-pick-quantity>
</span>
<show-selection-overflow class="mm-overflow"></show-selection-overflow>
</mix-match-selection-list>
<div class="mm-totals">
<show-mix-match-original-total class="mm-was"></show-mix-match-original-total>
<show-mix-match-total></show-mix-match-total>
</div>
<p class="mm-save">You save <show-mix-match-savings></show-mix-match-savings></p>
<show-mix-match-free-shipping hidden>Free shipping unlocked!</show-mix-match-free-shipping>
<add-mix-match-to-cart><button type="button" aria-label="Add box to cart">Add</button></add-mix-match-to-cart>
</footer>
</mix-match-offer>
</storesynk-mix-match>
/* Nothing live (or no namespace): hide the whole widget. */
storesynk-mix-match[ss-empty] { display: none; }
/* Accordion: rotate the chevron / highlight the open section. The panel's
visibility is native `hidden` on mix-match-item-list; style around it,
never force `display` on the panel unconditionally. */
mix-match-section { border: 1px solid #e5e5e5; border-radius: 8px; }
mix-match-section[ss-open] { border-color: #111; }
mix-match-section toggle-section button::after { content: "\25BE"; margin-left: auto; }
mix-match-section[ss-open] toggle-section button::after { transform: rotate(180deg); }
/* Met sections get a checkmark on the header. */
mix-match-section[ss-met] .mm-count::after { content: " \2713"; color: green; }
/* "+" divider between section cards: author a decorative element inside the
section template and hide it on the first clone. */
mix-match-section:first-of-type .mm-plus { display: none; }
/* Card selection: swap the Add row for the stepper. */
.mm-card .mm-stepper { display: none; }
.mm-card { position: relative; }
storesynk-product[ss-selected] .mm-add,
.mm-card:has(show-item-quantity:not([ss-empty])) .mm-add { display: none; }
mix-match-item-list > [ss-selected] .mm-stepper,
mix-match-item-list > [ss-selected] storesynk-product .mm-stepper { display: flex; }
/* Capacity: dim an Add/+ control the section can no longer accept. */
add-mix-match-item[ss-max], increase-mix-match-item[ss-max] { opacity: .4; }
/* Progress bar driven by the engine's --ss-progress (0..1). */
.mm-progress { display: block; height: 4px; background: #eee; border-radius: 2px; }
.mm-progress .mm-progress-fill {
display: block; height: 100%; background: #111; border-radius: 2px;
width: calc(var(--ss-progress, 0) * 100%); transition: width .2s ease;
}
/* CTA: visibly disabled until the box is complete. */
add-mix-match-to-cart[ss-incomplete] button { opacity: .5; cursor: not-allowed; }
add-mix-match-to-cart[ss-loading] button { opacity: .5; }
/* Summary strip. */
mix-match-selection-list[ss-empty]::before { content: "Nothing picked yet"; color: #999; }
.mm-was { text-decoration: line-through; color: #999; }

Note on ss-selected: the attribute lands on the item-list’s per-card clone root (the pool-card template’s outermost element, here the storesynk-product itself). If you wrap the card in a <div>, target mix-match-item-list > div[ss-selected].


Imported from the Storesynk skill v0.1.0 (references/component-reference.md). To change this page, change it there.