- Block placement requirements
- Quick-start integration (zero-code for most themes)
- Auto-listener behaviour
- The
window.Glood.upsellAPI —refresh(),setQty(),setVariant(),setContext(),setMode(),getPendingItems(),commitSelections(),subscribe(),publish(),getState() - Auto vs deeply-integrated mode (when to call the explicit setters)
- Linked vs Standalone modes (atomic Add-to-cart bundling)
- Wiring custom variant pickers, quantity inputs, cart-drawer refresh paths
- Verifying the integration end-to-end + an error reference
Quick start
Most themes need zero code. Drag the block into a section, save the theme, you’re done.- In the Shopify admin → Online Store → Themes → Customize, open a product template.
- In the section that should host the upsell (typically Product information below the price/description), click Add block → pick Glood Upsell for PDP.
- Repeat in the Cart drawer and the
/cartpage sections, adding Glood Upsell for Cart. - Save the theme.
[name="id"] / [name="quantity"] change events, and refresh on Rk:Added_To_Cart + Shopify-native cart:updated. Open the PDP, add the trigger product → the panel re-renders with applicable offers.
You only need theme code if the theme’s PDP uses a custom variant/qty picker that doesn’t dispatch standard form change events, OR you want the block linked to the theme’s native Add-to-cart button (single atomic add for host + upsells). Both paths are below.
Block placement
Both blocks are dragged into theme sections via Shopify’s theme editor. They depend on the section accepting theme app blocks — most modern themes do this via a{ "type": "@app" } entry in the section’s schema blocks array. Dawn, Sense, Studio, and other Online Store 2.0 themes work out of the box.
| Block | Where | Restriction |
|---|---|---|
| Glood Upsell for PDP | Any product-template section | enabled_on: { templates: ["product"] } |
| Glood Upsell for Cart | Cart drawer section AND/OR /cart page section | None — merchant drags wherever |
{% schema %} and add { "type": "@app" } to its blocks array.
Auto-listener behaviour
On boot,glood-upsell.js subscribes to a curated set of theme-agnostic events. No theme integration is required for any of these:
| Trigger | What fires |
|---|---|
[name="id"] change on any product form | refresh({ variantId }) |
[name="quantity"] change | refresh({ qty }) |
glood-ai:on_bundle_variant_change event | refresh({ variantId }) (Glood Bundles bus) |
Rk:Added_To_Cart event | refresh({ refetchCart: true }) |
Shopify-native cart:updated event | refresh({ cart }) |
Shopify-native cart:refresh event | refresh({ refetchCart: true }) |
refresh() re-evaluates which offers apply, recomputes the qty-aware slot count, and re-renders all mounted Glood Upsell blocks on the page.
The window.Glood.upsell API
Auto mode vs deeply-integrated mode
The block runs in one of two modes per state field (qty, variantId):
| Mode | Trigger | Driver for slot count |
|---|---|---|
| Auto (default) | Anything else — DOM auto-listeners, cart events, refresh() without source | max(qty input, trigger qty in cart) — so PDP qty bumps AND cart-side qty bumps both grow the slot budget |
| Deeply integrated | Theme calls setQty(), setVariant(), setContext(), or refresh({ ..., source: 'explicit' }) | The explicit value — represents the TOTAL intended trigger qty, including whatever’s already in the cart. Used verbatim even when it’s lower than cart qty. |
setQty(5) and the cart already has 3 pick lines for the offer, the panel will show 2 remaining slots.
Explicit setters
resetSource() / page navigation).
When to use which mode
- Use auto mode when you have a standard
[name="quantity"]input and[name="id"]variant selector. The block picks them up for free, and cart-side qty changes (drawer steppers, line removals) automatically grow / shrink the slot count. - Use deeply-integrated mode when:
- Your custom variant/qty picker doesn’t emit standard DOM events.
- You want the block to treat your number as authoritative even when the cart has a different qty (e.g. a “configure 5, add all at once” flow).
- You’re embedding the block inside a custom configurator that owns its own state machine.
setVariant() once and then let the auto-listener handle [name="quantity"] — only the field you called setVariant() on becomes explicit; the qty stays in auto mode.
Published events
| Event | Payload | When |
|---|---|---|
variant-changed | { variantId, productId, source? } | After refresh({ variantId }) |
qty-changed | { qty, source? } | After refresh({ qty }) |
cart-updated | { cart } | After refresh({ cart }) or { refetchCart: true } succeeds |
offer-applied | { offerExternalId, slotIndices?, source? } | After a pick offer is added to cart via the block’s Add button or commitSelections() |
mode-changed | { mode, source } | After setMode() |
Linked vs Standalone modes
The PDP block ships with two integration modes — pick the one that matches your theme.| Standalone (default) | Linked | |
|---|---|---|
| Slot count driven by | Trigger qty already in the cart | PDP qty input + trigger qty in the cart |
| Block’s own “Add to cart” button | Visible | Hidden (a “Added with your purchase.” caption sits in its place) |
| Commit path | The block’s own button fires /cart/add.js for the upsell items | The theme’s native Add-to-cart fires for the host product; the upsell items ride along via your code |
| Best fit | Theme exposes a separate upsell section after the product form, OR there’s no native PDP form to integrate with | Theme owns a custom configurator/build flow, OR you want a single combined add-to-cart action |
Choosing the mode
Per-block setting (theme editor — preferred). Drag the Glood Upsell for PDP block into a product section, open its settings panel, and pick Integration mode → Linked to the product’s Add to cart. Save. The block setsdata-glood-upsell-mode="linked" on the mount element and the JS picks it up on init.
JS override (runtime). Themes that need to flip dynamically:
Linked mode — the atomic-bundle pattern (preferred)
Bundle the upsell items into the SAME/cart/add.js POST that adds the host product. Single network round-trip; no flicker between the host landing and the upsell items landing.
Minimal recipe — vanilla form intercept (works on Dawn / Sense / most OS 2.0 themes):
getPendingItems()returns{ id, quantity, properties: { __glood_uo_* } }per picked pool slot, clamped to the offer’s remaining slot budget. Empty array when nothing’s picked.- The fallback
form.submit()on error ensures the customer can still complete the purchase even if Glood’s add fails — never block the host product from landing. - If your theme uses a custom AJAX add-to-cart (e.g. Dawn’s
product-form.js’sonSubmitHandler), intercept at that layer instead of the form’ssubmitevent. The body of the recipe is identical.
Linked mode — the two-trip fallback
If the theme’s Add-to-cart handler doesn’t expose a pre-POST hook, callcommitSelections() AFTER the theme’s own ATC completes:
commitSelections() fires its own /cart/add.js for the pending picks, clears state.selections, refetches the cart, fires offer-applied. Cost: the customer sees the host land first, then the upsell items land a moment later (two network trips).
Standalone mode — no theme integration needed
Just drop the block in. The block reads the cart, shows applicable offers when the trigger product is present, and uses its own Add-to-cart button to commit. The host PDP form is left alone. Empty-state copy: when the cart has zero of the offer’s trigger product, the panel collapses to a single “Add this product to your cart to unlock the upsell.” prompt instead of showing 0 selectable slots.When to use which mode
- Standalone when the upsell is a reactive “after-purchase add-on” experience — the customer must first add the host product to the cart, and the upsell section lights up afterwards. Works with literally any theme.
- Linked when the upsell is part of a bundle-style purchase — the customer configures host + accessories in one go and clicks one Add-to-cart button. Requires either picking the mode from the theme editor OR a small JS hook in the theme’s ATC handler.
Wiring native variant selectors
The block auto-listens to[name="id"] changes on any product form, which covers ~95% of themes. For custom variant pickers that don’t update a hidden name="id" input:
glood-ai:on_bundle_variant_change bus is already auto-handled.
Variant-aware pool items
If a pool item’s reward product has multiple variants, the block lazy-loads the variant list from Shopify’s public/products/{handle}.js endpoint when the customer picks that card. The customer can then pick a different variant from the dropdown that appears under the product name.
This works automatically when the canonical payload carries the product’s handle (admin captures it via the App Bridge resource picker). Single-variant products show no dropdown.
Wiring native quantity inputs
The block auto-listens to[name="quantity"] changes anywhere on the page. The PDP block’s slot meter is qty-aware: slotsPerMainQty × qty, capped at maxSlots.
For custom quantity pickers (e.g. a data-action="increase" button on a custom +/− widget), either dispatch a synthetic change event on the native input or call the API directly:
Reacting to cart updates
The block has three complementary cart-refresh paths:- Auto — Shopify-native
cart:updated/cart:refreshevents. - Existing Glood bus —
Rk:Added_To_Cart(dispatched by other Glood widgets). - Manual —
window.Glood.upsell.refresh({ refetchCart: true }).
refresh({ refetchCart: true }) after the section swap so the Glood block re-renders against the new cart state:
Custom add-to-cart flows
If your theme intercepts/cart/add.js POSTs (e.g. for analytics or to inject extra properties), the Glood Upsell add-to-cart uses the same endpoint with these properties:
Rk:Added_To_Cart and refreshes its own cart state. Listen on Rk:Added_To_Cart from your theme to chain additional behaviour.
Custom CSS
Each block has aCustom CSS textarea in its theme-editor settings — content is scoped to that block instance. For repo-wide overrides, target the block’s BEM-scoped classes from your theme’s stylesheet:
Verifying the integration
After placing the block and (optionally) wiring any theme hooks, paste these into the browser console on the PDP to validate end-to-end:| What to look for | If broken |
|---|---|
payload.pickOffers.length > 0 on the PDP | Trigger product has no glood.upsell_pick_offers metafield → run yarn commands setup-upsell-shop <shop> |
payload.freeGiftOffers.length > 0 on every page | No glood metaobject entries → run setup-upsell-shop too |
state.cart is non-null one second after load | /cart.js request failed — usually a CORS or auth header issue in the theme |
| Block visible but Add-to-cart button greyed out | Customer hasn’t selected a pool item — expected; the standalone empty state replaces this when no host is in cart |
Errors and what they mean
Every error surfaces as a pill on the gift card (free-gift) or an inline banner under the pick panel. The exact text → cause → fix mapping:| Pill / banner | Cause | Fix |
|---|---|---|
Adding… | A POST /cart/add.js for this tier is in flight | Wait — auto-clears on response. If stuck >5s, see Stuck Adding… below |
✓ In cart | Gift line confirmed in /cart.js | n/a — happy path |
⚠ Reward variant no longer available | Shopify returned “Cannot find variant” or “maximum quantity…” for the variant id we sent | Variant archived / out of stock. Run yarn commands audit-upsell-offer-variants <shop> to surface, re-pick via admin |
⚠ Reward variant missing — merchant needs to re-pick it | Market entry has no variantId (or NaN) — the storefront short-circuited before POST | Open the offer in admin → re-pick the reward variant on every tier/pool item |
⚠ <other Shopify text> | /cart/add.js returned an unrelated 4xx (e.g. selling-plan-only variant, B2B price list mismatch) | Read the message; usually fixable on the product config |
Empty-state prompt: Add this product to your cart to unlock the upsell. | Standalone mode, trigger product not in cart yet, no PDP qty input intent | Expected. Add the trigger product to the cart and the panel hydrates |
Stuck Adding…
If the pill stays on Adding… indefinitely, check the Network tab for POST /cart/add.js:
- No request fired → the storefront short-circuited before POST. Usually
market.variantIdis null/NaN. The error pill should already sayReward variant missing; if it doesn’t, hard-refresh the page (Cmd+Shift+R) — you may be on a stale extension JS bundle. - Request returned 5xx → Shopify-side hiccup; the block retries on the next cart event automatically.
- Request returned 4xx but pill still says “Adding…” → bug; please send the request body + response to the Glood support channel.
Debugging
glood-upsell.js logs every render with the full payload + state, so production debugging is one console line away:
payload.pickOffers and payload.freeGiftOffers to confirm the block has data; state.cart to confirm the cart snapshot is current.
If you see [Glood.upsell] mount has no payload, the inline <script type="application/json" data-glood-upsell-payload> was missing — usually means the Liquid block failed to render (check for theme-level JSON syntax errors in browser source view).
Related docs
- Upsell Promotions — the feature overview for merchants
- How to reload mini-cart sections using JavaScript — Section Render API pattern