> ## 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.

# Upsell Offers — JS Triggers

> Complete reference for the storefront JS surface — every event the upsell panel publishes, every callback you can register, and the patterns for driving the panel from theme code.

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](/for-developers/upsell-blocks-integration) — 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`).

```js theme={null}
const off = Glood.upsell.registerPickCallback((payload) => {
  // payload below
})

off()                                              // inline unsubscribe
// or:
Glood.upsell.deregisterPickCallback(cb)            // by-reference
```

**Payload shape:**

```js theme={null}
{
  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.

```js theme={null}
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:

```js theme={null}
const off = Glood.upsell.subscribe('variant-changed', ({ variantId, productId }) => {
  console.log('main variant changed:', variantId)
})
off()
```

Useful internal events:

| Event                    | Payload                                  | Fires 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-changed` | same as `registerPickCallback`           | Underlying 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:

```js theme={null}
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.

| Callback                              | Fires 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

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

### `registerSwapCallback` payload

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

### `registerSwapFailedCallback` payload

```js theme={null}
{
  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

```js theme={null}
{
  offerExternalId, offer,
  giftCartLine: { key, productId, variantId, slotIndex, quantity },
}
```

### Hydrate options

`Glood.upsell.miniCart.hydrate(opts)` accepts:

```js theme={null}
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):

```js theme={null}
;(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:

```js theme={null}
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:

```js theme={null}
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

```js theme={null}
// ── 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.

## Related

* [Upsell Offers overview →](/upsell-offers/introduction)
* [Enable the app embed →](/upsell-offers/activate)
* [Block placement + auto-listener behaviour →](/for-developers/upsell-blocks-integration)
