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

# Deep theme integration

> Wire the PDP upsell panel and the mini-cart carousel into a Shopify default-theme fork (Dawn / Sense / Studio). Covers deferred-render mode, variant-change re-anchoring, atomic add-to-cart commit, and mini-cart hydrate.

This guide walks through the **deep integration** path — appropriate when:

* Your PDP uses a custom variant picker that doesn't dispatch standard `[name="id"]` change events, OR
* You want the upsell section to render INSIDE the variant-specific markup (per-variant anchor), OR
* You want the upsell picks bundled atomically with the theme's own Add-to-cart POST instead of fired as a separate `/cart/add.js`.

The path described here is what most Dawn-forked themes need. If your theme works out of the box with the default block placement, see the [zero-code activation guide](/upsell-offers/activate) first — you may not need any of this.

## Overview — seven steps

1. **Theme editor** — drop the PDP upsell block, set Mode = `linked`, Render mode = `deferred`. The block doesn't paint until your theme code asks it to.
2. **PDP markup** — drop an anchor element next to the variant you want the panel under. The `variant-id="..."` attribute on the anchor is what `renderInto` targets.
3. **`assets/global.js`** — inside `VariantSelects.onVariantChange`, call `Glood.upsell.renderInto(...)` so the panel re-mounts on every variant switch.
4. **`assets/product-form.js`** — inside `ProductForm.onSubmitHandler`, await `Glood.upsell.commitPickSelections({ triggerVariantId })` and `commitFreeGiftSelections()` BEFORE the cart sections re-render. The picks ride along on the same Add-to-cart click.
5. **`snippets/cart-drawer.liquid`** — drop the `data-glood-mini-cart-line` marker `<div>` under each cart item. Required for the carousel / Reselect CTA to render in the mini-cart.
6. **`assets/cart-drawer.js`** — call `Glood.upsell.miniCart.hydrate()` on every cart-rendered event AND register confirm/swap callbacks that re-fetch the cart sections.
7. **App embed JS** — paste a small init script into the app embed's *Custom JS* setting. It boots the upsell on initial page load, registers the cart-drawer refresh callbacks, and re-hydrates on every theme `cart-update` event so the integration "just works" without further theme changes.

## Step 1 — Theme editor: PDP block, deferred + linked

1. In the Shopify admin → **Online Store → Themes → Customize**, open a product template.
2. In the *Product information* section, click **Add block** → **Glood Upsell for PDP**.
3. With the block selected, set:
   * **Mode**: `linked` — picks are written to `state.selections` but NOT auto-committed; the theme's Add-to-cart commits them atomically.
   * **Render mode**: `deferred` — the block renders hidden and waits for `Glood.upsell.renderInto(...)` to mount it into a theme anchor. Without this the block paints into its default position immediately and `renderInto` has nothing to relocate.
4. Save.

<Note>
  Deferred render + linked mode is a one-time configuration per block. You only need to do this once per theme — the settings persist in `settings_data.json`.
</Note>

## Step 2 — PDP markup: per-variant anchor

Inside the product template (or wherever you want the panel to land per variant), drop an anchor element with `variant-id="{{ variant.id }}"`. The Glood JS uses the selector to mount the deferred block:

```liquid theme={null}
{%- for variant in product.variants -%}
  <div variant-id="{{ variant.id }}" class="glood-upsell-variant-container">
    {%- comment -%}
      Empty container — Glood.upsell.renderInto moves the deferred block in
      here when the customer selects this variant. Hidden until the variant
      is active (we toggle data-active in JS in step 3).
    {%- endcomment -%}
  </div>
{%- endfor -%}
```

You can also use a single anchor and re-target it on every variant change — same code path, simpler markup:

```liquid theme={null}
<div id="my-upsell-anchor" class="glood-upsell-variant-container"></div>
```

The choice is yours. Per-variant anchors let you style each variant's container independently; a single anchor is simpler and re-paints on every switch.

## Step 3 — `assets/global.js`: re-anchor on variant change

Inside Dawn's `VariantSelects.onVariantChange` callback, call `Glood.upsell.renderInto(selector, { variantId })` whenever the customer picks a different variant:

