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.
Which API do you need?
Section titled “Which API do you need?”- Just the embed’s behaviour, with your own button — place
<dkl-change-gifts>or callwindow.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
groupsandslots, and either let the dialog write to the cart or take the lines withsubmitLines. - 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) anddiscount-kit-live:quickview:add-to-cart(what was written). See Events.
Selection mode
Section titled “Selection mode”await window.DiscountKitLive.openQuickview({ mode: 'selection', groups: [ { tier: 1, choices: [{ productHandle: 'tote-bag' }, { productHandle: 'beanie' }], lineProperties: { _dk_gift: 'Spend $50 gifts' }, }, { tier: 2, choices: [{ productHandle: 'water-bottle', variantIds: [41200000000001, 41200000000002] }], lineProperties: { _dk_gift: 'Spend $50 gifts' }, }, ], 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,})groups
Section titled “groups”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. With pre-filled slots the add-all reads Save changes and stays disabled until something differs from what the dialog opened with. |
quantity |
number |
Units the slot adds (default 1). |
Result
Section titled “Result”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.
Copy on the request
Section titled “Copy on the request”pickerHeading (the dialog’s heading on the picker), selectionHeading (the tray
heading with one group), addToSelectionLabel (the product screen’s action),
addAllLabel (the add-all button) and saveLabel (the add-all button when slots open
pre-filled, default “Save changes”). Everything else is a label.
Owning the write: submitLines
Section titled “Owning the write: submitLines”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.
Before the write: beforeSubmit
Section titled “Before the write: beforeSubmit”To save something the write depends on first, pass beforeSubmit. The dialog calls it with
the lines it is about to write, in the shape the add-to-cart event carries them (each with
its slotId), and waits for it before writing, whether the write is its own or
submitLines. Some themes reload the page as soon as the cart changes, so anything that
has to be saved can’t wait until after the write.
await window.DiscountKitLive.openQuickview({ mode: 'selection', groups, slots, beforeSubmit: async (lines) => { // lines: [{ slotId, variantId, quantity, properties, … }] await myApp.saveChoices(lines) },})If it throws or rejects, the error is logged and the write still goes ahead. If the shopper closes the dialog while it runs, nothing is written.
Behaviour in selection mode
Section titled “Behaviour in selection mode”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 tray always holds what the product screen shows, and the screen opens on the variant its tray cell holds.
- A tier of one gift is a pick-one. Opening a product puts it in the slot and picking another variant changes it — there is no action button on the product screen, only a Sold out notice for a variant that can’t be had (the slot keeps what it had).
- A tier of several gifts fills through the product screen’s action, which 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) and reads Add gift n of N on an empty cell. Another variant of the product already in the ringed cell changes it straight away; a different product for a filled cell takes the action (Change gift n of N), and the add-all waits until it is applied or the shopper goes back.
The product screen shows no price and no quantity controls, and the in-dialog volume picker is off — a gift is not a volume purchase.
Example Use Cases
Section titled “Example Use Cases”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 add-all reads Save changes (saveLabel to reword it) and stays disabled until
something differs from what the dialog opened with.
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.giftMarker }, })), slots: add.flatMap((tier, group) => Array.from({ length: tier.quantity }, (_, i) => ({ group, id: `${tier.discountId}-${tier.discountTier}-${i}` })), ), closeOnAdd: true, })})Keep the _dk_gift property: it is how the discount function and the embed recognise a
gift line. Take it from each add’s giftMarker: a gift with purchase marks its gifts with
its title, but a tiered rewards discount marks them with
its own id, and its function only discounts lines carrying it. Don’t mark the tier: a
discount’s gifts of one variant share a cart line whatever tiers they fill. Lines that
still carry the old _dk_gift_tier property keep working, on lines of their own.
The change-gifts decision
Section titled “The change-gifts decision”When the embed drives the gift selector, these are on window.discount_kit.core:
canChangeGifts()—truewhile a claimed tier offers a choice.openChangeGifts()— reopen the selector on the claimed tiers, pre-filled.falsewhen 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.
Labels
Section titled “Labels”Most of these are set without code in the
quickview component’s Gift selection
text. Override any subset globally through window.DklContext.quickviewLabels, which wins
over the component, 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 (shown only once its gift is removed with ×) |
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 (a single-slot tier swaps without it) |
addAll / addedAll |
Add to cart / Added to cart |
The add-all button |
saveChanges / savedChanges |
Save changes / Saved |
The add-all button when slots open pre-filled |
slotLabel |
Gift [n] |
An empty tray cell |
chooseSlot / removeSelection |
Choose / Remove |
Accessible names of a tray cell and its remove button |