The PDP product-addons widget ("frequently added together" - a checkbox list of add-on
The PDP product-addons widget (“frequently added together” - a checkbox list of add-on
products) renders the ONE live offer the Storesynk Shopify app synced for the current
product: PUBLIC_READ $app:product_addons metaobjects (entry id, title, subtitle, status,
targeting, ordered add-on rows, discount, date window, add-on product references), discovered
through the product’s product_addons metafield (“products”-targeted offers) plus the
shop-level product_addons list (all- and collections-targeted ones, membership-checked
against the product’s own collections), read with the browser-safe public token in one query.
It rides its own on-demand addons.js chunk, loaded when a <storesynk-addons> is on the
page; nothing to configure. Blueprint: build-patterns §2.14.
The overlap rule (one offer per PDP): only status: active offers inside their date window
with at least one resolvable add-on qualify, and the FIRST match by specificity wins -
products > collections > all, ties broken by list order. The widget renders that one offer
or nothing ([ss-empty]).
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 row deltas and the quote are integer-cent math shared with the
app’s Discount Function, which applies the real discount at checkout from the _ss_addons
(app entry id) / _ss_addons_host (host product gid) attributes stamped on every add-on line.
The main product line is deliberately not tagged.
Add flow - there is NO dedicated add-addons button. The page’s standard <add-to-cart>
cooperates: when the first <storesynk-addons> in its own product scope holds a ticked
selection, the click sends ONE multi-line Shopify.actions.updateCart({ lines }) - the
untagged host line (the page’s selected variant, quantity, selling plan) plus one attributed
line per ticked row (the row card’s selected variant × the row’s configured quantity). With
nothing ticked (or no widget) it is the normal single-line add.
storesynk-addons
Section titled “storesynk-addons”- Purpose: the widget root. Queries the offers targeting the current product, decides the
winner (the overlap rule), clones its template once for it, and provides
addonsContext(entry, resolvedrows,selection, advisoryquote). Preselected rows start ticked - in the client’s initial state AND the SSR zero-interaction render. - Attributes:
namespace : string = ""- overrides the store-levelapp-namespacefor this widget alone.
- Required ancestor / context:
storesynk-product(consumesproductContextfor the product id - SSK-102) +storeContext(client, locale,appNamespace). - Children / template rule: TEMPLATE-CLONE - its first element child is the
per-offer template (SSK-107; free-form, typically
show-addons-title+show-addons-subtitle+ anaddon-list). - Events / state: catches
sf:addon-togglefrom its rows (runs the selection reducer + re-provides) and re-keys ticked picks on a row card’ssf:variant-change. Sets[ss-loading]during the fetch and[ss-empty]when no live offer applies (or no namespace) - hide it viastoresynk-addons[ss-empty] { display: none }. A market/locale switch re-fetches (prices are market-aware).
show-addons-title / show-addons-subtitle
Section titled “show-addons-title / show-addons-subtitle”- Purpose: the offer’s customer-facing heading (e.g. “Complete the look”) and subheading.
- Attributes: none.
- Required ancestor / context:
storesynk-addons(consumeaddonsContext- SSK-105). - State:
show-addons-subtitlehides (nativehidden+[ss-empty]) when the merchant left it blank.
addon-list
Section titled “addon-list”- Purpose: the offer’s add-on rows. Clones its row-card template once per resolved row and
feeds each clone’s
storesynk-productviaprovideProduct()- the standard product displayers (show-title,show-price,show-image,product-link,change-option, …) all work inside, exactly as on bundle member cards. A row the merchant restricted to specific variants is fed only those variants (itschange-options offer only allowed combos; exactly one allowed variant renders no selector), and a card whose variant list hit the query cap degrades to default-variant mode with[ss-variants-truncated]. Also attaches a per-clone row context (selected state + the advisory ”+ price” pricing for the card’s currently selected variant) read by<select-addon>and theshow-addon-*displayers, and stampsss-selectedon ticked cards. - Attributes: none.
- Required ancestor / context:
storesynk-addons(consumesaddonsContext- SSK-105). - Children / template rule: TEMPLATE-CLONE - its first element child (a
storesynk-product, or a wrapper containing one) is the row-card template (SSK-107). The row-cardstoresynk-productcarries nohandle/product-idand is the third sanctionedstoresynk-productnesting (exempt from SSK-103). Selection updates refresh cards in place (re-cloning would wipe the shopper’s variant picks).
select-addon
Section titled “select-addon”- Purpose: the row’s checkbox toggle. Native-first: a nested
<input type="checkbox">is the control when present (the recommended markup - pair it with a label or give the input anaria-label); a nested<button>/<a>becomes an APG toggle button (aria-pressed); otherwise the host upgrades itself torole="checkbox"+tabindex- Enter/Space with
aria-checkedtracking the row. Preselected rows arrive already ticked.
- Enter/Space with
- Attributes: none authored.
- Required ancestor / context: inside the row-card template within
addon-list(consumes the per-clone row context - SSK-105) + the card’sproductContext(the pick is built from the card’s currently selected variant, read at click time). - Events: dispatches
sf:addon-toggle(bubbles, composed;detail = { rowId, pick }) up to the widget root. - State it sets:
[ss-out-of-stock]/[ss-unavailable]on itself while the card’s current variant can’t be bought (mutually exclusive), witharia-disabledon the control - ticking ON is blocked then; ticking OFF always works (a ticked row is never disabled).
show-addon-price / show-addon-original-price
Section titled “show-addon-price / show-addon-original-price”- Purpose: the row’s price displayers - what ticking the row adds to the order after
the offer’s discount, rendered as a ”+” delta (unit price × row quantity, e.g. ”+$12.00”);
and the undiscounted delta for a struck-through “was” price. Both are ADVISORY (the app’s
Discount Function prices the lines at checkout) and both format via the store’s
formatMoney. - Attributes: none.
- Required ancestor / context: inside the row-card template within
addon-list(consume the per-clone row context - SSK-105); +storeContextfor money. - State:
show-addon-pricehides (nativehidden+[ss-empty]) while the card resolves no variant;show-addon-original-pricehides the same way when the offer takes nothing off the price (none / free-shipping / 0% - nothing to strike through).
Complete authoring example (checkbox rows under the buy box)
Section titled “Complete authoring example (checkbox rows under the buy box)”<storesynk-addons> <!-- FIRST child = the per-offer template, cloned once for the winning offer. --> <div class="ao"> <header class="ao-head"> <show-addons-title></show-addons-title> <show-addons-subtitle class="ao-sub"></show-addons-subtitle> </header>
<addon-list class="ao-rows"> <!-- FIRST child = the row-card template. NO handle/product-id — it is fed data. --> <storesynk-product class="ao-card"> <select-addon class="ao-tick"> <input type="checkbox" aria-label="Add to order" /> </select-addon> <show-image class="ao-img"><img alt="" /></show-image> <div class="ao-info"> <show-title></show-title> <p class="ao-price"> <show-addon-original-price class="ao-was"></show-addon-original-price> <show-addon-price></show-addon-price> </p> <change-option group="1"><select><option>Choose</option></select></change-option> </div> </storesynk-product> </addon-list> </div></storesynk-addons>The ticked rows ride the page’s own <add-to-cart> - no extra button to author.
CSS state recipes
Section titled “CSS state recipes”/* Nothing live (or no namespace): hide the whole widget. */storesynk-addons[ss-empty], storesynk-addons[ss-loading] { display: none; }
/* Ticked rows: [ss-selected] lands on the row-card CLONE ROOT (here the storesynk-product itself; wrap the card in a <div> and target that instead). */.ao-card { display: flex; gap: 10px; border: 1px solid #e5e5e5; border-radius: 8px; padding: 10px; }.ao-card[ss-selected] { border-color: #111; }
/* Unbuyable rows: the engine blocks ticking ON and says so on the wrapper. */select-addon[ss-out-of-stock] input, select-addon[ss-unavailable] input { opacity: .4; cursor: not-allowed; }
/* Struck "was" price — hidden automatically when the offer has no price cut. */.ao-was { text-decoration: line-through; color: #999; margin-right: 4px; }
/* Truncated variant lists degrade the card to its default variant — say so. */.ao-card[ss-variants-truncated] show-title::after { content: " (more options on the product page)"; font-size: .8em; color: #777; }Imported from the Storesynk skill v0.1.0 (references/component-reference.md). To change this page, change it there.