@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/dist/index.js CHANGED
@@ -2,12 +2,16 @@
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
+ fetchBundlesForProduct
11
15
  } from "@lime-bundles/core";
12
16
 
13
17
  // src/renderers/fixed.ts
@@ -373,19 +377,35 @@ var WIDGET_STYLES = `
373
377
  `;
374
378
 
375
379
  // src/lime-bundle.ts
380
+ var cartStorageKey = (shopDomain) => `lb_cart_id:${shopDomain}`;
381
+ function resolveProductHandle(explicit) {
382
+ if (explicit) return explicit.trim() || null;
383
+ if (typeof document !== "undefined") {
384
+ const meta = document.querySelector(
385
+ 'meta[name="shopify:product-handle"]'
386
+ );
387
+ if (meta?.content) return meta.content.trim() || null;
388
+ }
389
+ if (typeof window !== "undefined") {
390
+ const match = window.location.pathname.match(/\/products\/([^/?#]+)/);
391
+ if (match?.[1]) return decodeURIComponent(match[1]);
392
+ }
393
+ return null;
394
+ }
376
395
  var LimeBundleElement = class extends HTMLElement {
377
396
  static observedAttributes = [
378
397
  "shop-domain",
379
398
  "storefront-token",
380
399
  "bundle-gid",
400
+ "product-handle",
381
401
  "app-url",
382
402
  "analytics",
383
403
  "locale"
384
404
  ];
385
405
  shadow;
386
- bundle = null;
406
+ bundles = [];
387
407
  abortController = null;
388
- impressionCleanup = null;
408
+ impressionCleanups = [];
389
409
  constructor() {
390
410
  super();
391
411
  this.shadow = this.attachShadow({ mode: "open" });
@@ -396,16 +416,16 @@ var LimeBundleElement = class extends HTMLElement {
396
416
  }
397
417
  disconnectedCallback() {
398
418
  this.abortController?.abort();
399
- this.teardownImpression();
419
+ this.teardownImpressions();
400
420
  }
401
- teardownImpression() {
402
- this.impressionCleanup?.();
403
- this.impressionCleanup = null;
421
+ teardownImpressions() {
422
+ for (const cleanup of this.impressionCleanups) cleanup();
423
+ this.impressionCleanups = [];
404
424
  }
405
425
  attributeChangedCallback(name, oldValue, newValue) {
406
426
  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) {
427
+ if (name === "bundle-gid" || name === "product-handle" || name === "shop-domain" || name === "storefront-token") {
428
+ if (this.shopDomain && this.storefrontToken) {
409
429
  this.fetchBundle();
410
430
  }
411
431
  }
@@ -419,6 +439,9 @@ var LimeBundleElement = class extends HTMLElement {
419
439
  get bundleGid() {
420
440
  return this.getAttribute("bundle-gid") ?? "";
421
441
  }
442
+ get productHandleAttr() {
443
+ return this.getAttribute("product-handle") ?? "";
444
+ }
422
445
  get appUrl() {
423
446
  return this.getAttribute("app-url") ?? "";
424
447
  }
@@ -426,9 +449,9 @@ var LimeBundleElement = class extends HTMLElement {
426
449
  return this.getAttribute("analytics") !== "false";
427
450
  }
428
451
  async fetchBundle() {
429
- if (!this.shopDomain || !this.storefrontToken || !this.bundleGid) {
452
+ if (!this.shopDomain || !this.storefrontToken) {
430
453
  this.renderError(
431
- "Missing required attributes: shop-domain, storefront-token, bundle-gid"
454
+ "Missing required attributes: shop-domain, storefront-token"
432
455
  );
433
456
  return;
434
457
  }
@@ -436,147 +459,255 @@ var LimeBundleElement = class extends HTMLElement {
436
459
  const controller = new AbortController();
437
460
  this.abortController = controller;
438
461
  this.renderLoading();
462
+ const client = createStorefrontClient({
463
+ shopDomain: this.shopDomain,
464
+ accessToken: this.storefrontToken
465
+ });
439
466
  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
- ]);
467
+ let bundlePromise;
468
+ let singleBundleMode = false;
469
+ if (this.bundleGid) {
470
+ singleBundleMode = true;
471
+ bundlePromise = this.fetchSingleBundle(client, controller.signal);
472
+ } else {
473
+ const handle = resolveProductHandle(this.productHandleAttr);
474
+ if (!handle) {
475
+ this.teardownImpressions();
476
+ this.renderError(
477
+ "No bundle-gid or product-handle provided, and the current URL doesn't match /products/<handle>."
478
+ );
479
+ return;
480
+ }
481
+ bundlePromise = this.fetchProductBundles(
482
+ client,
483
+ controller.signal,
484
+ handle
485
+ );
486
+ }
487
+ const cssPromise = client.query(SHOP_CUSTOM_CSS_QUERY, void 0, {
488
+ signal: controller.signal
489
+ }).catch(() => null);
490
+ await bundlePromise;
456
491
  if (controller.signal.aborted) return;
457
- if (!bundleData.metaobject) {
458
- this.bundle = null;
459
- this.teardownImpression();
492
+ if (singleBundleMode && this.bundles.length === 0) {
493
+ this.teardownImpressions();
460
494
  this.renderError("Bundle not found");
461
495
  return;
462
496
  }
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;
497
+ const css = await cssPromise;
498
+ if (css?.shop?.metafield?.value) {
499
+ injectCustomCss(this.shopDomain, css.shop.metafield.value);
471
500
  }
472
- if (cssData?.shop?.metafield?.value) {
473
- injectCustomCss(this.shopDomain, cssData.shop.metafield.value);
474
- }
475
- this.renderBundle();
476
- this.setupImpression();
501
+ this.renderBundles();
477
502
  } catch (err) {
478
503
  if (controller.signal.aborted) return;
479
- this.bundle = null;
480
- this.teardownImpression();
504
+ this.bundles = [];
505
+ this.teardownImpressions();
481
506
  this.renderError(
482
507
  err instanceof Error ? err.message : "Failed to load bundle"
483
508
  );
484
509
  }
485
510
  }
511
+ async fetchSingleBundle(client, signal) {
512
+ const data = await client.query(
513
+ BUNDLE_METAOBJECT_QUERY,
514
+ { id: this.bundleGid },
515
+ { signal }
516
+ );
517
+ if (!data.metaobject) {
518
+ this.bundles = [];
519
+ return;
520
+ }
521
+ const parsed = parseMetaobjectBundle(
522
+ data.metaobject.id,
523
+ data.metaobject.fields
524
+ );
525
+ this.bundles = parsed ? [parsed] : [];
526
+ }
527
+ async fetchProductBundles(client, signal, productHandle) {
528
+ const data = await client.query(
529
+ BUNDLES_FOR_PRODUCT_QUERY,
530
+ { handle: productHandle },
531
+ { signal }
532
+ );
533
+ if (!data.product) {
534
+ this.bundles = [];
535
+ return;
536
+ }
537
+ const refs = data.product.metafield?.references?.nodes ?? [];
538
+ const bundles = [];
539
+ for (const ref of refs) {
540
+ const parsed = parseMetaobjectBundle(ref.id, ref.fields);
541
+ if (parsed) bundles.push(parsed);
542
+ }
543
+ this.bundles = bundles;
544
+ }
486
545
  /**
487
- * Dispatch add-to-cart for merchant handling. Returns truethe widget
488
- * reports success optimistically. If the merchant's cart mutation fails,
489
- * they're responsible for surfacing that error in their own UI.
546
+ * Dispatch add-to-cart with a cancelable event, thenunless a listener
547
+ * called preventDefault execute the default cart-and-checkout flow.
548
+ *
549
+ * `fire-and-forget` against `reportAddToCart` runs regardless so merchants
550
+ * with BYO cart still get analytics.
490
551
  */
491
- dispatchAddToCart = (lines) => {
492
- this.dispatchEvent(
493
- new CustomEvent("lime-bundle:add-to-cart", {
494
- detail: { lines },
495
- bubbles: true,
496
- composed: true
497
- })
498
- );
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
- );
552
+ handleAddToCart = async (bundle, lines) => {
553
+ const ev = new CustomEvent("lime-bundle:add-to-cart", {
554
+ detail: { lines },
555
+ bubbles: true,
556
+ composed: true,
557
+ cancelable: true
558
+ });
559
+ const allowDefault = this.dispatchEvent(ev);
560
+ this.reportAddToCartEvent(bundle, lines);
561
+ if (allowDefault) {
562
+ await this.defaultAddToCart(lines);
521
563
  }
522
564
  };
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
565
+ /**
566
+ * Default cart flow: Shopify's Storefront Cart API is tokenless, so we
567
+ * don't need any additional scopes. Persist the cart ID in localStorage
568
+ * so subsequent adds on the same browser session join the existing cart
569
+ * instead of creating a new one every click.
570
+ */
571
+ async defaultAddToCart(lines) {
572
+ if (typeof window === "undefined") return;
573
+ const client = createStorefrontClient({
574
+ shopDomain: this.shopDomain,
575
+ accessToken: this.storefrontToken
576
+ });
577
+ const storage = window.localStorage;
578
+ const key = cartStorageKey(this.shopDomain);
579
+ const existingCartId = storage?.getItem(key) ?? null;
580
+ try {
581
+ let checkoutUrl = null;
582
+ if (existingCartId) {
583
+ const res = await client.query(
584
+ CART_LINES_ADD_MUTATION,
585
+ { cartId: existingCartId, lines }
535
586
  );
536
- break;
537
- case "mix_match":
538
- renderMixMatchBundle(
539
- container,
540
- this.bundle,
541
- this.dispatchAddToCart
587
+ const payload = res.cartLinesAdd;
588
+ if (payload?.userErrors?.length) {
589
+ storage?.removeItem(key);
590
+ } else if (payload?.cart) {
591
+ checkoutUrl = payload.cart.checkoutUrl;
592
+ }
593
+ }
594
+ if (!checkoutUrl) {
595
+ const res = await client.query(
596
+ CART_CREATE_MUTATION,
597
+ { input: { lines } }
542
598
  );
543
- break;
544
- case "volume":
545
- renderVolumeBundle(
546
- container,
547
- this.bundle,
548
- this.dispatchAddToCart
599
+ const payload = res.cartCreate;
600
+ if (payload?.cart) {
601
+ storage?.setItem(key, payload.cart.id);
602
+ checkoutUrl = payload.cart.checkoutUrl;
603
+ }
604
+ }
605
+ if (checkoutUrl) {
606
+ window.location.assign(checkoutUrl);
607
+ } else {
608
+ this.dispatchEvent(
609
+ new CustomEvent("lime-bundle:error", {
610
+ detail: { message: "Cart creation failed", code: "CART_ERROR" },
611
+ bubbles: true,
612
+ composed: true
613
+ })
549
614
  );
550
- break;
615
+ }
616
+ } catch (err) {
617
+ this.dispatchEvent(
618
+ new CustomEvent("lime-bundle:error", {
619
+ detail: {
620
+ message: err instanceof Error ? err.message : "Cart mutation failed",
621
+ code: "CART_ERROR"
622
+ },
623
+ bubbles: true,
624
+ composed: true
625
+ })
626
+ );
551
627
  }
628
+ }
629
+ reportAddToCartEvent(bundle, lines) {
630
+ if (!this.analyticsEnabled || !this.appUrl) return;
631
+ const quantity = lines.reduce((sum, l) => sum + l.quantity, 0);
632
+ const totalPrice = lines.reduce((sum, line) => {
633
+ const product = bundle.products.find(
634
+ (p) => p.variants.nodes.some((v) => v.id === line.merchandiseId)
635
+ );
636
+ const variant = product?.variants.nodes.find(
637
+ (v) => v.id === line.merchandiseId
638
+ );
639
+ const price = variant ? parseFloat(variant.price.amount) : 0;
640
+ return sum + price * line.quantity;
641
+ }, 0);
642
+ reportAddToCart(
643
+ { shopDomain: this.shopDomain, appUrl: this.appUrl },
644
+ {
645
+ bundleGid: bundle.id,
646
+ bundleType: bundle.bundleType,
647
+ productId: bundle.products[0]?.id ?? "",
648
+ quantity,
649
+ totalPrice: Math.round(totalPrice * 100) / 100
650
+ }
651
+ );
652
+ }
653
+ renderBundles() {
654
+ this.teardownImpressions();
552
655
  this.shadow.innerHTML = "";
553
656
  const style = document.createElement("style");
554
657
  style.textContent = WIDGET_STYLES;
555
658
  this.shadow.appendChild(style);
556
- this.shadow.appendChild(container);
659
+ for (const bundle of this.bundles) {
660
+ const container = document.createElement("div");
661
+ container.className = "lb-bundle";
662
+ container.setAttribute("role", "region");
663
+ container.setAttribute("aria-label", bundle.title);
664
+ const dispatch = (lines) => this.handleAddToCart(bundle, lines);
665
+ switch (bundle.bundleType) {
666
+ case "fixed":
667
+ renderFixedBundle(container, bundle, dispatch);
668
+ break;
669
+ case "mix_match":
670
+ renderMixMatchBundle(
671
+ container,
672
+ bundle,
673
+ dispatch
674
+ );
675
+ break;
676
+ case "volume":
677
+ renderVolumeBundle(container, bundle, dispatch);
678
+ break;
679
+ }
680
+ this.shadow.appendChild(container);
681
+ this.setupImpressionFor(bundle, container);
682
+ }
683
+ const first = this.bundles[0];
557
684
  this.dispatchEvent(
558
685
  new CustomEvent("lime-bundle:loaded", {
559
686
  detail: {
560
- bundleType: this.bundle.bundleType,
561
- title: this.bundle.title
687
+ bundleCount: this.bundles.length,
688
+ bundleTypes: this.bundles.map((b) => b.bundleType),
689
+ // Legacy fields — meaningful only in single-bundle mode. Preserved
690
+ // for merchants who attached listeners against the pre-1.0 shape.
691
+ bundleType: first?.bundleType,
692
+ title: first?.title
562
693
  },
563
694
  bubbles: true,
564
695
  composed: true
565
696
  })
566
697
  );
567
698
  }
568
- setupImpression() {
569
- if (!this.analyticsEnabled || !this.bundle || !this.appUrl) return;
570
- this.impressionCleanup?.();
571
- this.impressionCleanup = observeImpression(this, () => {
699
+ setupImpressionFor(bundle, element) {
700
+ if (!this.analyticsEnabled || !this.appUrl) return;
701
+ const cleanup = observeImpression(element, () => {
572
702
  reportImpression(
573
703
  { shopDomain: this.shopDomain, appUrl: this.appUrl },
574
704
  {
575
- bundleGid: this.bundleGid,
576
- bundleType: this.bundle.bundleType
705
+ bundleGid: bundle.id,
706
+ bundleType: bundle.bundleType
577
707
  }
578
708
  );
579
709
  });
710
+ this.impressionCleanups.push(cleanup);
580
711
  }
581
712
  renderLoading() {
582
713
  this.shadow.innerHTML = `