Skip to content

Product add-ons

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.

  • 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, resolved rows, selection, advisory quote). Preselected rows start ticked - in the client’s initial state AND the SSR zero-interaction render.
  • Attributes:
    • namespace : string = "" - overrides the store-level app-namespace for this widget alone.
  • Required ancestor / context: storesynk-product (consumes productContext for 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 + an addon-list).
  • Events / state: catches sf:addon-toggle from its rows (runs the selection reducer + re-provides) and re-keys ticked picks on a row card’s sf:variant-change. Sets [ss-loading] during the fetch and [ss-empty] when no live offer applies (or no namespace) - hide it via storesynk-addons[ss-empty] { display: none }. A market/locale switch re-fetches (prices are market-aware).
  • Purpose: the offer’s customer-facing heading (e.g. “Complete the look”) and subheading.
  • Attributes: none.
  • Required ancestor / context: storesynk-addons (consume addonsContext - SSK-105).
  • State: show-addons-subtitle hides (native hidden + [ss-empty]) when the merchant left it blank.
  • Purpose: the offer’s add-on rows. Clones its row-card template once per resolved row and feeds each clone’s storesynk-product via provideProduct() - 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 (its change-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 the show-addon-* displayers, and stamps ss-selected on ticked cards.
  • Attributes: none.
  • Required ancestor / context: storesynk-addons (consumes addonsContext - 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-card storesynk-product carries no handle/product-id and is the third sanctioned storesynk-product nesting (exempt from SSK-103). Selection updates refresh cards in place (re-cloning would wipe the shopper’s variant picks).
  • 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 an aria-label); a nested <button>/<a> becomes an APG toggle button (aria-pressed); otherwise the host upgrades itself to role="checkbox" + tabindex
    • Enter/Space with aria-checked tracking the row. Preselected rows arrive already ticked.
  • Attributes: none authored.
  • Required ancestor / context: inside the row-card template within addon-list (consumes the per-clone row context - SSK-105) + the card’s productContext (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), with aria-disabled on 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); + storeContext for money.
  • State: show-addon-price hides (native hidden + [ss-empty]) while the card resolves no variant; show-addon-original-price hides 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.

/* 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.