Documentation Index
Fetch the complete documentation index at: https://docs.glood.ai/llms.txt
Use this file to discover all available pages before exploring further.
The Glood Upsell for PDP and Glood Upsell for Cart theme app blocks ship with a small JS surface designed for easy integration into any Shopify theme — from Dawn out-of-the-box to deeply-customised builds. This guide covers:
- Block placement requirements
- Quick-start integration (zero-code for most themes)
- Auto-listener behaviour
- The
window.Glood.upsell API — 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
/cart page sections, adding Glood Upsell for Cart.
- Save the theme.
The blocks self-load their JS/CSS, auto-listen to standard [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 |
If your theme’s section doesn’t accept app blocks, the Glood entry won’t appear in the Add block picker. Edit the section’s {% 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 }) |
Each call to 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
// Imperative — trigger a re-render with new state. Each field is optional.
window.Glood.upsell.refresh({
variantId: 12345, // override the current variant
qty: 2, // override the main-product quantity
cart: cartSnapshot, // pass a /cart.js response object directly
refetchCart: true, // OR fetch /cart.js fresh
})
// Pub/sub — listen for state transitions.
const unsubscribe = window.Glood.upsell.subscribe('cart-updated', ({ cart }) => {
console.log('cart changed', cart)
})
// later:
unsubscribe()
// Manually publish — useful for instrumentation. Subscribers fire,
// but a publish() alone does NOT re-render (use refresh() for that).
window.Glood.upsell.publish('variant-changed', { variantId: 12345, productId: 67890 })
// Read-only snapshot.
const snapshot = window.Glood.upsell.getState()
// → { variantId, variantSource, productId, qty, qtySource, mode, modeSource, cart, selections }
// Canonical cart-line attribute keys — exposed for custom add-to-cart flows.
window.Glood.upsell.CART_LINE_ATTR
// → { EXTERNAL_ID, ROLE, SLOT_INDEX, TIER_ID, STATUS }
// ─── Linked-mode helpers (see "Linked vs Standalone modes" below) ──────────
// Snapshot of currently-picked pool slots, shaped exactly as Shopify
// /cart/add.js expects. Returns [] when nothing is picked.
window.Glood.upsell.getPendingItems()
// → [{ id, quantity, properties: { __glood_uo_external_id, __glood_uo_role, __glood_uo_slot_index, __glood_uo_status } }, …]
// Two-trip commit — fires /cart/add.js for getPendingItems(), clears
// selections, refreshes cart. Use when the theme can't atomically
// bundle. Returns { ok: true, items } | { ok: false, error }.
await window.Glood.upsell.commitSelections()
// Flip integration mode at runtime — overrides the per-block schema setting.
window.Glood.upsell.setMode('linked') // or 'standalone'
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. |
In deeply-integrated mode the block trusts the theme’s number and computes:
effectiveMax = slotsPerMainQty × explicitQty (capped at maxSlots)
remainingBudget = effectiveMax − pickQtyAlreadyInCart
So if the theme says setQty(5) and the cart already has 3 pick lines for the offer, the panel will show 2 remaining slots.
Explicit setters
// Set the active variant (used for trigger-matching on PDP).
window.Glood.upsell.setVariant(45678)
// Set the TOTAL intended trigger qty (cart + about-to-add combined).
window.Glood.upsell.setQty(3)
// Both at once.
window.Glood.upsell.setContext({ variantId: 45678, qty: 3 })
// Drop back to auto mode (cart becomes the source of truth again).
window.Glood.upsell.resetSource()
All three explicit setters flag the value as theme-owned for the rest of the session (or until 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.
If you mix the two — e.g. call 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 sets data-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:
window.Glood.upsell.setMode('linked') // or 'standalone'
window.Glood.upsell.getState().mode // 'linked'
window.Glood.upsell.getState().modeSource // 'explicit' (vs 'block' when set via schema)
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):
{%- comment -%} Place inside the section that hosts both the product form AND the Glood block. {%- endcomment -%}
<script>
(function () {
// 1. Find the host product's add-to-cart form. Dawn uses `<form action="/cart/add" ...>`.
const form = document.querySelector('form[action*="/cart/add"]')
if (!form) return
form.addEventListener('submit', async (e) => {
// Only intercept if Glood has picks AND we're in linked mode — otherwise let the theme's
// default submit happen (standalone mode handles its own cart-add via the block button).
const api = window.Glood && window.Glood.upsell
if (!api) return
if (api.getState().mode !== 'linked') return
const upsellItems = api.getPendingItems()
if (upsellItems.length === 0) return
e.preventDefault()
// 2. Build the items array: native form data first, then Glood's picks.
const fd = new FormData(form)
const mainItem = {
id: Number(fd.get('id')),
quantity: Math.max(1, Number(fd.get('quantity')) || 1),
}
// Forward any line-item properties the theme adds (e.g. gift-wrap, customisations).
const props = {}
for (const [k, v] of fd.entries()) {
if (k.startsWith('properties[')) {
const key = k.slice('properties['.length, -1)
if (key) props[key] = v
}
}
if (Object.keys(props).length) mainItem.properties = props
const items = [mainItem, ...upsellItems]
// 3. Single POST adds everything atomically.
try {
const res = await fetch('/cart/add.js', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' },
body: JSON.stringify({ items }),
})
if (!res.ok) {
const body = await res.json().catch(() => ({}))
console.warn('[Glood.upsell] linked ATC failed:', body.description || res.status)
// Fall back to the theme's default submit so the customer still gets the host.
form.submit()
return
}
// 4. Tell the block to re-render (clears selections, re-fetches cart).
await api.refresh({ refetchCart: true })
// 5. Fire the theme's own "added" hooks so the cart drawer opens / the toast shows.
document.dispatchEvent(new CustomEvent('cart:updated'))
document.dispatchEvent(new CustomEvent('Rk:Added_To_Cart', { detail: { source: 'glood-upsell-linked' } }))
} catch (err) {
console.error('[Glood.upsell] linked ATC error', err)
form.submit()
}
})
})()
</script>
Notes:
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’s onSubmitHandler), intercept at that layer instead of the form’s submit event. The body of the recipe is identical.
One-shot React/Vue/etc. ATC handler:
async function addToCartWithUpsell({ variantId, quantity, customProperties }) {
const upsellItems = window.Glood?.upsell?.getPendingItems() ?? []
const items = [
{ id: variantId, quantity, properties: customProperties },
...upsellItems,
]
const res = await fetch('/cart/add.js', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' },
body: JSON.stringify({ items }),
})
if (!res.ok) throw new Error((await res.json()).description || res.statusText)
await window.Glood?.upsell?.refresh({ refetchCart: true })
return res.json()
}
Linked mode — the two-trip fallback
If the theme’s Add-to-cart handler doesn’t expose a pre-POST hook, call commitSelections() AFTER the theme’s own ATC completes:
// In a post-ATC `Rk:Added_To_Cart` listener, or wherever your theme signals success:
const result = await window.Glood.upsell.commitSelections()
if (!result.ok) console.warn('[upsell]', result.error)
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:
{%- comment -%}
Custom variant picker that doesn't fire a `change` event on a form input.
Call refresh() directly after your picker's own state change.
{%- endcomment -%}
<script>
document.addEventListener('my-theme:variant-selected', (e) => {
if (window.Glood && window.Glood.upsell) {
window.Glood.upsell.refresh({ variantId: e.detail.variantId })
}
})
</script>
For themes that integrate with the Glood Bundles widget, the existing 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.
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:
// Option A — dispatch a change event so the auto-listener picks it up:
const qtyInput = document.querySelector('input[name="quantity"]')
qtyInput.value = String(newQty)
qtyInput.dispatchEvent(new Event('change', { bubbles: true }))
// Option B — call Glood directly (skips DOM event entirely):
window.Glood.upsell.refresh({ qty: newQty })
Both paths trigger the same internal state update. The slot meter and Add-to-cart button reflect the new qty within one paint.
Reacting to cart updates
The block has three complementary cart-refresh paths:
- Auto — Shopify-native
cart:updated / cart:refresh events.
- Existing Glood bus —
Rk:Added_To_Cart (dispatched by other Glood widgets).
- Manual —
window.Glood.upsell.refresh({ refetchCart: true }).
If your theme’s cart drawer uses the Section Render API to update its own markup, call refresh({ refetchCart: true }) after the section swap so the Glood block re-renders against the new cart state:
async function refreshCartDrawer() {
// Your theme's existing Section Render swap goes here. Pseudocode:
// const html = await fetch('/?section_id=cart-drawer').then(r => r.text())
// ...swap drawer markup using your theme's own helper...
// Re-hydrate the Glood upsell block with the new cart state:
if (window.Glood && window.Glood.upsell) {
window.Glood.upsell.refresh({ refetchCart: true })
}
}
The block doesn’t store cart-line markup itself — re-rendering after a Section Render is cheap.
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:
{
items: [{
id: 12345, // variant id
quantity: 1,
properties: {
__glood_uo_external_id: 'uo_xxxxxxxx', // offer.external_id
__glood_uo_role: 'pick', // 'pick' | 'free_gift'
__glood_uo_slot_index: '0', // pool slot index
__glood_uo_status: 'pending', // Function flips to 'qualified'
},
}]
}
These properties drive the Discount Function’s eligibility check at checkout. Don’t strip them in your theme middleware.
After a successful add, the block dispatches 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 a Custom 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:
/* Override the Glood upsell PDP panel background in a dark theme */
.glood-upsell-panel--pick {
background: linear-gradient(180deg, #2b2640 0%, #1a1729 60%);
border-color: #4d3a73;
color: #fff;
}
.glood-upsell-panel--pick .glood-upsell__gift-card {
background: rgba(255, 255, 255, 0.04);
border-color: rgba(255, 255, 255, 0.12);
}
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:
// 1. Block hydrated? Public API present?
console.log({
mounted: document.querySelectorAll('[data-glood-upsell]').length,
hasApi: !!window.Glood?.upsell,
state: window.Glood?.upsell?.getState(),
})
// 2. Payload reached the block?
const mount = document.querySelector('[data-glood-upsell]')
const payload = JSON.parse(mount.querySelector('script[data-glood-upsell-payload]').textContent)
console.log({
pickOffers: payload.pickOffers.length,
freeGiftOffers: payload.freeGiftOffers.length,
})
// 3. Slot count reacts to qty input (PDP linked mode):
window.Glood.upsell.setMode('linked')
const qtyEl = document.querySelector('[name="quantity"]')
qtyEl.value = '3'; qtyEl.dispatchEvent(new Event('change', { bubbles: true }))
// → check the panel's "You have N add-on gift slots." copy updates within a frame
Health checks:
| 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.variantId is null/NaN. The error pill should already say Reward 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:
[Glood.upsell] render pdp { payload, state, topFreeGift }
Check 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).