```js theme={null}
onVariantChange(event) {
  this.updateOptions();
  this.updateMasterId();
  this.updateSelectedSwatchValue(event);
  this.toggleAddButton(true, '', false);
  
  try {
    this.updatePickupAvailability();
  } catch (error) {
    console.warn('Error updating pickup availability:', error);
  }

  try {
    this.removeErrorMessage();
  } catch (error) {
    console.warn('Error removing error message:', error);
  }

  this.updateVariantStatuses();

  if (!this.currentVariant) {
    this.toggleAddButton(true, '', true);
    this.setUnavailable();
    this.updateDynamicCheckoutVisibility();
  } else {
    this.updateMedia();
    this.updateURL();
    this.updateVariantInput();
    this.renderProductInfo();
    this.updateShareUrl();
    this.updateDynamicCheckoutVisibility();
  }

  // ── Glood upsell — re-anchor the deferred block on variant change ──
  if (window.Glood && Glood?.upsell?.renderInto) {
    Glood.upsell.renderInto(
      `[variant-id="${this.currentVariant.id}"]`,
      { variantId: this.currentVariant.id }
    );

    // Toggle data-active on the per-variant anchors so your CSS can hide
    // the inactive ones (e.g. .glood-upsell-variant-container { display: none }
    // .glood-upsell-variant-container[data-active] { display: block }).
    document.querySelectorAll('[variant-id]').forEach((elem) => {
      elem.removeAttribute('data-active');
    });
    document
      .querySelector(`[variant-id="${this.currentVariant.id}"]`)
      ?.setAttribute('data-active', '1');
  }
}
```

### What `renderInto` does

```js theme={null}
Glood.upsell.renderInto(
  selectorOrElement,   // CSS selector string OR a DOM element
  { variantId }        // the main product variant id
)
```

