> ## 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 Blocks — Theme Integration & JS Hooks

> Wire the Glood Upsell PDP and Cart blocks into custom themes, integrate native variant + quantity selectors, and react to selections via the window.Glood.upsell pub/sub bus.

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.

| 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

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

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

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

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

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

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

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

```js theme={null}
// 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 bus** — `Rk:Added_To_Cart` (dispatched by other Glood widgets).
3. **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:

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

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

```css theme={null}
/* 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:

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

## Related docs

* [Upsell Promotions](/features/upsell-promotions) — the feature overview for merchants
* [How to reload mini-cart sections using JavaScript](/for-developers/how-to-reload-mini-cart-sections-using-javascript) — Section Render API pattern
