Skip to main content

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.
  1. In the Shopify admin → Online Store → Themes → Customize, open a product template.
  2. In the section that should host the upsell (typically Product information below the price/description), click Add block → pick Glood Upsell for PDP.
  3. Repeat in the Cart drawer and the /cart page sections, adding Glood Upsell for Cart.
  4. 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.
BlockWhereRestriction
Glood Upsell for PDPAny product-template sectionenabled_on: { templates: ["product"] }
Glood Upsell for CartCart drawer section AND/OR /cart page sectionNone — 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:
TriggerWhat fires
[name="id"] change on any product formrefresh({ variantId })
[name="quantity"] changerefresh({ qty })
glood-ai:on_bundle_variant_change eventrefresh({ variantId }) (Glood Bundles bus)
Rk:Added_To_Cart eventrefresh({ refetchCart: true })
Shopify-native cart:updated eventrefresh({ cart })
Shopify-native cart:refresh eventrefresh({ 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):
ModeTriggerDriver for slot count
Auto (default)Anything else — DOM auto-listeners, cart events, refresh() without sourcemax(qty input, trigger qty in cart) — so PDP qty bumps AND cart-side qty bumps both grow the slot budget
Deeply integratedTheme 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

EventPayloadWhen
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 byTrigger qty already in the cartPDP qty input + trigger qty in the cart
Block’s own “Add to cart” buttonVisibleHidden (a “Added with your purchase.” caption sits in its place)
Commit pathThe block’s own button fires /cart/add.js for the upsell itemsThe theme’s native Add-to-cart fires for the host product; the upsell items ride along via your code
Best fitTheme exposes a separate upsell section after the product form, OR there’s no native PDP form to integrate withTheme 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.

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:
// 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:
  1. Auto — Shopify-native cart:updated / cart:refresh events.
  2. Existing Glood busRk:Added_To_Cart (dispatched by other Glood widgets).
  3. Manualwindow.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 forIf broken
payload.pickOffers.length > 0 on the PDPTrigger product has no glood.upsell_pick_offers metafield → run yarn commands setup-upsell-shop <shop>
payload.freeGiftOffers.length > 0 on every pageNo 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 outCustomer 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 / bannerCauseFix
Adding…A POST /cart/add.js for this tier is in flightWait — auto-clears on response. If stuck >5s, see Stuck Adding… below
✓ In cartGift line confirmed in /cart.jsn/a — happy path
⚠ Reward variant no longer availableShopify returned “Cannot find variant” or “maximum quantity…” for the variant id we sentVariant 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 itMarket entry has no variantId (or NaN) — the storefront short-circuited before POSTOpen 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 intentExpected. 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).