@occluze/tether 0.6.6 → 0.6.8

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.
Files changed (135) hide show
  1. package/README.md +3 -3
  2. package/dist/esm/astro.js +2 -2
  3. package/dist/esm/chunks/account-C3NP6HFB.js +1 -0
  4. package/dist/esm/chunks/account-pages-WLK374FO.js +1 -0
  5. package/dist/esm/chunks/analytics-G3SAWMNL.js +1 -0
  6. package/dist/esm/chunks/chunk-27PJJZGC.js +1 -0
  7. package/dist/esm/chunks/chunk-2FSXS55U.js +1 -0
  8. package/dist/esm/chunks/chunk-FCSWQDVV.js +1 -0
  9. package/dist/esm/chunks/chunk-FOMSMIAM.js +1 -0
  10. package/dist/esm/chunks/chunk-GZJTCLLW.js +1 -0
  11. package/dist/esm/chunks/chunk-HEQILZVR.js +1 -0
  12. package/dist/esm/chunks/chunk-JOOEXYF3.js +4 -0
  13. package/dist/esm/chunks/chunk-MPTQYLSM.js +1 -0
  14. package/dist/esm/chunks/chunk-NEHZ37TU.js +3 -0
  15. package/dist/esm/chunks/chunk-V74KGEJF.js +1 -0
  16. package/dist/esm/chunks/chunk-VT4CDUJJ.js +1 -0
  17. package/dist/esm/chunks/chunk-YJLWX2HK.js +1 -0
  18. package/dist/esm/chunks/diagnose-6VVGZNTY.js +14 -0
  19. package/dist/esm/chunks/extras-GJVRDFM3.js +1 -0
  20. package/dist/esm/chunks/markets-ui-UNB4PTA7.js +1 -0
  21. package/dist/esm/chunks/search-OU75RNQL.js +1 -0
  22. package/dist/esm/index.js +1 -1
  23. package/dist/esm/server.js +2 -1
  24. package/dist/presets/barba.esm.js +56 -36
  25. package/dist/tether.account-pages.js +776 -432
  26. package/dist/tether.account-pages.min.js +1 -1
  27. package/dist/tether.account.js +655 -321
  28. package/dist/tether.account.min.js +1 -1
  29. package/dist/tether.analytics.js +318 -191
  30. package/dist/tether.analytics.min.js +1 -1
  31. package/dist/tether.diagnose.js +334 -243
  32. package/dist/tether.diagnose.min.js +2 -2
  33. package/dist/tether.extras.js +78 -33
  34. package/dist/tether.extras.min.js +1 -1
  35. package/dist/tether.js +3171 -1998
  36. package/dist/tether.markets.js +231 -126
  37. package/dist/tether.markets.min.js +1 -1
  38. package/dist/tether.min.js +0 -0
  39. package/dist/tether.min.js.map +4 -4
  40. package/dist/tether.search.js +221 -126
  41. package/dist/tether.search.min.js +1 -1
  42. package/package.json +23 -4
  43. package/src/account/addresses-view.js +92 -0
  44. package/src/account/browser-session.js +359 -0
  45. package/src/account/markup.js +48 -0
  46. package/src/account/operations.js +180 -0
  47. package/src/account/order-lines.js +96 -0
  48. package/src/account/order-list.js +91 -0
  49. package/src/account/order-page.js +192 -0
  50. package/src/account/page-events.js +288 -0
  51. package/src/account/server-client.js +172 -0
  52. package/src/account/shared.js +111 -0
  53. package/src/account/shopify-auth.js +150 -0
  54. package/src/account/subscriptions-view.js +158 -0
  55. package/src/account-api.js +118 -0
  56. package/src/account-links.js +35 -0
  57. package/src/account-pages.js +68 -284
  58. package/src/account.js +281 -317
  59. package/src/amounts.js +36 -0
  60. package/src/analytics-shopify.js +241 -0
  61. package/src/analytics.js +304 -184
  62. package/src/api-version.js +75 -25
  63. package/src/astro.js +233 -68
  64. package/src/cart/checkout.js +97 -0
  65. package/src/cart/drawer.js +138 -0
  66. package/src/cart/mutations.js +234 -0
  67. package/src/cart/render.js +294 -0
  68. package/src/cart/store.js +326 -0
  69. package/src/cart.js +322 -481
  70. package/src/client.js +214 -78
  71. package/src/collection.js +126 -57
  72. package/src/config.js +323 -59
  73. package/src/controls/cart-controls.js +155 -0
  74. package/src/controls/discount.js +96 -0
  75. package/src/controls/extras.js +63 -0
  76. package/src/controls/navigation.js +83 -0
  77. package/src/diagnose-markup.js +333 -0
  78. package/src/diagnose.js +251 -202
  79. package/src/dom.js +219 -220
  80. package/src/extras.js +119 -37
  81. package/src/fragments.js +115 -22
  82. package/src/images.js +104 -0
  83. package/src/index.js +245 -285
  84. package/src/initial.js +78 -66
  85. package/src/lazy-iife.js +92 -22
  86. package/src/lazy.js +25 -4
  87. package/src/loader.js +263 -124
  88. package/src/market-codes.js +35 -0
  89. package/src/markets-ui.js +169 -55
  90. package/src/markets.js +286 -85
  91. package/src/markup.js +64 -0
  92. package/src/mounting.js +214 -91
  93. package/src/page-market.js +116 -0
  94. package/src/parts/account-pages.js +4 -1
  95. package/src/parts/account.js +4 -1
  96. package/src/parts/analytics.js +4 -1
  97. package/src/parts/diagnose.js +4 -1
  98. package/src/parts/extras.js +4 -1
  99. package/src/parts/markets.js +4 -1
  100. package/src/parts/search.js +4 -1
  101. package/src/presets/barba.js +120 -30
  102. package/src/product-events.js +130 -0
  103. package/src/product-render.js +257 -0
  104. package/src/product-state.js +318 -0
  105. package/src/product.js +233 -459
  106. package/src/repeaters.js +173 -0
  107. package/src/search.js +277 -107
  108. package/src/selling-plans.js +107 -47
  109. package/src/server.js +168 -205
  110. package/src/shopify-values.js +89 -0
  111. package/src/status.js +99 -39
  112. package/src/store-html.js +0 -0
  113. package/src/storefront/catalog.js +199 -0
  114. package/src/storefront/json-ld.js +167 -0
  115. package/src/variant-render.js +165 -0
  116. package/types/astro.d.ts +13 -2
  117. package/types/index.d.ts +178 -27
  118. package/types/server.d.ts +89 -17
  119. package/dist/esm/chunks/account-QUBHQG3C.js +0 -1
  120. package/dist/esm/chunks/account-pages-VWDHF6WA.js +0 -1
  121. package/dist/esm/chunks/analytics-SLATWPLC.js +0 -1
  122. package/dist/esm/chunks/chunk-64TUD3QR.js +0 -1
  123. package/dist/esm/chunks/chunk-AJLPU2JC.js +0 -4
  124. package/dist/esm/chunks/chunk-FIOZPPC5.js +0 -1
  125. package/dist/esm/chunks/chunk-K2YEQAIH.js +0 -1
  126. package/dist/esm/chunks/chunk-L2TRRKYV.js +0 -1
  127. package/dist/esm/chunks/chunk-RREHGH4A.js +0 -1
  128. package/dist/esm/chunks/chunk-VC46IEJQ.js +0 -1
  129. package/dist/esm/chunks/chunk-VCPI7MJ2.js +0 -1
  130. package/dist/esm/chunks/chunk-VPVJPNB4.js +0 -1
  131. package/dist/esm/chunks/chunk-ZIUIWEON.js +0 -1
  132. package/dist/esm/chunks/diagnose-VXQBSD3A.js +0 -14
  133. package/dist/esm/chunks/extras-R4X34VQL.js +0 -1
  134. package/dist/esm/chunks/markets-ui-F3R5T5EL.js +0 -1
  135. package/dist/esm/chunks/search-NILMGYXG.js +0 -1
