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