@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.
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 +773 -442
  26. package/dist/tether.account-pages.min.js +1 -1
  27. package/dist/tether.account.js +648 -318
  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 +331 -242
  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 +3176 -2015
  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 +69 -298
  58. package/src/account.js +280 -323
  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 +320 -488
  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 -287
  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-Q6E2TFEV.js +0 -1
  120. package/dist/esm/chunks/account-pages-UZXR4R5F.js +0 -1
  121. package/dist/esm/chunks/analytics-IJBKATUP.js +0 -1
  122. package/dist/esm/chunks/chunk-4QJKLPYR.js +0 -1
  123. package/dist/esm/chunks/chunk-64TUD3QR.js +0 -1
  124. package/dist/esm/chunks/chunk-AJLPU2JC.js +0 -4
  125. package/dist/esm/chunks/chunk-FIOZPPC5.js +0 -1
  126. package/dist/esm/chunks/chunk-K2YEQAIH.js +0 -1
  127. package/dist/esm/chunks/chunk-KIDH76B7.js +0 -1
  128. package/dist/esm/chunks/chunk-PAYT5ZD7.js +0 -1
  129. package/dist/esm/chunks/chunk-RREHGH4A.js +0 -1
  130. package/dist/esm/chunks/chunk-SSJT43CJ.js +0 -1
  131. package/dist/esm/chunks/chunk-VC46IEJQ.js +0 -1
  132. package/dist/esm/chunks/diagnose-3WVAWNTX.js +0 -14
  133. package/dist/esm/chunks/extras-7I7WJX35.js +0 -1
  134. package/dist/esm/chunks/markets-ui-545XLFTE.js +0 -1
  135. 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
+ }