@lime-bundles/widget 2.0.0 → 2.2.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.d.cts CHANGED
@@ -60,7 +60,6 @@ declare class LimeBundleElement extends HTMLElement {
60
60
  private setupImpressionFor;
61
61
  private renderLoading;
62
62
  private renderError;
63
- private render;
64
63
  }
65
64
 
66
65
  export { LimeBundleElement };
package/dist/index.d.ts CHANGED
@@ -60,7 +60,6 @@ declare class LimeBundleElement extends HTMLElement {
60
60
  private setupImpressionFor;
61
61
  private renderLoading;
62
62
  private renderError;
63
- private render;
64
63
  }
65
64
 
66
65
  export { LimeBundleElement };
package/dist/index.js CHANGED
@@ -70,6 +70,32 @@ function parseIso(iso) {
70
70
  return Number.isFinite(t) ? t : null;
71
71
  }
72
72
 
73
+ // src/renderers/cta-button.ts
74
+ function buildCtaButton(label) {
75
+ const button = document.createElement("button");
76
+ button.type = "button";
77
+ button.className = "lb-bundle-cta";
78
+ button.setAttribute("data-add-bundle", "");
79
+ const labelSpan = document.createElement("span");
80
+ labelSpan.className = "lb-cta-label";
81
+ labelSpan.setAttribute("data-cta-label", "");
82
+ labelSpan.textContent = label;
83
+ button.appendChild(labelSpan);
84
+ const spinnerSpan = document.createElement("span");
85
+ spinnerSpan.className = "lb-cta-spinner";
86
+ spinnerSpan.setAttribute("data-cta-spinner", "");
87
+ spinnerSpan.setAttribute("aria-hidden", "true");
88
+ spinnerSpan.innerHTML = '<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><path d="M12 2a10 10 0 0 1 10 10" /></svg>';
89
+ button.appendChild(spinnerSpan);
90
+ return button;
91
+ }
92
+ function setCtaLabel(button, text) {
93
+ const label = button.querySelector("[data-cta-label]");
94
+ if (label) {
95
+ label.textContent = text;
96
+ }
97
+ }
98
+
73
99
  // src/renderers/dom.ts
74
100
  function el(tag, className, attrs = {}) {
75
101
  const node = document.createElement(tag);
@@ -410,15 +436,11 @@ function renderSavingsBar() {
410
436
  };
411
437
  }
