@occluze/tether 0.6.7 → 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.
- package/README.md +3 -3
- package/dist/esm/astro.js +2 -2
- package/dist/esm/chunks/account-C3NP6HFB.js +1 -0
- package/dist/esm/chunks/account-pages-WLK374FO.js +1 -0
- package/dist/esm/chunks/analytics-G3SAWMNL.js +1 -0
- package/dist/esm/chunks/chunk-27PJJZGC.js +1 -0
- package/dist/esm/chunks/chunk-2FSXS55U.js +1 -0
- package/dist/esm/chunks/chunk-FCSWQDVV.js +1 -0
- package/dist/esm/chunks/chunk-FOMSMIAM.js +1 -0
- package/dist/esm/chunks/chunk-GZJTCLLW.js +1 -0
- package/dist/esm/chunks/chunk-HEQILZVR.js +1 -0
- package/dist/esm/chunks/chunk-JOOEXYF3.js +4 -0
- package/dist/esm/chunks/chunk-MPTQYLSM.js +1 -0
- package/dist/esm/chunks/chunk-NEHZ37TU.js +3 -0
- package/dist/esm/chunks/chunk-V74KGEJF.js +1 -0
- package/dist/esm/chunks/chunk-VT4CDUJJ.js +1 -0
- package/dist/esm/chunks/chunk-YJLWX2HK.js +1 -0
- package/dist/esm/chunks/diagnose-6VVGZNTY.js +14 -0
- package/dist/esm/chunks/extras-GJVRDFM3.js +1 -0
- package/dist/esm/chunks/markets-ui-UNB4PTA7.js +1 -0
- package/dist/esm/chunks/search-OU75RNQL.js +1 -0
- package/dist/esm/index.js +1 -1
- package/dist/esm/server.js +2 -1
- package/dist/presets/barba.esm.js +56 -36
- package/dist/tether.account-pages.js +773 -442
- package/dist/tether.account-pages.min.js +1 -1
- package/dist/tether.account.js +648 -318
- package/dist/tether.account.min.js +1 -1
- package/dist/tether.analytics.js +318 -191
- package/dist/tether.analytics.min.js +1 -1
- package/dist/tether.diagnose.js +331 -242
- package/dist/tether.diagnose.min.js +2 -2
- package/dist/tether.extras.js +78 -33
- package/dist/tether.extras.min.js +1 -1
- package/dist/tether.js +3176 -2015
- package/dist/tether.markets.js +231 -126
- package/dist/tether.markets.min.js +1 -1
- package/dist/tether.min.js +0 -0
- package/dist/tether.min.js.map +4 -4
- package/dist/tether.search.js +221 -126
- package/dist/tether.search.min.js +1 -1
- package/package.json +23 -4
- package/src/account/addresses-view.js +92 -0
- package/src/account/browser-session.js +359 -0
- package/src/account/markup.js +48 -0
- package/src/account/operations.js +180 -0
- package/src/account/order-lines.js +96 -0
- package/src/account/order-list.js +91 -0
- package/src/account/order-page.js +192 -0
- package/src/account/page-events.js +288 -0
- package/src/account/server-client.js +172 -0
- package/src/account/shared.js +111 -0
- package/src/account/shopify-auth.js +150 -0
- package/src/account/subscriptions-view.js +158 -0
- package/src/account-api.js +118 -0
- package/src/account-links.js +35 -0
- package/src/account-pages.js +69 -298
- package/src/account.js +280 -323
- package/src/amounts.js +36 -0
- package/src/analytics-shopify.js +241 -0
- package/src/analytics.js +304 -184
- package/src/api-version.js +75 -25
- package/src/astro.js +233 -68
- package/src/cart/checkout.js +97 -0
- package/src/cart/drawer.js +138 -0
- package/src/cart/mutations.js +234 -0
- package/src/cart/render.js +294 -0
- package/src/cart/store.js +326 -0
- package/src/cart.js +320 -488
- package/src/client.js +214 -78
- package/src/collection.js +126 -57
- package/src/config.js +323 -59
- package/src/controls/cart-controls.js +155 -0
- package/src/controls/discount.js +96 -0
- package/src/controls/extras.js +63 -0
- package/src/controls/navigation.js +83 -0
- package/src/diagnose-markup.js +333 -0
- package/src/diagnose.js +251 -202
- package/src/dom.js +219 -220
- package/src/extras.js +119 -37
- package/src/fragments.js +115 -22
- package/src/images.js +104 -0
- package/src/index.js +245 -287
- package/src/initial.js +78 -66
- package/src/lazy-iife.js +92 -22
- package/src/lazy.js +25 -4
- package/src/loader.js +263 -124
- package/src/market-codes.js +35 -0
- package/src/markets-ui.js +169 -55
- package/src/markets.js +286 -85
- package/src/markup.js +64 -0
- package/src/mounting.js +214 -91
- package/src/page-market.js +116 -0
- package/src/parts/account-pages.js +4 -1
- package/src/parts/account.js +4 -1
- package/src/parts/analytics.js +4 -1
- package/src/parts/diagnose.js +4 -1
- package/src/parts/extras.js +4 -1
- package/src/parts/markets.js +4 -1
- package/src/parts/search.js +4 -1
- package/src/presets/barba.js +120 -30
- package/src/product-events.js +130 -0
- package/src/product-render.js +257 -0
- package/src/product-state.js +318 -0
- package/src/product.js +233 -459
- package/src/repeaters.js +173 -0
- package/src/search.js +277 -107
- package/src/selling-plans.js +107 -47
- package/src/server.js +168 -205
- package/src/shopify-values.js +89 -0
- package/src/status.js +99 -39
- package/src/store-html.js +0 -0
- package/src/storefront/catalog.js +199 -0
- package/src/storefront/json-ld.js +167 -0
- package/src/variant-render.js +165 -0
- package/types/astro.d.ts +13 -2
- package/types/index.d.ts +178 -27
- package/types/server.d.ts +89 -17
- package/dist/esm/chunks/account-Q6E2TFEV.js +0 -1
- package/dist/esm/chunks/account-pages-UZXR4R5F.js +0 -1
- package/dist/esm/chunks/analytics-IJBKATUP.js +0 -1
- package/dist/esm/chunks/chunk-4QJKLPYR.js +0 -1
- package/dist/esm/chunks/chunk-64TUD3QR.js +0 -1
- package/dist/esm/chunks/chunk-AJLPU2JC.js +0 -4
- package/dist/esm/chunks/chunk-FIOZPPC5.js +0 -1
- package/dist/esm/chunks/chunk-K2YEQAIH.js +0 -1
- package/dist/esm/chunks/chunk-KIDH76B7.js +0 -1
- package/dist/esm/chunks/chunk-PAYT5ZD7.js +0 -1
- package/dist/esm/chunks/chunk-RREHGH4A.js +0 -1
- package/dist/esm/chunks/chunk-SSJT43CJ.js +0 -1
- package/dist/esm/chunks/chunk-VC46IEJQ.js +0 -1
- package/dist/esm/chunks/diagnose-3WVAWNTX.js +0 -14
- package/dist/esm/chunks/extras-7I7WJX35.js +0 -1
- package/dist/esm/chunks/markets-ui-545XLFTE.js +0 -1
- package/dist/esm/chunks/search-NILMGYXG.js +0 -1
package/src/cart.js
CHANGED
|
@@ -1,559 +1,391 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
4
|
-
*
|
|
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,
|
|
8
|
-
import {
|
|
9
|
-
import {
|
|
10
|
-
import {
|
|
11
|
-
|
|
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
|
-
|
|
14
|
-
|
|
15
|
-
|
|
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
|
-
/**
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
}
|
|
34
|
-
|
|
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
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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
|
-
|
|
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
|
-
|
|
59
|
-
|
|
135
|
+
init,
|
|
136
|
+
/** @returns {string | null} The saved cart's id. */
|
|
137
|
+
id: savedCartId,
|
|
60
138
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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
|
|
70
|
-
|
|
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
|
-
|
|
92
|
-
|
|
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
|
-
|
|
107
|
-
|
|
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
|
-
|
|
117
|
-
|
|
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
|
-
|
|
143
|
-
const
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
return
|
|
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
|
-
/**
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
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(
|
|
173
|
-
*
|
|
174
|
-
*
|
|
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,
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
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)
|
|
228
|
+
if (cfg.openCartOnAdd) openCart();
|
|
183
229
|
return cart;
|
|
184
230
|
});
|
|
185
231
|
},
|
|
186
232
|
|
|
187
233
|
/**
|
|
188
|
-
* updateLine(lineId, 3)
|
|
189
|
-
*
|
|
190
|
-
*
|
|
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
|
|
194
|
-
if (
|
|
195
|
-
|
|
196
|
-
|
|
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
|
-
*
|
|
210
|
-
*
|
|
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
|
-
|
|
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
|
|
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 =
|
|
251
|
-
if (!ids.length) return Promise.resolve(
|
|
252
|
-
return
|
|
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
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
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
|
+
);
|
|
260
289
|
},
|
|
261
290
|
|
|
262
|
-
/**
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
'
|
|
273
|
-
|
|
291
|
+
/**
|
|
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>}
|
|
296
|
+
*/
|
|
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');
|
|
274
308
|
});
|
|
275
309
|
},
|
|
276
310
|
|
|
277
|
-
/**
|
|
311
|
+
/**
|
|
312
|
+
* Replaces the discount codes; `[]` removes them all.
|
|
313
|
+
*
|
|
314
|
+
* @param {string[] | string | ((current: string[]) => string[])} codes
|
|
315
|
+
* @returns {Promise<ShopifyCart | null>}
|
|
316
|
+
*/
|
|
278
317
|
setDiscountCodes(codes) {
|
|
279
|
-
return
|
|
280
|
-
|
|
281
|
-
const
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
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');
|
|
287
328
|
});
|
|
288
329
|
},
|
|
289
330
|
|
|
290
331
|
/**
|
|
291
|
-
* Adds a code. A code Shopify doesn't accept
|
|
292
|
-
*
|
|
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.
|
|
293
337
|
*/
|
|
294
338
|
async applyDiscount(code) {
|
|
295
|
-
const cart = await
|
|
296
|
-
const
|
|
297
|
-
|
|
298
|
-
|
|
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;
|
|
343
|
+
try {
|
|
344
|
+
await Cart.removeDiscount(code);
|
|
345
|
+
} catch (error) {
|
|
346
|
+
reportError(error, 'removing a discount code Shopify refused');
|
|
347
|
+
}
|
|
299
348
|
throw new TetherError(`Discount code not valid: ${code}`, { code: 'INVALID_DISCOUNT' });
|
|
300
349
|
},
|
|
301
350
|
|
|
302
|
-
removeDiscount(code) {
|
|
303
|
-
const k = String(code).toLowerCase();
|
|
304
|
-
return this.setDiscountCodes((current) => current.filter((c) => c.toLowerCase() !== k));
|
|
305
|
-
},
|
|
306
|
-
|
|
307
|
-
/** { countryCode, email, phone } — changes currency/prices for that market. */
|
|
308
|
-
setBuyerIdentity(buyerIdentity, { create = true } = {}) {
|
|
309
|
-
return this._queue(async () => {
|
|
310
|
-
const cart = await this._mutate('cartBuyerIdentityUpdate', '$buyerIdentity: CartBuyerIdentityInput!', 'buyerIdentity: $buyerIdentity', { buyerIdentity }, create ? { buyerIdentity } : null)
|
|
311
|
-
.catch((err) => {
|
|
312
|
-
if (create || !isMissingCart(err)) throw err;
|
|
313
|
-
this._setId(null); // expired cart: forget it instead of creating an empty one
|
|
314
|
-
return null;
|
|
315
|
-
});
|
|
316
|
-
return this._commit(cart, 'buyer-identity');
|
|
317
|
-
});
|
|
318
|
-
},
|
|
319
|
-
|
|
320
351
|
/**
|
|
321
|
-
*
|
|
322
|
-
*
|
|
323
|
-
* URL once we have it.
|
|
352
|
+
* @param {string} code
|
|
353
|
+
* @returns {Promise<ShopifyCart | null>}
|
|
324
354
|
*/
|
|
325
|
-
|
|
326
|
-
const
|
|
327
|
-
|
|
328
|
-
const tab = target === '_blank' ? window.open('', '_blank') : null;
|
|
329
|
-
if (tab) { try { tab.opener = null; } catch { /* ignore */ } }
|
|
330
|
-
|
|
331
|
-
try {
|
|
332
|
-
// Side by side: the cart's current checkout URL, and the account's own (beforeCheckout) if any.
|
|
333
|
-
const fresh = gql('query TetherCheckout($id: ID!) { cart(id: $id) { checkoutUrl } }', { id }).then((d) => d.cart?.checkoutUrl);
|
|
334
|
-
fresh.catch(() => {}); // not needed when beforeCheckout answers
|
|
335
|
-
let url = (this.beforeCheckout && await this.beforeCheckout().catch(() => null)) || await fresh;
|
|
336
|
-
if (url && this.customerAccessToken) url += `${url.includes('?') ? '&' : '?'}logged_in=true`;
|
|
337
|
-
if (!url) throw new TetherError('No checkoutUrl (cart empty or expired)');
|
|
338
|
-
emit('checkout', { url, cart: this.state });
|
|
339
|
-
if (tab && !tab.closed) tab.location.href = url;
|
|
340
|
-
else window.location.href = url;
|
|
341
|
-
return url;
|
|
342
|
-
} catch (err) {
|
|
343
|
-
if (tab) tab.close();
|
|
344
|
-
reportError(err, 'checkout failed');
|
|
345
|
-
return null;
|
|
346
|
-
}
|
|
347
|
-
},
|
|
348
|
-
|
|
349
|
-
// -------------------------------------------------------------- drawer
|
|
350
|
-
|
|
351
|
-
drawers() { return qsa(document, sel('cart-drawer')); },
|
|
352
|
-
|
|
353
|
-
/** Without a tether-cart-drawer (a React/Svelte/Vue cart), open/close still work: as state for subscribe. */
|
|
354
|
-
_open: false,
|
|
355
|
-
isOpen() {
|
|
356
|
-
const ds = this.drawers();
|
|
357
|
-
return ds.length ? ds.some((d) => d.classList.contains(cfg.cartDrawerOpenClass)) : this._open;
|
|
358
|
-
},
|
|
359
|
-
|
|
360
|
-
open() {
|
|
361
|
-
if (this.isOpen()) return;
|
|
362
|
-
this._open = true;
|
|
363
|
-
const ds = this.drawers().filter((d) => get(d, 'cart-drawer') !== 'manual');
|
|
364
|
-
ds.forEach((d) => { d.inert = false; d.classList.add(cfg.cartDrawerOpenClass); d.setAttribute('aria-hidden', 'false'); });
|
|
365
|
-
ariaState(true);
|
|
366
|
-
if (cfg.cartFocus !== false && ds[0]) {
|
|
367
|
-
// Move focus into the cart (after the open transition has made it visible).
|
|
368
|
-
returnFocus = document.activeElement;
|
|
369
|
-
setTimeout(() => {
|
|
370
|
-
const target = focusables(ds[0])[0] || ds[0];
|
|
371
|
-
if (target === ds[0] && !ds[0].hasAttribute('tabindex')) ds[0].tabIndex = -1;
|
|
372
|
-
target.focus?.({ preventScroll: true });
|
|
373
|
-
}, 60);
|
|
374
|
-
}
|
|
375
|
-
if (typeof cfg.onCartOpen === 'function') cfg.onCartOpen(ds);
|
|
376
|
-
emit('cart-toggled', { open: true });
|
|
377
|
-
},
|
|
378
|
-
|
|
379
|
-
close() {
|
|
380
|
-
if (!this.isOpen()) return;
|
|
381
|
-
this._open = false;
|
|
382
|
-
const ds = this.drawers();
|
|
383
|
-
const hadFocus = ds.some((d) => d.contains(document.activeElement));
|
|
384
|
-
ds.forEach((d) => { d.classList.remove(cfg.cartDrawerOpenClass); d.setAttribute('aria-hidden', 'true'); if (cfg.cartFocus !== false) d.inert = true; });
|
|
385
|
-
ariaState(false);
|
|
386
|
-
if (hadFocus && returnFocus?.isConnected) returnFocus.focus({ preventScroll: true });
|
|
387
|
-
returnFocus = null;
|
|
388
|
-
if (typeof cfg.onCartClose === 'function') cfg.onCartClose(ds);
|
|
389
|
-
emit('cart-toggled', { open: false });
|
|
355
|
+
removeDiscount(code) {
|
|
356
|
+
const unwanted = code.toLowerCase();
|
|
357
|
+
return Cart.setDiscountCodes((current) => current.filter((existing) => existing.toLowerCase() !== unwanted));
|
|
390
358
|
},
|
|
391
359
|
|
|
392
|
-
toggle() { if (this.isOpen()) this.close(); else this.open(); },
|
|
393
|
-
|
|
394
360
|
/**
|
|
395
|
-
*
|
|
396
|
-
*
|
|
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>}
|
|
397
366
|
*/
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
const count = cart ? cart.totalQuantity : 0;
|
|
411
|
-
const money = (k) => (cart ? formatMoney(cart.cost?.[k]) : '');
|
|
412
|
-
|
|
413
|
-
ariaState(this.isOpen());
|
|
414
|
-
qsa(document, sel('cart-count')).forEach((el) => setTextTracked(el, count));
|
|
415
|
-
qsa(document, sel('cart-subtotal')).forEach((el) => setTextTracked(el, money('subtotalAmount')));
|
|
416
|
-
qsa(document, sel('cart-total')).forEach((el) => setTextTracked(el, money('totalAmount')));
|
|
417
|
-
qsa(document, sel('cart-checkout-charge')).forEach((el) => setTextTracked(el, money('checkoutChargeAmount')));
|
|
418
|
-
|
|
419
|
-
const hasSub = this.lines.some((l) => l.sellingPlanAllocation);
|
|
420
|
-
const conds = {
|
|
421
|
-
'cart-empty': count === 0,
|
|
422
|
-
'cart-filled': count > 0,
|
|
423
|
-
'cart-has-subscription': hasSub,
|
|
424
|
-
'cart-no-subscription': !hasSub,
|
|
425
|
-
'cart-has-discount': (cart?.discountCodes || []).some((d) => d.applicable),
|
|
426
|
-
'cart-error': Boolean(this.lastError),
|
|
427
|
-
'cart-warning': this.warnings.length > 0,
|
|
428
|
-
};
|
|
429
|
-
qsa(document, sel('cart-warning')).forEach((el) => setTextTracked(el, [...new Set(this.warnings.map((w) => w.message))].join(' ')));
|
|
430
|
-
qsa(document, sel('show')).forEach((el) => {
|
|
431
|
-
const k = get(el, 'show');
|
|
432
|
-
if (k in conds && !el.closest(sel('product'))) show(el, conds[k]);
|
|
433
|
-
});
|
|
434
|
-
qsa(document, sel('checkout')).forEach((el) => {
|
|
435
|
-
el.classList.toggle('is-disabled', !count);
|
|
436
|
-
el.setAttribute('aria-disabled', String(!count));
|
|
437
|
-
});
|
|
438
|
-
|
|
439
|
-
qsa(document, `textarea${sel('cart-note')},input${sel('cart-note')}`).forEach((el) => {
|
|
440
|
-
if (document.activeElement !== el) el.value = cart?.note || '';
|
|
441
|
-
});
|
|
442
|
-
|
|
443
|
-
qsa(document, sel('cart-discounts')).forEach((container) => {
|
|
444
|
-
repeat(container, cart?.discountCodes || [], (node, d) => {
|
|
445
|
-
node.setAttribute(attr('discount-code'), d.code);
|
|
446
|
-
const t = node.querySelector(sel('discount', 'code')) || (node.firstElementChild ? null : node);
|
|
447
|
-
if (t) setText(t, d.code);
|
|
448
|
-
node.classList.toggle('is-inapplicable', !d.applicable);
|
|
449
|
-
});
|
|
450
|
-
});
|
|
451
|
-
|
|
452
|
-
qsa(document, sel('cart-lines')).forEach((container) => {
|
|
453
|
-
repeatKeyed(container, this.lines, (l) => l.id, (node, line) => renderLine(node, line), {
|
|
454
|
-
onEnter: cfg.onLineEnter, onLeave: cfg.onLineLeave,
|
|
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;
|
|
455
379
|
});
|
|
380
|
+
return commit(cart, 'buyer-identity');
|
|
456
381
|
});
|
|
457
|
-
|
|
458
|
-
if (cart) notifyLayoutChanged('cart');
|
|
459
382
|
},
|
|
460
|
-
};
|
|
461
|
-
|
|
462
|
-
function lineValue(line, key) {
|
|
463
|
-
const m = line.merchandise || {};
|
|
464
|
-
const plan = line.sellingPlanAllocation?.sellingPlan;
|
|
465
|
-
switch (key) {
|
|
466
|
-
case 'title': return m.product?.title;
|
|
467
|
-
case 'product-title': return m.product?.title;
|
|
468
|
-
case 'variant-title': return m.title === 'Default Title' ? '' : m.title;
|
|
469
|
-
case 'options': return (m.selectedOptions || [])
|
|
470
|
-
.filter((o) => o.value !== 'Default Title').map((o) => `${o.name}: ${o.value}`).join(', ');
|
|
471
|
-
case 'vendor': return m.product?.vendor;
|
|
472
|
-
case 'sku': return m.sku;
|
|
473
|
-
case 'price': return formatMoney(line.cost?.amountPerQuantity || m.price);
|
|
474
|
-
case 'compare-at-price': return formatMoney(line.cost?.compareAtAmountPerQuantity || m.compareAtPrice);
|
|
475
|
-
case 'line-total': return formatMoney(line.cost?.totalAmount);
|
|
476
|
-
case 'line-subtotal': return formatMoney(line.cost?.subtotalAmount);
|
|
477
|
-
case 'quantity': return Cart.quantityOf(line);
|
|
478
|
-
case 'plan': return plan?.name || '';
|
|
479
|
-
case 'plan-description': return plan?.description || '';
|
|
480
|
-
case 'components': return (line.lineComponents || [])
|
|
481
|
-
.map((c) => `${c.quantity} × ${c.merchandise?.product?.title || ''}`).join(', ');
|
|
482
|
-
default:
|
|
483
|
-
if (key.startsWith('attribute:')) {
|
|
484
|
-
const k = key.slice('attribute:'.length);
|
|
485
|
-
return (line.attributes || []).find((a) => a.key === k)?.value || '';
|
|
486
|
-
}
|
|
487
|
-
return undefined;
|
|
488
|
-
}
|
|
489
|
-
}
|
|
490
|
-
|
|
491
|
-
function renderLine(node, line) {
|
|
492
|
-
const m = line.merchandise || {};
|
|
493
|
-
const isSub = Boolean(line.sellingPlanAllocation);
|
|
494
|
-
node.setAttribute(attr('line-id'), line.id);
|
|
495
|
-
node.classList.toggle('is-subscription', isSub);
|
|
496
|
-
|
|
497
|
-
qsa(node, sel('line')).forEach((el) => {
|
|
498
|
-
const key = get(el, 'line');
|
|
499
|
-
if (key === 'image') return setImage(el, m.image?.url || m.product?.featuredImage?.url, m.product?.title);
|
|
500
|
-
if (key === 'url') {
|
|
501
|
-
const u = productUrl(m.product?.handle, m.product?.id);
|
|
502
|
-
const q = new URLSearchParams();
|
|
503
|
-
if (m.id) q.set('variant', String(m.id).split('/').pop());
|
|
504
|
-
const plan = line.sellingPlanAllocation?.sellingPlan?.id;
|
|
505
|
-
if (plan) q.set('selling_plan', plan.split('/').pop());
|
|
506
|
-
const qs = q.toString();
|
|
507
|
-
return el.setAttribute('href', qs ? `${u}${u.includes('?') ? '&' : '?'}${qs}` : u);
|
|
508
|
-
}
|
|
509
|
-
const v = lineValue(line, key);
|
|
510
|
-
if (v !== undefined) setTextTracked(el, v);
|
|
511
|
-
return undefined;
|
|
512
|
-
});
|
|
513
383
|
|
|
514
|
-
|
|
515
|
-
const warning = Cart.warnings.find((w) => w.target === line.id);
|
|
516
|
-
node.classList.toggle('is-warning', Boolean(warning));
|
|
517
|
-
const pending = Cart.pendingQty.has(line.id);
|
|
518
|
-
node.classList.toggle('is-busy', pending);
|
|
519
|
-
if (pending) node.setAttribute('aria-busy', 'true'); else node.removeAttribute('aria-busy');
|
|
520
|
-
qsa(node, sel('line', 'warning')).forEach((el) => setTextTracked(el, warning?.message || ''));
|
|
521
|
-
const lineConds = {
|
|
522
|
-
subscription: isSub,
|
|
523
|
-
'one-time': !isSub,
|
|
524
|
-
'on-sale': parseFloat(line.cost?.compareAtAmountPerQuantity?.amount) > parseFloat(line.cost?.amountPerQuantity?.amount),
|
|
525
|
-
'has-components': (line.lineComponents || []).length > 0,
|
|
526
|
-
warning: Boolean(warning),
|
|
527
|
-
};
|
|
528
|
-
qsa(node, sel('line-show')).forEach((el) => {
|
|
529
|
-
const k = get(el, 'line-show');
|
|
530
|
-
if (k in lineConds) show(el, lineConds[k]);
|
|
531
|
-
});
|
|
532
|
-
|
|
533
|
-
// Change frequency in the cart: <select tether-line-plan> lists the product's plans.
|
|
534
|
-
qsa(node, `select${sel('line-plan')}`).forEach((select) => {
|
|
535
|
-
const handle = m.product?.handle;
|
|
536
|
-
if (!handle) return;
|
|
537
|
-
getProduct(handle).then((product) => {
|
|
538
|
-
const plans = allPlans(product);
|
|
539
|
-
const allowOneTime = !product.requiresSellingPlan && get(select, 'line-plan') !== 'subscription-only';
|
|
540
|
-
const want = [
|
|
541
|
-
...(allowOneTime ? [{ value: '', label: select.getAttribute(attr('one-time-label')) || 'One-time purchase' }] : []),
|
|
542
|
-
...plans.map((p) => ({ value: p.id, label: p.name })),
|
|
543
|
-
];
|
|
544
|
-
const sig = want.map((w) => w.value).join('|');
|
|
545
|
-
if (select.dataset.tetherSig !== sig) {
|
|
546
|
-
select.innerHTML = '';
|
|
547
|
-
want.forEach((w) => {
|
|
548
|
-
const o = document.createElement('option');
|
|
549
|
-
o.value = w.value; o.textContent = w.label;
|
|
550
|
-
select.appendChild(o);
|
|
551
|
-
});
|
|
552
|
-
select.dataset.tetherSig = sig;
|
|
553
|
-
}
|
|
554
|
-
select.value = line.sellingPlanAllocation?.sellingPlan?.id || '';
|
|
555
|
-
show(select, plans.length > 0);
|
|
556
|
-
}).catch((err) => reportError(err, 'line plan options'));
|
|
557
|
-
});
|
|
558
|
-
}
|
|
384
|
+
checkout,
|
|
559
385
|
|
|
386
|
+
drawers,
|
|
387
|
+
isOpen,
|
|
388
|
+
open: openCart,
|
|
389
|
+
close: closeCart,
|
|
390
|
+
toggle: toggleCart,
|
|
391
|
+
};
|