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 Upsell Offers storefront module (glood-upsell.js) exposes a pub/sub bus and a small imperative API on window.Glood.upsell (PDP surface) and window.Glood.upsell.miniCart (cart-drawer surface). Themes use these to:
  • React to selection / quantity / variant changes the customer makes inside the panel.
  • Mirror picks into a theme-side totals widget.
  • Bundle Glood upsell picks atomically with the theme’s own Add-to-cart POST.
  • Drive the panel programmatically (set qty, set variant, commit picks) from custom UI.
This page covers every register/callback hook on both surfaces, with payload shapes and minimal wiring examples. If you’re integrating for the first time, start with the block placement guide — it covers the auto-listener behaviour every theme inherits for free.

PDP surface — window.Glood.upsell.register*

The PDP panel publishes through a single in-memory bus (the embed’s subscribe / publish). For the common selection-change use case, register through the typed helper instead of subscribing to the raw bus.

registerPickCallback(cb)

Fires on every mutation the customer makes inside a pick panel — checkbox toggle, card click, qty stepper +/−, qty input edit, variant picker change — AND on every equivalent call through the programmatic API (selectPickItem / setPickItemQty / setPickItemVariant).
const off = Glood.upsell.registerPickCallback((payload) => {
  // payload below
})

off()                                              // inline unsubscribe
// or:
Glood.upsell.deregisterPickCallback(cb)            // by-reference
Payload shape:
{
  offerExternalId,
  changeType,             // 'picked' | 'unpicked' | 'qty-changed' | 'variant-changed'
  changedSlotIndex,
  offer,                  // full offer object (same reference the storefront holds)
  selections: [           // CURRENTLY picked slots only (post-change)
    {
      slotIndex,
      variantId,
      productId,
      productTitle,       // hydrated from the Liquid sidecar
      variantTitle,       // empty for Default-Title variants
      quantity,
      pricePerUnit,       // numeric, in offer.currency_code; null when unresolvable
      regularPricePerUnit,// variant's normal price; null when unhydrated
      lineTotal,          // quantity × pricePerUnit; null when pricePerUnit is null
      pricingMode,        // 'flat_price' | 'percent_off' | 'fixed_amount_off' | null
    },
  ],
  totalQuantity,          // sum of selections[i].quantity
  totalPrice,             // sum of selections[i].lineTotal (skips nulls)
  currency,               // offer.currency_code
  triggerVariant: {       // current driving variant — full payload on PDP placements
    id, title, price, compareAtPrice,
    sku, available, options,
    featuredImage,
  } | null,               // null when no variant is resolved
  triggerProduct: {       // PDP host product
    id, title, handle, type,
  } | null,               // null on cart / collection / custom anchors
}
Does NOT fire on:
  • Commit-to-cart (use the existing offer-applied event instead).
  • Cart-driven auto-unpicks — the renderer collapses those silently to avoid flooding subscribers on every paint.
Callbacks that throw are isolated — logged via console.error, won’t break the chain for other subscribers.

registerFreeGiftCallback(cb)

Fires when a free-gift offer’s qualification state changes — tier crossed, variant changed on a multi-variant tier, gift line added/removed.
const off = Glood.upsell.registerFreeGiftCallback((payload) => {
  // ...
})

Glood.upsell.deregisterFreeGiftCallback(cb)        // by-reference
Payload shape mirrors registerPickCallback (offerExternalId, offer, triggerVariant, triggerProduct) plus free-gift-specific fields like the current tier id, the qualifying subtotal, and the chosen variant.

Raw pub/sub — Glood.upsell.subscribe(event, fn)

For lower-level events the typed helpers don’t cover, subscribe to the raw bus:
const off = Glood.upsell.subscribe('variant-changed', ({ variantId, productId }) => {
  console.log('main variant changed:', variantId)
})
off()
Useful internal events:
EventPayloadFires when
variant-changed{ variantId, productId, source }The driving variant changed (form [name="id"] change, programmatic setVariant, or scoped variant change).
qty-changed{ qty, source }Main-product qty changed (form [name="quantity"], programmatic setQty).
cart-updated{ cart }Internal cart state refreshed (NOT the same as Shopify’s DOM cart:updated event — this is the storefront module’s own publish after refresh({ refetchCart: true })).
pick-selection-changedsame as registerPickCallbackUnderlying event the typed register helper subscribes to. Use the helper unless you need raw access.
offer-applied{ offerExternalId, source }Picks committed to cart via commitPickSelections.
free-gift-added{ offerExternalId, tierId, variantId }Auto-add or manual-add of a free-gift line succeeded.
Glood.upsell.publish(event, payload) lets you fire events manually for instrumentation, but does NOT trigger a panel re-render — use Glood.upsell.refresh(...) for that.

Programmatic drivers

