@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 +155 -0
- package/dist/index.cjs +238 -111
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +71 -9
- package/dist/index.d.ts +71 -9
- package/dist/index.js +243 -112
- package/dist/index.js.map +1 -1
- package/dist/lime-bundle.global.js +126 -17
- package/dist/lime-bundle.global.js.map +1 -1
- package/package.json +2 -2
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
|
-
|
|
406
|
+
bundles = [];
|
|
387
407
|
abortController = null;
|
|
388
|
-
|
|
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.
|
|
419
|
+
this.teardownImpressions();
|
|
400
420
|
}
|
|
401
|
-
|
|
402
|
-
this.
|
|
403
|
-
this.
|
|
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
|
|
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
|
|
452
|
+
if (!this.shopDomain || !this.storefrontToken) {
|
|
430
453
|
this.renderError(
|
|
431
|
-
"Missing required attributes: shop-domain, storefront-token
|
|
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
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
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 (
|
|
458
|
-
this.
|
|
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
|
-
|
|
464
|
-
|
|
465
|
-
|
|
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
|
-
|
|
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.
|
|
480
|
-
this.
|
|
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
|
|
488
|
-
*
|
|
489
|
-
*
|
|
546
|
+
* Dispatch add-to-cart with a cancelable event, then — unless 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
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
);
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
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
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
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
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
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
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
561
|
-
|
|
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
|
-
|
|
569
|
-
if (!this.analyticsEnabled || !this.
|
|
570
|
-
|
|
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:
|
|
576
|
-
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 = `
|