Skip to content
Docs

JavaScript API

The gift selector is the quickview opened with mode: 'selection'. The Gift with purchase embed builds that request for you; this page is for custom gift experiences that open it themselves — everything here is the same DiscountKitLive.openQuickview() helper with a few more fields, and everything on the quickview page (the promise, the command events, closeQuickview(), labels) applies.

  • Just the embed’s behaviour, with your own button — place <dkl-change-gifts> or call window.discount_kit.core.openChangeGifts(). No request to build.
  • Your own gift logic (a custom app, a rule the discount can’t express, a loyalty reward) — open selection mode yourself with groups and slots, and either let the dialog write to the cart or take the lines with submitLines.
  • Reacting to what the embed does — listen to discount_kit:gift_adjustments (what the embed wants to add and remove), discount-kit-live:quickview:selection-change (what the shopper has picked) and discount-kit-live:quickview:add-to-cart (what was written). See Events.
await window.DiscountKitLive.openQuickview({
mode: 'selection',
groups: [
{
tier: 1,
choices: [{ productHandle: 'tote-bag' }, { productHandle: 'beanie' }],
lineProperties: { _dk_gift: 'Spend $50 gifts', _dk_gift_tier: '0' },
},
{
tier: 2,
choices: [{ productHandle: 'water-bottle', variantIds: [41200000000001, 41200000000002] }],
lineProperties: { _dk_gift: 'Spend $50 gifts', _dk_gift_tier: '1' },
},
],
slots: [
{ group: 0, id: 't1-a' },
{ group: 0, id: 't1-b' },
{ group: 1, id: 't2-a', label: 'Bottle' },
],
pickerHeading: "You've unlocked 3 free gifts!",
addAllLabel: 'Add gifts to cart',
closeOnAdd: true,
})

One per gift tier. Each group is the set of products a slot can be filled from.

Field Type Purpose
choices { productHandle | productId, variantIds? }[] The products. variantIds narrows a product to the variants the tier permits: values no permitted variant carries are hidden in the option picker, and the action refuses anything else. Absent = every variant.
tier number Captions the group “Tier [n]” in the tray (captions show with two or more groups).
heading string A caption of your own instead; also the picker heading while the group is active when no pickerHeading was given.
lineProperties Record<string, string> Line-item properties on every line filled from this group, merged over the request’s own.

A group with one product opens straight on that product with no picker and no back button.

One per gift, in display order.

Field Type Purpose
group number Index into groups.
id string Echoed back on the add event as slotId.
label string Text under the empty tray cell (default “Gift [n]”).
variantId number Pre-fill with a variant of one of the group’s products — a gift already in the cart. A tier whose slots all arrive pre-filled opens on its first slot with the action reading “Change gift 1 of N”.
quantity number Units the slot adds (default 1).

The promise resolves like any other open: { panel: 'product' | 'products', productHandle, productId } — product when the active tier has one choice and the dialog opened straight on it, products when it opened on the picker. It rejects if the bundle fails to load or the dialog is closed before the first product renders.

pickerHeading (the dialog’s heading on the picker), selectionHeading (the tray heading with one group), addToSelectionLabel (the product screen’s action) and addAllLabel (the add-all button). Everything else is a label.

By default the add-all action writes the filled slots to the cart itself. Pass submitLines to take the lines instead — the way the embed replaces gifts already in the cart:

await window.DiscountKitLive.openQuickview({
mode: 'selection',
groups, slots,
submitLines: async (lines) => {
// lines: [{ variantId, quantity, properties }]
const ok = await myCart.replaceGifts(lines)
return ok ? { ok: true } : { ok: false, message: 'Could not update your gifts' }
},
})

Resolve { ok: true } once the cart holds the lines — the dialog then emits discount-kit-live:quickview:add-to-cart with the lines as usual — or { ok: false, message } to show the message and keep the dialog open.

Tiers are claimed whole: the add-all button is enabled only when at least one tier is complete and none is half-picked, and it writes the complete tiers, leaving an untouched one open for later. The product screen’s action fills the tray cell ringed in the accent (the cell tapped, else the active tier’s first empty one, else — for a tier that opened pre-filled — its first slot, so a replacement can be picked without clearing anything), reads Add gift n of N on an empty cell and Change gift n of N on a filled one, and is disabled while the selected options are the variant that cell already holds. Quantity controls and the in-dialog volume picker are off — a gift is not a volume purchase.

A loyalty reward the discount engine doesn’t know about. Your app decides the shopper may pick one of three products; the dialog handles the picking and the cart write and tags the line so your backend can recognise it:

const { panel } = await window.DiscountKitLive.openQuickview({
mode: 'selection',
groups: [{
choices: [{ productHandle: 'tote-bag' }, { productHandle: 'beanie' }, { productHandle: 'socks' }],
lineProperties: { _loyalty_reward: 'silver-tier' },
}],
slots: [{ group: 0, id: 'reward' }],
pickerHeading: 'Pick your silver-tier reward',
addAllLabel: 'Claim reward',
closeOnAdd: true,
})

