@lime-bundles/widget 0.2.0 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -2,390 +2,2512 @@
2
2
  import {
3
3
  createStorefrontClient,
4
4
  BUNDLE_METAOBJECT_QUERY,
5
+ BUNDLES_FOR_PRODUCT_QUERY,
6
+ CART_CREATE_MUTATION,
7
+ CART_LINES_ADD_MUTATION,
5
8
  SHOP_CUSTOM_CSS_QUERY,
6
9
  parseMetaobjectBundle,
7
10
  observeImpression,
8
11
  reportImpression,
9
12
  reportAddToCart,
10
- injectCustomCss
13
+ injectCustomCss,
14
+ sanitizeCustomCss,
15
+ getABTestAssignment,
16
+ applyABVariantB
11
17
  } from "@lime-bundles/core";
12
18
 
13
- // src/renderers/fixed.ts
19
+ // src/renderers/pricing.ts
14
20
  import {
15
- formatMoney
21
+ parseCents,
22
+ formatCents,
23
+ percentageDiscountUnit,
24
+ computeFixedPricing,
25
+ computeBundleSaleCents
16
26
  } from "@lime-bundles/core";
17
- function renderFixedBundle(container, bundle, onAddToCart) {
27
+
28
+ // src/renderers/countdown.ts
29
+ import { formatCountdown } from "@lime-bundles/core";
30
+ function renderCountdown(endsAtIso) {
31
+ const parsed = parseIso(endsAtIso);
32
+ if (parsed === null) return null;
33
+ if (parsed <= Date.now()) return null;
34
+ const target = parsed;
35
+ const wrap = document.createElement("div");
36
+ wrap.className = "lb-bundle-countdown";
37
+ wrap.setAttribute("data-countdown", "");
38
+ const labelWrap = document.createElement("div");
39
+ labelWrap.className = "lb-bundle-countdown__label";
40
+ const labelText = document.createElement("span");
41
+ labelText.textContent = "Ends in";
42
+ labelWrap.appendChild(labelText);
43
+ wrap.appendChild(labelWrap);
44
+ const timer = document.createElement("span");
45
+ timer.className = "lb-bundle-countdown__timer";
46
+ timer.setAttribute("data-countdown-timer", "");
47
+ wrap.appendChild(timer);
48
+ let intervalId = null;
49
+ function tick() {
50
+ const msLeft = target - Date.now();
51
+ if (msLeft <= 0) {
52
+ wrap.style.display = "none";
53
+ stop();
54
+ return;
55
+ }
56
+ timer.textContent = formatCountdown(msLeft);
57
+ }
58
+ function stop() {
59
+ if (intervalId !== null) {
60
+ clearInterval(intervalId);
61
+ intervalId = null;
62
+ }
63
+ }
64
+ tick();
65
+ intervalId = setInterval(tick, 1e3);
66
+ return { el: wrap, stop };
67
+ }
68
+ function parseIso(iso) {
69
+ const t = Date.parse(iso);
70
+ return Number.isFinite(t) ? t : null;
71
+ }
72
+
73
+ // src/renderers/dom.ts
74
+ function el(tag, className, attrs = {}) {
75
+ const node = document.createElement(tag);
76
+ if (className) node.className = className;
77
+ for (const [k, v] of Object.entries(attrs)) {
78
+ node.setAttribute(k, v);
79
+ }
80
+ return node;
81
+ }
82
+
83
+ // src/renderers/image.ts
84
+ import {
85
+ transformImageUrl,
86
+ THUMB_PX
87
+ } from "@lime-bundles/core";
88
+
89
+ // src/renderers/fixed.ts
90
+ var PLACEHOLDER_THUMB_SVG = `
91
+ <svg class="lb-bundle-placeholder-icon" viewBox="0 0 28 28" xmlns="http://www.w3.org/2000/svg" aria-hidden="true" focusable="false">
92
+ <rect x="4" y="4" width="20" height="20" rx="3"></rect>
93
+ <line x1="4" y1="20" x2="24" y2="20"></line>
94
+ <circle cx="10" cy="12" r="2"></circle>
95
+ </svg>`;
96
+ function renderFixedBundle(container, bundle, onAddToCart, onCleanup) {
97
+ const wc = bundle.widgetConfig;
98
+ const qtyFor = (productId, variantId) => {
99
+ const vq = bundle.variantQuantities[variantId];
100
+ if (vq !== void 0) return vq;
101
+ return bundle.productQuantities[productId] ?? 1;
102
+ };
103
+ const rows = [];
104
+ let oosCount = 0;
105
+ bundle.products.forEach((product, idx) => {
106
+ const row = buildRowState(bundle, product, idx);
107
+ if (row.qty === 0) return;
108
+ if (row.isOos) {
109
+ oosCount++;
110
+ if (wc.outOfStockBehavior === "hide") return;
111
+ }
112
+ rows.push(row);
113
+ });
114
+ if (rows.length === 0) return;
18
115
  const currency = bundle.products[0]?.priceRange.minVariantPrice.currencyCode ?? "USD";
19
- const title = document.createElement("h3");
20
- title.className = "lb-bundle__title";
116
+ const root = el("div", "lb-fixed", {
117
+ "data-discount-type": bundle.discountConfig.discountType,
118
+ "data-discount-value": String(bundle.discountConfig.discountValue)
119
+ });
120
+ const headerHandle = renderHeader(bundle, currency);
121
+ root.appendChild(headerHandle.el);
122
+ if (wc.countdown.showCountdown && bundle.endsAt) {
123
+ const countdown = renderCountdown(bundle.endsAt);
124
+ if (countdown) {
125
+ root.appendChild(countdown.el);
126
+ onCleanup?.(countdown.stop);
127
+ }
128
+ }
129
+ const list = el("div", "lb-fixed__products");
130
+ const rowHandles = [];
131
+ rows.forEach((rowState) => {
132
+ const handle = renderProductRow(rowState, currency, qtyFor, () => {
133
+ updatePricing();
134
+ });
135
+ rowHandles.push(handle);
136
+ list.appendChild(handle.el);
137
+ });
138
+ root.appendChild(list);
139
+ root.appendChild(el("div", "lb-bundle-divider"));
140
+ const pricingHandle = renderPricingRow(bundle);
141
+ root.appendChild(pricingHandle.el);
142
+ const savingsBarHandle = wc.savingsBar.visible ? renderSavingsBar() : null;
143
+ if (savingsBarHandle) root.appendChild(savingsBarHandle.el);
144
+ const cta = renderCta(bundle, oosCount, () => {
145
+ const lines = rows.filter((r) => r.selected).map((r) => ({
146
+ merchandiseId: r.selected.id,
147
+ quantity: r.qty,
148
+ attributes: [
149
+ { key: "_lime_bundle_gid", value: bundle.id },
150
+ { key: "_lime_bundle_type", value: bundle.bundleType }
151
+ ]
152
+ }));
153
+ if (lines.length === 0) return;
154
+ onAddToCart(lines);
155
+ });
156
+ root.appendChild(cta);
157
+ root.appendChild(
158
+ el("p", "lb-bundle-error", { "data-error": "", "aria-live": "polite" })
159
+ );
160
+ root.appendChild(
161
+ el("span", "lb-visually-hidden", {
162
+ "data-status": "",
163
+ "aria-live": "polite"
164
+ })
165
+ );
166
+ container.appendChild(root);
167
+ updatePricing();
168
+ function updatePricing() {
169
+ const totalCents = rows.reduce((sum, r) => {
170
+ if (!r.selected) return sum;
171
+ const unit = parseCents(r.selected.price.amount);
172
+ return sum + unit * r.qty;
173
+ }, 0);
174
+ const saleCents = computeSale(totalCents, bundle.discountConfig, rows);
175
+ const savingsCents = Math.max(0, totalCents - saleCents);
176
+ pricingHandle.update({ totalCents, saleCents, savingsCents, currency });
177
+ if (savingsBarHandle) {
178
+ savingsBarHandle.update({ savingsCents, currency });
179
+ }
180
+ headerHandle.refresh(
181
+ deriveHeaderBadge(bundle, totalCents, saleCents, currency)
182
+ );
183
+ }
184
+ }
185
+ function buildRowState(bundle, product, productIndex) {
186
+ const selectedVariantIds = bundle.selectedVariantIds?.[productIndex] ?? null;
187
+ const available = product.variants.nodes.filter(
188
+ (v) => v.availableForSale
189
+ );
190
+ const eligibleVariants = selectedVariantIds && selectedVariantIds.length > 0 ? available.filter((v) => selectedVariantIds.includes(v.id)) : available;
191
+ const isOos = eligibleVariants.length === 0;
192
+ const selected = eligibleVariants[0] ?? null;
193
+ const productQty = bundle.productQuantities[product.id] ?? 1;
194
+ const variantQty = selected ? bundle.variantQuantities[selected.id] : void 0;
195
+ const qty = variantQty ?? productQty;
196
+ return { product, eligibleVariants, selected, qty, isOos };
197
+ }
198
+ function renderHeader(bundle, currency) {
199
+ const wc = bundle.widgetConfig;
200
+ const header = el("div", "lb-bundle-header");
201
+ const content = el("div", "lb-bundle-header__content");
202
+ const title = el("h3", "lb-bundle-title");
21
203
  title.textContent = bundle.title;
22
- title.setAttribute("part", "title");
23
- container.appendChild(title);
24
- if (bundle.discountLabel) {
25
- const badge = document.createElement("span");
26
- badge.className = "lb-bundle__discount-badge";
27
- badge.textContent = bundle.discountLabel;
28
- container.appendChild(badge);
29
- }
30
- const productsDiv = document.createElement("div");
31
- productsDiv.className = "lb-bundle__products";
32
- for (const product of bundle.products) {
33
- const productEl = document.createElement("div");
34
- productEl.className = "lb-bundle__product";
35
- productEl.setAttribute("part", "product");
36
- if (product.featuredImage) {
37
- const img = document.createElement("img");
38
- img.src = product.featuredImage.url;
39
- img.alt = product.featuredImage.altText ?? product.title;
40
- img.className = "lb-bundle__product-image";
41
- img.loading = "lazy";
42
- productEl.appendChild(img);
43
- }
44
- const info = document.createElement("div");
45
- info.className = "lb-bundle__product-info";
46
- info.innerHTML = `
47
- <p class="lb-bundle__product-title">${escapeHtml(product.title)}</p>
48
- <p class="lb-bundle__product-price">${escapeHtml(formatMoney(product.priceRange.minVariantPrice.amount, currency))}</p>
49
- `;
50
- productEl.appendChild(info);
51
- productsDiv.appendChild(productEl);
204
+ content.appendChild(title);
205
+ if (bundle.description) {
206
+ const subtitle = el("p", "lb-bundle-subtitle");
207
+ subtitle.textContent = bundle.description;
208
+ content.appendChild(subtitle);
209
+ }
210
+ header.appendChild(content);
211
+ const badgeEl = el("span", "lb-bundle-header__badge", {
212
+ "data-header-badge": ""
213
+ });
214
+ header.appendChild(badgeEl);
215
+ const initialPricing = computeFixedPricing(
216
+ bundle,
217
+ bundle.productQuantities,
218
+ wc.pricing.showSaveBadge
219
+ );
220
+ if (initialPricing.headerBadge) {
221
+ badgeEl.textContent = initialPricing.headerBadge;
222
+ } else {
223
+ badgeEl.style.display = "none";
224
+ }
225
+ void currency;
226
+ return {
227
+ el: header,
228
+ refresh(badgeText) {
229
+ if (!wc.pricing.showSaveBadge) {
230
+ badgeEl.style.display = "none";
231
+ return;
232
+ }
233
+ if (badgeText) {
234
+ badgeEl.textContent = badgeText;
235
+ badgeEl.style.display = "";
236
+ } else {
237
+ badgeEl.style.display = "none";
238
+ }
239
+ }
240
+ };
241
+ }
242
+ function deriveHeaderBadge(bundle, totalCents, saleCents, currency) {
243
+ if (!bundle.widgetConfig.pricing.showSaveBadge) return "";
244
+ const savings = totalCents - saleCents;
245
+ if (savings <= 0) return "";
246
+ const dc = bundle.discountConfig;
247
+ if (dc.discountType === "percentage" && dc.discountValue > 0) {
248
+ return `-${Math.round(dc.discountValue)}%`;
249
+ }
250
+ if (dc.discountType === "fixed_amount" && dc.discountValue > 0) {
251
+ return `-${formatCents(Math.round(dc.discountValue * 100), currency)}`;
52
252
  }
53
- container.appendChild(productsDiv);
253
+ return `-${formatCents(savings, currency)}`;
254
+ }
255
+ function renderProductRow(state, currency, qtyFor, onVariantChange) {
256
+ const rowEl = el(
257
+ "div",
258
+ state.isOos ? "lb-bundle-product-row lb-bundle-product-row--oos" : "lb-bundle-product-row",
259
+ {
260
+ "data-product-id": state.product.id.replace(/^.*\//, ""),
261
+ ...state.isOos ? { "aria-disabled": "true" } : {}
262
+ }
263
+ );
264
+ const thumb = el("div", "lb-bundle-thumbnail", { "data-thumbnail": "" });
265
+ if (state.product.featuredImage) {
266
+ const img = document.createElement("img");
267
+ img.src = transformImageUrl(state.product.featuredImage.url, {
268
+ width: THUMB_PX,
269
+ height: THUMB_PX,
270
+ crop: "center"
271
+ });
272
+ img.alt = state.product.featuredImage.altText ?? state.product.title;
273
+ img.width = THUMB_PX;
274
+ img.height = THUMB_PX;
275
+ img.loading = "lazy";
276
+ thumb.appendChild(img);
277
+ } else {
278
+ thumb.insertAdjacentHTML("beforeend", PLACEHOLDER_THUMB_SVG);
279
+ }
280
+ let qtyBadgeRef = null;
281
+ if (!state.isOos) {
282
+ qtyBadgeRef = el("span", "lb-bundle-qty-badge", {
283
+ "data-qty-badge": ""
284
+ });
285
+ qtyBadgeRef.textContent = String(state.qty);
286
+ thumb.appendChild(qtyBadgeRef);
287
+ }
288
+ rowEl.appendChild(thumb);
289
+ const info = el("div", "lb-bundle-product-info");
290
+ const name = document.createElement("a");
291
+ name.className = "lb-bundle-product-name";
292
+ name.href = `/products/${state.product.handle}`;
293
+ name.textContent = state.product.title;
294
+ info.appendChild(name);
295
+ if (state.isOos) {
296
+ const oosLabel = el("span", "lb-bundle-oos-label");
297
+ oosLabel.textContent = "Out of stock";
298
+ info.appendChild(oosLabel);
299
+ } else if (state.selected) {
300
+ const prices = el("span", "lb-bundle-product-prices");
301
+ const compare = el("span", "lb-bundle-product-compare-price", {
302
+ "data-product-compare-price": ""
303
+ });
304
+ const priceEl = el("span", "lb-bundle-product-price", {
305
+ "data-product-price": ""
306
+ });
307
+ prices.appendChild(compare);
308
+ prices.appendChild(priceEl);
309
+ info.appendChild(prices);
310
+ const applyVariantToRow = (variant) => {
311
+ const unit = parseCents(variant.price.amount);
312
+ priceEl.textContent = formatCents(unit, currency);
313
+ if (variant.compareAtPrice) {
314
+ const cmp = parseCents(variant.compareAtPrice.amount);
315
+ if (cmp > unit) {
316
+ compare.textContent = formatCents(cmp, currency);
317
+ compare.removeAttribute("hidden");
318
+ } else {
319
+ compare.setAttribute("hidden", "");
320
+ }
321
+ } else {
322
+ compare.setAttribute("hidden", "");
323
+ }
324
+ };
325
+ applyVariantToRow(state.selected);
326
+ if (state.eligibleVariants.length > 1) {
327
+ const select = document.createElement("select");
328
+ select.className = "lb-bundle-variant-select";
329
+ select.setAttribute("data-variant-select", "");
330
+ select.setAttribute(
331
+ "aria-label",
332
+ `Select variant for ${state.product.title}`
333
+ );
334
+ state.eligibleVariants.forEach((variant) => {
335
+ const opt = document.createElement("option");
336
+ opt.value = variant.id;
337
+ opt.textContent = variant.title;
338
+ if (variant.id === state.selected?.id) opt.selected = true;
339
+ select.appendChild(opt);
340
+ });
341
+ select.addEventListener("change", () => {
342
+ const variant = state.eligibleVariants.find(
343
+ (v) => v.id === select.value
344
+ );
345
+ if (!variant) return;
346
+ state.selected = variant;
347
+ state.qty = qtyFor(state.product.id, variant.id);
348
+ if (qtyBadgeRef) qtyBadgeRef.textContent = String(state.qty);
349
+ applyVariantToRow(variant);
350
+ onVariantChange();
351
+ });
352
+ info.appendChild(select);
353
+ } else if (state.eligibleVariants.length === 1 && state.product.variants.nodes.length > 1) {
354
+ const badge = el("span", "lb-bundle-variant-badge");
355
+ badge.textContent = state.eligibleVariants[0].title;
356
+ info.appendChild(badge);
357
+ }
358
+ }
359
+ rowEl.appendChild(info);
360
+ return { el: rowEl, state };
361
+ }
362
+ function renderPricingRow(bundle) {
363
+ const row = el("div", "lb-bundle-pricing");
364
+ const label = el("span", "lb-bundle-pricing__label");
365
+ label.textContent = "Bundle price";
366
+ row.appendChild(label);
367
+ const prices = el("span", "lb-bundle-pricing__prices");
368
+ const compare = el("span", "lb-bundle-compare-price", {
369
+ "data-compare-price": ""
370
+ });
371
+ compare.style.display = "none";
372
+ prices.appendChild(compare);
373
+ const sale = el("span", "lb-bundle-sale-price", {
374
+ "data-sale-price": ""
375
+ });
376
+ prices.appendChild(sale);
377
+ row.appendChild(prices);
378
+ return {
379
+ el: row,
380
+ update({ totalCents, saleCents, savingsCents, currency }) {
381
+ sale.textContent = formatCents(saleCents, currency);
382
+ if (bundle.widgetConfig.pricing.showCompareAtPrice && savingsCents > 0) {
383
+ compare.textContent = formatCents(totalCents, currency);
384
+ compare.style.display = "";
385
+ } else {
386
+ compare.style.display = "none";
387
+ }
388
+ }
389
+ };
390
+ }
391
+ function renderSavingsBar() {
392
+ const bar = el("div", "lb-bundle-savings-bar", {
393
+ "data-savings-bar": ""
394
+ });
395
+ const label = document.createElement("span");
396
+ label.textContent = "You save";
397
+ bar.appendChild(label);
398
+ const amount = el("span", "", { "data-savings-amount": "" });
399
+ bar.appendChild(amount);
400
+ return {
401
+ el: bar,
402
+ update({ savingsCents, currency }) {
403
+ if (savingsCents <= 0) {
404
+ bar.style.display = "none";
405
+ return;
406
+ }
407
+ bar.style.display = "";
408
+ amount.textContent = formatCents(savingsCents, currency);
409
+ }
410
+ };
411
+ }
412
+ function renderCta(bundle, oosCount, onClick) {
54
413
  const button = document.createElement("button");
55
- button.className = "lb-bundle__cta";
56
- button.textContent = bundle.widgetConfig.ctaText ?? "Add Bundle to Cart";
57
- button.setAttribute("part", "button");
58
- button.addEventListener("click", () => {
59
- const lines = bundle.products.filter((p) => p.variants.nodes.some((v) => v.availableForSale)).map((p) => {
60
- const variant = p.variants.nodes.find((v) => v.availableForSale);
61
- return {
414
+ button.type = "button";
415
+ button.className = "lb-bundle-cta";
416
+ button.setAttribute("data-add-bundle", "");
417
+ if (oosCount > 0) {
418
+ button.disabled = true;
419
+ button.textContent = `${oosCount} item${oosCount === 1 ? "" : "s"} out of stock`;
420
+ } else {
421
+ button.textContent = bundle.widgetConfig.cta.ctaText || "Add to cart";
422
+ button.addEventListener("click", () => {
423
+ if (button.disabled) return;
424
+ onClick();
425
+ });
426
+ }
427
+ return button;
428
+ }
429
+ function computeSale(totalCents, discount, rows) {
430
+ if (discount.discountType === "percentage") {
431
+ let saleCents = 0;
432
+ for (const r of rows) {
433
+ if (!r.selected) continue;
434
+ const unit = parseCents(r.selected.price.amount);
435
+ const off = Math.floor(unit * discount.discountValue / 100);
436
+ const perUnit = Math.max(0, unit - off);
437
+ saleCents += perUnit * r.qty;
438
+ }
439
+ return saleCents;
440
+ }
441
+ return Math.max(0, totalCents - Math.round(discount.discountValue * 100));
442
+ }
443
+
444
+ // src/renderers/mix-match.ts
445
+ var PLACEHOLDER_THUMB_SVG2 = `
446
+ <svg class="lb-bundle-placeholder-icon" viewBox="0 0 28 28" xmlns="http://www.w3.org/2000/svg" aria-hidden="true" focusable="false">
447
+ <rect x="4" y="4" width="20" height="20" rx="3"></rect>
448
+ <line x1="4" y1="20" x2="24" y2="20"></line>
449
+ <circle cx="10" cy="12" r="2"></circle>
450
+ </svg>`;
451
+ var PLUS_ICON_SVG = `
452
+ <svg width="18" height="18" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
453
+ <line x1="9" y1="3" x2="9" y2="15" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
454
+ <line x1="3" y1="9" x2="15" y2="9" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
455
+ </svg>`;
456
+ var CLOSE_ICON_SVG = `
457
+ <svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
458
+ <line x1="5" y1="5" x2="15" y2="15" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
459
+ <line x1="15" y1="5" x2="5" y2="15" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
460
+ </svg>`;
461
+ var SEARCH_CLEAR_ICON_SVG = `
462
+ <svg width="16" height="16" viewBox="0 0 20 20" fill="currentColor" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
463
+ <path d="M14.348 5.652a.5.5 0 0 0-.707 0L10 9.293 6.36 5.652a.5.5 0 1 0-.708.707L9.293 10l-3.641 3.641a.5.5 0 0 0 .708.707L10 10.707l3.641 3.641a.5.5 0 0 0 .707-.707L10.707 10l3.641-3.641a.5.5 0 0 0 0-.707z"/>
464
+ </svg>`;
465
+ function renderMixMatchBundle(container, bundle, onAddToCart, onCleanup) {
466
+ const wc = bundle.widgetConfig;
467
+ const currency = bundle.products[0]?.priceRange.minVariantPrice.currencyCode ?? "USD";
468
+ const requiredQty = bundle.minQuantity ?? 1;
469
+ const maxQty = bundle.maxQuantity ?? requiredQty;
470
+ const eligible = buildEligibleProducts(bundle, wc.outOfStockBehavior);
471
+ const inStockCount = eligible.filter((e) => !e.isOos).length;
472
+ if (inStockCount < requiredQty) return;
473
+ const selections = [];
474
+ const root = el("div", "lb-mix-match", {
475
+ "data-required-quantity": String(requiredQty),
476
+ "data-max-quantity": String(maxQty)
477
+ });
478
+ const header = renderHeader2(bundle);
479
+ root.appendChild(header);
480
+ if (wc.countdown.showCountdown && bundle.endsAt) {
481
+ const countdown = renderCountdown(bundle.endsAt);
482
+ if (countdown) {
483
+ root.appendChild(countdown.el);
484
+ onCleanup?.(countdown.stop);
485
+ }
486
+ }
487
+ const progress = renderProgress(requiredQty);
488
+ root.appendChild(progress.el);
489
+ const slotsContainer = el("div", "lb-mix-match__slots", {
490
+ "data-selection-slots": ""
491
+ });
492
+ root.appendChild(slotsContainer);
493
+ root.appendChild(el("div", "lb-bundle-divider"));
494
+ const pricingSection = renderPricingSection(wc.pricing.showCompareAtPrice);
495
+ root.appendChild(pricingSection.el);
496
+ const savingsBar = wc.savingsBar.visible ? renderSavingsBar2() : null;
497
+ if (savingsBar) root.appendChild(savingsBar.el);
498
+ const placeholder = el("div", "lb-mix-match__price-placeholder", {
499
+ "data-price-placeholder": ""
500
+ });
501
+ const placeholderText = el(
502
+ "span",
503
+ "lb-mix-match__price-placeholder-text"
504
+ );
505
+ placeholderText.textContent = `Select ${requiredQty} items to see price`;
506
+ placeholder.appendChild(placeholderText);
507
+ root.appendChild(placeholder);
508
+ const modal = renderModal(bundle, eligible, currency, {
509
+ showSearch: wc.showSearch,
510
+ onAdd: (product, variant) => addSelection(product, variant),
511
+ onRemove: (productId, variantId) => removeSelection(productId, variantId),
512
+ countFor: (productId, variantId) => selections.filter(
513
+ (s) => s.productId === productId && s.variantId === variantId
514
+ ).length,
515
+ isOverMax: () => selections.length >= maxQty
516
+ });
517
+ root.appendChild(modal.el);
518
+ const cta = document.createElement("button");
519
+ cta.type = "button";
520
+ cta.className = "lb-bundle-cta";
521
+ cta.setAttribute("data-add-bundle", "");
522
+ cta.disabled = true;
523
+ cta.textContent = `Select ${requiredQty} items to unlock`;
524
+ cta.addEventListener("click", () => {
525
+ if (cta.disabled) return;
526
+ const lines = selections.map((s) => ({
527
+ merchandiseId: s.variantId,
528
+ quantity: 1,
529
+ attributes: [
530
+ { key: "_lime_bundle_gid", value: bundle.id },
531
+ { key: "_lime_bundle_type", value: bundle.bundleType }
532
+ ]
533
+ }));
534
+ onAddToCart(lines);
535
+ });
536
+ root.appendChild(cta);
537
+ root.appendChild(
538
+ el("p", "lb-bundle-error", { "data-error": "", "aria-live": "polite" })
539
+ );
540
+ root.appendChild(
541
+ el("span", "lb-visually-hidden", {
542
+ "data-status": "",
543
+ "aria-live": "polite"
544
+ })
545
+ );
546
+ container.appendChild(root);
547
+ renderSlots();
548
+ function addSelection(product, variant) {
549
+ if (selections.length >= maxQty) return;
550
+ selections.push({
551
+ productId: product.id,
552
+ productTitle: product.title,
553
+ variantId: variant.id,
554
+ variantTitle: variant.title,
555
+ imageUrl: product.featuredImage?.url ?? null,
556
+ priceCents: parseCents(variant.price.amount),
557
+ compareCents: variant.compareAtPrice ? parseCents(variant.compareAtPrice.amount) : null
558
+ });
559
+ afterMutation();
560
+ }
561
+ function removeSelection(productId, variantId) {
562
+ const idx = selections.findIndex(
563
+ (s) => s.productId === productId && s.variantId === variantId
564
+ );
565
+ if (idx === -1) return;
566
+ selections.splice(idx, 1);
567
+ afterMutation();
568
+ }
569
+ function removeSlotAt(index) {
570
+ if (index < 0 || index >= selections.length) return;
571
+ selections.splice(index, 1);
572
+ afterMutation();
573
+ }
574
+ function afterMutation() {
575
+ renderSlots();
576
+ progress.update(selections.length);
577
+ pricingSection.update(selections, bundle, currency);
578
+ if (savingsBar) savingsBar.update(selections, bundle, currency);
579
+ placeholder.style.display = selections.length === 0 ? "" : "none";
580
+ modal.refreshCounts();
581
+ updateCta();
582
+ }
583
+ function renderSlots() {
584
+ slotsContainer.innerHTML = "";
585
+ const totalSlots = Math.max(requiredQty, selections.length);
586
+ for (let i = 0; i < totalSlots; i++) {
587
+ const selection = selections[i];
588
+ if (selection) {
589
+ slotsContainer.appendChild(
590
+ renderFilledSlot(selection, i, currency, () => removeSlotAt(i))
591
+ );
592
+ } else {
593
+ slotsContainer.appendChild(
594
+ renderEmptySlot(i, () => modal.open())
595
+ );
596
+ }
597
+ }
598
+ }
599
+ function updateCta() {
600
+ const count = selections.length;
601
+ if (count < requiredQty) {
602
+ cta.disabled = true;
603
+ cta.textContent = `Select ${requiredQty - count} more to unlock`;
604
+ } else {
605
+ cta.disabled = false;
606
+ cta.textContent = wc.cta.ctaText || "Add to cart";
607
+ }
608
+ }
609
+ }
610
+ function renderHeader2(bundle) {
611
+ const wc = bundle.widgetConfig;
612
+ const header = el("div", "lb-bundle-header");
613
+ const content = el("div", "lb-bundle-header__content");
614
+ const title = el("h3", "lb-bundle-title");
615
+ title.textContent = bundle.title;
616
+ content.appendChild(title);
617
+ header.appendChild(content);
618
+ if (wc.pricing.showSaveBadge) {
619
+ const { discountType, discountValue } = bundle.discountConfig;
620
+ let label = null;
621
+ if (discountType === "percentage" && discountValue > 0) {
622
+ label = `-${Math.round(discountValue)}%`;
623
+ } else if (discountType === "fixed_amount" && discountValue > 0) {
624
+ label = `-${formatCents(
625
+ Math.round(discountValue * 100),
626
+ bundle.products[0]?.priceRange.minVariantPrice.currencyCode ?? "USD"
627
+ )}`;
628
+ }
629
+ if (label) {
630
+ const badge = el("span", "lb-bundle-header__badge");
631
+ badge.textContent = label;
632
+ header.appendChild(badge);
633
+ }
634
+ }
635
+ return header;
636
+ }
637
+ function renderProgress(requiredQty) {
638
+ const wrap = el("div", "lb-mix-match__progress");
639
+ const labels = el("div", "lb-mix-match__progress-labels");
640
+ const count = el("span", "lb-mix-match__progress-count", {
641
+ "data-progress-count": ""
642
+ });
643
+ count.textContent = `0 of ${requiredQty} selected`;
644
+ labels.appendChild(count);
645
+ const remaining = el("span", "lb-mix-match__progress-remaining", {
646
+ "data-progress-remaining": ""
647
+ });
648
+ remaining.textContent = `${requiredQty} more to go`;
649
+ labels.appendChild(remaining);
650
+ wrap.appendChild(labels);
651
+ const track = el("div", "lb-mix-match__progress-track", {
652
+ role: "progressbar",
653
+ "aria-valuenow": "0",
654
+ "aria-valuemin": "0",
655
+ "aria-valuemax": String(requiredQty)
656
+ });
657
+ const fill = el("div", "lb-mix-match__progress-fill", {
658
+ "data-progress-fill": ""
659
+ });
660
+ fill.style.width = "0%";
661
+ track.appendChild(fill);
662
+ wrap.appendChild(track);
663
+ function update(selected) {
664
+ const pct = Math.min(100, selected / requiredQty * 100);
665
+ count.textContent = `${selected} of ${requiredQty} selected`;
666
+ if (selected >= requiredQty) {
667
+ remaining.textContent = "Complete";
668
+ } else {
669
+ remaining.textContent = `${requiredQty - selected} more to go`;
670
+ }
671
+ fill.style.width = `${pct}%`;
672
+ track.setAttribute("aria-valuenow", String(Math.min(selected, requiredQty)));
673
+ }
674
+ return { el: wrap, update };
675
+ }
676
+ function renderEmptySlot(index, onClick) {
677
+ const slot = el(
678
+ "div",
679
+ "lb-bundle-product-row lb-mix-match__slot lb-mix-match__slot--empty",
680
+ {
681
+ "data-slot": String(index + 1),
682
+ tabindex: "0",
683
+ role: "button",
684
+ "aria-label": "Add a product to the bundle"
685
+ }
686
+ );
687
+ const thumb = el("div", "lb-mix-match__empty-thumb");
688
+ thumb.innerHTML = PLUS_ICON_SVG;
689
+ slot.appendChild(thumb);
690
+ const text = el("span", "lb-mix-match__empty-text");
691
+ text.textContent = "Choose an item";
692
+ slot.appendChild(text);
693
+ slot.addEventListener("click", onClick);
694
+ slot.addEventListener("keydown", (e) => {
695
+ if (e.key === "Enter" || e.key === " ") {
696
+ e.preventDefault();
697
+ onClick();
698
+ }
699
+ });
700
+ return slot;
701
+ }
702
+ function renderFilledSlot(selection, index, currency, onRemove) {
703
+ const slot = el(
704
+ "div",
705
+ "lb-bundle-product-row lb-mix-match__slot lb-mix-match__slot--filled",
706
+ { "data-slot": String(index + 1) }
707
+ );
708
+ const thumb = el("div", "lb-bundle-thumbnail", { "data-thumbnail": "" });
709
+ if (selection.imageUrl) {
710
+ const img = document.createElement("img");
711
+ img.src = transformImageUrl(selection.imageUrl, {
712
+ width: THUMB_PX,
713
+ height: THUMB_PX,
714
+ crop: "center"
715
+ });
716
+ img.alt = selection.productTitle;
717
+ img.width = THUMB_PX;
718
+ img.height = THUMB_PX;
719
+ img.loading = "lazy";
720
+ thumb.appendChild(img);
721
+ } else {
722
+ thumb.insertAdjacentHTML("beforeend", PLACEHOLDER_THUMB_SVG2);
723
+ }
724
+ slot.appendChild(thumb);
725
+ const info = el("div", "lb-mix-match__filled-info");
726
+ const title = el("span", "lb-mix-match__filled-title");
727
+ title.textContent = selection.productTitle;
728
+ info.appendChild(title);
729
+ if (selection.variantTitle && selection.variantTitle !== "Default Title") {
730
+ const variant = el("span", "lb-mix-match__filled-variant");
731
+ variant.textContent = selection.variantTitle;
732
+ info.appendChild(variant);
733
+ }
734
+ const priceWrap = el("span", "lb-mix-match__filled-price");
735
+ if (selection.compareCents && selection.compareCents > selection.priceCents) {
736
+ const compare = el("span", "lb-mix-match__filled-compare");
737
+ compare.textContent = formatCents(selection.compareCents, currency);
738
+ priceWrap.appendChild(compare);
739
+ }
740
+ const priceEl = document.createElement("span");
741
+ priceEl.textContent = formatCents(selection.priceCents, currency);
742
+ priceWrap.appendChild(priceEl);
743
+ info.appendChild(priceWrap);
744
+ slot.appendChild(info);
745
+ const remove = document.createElement("button");
746
+ remove.type = "button";
747
+ remove.className = "lb-mix-match__slot-remove";
748
+ remove.setAttribute("aria-label", `Remove ${selection.productTitle}`);
749
+ remove.innerHTML = CLOSE_ICON_SVG;
750
+ remove.addEventListener("click", (e) => {
751
+ e.stopPropagation();
752
+ onRemove();
753
+ });
754
+ slot.appendChild(remove);
755
+ return slot;
756
+ }
757
+ function renderPricingSection(showCompareAtPrice) {
758
+ const wrap = el("div", "lb-bundle-pricing", { "data-pricing-section": "" });
759
+ wrap.style.display = "none";
760
+ const label = el("span", "lb-bundle-pricing__label");
761
+ label.textContent = "Bundle price";
762
+ wrap.appendChild(label);
763
+ const prices = el("span", "lb-bundle-pricing__prices");
764
+ const compare = el("span", "lb-bundle-compare-price", {
765
+ "data-compare-price": ""
766
+ });
767
+ if (showCompareAtPrice) prices.appendChild(compare);
768
+ const sale = el("span", "lb-bundle-sale-price", { "data-sale-price": "" });
769
+ prices.appendChild(sale);
770
+ wrap.appendChild(prices);
771
+ function update(selections, bundle, currency) {
772
+ if (selections.length === 0) {
773
+ wrap.style.display = "none";
774
+ return;
775
+ }
776
+ wrap.style.display = "";
777
+ const totalCents = selections.reduce((s, sel) => s + sel.priceCents, 0);
778
+ const saleCents = computeBundleSaleCents(totalCents, bundle.discountConfig);
779
+ if (showCompareAtPrice && totalCents > saleCents) {
780
+ compare.textContent = formatCents(totalCents, currency);
781
+ compare.style.display = "";
782
+ } else {
783
+ compare.style.display = "none";
784
+ }
785
+ sale.textContent = formatCents(saleCents, currency);
786
+ }
787
+ return { el: wrap, update };
788
+ }
789
+ function renderSavingsBar2() {
790
+ const wrap = el("div", "lb-bundle-savings-bar", {
791
+ "data-savings-bar": ""
792
+ });
793
+ wrap.style.display = "none";
794
+ const labelEl = document.createElement("span");
795
+ labelEl.textContent = "You save";
796
+ wrap.appendChild(labelEl);
797
+ const amount = el("span", "", { "data-savings-amount": "" });
798
+ wrap.appendChild(amount);
799
+ function update(selections, bundle, currency) {
800
+ if (selections.length === 0) {
801
+ wrap.style.display = "none";
802
+ return;
803
+ }
804
+ const totalCents = selections.reduce((s, sel) => s + sel.priceCents, 0);
805
+ const saleCents = computeBundleSaleCents(totalCents, bundle.discountConfig);
806
+ const savings = Math.max(0, totalCents - saleCents);
807
+ if (savings <= 0) {
808
+ wrap.style.display = "none";
809
+ return;
810
+ }
811
+ wrap.style.display = "";
812
+ amount.textContent = formatCents(savings, currency);
813
+ }
814
+ return { el: wrap, update };
815
+ }
816
+ function renderModal(bundle, eligible, currency, handlers) {
817
+ const overlay = el("div", "lb-mix-match__modal-overlay", {
818
+ "data-modal-overlay": "",
819
+ "data-bundle-gid": bundle.id
820
+ });
821
+ overlay.style.display = "none";
822
+ const modal = el("div", "lb-mix-match__modal", {
823
+ role: "dialog",
824
+ "aria-modal": "true",
825
+ "aria-labelledby": `lb-modal-title-${sanitizeId(bundle.id)}`,
826
+ tabindex: "-1"
827
+ });
828
+ const modalHeader = el("div", "lb-mix-match__modal-header");
829
+ const modalTitle = el("h4", "lb-mix-match__modal-title", {
830
+ id: `lb-modal-title-${sanitizeId(bundle.id)}`
831
+ });
832
+ modalTitle.textContent = "Pick an item";
833
+ modalHeader.appendChild(modalTitle);
834
+ const closeBtn = document.createElement("button");
835
+ closeBtn.type = "button";
836
+ closeBtn.className = "lb-mix-match__modal-close";
837
+ closeBtn.setAttribute("data-modal-close", "");
838
+ closeBtn.setAttribute("aria-label", "Close");
839
+ closeBtn.innerHTML = CLOSE_ICON_SVG;
840
+ closeBtn.addEventListener("click", close);
841
+ modalHeader.appendChild(closeBtn);
842
+ modal.appendChild(modalHeader);
843
+ let searchInput = null;
844
+ let searchClearBtn = null;
845
+ if (handlers.showSearch) {
846
+ const searchWrap = el("div", "lb-mix-match__modal-search");
847
+ searchInput = document.createElement("input");
848
+ searchInput.type = "text";
849
+ searchInput.className = "lb-mix-match__modal-search-input";
850
+ searchInput.setAttribute("data-modal-search", "");
851
+ searchInput.setAttribute("role", "searchbox");
852
+ searchInput.setAttribute("aria-label", "Search products");
853
+ searchInput.setAttribute("placeholder", "Search products");
854
+ searchInput.autocomplete = "off";
855
+ searchInput.addEventListener("input", () => applySearch());
856
+ searchWrap.appendChild(searchInput);
857
+ searchClearBtn = document.createElement("button");
858
+ searchClearBtn.type = "button";
859
+ searchClearBtn.className = "lb-mix-match__modal-search-clear";
860
+ searchClearBtn.setAttribute("data-modal-search-clear", "");
861
+ searchClearBtn.setAttribute("aria-label", "Clear search");
862
+ searchClearBtn.style.display = "none";
863
+ searchClearBtn.innerHTML = SEARCH_CLEAR_ICON_SVG;
864
+ searchClearBtn.addEventListener("click", () => {
865
+ if (!searchInput) return;
866
+ searchInput.value = "";
867
+ applySearch();
868
+ searchInput.focus();
869
+ });
870
+ searchWrap.appendChild(searchClearBtn);
871
+ modal.appendChild(searchWrap);
872
+ }
873
+ const list = el("div", "lb-mix-match__modal-list", {
874
+ "data-modal-list": ""
875
+ });
876
+ modal.appendChild(list);
877
+ const empty = el("div", "lb-mix-match__modal-empty", {
878
+ "data-modal-empty": ""
879
+ });
880
+ empty.style.display = "none";
881
+ const emptyText = document.createElement("p");
882
+ emptyText.textContent = "No products match your search.";
883
+ empty.appendChild(emptyText);
884
+ modal.appendChild(empty);
885
+ const live = el("span", "lb-visually-hidden", {
886
+ "data-modal-live": "",
887
+ "aria-live": "polite"
888
+ });
889
+ modal.appendChild(live);
890
+ overlay.appendChild(modal);
891
+ let rowsBuilt = false;
892
+ const productRows = [];
893
+ function buildRows() {
894
+ if (rowsBuilt) return;
895
+ rowsBuilt = true;
896
+ list.innerHTML = "";
897
+ eligible.forEach((ep) => {
898
+ const variant = ep.firstAvailableVariant ?? ep.variants[0];
899
+ if (!variant) return;
900
+ const productEl = el(
901
+ "div",
902
+ ep.isOos ? "lb-mix-match__modal-product lb-mix-match__modal-product--sold-out" : "lb-mix-match__modal-product",
903
+ { "data-product-id": ep.product.id.replace(/^.*\//, "") }
904
+ );
905
+ const thumb = el("div", "lb-mix-match__modal-product-thumb");
906
+ if (ep.product.featuredImage) {
907
+ const img = document.createElement("img");
908
+ img.src = transformImageUrl(ep.product.featuredImage.url, {
909
+ width: THUMB_PX,
910
+ height: THUMB_PX,
911
+ crop: "center"
912
+ });
913
+ img.alt = ep.product.featuredImage.altText ?? ep.product.title;
914
+ img.width = THUMB_PX;
915
+ img.height = THUMB_PX;
916
+ img.loading = "lazy";
917
+ thumb.appendChild(img);
918
+ } else {
919
+ thumb.insertAdjacentHTML("beforeend", PLACEHOLDER_THUMB_SVG2);
920
+ }
921
+ productEl.appendChild(thumb);
922
+ const info = el("div", "lb-mix-match__modal-product-info");
923
+ const title = el("span", "lb-mix-match__modal-product-title");
924
+ title.textContent = ep.product.title;
925
+ info.appendChild(title);
926
+ const price = el("span", "lb-mix-match__modal-product-price");
927
+ price.textContent = formatCents(parseCents(variant.price.amount), currency);
928
+ info.appendChild(price);
929
+ if (ep.isOos) {
930
+ const soldOut = el("span", "lb-mix-match__modal-sold-out-label");
931
+ soldOut.textContent = "Sold out";
932
+ info.appendChild(soldOut);
933
+ }
934
+ productEl.appendChild(info);
935
+ if (!ep.isOos) {
936
+ const addBtn = document.createElement("button");
937
+ addBtn.type = "button";
938
+ addBtn.className = "lb-mix-match__modal-add";
939
+ addBtn.setAttribute("aria-label", `Add ${ep.product.title}`);
940
+ addBtn.innerHTML = PLUS_ICON_SVG;
941
+ const countBadge = el("span", "lb-bundle-qty-badge");
942
+ countBadge.style.display = "none";
943
+ addBtn.appendChild(countBadge);
944
+ addBtn.addEventListener("click", () => {
945
+ if (handlers.isOverMax()) return;
946
+ handlers.onAdd(ep.product, variant);
947
+ });
948
+ productEl.appendChild(addBtn);
949
+ const removeBtn = document.createElement("button");
950
+ removeBtn.type = "button";
951
+ removeBtn.className = "lb-mix-match__slot-remove";
952
+ removeBtn.setAttribute("aria-label", `Remove one ${ep.product.title}`);
953
+ removeBtn.style.display = "none";
954
+ removeBtn.innerHTML = CLOSE_ICON_SVG;
955
+ removeBtn.addEventListener("click", (e) => {
956
+ e.stopPropagation();
957
+ handlers.onRemove(ep.product.id, variant.id);
958
+ });
959
+ productEl.appendChild(removeBtn);
960
+ productRows.push({
961
+ el: productEl,
962
+ product: ep.product,
963
+ variant,
964
+ updateCount: () => {
965
+ const count = handlers.countFor(ep.product.id, variant.id);
966
+ if (count > 0) {
967
+ countBadge.textContent = String(count);
968
+ countBadge.style.display = "";
969
+ removeBtn.style.display = "";
970
+ } else {
971
+ countBadge.style.display = "none";
972
+ removeBtn.style.display = "none";
973
+ }
974
+ }
975
+ });
976
+ } else {
977
+ productRows.push({
978
+ el: productEl,
979
+ product: ep.product,
980
+ variant,
981
+ updateCount: () => {
982
+ }
983
+ });
984
+ }
985
+ list.appendChild(productEl);
986
+ });
987
+ refreshCounts();
988
+ }
989
+ function applySearch() {
990
+ if (!searchInput) return;
991
+ const query = searchInput.value.trim().toLowerCase();
992
+ if (searchClearBtn) {
993
+ searchClearBtn.style.display = query ? "" : "none";
994
+ }
995
+ let visibleCount = 0;
996
+ productRows.forEach((row) => {
997
+ const match = !query || row.product.title.toLowerCase().includes(query);
998
+ row.el.style.display = match ? "" : "none";
999
+ if (match) visibleCount++;
1000
+ });
1001
+ empty.style.display = visibleCount === 0 && query ? "" : "none";
1002
+ }
1003
+ let lastFocused = null;
1004
+ function onKeydown(e) {
1005
+ if (e.key === "Escape") {
1006
+ e.preventDefault();
1007
+ close();
1008
+ return;
1009
+ }
1010
+ if (e.key === "Tab") {
1011
+ trapFocus(e, modal);
1012
+ }
1013
+ }
1014
+ let isOpen = false;
1015
+ function open() {
1016
+ if (isOpen) return;
1017
+ if (handlers.isOverMax()) return;
1018
+ isOpen = true;
1019
+ buildRows();
1020
+ lastFocused = overlay.getRootNode().activeElement;
1021
+ overlay.style.display = "";
1022
+ overlay.classList.add("lb-mix-match__modal-overlay--open");
1023
+ modal.focus();
1024
+ document.addEventListener("keydown", onKeydown);
1025
+ overlay.addEventListener("click", onOverlayClick);
1026
+ }
1027
+ function close() {
1028
+ if (!isOpen) return;
1029
+ isOpen = false;
1030
+ overlay.classList.remove("lb-mix-match__modal-overlay--open");
1031
+ overlay.style.display = "none";
1032
+ document.removeEventListener("keydown", onKeydown);
1033
+ overlay.removeEventListener("click", onOverlayClick);
1034
+ if (lastFocused instanceof HTMLElement) {
1035
+ lastFocused.focus();
1036
+ }
1037
+ }
1038
+ function onOverlayClick(e) {
1039
+ if (e.target === overlay) close();
1040
+ }
1041
+ function refreshCounts() {
1042
+ productRows.forEach((r) => r.updateCount());
1043
+ }
1044
+ return { el: overlay, open, close, refreshCounts };
1045
+ }
1046
+ function buildEligibleProducts(bundle, oosBehavior) {
1047
+ const result = [];
1048
+ const seen = /* @__PURE__ */ new Set();
1049
+ for (const product of bundle.products) {
1050
+ if (seen.has(product.id)) continue;
1051
+ seen.add(product.id);
1052
+ const available = product.variants.nodes.filter((v) => v.availableForSale);
1053
+ const isOos = available.length === 0;
1054
+ if (isOos && oosBehavior === "hide") continue;
1055
+ result.push({
1056
+ product,
1057
+ variants: product.variants.nodes,
1058
+ firstAvailableVariant: available[0] ?? null,
1059
+ isOos
1060
+ });
1061
+ }
1062
+ return result;
1063
+ }
1064
+ function trapFocus(e, container) {
1065
+ const focusables = container.querySelectorAll(
1066
+ 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
1067
+ );
1068
+ if (focusables.length === 0) return;
1069
+ const first = focusables[0];
1070
+ const last = focusables[focusables.length - 1];
1071
+ const active = container.getRootNode().activeElement;
1072
+ if (e.shiftKey && active === first) {
1073
+ e.preventDefault();
1074
+ last.focus();
1075
+ } else if (!e.shiftKey && active === last) {
1076
+ e.preventDefault();
1077
+ first.focus();
1078
+ }
1079
+ }
1080
+ function sanitizeId(gid) {
1081
+ return gid.replace(/[^a-zA-Z0-9_-]/g, "-");
1082
+ }
1083
+
1084
+ // src/renderers/volume.ts
1085
+ function renderVolumeBundle(container, bundle, onAddToCart, onCleanup) {
1086
+ const wc = bundle.widgetConfig;
1087
+ const product = bundle.products[0];
1088
+ const variant = product?.variants.nodes.find((v) => v.availableForSale);
1089
+ if (!variant && wc.outOfStockBehavior === "hide") return;
1090
+ const basePriceCents = variant ? parseCents(variant.price.amount) : 0;
1091
+ const currency = variant?.price.currencyCode ?? "USD";
1092
+ const resolved = bundle.volumeTiers.map((tier, index) => {
1093
+ let perUnit;
1094
+ if (tier.discountType === "fixed_amount") {
1095
+ const amt = Math.round(tier.discountValue * 100);
1096
+ perUnit = Math.max(0, basePriceCents - amt);
1097
+ } else {
1098
+ const discount = Math.floor(
1099
+ basePriceCents * tier.discountValue / 100
1100
+ );
1101
+ perUnit = Math.max(0, basePriceCents - discount);
1102
+ }
1103
+ return {
1104
+ tier,
1105
+ index,
1106
+ qty: tier.minQuantity,
1107
+ pricePerUnitCents: perUnit,
1108
+ basePricePerUnitCents: basePriceCents
1109
+ };
1110
+ });
1111
+ const bestTierIndex = pickBestTierIndex(resolved);
1112
+ let selectedIndex = wc.defaultTier === "best_value" ? bestTierIndex : 0;
1113
+ if (typeof wc.defaultTier === "number") {
1114
+ selectedIndex = clamp(wc.defaultTier, 0, resolved.length - 1);
1115
+ }
1116
+ const popularIndex = wc.popularBadge.tierIndex !== void 0 ? clamp(wc.popularBadge.tierIndex, 0, resolved.length - 1) : bestTierIndex;
1117
+ const root = el("div", "lb-volume");
1118
+ root.appendChild(renderHeader3(bundle, resolved, selectedIndex, currency));
1119
+ if (wc.countdown.showCountdown && bundle.endsAt) {
1120
+ const countdown = renderCountdown(bundle.endsAt);
1121
+ if (countdown) {
1122
+ root.appendChild(countdown.el);
1123
+ onCleanup?.(countdown.stop);
1124
+ }
1125
+ }
1126
+ const tierGroup = el("div", "lb-volume__tiers", {
1127
+ role: "radiogroup",
1128
+ "aria-label": "Quantity tiers",
1129
+ "data-tier-group": ""
1130
+ });
1131
+ resolved.forEach((r) => {
1132
+ const tierEl = renderTierCard(
1133
+ r,
1134
+ r.index === selectedIndex,
1135
+ currency,
1136
+ wc.popularBadge.visible && r.index === popularIndex ? wc.popularBadge.text : null,
1137
+ wc.pricing.showComparePrice,
1138
+ wc.pricing.showPerUnitPrice
1139
+ );
1140
+ tierEl.addEventListener("click", () => selectTier(r.index));
1141
+ tierEl.addEventListener("keydown", (e) => {
1142
+ if (e.key === "Enter" || e.key === " ") {
1143
+ e.preventDefault();
1144
+ selectTier(r.index);
1145
+ return;
1146
+ }
1147
+ if (e.key === "ArrowDown" || e.key === "ArrowRight" || e.key === "ArrowUp" || e.key === "ArrowLeft") {
1148
+ e.preventDefault();
1149
+ const delta = e.key === "ArrowDown" || e.key === "ArrowRight" ? 1 : -1;
1150
+ const next = (r.index + delta + resolved.length) % resolved.length;
1151
+ selectTier(next);
1152
+ const target = tierGroup.children[next];
1153
+ target?.focus();
1154
+ }
1155
+ });
1156
+ tierGroup.appendChild(tierEl);
1157
+ });
1158
+ root.appendChild(tierGroup);
1159
+ root.appendChild(el("div", "lb-bundle-divider"));
1160
+ let pricingEl = renderPricingRow2(
1161
+ resolved,
1162
+ selectedIndex,
1163
+ currency,
1164
+ wc.pricing.showItemCount,
1165
+ wc.pricing.showCompareAtPrice
1166
+ );
1167
+ root.appendChild(pricingEl);
1168
+ let savingsBarEl = wc.savingsBar.visible ? renderSavingsBar3(resolved, selectedIndex, currency) : null;
1169
+ if (savingsBarEl) root.appendChild(savingsBarEl);
1170
+ const cta = renderCta2(bundle, () => {
1171
+ if (!variant) return;
1172
+ const r = resolved[selectedIndex];
1173
+ if (!r) return;
1174
+ onAddToCart([
1175
+ {
62
1176
  merchandiseId: variant.id,
63
- quantity: 1,
1177
+ quantity: r.qty,
64
1178
  attributes: [
65
1179
  { key: "_lime_bundle_gid", value: bundle.id },
66
1180
  { key: "_lime_bundle_type", value: bundle.bundleType }
67
1181
  ]
68
- };
1182
+ }
1183
+ ]);
1184
+ });
1185
+ root.appendChild(cta);
1186
+ root.appendChild(
1187
+ el("p", "lb-bundle-error", { "data-error": "", "aria-live": "polite" })
1188
+ );
1189
+ root.appendChild(
1190
+ el("span", "lb-visually-hidden", {
1191
+ "data-status": "",
1192
+ "aria-live": "polite"
1193
+ })
1194
+ );
1195
+ container.appendChild(root);
1196
+ function selectTier(idx) {
1197
+ if (idx === selectedIndex || idx < 0 || idx >= resolved.length) return;
1198
+ selectedIndex = idx;
1199
+ Array.from(tierGroup.children).forEach((card, i) => {
1200
+ card.setAttribute("aria-checked", String(i === idx));
1201
+ card.tabIndex = i === idx ? 0 : -1;
69
1202
  });
70
- if (lines.length === 0) return;
71
- onAddToCart(lines);
1203
+ const newPricing = renderPricingRow2(
1204
+ resolved,
1205
+ idx,
1206
+ currency,
1207
+ wc.pricing.showItemCount,
1208
+ wc.pricing.showCompareAtPrice
1209
+ );
1210
+ pricingEl.replaceWith(newPricing);
1211
+ pricingEl = newPricing;
1212
+ if (savingsBarEl) {
1213
+ const newBar = renderSavingsBar3(resolved, idx, currency);
1214
+ savingsBarEl.replaceWith(newBar);
1215
+ savingsBarEl = newBar;
1216
+ }
1217
+ const badgeEl = root.querySelector("[data-header-badge]");
1218
+ if (badgeEl) {
1219
+ const label = badgeFor(resolved[idx], currency);
1220
+ if (label) {
1221
+ badgeEl.textContent = label;
1222
+ badgeEl.style.display = "";
1223
+ } else {
1224
+ badgeEl.style.display = "none";
1225
+ }
1226
+ }
1227
+ }
1228
+ }
1229
+ function renderHeader3(bundle, resolved, selectedIndex, currency) {
1230
+ const wc = bundle.widgetConfig;
1231
+ const header = el("div", "lb-bundle-header");
1232
+ const content = el("div", "lb-bundle-header__content");
1233
+ const title = el("h3", "lb-bundle-title");
1234
+ title.textContent = bundle.title;
1235
+ content.appendChild(title);
1236
+ header.appendChild(content);
1237
+ if (wc.pricing.showSaveBadge) {
1238
+ const badge = badgeFor(resolved[selectedIndex], currency);
1239
+ if (badge) {
1240
+ const badgeEl = el("span", "lb-bundle-header__badge", {
1241
+ "data-header-badge": ""
1242
+ });
1243
+ badgeEl.textContent = badge;
1244
+ header.appendChild(badgeEl);
1245
+ }
1246
+ }
1247
+ return header;
1248
+ }
1249
+ function renderTierCard(r, isSelected, currency, popularLabel, showComparePrice, showPerUnitPrice) {
1250
+ const tier = el("div", "lb-volume__tier", {
1251
+ role: "radio",
1252
+ "aria-checked": String(isSelected),
1253
+ tabindex: isSelected ? "0" : "-1",
1254
+ "data-tier-index": String(r.index),
1255
+ "data-tier-qty": String(r.qty)
1256
+ });
1257
+ const radio = el("span", "lb-volume__radio");
1258
+ radio.appendChild(el("span", "lb-volume__radio-dot"));
1259
+ tier.appendChild(radio);
1260
+ const grid = el("span", "lb-volume__tier-grid");
1261
+ const label = el("span", "lb-volume__tier-label");
1262
+ label.textContent = `Buy ${r.qty}`;
1263
+ grid.appendChild(label);
1264
+ const price = el("span", "lb-volume__tier-price");
1265
+ if (showComparePrice && r.pricePerUnitCents < r.basePricePerUnitCents) {
1266
+ const compare = el("span", "lb-volume__tier-compare");
1267
+ compare.textContent = formatCents(r.basePricePerUnitCents, currency);
1268
+ price.appendChild(compare);
1269
+ }
1270
+ if (showPerUnitPrice) {
1271
+ const each = document.createElement("span");
1272
+ each.setAttribute("data-tier-price-each", "");
1273
+ each.textContent = formatCents(r.pricePerUnitCents, currency);
1274
+ price.appendChild(each);
1275
+ const unit = el("span", "lb-volume__tier-unit");
1276
+ unit.textContent = " each";
1277
+ price.appendChild(unit);
1278
+ }
1279
+ grid.appendChild(price);
1280
+ tier.appendChild(grid);
1281
+ const badge = el("span", "lb-volume__tier-badge");
1282
+ if (popularLabel) {
1283
+ badge.textContent = popularLabel;
1284
+ } else {
1285
+ badge.style.display = "none";
1286
+ }
1287
+ tier.appendChild(badge);
1288
+ return tier;
1289
+ }
1290
+ function renderPricingRow2(resolved, selectedIndex, currency, showItemCount, showCompareAtPrice) {
1291
+ const r = resolved[selectedIndex];
1292
+ const totalCents = r ? r.pricePerUnitCents * r.qty : 0;
1293
+ const undiscountedCents = r ? r.basePricePerUnitCents * r.qty : 0;
1294
+ const savings = Math.max(0, undiscountedCents - totalCents);
1295
+ const row = el("div", "lb-bundle-pricing");
1296
+ const label = el("span", "lb-bundle-pricing__label", {
1297
+ "data-total-label": ""
1298
+ });
1299
+ label.textContent = "Total";
1300
+ if (showItemCount && r) {
1301
+ const count = document.createElement("span");
1302
+ count.setAttribute("data-item-count", "");
1303
+ count.textContent = ` (${r.qty} item${r.qty === 1 ? "" : "s"})`;
1304
+ label.appendChild(count);
1305
+ }
1306
+ row.appendChild(label);
1307
+ const prices = el("span", "lb-bundle-pricing__prices");
1308
+ if (showCompareAtPrice && savings > 0) {
1309
+ const compare = el("span", "lb-bundle-compare-price", {
1310
+ "data-compare-price": ""
1311
+ });
1312
+ compare.textContent = formatCents(undiscountedCents, currency);
1313
+ prices.appendChild(compare);
1314
+ }
1315
+ const sale = el("span", "lb-bundle-sale-price", { "data-total-price": "" });
1316
+ sale.textContent = formatCents(totalCents, currency);
1317
+ prices.appendChild(sale);
1318
+ row.appendChild(prices);
1319
+ return row;
1320
+ }
1321
+ function renderSavingsBar3(resolved, selectedIndex, currency) {
1322
+ const r = resolved[selectedIndex];
1323
+ const totalCents = r ? r.pricePerUnitCents * r.qty : 0;
1324
+ const undiscountedCents = r ? r.basePricePerUnitCents * r.qty : 0;
1325
+ const savings = Math.max(0, undiscountedCents - totalCents);
1326
+ const bar = el("div", "lb-bundle-savings-bar", { "data-savings-bar": "" });
1327
+ if (savings <= 0) bar.style.display = "none";
1328
+ const labelEl = document.createElement("span");
1329
+ labelEl.textContent = "You save";
1330
+ bar.appendChild(labelEl);
1331
+ const amount = el("span", "", { "data-savings-amount": "" });
1332
+ amount.textContent = formatCents(savings, currency);
1333
+ bar.appendChild(amount);
1334
+ return bar;
1335
+ }
1336
+ function renderCta2(bundle, onClick) {
1337
+ const product = bundle.products[0];
1338
+ const isAvailable = product?.variants.nodes.some((v) => v.availableForSale);
1339
+ const button = document.createElement("button");
1340
+ button.type = "button";
1341
+ button.className = "lb-bundle-cta";
1342
+ button.setAttribute("data-add-bundle", "");
1343
+ if (!isAvailable) button.disabled = true;
1344
+ button.textContent = isAvailable ? bundle.widgetConfig.cta.ctaText || "Add to cart" : "Sold out";
1345
+ button.addEventListener("click", () => {
1346
+ if (button.disabled) return;
1347
+ onClick();
72
1348
  });
73
- container.appendChild(button);
1349
+ return button;
1350
+ }
1351
+ function badgeFor(resolved, currency) {
1352
+ if (!resolved) return null;
1353
+ const { tier } = resolved;
1354
+ if (tier.discountType === "fixed_amount" && tier.discountValue > 0) {
1355
+ return `-${formatCents(Math.round(tier.discountValue * 100), currency)}`;
1356
+ }
1357
+ if (tier.discountType === "percentage" && tier.discountValue > 0) {
1358
+ return `-${Math.round(tier.discountValue)}%`;
1359
+ }
1360
+ return null;
1361
+ }
1362
+ function pickBestTierIndex(resolved) {
1363
+ let bestSavings = 0;
1364
+ let bestIndex = 0;
1365
+ resolved.forEach((r, i) => {
1366
+ const savings = r.basePricePerUnitCents - r.pricePerUnitCents;
1367
+ if (savings > bestSavings) {
1368
+ bestSavings = savings;
1369
+ bestIndex = i;
1370
+ }
1371
+ });
1372
+ return bestIndex;
1373
+ }
1374
+ function clamp(n, min, max) {
1375
+ return Math.max(min, Math.min(max, n));
1376
+ }
1377
+
1378
+ // src/lime-bundle.ts
1379
+ import { applyWidgetConfigVars } from "@lime-bundles/core";
1380
+
1381
+ // src/styles/bundle-css.ts
1382
+ var BUNDLE_BASE_CSS = `/* Lime Bundles \u2014 shared base styles for all bundle widget types */
1383
+
1384
+ .lb-bundle-widget.lb-bundle-widget,
1385
+ .lb-bundle-widget.lb-bundle-widget * {
1386
+ line-height: normal;
1387
+ }
1388
+
1389
+ .lb-bundle-widget {
1390
+ /* Internal CSS-only vars (not merchant-configurable). */
1391
+ --lb-text-muted: #666666;
1392
+ --lb-thumbnail-bg: #F0F0F0;
1393
+ --lb-widget-pad: 20px;
1394
+ --lb-progress-color: var(--lb-primary-color);
1395
+
1396
+ font-family: inherit;
1397
+ font-size: 16px;
1398
+ background: var(--lb-bg);
1399
+ border: var(--lb-border-width) solid var(--lb-border);
1400
+ border-radius: var(--lb-radius);
1401
+ padding: var(--lb-widget-pad) var(--lb-widget-pad) 20px;
1402
+ box-sizing: border-box;
1403
+ }
1404
+
1405
+ /* Countdown timer bar \u2014 sits below the gradient header */
1406
+ .lb-bundle-countdown {
1407
+ margin: 0 calc(-1 * var(--lb-widget-pad)) 20px;
1408
+ padding: 12px 20px;
1409
+ background: var(--lb-countdown-bg);
1410
+ border-top: 1px solid rgba(0, 0, 0, 0.06);
1411
+ display: flex;
1412
+ align-items: center;
1413
+ justify-content: space-between;
1414
+ }
1415
+
1416
+ .lb-bundle-countdown__label {
1417
+ display: flex;
1418
+ align-items: center;
1419
+ gap: 8px;
1420
+ }
1421
+
1422
+ .lb-bundle-countdown__label svg {
1423
+ width: 16px;
1424
+ height: 16px;
1425
+ flex-shrink: 0;
1426
+ color: var(--lb-countdown-text);
1427
+ }
1428
+
1429
+ .lb-bundle-countdown__label span {
1430
+ font-size: 12px;
1431
+ font-weight: 500;
1432
+ line-height: 1;
1433
+ color: var(--lb-countdown-text);
1434
+ }
1435
+
1436
+ .lb-bundle-countdown__timer {
1437
+ font-family: 'SF Mono', 'Roboto Mono', ui-monospace, monospace;
1438
+ font-size: 12px;
1439
+ font-weight: 600;
1440
+ line-height: 1;
1441
+ color: var(--lb-countdown-text);
1442
+ letter-spacing: 0.02em;
1443
+ }
1444
+
1445
+ /* Hide wrapper when the inner snippet rendered nothing (product OOS / unfulfillable) */
1446
+ .lb-bundle-widget:not(:has(.lb-fixed, .lb-mix-match, .lb-volume)) {
1447
+ display: none;
1448
+ }
1449
+
1450
+ /* Adjacent widget spacing \u2014 separates multiple bundles on the same product page */
1451
+ .lb-bundle-widget + .lb-bundle-widget {
1452
+ margin-top: 24px;
1453
+ padding-top: 24px;
1454
+ border-top: 1px solid var(--lb-border);
1455
+ }
1456
+
1457
+ /* Header \u2014 gradient banner with title + savings badge */
1458
+ .lb-bundle-header {
1459
+ margin: calc(-1 * var(--lb-widget-pad)) calc(-1 * var(--lb-widget-pad)) 20px;
1460
+ padding: 20px 20px;
1461
+ background: var(--lb-header-bg);
1462
+ /* Match the widget's inner border curve so there's no background gap at the top corners. */
1463
+ border-radius: max(0px, calc(var(--lb-radius) - var(--lb-border-width))) max(0px, calc(var(--lb-radius) - var(--lb-border-width))) 0 0;
1464
+ display: flex;
1465
+ align-items: center;
1466
+ justify-content: space-between;
1467
+ gap: 16px;
1468
+ }
1469
+
1470
+ /* When countdown follows header, remove header bottom margin */
1471
+ .lb-bundle-header:has(+ .lb-bundle-countdown) {
1472
+ margin-bottom: 0;
1473
+ }
1474
+
1475
+ .lb-bundle-header__content {
1476
+ flex: 1;
1477
+ min-width: 0;
1478
+ }
1479
+
1480
+ .lb-bundle-title {
1481
+ font-size: 20px;
1482
+ font-weight: 700;
1483
+ line-height: 28px;
1484
+ letter-spacing: -0.02em;
1485
+ color: var(--lb-text);
1486
+ margin: 0;
1487
+ }
1488
+
1489
+ .lb-bundle-header .lb-bundle-title {
1490
+ color: var(--lb-header-text);
1491
+ }
1492
+
1493
+ .lb-bundle-subtitle {
1494
+ font-size: 16px;
1495
+ font-weight: 400;
1496
+ line-height: 20px;
1497
+ color: var(--lb-text-muted);
1498
+ margin: 4px 0 0;
1499
+ }
1500
+
1501
+ .lb-bundle-header .lb-bundle-subtitle {
1502
+ color: var(--lb-header-text);
1503
+ opacity: 0.85;
1504
+ margin-top: 8px;
1505
+ }
1506
+
1507
+ .lb-bundle-header:has(.lb-bundle-subtitle) {
1508
+ align-items: flex-start;
1509
+ }
1510
+
1511
+ .lb-bundle-header__badge {
1512
+ background: var(--lb-save-badge-bg);
1513
+ color: var(--lb-save-badge-text);
1514
+ border: var(--lb-save-badge-border-width) solid var(--lb-save-badge-border-color);
1515
+ font-size: 16px;
1516
+ font-weight: 700;
1517
+ line-height: 1;
1518
+ padding: 4px 12px;
1519
+ border-radius: var(--lb-save-badge-radius);
1520
+ white-space: nowrap;
1521
+ flex-shrink: 0;
1522
+ }
1523
+
1524
+ /* Override Dawn's \`div:empty { display: none }\` reset for decorative elements */
1525
+ .lb-bundle-divider:empty,
1526
+ .lb-mix-match__progress-fill:empty {
1527
+ display: block;
1528
+ }
1529
+
1530
+ /* Divider */
1531
+ .lb-bundle-divider {
1532
+ height: 1px;
1533
+ background: color-mix(in srgb, var(--lb-text) 7%, transparent);
1534
+ margin: 16px 0;
1535
+ }
1536
+
1537
+ /* Product rows */
1538
+ .lb-bundle-product-row {
1539
+ display: flex;
1540
+ gap: 12px;
1541
+ padding: 12px 0;
1542
+ }
1543
+
1544
+ .lb-bundle-thumbnail {
1545
+ position: relative;
1546
+ width: 48px;
1547
+ height: 48px;
1548
+ min-width: 48px;
1549
+ background: var(--lb-thumbnail-bg);
1550
+ border-radius: 8px;
1551
+ overflow: hidden;
1552
+ display: flex;
1553
+ align-items: center;
1554
+ justify-content: center;
1555
+ }
1556
+
1557
+ .lb-bundle-thumbnail img {
1558
+ width: 100%;
1559
+ height: 100%;
1560
+ object-fit: cover;
1561
+ }
1562
+
1563
+ .lb-bundle-thumbnail svg {
1564
+ width: 28px;
1565
+ height: 28px;
1566
+ color: #BBBBBB;
1567
+ }
1568
+
1569
+ .lb-bundle-product-info {
1570
+ flex: 1;
1571
+ min-width: 0;
1572
+ }
1573
+
1574
+ .lb-bundle-product-name {
1575
+ font-size: 16px;
1576
+ font-weight: 700;
1577
+ line-height: 20px;
1578
+ color: var(--lb-text);
1579
+ margin: 0;
1580
+ text-decoration: none;
1581
+ display: block;
1582
+ }
1583
+
1584
+ .lb-bundle-product-name:hover {
1585
+ text-decoration: underline;
1586
+ }
1587
+
1588
+ .lb-bundle-product-price {
1589
+ /* --lb-product-price-display fallback is the "on" branch; Liquid sets 'none' when merchant disables. */
1590
+ display: var(--lb-product-price-display, block);
1591
+ font-size: 12px;
1592
+ font-weight: 400;
1593
+ line-height: 20px;
1594
+ color: var(--lb-text);
1595
+ }
1596
+
1597
+ .lb-bundle-product-prices {
1598
+ display: flex;
1599
+ align-items: baseline;
1600
+ gap: 6px;
1601
+ flex-wrap: wrap;
1602
+ margin-top: 2px;
1603
+ }
1604
+
1605
+ .lb-bundle-product-compare-price {
1606
+ /* --lb-product-compare-display fallback is the "on" branch; Liquid sets 'none' when merchant disables. */
1607
+ display: var(--lb-product-compare-display, inline);
1608
+ font-size: 12px;
1609
+ font-weight: 400;
1610
+ line-height: 20px;
1611
+ color: color-mix(in srgb, var(--lb-text) 60%, transparent);
1612
+ text-decoration: line-through;
1613
+ }
1614
+
1615
+ /* Setting \`display\` above outranks the UA [hidden] rule \u2014 restore it so rows
1616
+ without a compare-at price don't leave a phantom flex item + gap. */
1617
+ .lb-bundle-product-compare-price[hidden] {
1618
+ display: none;
1619
+ }
1620
+
1621
+ .lb-bundle-variant-badge {
1622
+ display: inline-block;
1623
+ border: var(--lb-variant-border-width) solid var(--lb-variant-border-color);
1624
+ border-radius: var(--lb-variant-radius);
1625
+ padding: 8px 12px;
1626
+ font-size: 12px;
1627
+ line-height: 16px;
1628
+ color: var(--lb-text-muted);
1629
+ margin-top: 8px;
1630
+ }
1631
+
1632
+ .lb-bundle-quantity {
1633
+ font-size: 12px;
1634
+ line-height: 16px;
1635
+ color: var(--lb-text-muted);
1636
+ margin-left: 8px;
1637
+ white-space: nowrap;
1638
+ }
1639
+
1640
+ /* Pricing row \u2014 label left, prices right */
1641
+ .lb-bundle-pricing {
1642
+ display: flex;
1643
+ align-items: baseline;
1644
+ justify-content: space-between;
1645
+ padding: 4px 0 8px;
1646
+ gap: 12px;
1647
+ }
1648
+
1649
+ .lb-bundle-pricing__label {
1650
+ font-size: 16px;
1651
+ font-weight: 400;
1652
+ line-height: 20px;
1653
+ color: var(--lb-text);
1654
+ white-space: nowrap;
1655
+ }
1656
+
1657
+ .lb-bundle-pricing__prices {
1658
+ display: flex;
1659
+ align-items: baseline;
1660
+ gap: 8px;
1661
+ }
1662
+
1663
+ .lb-bundle-sale-price {
1664
+ font-size: 20px;
1665
+ font-weight: 700;
1666
+ line-height: 1;
1667
+ letter-spacing: -0.02em;
1668
+ color: var(--lb-text);
1669
+ }
1670
+
1671
+ .lb-bundle-compare-price {
1672
+ font-size: 16px;
1673
+ font-weight: 400;
1674
+ line-height: 20px;
1675
+ color: color-mix(in srgb, var(--lb-text) 60%, transparent);
1676
+ text-decoration: line-through;
1677
+ }
1678
+
1679
+ /* Savings bar \u2014 green banner below pricing */
1680
+ .lb-bundle-savings-bar {
1681
+ display: flex;
1682
+ align-items: center;
1683
+ justify-content: space-between;
1684
+ background: var(--lb-savings-bar-bg);
1685
+ color: var(--lb-savings-bar-text);
1686
+ border: var(--lb-savings-bar-border-width) solid var(--lb-savings-bar-border-color);
1687
+ font-size: 16px;
1688
+ font-weight: 600;
1689
+ line-height: 20px;
1690
+ padding: 12px 16px;
1691
+ border-radius: var(--lb-savings-bar-radius);
1692
+ margin-bottom: 12px;
1693
+ }
1694
+
1695
+ /* Quantity badge \u2014 overlay on thumbnail top-right */
1696
+ .lb-bundle-qty-badge.lb-bundle-qty-badge {
1697
+ position: absolute;
1698
+ top: -8px;
1699
+ right: -8px;
1700
+ /* --lb-qty-badge-display fallback is the "on" branch; Liquid sets 'none' when merchant disables. */
1701
+ display: var(--lb-qty-badge-display, flex);
1702
+ align-items: center;
1703
+ justify-content: center;
1704
+ width: 24px;
1705
+ height: 24px;
1706
+ border-radius: 50%;
1707
+ background: var(--lb-qty-badge-bg);
1708
+ border: var(--lb-image-border-width) solid var(--lb-image-border-color);
1709
+ color: var(--lb-qty-badge-color);
1710
+ font-size: 12px;
1711
+ font-weight: 700;
1712
+ line-height: 0;
1713
+ text-align: center;
1714
+ z-index: 1;
1715
+ box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08);
1716
+ }
1717
+
1718
+ /* Override Dawn's \`div:empty\` for savings bar when hidden */
1719
+ .lb-bundle-savings-bar:empty {
1720
+ display: none;
1721
+ }
1722
+
1723
+ /* CTA button */
1724
+ .lb-bundle-cta {
1725
+ display: block;
1726
+ width: 100%;
1727
+ padding: 16px;
1728
+ border: var(--lb-cta-border-width) solid var(--lb-cta-border-color);
1729
+ border-radius: var(--lb-cta-radius);
1730
+ font-size: 16px;
1731
+ font-weight: 600;
1732
+ line-height: 20px;
1733
+ cursor: pointer;
1734
+ text-align: center;
1735
+ transition: opacity 0.15s ease;
1736
+ font-family: inherit;
1737
+ }
1738
+
1739
+ .lb-bundle-cta:not(:disabled) {
1740
+ background: var(--lb-primary-color);
1741
+ color: var(--lb-btn-text);
1742
+ }
1743
+
1744
+ .lb-bundle-cta:not(:disabled):hover {
1745
+ opacity: 0.9;
1746
+ }
1747
+
1748
+ .lb-bundle-cta:disabled {
1749
+ background: color-mix(in srgb, var(--lb-primary-color) 35%, var(--lb-bg));
1750
+ color: color-mix(in srgb, var(--lb-btn-text) 85%, transparent);
1751
+ cursor: not-allowed;
1752
+ }
1753
+
1754
+ .lb-bundle-cta[data-loading="true"] {
1755
+ opacity: 0.7;
1756
+ cursor: wait;
1757
+ pointer-events: none;
1758
+ }
1759
+
1760
+ /* Error message */
1761
+ .lb-bundle-error {
1762
+ font-size: 16px;
1763
+ color: #D72C0D;
1764
+ margin-top: 8px;
1765
+ display: none;
1766
+ }
1767
+
1768
+ .lb-bundle-error[data-visible="true"] {
1769
+ display: block;
1770
+ }
1771
+
1772
+ /* Visually hidden \u2014 accessible to screen readers only */
1773
+ .lb-visually-hidden {
1774
+ position: absolute !important;
1775
+ width: 1px !important;
1776
+ height: 1px !important;
1777
+ padding: 0 !important;
1778
+ margin: -1px !important;
1779
+ overflow: hidden !important;
1780
+ clip-path: inset(50%) !important;
1781
+ white-space: nowrap !important;
1782
+ border: 0 !important;
1783
+ }
1784
+
1785
+ /* Placeholder SVG icon for missing images */
1786
+ .lb-bundle-placeholder-icon {
1787
+ width: 28px;
1788
+ height: 28px;
1789
+ stroke: #BBBBBB;
1790
+ stroke-width: 1.5;
1791
+ fill: none;
1792
+ }
1793
+
1794
+ /* Out-of-stock product row */
1795
+ .lb-bundle-product-row--oos {
1796
+ opacity: 0.5;
1797
+ }
1798
+
1799
+ .lb-bundle-oos-label {
1800
+ font-size: 12px;
1801
+ font-weight: 500;
1802
+ color: #D72C0D;
1803
+ white-space: nowrap;
1804
+ margin-left: auto;
1805
+ }
1806
+
1807
+ /* A/B test: hide save badge until JS swaps the label (prevents flash of default) */
1808
+ .lb-ab-pending {
1809
+ visibility: hidden;
1810
+ }
1811
+ `;
1812
+ var BUNDLE_FIXED_CSS = `/* Lime Bundles \u2014 Fixed bundle styles */
1813
+
1814
+ .lb-fixed__products {
1815
+ display: flex;
1816
+ flex-direction: column;
1817
+ gap: 0;
1818
+ margin: 0;
1819
+ }
1820
+
1821
+ /* Fixed bundles: product rows */
1822
+ .lb-fixed .lb-bundle-product-row {
1823
+ gap: 16px;
1824
+ align-items: center;
1825
+ }
1826
+
1827
+ /* Fixed bundles: larger thumbnails */
1828
+ .lb-fixed .lb-bundle-thumbnail {
1829
+ width: 60px;
1830
+ height: 60px;
1831
+ min-width: 60px;
1832
+ border-radius: var(--lb-image-border-radius);
1833
+ border: var(--lb-image-border-width) solid var(--lb-image-border-color);
1834
+ box-sizing: border-box;
1835
+ overflow: visible;
1836
+ }
1837
+
1838
+ .lb-fixed .lb-bundle-thumbnail img {
1839
+ border-radius: max(0px, calc(var(--lb-image-border-radius) - var(--lb-image-border-width)));
1840
+ border: none;
1841
+ }
1842
+
1843
+ /* Variant picker select \u2014 styled to match the variant badge aesthetic */
1844
+ .lb-bundle-variant-select {
1845
+ margin-top: 8px;
1846
+ display: inline-block;
1847
+ border: var(--lb-variant-border-width) solid var(--lb-variant-border-color);
1848
+ border-radius: var(--lb-variant-radius);
1849
+ padding: 8px 32px 8px 12px;
1850
+ font-size: 12px;
1851
+ line-height: 16px;
1852
+ color: var(--lb-text);
1853
+ background: var(--lb-bg);
1854
+ font-family: inherit;
1855
+ cursor: pointer;
1856
+ appearance: none;
1857
+ -webkit-appearance: none;
1858
+ background-image: var(--lb-variant-chevron);
1859
+ background-repeat: no-repeat;
1860
+ background-position: right 8px center;
1861
+ background-size: 12px;
1862
+ max-width: 100%;
1863
+ }
1864
+
1865
+ .lb-bundle-variant-select:focus-visible {
1866
+ outline: 2px solid var(--lb-primary-color);
1867
+ outline-offset: 2px;
1868
+ }
1869
+ `;
1870
+ var BUNDLE_MIX_MATCH_CSS = `/* Lime Bundles \u2014 Mix & Match styles */
1871
+
1872
+ /* === Progress Bar === */
1873
+ .lb-mix-match__progress {
1874
+ margin-bottom: 16px;
1875
+ }
1876
+
1877
+ .lb-mix-match__progress-labels {
1878
+ display: flex;
1879
+ justify-content: space-between;
1880
+ margin-bottom: 8px;
1881
+ }
1882
+
1883
+ .lb-mix-match__progress-count {
1884
+ font-size: 12px;
1885
+ font-weight: 500;
1886
+ line-height: 16px;
1887
+ color: color-mix(in srgb, var(--lb-text) 60%, transparent);
1888
+ }
1889
+
1890
+ .lb-mix-match__progress-remaining {
1891
+ font-size: 12px;
1892
+ font-weight: 500;
1893
+ line-height: 16px;
1894
+ color: var(--lb-text);
74
1895
  }
75
- function escapeHtml(str) {
76
- const div = document.createElement("div");
77
- div.textContent = str;
78
- return div.innerHTML;
1896
+
1897
+ .lb-mix-match__progress-track {
1898
+ width: 100%;
1899
+ height: 4px;
1900
+ background: color-mix(in srgb, var(--lb-text) 10%, transparent);
1901
+ border-radius: 4px;
1902
+ overflow: hidden;
79
1903
  }
80
1904
 
81
- // src/renderers/mix-match.ts
82
- import {
83
- formatMoney as formatMoney2,
84
- validateQuantity
85
- } from "@lime-bundles/core";
86
- function renderMixMatchBundle(container, bundle, onAddToCart) {
87
- const currency = bundle.products[0]?.priceRange.minVariantPrice.currencyCode ?? "USD";
88
- const selections = /* @__PURE__ */ new Map();
89
- const title = document.createElement("h3");
90
- title.className = "lb-bundle__title";
91
- title.textContent = bundle.title;
92
- title.setAttribute("part", "title");
93
- container.appendChild(title);
94
- const instructions = document.createElement("p");
95
- instructions.className = "lb-bundle__instructions";
96
- instructions.textContent = bundle.minQuantity && bundle.maxQuantity ? `Select ${bundle.minQuantity}\u2013${bundle.maxQuantity} items` : bundle.minQuantity ? `Select at least ${bundle.minQuantity} items` : "Select your items";
97
- container.appendChild(instructions);
98
- const productsDiv = document.createElement("div");
99
- productsDiv.className = "lb-bundle__products lb-bundle__products--selectable";
100
- for (const product of bundle.products) {
101
- const variant = product.variants.nodes.find((v) => v.availableForSale) ?? product.variants.nodes[0];
102
- if (!variant) continue;
103
- const productEl = document.createElement("div");
104
- productEl.className = "lb-bundle__product lb-bundle__product--selectable";
105
- if (product.featuredImage) {
106
- const img = document.createElement("img");
107
- img.src = product.featuredImage.url;
108
- img.alt = product.featuredImage.altText ?? product.title;
109
- img.className = "lb-bundle__product-image";
110
- img.loading = "lazy";
111
- productEl.appendChild(img);
112
- }
113
- const info = document.createElement("div");
114
- info.className = "lb-bundle__product-info";
115
- info.innerHTML = `
116
- <p class="lb-bundle__product-title">${escapeHtml2(product.title)}</p>
117
- <p class="lb-bundle__product-price">${escapeHtml2(formatMoney2(variant.price.amount, currency))}</p>
118
- `;
119
- productEl.appendChild(info);
120
- const selectBtn = document.createElement("button");
121
- selectBtn.className = "lb-bundle__select-btn";
122
- selectBtn.textContent = variant.availableForSale ? "Select" : "Sold out";
123
- selectBtn.disabled = !variant.availableForSale;
124
- selectBtn.addEventListener("click", () => {
125
- const key = product.id;
126
- if (selections.has(key)) {
127
- selections.delete(key);
128
- productEl.classList.remove("lb-bundle__product--selected");
129
- selectBtn.textContent = "Select";
130
- } else {
131
- selections.set(key, { variantId: variant.id, quantity: 1 });
132
- productEl.classList.add("lb-bundle__product--selected");
133
- selectBtn.textContent = "Selected";
134
- }
135
- updateCta();
136
- });
137
- productEl.appendChild(selectBtn);
138
- productsDiv.appendChild(productEl);
1905
+ .lb-mix-match__progress-fill {
1906
+ height: 100%;
1907
+ background: var(--lb-text);
1908
+ border-radius: 4px;
1909
+ transition: width 0.3s ease;
1910
+ }
1911
+
1912
+ /* === Slots === */
1913
+ .lb-mix-match__slot {
1914
+ cursor: pointer;
1915
+ }
1916
+
1917
+ .lb-mix-match .lb-bundle-product-row {
1918
+ align-items: center;
1919
+ }
1920
+
1921
+ .lb-mix-match__slot--empty:focus-visible {
1922
+ outline: 2px solid var(--lb-primary-color);
1923
+ outline-offset: 2px;
1924
+ border-radius: 8px;
1925
+ }
1926
+
1927
+ .lb-mix-match__slot--empty .lb-mix-match__empty-thumb {
1928
+ width: 60px;
1929
+ height: 60px;
1930
+ min-width: 60px;
1931
+ border: var(--lb-image-border-width) dashed color-mix(in srgb, var(--lb-text) 20%, transparent);
1932
+ border-radius: var(--lb-image-border-radius);
1933
+ box-sizing: border-box;
1934
+ display: flex;
1935
+ align-items: center;
1936
+ justify-content: center;
1937
+ color: color-mix(in srgb, var(--lb-text) 35%, transparent);
1938
+ }
1939
+
1940
+ .lb-mix-match__empty-text {
1941
+ font-size: 16px;
1942
+ line-height: 20px;
1943
+ color: color-mix(in srgb, var(--lb-text) 45%, transparent);
1944
+ }
1945
+
1946
+ /* Filled slot */
1947
+ .lb-mix-match__slot--filled {
1948
+ cursor: default;
1949
+ }
1950
+
1951
+ .lb-mix-match .lb-bundle-thumbnail {
1952
+ width: 60px;
1953
+ height: 60px;
1954
+ min-width: 60px;
1955
+ border-radius: var(--lb-image-border-radius);
1956
+ border: var(--lb-image-border-width) solid var(--lb-image-border-color);
1957
+ box-sizing: border-box;
1958
+ overflow: visible;
1959
+ }
1960
+
1961
+ .lb-mix-match .lb-bundle-thumbnail img {
1962
+ border-radius: max(0px, calc(var(--lb-image-border-radius) - var(--lb-image-border-width)));
1963
+ border: none;
1964
+ }
1965
+
1966
+ .lb-mix-match__slot--filled .lb-mix-match__filled-info {
1967
+ flex: 1;
1968
+ min-width: 0;
1969
+ display: flex;
1970
+ flex-direction: column;
1971
+ }
1972
+
1973
+ .lb-mix-match__slot--filled .lb-mix-match__filled-title {
1974
+ font-size: 16px;
1975
+ font-weight: 700;
1976
+ line-height: 20px;
1977
+ color: var(--lb-text);
1978
+ text-decoration: none;
1979
+ overflow-wrap: break-word;
1980
+ }
1981
+
1982
+ .lb-mix-match__slot--filled a.lb-mix-match__filled-title:hover {
1983
+ text-decoration: underline;
1984
+ }
1985
+
1986
+ .lb-mix-match__slot--filled .lb-mix-match__filled-variant {
1987
+ font-size: 12px;
1988
+ line-height: 20px;
1989
+ margin-top: 2px;
1990
+ color: color-mix(in srgb, var(--lb-text) 60%, transparent);
1991
+ }
1992
+
1993
+ .lb-mix-match__filled-price {
1994
+ /* --lb-product-price-display fallback is the "on" branch; Liquid sets 'none' when merchant disables. */
1995
+ display: var(--lb-product-price-display, block);
1996
+ font-size: 12px;
1997
+ line-height: 20px;
1998
+ margin-top: 4px;
1999
+ color: var(--lb-text);
2000
+ font-weight: 500;
2001
+ }
2002
+
2003
+ .lb-mix-match__filled-compare {
2004
+ /* --lb-product-compare-display fallback is the "on" branch; Liquid sets 'none' when merchant disables. */
2005
+ display: var(--lb-product-compare-display, inline);
2006
+ font-size: 12px;
2007
+ font-weight: 400;
2008
+ color: color-mix(in srgb, var(--lb-text) 60%, transparent);
2009
+ text-decoration: line-through;
2010
+ margin-right: 4px;
2011
+ }
2012
+
2013
+ .lb-mix-match__slot-remove {
2014
+ min-width: 44px;
2015
+ min-height: 44px;
2016
+ display: flex;
2017
+ align-items: center;
2018
+ justify-content: center;
2019
+ background: none;
2020
+ border: none;
2021
+ cursor: pointer;
2022
+ color: var(--lb-text);
2023
+ padding: 0;
2024
+ margin-left: auto;
2025
+ border-radius: 8px;
2026
+ }
2027
+
2028
+ .lb-mix-match__slot-remove:hover {
2029
+ color: var(--lb-text);
2030
+ }
2031
+
2032
+ .lb-mix-match__slot-remove:focus-visible {
2033
+ outline: 2px solid var(--lb-primary-color);
2034
+ outline-offset: 2px;
2035
+ }
2036
+
2037
+ /* Price placeholder */
2038
+ .lb-mix-match__price-placeholder {
2039
+ padding: 4px 0 16px;
2040
+ text-align: center;
2041
+ }
2042
+
2043
+ .lb-mix-match__price-placeholder-text {
2044
+ font-size: 16px;
2045
+ color: var(--lb-text);
2046
+ }
2047
+
2048
+ /* === Modal Overlay === */
2049
+ .lb-mix-match__modal-overlay {
2050
+ position: fixed;
2051
+ top: 0;
2052
+ left: 0;
2053
+ right: 0;
2054
+ bottom: 0;
2055
+ background: rgba(0, 0, 0, 0.5);
2056
+ z-index: 9999;
2057
+ display: flex;
2058
+ align-items: center;
2059
+ justify-content: center;
2060
+ opacity: 0;
2061
+ transition: opacity 0.2s ease-out;
2062
+ }
2063
+
2064
+ .lb-mix-match__modal-overlay--open {
2065
+ opacity: 1;
2066
+ }
2067
+
2068
+ /* === Modal Panel === */
2069
+ .lb-mix-match__modal {
2070
+ background: var(--lb-picker-bg);
2071
+ color: var(--lb-picker-text);
2072
+ border-radius: var(--lb-picker-radius);
2073
+ border: var(--lb-picker-border-width) solid var(--lb-picker-border-color);
2074
+ box-sizing: border-box;
2075
+ width: 100%;
2076
+ max-width: 480px;
2077
+ max-height: 70vh;
2078
+ display: flex;
2079
+ flex-direction: column;
2080
+ overflow: hidden;
2081
+ box-shadow: 0 8px 32px rgba(0, 0, 0, 0.16);
2082
+ transform: translateY(24px);
2083
+ transition: transform 0.25s ease-out;
2084
+ will-change: transform;
2085
+ }
2086
+
2087
+ .lb-mix-match__modal-overlay--open .lb-mix-match__modal {
2088
+ transform: translateY(0);
2089
+ }
2090
+
2091
+ /* === Modal Header === */
2092
+ .lb-mix-match__modal-header {
2093
+ display: flex;
2094
+ align-items: center;
2095
+ justify-content: space-between;
2096
+ padding: 20px 20px 12px;
2097
+ flex-shrink: 0;
2098
+ }
2099
+
2100
+ .lb-mix-match__modal-title {
2101
+ font-size: 20px;
2102
+ font-weight: 600;
2103
+ line-height: 24px;
2104
+ margin: 0;
2105
+ color: inherit;
2106
+ }
2107
+
2108
+ .lb-mix-match__modal-close {
2109
+ min-width: 44px;
2110
+ min-height: 44px;
2111
+ display: flex;
2112
+ align-items: center;
2113
+ justify-content: center;
2114
+ background: none;
2115
+ border: none;
2116
+ cursor: pointer;
2117
+ color: var(--lb-picker-text);
2118
+ border-radius: 8px;
2119
+ padding: 0;
2120
+ margin: -12px -12px -12px 0;
2121
+ }
2122
+
2123
+ .lb-mix-match__modal-close:focus-visible {
2124
+ outline: 2px solid var(--lb-primary-color);
2125
+ outline-offset: 2px;
2126
+ }
2127
+
2128
+ /* === Modal Search === */
2129
+ .lb-mix-match__modal-search {
2130
+ padding: 0 20px 12px;
2131
+ position: relative;
2132
+ flex-shrink: 0;
2133
+ }
2134
+
2135
+ .lb-mix-match__modal-search-input {
2136
+ width: 100%;
2137
+ padding: 12px 40px 12px 16px;
2138
+ border: var(--lb-picker-search-border-width) solid var(--lb-picker-search-border-color);
2139
+ border-radius: var(--lb-picker-search-radius);
2140
+ font-size: 16px;
2141
+ line-height: 20px;
2142
+ color: var(--lb-picker-text);
2143
+ background: var(--lb-picker-bg);
2144
+ box-sizing: border-box;
2145
+ -webkit-appearance: none;
2146
+ appearance: none;
2147
+ }
2148
+
2149
+ .lb-mix-match__modal-search-input::placeholder {
2150
+ color: color-mix(in srgb, var(--lb-picker-text) 50%, transparent);
2151
+ }
2152
+
2153
+ .lb-mix-match__modal-search-input:focus {
2154
+ outline: none;
2155
+ box-shadow: 0 0 0 1px var(--lb-primary-color);
2156
+ }
2157
+
2158
+ .lb-mix-match__modal-search-clear {
2159
+ position: absolute;
2160
+ right: 32px;
2161
+ /* Anchor to the input area only \u2014 parent has padding-bottom: 12px which would
2162
+ otherwise push a top:50% center down by 6px. */
2163
+ top: 0;
2164
+ bottom: 12px;
2165
+ min-width: 32px;
2166
+ min-height: 32px;
2167
+ display: flex;
2168
+ align-items: center;
2169
+ justify-content: center;
2170
+ background: none;
2171
+ border: none;
2172
+ cursor: pointer;
2173
+ color: var(--lb-picker-text);
2174
+ padding: 0;
2175
+ }
2176
+
2177
+ /* === Modal Product List === */
2178
+ .lb-mix-match__modal-list {
2179
+ overflow-y: auto;
2180
+ flex: 1;
2181
+ padding: 0 20px;
2182
+ -webkit-overflow-scrolling: touch;
2183
+ }
2184
+
2185
+ .lb-mix-match__modal-product {
2186
+ display: flex;
2187
+ align-items: center;
2188
+ gap: 12px;
2189
+ padding: 12px 0;
2190
+ border-bottom: 1px solid color-mix(in srgb, var(--lb-picker-text) 7%, transparent);
2191
+ }
2192
+
2193
+ .lb-mix-match__modal-product:last-child {
2194
+ border-bottom: none;
2195
+ }
2196
+
2197
+ .lb-mix-match__modal-product-thumb {
2198
+ position: relative;
2199
+ width: 48px;
2200
+ height: 48px;
2201
+ min-width: 48px;
2202
+ border-radius: var(--lb-picker-product-radius);
2203
+ border: var(--lb-picker-product-border-width) solid var(--lb-picker-product-border-color);
2204
+ box-sizing: border-box;
2205
+ overflow: visible;
2206
+ background: #F0F0F0;
2207
+ }
2208
+
2209
+ /* Qty badge inside the picker modal inherits picker-product border (width + color) plus
2210
+ inverted picker bg/text for clear contrast against the modal \u2014 always stays round
2211
+ (the badge shape is independent of the thumbnail shape). */
2212
+ .lb-mix-match__modal-product-thumb .lb-bundle-qty-badge.lb-bundle-qty-badge {
2213
+ /* --lb-picker-qty-badge-display fallback is the "on" branch; Liquid sets 'none' when merchant disables. */
2214
+ display: var(--lb-picker-qty-badge-display, flex);
2215
+ background: var(--lb-picker-qty-badge-bg);
2216
+ color: var(--lb-picker-qty-badge-color);
2217
+ border: var(--lb-picker-product-border-width) solid var(--lb-picker-product-border-color);
2218
+ }
2219
+
2220
+ .lb-mix-match__modal-product-thumb img {
2221
+ width: 100%;
2222
+ height: 100%;
2223
+ object-fit: cover;
2224
+ border-radius: max(0px, calc(var(--lb-picker-product-radius) - var(--lb-picker-product-border-width)));
2225
+ }
2226
+
2227
+ .lb-mix-match__modal-product-info {
2228
+ flex: 1;
2229
+ min-width: 0;
2230
+ }
2231
+
2232
+ .lb-mix-match__modal-product-title {
2233
+ font-size: 16px;
2234
+ font-weight: 700;
2235
+ line-height: 20px;
2236
+ color: inherit;
2237
+ margin: 0;
2238
+ }
2239
+
2240
+ .lb-mix-match__modal-product-price {
2241
+ font-size: 12px;
2242
+ line-height: 20px;
2243
+ color: inherit;
2244
+ margin: 4px 0 0;
2245
+ }
2246
+
2247
+ .lb-mix-match__variant-select {
2248
+ font-size: 12px;
2249
+ margin: 4px 0 0;
2250
+ padding: 4px 24px 4px 8px;
2251
+ border: var(--lb-picker-variant-border-width) solid var(--lb-picker-variant-border-color);
2252
+ border-radius: var(--lb-picker-variant-radius);
2253
+ background-color: var(--lb-picker-bg);
2254
+ background-image: var(--lb-picker-variant-chevron);
2255
+ background-repeat: no-repeat;
2256
+ background-position: right 6px center;
2257
+ background-size: 12px;
2258
+ color: var(--lb-picker-text);
2259
+ font-family: inherit;
2260
+ min-height: 32px;
2261
+ cursor: pointer;
2262
+ max-width: 120px;
2263
+ appearance: none;
2264
+ -webkit-appearance: none;
2265
+ }
2266
+
2267
+ .lb-mix-match__variant-select:focus-visible {
2268
+ outline: 2px solid var(--lb-primary-color);
2269
+ outline-offset: 2px;
2270
+ }
2271
+
2272
+ .lb-mix-match__modal-add {
2273
+ padding: 8px 20px;
2274
+ background: var(--lb-picker-add-bg);
2275
+ color: var(--lb-picker-add-label);
2276
+ border: var(--lb-picker-add-border-width) solid var(--lb-picker-add-border-color);
2277
+ border-radius: var(--lb-picker-add-radius);
2278
+ box-sizing: border-box;
2279
+ font-size: 12px;
2280
+ font-weight: 600;
2281
+ cursor: pointer;
2282
+ white-space: nowrap;
2283
+ flex-shrink: 0;
2284
+ }
2285
+
2286
+ .lb-mix-match__modal-add:hover {
2287
+ opacity: 0.9;
2288
+ }
2289
+
2290
+ .lb-mix-match__modal-add:focus-visible {
2291
+ outline: 2px solid var(--lb-primary-color);
2292
+ outline-offset: 2px;
2293
+ }
2294
+
2295
+ .lb-mix-match__modal-add:disabled {
2296
+ background: color-mix(in srgb, var(--lb-picker-add-bg) 35%, var(--lb-picker-bg));
2297
+ color: color-mix(in srgb, var(--lb-picker-add-label) 85%, transparent);
2298
+ cursor: not-allowed;
2299
+ }
2300
+
2301
+ /* Sold out product row */
2302
+ .lb-mix-match__modal-product--sold-out {
2303
+ opacity: 0.5;
2304
+ }
2305
+
2306
+ .lb-mix-match__modal-product--sold-out .lb-mix-match__modal-sold-out-label {
2307
+ font-size: 12px;
2308
+ color: inherit;
2309
+ font-weight: 500;
2310
+ white-space: nowrap;
2311
+ }
2312
+
2313
+ /* === Modal Empty State === */
2314
+ .lb-mix-match__modal-empty {
2315
+ padding: 32px 20px;
2316
+ text-align: center;
2317
+ }
2318
+
2319
+ .lb-mix-match__modal-empty p {
2320
+ margin: 0;
2321
+ font-size: 16px;
2322
+ color: var(--lb-picker-text);
2323
+ }
2324
+
2325
+ /* Hidden utility for search filtering */
2326
+ .lb-hidden {
2327
+ display: none !important;
2328
+ }
2329
+
2330
+ /* === Mobile Full-Screen Modal === */
2331
+ @media (max-width: 767px) {
2332
+ .lb-mix-match__modal-overlay {
2333
+ align-items: flex-end;
139
2334
  }
140
- container.appendChild(productsDiv);
141
- const validationEl = document.createElement("p");
142
- validationEl.className = "lb-bundle__validation";
143
- container.appendChild(validationEl);
144
- const button = document.createElement("button");
145
- button.className = "lb-bundle__cta";
146
- button.setAttribute("part", "button");
147
- button.disabled = true;
148
- container.appendChild(button);
149
- function updateCta() {
150
- const total = Array.from(selections.values()).reduce(
151
- (s, v) => s + v.quantity,
152
- 0
153
- );
154
- const validation = validateQuantity(
155
- total,
156
- bundle.minQuantity,
157
- bundle.maxQuantity
158
- );
159
- button.disabled = !validation.valid;
160
- button.textContent = bundle.widgetConfig.ctaText ?? `Add ${total} Items to Cart`;
161
- validationEl.textContent = validation.message ?? "";
2335
+
2336
+ .lb-mix-match__modal {
2337
+ max-width: 100%;
2338
+ max-height: 90vh;
2339
+ border-radius: 16px 16px 0 0;
2340
+ transform: translateY(100%);
2341
+ }
2342
+
2343
+ .lb-mix-match__modal-overlay--open .lb-mix-match__modal {
2344
+ transform: translateY(0);
162
2345
  }
163
- updateCta();
164
- button.addEventListener("click", () => {
165
- const lines = Array.from(selections.values()).map((s) => ({
166
- merchandiseId: s.variantId,
167
- quantity: s.quantity,
168
- attributes: [
169
- { key: "_lime_bundle_gid", value: bundle.id },
170
- { key: "_lime_bundle_type", value: bundle.bundleType }
171
- ]
172
- }));
173
- if (lines.length === 0) return;
174
- onAddToCart(lines);
175
- });
176
2346
  }
177
- function escapeHtml2(str) {
178
- const div = document.createElement("div");
179
- div.textContent = str;
180
- return div.innerHTML;
2347
+
2348
+ /* === Reduced Motion === */
2349
+ @media (prefers-reduced-motion: reduce) {
2350
+ .lb-mix-match__modal-overlay,
2351
+ .lb-mix-match__modal,
2352
+ .lb-mix-match__progress-fill {
2353
+ transition: none;
2354
+ }
181
2355
  }
2356
+ `;
2357
+ var BUNDLE_VOLUME_CSS = `/* Lime Bundles \u2014 Volume / Quantity Breaks styles */
182
2358
 
183
- // src/renderers/volume.ts
184
- import {
185
- formatMoney as formatMoney3,
186
- calculateTierSavings
187
- } from "@lime-bundles/core";
188
- function renderVolumeBundle(container, bundle, onAddToCart) {
189
- const product = bundle.products[0];
190
- if (!product) return;
191
- const basePrice = parseFloat(product.priceRange.minVariantPrice.amount);
192
- const currency = product.priceRange.minVariantPrice.currencyCode;
193
- let quantity = 1;
194
- const title = document.createElement("h3");
195
- title.className = "lb-bundle__title";
196
- title.textContent = bundle.title;
197
- title.setAttribute("part", "title");
198
- container.appendChild(title);
199
- const productEl = document.createElement("div");
200
- productEl.className = "lb-bundle__product lb-bundle__product--volume";
201
- if (product.featuredImage) {
202
- const img = document.createElement("img");
203
- img.src = product.featuredImage.url;
204
- img.alt = product.featuredImage.altText ?? product.title;
205
- img.className = "lb-bundle__product-image";
206
- img.loading = "lazy";
207
- productEl.appendChild(img);
208
- }
209
- const info = document.createElement("div");
210
- info.className = "lb-bundle__product-info";
211
- info.innerHTML = `
212
- <p class="lb-bundle__product-title">${escapeHtml3(product.title)}</p>
213
- <p class="lb-bundle__product-price">${escapeHtml3(formatMoney3(basePrice, currency))} each</p>
214
- `;
215
- productEl.appendChild(info);
216
- container.appendChild(productEl);
217
- const tiersDiv = document.createElement("div");
218
- tiersDiv.className = "lb-bundle__tiers";
219
- tiersDiv.setAttribute("role", "table");
220
- tiersDiv.setAttribute("aria-label", "Volume discounts");
221
- container.appendChild(tiersDiv);
222
- const qtyWrapper = document.createElement("div");
223
- qtyWrapper.className = "lb-bundle__quantity-selector";
224
- const label = document.createElement("label");
225
- label.textContent = "Quantity";
226
- qtyWrapper.appendChild(label);
227
- const qtyControl = document.createElement("div");
228
- qtyControl.className = "lb-bundle__quantity-control";
229
- const minusBtn = document.createElement("button");
230
- minusBtn.textContent = "\u2212";
231
- minusBtn.setAttribute("aria-label", "Decrease quantity");
232
- const qtyInput = document.createElement("input");
233
- qtyInput.type = "number";
234
- qtyInput.min = "1";
235
- qtyInput.value = "1";
236
- qtyInput.className = "lb-bundle__quantity-input";
237
- const plusBtn = document.createElement("button");
238
- plusBtn.textContent = "+";
239
- plusBtn.setAttribute("aria-label", "Increase quantity");
240
- qtyControl.append(minusBtn, qtyInput, plusBtn);
241
- qtyWrapper.appendChild(qtyControl);
242
- container.appendChild(qtyWrapper);
243
- const button = document.createElement("button");
244
- button.className = "lb-bundle__cta";
245
- button.setAttribute("part", "button");
246
- container.appendChild(button);
247
- function updateTiers() {
248
- const savings = calculateTierSavings(
249
- bundle.volumeTiers,
250
- basePrice,
251
- quantity
252
- );
253
- tiersDiv.innerHTML = "";
254
- for (const ts of savings) {
255
- const row = document.createElement("div");
256
- row.className = `lb-bundle__tier${ts.isActive ? " lb-bundle__tier--active" : ""}`;
257
- row.setAttribute("role", "row");
258
- row.innerHTML = `
259
- <span class="lb-bundle__tier-quantity" role="cell">${ts.tier.minQuantity}+ items</span>
260
- <span class="lb-bundle__tier-price" role="cell">${escapeHtml3(formatMoney3(ts.unitPrice, currency))} each</span>
261
- <span class="lb-bundle__tier-savings" role="cell">Save ${ts.savingsPercent.toFixed(0)}%</span>
262
- ${ts.tier.label ? `<span class="lb-bundle__tier-label" role="cell">${escapeHtml3(ts.tier.label)}</span>` : ""}
263
- `;
264
- tiersDiv.appendChild(row);
265
- }
266
- button.textContent = bundle.widgetConfig.ctaText ?? `Add ${quantity} to Cart`;
267
- }
268
- updateTiers();
269
- minusBtn.addEventListener("click", () => {
270
- if (quantity > 1) {
271
- quantity--;
272
- qtyInput.value = String(quantity);
273
- updateTiers();
274
- }
275
- });
276
- plusBtn.addEventListener("click", () => {
277
- quantity++;
278
- qtyInput.value = String(quantity);
279
- updateTiers();
280
- });
281
- qtyInput.addEventListener("change", () => {
282
- const val = parseInt(qtyInput.value, 10);
283
- if (!isNaN(val) && val > 0) {
284
- quantity = val;
285
- updateTiers();
286
- }
287
- });
288
- button.addEventListener("click", () => {
289
- const variant = product.variants.nodes.find((v) => v.availableForSale);
290
- if (!variant) return;
291
- onAddToCart([
292
- {
293
- merchandiseId: variant.id,
294
- quantity,
295
- attributes: [
296
- { key: "_lime_bundle_gid", value: bundle.id },
297
- { key: "_lime_bundle_type", value: bundle.bundleType }
298
- ]
299
- }
300
- ]);
301
- });
2359
+ .lb-volume__tiers {
2360
+ display: flex;
2361
+ flex-direction: column;
2362
+ gap: 12px;
302
2363
  }
303
- function escapeHtml3(str) {
304
- const div = document.createElement("div");
305
- div.textContent = str;
306
- return div.innerHTML;
2364
+
2365
+ .lb-volume__tier {
2366
+ display: flex;
2367
+ align-items: center;
2368
+ gap: 12px;
2369
+ border: var(--lb-tier-border-width) solid var(--lb-tier-border-color);
2370
+ border-radius: var(--lb-tier-radius);
2371
+ padding: 12px 16px;
2372
+ cursor: pointer;
2373
+ position: relative;
2374
+ transition: border-color 0.15s ease;
307
2375
  }
308
2376
 
309
- // src/styles/widget-styles.ts
310
- var WIDGET_STYLES = `
311
- :host {
312
- display: block;
313
- --lb-primary-color: #000;
314
- --lb-secondary-color: #666;
315
- --lb-accent-color: #2563eb;
316
- --lb-background: #fff;
317
- --lb-border-color: #e5e7eb;
318
- --lb-border-radius: 8px;
319
- --lb-font-family: inherit;
320
- --lb-font-size: 14px;
321
- --lb-spacing-sm: 8px;
322
- --lb-spacing-md: 16px;
323
- --lb-spacing-lg: 24px;
324
- --lb-button-bg: var(--lb-accent-color);
325
- --lb-button-text: #fff;
326
- --lb-button-radius: var(--lb-border-radius);
327
- --lb-savings-color: #16a34a;
328
- --lb-error-color: #dc2626;
329
- }
330
-
331
- .lb-bundle {
332
- font-family: var(--lb-font-family);
333
- font-size: var(--lb-font-size);
334
- color: var(--lb-primary-color);
335
- background: var(--lb-background);
336
- border: 1px solid var(--lb-border-color);
337
- border-radius: var(--lb-border-radius);
338
- padding: var(--lb-spacing-lg);
339
- }
340
-
341
- .lb-bundle__title { margin: 0 0 var(--lb-spacing-md); font-size: 1.25em; font-weight: 600; }
342
- .lb-bundle__discount-badge { display: inline-block; background: var(--lb-savings-color); color: #fff; padding: 2px 8px; border-radius: 4px; font-size: 0.85em; font-weight: 600; margin-bottom: var(--lb-spacing-md); }
343
- .lb-bundle__products { display: grid; gap: var(--lb-spacing-md); margin-bottom: var(--lb-spacing-lg); }
344
- .lb-bundle__product { display: flex; gap: var(--lb-spacing-md); align-items: center; padding: var(--lb-spacing-sm); border: 1px solid var(--lb-border-color); border-radius: var(--lb-border-radius); }
345
- .lb-bundle__product--selected { border-color: var(--lb-accent-color); }
346
- .lb-bundle__product-image { width: 64px; height: 64px; object-fit: cover; border-radius: calc(var(--lb-border-radius) - 2px); flex-shrink: 0; }
347
- .lb-bundle__product-info { flex: 1; min-width: 0; }
348
- .lb-bundle__product-title { margin: 0; font-weight: 500; }
349
- .lb-bundle__product-price { margin: 4px 0 0; color: var(--lb-secondary-color); }
350
- .lb-bundle__instructions { color: var(--lb-secondary-color); margin: 0 0 var(--lb-spacing-md); }
351
- .lb-bundle__tiers { margin-bottom: var(--lb-spacing-lg); }
352
- .lb-bundle__tier { display: flex; align-items: center; gap: var(--lb-spacing-md); padding: var(--lb-spacing-sm) var(--lb-spacing-md); border: 1px solid var(--lb-border-color); border-radius: var(--lb-border-radius); margin-bottom: var(--lb-spacing-sm); }
353
- .lb-bundle__tier--active { border-color: var(--lb-savings-color); }
354
- .lb-bundle__tier-savings { color: var(--lb-savings-color); font-weight: 600; }
355
- .lb-bundle__quantity-selector { margin-bottom: var(--lb-spacing-lg); }
356
- .lb-bundle__quantity-selector label { display: block; margin-bottom: var(--lb-spacing-sm); font-weight: 500; }
357
- .lb-bundle__quantity-control { display: inline-flex; align-items: center; border: 1px solid var(--lb-border-color); border-radius: var(--lb-border-radius); }
358
- .lb-bundle__quantity-control button { width: 32px; height: 32px; border: none; background: transparent; cursor: pointer; font-size: 1.1em; display: flex; align-items: center; justify-content: center; }
359
- .lb-bundle__quantity-input { width: 40px; text-align: center; border: none; border-left: 1px solid var(--lb-border-color); border-right: 1px solid var(--lb-border-color); height: 32px; font-size: var(--lb-font-size); -moz-appearance: textfield; }
360
- .lb-bundle__quantity-input::-webkit-outer-spin-button, .lb-bundle__quantity-input::-webkit-inner-spin-button { -webkit-appearance: none; }
361
- .lb-bundle__select-btn { padding: 6px 12px; border: 1px solid var(--lb-border-color); border-radius: var(--lb-border-radius); background: transparent; cursor: pointer; }
362
- .lb-bundle__select-btn:disabled { opacity: 0.5; cursor: not-allowed; }
363
- .lb-bundle__cta { width: 100%; padding: 12px 24px; border: none; border-radius: var(--lb-button-radius); background: var(--lb-button-bg); color: var(--lb-button-text); font-size: 1em; font-weight: 600; cursor: pointer; transition: opacity 0.15s; }
364
- .lb-bundle__cta:hover:not(:disabled) { opacity: 0.9; }
365
- .lb-bundle__cta:disabled { opacity: 0.5; cursor: not-allowed; }
366
- .lb-bundle__error { color: var(--lb-error-color); margin: var(--lb-spacing-sm) 0; font-size: 0.9em; }
367
- .lb-bundle__validation { color: var(--lb-secondary-color); margin: var(--lb-spacing-sm) 0; font-size: 0.9em; }
368
-
369
- .lb-skeleton { background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%); background-size: 200% 100%; animation: lb-shimmer 1.5s infinite; border-radius: var(--lb-border-radius); }
370
- .lb-skeleton--title { height: 24px; width: 60%; margin-bottom: var(--lb-spacing-md); }
371
- .lb-skeleton--products { height: 200px; }
372
- @keyframes lb-shimmer { 0% { background-position: 200% 0; } 100% { background-position: -200% 0; } }
2377
+ .lb-volume__tier:hover {
2378
+ border-color: color-mix(in srgb, var(--lb-tier-border-color) 50%, black);
2379
+ }
2380
+
2381
+ .lb-volume__tier:focus-visible {
2382
+ outline: 2px solid var(--lb-primary-color);
2383
+ outline-offset: 2px;
2384
+ }
2385
+
2386
+ .lb-volume__tier[aria-checked="true"] {
2387
+ border-color: var(--lb-tier-selected-border-color);
2388
+ outline: var(--lb-tier-selected-border-width) solid var(--lb-tier-selected-border-color);
2389
+ outline-offset: calc(-1 * var(--lb-tier-selected-border-width));
2390
+ }
2391
+
2392
+ .lb-volume__radio {
2393
+ width: 20px;
2394
+ height: 20px;
2395
+ min-width: 20px;
2396
+ border: 2px solid var(--lb-text);
2397
+ border-radius: 50%;
2398
+ display: flex;
2399
+ align-items: center;
2400
+ justify-content: center;
2401
+ transition: border-color 0.15s ease;
2402
+ }
2403
+
2404
+ .lb-volume__tier[aria-checked="true"] .lb-volume__radio {
2405
+ border-color: var(--lb-text);
2406
+ }
2407
+
2408
+ .lb-volume__radio-dot {
2409
+ width: 12px;
2410
+ height: 12px;
2411
+ border-radius: 50%;
2412
+ background: transparent;
2413
+ transition: background 0.15s ease;
2414
+ }
2415
+
2416
+ .lb-volume__tier[aria-checked="true"] .lb-volume__radio-dot {
2417
+ background: var(--lb-text);
2418
+ }
2419
+
2420
+ /* Tier content: 2x2 grid layout */
2421
+ .lb-volume__tier-grid {
2422
+ flex: 1;
2423
+ display: grid;
2424
+ row-gap: 4px;
2425
+ align-items: center;
2426
+ }
2427
+
2428
+ .lb-volume__tier-label {
2429
+ font-size: 16px;
2430
+ font-weight: 700;
2431
+ color: var(--lb-text);
2432
+ }
2433
+
2434
+ .lb-volume__tier-badge {
2435
+ flex-shrink: 0;
2436
+ font-size: 12px;
2437
+ line-height: 16px;
2438
+ color: var(--lb-popular-badge-text);
2439
+ background: var(--lb-popular-badge-bg);
2440
+ border: var(--lb-popular-badge-border-width) solid var(--lb-popular-badge-border-color);
2441
+ border-radius: var(--lb-popular-badge-radius);
2442
+ padding: 4px 8px;
2443
+ }
2444
+
2445
+ .lb-volume__tier-price {
2446
+ grid-column: 1 / -1;
2447
+ font-size: 12px;
2448
+ font-weight: 500;
2449
+ color: var(--lb-text);
2450
+ }
2451
+
2452
+ .lb-volume__tier-unit {
2453
+ font-size: 12px;
2454
+ color: var(--lb-text);
2455
+ }
2456
+
2457
+ /* Compare-at (strikethrough) price */
2458
+ .lb-volume__tier-compare {
2459
+ font-size: 12px;
2460
+ line-height: 16px;
2461
+ color: color-mix(in srgb, var(--lb-text) 60%, transparent);
2462
+ text-decoration: line-through;
2463
+ margin-right: 4px;
2464
+ }
2465
+
2466
+
373
2467
  `;
374
2468
 
375
2469
  // src/lime-bundle.ts
2470
+ var cartStorageKey = (shopDomain) => `lb_cart_id:${shopDomain}`;
2471
+ function resolveProductHandle(explicit) {
2472
+ if (explicit) return explicit.trim() || null;
2473
+ if (typeof document !== "undefined") {
2474
+ const meta = document.querySelector(
2475
+ 'meta[name="shopify:product-handle"]'
2476
+ );
2477
+ if (meta?.content) return meta.content.trim() || null;
2478
+ }
2479
+ if (typeof window !== "undefined") {
2480
+ const match = window.location.pathname.match(/\/products\/([^/?#]+)/);
2481
+ if (match?.[1]) return decodeURIComponent(match[1]);
2482
+ }
2483
+ return null;
2484
+ }
376
2485
  var LimeBundleElement = class extends HTMLElement {
377
2486
  static observedAttributes = [
378
2487
  "shop-domain",
379
2488
  "storefront-token",
380
2489
  "bundle-gid",
2490
+ "product-handle",
381
2491
  "app-url",
382
2492
  "analytics",
383
2493
  "locale"
384
2494
  ];
385
2495
  shadow;
386
- bundle = null;
2496
+ bundles = [];
387
2497
  abortController = null;
388
- impressionCleanup = null;
2498
+ impressionCleanups = [];
2499
+ /**
2500
+ * Tick / observer / listener cleanups registered by individual renderers
2501
+ * (e.g. countdown setInterval handles). Flushed in disconnectedCallback
2502
+ * so we never leak timers or event listeners when the widget is removed.
2503
+ */
2504
+ renderCleanups = [];
2505
+ /**
2506
+ * Merchant custom CSS fetched from the shop metafield. Injected inside
2507
+ * the shadow root alongside the bundle stylesheets so selectors like
2508
+ * `.lb-bundle-widget { ... }` reach the widget's DOM.
2509
+ */
2510
+ shopCustomCss = null;
389
2511
  constructor() {
390
2512
  super();
391
2513
  this.shadow = this.attachShadow({ mode: "open" });
@@ -396,16 +2518,21 @@ var LimeBundleElement = class extends HTMLElement {
396
2518
  }
397
2519
  disconnectedCallback() {
398
2520
  this.abortController?.abort();
399
- this.teardownImpression();
2521
+ this.teardownImpressions();
2522
+ this.teardownRenderers();
400
2523
  }
401
- teardownImpression() {
402
- this.impressionCleanup?.();
403
- this.impressionCleanup = null;
2524
+ teardownImpressions() {
2525
+ for (const cleanup of this.impressionCleanups) cleanup();
2526
+ this.impressionCleanups = [];
2527
+ }
2528
+ teardownRenderers() {
2529
+ for (const cleanup of this.renderCleanups) cleanup();
2530
+ this.renderCleanups = [];
404
2531
  }
405
2532
  attributeChangedCallback(name, oldValue, newValue) {
406
2533
  if (oldValue === newValue || !this.isConnected) return;
407
- if (name === "bundle-gid" || name === "shop-domain" || name === "storefront-token") {
408
- if (this.shopDomain && this.storefrontToken && this.bundleGid) {
2534
+ if (name === "bundle-gid" || name === "product-handle" || name === "shop-domain" || name === "storefront-token") {
2535
+ if (this.shopDomain && this.storefrontToken) {
409
2536
  this.fetchBundle();
410
2537
  }
411
2538
  }
@@ -419,6 +2546,9 @@ var LimeBundleElement = class extends HTMLElement {
419
2546
  get bundleGid() {
420
2547
  return this.getAttribute("bundle-gid") ?? "";
421
2548
  }
2549
+ get productHandleAttr() {
2550
+ return this.getAttribute("product-handle") ?? "";
2551
+ }
422
2552
  get appUrl() {
423
2553
  return this.getAttribute("app-url") ?? "";
424
2554
  }
@@ -426,9 +2556,9 @@ var LimeBundleElement = class extends HTMLElement {
426
2556
  return this.getAttribute("analytics") !== "false";
427
2557
  }
428
2558
  async fetchBundle() {
429
- if (!this.shopDomain || !this.storefrontToken || !this.bundleGid) {
2559
+ if (!this.shopDomain || !this.storefrontToken) {
430
2560
  this.renderError(
431
- "Missing required attributes: shop-domain, storefront-token, bundle-gid"
2561
+ "Missing required attributes: shop-domain, storefront-token"
432
2562
  );
433
2563
  return;
434
2564
  }
@@ -436,152 +2566,309 @@ var LimeBundleElement = class extends HTMLElement {
436
2566
  const controller = new AbortController();
437
2567
  this.abortController = controller;
438
2568
  this.renderLoading();
2569
+ const client = createStorefrontClient({
2570
+ shopDomain: this.shopDomain,
2571
+ accessToken: this.storefrontToken
2572
+ });
439
2573
  try {
440
- const client = createStorefrontClient({
441
- shopDomain: this.shopDomain,
442
- accessToken: this.storefrontToken
443
- });
444
- const [bundleData, cssData] = await Promise.all([
445
- client.query(
446
- BUNDLE_METAOBJECT_QUERY,
447
- { id: this.bundleGid },
448
- { signal: controller.signal }
449
- ),
450
- client.query(
451
- SHOP_CUSTOM_CSS_QUERY,
452
- void 0,
453
- { signal: controller.signal }
454
- ).catch(() => null)
455
- ]);
2574
+ let bundlePromise;
2575
+ let singleBundleMode = false;
2576
+ if (this.bundleGid) {
2577
+ singleBundleMode = true;
2578
+ bundlePromise = this.fetchSingleBundle(client, controller.signal);
2579
+ } else {
2580
+ const handle = resolveProductHandle(this.productHandleAttr);
2581
+ if (!handle) {
2582
+ this.teardownImpressions();
2583
+ this.renderError(
2584
+ "No bundle-gid or product-handle provided, and the current URL doesn't match /products/<handle>."
2585
+ );
2586
+ return;
2587
+ }
2588
+ bundlePromise = this.fetchProductBundles(
2589
+ client,
2590
+ controller.signal,
2591
+ handle
2592
+ );
2593
+ }
2594
+ const cssPromise = client.query(SHOP_CUSTOM_CSS_QUERY, void 0, {
2595
+ signal: controller.signal
2596
+ }).catch(() => null);
2597
+ await bundlePromise;
456
2598
  if (controller.signal.aborted) return;
457
- if (!bundleData.metaobject) {
458
- this.bundle = null;
459
- this.teardownImpression();
2599
+ if (singleBundleMode && this.bundles.length === 0) {
2600
+ this.teardownImpressions();
460
2601
  this.renderError("Bundle not found");
461
2602
  return;
462
2603
  }
463
- this.bundle = parseMetaobjectBundle(
464
- bundleData.metaobject.id,
465
- bundleData.metaobject.fields
466
- );
467
- if (!this.bundle) {
468
- this.teardownImpression();
469
- this.renderError("Bundle is not active or has expired");
470
- return;
471
- }
472
- if (cssData?.shop?.metafield?.value) {
473
- injectCustomCss(this.shopDomain, cssData.shop.metafield.value);
2604
+ const css = await cssPromise;
2605
+ if (css?.shop?.metafield?.value) {
2606
+ injectCustomCss(this.shopDomain, css.shop.metafield.value);
2607
+ const sanitized = sanitizeCustomCss(css.shop.metafield.value);
2608
+ if (sanitized.ok) this.shopCustomCss = sanitized.css;
474
2609
  }
475
- this.renderBundle();
476
- this.setupImpression();
2610
+ await this.applyABVariants();
2611
+ this.renderBundles();
477
2612
  } catch (err) {
478
2613
  if (controller.signal.aborted) return;
479
- this.bundle = null;
480
- this.teardownImpression();
2614
+ this.bundles = [];
2615
+ this.teardownImpressions();
481
2616
  this.renderError(
482
2617
  err instanceof Error ? err.message : "Failed to load bundle"
483
2618
  );
484
2619
  }
485
2620
  }
486
2621
  /**
487
- * Dispatch add-to-cart for merchant handling. Returns true the widget
488
- * reports success optimistically. If the merchant's cart mutation fails,
489
- * they're responsible for surfacing that error in their own UI.
2622
+ * Resolve the visitor's A/B bucket for every bundle with an active test
2623
+ * and merge Variant B overrides where applicable. Runs in parallel; any
2624
+ * assignment failure logs internally but still renders Variant A (safe
2625
+ * default). The `getABTestAssignment` helper also persists the bucket
2626
+ * via a first-party cookie + POSTs to /api/ab-assign for server-side
2627
+ * analytics.
490
2628
  */
491
- dispatchAddToCart = (lines) => {
492
- this.dispatchEvent(
493
- new CustomEvent("lime-bundle:add-to-cart", {
494
- detail: { lines },
495
- bubbles: true,
496
- composed: true
2629
+ async applyABVariants() {
2630
+ if (!this.appUrl || this.bundles.length === 0) return;
2631
+ const results = await Promise.all(
2632
+ this.bundles.map(async (bundle) => {
2633
+ if (!bundle.abTestId || !bundle.abVariantB) return bundle;
2634
+ try {
2635
+ const assignment = await getABTestAssignment(
2636
+ this.appUrl,
2637
+ this.shopDomain,
2638
+ bundle.abTestId,
2639
+ bundle.id
2640
+ );
2641
+ if (assignment?.variant === "B") {
2642
+ return applyABVariantB(bundle);
2643
+ }
2644
+ } catch (err) {
2645
+ console.warn(
2646
+ `[lime-bundle] A/B assignment failed for bundle ${bundle.id}; falling back to Variant A.`,
2647
+ err
2648
+ );
2649
+ }
2650
+ return bundle;
497
2651
  })
498
2652
  );
499
- if (this.analyticsEnabled && this.appUrl && this.bundle) {
500
- const quantity = lines.reduce((sum, l) => sum + l.quantity, 0);
501
- const totalPrice = lines.reduce((sum, line) => {
502
- const product = this.bundle.products.find(
503
- (p) => p.variants.nodes.some((v) => v.id === line.merchandiseId)
504
- );
505
- const variant = product?.variants.nodes.find(
506
- (v) => v.id === line.merchandiseId
507
- );
508
- const price = variant ? parseFloat(variant.price.amount) : 0;
509
- return sum + price * line.quantity;
510
- }, 0);
511
- reportAddToCart(
512
- { shopDomain: this.shopDomain, appUrl: this.appUrl },
513
- {
514
- bundleGid: this.bundleGid,
515
- bundleType: this.bundle.bundleType,
516
- productId: this.bundle.products[0]?.id ?? "",
517
- quantity,
518
- totalPrice: Math.round(totalPrice * 100) / 100
519
- }
520
- );
2653
+ this.bundles = results;
2654
+ }
2655
+ async fetchSingleBundle(client, signal) {
2656
+ const data = await client.query(
2657
+ BUNDLE_METAOBJECT_QUERY,
2658
+ { id: this.bundleGid },
2659
+ { signal }
2660
+ );
2661
+ if (!data.metaobject) {
2662
+ this.bundles = [];
2663
+ return;
2664
+ }
2665
+ const parsed = parseMetaobjectBundle(
2666
+ data.metaobject.id,
2667
+ data.metaobject.fields
2668
+ );
2669
+ this.bundles = parsed ? [parsed] : [];
2670
+ }
2671
+ async fetchProductBundles(client, signal, productHandle) {
2672
+ const data = await client.query(
2673
+ BUNDLES_FOR_PRODUCT_QUERY,
2674
+ { handle: productHandle },
2675
+ { signal }
2676
+ );
2677
+ if (!data.product) {
2678
+ this.bundles = [];
2679
+ return;
2680
+ }
2681
+ const refs = data.product.metafield?.references?.nodes ?? [];
2682
+ const bundles = [];
2683
+ for (const ref of refs) {
2684
+ const parsed = parseMetaobjectBundle(ref.id, ref.fields);
2685
+ if (parsed) bundles.push(parsed);
2686
+ }
2687
+ this.bundles = bundles;
2688
+ }
2689
+ /**
2690
+ * Dispatch add-to-cart with a cancelable event, then — unless a listener
2691
+ * called preventDefault — execute the default cart-and-checkout flow.
2692
+ *
2693
+ * `fire-and-forget` against `reportAddToCart` runs regardless so merchants
2694
+ * with BYO cart still get analytics.
2695
+ */
2696
+ handleAddToCart = async (bundle, lines) => {
2697
+ const ev = new CustomEvent("lime-bundle:add-to-cart", {
2698
+ detail: { lines },
2699
+ bubbles: true,
2700
+ composed: true,
2701
+ cancelable: true
2702
+ });
2703
+ const allowDefault = this.dispatchEvent(ev);
2704
+ this.reportAddToCartEvent(bundle, lines);
2705
+ if (allowDefault) {
2706
+ await this.defaultAddToCart(lines);
521
2707
  }
522
2708
  };
523
- renderBundle() {
524
- if (!this.bundle) return;
525
- const container = document.createElement("div");
526
- container.className = "lb-bundle";
527
- container.setAttribute("role", "region");
528
- container.setAttribute("aria-label", this.bundle.title);
529
- switch (this.bundle.bundleType) {
530
- case "fixed":
531
- renderFixedBundle(
532
- container,
533
- this.bundle,
534
- this.dispatchAddToCart
2709
+ /**
2710
+ * Default cart flow: Shopify's Storefront Cart API is tokenless, so we
2711
+ * don't need any additional scopes. Persist the cart ID in localStorage
2712
+ * so subsequent adds on the same browser session join the existing cart
2713
+ * instead of creating a new one every click.
2714
+ */
2715
+ async defaultAddToCart(lines) {
2716
+ if (typeof window === "undefined") return;
2717
+ const client = createStorefrontClient({
2718
+ shopDomain: this.shopDomain,
2719
+ accessToken: this.storefrontToken
2720
+ });
2721
+ const storage = window.localStorage;
2722
+ const key = cartStorageKey(this.shopDomain);
2723
+ const existingCartId = storage?.getItem(key) ?? null;
2724
+ try {
2725
+ let checkoutUrl = null;
2726
+ if (existingCartId) {
2727
+ const res = await client.query(
2728
+ CART_LINES_ADD_MUTATION,
2729
+ { cartId: existingCartId, lines }
535
2730
  );
536
- break;
537
- case "mix_match":
538
- renderMixMatchBundle(
539
- container,
540
- this.bundle,
541
- this.dispatchAddToCart
2731
+ const payload = res.cartLinesAdd;
2732
+ if (payload?.userErrors?.length) {
2733
+ storage?.removeItem(key);
2734
+ } else if (payload?.cart) {
2735
+ checkoutUrl = payload.cart.checkoutUrl;
2736
+ }
2737
+ }
2738
+ if (!checkoutUrl) {
2739
+ const res = await client.query(
2740
+ CART_CREATE_MUTATION,
2741
+ { input: { lines } }
542
2742
  );
543
- break;
544
- case "volume":
545
- renderVolumeBundle(
546
- container,
547
- this.bundle,
548
- this.dispatchAddToCart
2743
+ const payload = res.cartCreate;
2744
+ if (payload?.cart) {
2745
+ storage?.setItem(key, payload.cart.id);
2746
+ checkoutUrl = payload.cart.checkoutUrl;
2747
+ }
2748
+ }
2749
+ if (checkoutUrl) {
2750
+ window.location.assign(checkoutUrl);
2751
+ } else {
2752
+ this.dispatchEvent(
2753
+ new CustomEvent("lime-bundle:error", {
2754
+ detail: { message: "Cart creation failed", code: "CART_ERROR" },
2755
+ bubbles: true,
2756
+ composed: true
2757
+ })
549
2758
  );
550
- break;
2759
+ }
2760
+ } catch (err) {
2761
+ this.dispatchEvent(
2762
+ new CustomEvent("lime-bundle:error", {
2763
+ detail: {
2764
+ message: err instanceof Error ? err.message : "Cart mutation failed",
2765
+ code: "CART_ERROR"
2766
+ },
2767
+ bubbles: true,
2768
+ composed: true
2769
+ })
2770
+ );
551
2771
  }
2772
+ }
2773
+ reportAddToCartEvent(bundle, lines) {
2774
+ if (!this.analyticsEnabled || !this.appUrl) return;
2775
+ const quantity = lines.reduce((sum, l) => sum + l.quantity, 0);
2776
+ const totalPrice = lines.reduce((sum, line) => {
2777
+ const product = bundle.products.find(
2778
+ (p) => p.variants.nodes.some((v) => v.id === line.merchandiseId)
2779
+ );
2780
+ const variant = product?.variants.nodes.find(
2781
+ (v) => v.id === line.merchandiseId
2782
+ );
2783
+ const price = variant ? parseFloat(variant.price.amount) : 0;
2784
+ return sum + price * line.quantity;
2785
+ }, 0);
2786
+ reportAddToCart(
2787
+ { shopDomain: this.shopDomain, appUrl: this.appUrl },
2788
+ {
2789
+ bundleGid: bundle.id,
2790
+ bundleType: bundle.bundleType,
2791
+ productId: bundle.products[0]?.id ?? "",
2792
+ quantity,
2793
+ totalPrice: Math.round(totalPrice * 100) / 100
2794
+ }
2795
+ );
2796
+ }
2797
+ renderBundles() {
2798
+ this.teardownImpressions();
2799
+ this.teardownRenderers();
552
2800
  this.shadow.innerHTML = "";
553
2801
  const style = document.createElement("style");
554
- style.textContent = WIDGET_STYLES;
2802
+ style.textContent = [
2803
+ BUNDLE_BASE_CSS,
2804
+ BUNDLE_FIXED_CSS,
2805
+ BUNDLE_MIX_MATCH_CSS,
2806
+ BUNDLE_VOLUME_CSS
2807
+ ].join("\n");
555
2808
  this.shadow.appendChild(style);
556
- this.shadow.appendChild(container);
2809
+ if (this.shopCustomCss) {
2810
+ const customStyle = document.createElement("style");
2811
+ customStyle.setAttribute("data-lime-bundles", "shop-custom-css");
2812
+ customStyle.textContent = this.shopCustomCss;
2813
+ this.shadow.appendChild(customStyle);
2814
+ }
2815
+ for (const bundle of this.bundles) {
2816
+ const container = document.createElement("div");
2817
+ container.className = "lb-bundle-widget";
2818
+ container.setAttribute("role", "region");
2819
+ container.setAttribute("aria-label", bundle.title);
2820
+ container.setAttribute("data-bundle-type", bundle.bundleType);
2821
+ container.setAttribute("data-bundle-gid", bundle.id);
2822
+ applyWidgetConfigVars(container, bundle.widgetConfig);
2823
+ const dispatch = (lines) => this.handleAddToCart(bundle, lines);
2824
+ const registerCleanup = (fn) => this.renderCleanups.push(fn);
2825
+ switch (bundle.bundleType) {
2826
+ case "fixed":
2827
+ renderFixedBundle(container, bundle, dispatch, registerCleanup);
2828
+ break;
2829
+ case "mix_match":
2830
+ renderMixMatchBundle(container, bundle, dispatch, registerCleanup);
2831
+ break;
2832
+ case "volume":
2833
+ renderVolumeBundle(container, bundle, dispatch, registerCleanup);
2834
+ break;
2835
+ }
2836
+ this.shadow.appendChild(container);
2837
+ this.setupImpressionFor(bundle, container);
2838
+ }
2839
+ const first = this.bundles[0];
557
2840
  this.dispatchEvent(
558
2841
  new CustomEvent("lime-bundle:loaded", {
559
2842
  detail: {
560
- bundleType: this.bundle.bundleType,
561
- title: this.bundle.title
2843
+ bundleCount: this.bundles.length,
2844
+ bundleTypes: this.bundles.map((b) => b.bundleType),
2845
+ // Legacy fields — meaningful only in single-bundle mode. Preserved
2846
+ // for merchants who attached listeners against the pre-1.0 shape.
2847
+ bundleType: first?.bundleType,
2848
+ title: first?.title
562
2849
  },
563
2850
  bubbles: true,
564
2851
  composed: true
565
2852
  })
566
2853
  );
567
2854
  }
568
- setupImpression() {
569
- if (!this.analyticsEnabled || !this.bundle || !this.appUrl) return;
570
- this.impressionCleanup?.();
571
- this.impressionCleanup = observeImpression(this, () => {
2855
+ setupImpressionFor(bundle, element) {
2856
+ if (!this.analyticsEnabled || !this.appUrl) return;
2857
+ const cleanup = observeImpression(element, () => {
572
2858
  reportImpression(
573
2859
  { shopDomain: this.shopDomain, appUrl: this.appUrl },
574
2860
  {
575
- bundleGid: this.bundleGid,
576
- bundleType: this.bundle.bundleType
2861
+ bundleGid: bundle.id,
2862
+ bundleType: bundle.bundleType
577
2863
  }
578
2864
  );
579
2865
  });
2866
+ this.impressionCleanups.push(cleanup);
580
2867
  }
581
2868
  renderLoading() {
582
2869
  this.shadow.innerHTML = `
583
- <style>${WIDGET_STYLES}</style>
584
- <div class="lb-bundle lb-bundle--loading">
2870
+ <style>${BUNDLE_BASE_CSS}</style>
2871
+ <div class="lb-bundle-widget lb-bundle-widget--loading" aria-busy="true">
585
2872
  <div class="lb-skeleton lb-skeleton--title"></div>
586
2873
  <div class="lb-skeleton lb-skeleton--products"></div>
587
2874
  </div>
@@ -598,7 +2885,7 @@ var LimeBundleElement = class extends HTMLElement {
598
2885
  );
599
2886
  }
600
2887
  render() {
601
- this.shadow.innerHTML = `<style>${WIDGET_STYLES}</style>`;
2888
+ this.shadow.innerHTML = `<style>${BUNDLE_BASE_CSS}</style>`;
602
2889
  }
603
2890
  };
604
2891