package/src/cart.js CHANGED
@@ -1,550 +1,391 @@
1
1
  /**
2
- * Cart: Shopify Cart API, state in localStorage, document-wide rendering.
3
- *
4
- * Every mutation is queued, so fast clicking never races. All public methods
5
- * return the updated cart.
2
+ * The cart, as one object: `Tether.cart`. Every change goes through one queue (fast clicks never race) and resolves
3
+ * with the cart Shopify sent back. The state lives in `cart/store.js`, the Cart API calls in `cart/mutations.js`, the
4
+ * drawer in `cart/drawer.js`, the document rendering in `cart/render.js` and the checkout in `cart/checkout.js`.
6
5
  */
7
- import { cfg, log, reportError } from './config.js';
8
- import { gql, unwrap, TetherError } from './client.js';
9
- import { cartFragment } from './fragments.js';
10
- import { getProduct } from './loader.js';
11
- import { allPlans, GID } from './selling-plans.js';
6
+ import { cfg, reportError } from './config.js';
7
+ import { TetherError } from './client.js';
8
+ import { emit, toAttributes } from './dom.js';
9
+ import {
10
+ fetchCart,
11
+ isCartGone,
12
+ mutateCart,
13
+ removeLines,
14
+ setLineQuantity,
15
+ toLineInput,
16
+ toLineUpdate,
17
+ updateLines,
18
+ } from './cart/mutations.js';
19
+ import { checkout } from './cart/checkout.js';
20
+ import { drawers, isOpen, openCart, closeCart, toggleCart, trapFocus } from './cart/drawer.js';
12
21
  import {
13
- attr, sel, get, qsa, emit, formatMoney, setText, setTextTracked, setImage, show,
14
- repeat, repeatKeyed, toAttributes, productUrl, notifyLayoutChanged,
15
- } from './dom.js';
22
+ announce,
23
+ cartStatus,
24
+ commit,
25
+ currentCart,
26
+ currentWarnings,
27
+ customer,
28
+ enqueue,
29
+ forgetSavedCart,
30
+ keepQuietly,
31
+ pendingQuantities,
32
+ pendingQuantityOf,
33
+ render,
34
+ savedCartId,
35
+ setFailure,
36
+ settleLoad,
37
+ showLastKnownCount,
38
+ subscribe,
39
+ } from './cart/store.js';
40
+ /** @import { CartWarning } from './config.js' */
41
+ /** @import { Attributes, LineSpec, CartLineInput, LinePatch } from './cart/mutations.js' */
42
+ /** @import { ShopifyCart, ShopifyCartLine, CustomerLink, CartStatus } from './cart/store.js' */
43
+
44
+ export { trapFocus };
45
+
46
+ /** @returns {readonly ShopifyCartLine[]} */
47
+ const currentLines = () => currentCart()?.lines.nodes ?? [];
16
48
 