Combine these with the register hooks above for fully theme-driven panels:
Glood.upsell.setQty(3)                                 // override main-product qty
Glood.upsell.setVariant(48328565752127)                // override main variant
Glood.upsell.setVariant(48328565752127, { productId: 12345 }) // scoped to one offer
Glood.upsell.refresh({ refetchCart: true })             // re-render after cart mutation
Glood.upsell.selectPickItem(offerExternalId, slotIndex) // toggle a slot pick
Glood.upsell.setPickItemQty(offerExternalId, slotIndex, qty)
Glood.upsell.setPickItemVariant(offerExternalId, slotIndex, variantId)

Mini-cart adapter — Glood.upsell.miniCart.register*

The mini-cart adapter is a separate surface — one marker <div> per cart line, hydrated explicitly by the theme. Its callbacks are namespaced under Glood.upsell.miniCart.* so they don’t compete with the PDP panel’s selection state.
CallbackFires when
registerSelectionChangeCallback(cb)Every pick/unpick/qty stepper/variant edit inside any mini-cart carousel. Use this for live totals.
registerConfirmCallback(cb)Confirm clicked on a TRIGGER-line carousel (first-add path) AND commit succeeded.
registerSwapCallback(cb)Confirm clicked in swap-mode (Reselect → pick replacement) AND the atomic swap committed.
registerSwapFailedCallback(cb)Swap commit failed. Theme owns recovery UI (e.g., toast).
registerReselectCallback(cb)Customer clicked Reselect on a gift line. Fires BEFORE any cart mutation.
All five return an off() unsubscribe handle and have a matching deregister* for by-reference cleanup, exactly like the PDP helpers.

registerSelectionChangeCallback payload

Same shape as Glood.upsell.registerPickCallback — see above. The change events bus is shared (pick-selection-changed) but auto-scoped to mini-cart anchors so PDP listeners aren’t double-fired.

registerConfirmCallback payload

{
  offerExternalId, offer,
  triggerCartLine: { key, productId, variantId, quantity },
  selections: [
    {
      slotIndex, variantId, productId, productTitle, variantTitle,
      quantity, pricePerUnit, regularPricePerUnit, lineTotal, pricingMode,
    },
  ],
  totalQuantity, totalPrice, currency,
}

registerSwapCallback payload

{
  offerExternalId, offer,
  removedCartLine: { key, productId, variantId, slotIndex, quantity },
  addedSelections: [ /* same shape as `selections` above */ ],
  totalQuantity, totalPrice, currency,
}

registerSwapFailedCallback payload

{
  offerExternalId, offer,
  originalCartLine,     // snapshot — what we tried to roll back to
  attemptedSelections,  // what the customer picked but didn't get
  removeError,          // Error from step 1 (or null if step 1 succeeded)
  commitError,          // Error from step 2
  rollbackError,        // Error from rollback /add.js (or null if rollback succeeded)
}

registerReselectCallback payload

{
  offerExternalId, offer,
  giftCartLine: { key, productId, variantId, slotIndex, quantity },
}

Hydrate options

Glood.upsell.miniCart.hydrate(opts) accepts:
Glood.upsell.miniCart.hydrate({
  rootElement,  // DOM scope (default: document) — use for shadow-DOM cart drawers
  text: {       // mini-cart-specific text overrides (ONLY these three keys)
    miniCartReselectLabel: 'Tap to change your free gift',
    miniCartNavPrevAria:   'Previous gift',
    miniCartNavNextAria:   'Next gift',
  },
  classNames: { // CSS class hooks
    root:     'my-theme-upsell-mini',
    confirm:  'btn btn-primary',
    reselect: 'my-reselect-pill',
  },
  swiper: {     // forwarded to `new Swiper(...)` — any Swiper option works
    slidesPerView: 1.3,
    spaceBetween:  10,
    breakpoints: {
      768: { slidesPerView: 2.2 },
    },
  },
})
Reserved Swiper keys (modules, navigation) are stripped — the prev/next buttons are wired by the storefront and can’t be overridden. Text slots that live in the merchant dashboard (panelTitle, ctaAddToCart, etc.) are intentionally NOT accepted via hydrate({ text }) — those flow from the offer’s per-locale Display config so the merchant always controls the wording.

Worked example — live PDP total

