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.
storesynk-mix-match
Section titled “storesynk-mix-match”- 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-levelapp-namespacefor this widget alone.handle : string = "", a direct metaobject lookup for a standalone builder page (nostoresynk-productancestor needed; SSK-102 applies only without it).
- Required ancestor / context:
storesynk-product(SSK-102, unlesshandleis 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 viastoresynk-mix-match[ss-empty] { display: none }.
mix-match-offer
Section titled “mix-match-offer”- 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).
mix-match-section-list
Section titled “mix-match-section-list”- 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
minbecomes 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.
mix-match-section
Section titled “mix-match-section”- Purpose: one section, the styleable wrapper the list stamps state on:
[ss-open]while its panel is expanded,[ss-met]once itsminrequirement is met. - Attributes: none. Required ancestor:
mix-match-section-list(SSK-106).
toggle-section
Section titled “toggle-section”- Purpose: the section’s disclosure header (APG disclosure pattern). The focusable control
(nest a native
<button>; else the host upgrades itself) carriesaria-expanded+aria-controlspointing at the section’smix-match-item-listpanel. Click / Enter / Space dispatchsf:section-toggleup to the list. - Attributes: none. Required ancestor:
mix-match-section(SSK-105).
mix-match-item-list
Section titled “mix-match-item-list”- Purpose: the section’s product pool, and its accordion panel (the engine assigns its
deterministic
idand toggles nativehiddenwhile collapsed, so do not forcedisplayon it unconditionally in CSS). Clones its pool-card template per pool product; each card is a fullstoresynk-productfed viaprovideProduct(), 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 sanctionedstoresynk-productnesting (no SSK-103) with nohandle/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-itemreads the card’s currently selected variant at click time and dispatches anaddpick (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-quantityis 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-itemmirrors the card’s availability ([ss-out-of-stock]/[ss-unavailable],aria-disabledon the control) and gets[ss-max]+aria-disabledwhile the section is full (as doesincrease-mix-match-item);decrease/removearearia-disabledwhile the card is unpicked.
mix-match-selection-list
Section titled “mix-match-selection-list”- 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 withshow-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-overflowrenders “+N” for picks the list’smaxhid (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-hinttakestext(placeholders{remaining}and{discount}; default “Add {remaining} more to unlock {discount}”). Money formats via the store’sformatMoney, always. - Required ancestor / context:
mix-match-offer(SSK-105) +storeContextfor 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>, decorativealt=""); the requirement copy (defaults “Add N items” / “Add up to M items” / “Add N–M items”; atextattribute overrides with{min}/{max}placeholders); the live picked count (hidden at zero); and the progress bar, an ARIA progressbar (aria-valuenow/aria-valuemaxtoward the requirement) that also sets a--ss-progresscustom property from 0 to 1 for the visual fill. - Required ancestor:
mix-match-section(SSK-105).
add-mix-match-to-cart
Section titled “add-mix-match-to-cart”- Purpose: adds the whole selection in ONE multi-line
Shopify.actions.updateCartcall, 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_sectionattributes 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-carton success (the drawer’s open-on-add works);[ss-incomplete]+aria-disableduntil 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">−</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">×</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>CSS state recipes
Section titled “CSS state recipes”/* 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 thestoresynk-productitself). If you wrap the card in a<div>, targetmix-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.