Pick a colour, not a product. One product with a variant allow-list opens straight on that product with only the permitted colours showing:

window.DiscountKitLive.openQuickview({
mode: 'selection',
groups: [{ choices: [{ productHandle: 'water-bottle', variantIds: allowedVariantIds }] }],
slots: [{ group: 0, id: 'bottle', label: 'Your bottle' }],
optionsHeading: 'Choose a colour',
closeOnAdd: true,
})

Change what’s already there. Pre-fill the slots from the cart’s lines and take the write yourself, so old lines are swapped for new ones rather than added to:

const current = cart.items.filter((line) => line.properties?._loyalty_reward)
await window.DiscountKitLive.openQuickview({
mode: 'selection',
groups: [{ choices, lineProperties: { _loyalty_reward: 'silver-tier' } }],
slots: current.map((line, i) => ({ group: 0, id: `reward-${i}`, variantId: line.variant_id })),
closeOnAdd: true,
submitLines: async (lines) => {
// remove every current reward line, add the chosen ones — one request via /cart/update.js
// then /cart/add.js, or one updateCart call where the theme exposes standard actions
await myCart.replace(current, lines)
return { ok: true }
},
})

The dialog opens on the first slot reading Change gift 1 of N, disabled until the shopper picks something different.

Show the picks somewhere else. Mirror the tray in your own UI — a cart drawer header, a progress step — from the selection event:

document.addEventListener('discount-kit-live:quickview:selection-change', (e) => {
const { slots, complete } = e.detail.resource
drawerHeader.textContent = `${slots.filter((s) => s.variantId).length} of ${slots.length} gifts chosen`
drawerHeader.classList.toggle('is-complete', complete)
})

Drive the embed from your own cart UI. When the embed owns the gifts, don’t rebuild the request — ask it:

document.addEventListener('discount_kit:gift_choices', (e) => {
myDrawer.querySelector('.change-gifts').hidden = !e.detail.changeable
})
myDrawer.querySelector('.change-gifts').addEventListener('click', () => {
window.discount_kit.core.openChangeGifts()
})

(That’s exactly what <dkl-change-gifts> does, so wrapping your button in it is shorter.)

A fully custom gift flow on the embed’s data. The embed publishes what it would add and remove on every cart pass; with the selector turned off in its settings, you can open the dialog yourself from that:

document.addEventListener('discount_kit:gift_adjustments', (e) => {
const { add } = e.detail
if (!add.length) return
window.DiscountKitLive.openQuickview({
mode: 'selection',
groups: add.map((tier) => ({
tier: tier.discountTier + 1,
choices: tier.options.map((o) => ({
productHandle: o.handle,
// only a rule that names variants restricts the picker; `anyVariant` tiers stay open
...(o.anyVariant ? {} : { variantIds: o.variants.map((v) => v.id) }),
})),
lineProperties: { _dk_gift: tier.discountTitle, _dk_gift_tier: String(tier.discountTier) },
})),
slots: add.flatMap((tier, group) =>
Array.from({ length: tier.quantity }, (_, i) => ({ group, id: `${tier.discountId}-${tier.discountTier}-${i}` })),
),
closeOnAdd: true,
})
})

Keep the _dk_gift / _dk_gift_tier properties: they are how the discount function and the embed recognise a gift line.

When the embed drives the gift selector, these are on window.discount_kit.core:

  • canChangeGifts() — true while a claimed tier offers a choice.
  • openChangeGifts() — reopen the selector on the claimed tiers, pre-filled. false when there is nothing to change.
  • changeableGifts() — the underlying list: [{ item, currentLines }] per changeable tier.

discount_kit:gift_choices fires on document with detail.changeable after every cart pass; <dkl-change-gifts> follows it.

Override any subset globally through window.DklContext.quickviewLabels, exactly as for the quickview’s labels:

Key Default Where
selectionHeading Your selection Tray heading with one group
groupSelectionHeading [group] selection Tray heading with several groups; [group] is the active tier’s caption
tierLabel Tier [n] A group’s caption, from its tier
selectionCount [filled] of [total] chosen Tray count
chooseMore / chooseOneMore Choose [n] more / Choose 1 more Announced to assistive tech after a pick while the tier has empty slots
addToSelection Add to selection Product screen action, single-slot tier
addToSelectionNth Add gift [n] of [total] Product screen action, several slots
changeSelection / changeSelectionNth Change selection / Change gift [n] of [total] The same action when the slot already holds a gift
addAll / addedAll Add to cart / Added to cart The add-all button
slotLabel Gift [n] An empty tray cell
chooseSlot / removeSelection Choose / Remove Accessible names of a tray cell and its remove button