Combine the trigger variant × qty (from the theme) with the upsell selections total (from the callback):
;(function () {
  function whenReady(cb) {
    if (window.Glood?.upsell) return cb()
    window.addEventListener('glood:upsell-ready', cb, { once: true })
  }

  whenReady(() => {
    const product = window.glood?.product
    if (!product) return  // not a PDP

    // Normalise to cents — Shopify's PDP JSON puts variant.price in CENTS;
    // the upsell callback's totalPrice is in offer-currency MAJOR units.
    const toCents = (major) => Math.round(Number(major || 0) * 100)
    const fmt     = (cents) => (cents / 100).toFixed(2)
    const variantCents = (vid) => {
      const v = product.variants.find((x) => Number(x.id) === Number(vid))
      return v ? Number(v.price) : 0
    }

    const state = {
      mainVariantId: Glood.upsell.getState().variantId || product.selected_or_first_available_variant?.id,
      mainQty:       Glood.upsell.getState().qty || 1,
      upsellCents:   0,
      currency:      null,
    }

    Glood.upsell.subscribe('variant-changed', ({ variantId }) => {
      state.mainVariantId = Number(variantId) || state.mainVariantId
      render()
    })
    Glood.upsell.subscribe('qty-changed', ({ qty }) => {
      state.mainQty = Math.max(1, Number(qty) || 1)
      render()
    })
    Glood.upsell.registerPickCallback((p) => {
      state.upsellCents = toCents(p.totalPrice)
      state.currency    = p.currency || state.currency
      render()
    })

    function render() {
      const mainCents  = variantCents(state.mainVariantId) * state.mainQty
      const totalCents = mainCents + state.upsellCents
      const mount      = document.querySelector('#my-upsell-total')
      if (!mount) return
      mount.textContent = `Total: ${fmt(totalCents)} ${state.currency || ''}`
    }
    render()
  })
})()
The whenReady guard is essential — app-embed.liquid defers loading the upsell asset, so binding before glood:upsell-ready silently no-ops.

Wiring picks into the theme’s Add-to-cart

If your theme owns the Add-to-cart flow (Dawn-style <product-form>), call Glood.upsell.commitPickSelections({ triggerVariantId }) after your /cart/add.js POST resolves:
fetch(routes.cart_add_url, config)
  .then((res) => res.json())
  .then(async (response) => {
    await Glood.upsell.commitPickSelections({
      triggerVariantId: response.variant_id,
    })
    await Glood.upsell.commitFreeGiftSelections()
    await Glood.upsell.refresh()
    // ...theme's own renderContents / drawer-open code
  })
What this does:
  1. Walks state.selections (the customer’s pending picks from the PDP panel).
  2. Finds the host cart line matching triggerVariantId (the one your /cart/add.js just created/incremented).
  3. Splits the host into quantity=1 cart lines per bundle, each carrying its own __glood_uo_pick_gifts JSON — so the Cart Transform Function emits one bundle per main unit.
  4. Awaits the auto-attach pipeline so the cart is fully split before refresh() resolves. Your next-line navigation (e.g., this.cart.renderContents(response)) sees the final cart state.
commitFreeGiftSelections() does the equivalent for any free-gift selections the customer made on multi-variant tiers (where the variant requires explicit picking).

Theme-side cart-drawer refresh on mini-cart actions

The mini-cart confirm/swap fires /cart/change.js + /cart/add.js directly — the cart line count changes but the theme’s drawer markup doesn’t know about it unless you re-render. Wire it up:
Glood.upsell.miniCart.registerConfirmCallback(async () => {
  await document.querySelector('cart-drawer').refresh()
})
Glood.upsell.miniCart.registerSwapCallback(async () => {
  await document.querySelector('cart-drawer').refresh()
})
cart-drawer.refresh() is theme-specific — the example above is Dawn. For custom drawers, replace with whatever method re-fetches the cart sections and re-paints.

Reference: every register helper

// ── PDP surface ──────────────────────────────────────────────────────────
Glood.upsell.registerPickCallback(cb)
Glood.upsell.deregisterPickCallback(cb)
Glood.upsell.registerFreeGiftCallback(cb)
Glood.upsell.deregisterFreeGiftCallback(cb)

// ── Mini-cart surface ────────────────────────────────────────────────────
Glood.upsell.miniCart.registerSelectionChangeCallback(cb)
Glood.upsell.miniCart.deregisterSelectionChangeCallback(cb)
Glood.upsell.miniCart.registerConfirmCallback(cb)
Glood.upsell.miniCart.deregisterConfirmCallback(cb)
Glood.upsell.miniCart.registerSwapCallback(cb)
Glood.upsell.miniCart.deregisterSwapCallback(cb)
Glood.upsell.miniCart.registerSwapFailedCallback(cb)
Glood.upsell.miniCart.deregisterSwapFailedCallback(cb)
Glood.upsell.miniCart.registerReselectCallback(cb)
Glood.upsell.miniCart.deregisterReselectCallback(cb)

// ── Raw pub/sub (escape hatch) ───────────────────────────────────────────
Glood.upsell.subscribe(event, fn)        // → unsubscribe()
Glood.upsell.publish(event, payload)
Every typed register* returns an off() handle; every deregister* accepts a callback reference. Pick whichever pattern fits your code style — both are first-class.