@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
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Storefront Cart API: loading the saved cart, creating one, and the mutations that change it. Shopify forgets a
|
|
3
|
+
* cart some days after its last change and once it has been checked out; a change that needs a cart then creates a
|
|
4
|
+
* new one with the same input, so the visitor's click still lands.
|
|
5
|
+
*/
|
|
6
|
+
import { cfg, log } from '../config.js';
|
|
7
|
+
import { gql, unwrap, TetherError } from '../client.js';
|
|
8
|
+
import { cartFragment } from '../fragments.js';
|
|
9
|
+
import { GID } from '../selling-plans.js';
|
|
10
|
+
import { toAttributes } from '../dom.js';
|
|
11
|
+
import { customer, savedCartId, forgetSavedCart, setWarnings } from './store.js';
|
|
12
|
+
/** @import { CartWarning } from '../config.js' */
|
|
13
|
+
/** @import { UserError } from '../client.js' */
|
|
14
|
+
/** @import { AttributeValue } from '../dom.js' */
|
|
15
|
+
/** @import { ShopifyCart, Attribute } from './store.js' */
|
|
16
|
+
|
|
17
|
+
/** @typedef {Record<string, AttributeValue> | { key: string | number, value?: AttributeValue }[]} Attributes */
|
|
18
|
+
/**
|
|
19
|
+
* @typedef {object} LineSpec A line to add, in any of the shapes `Cart.add` takes.
|
|
20
|
+
* @property {string | number} [variantId]
|
|
21
|
+
* @property {string | number} [merchandiseId]
|
|
22
|
+
* @property {string | number} [id]
|
|
23
|
+
* @property {number | string} [quantity]
|
|
24
|
+
* @property {string | number | null} [sellingPlanId]
|
|
25
|
+
* @property {string | number | null} [sellingPlan]
|
|
26
|
+
* @property {Attributes | null} [attributes]
|
|
27
|
+
*/
|
|
28
|
+
/**
|
|
29
|
+
* @typedef {object} CartLineInput Shopify's `CartLineInput`.
|
|
30
|
+
* @property {string} merchandiseId
|
|
31
|
+
* @property {number} quantity
|
|
32
|
+
* @property {string} [sellingPlanId]
|
|
33
|
+
* @property {Attribute[]} [attributes]
|
|
34
|
+
*/
|
|
35
|
+
/**
|
|
36
|
+
* @typedef {object} CartPayload What every cart mutation answers.
|
|
37
|
+
* @property {ShopifyCart | null} cart
|
|
38
|
+
* @property {UserError[]} userErrors
|
|
39
|
+
* @property {CartWarning[]} [warnings]
|
|
40
|
+
*/
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Shopify answers a cart id it can't resolve, in a mutation, with a top-level GraphQL error whose only detail is
|
|
44
|
+
* this message (no `extensions.code`). A user error on the `cartId` field is recognised by its shape instead.
|
|
45
|
+
*/
|
|
46
|
+
const UNKNOWN_CART_MESSAGE = /does not exist|not found|invalid id/i;
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* @param {unknown} error
|
|
50
|
+
* @returns {boolean} True when `error` says Shopify doesn't have the cart, false for any other failure (the network,
|
|
51
|
+
* the token, a line it refused), after which the saved cart is still good.
|
|
52
|
+
*/
|
|
53
|
+
export function isCartGone(error) {
|
|
54
|
+
if (!(error instanceof TetherError)) return false;
|
|
55
|
+
if (error.userErrors?.some((userError) => userError.field?.[0] === 'cartId')) return true;
|
|
56
|
+
return (error.errors ?? []).some((graphQLError) => UNKNOWN_CART_MESSAGE.test(graphQLError.message));
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* @param {string | number | LineSpec | null | undefined} input A variant id, or a line.
|
|
61
|
+
* @param {number} [quantity] For a variant id.
|
|
62
|
+
* @param {Omit<LineSpec, 'variantId' | 'quantity'>} [options] For a variant id.
|
|
63
|
+
* @returns {CartLineInput}
|
|
64
|
+
* @throws {TetherError} `ERROR` without a variant id.
|
|
65
|
+
*/
|
|
66
|
+
export function toLineInput(input, quantity = 1, options = {}) {
|
|
67
|
+
const spec = typeof input === 'object' && input !== null ? input : { ...options, variantId: input, quantity };
|
|
68
|
+
const merchandiseId = GID.variant(spec.merchandiseId || spec.variantId || spec.id);
|
|
69
|
+
if (!merchandiseId) throw new TetherError('add(): variantId is required');
|
|
70
|
+
const asked = parseInt(String(spec.quantity ?? 1), 10);
|
|
71
|
+
/** @type {CartLineInput} */
|
|
72
|
+
const line = { merchandiseId, quantity: Math.max(1, asked || 1) };
|
|
73
|
+
const planId = GID.sellingPlan(spec.sellingPlanId || spec.sellingPlan);
|
|
74
|
+
if (planId) line.sellingPlanId = planId;
|
|
75
|
+
const attributes = toAttributes(spec.attributes);
|
|
76
|
+
if (attributes.length) line.attributes = attributes;
|
|
77
|
+
return line;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* @param {string} name The mutation, which is also its payload's field.
|
|
82
|
+
* @param {string} args The operation's variable definitions.
|
|
83
|
+
* @param {string} inner The mutation field's arguments.
|
|
84
|
+
*/
|
|
85
|
+
function mutationText(name, args, inner) {
|
|
86
|
+
return `mutation Tether_${name}(${args}) {
|
|
87
|
+
${name}(${inner}) { cart { ...TetherCart } userErrors { field message } warnings { code message target } }
|
|
88
|
+
}
|
|
89
|
+
${cartFragment()}`;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* @param {string} name
|
|
94
|
+
* @param {string} args
|
|
95
|
+
* @param {string} inner
|
|
96
|
+
* @param {Record<string, unknown>} variables
|
|
97
|
+
* @returns {Promise<ShopifyCart | null>} The cart in Shopify's answer, after keeping its warnings.
|
|
98
|
+
* @throws {TetherError} For a failed request, or the mutation's user errors.
|
|
99
|
+
*/
|
|
100
|
+
async function send(name, args, inner, variables) {
|
|
101
|
+
/** @type {Record<string, CartPayload | null | undefined>} */
|
|
102
|
+
const data = await gql(mutationText(name, args, inner), variables);
|
|
103
|
+
const payload = unwrap(data[name], name);
|
|
104
|
+
setWarnings(payload.warnings ?? []);
|
|
105
|
+
return payload.cart;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* A new cart in the visitor's market, for the logged-in customer if there is one.
|
|
110
|
+
*
|
|
111
|
+
* @param {Record<string, unknown>} input More of Shopify's `CartInput`, e.g. the first lines.
|
|
112
|
+
* @returns {Promise<ShopifyCart | null>}
|
|
113
|
+
*/
|
|
114
|
+
function createCart(input) {
|
|
115
|
+
const buyerIdentity = {
|
|
116
|
+
...(cfg.country ? { countryCode: cfg.country.toUpperCase() } : {}),
|
|
117
|
+
...(customer.accessToken ? { customerAccessToken: customer.accessToken } : {}),
|
|
118
|
+
};
|
|
119
|
+
const withIdentity = Object.keys(buyerIdentity).length ? { buyerIdentity, ...input } : input;
|
|
120
|
+
return send('cartCreate', '$input: CartInput!', 'input: $input', { input: withIdentity });
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* @typedef {object} CartMutation A mutation on the saved cart.
|
|
125
|
+
* @property {string} name The mutation, e.g. `cartLinesAdd`.
|
|
126
|
+
* @property {string} args Its variable definitions besides `$cartId`.
|
|
127
|
+
* @property {string} inner Its arguments besides `cartId`.
|
|
128
|
+
* @property {Record<string, unknown>} variables
|
|
129
|
+
* @property {Record<string, unknown> | null} createInput The `CartInput` of a new cart that does what the mutation
|
|
130
|
+
* asks, when there is no cart (or Shopify no longer has it); `null` leaves the visitor without a cart instead.
|
|
131
|
+
*/
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Runs a mutation on the saved cart, or creates a cart from `createInput` when there is none to change.
|
|
135
|
+
*
|
|
136
|
+
* @param {CartMutation} mutation
|
|
137
|
+
* @returns {Promise<ShopifyCart | null>} The changed cart; `null` without a cart and without a `createInput`.
|
|
138
|
+
* @throws {TetherError} For a failed request or Shopify's user errors; also when the cart is gone and there is no
|
|
139
|
+
* `createInput` (the saved cart is forgotten first).
|
|
140
|
+
*/
|
|
141
|
+
export async function mutateCart({ name, args, inner, variables, createInput }) {
|
|
142
|
+
const cartId = savedCartId();
|
|
143
|
+
if (!cartId) return createInput ? createCart(createInput) : null;
|
|
144
|
+
try {
|
|
145
|
+
return await send(name, `$cartId: ID!, ${args}`, `cartId: $cartId, ${inner}`, { ...variables, cartId });
|
|
146
|
+
} catch (error) {
|
|
147
|
+
if (!isCartGone(error)) throw error;
|
|
148
|
+
forgetSavedCart();
|
|
149
|
+
if (!createInput) throw error;
|
|
150
|
+
log('cart expired, creating a new one');
|
|
151
|
+
return createCart(createInput);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* @param {string} id
|
|
157
|
+
* @returns {Promise<ShopifyCart | null>} The cart, `null` when Shopify doesn't have it (expired or checked out).
|
|
158
|
+
*/
|
|
159
|
+
export async function fetchCart(id) {
|
|
160
|
+
/** @type {{ cart: ShopifyCart | null }} */
|
|
161
|
+
const data = await gql(`query TetherCart($id: ID!) { cart(id: $id) { ...TetherCart } }\n${cartFragment()}`, { id });
|
|
162
|
+
return data.cart;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* @param {string} id
|
|
167
|
+
* @returns {Promise<string | null>} The cart's current checkout URL, `null` when Shopify doesn't have the cart.
|
|
168
|
+
*/
|
|
169
|
+
export async function fetchCheckoutUrl(id) {
|
|
170
|
+
/** @type {{ cart: { checkoutUrl: string } | null }} */
|
|
171
|
+
const data = await gql('query TetherCheckout($id: ID!) { cart(id: $id) { checkoutUrl } }', { id });
|
|
172
|
+
return data.cart?.checkoutUrl ?? null;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* @typedef {object} LinePatch A change to one line, for `updateLine`.
|
|
177
|
+
* @property {number} [quantity] 0 or less removes the line.
|
|
178
|
+
* @property {string | number | null} [sellingPlanId] `null` switches the line back to one-time purchase.
|
|
179
|
+
* @property {Attributes} [attributes]
|
|
180
|
+
* @property {string | number} [merchandiseId]
|
|
181
|
+
* @property {string | number} [variantId]
|
|
182
|
+
*/
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* @param {string} lineId
|
|
186
|
+
* @param {number | LinePatch} patch
|
|
187
|
+
* @returns {Record<string, unknown>} Shopify's `CartLineUpdateInput`.
|
|
188
|
+
*/
|
|
189
|
+
export function toLineUpdate(lineId, patch) {
|
|
190
|
+
const change = typeof patch === 'number' ? { quantity: patch } : patch;
|
|
191
|
+
/** @type {Record<string, unknown>} */
|
|
192
|
+
const line = { id: lineId };
|
|
193
|
+
if (change.quantity !== undefined) line['quantity'] = change.quantity;
|
|
194
|
+
if ('sellingPlanId' in change)
|
|
195
|
+
line['sellingPlanId'] = change.sellingPlanId ? GID.sellingPlan(change.sellingPlanId) : null;
|
|
196
|
+
if (change.attributes) line['attributes'] = toAttributes(change.attributes);
|
|
197
|
+
const variantId = change.merchandiseId || change.variantId;
|
|
198
|
+
if (variantId) line['merchandiseId'] = GID.variant(variantId);
|
|
199
|
+
return line;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* @param {Record<string, unknown>[]} lines
|
|
204
|
+
* @returns {Promise<ShopifyCart | null>}
|
|
205
|
+
*/
|
|
206
|
+
export const updateLines = (lines) =>
|
|
207
|
+
mutateCart({
|
|
208
|
+
name: 'cartLinesUpdate',
|
|
209
|
+
args: '$lines: [CartLineUpdateInput!]!',
|
|
210
|
+
inner: 'lines: $lines',
|
|
211
|
+
variables: { lines },
|
|
212
|
+
createInput: null,
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* @param {string[]} lineIds
|
|
217
|
+
* @returns {Promise<ShopifyCart | null>}
|
|
218
|
+
*/
|
|
219
|
+
export const removeLines = (lineIds) =>
|
|
220
|
+
mutateCart({
|
|
221
|
+
name: 'cartLinesRemove',
|
|
222
|
+
args: '$lineIds: [ID!]!',
|
|
223
|
+
inner: 'lineIds: $lineIds',
|
|
224
|
+
variables: { lineIds },
|
|
225
|
+
createInput: null,
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* @param {string} lineId
|
|
230
|
+
* @param {number} target
|
|
231
|
+
* @returns {Promise<ShopifyCart | null>} The cart once `lineId` has `target` (0 removes it).
|
|
232
|
+
*/
|
|
233
|
+
export const setLineQuantity = (lineId, target) =>
|
|
234
|
+
target > 0 ? updateLines([{ id: lineId, quantity: target }]) : removeLines([lineId]);
|
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shows the cart everywhere on the page: counts, totals, `tether-show="cart-*"` conditions, the note, the discount
|
|
3
|
+
* codes and the `[tether-cart-lines]` repeaters. It renders what it is given (a `CartView`) and holds no state, so
|
|
4
|
+
* every render shows the cart as the store has it at that moment.
|
|
5
|
+
*/
|
|
6
|
+
import { cfg, reportError } from '../config.js';
|
|
7
|
+
import { getProduct } from '../loader.js';
|
|
8
|
+
import { allPlans } from '../selling-plans.js';
|
|
9
|
+
import { isGreaterAmount } from '../amounts.js';
|
|
10
|
+
import {
|
|
11
|
+
attr,
|
|
12
|
+
sel,
|
|
13
|
+
get,
|
|
14
|
+
qsa,
|
|
15
|
+
formatMoney,
|
|
16
|
+
setText,
|
|
17
|
+
setTextTracked,
|
|
18
|
+
setImage,
|
|
19
|
+
show,
|
|
20
|
+
repeat,
|
|
21
|
+
repeatKeyed,
|
|
22
|
+
productUrl,
|
|
23
|
+
notifyLayoutChanged,
|
|
24
|
+
} from '../dom.js';
|
|
25
|
+
import { isOpen, syncDrawerAria } from './drawer.js';
|
|
26
|
+
/** @import { CartWarning } from '../config.js' */
|
|
27
|
+
/** @import { Product } from '../fragments.js' */
|
|
28
|
+
/** @import { ShopifyCart, ShopifyCartLine, PendingQuantity } from './store.js' */
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* @typedef {object} CartView What a render shows.
|
|
32
|
+
* @property {ShopifyCart | null} cart
|
|
33
|
+
* @property {readonly CartWarning[]} warnings
|
|
34
|
+
* @property {ReadonlyMap<string, PendingQuantity>} pending Line id → the quantity quick clicks asked for.
|
|
35
|
+
* @property {boolean} failed The last load or change failed (`tether-show="cart-error"`).
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
/** Shopify's title of a variant of a product without options. */
|
|
39
|
+
const DEFAULT_TITLE = 'Default Title';
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* @param {ShopifyCartLine} line
|
|
43
|
+
* @param {ReadonlyMap<string, PendingQuantity>} pending
|
|
44
|
+
* @returns {number} The quantity to show: the one asked for while an update is on its way.
|
|
45
|
+
*/
|
|
46
|
+
export const quantityOf = (line, pending) => pending.get(line.id)?.quantity ?? line.quantity;
|
|
47
|
+
|
|
48
|
+
/** @param {string} gid */
|
|
49
|
+
const numericId = (gid) => gid.split('/').pop() ?? gid;
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* @param {ShopifyCartLine} line
|
|
53
|
+
* @returns {string} The product page, with the line's variant and plan selected.
|
|
54
|
+
*/
|
|
55
|
+
function lineUrl(line) {
|
|
56
|
+
const variant = line.merchandise;
|
|
57
|
+
const base = productUrl(variant.product.handle, variant.product.id);
|
|
58
|
+
const query = new URLSearchParams();
|
|
59
|
+
query.set('variant', numericId(variant.id));
|
|
60
|
+
const planId = line.sellingPlanAllocation?.sellingPlan.id;
|
|
61
|
+
if (planId) query.set('selling_plan', numericId(planId));
|
|
62
|
+
return `${base}${base.includes('?') ? '&' : '?'}${query.toString()}`;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** @typedef {(line: ShopifyCartLine, view: CartView) => string | number} LineText */
|
|
66
|
+
|
|
67
|
+
/** @type {LineText} */
|
|
68
|
+
const productTitle = (line) => line.merchandise.product.title;
|
|
69
|
+
|
|
70
|
+
/** @type {Record<string, LineText>} The text of each `tether-line` key. */
|
|
71
|
+
const LINE_TEXT = {
|
|
72
|
+
title: productTitle,
|
|
73
|
+
'product-title': productTitle,
|
|
74
|
+
'variant-title': (line) => (line.merchandise.title === DEFAULT_TITLE ? '' : line.merchandise.title),
|
|
75
|
+
options: (line) =>
|
|
76
|
+
line.merchandise.selectedOptions
|
|
77
|
+
.filter((option) => option.value !== DEFAULT_TITLE)
|
|
78
|
+
.map((option) => `${option.name}: ${option.value}`)
|
|
79
|
+
.join(', '),
|
|
80
|
+
vendor: (line) => line.merchandise.product.vendor,
|
|
81
|
+
sku: (line) => line.merchandise.sku ?? '',
|
|
82
|
+
price: (line) => formatMoney(line.cost.amountPerQuantity),
|
|
83
|
+
'compare-at-price': (line) => formatMoney(line.cost.compareAtAmountPerQuantity ?? line.merchandise.compareAtPrice),
|
|
84
|
+
'line-total': (line) => formatMoney(line.cost.totalAmount),
|
|
85
|
+
'line-subtotal': (line) => formatMoney(line.cost.subtotalAmount),
|
|
86
|
+
quantity: (line, view) => quantityOf(line, view.pending),
|
|
87
|
+
plan: (line) => line.sellingPlanAllocation?.sellingPlan.name ?? '',
|
|
88
|
+
'plan-description': (line) => line.sellingPlanAllocation?.sellingPlan.description ?? '',
|
|
89
|
+
components: (line) =>
|
|
90
|
+
(line.lineComponents ?? [])
|
|
91
|
+
.map((component) => `${component.quantity} × ${component.merchandise.product.title}`)
|
|
92
|
+
.join(', '),
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
const ATTRIBUTE_KEY = 'attribute:';
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* @param {ShopifyCartLine} line
|
|
99
|
+
* @param {string} key The `tether-line` value.
|
|
100
|
+
* @param {CartView} view
|
|
101
|
+
* @returns {string | number | undefined} The text for `key`, `undefined` for a key Tether doesn't know.
|
|
102
|
+
*/
|
|
103
|
+
function lineValue(line, key, view) {
|
|
104
|
+
if (Object.hasOwn(LINE_TEXT, key)) return LINE_TEXT[key]?.(line, view);
|
|
105
|
+
if (!key.startsWith(ATTRIBUTE_KEY)) return undefined;
|
|
106
|
+
const name = key.slice(ATTRIBUTE_KEY.length);
|
|
107
|
+
return line.attributes.find((attribute) => attribute.key === name)?.value ?? '';
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Fills a `<select tether-line-plan>` with the product's plans (and one-time purchase, when the product allows it and
|
|
112
|
+
* the select isn't `subscription-only`), keeping the options when they haven't changed.
|
|
113
|
+
*
|
|
114
|
+
* @param {HTMLSelectElement} select
|
|
115
|
+
* @param {ShopifyCartLine} line
|
|
116
|
+
* @param {Product} product
|
|
117
|
+
*/
|
|
118
|
+
function fillPlanSelect(select, line, product) {
|
|
119
|
+
const plans = allPlans(product);
|
|
120
|
+
const allowOneTime = !product.requiresSellingPlan && get(select, 'line-plan') !== 'subscription-only';
|
|
121
|
+
const oneTime = { value: '', label: select.getAttribute(attr('one-time-label')) || 'One-time purchase' };
|
|
122
|
+
const choices = [...(allowOneTime ? [oneTime] : []), ...plans.map((plan) => ({ value: plan.id, label: plan.name }))];
|
|
123
|
+
const signature = choices.map((choice) => choice.value).join('|');
|
|
124
|
+
if (select.dataset['tetherSig'] !== signature) {
|
|
125
|
+
select.replaceChildren(
|
|
126
|
+
...choices.map((choice) => {
|
|
127
|
+
const option = document.createElement('option');
|
|
128
|
+
option.value = choice.value;
|
|
129
|
+
option.textContent = choice.label;
|
|
130
|
+
return option;
|
|
131
|
+
}),
|
|
132
|
+
);
|
|
133
|
+
select.dataset['tetherSig'] = signature;
|
|
134
|
+
}
|
|
135
|
+
select.value = line.sellingPlanAllocation?.sellingPlan.id ?? '';
|
|
136
|
+
show(select, plans.length > 0);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* @param {HTMLElement} node
|
|
141
|
+
* @param {ShopifyCartLine} line
|
|
142
|
+
* @param {CartView} view
|
|
143
|
+
*/
|
|
144
|
+
function renderLineState(node, line, view) {
|
|
145
|
+
const isSubscription = Boolean(line.sellingPlanAllocation);
|
|
146
|
+
node.classList.toggle('is-subscription', isSubscription);
|
|
147
|
+
// Shopify's note about this line, such as less stock than asked.
|
|
148
|
+
const warning = view.warnings.find((candidate) => candidate.target === line.id);
|
|
149
|
+
node.classList.toggle('is-warning', Boolean(warning));
|
|
150
|
+
const pending = view.pending.has(line.id);
|
|
151
|
+
node.classList.toggle('is-busy', pending);
|
|
152
|
+
if (pending) node.setAttribute('aria-busy', 'true');
|
|
153
|
+
else node.removeAttribute('aria-busy');
|
|
154
|
+
qsa(node, sel('line', 'warning')).forEach((el) => {
|
|
155
|
+
setTextTracked(el, warning?.message ?? '');
|
|
156
|
+
});
|
|
157
|
+
const conditions = {
|
|
158
|
+
subscription: isSubscription,
|
|
159
|
+
'one-time': !isSubscription,
|
|
160
|
+
'on-sale': isGreaterAmount(line.cost.compareAtAmountPerQuantity?.amount, line.cost.amountPerQuantity.amount),
|
|
161
|
+
'has-components': (line.lineComponents ?? []).length > 0,
|
|
162
|
+
warning: Boolean(warning),
|
|
163
|
+
};
|
|
164
|
+
qsa(node, sel('line-show')).forEach((el) => {
|
|
165
|
+
const key = get(el, 'line-show');
|
|
166
|
+
if (key !== null && Object.hasOwn(conditions, key)) show(el, conditions[/** @type {keyof conditions} */ (key)]);
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* @param {HTMLElement} node
|
|
172
|
+
* @param {ShopifyCartLine} line
|
|
173
|
+
* @param {CartView} view
|
|
174
|
+
*/
|
|
175
|
+
function renderLine(node, line, view) {
|
|
176
|
+
const variant = line.merchandise;
|
|
177
|
+
node.setAttribute(attr('line-id'), line.id);
|
|
178
|
+
qsa(node, sel('line')).forEach((el) => {
|
|
179
|
+
const key = get(el, 'line') ?? '';
|
|
180
|
+
if (key === 'image') {
|
|
181
|
+
setImage(el, variant.image?.url ?? variant.product.featuredImage?.url, variant.product.title);
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
if (key === 'url') {
|
|
185
|
+
el.setAttribute('href', lineUrl(line));
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
const value = lineValue(line, key, view);
|
|
189
|
+
if (value !== undefined) setTextTracked(el, value);
|
|
190
|
+
});
|
|
191
|
+
renderLineState(node, line, view);
|
|
192
|
+
qsa(node, `select${sel('line-plan')}`).forEach((select) => {
|
|
193
|
+
getProduct(variant.product.handle)
|
|
194
|
+
.then((product) => {
|
|
195
|
+
// The selector matches only <select> elements.
|
|
196
|
+
fillPlanSelect(/** @type {HTMLSelectElement} */ (select), line, product);
|
|
197
|
+
})
|
|
198
|
+
.catch((/** @type {unknown} */ error) => {
|
|
199
|
+
reportError(error, 'line plan options');
|
|
200
|
+
});
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* @param {ShopifyCart | null} cart
|
|
206
|
+
* @param {CartView} view
|
|
207
|
+
*/
|
|
208
|
+
function renderConditions(cart, view) {
|
|
209
|
+
const count = cart?.totalQuantity ?? 0;
|
|
210
|
+
const hasSubscription = (cart?.lines.nodes ?? []).some((line) => line.sellingPlanAllocation);
|
|
211
|
+
const conditions = {
|
|
212
|
+
'cart-empty': count === 0,
|
|
213
|
+
'cart-filled': count > 0,
|
|
214
|
+
'cart-has-subscription': hasSubscription,
|
|
215
|
+
'cart-no-subscription': !hasSubscription,
|
|
216
|
+
'cart-has-discount': (cart?.discountCodes ?? []).some((code) => code.applicable),
|
|
217
|
+
'cart-error': view.failed,
|
|
218
|
+
'cart-warning': view.warnings.length > 0,
|
|
219
|
+
};
|
|
220
|
+
qsa(document, sel('show')).forEach((el) => {
|
|
221
|
+
const key = get(el, 'show');
|
|
222
|
+
if (key === null || !Object.hasOwn(conditions, key) || el.closest(sel('product'))) return;
|
|
223
|
+
show(el, conditions[/** @type {keyof conditions} */ (key)]);
|
|
224
|
+
});
|
|
225
|
+
qsa(document, sel('checkout')).forEach((el) => {
|
|
226
|
+
el.classList.toggle('is-disabled', !count);
|
|
227
|
+
el.setAttribute('aria-disabled', String(!count));
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/** @param {ShopifyCart | null} cart */
|
|
232
|
+
function renderFields(cart) {
|
|
233
|
+
qsa(document, `textarea${sel('cart-note')},input${sel('cart-note')}`).forEach((el) => {
|
|
234
|
+
// The visitor may be typing in it.
|
|
235
|
+
if (document.activeElement !== el) setText(el, cart?.note ?? '');
|
|
236
|
+
});
|
|
237
|
+
qsa(document, sel('cart-discounts')).forEach((container) => {
|
|
238
|
+
repeat(container, cart?.discountCodes ?? [], (node, discount) => {
|
|
239
|
+
node.setAttribute(attr('discount-code'), discount.code);
|
|
240
|
+
const target = node.querySelector(sel('discount', 'code')) ?? (node.firstElementChild ? null : node);
|
|
241
|
+
if (target) setText(target, discount.code);
|
|
242
|
+
node.classList.toggle('is-inapplicable', !discount.applicable);
|
|
243
|
+
});
|
|
244
|
+
});
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/** @param {CartView} view */
|
|
248
|
+
export function renderCart(view) {
|
|
249
|
+
const { cart } = view;
|
|
250
|
+
/** @param {'subtotalAmount' | 'totalAmount' | 'checkoutChargeAmount'} key */
|
|
251
|
+
const money = (key) => (cart ? formatMoney(cart.cost[key]) : '');
|
|
252
|
+
|
|
253
|
+
syncDrawerAria(isOpen());
|
|
254
|
+
qsa(document, sel('cart-count')).forEach((el) => {
|
|
255
|
+
setTextTracked(el, cart?.totalQuantity ?? 0);
|
|
256
|
+
});
|
|
257
|
+
qsa(document, sel('cart-subtotal')).forEach((el) => {
|
|
258
|
+
setTextTracked(el, money('subtotalAmount'));
|
|
259
|
+
});
|
|
260
|
+
qsa(document, sel('cart-total')).forEach((el) => {
|
|
261
|
+
setTextTracked(el, money('totalAmount'));
|
|
262
|
+
});
|
|
263
|
+
qsa(document, sel('cart-checkout-charge')).forEach((el) => {
|
|
264
|
+
setTextTracked(el, money('checkoutChargeAmount'));
|
|
265
|
+
});
|
|
266
|
+
renderConditions(cart, view);
|
|
267
|
+
qsa(document, sel('cart-warning')).forEach((el) => {
|
|
268
|
+
setTextTracked(el, [...new Set(view.warnings.map((warning) => warning.message))].join(' '));
|
|
269
|
+
});
|
|
270
|
+
renderFields(cart);
|
|
271
|
+
qsa(document, sel('cart-lines')).forEach((container) => {
|
|
272
|
+
repeatKeyed(
|
|
273
|
+
container,
|
|
274
|
+
cart?.lines.nodes ?? [],
|
|
275
|
+
(line) => line.id,
|
|
276
|
+
(node, line) => {
|
|
277
|
+
renderLine(node, line, view);
|
|
278
|
+
},
|
|
279
|
+
{ onEnter: cfg.onLineEnter, onLeave: cfg.onLineLeave },
|
|
280
|
+
);
|
|
281
|
+
});
|
|
282
|
+
if (cart) notifyLayoutChanged('cart');
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* Shows the count a saved cart had on the last page in the cart counts the page left at 0, until it has loaded.
|
|
287
|
+
*
|
|
288
|
+
* @param {string} count
|
|
289
|
+
*/
|
|
290
|
+
export function showSavedCount(count) {
|
|
291
|
+
qsa(document, sel('cart-count')).forEach((el) => {
|
|
292
|
+
if (/^\s*0?\s*$/.test(el.textContent)) el.textContent = count;
|
|
293
|
+
});
|
|
294
|
+
}
|