@lime-bundles/widget 0.2.0 → 1.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/README.md ADDED
@@ -0,0 +1,155 @@
1
+ # @lime-bundles/widget
2
+
3
+ Framework-agnostic `<lime-bundle>` custom element for rendering Lime Bundles on any storefront: Astro, Vue, Svelte, plain HTML, classic Shopify themes via `<script>` tag.
4
+
5
+ ## Install
6
+
7
+ Via npm:
8
+
9
+ ```bash
10
+ npm install @lime-bundles/widget
11
+ ```
12
+
13
+ ```ts
14
+ import "@lime-bundles/widget"; // registers <lime-bundle> globally
15
+ ```
16
+
17
+ Or via CDN, zero build step:
18
+
19
+ ```html
20
+ <script type="module" src="https://unpkg.com/@lime-bundles/widget"></script>
21
+ ```
22
+
23
+ ## Usage: the paste-and-go default
24
+
25
+ One snippet in your product page template. The widget auto-detects the current product from the URL (`/products/<handle>`) and renders every active bundle for it. On "Add bundle", the widget calls Shopify's tokenless Storefront Cart API and redirects to checkout with the bundle discount applied. No cart code required.
26
+
27
+ ```html
28
+ <script type="module" src="https://unpkg.com/@lime-bundles/widget"></script>
29
+ <lime-bundle
30
+ shop-domain="my-shop.myshopify.com"
31
+ storefront-token="<YOUR_LIME_BUNDLES_TOKEN>"
32
+ ></lime-bundle>
33
+ ```
34
+
35
+ ### BYO cart
36
+
37
+ If you have your own cart, listen for `lime-bundle:add-to-cart` and call `event.preventDefault()` to suppress the default redirect:
38
+
39
+ ```html
40
+ <script>
41
+ document.querySelector("lime-bundle").addEventListener(
42
+ "lime-bundle:add-to-cart",
43
+ async (event) => {
44
+ event.preventDefault();
45
+ await myCart.linesAdd(event.detail.lines);
46
+ },
47
+ );
48
+ </script>
49
+ ```
50
+
51
+ ## Attributes
52
+
53
+ | Attribute | Required | Purpose |
54
+ |---|:-:|---|
55
+ | `shop-domain` | ✓ | Your shop domain, e.g. `my-shop.myshopify.com`. |
56
+ | `storefront-token` | ✓ | Public Storefront Access Token. Generated in `/app/settings/headless`. |
57
+ | `bundle-gid` | | Pin one specific bundle. When set, overrides auto-detect. |
58
+ | `product-handle` | | Render bundles for a specific product handle. Overrides URL detection. |
59
+ | `app-url` | | Lime Bundles app URL; enables analytics when set. |
60
+ | `analytics` | | Set to `"false"` to suppress analytics even with `app-url` set. |
61
+ | `locale` | | BCP-47 tag forwarded to Storefront API. |
62
+
63
+ Product resolution cascade when `bundle-gid` is absent: explicit `product-handle` → `<meta name="shopify:product-handle">` → `/products/<handle>` URL segment.
64
+
65
+ Changing any attribute at runtime re-fetches and re-renders.
66
+
67
+ ## Events
68
+
69
+ ### `lime-bundle:add-to-cart`
70
+
71
+ Fired on CTA click. `event.detail`:
72
+
73
+ ```ts
74
+ {
75
+ lines: CartLineInput[];
76
+ bundleType: "fixed" | "volume" | "mix_match";
77
+ bundleId: string;
78
+ }
79
+ ```
80
+
81
+ Every line's `attributes` array includes `{ key: "_lime_bundle_gid", value: bundleId }`. Preserve it on the way to Shopify cart mutation or purchase attribution breaks.
82
+
83
+ ### `lime-bundle:error`
84
+
85
+ Fired if bundle fetch / parse fails. `event.detail.error` is an `Error`. Render your own fallback UI in response.
86
+
87
+ ## Framework snippets
88
+
89
+ **Astro:**
90
+
91
+ ```astro
92
+ <lime-bundle shop-domain="..." storefront-token={import.meta.env.PUBLIC_LIME_BUNDLES_TOKEN} bundle-gid="..." />
93
+ <script>
94
+ import "@lime-bundles/widget";
95
+ document.querySelector("lime-bundle")!.addEventListener("lime-bundle:add-to-cart", (e: any) => {
96
+ fetch("/api/cart-add", { method: "POST", body: JSON.stringify(e.detail.lines) });
97
+ });
98
+ </script>
99
+ ```
100
+
101
+ **Vue 3:**
102
+
103
+ ```vue
104
+ <lime-bundle
105
+ shop-domain="my-shop.myshopify.com"
106
+ :storefront-token="token"
107
+ bundle-gid="..."
108
+ @lime-bundle:add-to-cart="handle"
109
+ />
110
+ ```
111
+
112
+ Configure `app.config.compilerOptions.isCustomElement = (tag) => tag === "lime-bundle"` to silence Vue's warning.
113
+
114
+ **Svelte:**
115
+
116
+ ```svelte
117
+ <lime-bundle
118
+ shop-domain="my-shop.myshopify.com"
119
+ storefront-token={TOKEN}
120
+ bundle-gid="..."
121
+ on:lime-bundle:add-to-cart={handle}
122
+ />
123
+ ```
124
+
125
+ ## Styling
126
+
127
+ Render happens inside a closed Shadow DOM. Override CSS custom properties on the host:
128
+
129
+ ```html
130
+ <lime-bundle
131
+ style="--lb-primary-color: #e91e63; --lb-radius: 16px;"
132
+ shop-domain="..."
133
+ ></lime-bundle>
134
+ ```
135
+
136
+ Merchant custom CSS from `/app/settings/custom-css` is auto-fetched and injected on mount.
137
+
138
+ Full variable list: [css-variables.md](https://github.com/lime-app-dev/lime-bundles-app/blob/main/docs/headless/css-variables.md).
139
+
140
+ ## Bundle size
141
+
142
+ `lime-bundle.js` (IIFE, gzipped) is <30 KB. No runtime framework dependency.
143
+
144
+ ## Versioning
145
+
146
+ Major versions bump together with `@lime-bundles/core` and `@lime-bundles/react`.
147
+
148
+ ## License
149
+
150
+ MIT. See repo root.
151
+
152
+ ## Links
153
+
154
+ - [Web component guide](https://github.com/lime-app-dev/lime-bundles-app/blob/main/docs/headless/web-component.md)
155
+ - [Report issues](https://github.com/lime-app-dev/lime-bundles-app/issues)
package/dist/index.cjs CHANGED
@@ -382,19 +382,35 @@ var WIDGET_STYLES = `
382
382
  `;
383
383
 
384
384
  // src/lime-bundle.ts
385
+ var cartStorageKey = (shopDomain) => `lb_cart_id:${shopDomain}`;
386
+ function resolveProductHandle(explicit) {
387
+ if (explicit) return explicit.trim() || null;
388
+ if (typeof document !== "undefined") {
389
+ const meta = document.querySelector(
390
+ 'meta[name="shopify:product-handle"]'
391
+ );
392
+ if (meta?.content) return meta.content.trim() || null;
393
+ }
394
+ if (typeof window !== "undefined") {
395
+ const match = window.location.pathname.match(/\/products\/([^/?#]+)/);
396
+ if (match?.[1]) return decodeURIComponent(match[1]);
397
+ }
398
+ return null;
399
+ }
385
400
  var LimeBundleElement = class extends HTMLElement {
386
401
  static observedAttributes = [
387
402
  "shop-domain",
388
403
  "storefront-token",
389
404
  "bundle-gid",
405
+ "product-handle",
390
406
  "app-url",
391
407
  "analytics",
392
408
  "locale"
393
409
  ];
394
410
  shadow;
395
- bundle = null;
411
+ bundles = [];
396
412
  abortController = null;
397
- impressionCleanup = null;
413
+ impressionCleanups = [];
398
414
  constructor() {
399
415
  super();
400
416
  this.shadow = this.attachShadow({ mode: "open" });
@@ -405,16 +421,16 @@ var LimeBundleElement = class extends HTMLElement {
405
421
  }
406
422
  disconnectedCallback() {
407
423
  this.abortController?.abort();
408
- this.teardownImpression();
424
+ this.teardownImpressions();
409
425
  }
410
- teardownImpression() {
411
- this.impressionCleanup?.();
412
- this.impressionCleanup = null;
426
+ teardownImpressions() {
427
+ for (const cleanup of this.impressionCleanups) cleanup();
428
+ this.impressionCleanups = [];
413
429
  }
414
430
  attributeChangedCallback(name, oldValue, newValue) {
415
431
  if (oldValue === newValue || !this.isConnected) return;
416
- if (name === "bundle-gid" || name === "shop-domain" || name === "storefront-token") {
417
- if (this.shopDomain && this.storefrontToken && this.bundleGid) {
432
+ if (name === "bundle-gid" || name === "product-handle" || name === "shop-domain" || name === "storefront-token") {
433
+ if (this.shopDomain && this.storefrontToken) {
418
434
  this.fetchBundle();
419
435
  }
420
436
  }
@@ -428,6 +444,9 @@ var LimeBundleElement = class extends HTMLElement {
428
444
  get bundleGid() {
429
445
  return this.getAttribute("bundle-gid") ?? "";
430
446
  }
447
+ get productHandleAttr() {
448
+ return this.getAttribute("product-handle") ?? "";
449
+ }
431
450
  get appUrl() {
432
451
  return this.getAttribute("app-url") ?? "";
433
452
  }
@@ -435,9 +454,9 @@ var LimeBundleElement = class extends HTMLElement {
435
454
  return this.getAttribute("analytics") !== "false";
436
455
  }
437
456
  async fetchBundle() {
438
- if (!this.shopDomain || !this.storefrontToken || !this.bundleGid) {
457
+ if (!this.shopDomain || !this.storefrontToken) {
439
458
  this.renderError(
440
- "Missing required attributes: shop-domain, storefront-token, bundle-gid"
459
+ "Missing required attributes: shop-domain, storefront-token"
441
460
  );
442
461
  return;
443
462
  }
@@ -445,147 +464,255 @@ var LimeBundleElement = class extends HTMLElement {
445
464
  const controller = new AbortController();
446
465
  this.abortController = controller;
447
466
  this.renderLoading();
467
+ const client = (0, import_core4.createStorefrontClient)({
468
+ shopDomain: this.shopDomain,
469
+ accessToken: this.storefrontToken
470
+ });
448
471
  try {
449
- const client = (0, import_core4.createStorefrontClient)({
450
- shopDomain: this.shopDomain,
451
- accessToken: this.storefrontToken
452
- });
453
- const [bundleData, cssData] = await Promise.all([
454
- client.query(
455
- import_core4.BUNDLE_METAOBJECT_QUERY,
456
- { id: this.bundleGid },
457
- { signal: controller.signal }
458
- ),
459
- client.query(
460
- import_core4.SHOP_CUSTOM_CSS_QUERY,
461
- void 0,
462
- { signal: controller.signal }
463
- ).catch(() => null)
464
- ]);
472
+ let bundlePromise;
473
+ let singleBundleMode = false;
474
+ if (this.bundleGid) {
475
+ singleBundleMode = true;
476
+ bundlePromise = this.fetchSingleBundle(client, controller.signal);
477
+ } else {
478
+ const handle = resolveProductHandle(this.productHandleAttr);
479
+ if (!handle) {
480
+ this.teardownImpressions();
481
+ this.renderError(
482
+ "No bundle-gid or product-handle provided, and the current URL doesn't match /products/<handle>."
483
+ );
484
+ return;
485
+ }
486
+ bundlePromise = this.fetchProductBundles(
487
+ client,
488
+ controller.signal,
489
+ handle
490
+ );
491
+ }
492
+ const cssPromise = client.query(import_core4.SHOP_CUSTOM_CSS_QUERY, void 0, {
493
+ signal: controller.signal
494
+ }).catch(() => null);
495
+ await bundlePromise;
465
496
  if (controller.signal.aborted) return;
466
- if (!bundleData.metaobject) {
467
- this.bundle = null;
468
- this.teardownImpression();
497
+ if (singleBundleMode && this.bundles.length === 0) {
498
+ this.teardownImpressions();
469
499
  this.renderError("Bundle not found");
470
500
  return;
471
501
  }
472
- this.bundle = (0, import_core4.parseMetaobjectBundle)(
473
- bundleData.metaobject.id,
474
- bundleData.metaobject.fields
475
- );
476
- if (!this.bundle) {
477
- this.teardownImpression();
478
- this.renderError("Bundle is not active or has expired");
479
- return;
502
+ const css = await cssPromise;
503
+ if (css?.shop?.metafield?.value) {
504
+ (0, import_core4.injectCustomCss)(this.shopDomain, css.shop.metafield.value);
480
505
  }
481
- if (cssData?.shop?.metafield?.value) {
482
- (0, import_core4.injectCustomCss)(this.shopDomain, cssData.shop.metafield.value);
483
- }
484
- this.renderBundle();
485
- this.setupImpression();
506
+ this.renderBundles();
486
507
  } catch (err) {
487
508
  if (controller.signal.aborted) return;
488
- this.bundle = null;
489
- this.teardownImpression();
509
+ this.bundles = [];
510
+ this.teardownImpressions();
490
511
  this.renderError(
491
512
  err instanceof Error ? err.message : "Failed to load bundle"
492
513
  );
493
514
  }
494
515
  }
516
+ async fetchSingleBundle(client, signal) {
517
+ const data = await client.query(
518
+ import_core4.BUNDLE_METAOBJECT_QUERY,
519
+ { id: this.bundleGid },
520
+ { signal }
521
+ );
522
+ if (!data.metaobject) {
523
+ this.bundles = [];
524
+ return;
525
+ }
526
+ const parsed = (0, import_core4.parseMetaobjectBundle)(
527
+ data.metaobject.id,
528
+ data.metaobject.fields
529
+ );
530
+ this.bundles = parsed ? [parsed] : [];
531
+ }
532
+ async fetchProductBundles(client, signal, productHandle) {
533
+ const data = await client.query(
534
+ import_core4.BUNDLES_FOR_PRODUCT_QUERY,
535
+ { handle: productHandle },
536
+ { signal }
537
+ );
538
+ if (!data.product) {
539
+ this.bundles = [];
540
+ return;
541
+ }
542
+ const refs = data.product.metafield?.references?.nodes ?? [];
543
+ const bundles = [];
544
+ for (const ref of refs) {
545
+ const parsed = (0, import_core4.parseMetaobjectBundle)(ref.id, ref.fields);
546
+ if (parsed) bundles.push(parsed);
547
+ }
548
+ this.bundles = bundles;
549
+ }
495
550
  /**
496
- * Dispatch add-to-cart for merchant handling. Returns truethe widget
497
- * reports success optimistically. If the merchant's cart mutation fails,
498
- * they're responsible for surfacing that error in their own UI.
551
+ * Dispatch add-to-cart with a cancelable event, thenunless a listener
552
+ * called preventDefault execute the default cart-and-checkout flow.
553
+ *
554
+ * `fire-and-forget` against `reportAddToCart` runs regardless so merchants
555
+ * with BYO cart still get analytics.
499
556
  */
500
- dispatchAddToCart = (lines) => {
501
- this.dispatchEvent(
502
- new CustomEvent("lime-bundle:add-to-cart", {
503
- detail: { lines },
504
- bubbles: true,
505
- composed: true
506
- })
507
- );
508
- if (this.analyticsEnabled && this.appUrl && this.bundle) {
509
- const quantity = lines.reduce((sum, l) => sum + l.quantity, 0);
510
- const totalPrice = lines.reduce((sum, line) => {
511
- const product = this.bundle.products.find(
512
- (p) => p.variants.nodes.some((v) => v.id === line.merchandiseId)
513
- );
514
- const variant = product?.variants.nodes.find(
515
- (v) => v.id === line.merchandiseId
516
- );
517
- const price = variant ? parseFloat(variant.price.amount) : 0;
518
- return sum + price * line.quantity;
519
- }, 0);
520
- (0, import_core4.reportAddToCart)(
521
- { shopDomain: this.shopDomain, appUrl: this.appUrl },
522
- {
523
- bundleGid: this.bundleGid,
524
- bundleType: this.bundle.bundleType,
525
- productId: this.bundle.products[0]?.id ?? "",
526
- quantity,
527
- totalPrice: Math.round(totalPrice * 100) / 100
528
- }
529
- );
557
+ handleAddToCart = async (bundle, lines) => {
558
+ const ev = new CustomEvent("lime-bundle:add-to-cart", {
559
+ detail: { lines },
560
+ bubbles: true,
561
+ composed: true,
562
+ cancelable: true
563
+ });
564
+ const allowDefault = this.dispatchEvent(ev);
565
+ this.reportAddToCartEvent(bundle, lines);
566
+ if (allowDefault) {
567
+ await this.defaultAddToCart(lines);
530
568
  }
531
569
  };
532
- renderBundle() {
533
- if (!this.bundle) return;
534
- const container = document.createElement("div");
535
- container.className = "lb-bundle";
536
- container.setAttribute("role", "region");
537
- container.setAttribute("aria-label", this.bundle.title);
538
- switch (this.bundle.bundleType) {
539
- case "fixed":
540
- renderFixedBundle(
541
- container,
542
- this.bundle,
543
- this.dispatchAddToCart
570
+ /**
571
+ * Default cart flow: Shopify's Storefront Cart API is tokenless, so we
572
+ * don't need any additional scopes. Persist the cart ID in localStorage
573
+ * so subsequent adds on the same browser session join the existing cart
574
+ * instead of creating a new one every click.
575
+ */
576
+ async defaultAddToCart(lines) {
577
+ if (typeof window === "undefined") return;
578
+ const client = (0, import_core4.createStorefrontClient)({
579
+ shopDomain: this.shopDomain,
580
+ accessToken: this.storefrontToken
581
+ });
582
+ const storage = window.localStorage;
583
+ const key = cartStorageKey(this.shopDomain);
584
+ const existingCartId = storage?.getItem(key) ?? null;
585
+ try {
586
+ let checkoutUrl = null;
587
+ if (existingCartId) {
588
+ const res = await client.query(
589
+ import_core4.CART_LINES_ADD_MUTATION,
590
+ { cartId: existingCartId, lines }
544
591
  );
545
- break;
546
- case "mix_match":
547
- renderMixMatchBundle(
548
- container,
549
- this.bundle,
550
- this.dispatchAddToCart
592
+ const payload = res.cartLinesAdd;
593
+ if (payload?.userErrors?.length) {
594
+ storage?.removeItem(key);
595
+ } else if (payload?.cart) {
596
+ checkoutUrl = payload.cart.checkoutUrl;
597
+ }
598
+ }
599
+ if (!checkoutUrl) {
600
+ const res = await client.query(
601
+ import_core4.CART_CREATE_MUTATION,
602
+ { input: { lines } }
551
603
  );
552
- break;
553
- case "volume":
554
- renderVolumeBundle(
555
- container,
556
- this.bundle,
557
- this.dispatchAddToCart
604
+ const payload = res.cartCreate;
605
+ if (payload?.cart) {
606
+ storage?.setItem(key, payload.cart.id);
607
+ checkoutUrl = payload.cart.checkoutUrl;
608
+ }
609
+ }
610
+ if (checkoutUrl) {
611
+ window.location.assign(checkoutUrl);
612
+ } else {
613
+ this.dispatchEvent(
614
+ new CustomEvent("lime-bundle:error", {
615
+ detail: { message: "Cart creation failed", code: "CART_ERROR" },
616
+ bubbles: true,
617
+ composed: true
618
+ })
558
619
  );
559
- break;
620
+ }
621
+ } catch (err) {
622
+ this.dispatchEvent(
623
+ new CustomEvent("lime-bundle:error", {
624
+ detail: {
625
+ message: err instanceof Error ? err.message : "Cart mutation failed",
626
+ code: "CART_ERROR"
627
+ },
628
+ bubbles: true,
629
+ composed: true
630
+ })
631
+ );
560
632
  }
633
+ }
634
+ reportAddToCartEvent(bundle, lines) {
635
+ if (!this.analyticsEnabled || !this.appUrl) return;
636
+ const quantity = lines.reduce((sum, l) => sum + l.quantity, 0);
637
+ const totalPrice = lines.reduce((sum, line) => {
638
+ const product = bundle.products.find(
639
+ (p) => p.variants.nodes.some((v) => v.id === line.merchandiseId)
640
+ );
641
+ const variant = product?.variants.nodes.find(
642
+ (v) => v.id === line.merchandiseId
643
+ );
644
+ const price = variant ? parseFloat(variant.price.amount) : 0;
645
+ return sum + price * line.quantity;
646
+ }, 0);
647
+ (0, import_core4.reportAddToCart)(
648
+ { shopDomain: this.shopDomain, appUrl: this.appUrl },
649
+ {
650
+ bundleGid: bundle.id,
651
+ bundleType: bundle.bundleType,
652
+ productId: bundle.products[0]?.id ?? "",
653
+ quantity,
654
+ totalPrice: Math.round(totalPrice * 100) / 100
655
+ }
656
+ );
657
+ }
658
+ renderBundles() {
659
+ this.teardownImpressions();
561
660
  this.shadow.innerHTML = "";
562
661
  const style = document.createElement("style");
563
662
  style.textContent = WIDGET_STYLES;
564
663
  this.shadow.appendChild(style);
565
- this.shadow.appendChild(container);
664
+ for (const bundle of this.bundles) {
665
+ const container = document.createElement("div");
666
+ container.className = "lb-bundle";
667
+ container.setAttribute("role", "region");
668
+ container.setAttribute("aria-label", bundle.title);
669
+ const dispatch = (lines) => this.handleAddToCart(bundle, lines);
670
+ switch (bundle.bundleType) {
671
+ case "fixed":
672
+ renderFixedBundle(container, bundle, dispatch);
673
+ break;
674
+ case "mix_match":
675
+ renderMixMatchBundle(
676
+ container,
677
+ bundle,
678
+ dispatch
679
+ );
680
+ break;
681
+ case "volume":
682
+ renderVolumeBundle(container, bundle, dispatch);
683
+ break;
684
+ }
685
+ this.shadow.appendChild(container);
686
+ this.setupImpressionFor(bundle, container);
687
+ }
688
+ const first = this.bundles[0];
566
689
  this.dispatchEvent(
567
690
  new CustomEvent("lime-bundle:loaded", {
568
691
  detail: {
569
- bundleType: this.bundle.bundleType,
570
- title: this.bundle.title
692
+ bundleCount: this.bundles.length,
693
+ bundleTypes: this.bundles.map((b) => b.bundleType),
694
+ // Legacy fields — meaningful only in single-bundle mode. Preserved
695
+ // for merchants who attached listeners against the pre-1.0 shape.
696
+ bundleType: first?.bundleType,
697
+ title: first?.title
571
698
  },
572
699
  bubbles: true,
573
700
  composed: true
574
701
  })
575
702
  );
576
703
  }
577
- setupImpression() {
578
- if (!this.analyticsEnabled || !this.bundle || !this.appUrl) return;
579
- this.impressionCleanup?.();
580
- this.impressionCleanup = (0, import_core4.observeImpression)(this, () => {
704
+ setupImpressionFor(bundle, element) {
705
+ if (!this.analyticsEnabled || !this.appUrl) return;
706
+ const cleanup = (0, import_core4.observeImpression)(element, () => {
581
707
  (0, import_core4.reportImpression)(
582
708
  { shopDomain: this.shopDomain, appUrl: this.appUrl },
583
709
  {
584
- bundleGid: this.bundleGid,
585
- bundleType: this.bundle.bundleType
710
+ bundleGid: bundle.id,
711
+ bundleType: bundle.bundleType
586
712
  }
587
713
  );
588
714
  });
715
+ this.impressionCleanups.push(cleanup);
589
716
  }
590
717
  renderLoading() {
591
718
  this.shadow.innerHTML = `