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.

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

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:
{%- 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:
<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:
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

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:
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:
{%- 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

AttributeSourcePurpose
data-line-keyitem.keyStable Shopify cart-line identifier — scopes swap-mode state.
data-line-product-iditem.product_idMatched against product-mode offer triggers.
data-line-variant-iditem.variant_idMatched against variant-mode offer triggers.
data-line-qtyitem.quantityDrives slot budget when the line is a trigger product.
data-line-properties-jsonitem.properties | json | escapeJSON of the line’s properties. The | escape is load-bearing — JSON inside an HTML attribute MUST be entity-encoded.
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.

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:
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:
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();
}
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.

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:
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:
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:
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 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:
/**
 * 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

BlockWhy it’s load-bearing
initGloodUpsell() + the final initGloodUpsell() call inside DOMContentLoadedBootstraps 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 fallbackHandles 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 listenerMirrors 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.
registerFreeGiftCallbackrefreshCartDrawer(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 + registerConfirmCallbackrefreshCartDrawer()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.
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).
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.

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

SymptomLikely causeFix
Block renders in the default position, not in your variant anchorRender mode is auto, not deferredTheme Editor → block settings → Render mode = deferred
Mini-cart counter shows “1 / 2” when cart has 2 giftsOlder glood-upsell.js — pre-fix counters didn’t multiply by line quantityUpdate to the latest embed; redeploy via shopify app deploy
9 mains + 9 gifts at checkout after qty=3 addCart Transform Function not yet deployed OR commitPickSelections runs with stale state.cartVerify 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 ReselecthostLine variant-id resolution falling through (older glood-upsell.js)Update embed
Upsell CTA shows under products not configured for the offerTrigger-product gate missing in older embedUpdate embed
Drawer paints with the host but no bundled gifts; resolves a moment laterrefresh() not awaitedConfirm await Glood.upsell.refresh() is in the Add-to-cart chain (step 4)