412
438
  function renderCta(bundle, oosCount, onClick) {
413
- const button = document.createElement("button");
414
- button.type = "button";
415
- button.className = "lb-bundle-cta";
416
- button.setAttribute("data-add-bundle", "");
439
+ const label = oosCount > 0 ? `${oosCount} item${oosCount === 1 ? "" : "s"} out of stock` : bundle.widgetConfig.cta.ctaText || "Add to cart";
440
+ const button = buildCtaButton(label);
417
441
  if (oosCount > 0) {
418
442
  button.disabled = true;
419
- button.textContent = `${oosCount} item${oosCount === 1 ? "" : "s"} out of stock`;
420
443
  } else {
421
- button.textContent = bundle.widgetConfig.cta.ctaText || "Add to cart";
422
444
  button.addEventListener("click", () => {
423
445
  if (button.disabled) return;
424
446
  onClick();
@@ -515,12 +537,8 @@ function renderMixMatchBundle(container, bundle, onAddToCart, onCleanup) {
515
537
  isOverMax: () => selections.length >= maxQty
516
538
  });
517
539
  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", "");
540
+ const cta = buildCtaButton(`Select ${requiredQty} items to unlock`);
522
541
  cta.disabled = true;
523
- cta.textContent = `Select ${requiredQty} items to unlock`;
524
542
  cta.addEventListener("click", () => {
525
543
  if (cta.disabled) return;
526
544
  const lines = selections.map((s) => ({
@@ -544,7 +562,20 @@ function renderMixMatchBundle(container, bundle, onAddToCart, onCleanup) {
544
562
  })
545
563
  );
546
564
  container.appendChild(root);
547
- renderSlots();
565
+ const firstEligible = eligible.find((ep) => !ep.isOos);
566
+ const firstVariant = firstEligible?.firstAvailableVariant ?? firstEligible?.variants[0];
567
+ if (firstEligible && firstVariant) {
568
+ selections.push({
569
+ productId: firstEligible.product.id,
570
+ productTitle: firstEligible.product.title,
571
+ variantId: firstVariant.id,
572
+ variantTitle: firstVariant.title,
573
+ imageUrl: firstEligible.product.featuredImage?.url ?? null,
574
+ priceCents: parseCents(firstVariant.price.amount),
575
+ compareCents: firstVariant.compareAtPrice ? parseCents(firstVariant.compareAtPrice.amount) : null
576
+ });
577
+ }
578
+ afterMutation();
548
579
  function addSelection(product, variant) {
549
580
  if (selections.length >= maxQty) return;
550
581
  selections.push({
@@ -600,10 +631,10 @@ function renderMixMatchBundle(container, bundle, onAddToCart, onCleanup) {
600
631
  const count = selections.length;
601
632
  if (count < requiredQty) {
602
633
  cta.disabled = true;
603
- cta.textContent = `Select ${requiredQty - count} more to unlock`;
634
+ setCtaLabel(cta, `Select ${requiredQty - count} more to unlock`);
604
635
  } else {
605
636
  cta.disabled = false;
606
- cta.textContent = wc.cta.ctaText || "Add to cart";
637
+ setCtaLabel(cta, wc.cta.ctaText || "Add to cart");
607
638
  }
608
639
  }
609
640
  }
@@ -1089,15 +1120,15 @@ function renderVolumeBundle(container, bundle, onAddToCart, onCleanup) {
1089
1120
  if (!variant && wc.outOfStockBehavior === "hide") return;
1090
1121
  const basePriceCents = variant ? parseCents(variant.price.amount) : 0;
1091
1122
  const currency = variant?.price.currencyCode ?? "USD";
1123
+ const discountType = bundle.discountConfig.discountType;
1092
1124
  const resolved = bundle.volumeTiers.map((tier, index) => {
1093
1125
  let perUnit;
1094
- if (tier.discountType === "fixed_amount") {
1095
- const amt = Math.round(tier.discountValue * 100);
1126
+ if (discountType === "fixed_amount") {
1127
+ const amt = Math.round((tier.amount ?? 0) * 100);
1096
1128
  perUnit = Math.max(0, basePriceCents - amt);
1097
1129
  } else {
1098
- const discount = Math.floor(
1099
- basePriceCents * tier.discountValue / 100
1100
- );
1130
+ const pct = tier.percentage ?? 0;
1131
+ const discount = Math.floor(basePriceCents * pct / 100);
1101
1132
  perUnit = Math.max(0, basePriceCents - discount);
1102
1133
  }
1103
1134
  return {
@@ -1115,7 +1146,9 @@ function renderVolumeBundle(container, bundle, onAddToCart, onCleanup) {
1115
1146
  }
1116
1147
  const popularIndex = wc.popularBadge.tierIndex !== void 0 ? clamp(wc.popularBadge.tierIndex, 0, resolved.length - 1) : bestTierIndex;
1117
1148
  const root = el("div", "lb-volume");
1118
- root.appendChild(renderHeader3(bundle, resolved, selectedIndex, currency));
1149
+ root.appendChild(
1150
+ renderHeader3(bundle, resolved, selectedIndex, currency, discountType)
1151
+ );
1119
1152
  if (wc.countdown.showCountdown && bundle.endsAt) {
1120
1153
  const countdown = renderCountdown(bundle.endsAt);
1121
1154
  if (countdown) {
@@ -1216,7 +1249,7 @@ function renderVolumeBundle(container, bundle, onAddToCart, onCleanup) {
1216
1249
  }
1217
1250
  const badgeEl = root.querySelector("[data-header-badge]");
1218
1251
  if (badgeEl) {
1219
- const label = badgeFor(resolved[idx], currency);
1252
+ const label = badgeFor(resolved[idx], currency, discountType);
1220
1253
  if (label) {
1221
1254
  badgeEl.textContent = label;
1222
1255
  badgeEl.style.display = "";
@@ -1226,7 +1259,7 @@ function renderVolumeBundle(container, bundle, onAddToCart, onCleanup) {
1226
1259
  }
1227
1260
  }
1228
1261
  }
1229
- function renderHeader3(bundle, resolved, selectedIndex, currency) {
1262
+ function renderHeader3(bundle, resolved, selectedIndex, currency, discountType) {
1230
1263
  const wc = bundle.widgetConfig;
1231
1264
  const header = el("div", "lb-bundle-header");
1232
1265
  const content = el("div", "lb-bundle-header__content");
@@ -1235,7 +1268,7 @@ function renderHeader3(bundle, resolved, selectedIndex, currency) {
1235
1268
  content.appendChild(title);
1236
1269
  header.appendChild(content);
1237
1270
  if (wc.pricing.showSaveBadge) {
1238
- const badge = badgeFor(resolved[selectedIndex], currency);
1271
+ const badge = badgeFor(resolved[selectedIndex], currency, discountType);
1239
1272
  if (badge) {
1240
1273
  const badgeEl = el("span", "lb-bundle-header__badge", {
1241
1274
  "data-header-badge": ""
@@ -1336,26 +1369,27 @@ function renderSavingsBar3(resolved, selectedIndex, currency) {
1336
1369
  function renderCta2(bundle, onClick) {
1337
1370
  const product = bundle.products[0];
1338
1371
  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", "");
1372
+ const label = isAvailable ? bundle.widgetConfig.cta.ctaText || "Add to cart" : "Sold out";
1373
+ const button = buildCtaButton(label);
1343
1374
  if (!isAvailable) button.disabled = true;
1344
- button.textContent = isAvailable ? bundle.widgetConfig.cta.ctaText || "Add to cart" : "Sold out";
1345
1375
  button.addEventListener("click", () => {
1346
1376
  if (button.disabled) return;
1347
1377
  onClick();
1348
1378
  });
1349
1379
  return button;
1350
1380
  }
1351
- function badgeFor(resolved, currency) {
1381
+ function badgeFor(resolved, currency, discountType) {
1352
1382
  if (!resolved) return null;
1353
1383
  const { tier } = resolved;
1354
- if (tier.discountType === "fixed_amount" && tier.discountValue > 0) {
1355
- return `-${formatCents(Math.round(tier.discountValue * 100), currency)}`;
1384
+ if (discountType === "fixed_amount") {
1385
+ const amount = tier.amount ?? 0;
1386
+ if (amount > 0) return `-${formatCents(Math.round(amount * 100), currency)}`;
1387
+ return null;
1356
1388
  }
1357
- if (tier.discountType === "percentage" && tier.discountValue > 0) {
1358
- return `-${Math.round(tier.discountValue)}%`;
1389
+ if (discountType === "percentage") {
1390
+ const pct = tier.percentage ?? 0;
1391
+ if (pct > 0) return `-${Math.round(pct)}%`;
1392
+ return null;
1359
1393
  }
1360
1394
  return null;
1361
1395
  }
@@ -1400,6 +1434,10 @@ var BUNDLE_BASE_CSS = `/* Lime Bundles \u2014 shared base styles for all bundle
1400
1434
  border-radius: var(--lb-radius);
1401
1435
  padding: var(--lb-widget-pad) var(--lb-widget-pad) 20px;
1402
1436
  box-sizing: border-box;
1437
+ /* Cap the widget at a comfortable reading width on desktop. Below
1438
+ 440px viewports the container is already narrower than the cap,
1439
+ so the rule is inert on mobile. */
1440
+ max-width: 440px;
1403
1441
  }
1404
1442
 
1405
1443
  /* Countdown timer bar \u2014 sits below the gradient header */
@@ -1720,9 +1758,16 @@ var BUNDLE_BASE_CSS = `/* Lime Bundles \u2014 shared base styles for all bundle
1720
1758
  display: none;
1721
1759
  }
1722
1760
 
1723
- /* CTA button */
1761
+ /* CTA button.
1762
+ * Label and spinner share a single 1\xD71 grid cell so the button's intrinsic
1763
+ * width/height stays fixed when swapping between them \u2014 no layout shift when
1764
+ * entering the loading state. Visibility (not display) is used so the hidden
1765
+ * child still contributes to the cell's min-content sizing. See the
1766
+ * [data-loading="true"] rules below. */
1724
1767
  .lb-bundle-cta {
1725
- display: block;
1768
+ display: grid;
1769
+ grid-template-rows: 1fr;
1770
+ grid-template-columns: 1fr;
1726
1771
  width: 100%;
1727
1772
  padding: 16px;
1728
1773
  border: var(--lb-cta-border-width) solid var(--lb-cta-border-color);
@@ -1751,12 +1796,51 @@ var BUNDLE_BASE_CSS = `/* Lime Bundles \u2014 shared base styles for all bundle
1751
1796
  cursor: not-allowed;
1752
1797
  }
1753
1798
 
1799
+ /* Both the label and the spinner occupy grid cell (1, 1). Only one is
1800
+ * visible at a time; the other keeps its box for sizing but is invisible. */
1801
+ .lb-cta-label,
1802
+ .lb-cta-spinner {
1803
+ grid-row: 1;
1804
+ grid-column: 1;
1805
+ display: flex;
1806
+ align-items: center;
1807
+ justify-content: center;
1808
+ min-width: 0;
1809
+ }
1810
+
1811
+ .lb-cta-spinner {
1812
+ visibility: hidden;
1813
+ }
1814
+
1815
+ .lb-cta-spinner svg {
1816
+ width: 20px;
1817
+ height: 20px;
1818
+ animation: lb-cta-spin 0.8s linear infinite;
1819
+ }
1820
+
1821
+ @keyframes lb-cta-spin {
1822
+ to { transform: rotate(360deg); }
1823
+ }
1824
+
1754
1825
  .lb-bundle-cta[data-loading="true"] {
1755
- opacity: 0.7;
1756
1826
  cursor: wait;
1757
1827
  pointer-events: none;
1758
1828
  }
1759
1829
 
1830
+ .lb-bundle-cta[data-loading="true"] .lb-cta-label {
1831
+ visibility: hidden;
1832
+ }
1833
+
1834
+ .lb-bundle-cta[data-loading="true"] .lb-cta-spinner {
1835
+ visibility: visible;
1836
+ }
1837
+
1838
+ @media (prefers-reduced-motion: reduce) {
1839
+ .lb-cta-spinner svg {
1840
+ animation-duration: 2.5s;
1841
+ }
1842
+ }
1843
+
1760
1844
  /* Error message */
1761
1845
  .lb-bundle-error {
1762
1846
  font-size: 16px;
@@ -2464,6 +2548,103 @@ var BUNDLE_VOLUME_CSS = `/* Lime Bundles \u2014 Volume / Quantity Breaks styles
2464
2548
  }
2465
2549
 
2466
2550
 
2551
+ `;
2552
+ var BUNDLE_SKELETON_CSS = `/* Lime Bundles \u2014 web-component loading skeleton (not mirrored to theme assets) */
2553
+
2554
+ .lb-bundle-widget--loading {
2555
+ display: block;
2556
+ padding: var(--lb-widget-pad, 20px);
2557
+ border: 1px solid var(--lb-border, #E5E5E5);
2558
+ border-radius: var(--lb-radius, 12px);
2559
+ background: var(--lb-bg, #FFFFFF);
2560
+ /* Contain layout/paint so the skeleton doesn't influence ancestor
2561
+ layout once the real content swaps in. */
2562
+ contain: layout paint;
2563
+ }
2564
+
2565
+ .lb-bundle-widget--loading .lb-skeleton {
2566
+ background: linear-gradient(
2567
+ 90deg,
2568
+ rgba(0, 0, 0, 0.06) 0%,
2569
+ rgba(0, 0, 0, 0.10) 50%,
2570
+ rgba(0, 0, 0, 0.06) 100%
2571
+ );
2572
+ background-size: 200% 100%;
2573
+ border-radius: 6px;
2574
+ animation: lb-skeleton-pulse 1.4s ease-in-out infinite;
2575
+ }
2576
+
2577
+ .lb-bundle-widget--loading .lb-skeleton--title {
2578
+ height: 28px;
2579
+ width: 60%;
2580
+ margin-bottom: 16px;
2581
+ }
2582
+
2583
+ .lb-bundle-widget--loading .lb-skeleton--products {
2584
+ display: grid;
2585
+ gap: 12px;
2586
+ margin-bottom: 16px;
2587
+ }
2588
+
2589
+ .lb-bundle-widget--loading .lb-skeleton--row {
2590
+ display: grid;
2591
+ grid-template-columns: 56px 1fr 60px;
2592
+ gap: 12px;
2593
+ align-items: center;
2594
+ }
2595
+
2596
+ .lb-bundle-widget--loading .lb-skeleton--thumb {
2597
+ height: 56px;
2598
+ width: 56px;
2599
+ border-radius: 8px;
2600
+ }
2601
+
2602
+ .lb-bundle-widget--loading .lb-skeleton--line {
2603
+ height: 14px;
2604
+ border-radius: 4px;
2605
+ }
2606
+
2607
+ .lb-bundle-widget--loading .lb-skeleton--line + .lb-skeleton--line {
2608
+ margin-top: 8px;
2609
+ width: 70%;
2610
+ }
2611
+
2612
+ .lb-bundle-widget--loading .lb-skeleton--price {
2613
+ height: 20px;
2614
+ width: 60px;
2615
+ }
2616
+
2617
+ .lb-bundle-widget--loading .lb-skeleton--footer {
2618
+ margin-top: 16px;
2619
+ padding-top: 16px;
2620
+ border-top: 1px solid var(--lb-border, #E5E5E5);
2621
+ display: grid;
2622
+ grid-template-columns: 1fr auto;
2623
+ gap: 12px;
2624
+ align-items: center;
2625
+ }
2626
+
2627
+ .lb-bundle-widget--loading .lb-skeleton--total {
2628
+ height: 24px;
2629
+ width: 40%;
2630
+ }
2631
+
2632
+ .lb-bundle-widget--loading .lb-skeleton--cta {
2633
+ height: 44px;
2634
+ width: 140px;
2635
+ border-radius: var(--lb-cta-radius, 8px);
2636
+ }
2637
+
2638
+ @keyframes lb-skeleton-pulse {
2639
+ 0% { background-position: 0% 50%; }
2640
+ 100% { background-position: -200% 50%; }
2641
+ }
2642
+
2643
+ @media (prefers-reduced-motion: reduce) {
2644
+ .lb-bundle-widget--loading .lb-skeleton {
2645
+ animation: none;
2646
+ }
2647
+ }
2467
2648
  `;
2468
2649
 
2469
2650
  // src/lime-bundle.ts
@@ -2513,7 +2694,6 @@ var LimeBundleElement = class extends HTMLElement {
2513
2694
  this.shadow = this.attachShadow({ mode: "open" });
2514
2695
  }
2515
2696
  connectedCallback() {
2516
- this.render();
2517
2697
  this.fetchBundle();
2518
2698
  }
2519
2699
  disconnectedCallback() {
@@ -2868,9 +3048,39 @@ var LimeBundleElement = class extends HTMLElement {
2868
3048
  renderLoading() {
2869
3049
  this.shadow.innerHTML = `
2870
3050
  <style>${BUNDLE_BASE_CSS}</style>
2871
- <div class="lb-bundle-widget lb-bundle-widget--loading" aria-busy="true">
3051
+ <style>${BUNDLE_SKELETON_CSS}</style>
3052
+ <div class="lb-bundle-widget lb-bundle-widget--loading" aria-busy="true" aria-label="Loading bundle">
2872
3053
  <div class="lb-skeleton lb-skeleton--title"></div>
2873
- <div class="lb-skeleton lb-skeleton--products"></div>
3054
+ <div class="lb-skeleton--products">
3055
+ <div class="lb-skeleton--row">
3056
+ <div class="lb-skeleton lb-skeleton--thumb"></div>
3057
+ <div>
3058
+ <div class="lb-skeleton lb-skeleton--line"></div>
3059
+ <div class="lb-skeleton lb-skeleton--line"></div>
3060
+ </div>
3061
+ <div class="lb-skeleton lb-skeleton--price"></div>
3062
+ </div>
3063
+ <div class="lb-skeleton--row">
3064
+ <div class="lb-skeleton lb-skeleton--thumb"></div>
3065
+ <div>
3066
+ <div class="lb-skeleton lb-skeleton--line"></div>
3067
+ <div class="lb-skeleton lb-skeleton--line"></div>
3068
+ </div>
3069
+ <div class="lb-skeleton lb-skeleton--price"></div>
3070
+ </div>
3071
+ <div class="lb-skeleton--row">
3072
+ <div class="lb-skeleton lb-skeleton--thumb"></div>
3073
+ <div>
3074
+ <div class="lb-skeleton lb-skeleton--line"></div>
3075
+ <div class="lb-skeleton lb-skeleton--line"></div>
3076
+ </div>
3077
+ <div class="lb-skeleton lb-skeleton--price"></div>
3078
+ </div>
3079
+ </div>
3080
+ <div class="lb-skeleton--footer">
3081
+ <div class="lb-skeleton lb-skeleton--total"></div>
3082
+ <div class="lb-skeleton lb-skeleton--cta"></div>
3083
+ </div>
2874
3084
  </div>
2875
3085
  `;
2876
3086
  }
@@ -2884,9 +3094,6 @@ var LimeBundleElement = class extends HTMLElement {
2884
3094
  })
2885
3095
  );
2886
3096
  }
2887
- render() {
2888
- this.shadow.innerHTML = `<style>${BUNDLE_BASE_CSS}</style>`;
2889
- }
2890
3097
  };
2891
3098
 
2892
3099
  // src/index.ts