17
- /** Accepts many shapes and returns a Shopify CartLineInput. */
18
- export function toLineInput(input, quantity = 1, opts = {}) {
19
- const src = typeof input === 'object' && input !== null ? input : { variantId: input, quantity, ...opts };
20
- const merchandiseId = GID.variant(src.merchandiseId || src.variantId || src.id);
21
- if (!merchandiseId) throw new TetherError('add(): variantId is required');
22
- const line = { merchandiseId, quantity: Math.max(1, parseInt(src.quantity ?? 1, 10) || 1) };
23
- const plan = src.sellingPlanId || src.sellingPlan;
24
- if (plan) line.sellingPlanId = GID.sellingPlan(plan);
25
- const attrs = toAttributes(src.attributes);
26
- if (attrs.length) line.attributes = attrs;
27
- return line;
49
+ /**
50
+ * +1 / -1 for a line. Quick clicks add up: each builds on the quantity the click before asked for, which shows at once
51
+ * (`tether-line="quantity"`, `quantityOf`) until Shopify has answered. Only the latest target is sent; the ones it
52
+ * replaced are skipped.
53
+ *
54
+ * @param {string} lineId
55
+ * @param {number} delta
56
+ * @returns {Promise<ShopifyCart | null>}
57
+ */
58
+ function changeQuantity(lineId, delta) {
59
+ const line = currentLines().find((candidate) => candidate.id === lineId);
60
+ if (!line) return Promise.resolve(currentCart());
61
+ const target = Math.max(0, pendingQuantityOf(line) + delta);
62
+ const job = { quantity: target };
63
+ pendingQuantities.set(lineId, job);
64
+ announce('pending');
65
+ const isLatest = () => pendingQuantities.get(lineId) === job;
66
+ const sent = enqueue(async () => {
67
+ if (!isLatest()) return currentCart();
68
+ const now = currentLines().find((candidate) => candidate.id === lineId);
69
+ // A + then a -: Shopify already has the quantity.
70
+ if (now?.quantity === target) return currentCart();
71
+ const cart = await setLineQuantity(lineId, target);
72
+ // A newer click is on its way: keep this answer, but show totals and count once that one is in, instead of
73
+ // stepping through every reply.
74
+ if (!isLatest()) {
75
+ keepQuietly(cart);
76
+ return cart;
77
+ }
78
+ return commit(cart, target > 0 ? 'update' : 'remove');
79
+ });
80
+ return sent.finally(() => {
81
+ if (!isLatest()) return;
82
+ pendingQuantities.delete(lineId);
83
+ announce('settled');
84
+ });
28
85
  }
29
86
 
30
- function mutation(name, args, inner) {
31
- return `mutation Tether_${name}(${args}) {
32
- ${name}(${inner}) { cart { ...TetherCart } userErrors { field message } warnings { code message target } }
33
- }
34
- ${cartFragment()}`;
87
+ /**
88
+ * The codes the cart has once a change is its turn in the queue.
89
+ *
90
+ * @param {string[] | string | ((current: string[]) => string[])} codes A function gets the codes as they are by then.
91
+ * @returns {string[]}
92
+ */
93
+ function discountCodesFrom(codes) {
94
+ const current = (currentCart()?.discountCodes ?? []).map((discount) => discount.code);
95
+ const list = typeof codes === 'function' ? codes(current) : codes;
96
+ return (Array.isArray(list) ? list : [list]).map((code) => code.trim()).filter(Boolean);
35
97
  }
36
98
 
37
- const FOCUSABLE = 'a[href],button:not([disabled]),input:not([disabled]),select:not([disabled]),textarea:not([disabled]),[tabindex]:not([tabindex="-1"])';
38
- let returnFocus = null;
39
-
40
- /** Focusable elements inside an open drawer, in order (visible ones only). */
41
- export const focusables = (d) => qsa(d, FOCUSABLE).filter((el) => el.offsetParent !== null || el === document.activeElement);
42
-
43
- /** aria-expanded on the open buttons; dialog semantics on drawers (unless the markup has its own). */
44
- function ariaState(open) {
45
- qsa(document, sel('cart-open')).forEach((b) => b.setAttribute('aria-expanded', String(open)));
46
- qsa(document, sel('cart-drawer')).forEach((d) => {
47
- if (!d.hasAttribute('role')) d.setAttribute('role', 'dialog');
48
- if (!d.hasAttribute('aria-label') && !d.hasAttribute('aria-labelledby')) d.setAttribute('aria-label', get(d, 'cart-label') || 'Cart');
49
- d.setAttribute('aria-modal', String(open));
50
- // Closed: hidden from screen readers and out of the Tab order (inert), like a closed dialog.
51
- if (!d.classList.contains(cfg.cartDrawerOpenClass)) { d.setAttribute('aria-hidden', 'true'); if (cfg.cartFocus !== false) d.inert = true; }
52
- });
99
+ /**
100
+ * @param {Attributes} attributes
101
+ * @returns {{ key: string, value: string }[]} The cart's attributes with `attributes` merged in; `''` removes a key.
102
+ */
103
+ function mergedAttributes(attributes) {
104
+ const merged = new Map((currentCart()?.attributes ?? []).map((attribute) => [attribute.key, attribute.value]));
105
+ toAttributes(attributes).forEach((attribute) => merged.set(attribute.key, attribute.value));
106
+ return [...merged].filter(([, value]) => value !== '').map(([key, value]) => ({ key, value }));
53
107
  }
54
108
 
55
- const isMissingCart = (err) => /does not exist|not found|invalid id/i.test(err?.message || '');
109
+ /**
110
+ * Loads the saved cart. A cart Shopify no longer has is forgotten; one it can't be asked about (the network, the
111
+ * token) is kept, because it is probably fine.
112
+ *
113
+ * @returns {Promise<ShopifyCart | null>}
114
+ */
115
+ async function init() {
116
+ const id = savedCartId();
117
+ if (!id) {
118
+ settleLoad({ kind: 'loaded', cart: null });
119
+ return null;
120
+ }
121
+ showLastKnownCount();
122
+ try {
123
+ const cart = await fetchCart(id);
124
+ if (!cart) forgetSavedCart();
125
+ settleLoad({ kind: 'loaded', cart });
126
+ } catch (error) {
127
+ reportError(error, 'cart load failed');
128
+ if (isCartGone(error)) forgetSavedCart();
129
+ settleLoad({ kind: 'failed', error });
130
+ }
131
+ return currentCart();
132
+ }
56
133
 
57
134
  export const Cart = {
58
- state: null,
59
- _chain: Promise.resolve(),
135
+ init,
136
+ /** @returns {string | null} The saved cart's id. */
137
+ id: savedCartId,
60
138
 
61
- id() { try { return localStorage.getItem(cfg.storageKey); } catch { return null; } },
62
- _setId(id) {
63
- try {
64
- if (id) localStorage.setItem(cfg.storageKey, id);
65
- else localStorage.removeItem(cfg.storageKey);
66
- } catch { /* private mode */ }
139
+ /** @returns {CartStatus} */
140
+ get status() {
141
+ return cartStatus();
67
142
  },
68
-
69
- get lines() { return this.state?.lines?.nodes || []; },
70
- get count() { return this.state?.totalQuantity || 0; },
71
-
72
- lastError: null,
73
- /** Set by the account part while a customer is logged in (Customer Account API token). */
74
- customerAccessToken: null,
75
- /** line id -> { qty, done }: what quick +/- clicks asked for, shown until Shopify has answered. */
76
- pendingQty: new Map(),
77
- /** Shopify's notes on the last change, e.g. less stock than asked: [{ code, message }]. */
78
- warnings: [],
79
- /** true once the saved cart (or the absence of one) is known. */
80
- loaded: false,
81
-
82
- _take(payload, name) {
83
- const p = unwrap(payload, name);
84
- // Shopify can repeat a warning per line or code: each message once.
85
- this.warnings = (p.warnings || []).filter((w, i, all) => all.findIndex((x) => x.message === w.message) === i)
86
- .map((w) => ({ ...w, message: (typeof cfg.warningText === 'function' && cfg.warningText(w)) || w.message }));
87
- if (this.warnings.length) emit('cart-warning', { warnings: this.warnings });
88
- return p.cart;
143
+ /** @returns {ShopifyCart | null} */
144
+ get state() {
145
+ return currentCart();
89
146
  },
90
-
91
- _queue(fn) {
92
- const run = this._chain.then(fn, fn).then((value) => {
93
- if (this.lastError) { this.lastError = null; this.render(); }
94
- return value;
95
- }, (err) => {
96
- // tether-show="cart-error" becomes visible until the next successful cart call.
97
- this.lastError = err;
98
- this.render();
99
- emit('cart-error', { error: err });
100
- throw err;
101
- });
102
- this._chain = run.catch(() => {});
103
- return run;
147
+ /** @returns {readonly ShopifyCartLine[]} */
148
+ get lines() {
149
+ return currentLines();
104
150
  },
105
-
106
- _commit(cart, reason) {
107
- this.state = cart;
108
- if (cart?.id) this._setId(cart.id);
109
- // Other tabs reload the cart (storage event); the count also shows at once on the next page.
110
- try { localStorage.setItem(`${cfg.storageKey}:seen`, `${Date.now()}|${cart?.totalQuantity || 0}`); } catch { /* private mode */ }
111
- this.render();
112
- emit('cart-updated', { cart, reason });
113
- return cart;
151
+ /** @returns {number} */
152
+ get count() {
153
+ return currentCart()?.totalQuantity ?? 0;
114
154
  },
115
-
116
- async init() {
117
- const id = this.id();
118
- if (!id) { this.state = null; this.loaded = true; this.render(); emit('cart-updated', { cart: null, reason: 'load' }); return null; }
119
- // Until the saved cart has loaded, show its last known count instead of the page's 0.
120
- if (!this.loaded) {
121
- try {
122
- const n = (localStorage.getItem(`${cfg.storageKey}:seen`) || '').split('|')[1];
123
- if (n) qsa(document, sel('cart-count')).forEach((el) => { if (/^\s*0?\s*$/.test(el.textContent)) el.textContent = n; });
124
- } catch { /* private mode */ }
125
- }
126
- try {
127
- const data = await gql(`query TetherCart($id: ID!) { cart(id: $id) { ...TetherCart } }\n${cartFragment()}`, { id });
128
- if (!data.cart) { this._setId(null); this.state = null; } else this.state = data.cart;
129
- } catch (err) {
130
- reportError(err, 'cart load failed');
131
- this.lastError = err;
132
- // Keep the cart id on network/API errors: the cart is probably fine, Shopify isn't reachable.
133
- if (isMissingCart(err)) this._setId(null);
134
- this.state = null;
135
- }
136
- this.loaded = true;
137
- this.render();
138
- emit('cart-updated', { cart: this.state, reason: 'load' }); // islands (cart.subscribe) hear the loaded cart
139
- return this.state;
155
+ /** @returns {boolean} True once the saved cart, or that there is none, is known. */
156
+ get loaded() {
157
+ return cartStatus().kind !== 'unknown';
140
158
  },
141
-
142
- async _create(input = {}) {
143
- const buyerIdentity = cfg.country || this.customerAccessToken ? {
144
- ...(cfg.country ? { countryCode: String(cfg.country).toUpperCase() } : {}),
145
- ...(this.customerAccessToken ? { customerAccessToken: this.customerAccessToken } : {}),
146
- } : undefined;
147
- const data = await gql(
148
- mutation('cartCreate', '$input: CartInput!', 'input: $input'),
149
- { input: { buyerIdentity, ...input } },
150
- );
151
- return this._take(data.cartCreate, 'cartCreate');
159
+ /** @returns {unknown} The failure of the last load or change, until a change succeeds. */
160
+ get lastError() {
161
+ const current = cartStatus();
162
+ return current.kind === 'error' ? current.error : null;
163
+ },
164
+ set lastError(error) {
165
+ setFailure(error);
166
+ },
167
+ /** @returns {readonly CartWarning[]} Shopify's notes on the last change. */
168
+ get warnings() {
169
+ return currentWarnings();
170
+ },
171
+ /** @returns {ReadonlyMap<string, { quantity: number }>} Line id → the quantity quick clicks asked for. */
172
+ get pendingQty() {
173
+ return pendingQuantities;
152
174
  },
153
175
 
154
- /** Runs a cart mutation; creates a fresh cart if ours expired. */
155
- async _mutate(name, args, inner, variables, createInput) {
156
- const cartId = this.id();
157
- if (!cartId) return createInput ? this._create(createInput) : null;
158
- try {
159
- const data = await gql(mutation(name, `$cartId: ID!, ${args}`, `cartId: $cartId, ${inner}`), { ...variables, cartId });
160
- return this._take(data[name], name);
161
- } catch (err) {
162
- if (createInput && isMissingCart(err)) {
163
- log('cart expired, creating a new one');
164
- this._setId(null);
165
- return this._create(createInput);
166
- }
167
- throw err;
168
- }
176
+ /** @returns {string | null} The logged-in customer's Customer Account API token (set by the account part). */
177
+ get customerAccessToken() {
178
+ return customer.accessToken;
179
+ },
180
+ set customerAccessToken(token) {
181
+ customer.accessToken = token || null;
182
+ },
183
+ /** @returns {(() => Promise<string | null>) | null} The account's own checkout URL (set by the account part). */
184
+ get beforeCheckout() {
185
+ return customer.beforeCheckout;
186
+ },
187
+ set beforeCheckout(fetchUrl) {
188
+ customer.beforeCheckout = typeof fetchUrl === 'function' ? fetchUrl : null;
169
189
  },
190
+ /**
191
+ * Links the cart to a logged-in customer, or unlinks it with `null`s. Carts created from now on belong to the
192
+ * customer; `beforeCheckout` gives the checkout URL (`null` from it uses the cart's).
193
+ *
194
+ * @param {Partial<CustomerLink>} link Fields left out keep their value.
195
+ */
196
+ setCustomer(link) {
197
+ if (link.accessToken !== undefined) customer.accessToken = link.accessToken;
198
+ if (link.beforeCheckout !== undefined) customer.beforeCheckout = link.beforeCheckout;
199
+ },
200
+
201
+ /** Keeps a cart and shows it, as a change does; tests seed the cart with it. */
202
+ _commit: commit,
203
+ render,
204
+ subscribe,
170
205
 
171
206
  /**
172
- * add('gid://shopify/ProductVariant/1', 2)
173
- * add({ variantId, quantity, sellingPlanId, attributes })
174
- * add([line, line]) // bundles / build-a-box in one request
207
+ * `add(variantId, 2)`, `add({ variantId, quantity, sellingPlanId, attributes })`, or `add([line, line])` for
208
+ * bundles in one request. Creates the cart when there is none.
209
+ *
210
+ * @param {string | number | LineSpec | (string | number | LineSpec)[]} input
211
+ * @param {number} [quantity]
212
+ * @param {Omit<LineSpec, 'variantId' | 'quantity'>} [options]
213
+ * @returns {Promise<ShopifyCart | null>}
175
214
  */
176
- add(input, quantity = 1, opts = {}) {
177
- const lines = (Array.isArray(input) ? input : [input]).map((l) => toLineInput(l, quantity, opts));
178
- return this._queue(async () => {
179
- const cart = await this._mutate('cartLinesAdd', '$lines: [CartLineInput!]!', 'lines: $lines', { lines }, { lines });
180
- this._commit(cart, 'add');
215
+ add(input, quantity = 1, options = {}) {
216
+ /** @type {CartLineInput[]} */
217
+ const lines = (Array.isArray(input) ? input : [input]).map((line) => toLineInput(line, quantity, options));
218
+ return enqueue(async () => {
219
+ const cart = await mutateCart({
220
+ name: 'cartLinesAdd',
221
+ args: '$lines: [CartLineInput!]!',
222
+ inner: 'lines: $lines',
223
+ variables: { lines },
224
+ createInput: { lines },
225
+ });
226
+ commit(cart, 'add');
181
227
  emit('added', { cart, lines });
182
- if (cfg.openCartOnAdd) this.open();
228
+ if (cfg.openCartOnAdd) openCart();
183
229
  return cart;
184
230
  });
185
231
  },
186
232
 
187
233
  /**
188
- * updateLine(lineId, 3)
189
- * updateLine(lineId, { quantity, sellingPlanId, attributes })
190
- * sellingPlanId: null switches a line back to one-time purchase.
234
+ * `updateLine(lineId, 3)` or `updateLine(lineId, { quantity, sellingPlanId, attributes })`.
235
+ *
236
+ * @param {string} lineId
237
+ * @param {number | LinePatch} patch A quantity of 0 or less removes the line.
238
+ * @returns {Promise<ShopifyCart | null>}
191
239
  */
192
240
  updateLine(lineId, patch) {
193
- const p = typeof patch === 'number' ? { quantity: patch } : { ...patch };
194
- if (p.quantity !== undefined && p.quantity <= 0) return this.removeLine(lineId);
195
-
196
- const line = { id: lineId };
197
- if (p.quantity !== undefined) line.quantity = p.quantity;
198
- if ('sellingPlanId' in p) line.sellingPlanId = p.sellingPlanId ? GID.sellingPlan(p.sellingPlanId) : null;
199
- if (p.attributes) line.attributes = toAttributes(p.attributes);
200
- if (p.merchandiseId || p.variantId) line.merchandiseId = GID.variant(p.merchandiseId || p.variantId);
201
-
202
- return this._queue(async () => this._commit(
203
- await this._mutate('cartLinesUpdate', '$lines: [CartLineUpdateInput!]!', 'lines: $lines', { lines: [line] }),
204
- 'update',
205
- ));
241
+ const quantity = typeof patch === 'number' ? patch : patch.quantity;
242
+ if (quantity !== undefined && quantity <= 0) return Cart.removeLine(lineId);
243
+ const line = toLineUpdate(lineId, patch);
244
+ return enqueue(async () => commit(await updateLines([line]), 'update'));
206
245
  },
207
246
 
247
+ changeQuantity,
248
+
208
249
  /**
209
- * +1 / -1 for a line. Quick clicks add up: each builds on the quantity the last click asked for,
210
- * which shows at once (tether-line="quantity", Cart.lines) until Shopify has answered.
250
+ * @param {ShopifyCartLine} line
251
+ * @returns {number} The quantity to show: the one asked for while an update is on its way.
211
252
  */
212
- changeQuantity(lineId, delta) {
213
- const current = this.lines.find((l) => l.id === lineId);
214
- if (!current) return Promise.resolve(this.state);
215
- const target = Math.max(0, (this.pendingQty.get(lineId)?.qty ?? current.quantity) + delta);
216
- const job = { qty: target, done: false };
217
- this.pendingQty.set(lineId, job);
218
- this.render();
219
- emit('cart-updated', { cart: this.state, reason: 'pending' });
220
- const latest = () => this.pendingQty.get(lineId) === job;
221
- return this._queue(async () => {
222
- // Quick clicks: only the latest target goes to Shopify; the ones it replaced are skipped.
223
- if (!latest()) return this.state;
224
- const now = this.lines.find((l) => l.id === lineId);
225
- if (now && now.quantity === target) return this.state; // e.g. + then -: nothing to change
226
- const cart = target > 0
227
- ? await this._mutate('cartLinesUpdate', '$lines: [CartLineUpdateInput!]!', 'lines: $lines', { lines: [{ id: lineId, quantity: target }] })
228
- : await this._mutate('cartLinesRemove', '$lineIds: [ID!]!', 'lineIds: $lineIds', { lineIds: [lineId] });
229
- // A newer click is on its way: keep this answer, but show totals and count only once that
230
- // one is in (no stepping through every reply).
231
- if (!latest()) { this.state = cart; if (cart?.id) this._setId(cart.id); return cart; }
232
- return this._commit(cart, target > 0 ? 'update' : 'remove');
233
- }).finally(() => {
234
- if (latest()) { this.pendingQty.delete(lineId); this.render(); emit('cart-updated', { cart: this.state, reason: 'settled' }); }
235
- });
236
- },
237
-
238
- /** Quantity to show for a line: the one asked for while an update is on its way. */
239
- quantityOf(line) { return this.pendingQty.get(line.id)?.qty ?? line.quantity; },
253
+ quantityOf: pendingQuantityOf,
240
254
 
255
+ /**
256
+ * @param {string | string[]} lineIds
257
+ * @returns {Promise<ShopifyCart | null>}
258
+ */
241
259
  removeLine(lineIds) {
242
260
  const ids = Array.isArray(lineIds) ? lineIds : [lineIds];
243
- return this._queue(async () => this._commit(
244
- await this._mutate('cartLinesRemove', '$lineIds: [ID!]!', 'lineIds: $lineIds', { lineIds: ids }),
245
- 'remove',
246
- ));
261
+ return enqueue(async () => commit(await removeLines(ids), 'remove'));
247
262
  },
248
263
 
264
+ /** @returns {Promise<ShopifyCart | null>} */
249
265
  clear() {
250
- const ids = this.lines.map((l) => l.id);
251
- if (!ids.length) return Promise.resolve(this.state);
252
- return this.removeLine(ids);
266
+ const ids = currentLines().map((line) => line.id);
267
+ if (!ids.length) return Promise.resolve(currentCart());
268
+ return Cart.removeLine(ids);
253
269
  },
254
270
 
271
+ /**
272
+ * @param {string | null | undefined} note
273
+ * @returns {Promise<ShopifyCart | null>}
274
+ */
255
275
  setNote(note) {
256
- return this._queue(async () => this._commit(
257
- await this._mutate('cartNoteUpdate', '$note: String!', 'note: $note', { note: note ?? '' }, { note: note ?? '' }),
258
- 'note',
259
- ));
260
- },
261
-
262
- /** Cart-level attributes, merged with existing ones. Value '' removes a key. */
263
- setAttributes(obj) {
264
- const merged = new Map((this.state?.attributes || []).map((a) => [a.key, a.value]));
265
- toAttributes(obj).forEach((a) => merged.set(a.key, a.value));
266
- const attributes = [...merged].filter(([, v]) => v !== '').map(([key, value]) => ({ key, value }));
267
- return this._queue(async () => this._commit(
268
- await this._mutate('cartAttributesUpdate', '$attributes: [AttributeInput!]!', 'attributes: $attributes', { attributes }, { attributes }),
269
- 'attributes',
270
- ));
271
- },
272
-
273
- /** Replaces the discount codes. Pass [] to remove all. */
274
- setDiscountCodes(codes) {
275
- const discountCodes = (Array.isArray(codes) ? codes : [codes]).map((c) => String(c).trim()).filter(Boolean);
276
- return this._queue(async () => this._commit(
277
- await this._mutate('cartDiscountCodesUpdate', '$discountCodes: [String!]!', 'discountCodes: $discountCodes', { discountCodes }, { discountCodes }),
278
- 'discount',
279
- ));
276
+ const variables = { note: note ?? '' };
277
+ return enqueue(async () =>
278
+ commit(
279
+ await mutateCart({
280
+ name: 'cartNoteUpdate',
281
+ args: '$note: String!',
282
+ inner: 'note: $note',
283
+ variables,
284
+ createInput: variables,
285
+ }),
286
+ 'note',
287
+ ),
288
+ );
280
289
  },
281
290
 
282
291
  /**
283
- * Adds a code. A code Shopify doesn't accept isn't kept in the cart (it would warn on every change):
284
- * it's removed again and the promise rejects with code INVALID_DISCOUNT.
292
+ * Cart attributes, merged with the cart's (as it is when the change runs, so quick calls don't drop each other).
293
+ *
294
+ * @param {Attributes} attributes The value `''` removes a key.
295
+ * @returns {Promise<ShopifyCart | null>}
285
296
  */
286
- async applyDiscount(code) {
287
- const current = (this.state?.discountCodes || []).map((d) => d.code);
288
- const cart = await this.setDiscountCodes([...new Set([...current, code])]);
289
- const hit = (cart?.discountCodes || []).find((d) => d.code.toLowerCase() === String(code).toLowerCase());
290
- if (hit?.applicable) return cart;
291
- await this.removeDiscount(code).catch(() => {});
292
- throw new TetherError(`Discount code not valid: ${code}`, { code: 'INVALID_DISCOUNT' });
293
- },
294
-
295
- removeDiscount(code) {
296
- const k = String(code).toLowerCase();
297
- return this.setDiscountCodes((this.state?.discountCodes || []).map((d) => d.code).filter((c) => c.toLowerCase() !== k));
297
+ setAttributes(attributes) {
298
+ return enqueue(async () => {
299
+ const variables = { attributes: mergedAttributes(attributes) };
300
+ const cart = await mutateCart({
301
+ name: 'cartAttributesUpdate',
302
+ args: '$attributes: [AttributeInput!]!',
303
+ inner: 'attributes: $attributes',
304
+ variables,
305
+ createInput: variables,
306
+ });
307
+ return commit(cart, 'attributes');
308
+ });
298
309
  },
299
310
 
300
- /** { countryCode, email, phone } — changes currency/prices for that market. */
301
- setBuyerIdentity(buyerIdentity, { create = true } = {}) {
302
- return this._queue(async () => {
303
- const cart = await this._mutate('cartBuyerIdentityUpdate', '$buyerIdentity: CartBuyerIdentityInput!', 'buyerIdentity: $buyerIdentity', { buyerIdentity }, create ? { buyerIdentity } : null)
304
- .catch((err) => {
305
- if (create || !isMissingCart(err)) throw err;
306
- this._setId(null); // expired cart: forget it instead of creating an empty one
307
- return null;
308
- });
309
- return this._commit(cart, 'buyer-identity');
311
+ /**
312
+ * Replaces the discount codes; `[]` removes them all.
313
+ *
314
+ * @param {string[] | string | ((current: string[]) => string[])} codes
315
+ * @returns {Promise<ShopifyCart | null>}
316
+ */
317
+ setDiscountCodes(codes) {
318
+ return enqueue(async () => {
319
+ const variables = { discountCodes: discountCodesFrom(codes) };
320
+ const cart = await mutateCart({
321
+ name: 'cartDiscountCodesUpdate',
322
+ args: '$discountCodes: [String!]!',
323
+ inner: 'discountCodes: $discountCodes',
324
+ variables,
325
+ createInput: variables,
326
+ });
327
+ return commit(cart, 'discount');
310
328
  });
311
329
  },
312
330
 
313
331
  /**
314
- * Goes to Shopify checkout. With checkoutTarget '_blank' the tab is opened
315
- * inside the click (Safari blocks window.open after an await) and gets its
316
- * URL once we have it.
332
+ * Adds a code. A code Shopify doesn't accept is removed again, so it doesn't warn on every later change.
333
+ *
334
+ * @param {string} code
335
+ * @returns {Promise<ShopifyCart | null>}
336
+ * @throws {TetherError} `INVALID_DISCOUNT` (rejected) when Shopify doesn't accept the code.
317
337
  */
318
- async checkout(target = cfg.checkoutTarget) {
319
- const id = this.id();
320
- if (!id || (this.state && !this.count)) return null; // nothing to check out
321
- const tab = target === '_blank' ? window.open('', '_blank') : null;
322
- if (tab) { try { tab.opener = null; } catch { /* ignore */ } }
323
-
338
+ async applyDiscount(code) {
339
+ const cart = await Cart.setDiscountCodes((current) => [...new Set([...current, code])]);
340
+ const wanted = code.toLowerCase();
341
+ const applied = (cart?.discountCodes ?? []).find((discount) => discount.code.toLowerCase() === wanted);
342
+ if (applied?.applicable) return cart;
324
343
  try {
325
- const data = await gql('query TetherCheckout($id: ID!) { cart(id: $id) { checkoutUrl } }', { id });
326
- let url = (this.beforeCheckout && await this.beforeCheckout().catch(() => null)) || data.cart?.checkoutUrl;
327
- if (url && this.customerAccessToken) url += `${url.includes('?') ? '&' : '?'}logged_in=true`;
328
- if (!url) throw new TetherError('No checkoutUrl (cart empty or expired)');
329
- emit('checkout', { url, cart: this.state });
330
- if (tab && !tab.closed) tab.location.href = url;
331
- else window.location.href = url;
332
- return url;
333
- } catch (err) {
334
- if (tab) tab.close();
335
- reportError(err, 'checkout failed');
336
- return null;
344
+ await Cart.removeDiscount(code);
345
+ } catch (error) {
346
+ reportError(error, 'removing a discount code Shopify refused');
337
347
  }
348
+ throw new TetherError(`Discount code not valid: ${code}`, { code: 'INVALID_DISCOUNT' });
338
349
  },
339
350
 
340
- // -------------------------------------------------------------- drawer
341
-
342
- drawers() { return qsa(document, sel('cart-drawer')); },
343
-
344
- /** Without a tether-cart-drawer (a React/Svelte/Vue cart), open/close still work: as state for subscribe. */
345
- _open: false,
346
- isOpen() {
347
- const ds = this.drawers();
348
- return ds.length ? ds.some((d) => d.classList.contains(cfg.cartDrawerOpenClass)) : this._open;
349
- },
350
-
351
- open() {
352
- if (this.isOpen()) return;
353
- this._open = true;
354
- const ds = this.drawers().filter((d) => get(d, 'cart-drawer') !== 'manual');
355
- ds.forEach((d) => { d.inert = false; d.classList.add(cfg.cartDrawerOpenClass); d.setAttribute('aria-hidden', 'false'); });
356
- ariaState(true);
357
- if (cfg.cartFocus !== false && ds[0]) {
358
- // Move focus into the cart (after the open transition has made it visible).
359
- returnFocus = document.activeElement;
360
- setTimeout(() => {
361
- const target = focusables(ds[0])[0] || ds[0];
362
- if (target === ds[0] && !ds[0].hasAttribute('tabindex')) ds[0].tabIndex = -1;
363
- target.focus?.({ preventScroll: true });
364
- }, 60);
365
- }
366
- if (typeof cfg.onCartOpen === 'function') cfg.onCartOpen(ds);
367
- emit('cart-toggled', { open: true });
368
- },
369
-
370
- close() {
371
- if (!this.isOpen()) return;
372
- this._open = false;
373
- const ds = this.drawers();
374
- const hadFocus = ds.some((d) => d.contains(document.activeElement));
375
- ds.forEach((d) => { d.classList.remove(cfg.cartDrawerOpenClass); d.setAttribute('aria-hidden', 'true'); if (cfg.cartFocus !== false) d.inert = true; });
376
- ariaState(false);
377
- if (hadFocus && returnFocus?.isConnected) returnFocus.focus({ preventScroll: true });
378
- returnFocus = null;
379
- if (typeof cfg.onCartClose === 'function') cfg.onCartClose(ds);
380
- emit('cart-toggled', { open: false });
381
- },
382
-
383
- toggle() { if (this.isOpen()) this.close(); else this.open(); },
384
-
385
351
  /**
386
- * For framework islands (React, Svelte, Vue): fn(cart, { open }) now and on every change.
387
- * Returns a function that stops listening.
352
+ * @param {string} code
353
+ * @returns {Promise<ShopifyCart | null>}
388
354
  */
389
- subscribe(fn) {
390
- const call = () => fn(this.state, { open: this.isOpen(), count: this.count, loaded: this.loaded, warnings: this.warnings });
391
- const h = () => call();
392
- ['cart-updated', 'cart-toggled'].forEach((e) => document.addEventListener(`${cfg.prefix}:${e}`, h));
393
- call();
394
- return () => ['cart-updated', 'cart-toggled'].forEach((e) => document.removeEventListener(`${cfg.prefix}:${e}`, h));
355
+ removeDiscount(code) {
356
+ const unwanted = code.toLowerCase();
357
+ return Cart.setDiscountCodes((current) => current.filter((existing) => existing.toLowerCase() !== unwanted));
395
358
  },
396
359
 
397
- // -------------------------------------------------------------- render
398
-
399
- render() {
400
- const cart = this.state;
401
- const count = cart ? cart.totalQuantity : 0;
402
- const money = (k) => (cart ? formatMoney(cart.cost?.[k]) : '');
403
-
404
- ariaState(this.isOpen());
405
- qsa(document, sel('cart-count')).forEach((el) => setTextTracked(el, count));
406
- qsa(document, sel('cart-subtotal')).forEach((el) => setTextTracked(el, money('subtotalAmount')));
407
- qsa(document, sel('cart-total')).forEach((el) => setTextTracked(el, money('totalAmount')));
408
- qsa(document, sel('cart-checkout-charge')).forEach((el) => setTextTracked(el, money('checkoutChargeAmount')));
409
-
410
- const hasSub = this.lines.some((l) => l.sellingPlanAllocation);
411
- const conds = {
412
- 'cart-empty': count === 0,
413
- 'cart-filled': count > 0,
414
- 'cart-has-subscription': hasSub,
415
- 'cart-no-subscription': !hasSub,
416
- 'cart-has-discount': (cart?.discountCodes || []).some((d) => d.applicable),
417
- 'cart-error': Boolean(this.lastError),
418
- 'cart-warning': this.warnings.length > 0,
419
- };
420
- qsa(document, sel('cart-warning')).forEach((el) => setTextTracked(el, [...new Set(this.warnings.map((w) => w.message))].join(' ')));
421
- qsa(document, sel('show')).forEach((el) => {
422
- const k = get(el, 'show');
423
- if (k in conds && !el.closest(sel('product'))) show(el, conds[k]);
424
- });
425
- qsa(document, sel('checkout')).forEach((el) => {
426
- el.classList.toggle('is-disabled', !count);
427
- el.setAttribute('aria-disabled', String(!count));
428
- });
429
-
430
- qsa(document, `textarea${sel('cart-note')},input${sel('cart-note')}`).forEach((el) => {
431
- if (document.activeElement !== el) el.value = cart?.note || '';
432
- });
433
-
434
- qsa(document, sel('cart-discounts')).forEach((container) => {
435
- repeat(container, cart?.discountCodes || [], (node, d) => {
436
- node.setAttribute(attr('discount-code'), d.code);
437
- const t = node.querySelector(sel('discount', 'code')) || (node.firstElementChild ? null : node);
438
- if (t) setText(t, d.code);
439
- node.classList.toggle('is-inapplicable', !d.applicable);
440
- });
441
- });
442
-
443
- qsa(document, sel('cart-lines')).forEach((container) => {
444
- repeatKeyed(container, this.lines, (l) => l.id, (node, line) => renderLine(node, line), {
445
- onEnter: cfg.onLineEnter, onLeave: cfg.onLineLeave,
360
+ /**
361
+ * Sets who is buying; a country changes the cart's currency and prices to that market's.
362
+ *
363
+ * @param {{ countryCode?: string, email?: string, phone?: string, customerAccessToken?: string | null }} buyerIdentity
364
+ * @param {{ create?: boolean }} [options] `create: false` forgets an expired cart instead of creating an empty one.
365
+ * @returns {Promise<ShopifyCart | null>}
366
+ */
367
+ setBuyerIdentity(buyerIdentity, { create = true } = {}) {
368
+ return enqueue(async () => {
369
+ const cart = await mutateCart({
370
+ name: 'cartBuyerIdentityUpdate',
371
+ args: '$buyerIdentity: CartBuyerIdentityInput!',
372
+ inner: 'buyerIdentity: $buyerIdentity',
373
+ variables: { buyerIdentity },
374
+ createInput: create ? { buyerIdentity } : null,
375
+ }).catch((/** @type {unknown} */ error) => {
376
+ // mutateCart has forgotten the cart Shopify no longer has; without `create` that leaves no cart.
377
+ if (create || !isCartGone(error)) throw error;
378
+ return null;
446
379
  });
380
+ return commit(cart, 'buyer-identity');
447
381
  });
448
-
449
- if (cart) notifyLayoutChanged('cart');
450
382
  },
451
- };
452
-
453
- function lineValue(line, key) {
454
- const m = line.merchandise || {};
455
- const plan = line.sellingPlanAllocation?.sellingPlan;
456
- switch (key) {
457
- case 'title': return m.product?.title;
458
- case 'product-title': return m.product?.title;
459
- case 'variant-title': return m.title === 'Default Title' ? '' : m.title;
460
- case 'options': return (m.selectedOptions || [])
461
- .filter((o) => o.value !== 'Default Title').map((o) => `${o.name}: ${o.value}`).join(', ');
462
- case 'vendor': return m.product?.vendor;
463
- case 'sku': return m.sku;
464
- case 'price': return formatMoney(line.cost?.amountPerQuantity || m.price);
465
- case 'compare-at-price': return formatMoney(line.cost?.compareAtAmountPerQuantity || m.compareAtPrice);
466
- case 'line-total': return formatMoney(line.cost?.totalAmount);
467
- case 'line-subtotal': return formatMoney(line.cost?.subtotalAmount);
468
- case 'quantity': return Cart.quantityOf(line);
469
- case 'plan': return plan?.name || '';
470
- case 'plan-description': return plan?.description || '';
471
- case 'components': return (line.lineComponents || [])
472
- .map((c) => `${c.quantity} × ${c.merchandise?.product?.title || ''}`).join(', ');
473
- default:
474
- if (key.startsWith('attribute:')) {
475
- const k = key.slice('attribute:'.length);
476
- return (line.attributes || []).find((a) => a.key === k)?.value || '';
477
- }
478
- return undefined;
479
- }
480
- }
481
383
 
482
- function renderLine(node, line) {
483
- const m = line.merchandise || {};
484
- const isSub = Boolean(line.sellingPlanAllocation);
485
- node.setAttribute(attr('line-id'), line.id);
486
- node.classList.toggle('is-subscription', isSub);
487
-
488
- qsa(node, sel('line')).forEach((el) => {
489
- const key = get(el, 'line');
490
- if (key === 'image') return setImage(el, m.image?.url || m.product?.featuredImage?.url, m.product?.title);
491
- if (key === 'url') {
492
- const u = productUrl(m.product?.handle, m.product?.id);
493
- const q = new URLSearchParams();
494
- if (m.id) q.set('variant', String(m.id).split('/').pop());
495
- const plan = line.sellingPlanAllocation?.sellingPlan?.id;
496
- if (plan) q.set('selling_plan', plan.split('/').pop());
497
- const qs = q.toString();
498
- return el.setAttribute('href', qs ? `${u}${u.includes('?') ? '&' : '?'}${qs}` : u);
499
- }
500
- const v = lineValue(line, key);
501
- if (v !== undefined) setTextTracked(el, v);
502
- return undefined;
503
- });
504
-
505
- // Shopify's note about this line (e.g. less stock than asked): class is-warning, tether-line="warning".
506
- const warning = Cart.warnings.find((w) => w.target === line.id);
507
- node.classList.toggle('is-warning', Boolean(warning));
508
- const pending = Cart.pendingQty.has(line.id);
509
- node.classList.toggle('is-busy', pending);
510
- if (pending) node.setAttribute('aria-busy', 'true'); else node.removeAttribute('aria-busy');
511
- qsa(node, sel('line', 'warning')).forEach((el) => setTextTracked(el, warning?.message || ''));
512
- const lineConds = {
513
- subscription: isSub,
514
- 'one-time': !isSub,
515
- 'on-sale': parseFloat(line.cost?.compareAtAmountPerQuantity?.amount) > parseFloat(line.cost?.amountPerQuantity?.amount),
516
- 'has-components': (line.lineComponents || []).length > 0,
517
- warning: Boolean(warning),
518
- };
519
- qsa(node, sel('line-show')).forEach((el) => {
520
- const k = get(el, 'line-show');
521
- if (k in lineConds) show(el, lineConds[k]);
522
- });
523
-
524
- // Change frequency in the cart: <select tether-line-plan> lists the product's plans.
525
- qsa(node, `select${sel('line-plan')}`).forEach((select) => {
526
- const handle = m.product?.handle;
527
- if (!handle) return;
528
- getProduct(handle).then((product) => {
529
- const plans = allPlans(product);
530
- const allowOneTime = !product.requiresSellingPlan && get(select, 'line-plan') !== 'subscription-only';
531
- const want = [
532
- ...(allowOneTime ? [{ value: '', label: select.getAttribute(attr('one-time-label')) || 'One-time purchase' }] : []),
533
- ...plans.map((p) => ({ value: p.id, label: p.name })),
534
- ];
535
- const sig = want.map((w) => w.value).join('|');
536
- if (select.dataset.tetherSig !== sig) {
537
- select.innerHTML = '';
538
- want.forEach((w) => {
539
- const o = document.createElement('option');
540
- o.value = w.value; o.textContent = w.label;
541
- select.appendChild(o);
542
- });
543
- select.dataset.tetherSig = sig;
544
- }
545
- select.value = line.sellingPlanAllocation?.sellingPlan?.id || '';
546
- show(select, plans.length > 0);
547
- }).catch((err) => reportError(err, 'line plan options'));
548
- });
549
- }
384
+ checkout,
550
385
 
386
+ drawers,
387
+ isOpen,
388
+ open: openCart,
389
+ close: closeCart,
390
+ toggle: toggleCart,
391
+ };