* **First arg** — selector (e.g. `[variant-id="48328565752127"]`) OR a direct DOM element reference. Both work.
* **`variantId`** — the variant the panel should scope its budget/trigger against. Without it the panel falls back to whatever `state.variantId` holds (which may lag the customer's click by one tick on slow variant pickers).

Idempotent — calling `renderInto` again on the same anchor with the same variant id is a no-op. Calling it with a DIFFERENT anchor moves the mounted node (useful for SPA navigations).

## Step 4 — `assets/product-form.js`: atomic commit on Add-to-cart

Inside `ProductForm.onSubmitHandler`, after the theme's `/cart/add.js` POST resolves, await the upsell commits BEFORE the cart-drawer re-renders. The picks ride along on the same click and the customer sees one atomic update:

```js theme={null}
onSubmitHandler(evt) {
  evt.preventDefault();
  if (this.submitButton.getAttribute('aria-disabled') === 'true') return;

  this.handleErrorMessage();

  this.submitButton.setAttribute('aria-disabled', true);
  this.submitButton.classList.add('loading');
  this.querySelector('.loading-overlay__spinner').classList.remove('hidden');

  const config = fetchConfig('javascript');
  config.headers['X-Requested-With'] = 'XMLHttpRequest';
  delete config.headers['Content-Type'];

  const formData = new FormData(this.form);
  if (this.cart) {
    formData.append('sections', this.cart.getSectionsToRender().map((section) => section.id));
    formData.append('sections_url', window.location.pathname);
    this.cart.setActiveElement(document.activeElement);
  }

  // Selling plan support (theme-specific — Dawn variant).
  const sellingPlanId = window.getCurrentSellingPlanId();
  formData.append('selling_plan', sellingPlanId);

  config.body = formData;

  fetch(`${routes.cart_add_url}`, config)
    .then((response) => response.json())
    .then(async (response) => {
      // ── Glood upsell — commit picks + free-gift selections BEFORE
      //    the cart sections re-render so the drawer paints with the
      //    bundled lines already attached. ───────────────────────────
      await Glood.upsell.commitPickSelections({
        triggerVariantId: response.variant_id,
      });
      await Glood.upsell.commitFreeGiftSelections();
      await Glood.upsell.refresh();

      // After the upsell commits, fetch fresh sections so the drawer
      // includes the new bundled lines in its next paint.
      if (this.cart && !response.status) {
        const sectionIds = this.cart.getSectionsToRender().map((s) => s.id);
        try {
          const sectionsResponse = await fetch(
            `${routes.cart_url}?sections=${sectionIds.join(',')}&sections_url=${encodeURIComponent(window.location.pathname)}`
          );
          const freshSections = await sectionsResponse.json();
          response.sections = freshSections;
        } catch (err) {
          console.error('Failed to refresh cart sections after upsell commit', err);
        }
      }

      if (response.status) {
        publish(PUB_SUB_EVENTS.cartError, {
          source: 'product-form',
          productVariantId: formData.get('id'),
          errors: response.description,
          message: response.message,
        });
        this.handleErrorMessage(response.description);

        const soldOutMessage = this.submitButton.querySelector('.sold-out-message');
        if (!soldOutMessage) return;
        this.submitButton.setAttribute('aria-disabled', true);
        this.submitButton.querySelector('span').classList.add('hidden');
        soldOutMessage.classList.remove('hidden');
        this.error = true;
        return;
      } else if (!this.cart) {
        window.location = window.routes.cart_url;
        return;
      }

      if (!this.error) {
        publish(PUB_SUB_EVENTS.cartUpdate, {
          source: 'product-form',
          productVariantId: formData.get('id'),
        });
      }
      this.error = false;

      const quickAddModal = this.closest('quick-add-modal');
      if (quickAddModal) {
        document.body.addEventListener('modalClosed', () => {
          setTimeout(() => { this.cart.renderContents(response); });
        }, { once: true });
        quickAddModal.hide(true);
      } else {
        this.cart.renderContents(response);
      }
    })
    .catch((e) => { console.error(e); })
    .finally(() => {
      this.submitButton.classList.remove('loading');
      if (this.cart && this.cart.classList.contains('is-empty')) {
        this.cart.classList.remove('is-empty');
      }
      if (!this.error) this.submitButton.removeAttribute('aria-disabled');
      this.querySelector('.loading-overlay__spinner').classList.add('hidden');
    });
}
```

### Why the order matters

```
/cart/add.js POSTs main qty=N
              ↓
commitPickSelections({ triggerVariantId })  ← refetches /cart.js,
              ↓                                splits qty=N into qty=1
commitFreeGiftSelections()                     bundles, attaches gifts JSON
              ↓
refresh()                                    ← awaits autoAttach so the
              ↓                                split is fully landed
re-fetch cart sections                       ← drawer paints with the
              ↓                                bundled state
renderContents(response)
```

If you fetched the sections BEFORE the upsell commits, the drawer would paint the host line at `quantity=N` with no gifts attached — and a fraction of a second later the cart would re-shape to N qty=1 bundles. Customer sees a flicker. Always await the commits first.

The `await Glood.upsell.refresh()` is load-bearing — it awaits the auto-attach pipeline so the multi-qty split has fully landed before the next-line code reads the cart. Without it, a fast click-to-checkout could race the split.

## Step 5 — `snippets/cart-drawer.liquid`: marker div per line

For the mini-cart carousel and Reselect CTA to render, drop the `data-glood-mini-cart-line` marker `<div>` once per cart line:

```liquid theme={null}
{%- if cart != empty -%}
  <div class="drawer__cart-items-wrapper">
    <div class="cart-items" role="table">
      <div role="rowgroup">
        {%- for item in cart.items -%}
          <div id="CartDrawer-Item-{{ item.index | plus: 1 }}" class="cart-item" role="row">
            {%- comment -%} ...existing cart-item markup... {%- endcomment -%}

            {%- comment -%}
              Glood mini-cart marker — required for the upsell carousel /
              Reselect CTA to render on this line. Must be inside the
              cart-item wrapper, after the product info.
            {%- endcomment -%}
            <div
              data-glood-mini-cart-line
              data-line-key="{{ item.key }}"
              data-line-product-id="{{ item.product_id }}"
              data-line-variant-id="{{ item.variant_id }}"
              data-line-qty="{{ item.quantity }}"
              data-line-properties-json="{{ item.properties | json | escape }}"
            ></div>
          </div>
        {%- endfor -%}
      </div>
    </div>
  </div>
{%- endif -%}
```

### Attribute contract

| Attribute                   | Source                              | Purpose                                                                                                                |
| --------------------------- | ----------------------------------- | ---------------------------------------------------------------------------------------------------------------------- |
| `data-line-key`             | `item.key`                          | Stable Shopify cart-line identifier — scopes swap-mode state.                                                          |
| `data-line-product-id`      | `item.product_id`                   | Matched against product-mode offer triggers.                                                                           |
| `data-line-variant-id`      | `item.variant_id`                   | Matched against variant-mode offer triggers.                                                                           |
| `data-line-qty`             | `item.quantity`                     | Drives slot budget when the line is a trigger product.                                                                 |
| `data-line-properties-json` | `item.properties \| json \| escape` | JSON of the line's properties. The `\| escape` is load-bearing — JSON inside an HTML attribute MUST be entity-encoded. |

<Note>
  Dawn-style themes (and many others) strip properties whose key starts with `_` from `item.properties` before they reach the rendered DOM. That's why the storefront also reads `pick_gifts` JSON from the live `/cart.js` response (keyed by `data-line-key`) — the anchor's `data-line-properties-json` may be incomplete but the carousel still hydrates correctly.
</Note>

## Step 6 — `assets/cart-drawer.js`: hydrate + refresh on commit/swap

The mini-cart adapter is theme-driven — Glood doesn't auto-rehydrate on cart events because re-rendering at the wrong moment can corrupt theme animations. The theme calls `hydrate()` on every cart-rendered event it knows about.

### Add a `refresh()` method to `CartDrawer`

For the mini-cart confirm/swap callbacks to re-render the drawer, the drawer needs an idempotent refresh method:

```js theme={null}
class CartDrawer extends HTMLElement {
  // ... existing methods ...

  async refresh({ open = false } = {}) {
    const sectionIds = this.getSectionsToRender().map((s) => s.id);
    const response = await fetch(
      `${routes.cart_url}?sections=${sectionIds.join(',')}&sections_url=${encodeURIComponent(window.location.pathname)}`
    );
    const sections = await response.json();

    this.getSectionsToRender().forEach((section) => {
      const sectionElement = section.selector
        ? document.querySelector(section.selector)
        : document.getElementById(section.id);
      if (!sectionElement || !sections[section.id]) return;
      sectionElement.innerHTML = this.getSectionInnerHTML(sections[section.id], section.selector);
    });

    // Re-derive emptiness from the just-rendered DOM.
    this.classList.toggle('is-empty', !this.querySelector('.cart-item'));

    // Overlay was just replaced — rebind always.
    this.querySelector('#CartDrawer-Overlay')?.addEventListener('click', this.close.bind(this));

    if (open) {
      this.open(); // open() already calls Glood.upsell.miniCart.hydrate()
    } else {
      window.Glood?.upsell?.miniCart?.hydrate?.();
    }
  }
}
```

### Hydrate on drawer open

Glood's storefront JS won't paint the carousel until you call `hydrate()`. The simplest hook is inside `open()` — every time the drawer opens (icon click, post-Add-to-cart auto-open, etc.) the carousel re-paints fresh:

```js theme={null}
open(triggeredBy) {
  if (triggeredBy) this.setActiveElement(triggeredBy);
  const cartDrawerNote = this.querySelector('[id^="Details-"] summary');
  if (cartDrawerNote && !cartDrawerNote.hasAttribute('role')) this.setSummaryAccessibility(cartDrawerNote);
  // here the animation doesn't seem to always get triggered. A timeout seem to help
  setTimeout(() => { this.classList.add('animate', 'active'); });

  this.addEventListener('transitionend', () => {
    const containerToTrapFocusOn = this.classList.contains('is-empty')
      ? this.querySelector('.drawer__inner-empty')
      : document.getElementById('CartDrawer');
    const focusElement = this.querySelector('.drawer__inner') || this.querySelector('.drawer__close');
    trapFocus(containerToTrapFocusOn, focusElement);
  }, { once: true });

  document.body.classList.add('overflow-hidden');

  // ── Glood mini-cart hydrate — paints the carousel / Reselect CTA into
  //    every [data-glood-mini-cart-line] marker inside the drawer. Safe to
  //    call multiple times; subsequent calls re-paint with the latest cart
  //    state. ─────────────────────────────────────────────────────────────
  Glood.upsell.miniCart.hydrate();
}
```

<Note>
  Place the `hydrate()` call AFTER `document.body.classList.add('overflow-hidden')` (right at the end of `open()`). Calling it before the drawer is in its final state can leave the carousel measuring widths against the still-animating container — the Swiper picks up the wrong slidesPerView for one frame before settling.
</Note>

### Hydrate after the drawer's content re-renders

The `open()` hook handles "drawer opens after being closed." You ALSO need to hydrate after `renderContents()` (the method Dawn calls when the cart sections HTML is replaced after Add-to-cart), and after your custom `refresh()` method (step 6 below). The example `refresh()` further down already calls `hydrate()` at the end; for `renderContents()` add it inline:

```js theme={null}
renderContents(parsedState) {
  this.querySelector('.drawer__inner').classList.contains('is-empty') &&
    this.querySelector('.drawer__inner').classList.remove('is-empty');
  this.productId = parsedState.id;
  this.getSectionsToRender().forEach((section) => {
    const sectionElement = section.selector
      ? document.querySelector(section.selector)
      : document.getElementById(section.id);
    sectionElement.innerHTML =
      this.getSectionInnerHTML(parsedState.sections[section.id], section.selector);
  });

  setTimeout(() => {
    this.querySelector('#CartDrawer-Overlay').addEventListener('click', this.close.bind(this));
    this.open();   // open() now calls Glood.upsell.miniCart.hydrate() — covered.
  });
}
```

Because `renderContents()` ends by calling `open()`, the `hydrate()` inside `open()` is sufficient — no extra call needed here. If your theme has a `renderContents` path that does NOT call `open()` afterward (e.g., a non-drawer cart page render), add `window.Glood?.upsell?.miniCart?.hydrate?.()` at the end of that path too.

### Wire the mini-cart callbacks to `refresh()`

The mini-cart Confirm and Reselect → Confirm flows mutate the cart via `/cart/change.js` + `/cart/add.js` directly. The theme's drawer doesn't know about those mutations unless you re-render. Register the callbacks once — typically inside the app embed's custom JS settings or a small snippet you load on every page:

```js theme={null}
document.addEventListener('DOMContentLoaded', () => {
  if (!window.Glood?.upsell?.miniCart) return;

  Glood.upsell.miniCart.registerConfirmCallback(async () => {
    await document.querySelector('cart-drawer')?.refresh();
  });

  Glood.upsell.miniCart.registerSwapCallback(async () => {
    await document.querySelector('cart-drawer')?.refresh();
  });

  // Optional — open the drawer when a free-gift line is auto-added so the
  // customer notices the new gift in their cart.
  Glood.upsell.registerFreeGiftCallback(() => {
    document.querySelector('cart-drawer')?.refresh({ open: true });
  });
});
```

### Hydrate options (`slidesPerView`, text overrides, classNames)

`hydrate()` accepts an options object you can use to tweak the Swiper, override mini-cart-specific text, and add CSS classes:

```js theme={null}
Glood.upsell.miniCart.hydrate({
  swiper: {
    slidesPerView: 1.3,           // 1.3 cards visible at once
    spaceBetween:  10,
    breakpoints: {
      768: { slidesPerView: 2.2, spaceBetween: 12 },
    },
  },
  text: {
    miniCartReselectLabel: 'Tap to change your free gift',
    miniCartNavPrevAria:   'Previous gift',
    miniCartNavNextAria:   'Next gift',
  },
  classNames: {
    root:     'my-theme-upsell-mini',
    confirm:  'btn btn-primary',
    reselect: 'my-reselect-pill',
  },
});
```

Reserved Swiper keys (`modules`, `navigation`) are stripped — the prev/next buttons are wired by the storefront and can't be overridden.

See the [JS triggers reference](/for-developers/upsell-offers-js-triggers#hydrate-options) for the full hydrate options contract.

## Step 7 — App embed JS: boot, callbacks, and cart-update rehydrate

Steps 1–6 wire the upsell into the theme's own classes (`VariantSelects`, `ProductForm`, `CartDrawer`). That covers every action the customer takes after the page is interactive. **Step 7 boots the upsell on initial page load** — the first PDP render before any variant change, the first cart-drawer paint before any open/close cycle — and registers the cross-cutting callbacks (free-gift auto-add, mini-cart confirm, mini-cart swap, theme `cart-update` event) so the integration stays in sync without you re-wiring them in every class.

Paste this into the app embed's **Custom JS** setting (Theme Editor → Theme Settings → App embeds → Glood.AI Recommendations → expand the embed → *Custom JS* field). Saving the theme injects it into every storefront page:

```js theme={null}
/**
 * Initialize Glood upsell widget for the current product variant.
 *
 * Reads `?variant=` from the URL and falls back to the product's first
 * variant when the param isn't present (initial PDP load). Calling
 * Glood.upsell.renderInto here ensures the deferred PDP block lands in
 * its per-variant anchor BEFORE the customer touches the variant picker
 * — without this the panel only renders on the first variant change.
 */
function initGloodUpsell() {
  const params = new URLSearchParams(window.location.search);

  // Get variant ID from URL
  let variantId = params.get('variant');

  // Fallback to first product variant if variant is not present in URL
  if (
    !variantId &&
    typeof window.glood !== 'undefined' &&
    window.glood?.product?.variants?.length
  ) {
    variantId = window.glood.product.variants[0].id;
  }

  console.log('variant', variantId);

  // Exit if variant ID or Glood render function is unavailable
  if (!variantId || !window.Glood?.upsell?.renderInto) return;

  // Render upsell widget into matching container
  Glood.upsell.renderInto(`[variant-id="${variantId}"]`, {
    variantId,
  });
}

/**
 * Refresh Shopify cart drawer.
 * @param {boolean} open - Whether to open cart drawer after refresh
 */
async function refreshCartDrawer(open = false) {
  const cartDrawer = document.querySelector('cart-drawer');

  // Exit if cart drawer refresh method is unavailable
  if (!cartDrawer?.refresh) return;

  await cartDrawer.refresh({ open });
}

document.addEventListener('DOMContentLoaded', () => {
  /**
   * Sync quantity changes with Glood upsell.
   * Mirrors qty-input changes into Glood.upsell.setQty so the slot budget
   * stays accurate even when the customer changes qty BEFORE Add-to-cart.
   */
  document.querySelectorAll('.quantity__input').forEach((elem) => {
    elem.addEventListener('change', (e) => {
      const qty = parseInt(e.target.value, 10);

      console.log('Quantity changed:', qty);

      // Update Glood quantity
      if (!Number.isNaN(qty) && window.Glood?.upsell?.setQty) {
        Glood.upsell.setQty(qty);
      }
    });
  });

  /**
   * Register Glood callbacks.
   */
  if (window.Glood?.upsell) {
    /**
     * Refresh and OPEN cart drawer after free gift is added.
     * Tier crossings auto-add a gift line server-side; opening the drawer
     * surfaces the new line so the customer notices it.
     */
    if (window.Glood.upsell.registerFreeGiftCallback) {
      Glood.upsell.registerFreeGiftCallback(async () => {
        await refreshCartDrawer(true);
      });
    }

    /**
     * Refresh cart drawer after mini-cart swap action.
     */
    if (window.Glood.upsell?.miniCart) {
      Glood.upsell.miniCart.registerSwapCallback(async () => {
        await refreshCartDrawer();
      });

      /**
       * Refresh cart drawer after mini-cart confirm action.
       */
      Glood.upsell.miniCart.registerConfirmCallback(async () => {
        await refreshCartDrawer();
      });
    }
  }

  /**
   * Listen for theme cart updates (Dawn publishes 'cart-update' via
   * PUB_SUB_EVENTS.cartUpdate after every Add-to-cart). Re-hydrate the
   * mini-cart carousel and re-init the PDP upsell so both reflect the
   * latest cart state. The 1s delay accommodates the theme's own re-render
   * pipeline — increase if your theme is slower to commit DOM changes.
   */
  if (typeof subscribe === 'function') {
    subscribe('cart-update', () => {
      console.log('cart update event received');

      // Rehydrate Glood mini-cart
      if (window.Glood?.upsell?.miniCart?.hydrate) {
        Glood.upsell.miniCart.hydrate();
      }

      // Refresh upsell after cart update
      setTimeout(() => {
        if (window.Glood?.upsell?.refresh) {
          Glood.upsell.refresh();
          console.log('upsell section refreshed');
        }

        initGloodUpsell();
      }, 1000);
    });
  }

  // Initial upsell render
  initGloodUpsell();
});
```

### What each block does

| Block                                                                              | Why it's load-bearing                                                                                                                                                                                                                                                                          |
| ---------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `initGloodUpsell()` + the final `initGloodUpsell()` call inside `DOMContentLoaded` | Bootstraps the PDP panel on FIRST page load. Step 3's `renderInto` only fires on variant CHANGE — without this initial call, customers who land directly on a product page (e.g., via a deep link) see no upsell until they touch the variant picker.                                          |
| `?variant=` URL param + first-variant fallback                                     | Handles both deep links (`/products/x?variant=42`) and the bare PDP (`/products/x`). The fallback walks `window.glood.product.variants[0]` — populated by `app-embed.liquid` via `{{ product \| json }}`.                                                                                      |
| `refreshCartDrawer(open)`                                                          | Single source of truth for re-rendering the drawer. Used by three callbacks: free-gift auto-add (opens the drawer), mini-cart confirm (silent refresh), mini-cart swap (silent refresh). Centralising means you tweak the refresh behaviour in one place.                                      |
| `.quantity__input` change listener                                                 | Mirrors the PDP qty stepper into `Glood.upsell.setQty()`. Without this, the slot budget (`slotsPerMainQty × qty`) stays at qty=1 in the panel even when the customer bumps the stepper to 3.                                                                                                   |
| `registerFreeGiftCallback` → `refreshCartDrawer(true)`                             | When the customer crosses a free-gift tier the Cart Transform Function injects the gift server-side. Opening the drawer surfaces the new line so the customer notices what they got.                                                                                                           |
| `registerSwapCallback` + `registerConfirmCallback` → `refreshCartDrawer()`         | The mini-cart Confirm/Reselect flows fire `/cart/change.js` + `/cart/add.js` directly. The theme's drawer DOM doesn't know about those mutations unless you re-render — same logic from Step 6, registered here so it always wires up at boot regardless of which page loads.                  |
| `subscribe('cart-update', ...)`                                                    | Dawn publishes `cart-update` via its internal `PUB_SUB_EVENTS.cartUpdate` after every Add-to-cart. Re-hydrating the mini-cart + re-initing the PDP upsell here keeps both surfaces in sync with the latest cart state. The `setTimeout` of 1s accommodates the theme's own re-render pipeline. |

<Note>
  The `subscribe` function is the Dawn theme's own pub/sub helper (defined in `assets/pubsub.js`). It's NOT the same as `Glood.upsell.subscribe` — that one targets the storefront's internal bus. The `typeof subscribe === 'function'` guard handles themes that don't expose it (custom themes, older Dawn forks).
</Note>

<Note>
  Some merchants prefer to drop this into a theme JS file (e.g. `assets/glood-init.js` + a `&#123;% script %&#125;` tag) instead of the app embed's Custom JS field. Both work — the embed's Custom JS is just the path with no theme-file edit required. If you're committing to a Git-managed theme, prefer the file path so the change is reviewable.
</Note>

## Verify the integration end-to-end

After all seven steps:

1. **PDP variant switch** — open a PDP that triggers an offer. Switch the variant picker. The upsell panel should re-mount under the active variant's anchor; the previous variant's anchor should lose its `data-active` attribute.
2. **PDP add-to-cart** — pick a gift, click Add to cart. The cart drawer should open with the main product AND the bundled gift line, no flicker.
3. **Multi-qty** — set the PDP quantity stepper to 3 with one or more picks, click Add. Cart should show 3 split bundles (one per main unit), each carrying its own gifts.
4. **Mini-cart Reselect** — open the drawer, click Reselect on a gift line, pick a replacement, Confirm. The drawer should re-render with the new gift in place of the old one (no separate manual reload).
5. **Free-gift auto-add** — add enough to cart to cross a free-gift tier. The gift should auto-add at €0 and the drawer should re-render to show it.

## Common pitfalls

| Symptom                                                                   | Likely cause                                                                                    | Fix                                                                                                                                                      |
| ------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Block renders in the default position, not in your variant anchor         | Render mode is `auto`, not `deferred`                                                           | Theme Editor → block settings → Render mode = `deferred`                                                                                                 |
| Mini-cart counter shows "1 / 2" when cart has 2 gifts                     | Older `glood-upsell.js` — pre-fix counters didn't multiply by line quantity                     | Update to the latest embed; redeploy via `shopify app deploy`                                                                                            |
| 9 mains + 9 gifts at checkout after qty=3 add                             | Cart Transform Function not yet deployed OR `commitPickSelections` runs with stale `state.cart` | Verify the Function is deployed; confirm `await Glood.upsell.commitPickSelections(...)` runs INSIDE the `.then(async ...)` of `/cart/add.js`, not before |
| `id: 0` 422 errors on mini-cart Reselect                                  | hostLine variant-id resolution falling through (older `glood-upsell.js`)                        | Update embed                                                                                                                                             |
| Upsell CTA shows under products not configured for the offer              | Trigger-product gate missing in older embed                                                     | Update embed                                                                                                                                             |
| Drawer paints with the host but no bundled gifts; resolves a moment later | `refresh()` not awaited                                                                         | Confirm `await Glood.upsell.refresh()` is in the Add-to-cart chain (step 4)                                                                              |

## Related

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