@occluze/tether 0.7.0 → 0.7.2

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 (139) hide show
  1. package/README.md +51 -109
  2. package/dist/esm/astro.js +1 -1
  3. package/dist/esm/chunks/{account-UIPWJQ3Y.js → account-W4FBRKXA.js} +1 -1
  4. package/dist/esm/chunks/{account-pages-6ROA6Y4V.js → account-pages-IM3OQTOP.js} +1 -1
  5. package/dist/esm/chunks/analytics-QDVOLZY3.js +1 -0
  6. package/dist/esm/chunks/{chunk-T44MUL4Y.js → chunk-AUVAN3KZ.js} +1 -1
  7. package/dist/esm/chunks/chunk-BMDYNMRC.js +1 -0
  8. package/dist/esm/chunks/chunk-CUL66KRA.js +1 -0
  9. package/dist/esm/chunks/{chunk-6OZJCAGZ.js → chunk-CVNRODH3.js} +1 -1
  10. package/dist/esm/chunks/{chunk-5Z5O3767.js → chunk-CXHPBT7N.js} +1 -1
  11. package/dist/esm/chunks/chunk-GF6JTI65.js +1 -0
  12. package/dist/esm/chunks/chunk-MRSDJPBC.js +1 -0
  13. package/dist/esm/chunks/chunk-NYDZPTLP.js +1 -0
  14. package/dist/esm/chunks/chunk-QDHLXXAZ.js +1 -0
  15. package/dist/esm/chunks/{chunk-4GCXM764.js → chunk-QUECUXHD.js} +1 -1
  16. package/dist/esm/chunks/chunk-RJY76ESL.js +1 -0
  17. package/dist/esm/chunks/chunk-RPI5C3EJ.js +1 -0
  18. package/dist/esm/chunks/{chunk-BFTZR4OB.js → chunk-YC4JXWRG.js} +2 -2
  19. package/dist/esm/chunks/{diagnose-3FEKRIEK.js → diagnose-3XHPZZNP.js} +1 -1
  20. package/dist/esm/chunks/extras-C724KKG6.js +1 -0
  21. package/dist/esm/chunks/{markets-ui-FD6NI2OG.js → markets-ui-WBWWQAGU.js} +1 -1
  22. package/dist/esm/chunks/{search-UJ73YBQ5.js → search-5NFIZBPJ.js} +1 -1
  23. package/dist/esm/index.js +1 -1
  24. package/dist/esm/server.js +2 -2
  25. package/dist/tether.account-pages.js +13 -3
  26. package/dist/tether.account-pages.min.js +1 -1
  27. package/dist/tether.account.js +13 -3
  28. package/dist/tether.account.min.js +1 -1
  29. package/dist/tether.analytics.js +18 -3
  30. package/dist/tether.analytics.min.js +1 -1
  31. package/dist/tether.diagnose.js +20 -5
  32. package/dist/tether.diagnose.min.js +2 -2
  33. package/dist/tether.extras.js +52 -28
  34. package/dist/tether.extras.min.js +1 -1
  35. package/dist/tether.js +353 -288
  36. package/dist/tether.markets.js +31 -5
  37. package/dist/tether.markets.min.js +1 -1
  38. package/dist/tether.min.js +2 -2
  39. package/dist/tether.min.js.map +4 -4
  40. package/dist/tether.search.js +18 -3
  41. package/dist/tether.search.min.js +1 -1
  42. package/dist/types/server/src/shared/cart-id.d.ts +2 -0
  43. package/dist/types/src/cart/mutations.d.ts +6 -0
  44. package/dist/types/src/cart/store.d.ts +5 -2
  45. package/dist/types/src/cart.d.ts +3 -2
  46. package/dist/types/src/client.d.ts +61 -22
  47. package/dist/types/src/config.d.ts +6 -1
  48. package/dist/types/src/index.d.ts +2 -2
  49. package/dist/types/src/markets.d.ts +9 -2
  50. package/dist/types/src/product-shape.d.ts +3 -1
  51. package/dist/types/src/uri.d.ts +6 -0
  52. package/package.json +54 -6
  53. package/dist/esm/chunks/analytics-2V632IRV.js +0 -1
  54. package/dist/esm/chunks/chunk-56FCLJJD.js +0 -1
  55. package/dist/esm/chunks/chunk-A5SYCVQM.js +0 -1
  56. package/dist/esm/chunks/chunk-BA4LQJJQ.js +0 -1
  57. package/dist/esm/chunks/chunk-C5JYQCWH.js +0 -1
  58. package/dist/esm/chunks/chunk-E5YO6274.js +0 -1
  59. package/dist/esm/chunks/chunk-MPDWM2NN.js +0 -1
  60. package/dist/esm/chunks/chunk-YLUULATY.js +0 -1
  61. package/dist/esm/chunks/extras-6POFBNOP.js +0 -1
  62. package/src/account/addresses-view.ts +0 -58
  63. package/src/account/answers.ts +0 -79
  64. package/src/account/markup.ts +0 -36
  65. package/src/account/operations.ts +0 -145
  66. package/src/account/order-lines.ts +0 -75
  67. package/src/account/order-list.ts +0 -107
  68. package/src/account/order-page.ts +0 -137
  69. package/src/account/page-answers.ts +0 -317
  70. package/src/account/page-events.ts +0 -253
  71. package/src/account/server-client.ts +0 -183
  72. package/src/account/subscriptions-view.ts +0 -115
  73. package/src/account-api.ts +0 -98
  74. package/src/account-marks.ts +0 -35
  75. package/src/account-pages.ts +0 -81
  76. package/src/account.ts +0 -284
  77. package/src/amounts.ts +0 -64
  78. package/src/analytics-shopify.ts +0 -246
  79. package/src/analytics.ts +0 -315
  80. package/src/api-version.ts +0 -79
  81. package/src/astro.ts +0 -272
  82. package/src/cart/cart-shape.ts +0 -25
  83. package/src/cart/checkout.ts +0 -101
  84. package/src/cart/drawer.ts +0 -125
  85. package/src/cart/mutations.ts +0 -237
  86. package/src/cart/render.ts +0 -266
  87. package/src/cart/store.ts +0 -279
  88. package/src/cart.ts +0 -345
  89. package/src/client.ts +0 -391
  90. package/src/collection.ts +0 -175
  91. package/src/config-sections.ts +0 -284
  92. package/src/config.ts +0 -480
  93. package/src/controls/cart-controls.ts +0 -123
  94. package/src/controls/discount.ts +0 -75
  95. package/src/controls/extras.ts +0 -54
  96. package/src/controls/navigation.ts +0 -77
  97. package/src/diagnose-markup.ts +0 -298
  98. package/src/diagnose.ts +0 -294
  99. package/src/dom.ts +0 -268
  100. package/src/extras.ts +0 -136
  101. package/src/fragments.ts +0 -261
  102. package/src/globals.d.ts +0 -15
  103. package/src/images.ts +0 -91
  104. package/src/index.ts +0 -415
  105. package/src/initial.ts +0 -101
  106. package/src/lazy-iife.ts +0 -197
  107. package/src/lazy.ts +0 -42
  108. package/src/loader.ts +0 -363
  109. package/src/market-codes.ts +0 -6
  110. package/src/markets-ui.ts +0 -227
  111. package/src/markets.ts +0 -246
  112. package/src/markup.ts +0 -127
  113. package/src/mounting.ts +0 -264
  114. package/src/page-market.ts +0 -110
  115. package/src/part-integrity.ts +0 -7
  116. package/src/parts/account-pages.ts +0 -7
  117. package/src/parts/account.ts +0 -7
  118. package/src/parts/analytics.ts +0 -7
  119. package/src/parts/diagnose.ts +0 -7
  120. package/src/parts/extras.ts +0 -7
  121. package/src/parts/markets.ts +0 -7
  122. package/src/parts/search.ts +0 -7
  123. package/src/presets/barba.ts +0 -194
  124. package/src/product-events.ts +0 -115
  125. package/src/product-render.ts +0 -218
  126. package/src/product-shape.ts +0 -49
  127. package/src/product-state.ts +0 -318
  128. package/src/product.ts +0 -308
  129. package/src/repeaters.ts +0 -161
  130. package/src/search.ts +0 -305
  131. package/src/selling-plans.ts +0 -145
  132. package/src/server.ts +0 -211
  133. package/src/shopify-values.ts +0 -91
  134. package/src/status.ts +0 -106
  135. package/src/storage.ts +0 -34
  136. package/src/store-html.ts +0 -359
  137. package/src/storefront/catalog.ts +0 -196
  138. package/src/storefront/json-ld.ts +0 -144
  139. package/src/variant-render.ts +0 -139
@@ -1,266 +0,0 @@
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.ts';
7
- import { getProduct } from '../loader.ts';
8
- import { allPlans } from '../selling-plans.ts';
9
- import { isGreaterAmount } from '../amounts.ts';
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.ts';
25
- import { isOpen, syncDrawerAria } from './drawer.ts';
26
- import type { CartWarning } from '../config.ts';
27
- import type { Product } from '../fragments.ts';
28
- import type { ShopifyCart, ShopifyCartLine, PendingQuantity } from './store.ts';
29
-
30
- /** What a render shows. */
31
- interface CartView {
32
- cart: ShopifyCart | null;
33
- warnings: readonly CartWarning[];
34
- /** Line id → the quantity quick clicks asked for. */
35
- pending: ReadonlyMap<string, PendingQuantity>;
36
- /** The last load or change failed (`tether-show="cart-error"`). */
37
- failed: boolean;
38
- }
39
-
40
- /** Shopify's title of a variant of a product without options. */
41
- const DEFAULT_TITLE = 'Default Title';
42
-
43
- /** @returns The quantity to show: the one asked for while an update is on its way. */
44
- export const quantityOf = (line: ShopifyCartLine, pending: ReadonlyMap<string, PendingQuantity>): number =>
45
- pending.get(line.id)?.quantity ?? line.quantity;
46
-
47
- const numericId = (gid: string) => gid.split('/').pop() ?? gid;
48
-
49
- /** @returns The product page, with the line's variant and plan selected. */
50
- function lineUrl(line: ShopifyCartLine): string | null {
51
- const variant = line.merchandise;
52
- const base = productUrl(variant.product.handle, variant.product.id);
53
- if (!base) return null;
54
- const query = new URLSearchParams();
55
- query.set('variant', numericId(variant.id));
56
- const planId = line.sellingPlanAllocation?.sellingPlan.id;
57
- if (planId) query.set('selling_plan', numericId(planId));
58
- return `${base}${base.includes('?') ? '&' : '?'}${query.toString()}`;
59
- }
60
-
61
- type LineText = (line: ShopifyCartLine, view: CartView) => string | number;
62
-
63
- const productTitle: LineText = (line) => line.merchandise.product.title;
64
-
65
- /** The text of each `tether-line` key. */
66
- const LINE_TEXT: Record<string, LineText> = {
67
- title: productTitle,
68
- 'product-title': productTitle,
69
- 'variant-title': (line) => (line.merchandise.title === DEFAULT_TITLE ? '' : line.merchandise.title),
70
- options: (line) =>
71
- line.merchandise.selectedOptions
72
- .filter((option) => option.value !== DEFAULT_TITLE)
73
- .map((option) => `${option.name}: ${option.value}`)
74
- .join(', '),
75
- vendor: (line) => line.merchandise.product.vendor,
76
- sku: (line) => line.merchandise.sku ?? '',
77
- price: (line) => formatMoney(line.cost.amountPerQuantity),
78
- 'compare-at-price': (line) => formatMoney(line.cost.compareAtAmountPerQuantity ?? line.merchandise.compareAtPrice),
79
- 'line-total': (line) => formatMoney(line.cost.totalAmount),
80
- 'line-subtotal': (line) => formatMoney(line.cost.subtotalAmount),
81
- quantity: (line, view) => quantityOf(line, view.pending),
82
- plan: (line) => line.sellingPlanAllocation?.sellingPlan.name ?? '',
83
- 'plan-description': (line) => line.sellingPlanAllocation?.sellingPlan.description ?? '',
84
- components: (line) =>
85
- (line.lineComponents ?? [])
86
- .map((component) => `${component.quantity} × ${component.merchandise.product.title}`)
87
- .join(', '),
88
- };
89
-
90
- const ATTRIBUTE_KEY = 'attribute:';
91
-
92
- /**
93
- * @param key The `tether-line` value.
94
- * @returns The text for `key`, `undefined` for a key Tether doesn't know.
95
- */
96
- function lineValue(line: ShopifyCartLine, key: string, view: CartView): string | number | undefined {
97
- if (Object.hasOwn(LINE_TEXT, key)) return LINE_TEXT[key]?.(line, view);
98
- if (!key.startsWith(ATTRIBUTE_KEY)) return undefined;
99
- const name = key.slice(ATTRIBUTE_KEY.length);
100
- return line.attributes.find((attribute) => attribute.key === name)?.value ?? '';
101
- }
102
-
103
- /**
104
- * Fills a `<select tether-line-plan>` with the product's plans (and one-time purchase, when the product allows it and
105
- * the select isn't `subscription-only`), keeping the options when they haven't changed.
106
- */
107
- function fillPlanSelect(select: HTMLSelectElement, line: ShopifyCartLine, product: Product) {
108
- const plans = allPlans(product);
109
- const allowOneTime = !product.requiresSellingPlan && get(select, 'line-plan') !== 'subscription-only';
110
- const oneTime = { value: '', label: select.getAttribute(attr('one-time-label')) || 'One-time purchase' };
111
- const choices = [...(allowOneTime ? [oneTime] : []), ...plans.map((plan) => ({ value: plan.id, label: plan.name }))];
112
- const signature = choices.map((choice) => choice.value).join('|');
113
- if (select.dataset['tetherSig'] !== signature) {
114
- select.replaceChildren(
115
- ...choices.map((choice) => {
116
- const option = document.createElement('option');
117
- option.value = choice.value;
118
- option.textContent = choice.label;
119
- return option;
120
- }),
121
- );
122
- select.dataset['tetherSig'] = signature;
123
- }
124
- select.value = line.sellingPlanAllocation?.sellingPlan.id ?? '';
125
- show(select, plans.length > 0);
126
- }
127
-
128
- function renderLineState(node: HTMLElement, line: ShopifyCartLine, view: CartView) {
129
- const isSubscription = Boolean(line.sellingPlanAllocation);
130
- node.classList.toggle('is-subscription', isSubscription);
131
- // Shopify's note about this line, such as less stock than asked.
132
- const warning = view.warnings.find((candidate) => candidate.target === line.id);
133
- node.classList.toggle('is-warning', Boolean(warning));
134
- const pending = view.pending.has(line.id);
135
- node.classList.toggle('is-busy', pending);
136
- if (pending) node.setAttribute('aria-busy', 'true');
137
- else node.removeAttribute('aria-busy');
138
- qsa(node, sel('line', 'warning')).forEach((el) => {
139
- setTextTracked(el, warning?.message ?? '');
140
- });
141
- const conditions = new Map([
142
- ['subscription', isSubscription],
143
- ['one-time', !isSubscription],
144
- ['on-sale', isGreaterAmount(line.cost.compareAtAmountPerQuantity?.amount, line.cost.amountPerQuantity.amount)],
145
- ['has-components', (line.lineComponents ?? []).length > 0],
146
- ['warning', Boolean(warning)],
147
- ]);
148
- qsa(node, sel('line-show')).forEach((el) => {
149
- const key = get(el, 'line-show');
150
- const visible = key === null ? undefined : conditions.get(key);
151
- if (visible !== undefined) show(el, visible);
152
- });
153
- }
154
-
155
- function renderLine(node: HTMLElement, line: ShopifyCartLine, view: CartView) {
156
- const variant = line.merchandise;
157
- node.setAttribute(attr('line-id'), line.id);
158
- qsa(node, sel('line')).forEach((el) => {
159
- const key = get(el, 'line') ?? '';
160
- if (key === 'image') {
161
- setImage(el, variant.image?.url ?? variant.product.featuredImage?.url, variant.product.title);
162
- return;
163
- }
164
- if (key === 'url') {
165
- const url = lineUrl(line);
166
- if (url) el.setAttribute('href', url);
167
- return;
168
- }
169
- const value = lineValue(line, key, view);
170
- if (value !== undefined) setTextTracked(el, value);
171
- });
172
- renderLineState(node, line, view);
173
- qsa(node, `select${sel('line-plan')}`).forEach((select) => {
174
- getProduct(variant.product.handle)
175
- .then((product) => {
176
- // The selector matches only <select> elements.
177
- fillPlanSelect(select as HTMLSelectElement, line, product);
178
- })
179
- .catch((error: unknown) => {
180
- reportError(error, 'line plan options');
181
- });
182
- });
183
- }
184
-
185
- function renderConditions(cart: ShopifyCart | null, view: CartView) {
186
- const count = cart?.totalQuantity ?? 0;
187
- const hasSubscription = (cart?.lines.nodes ?? []).some((line) => line.sellingPlanAllocation);
188
- const conditions = new Map([
189
- ['cart-empty', count === 0],
190
- ['cart-filled', count > 0],
191
- ['cart-has-subscription', hasSubscription],
192
- ['cart-no-subscription', !hasSubscription],
193
- ['cart-has-discount', (cart?.discountCodes ?? []).some((code) => code.applicable)],
194
- ['cart-error', view.failed],
195
- ['cart-warning', view.warnings.length > 0],
196
- ]);
197
- qsa(document, sel('show')).forEach((el) => {
198
- const key = get(el, 'show');
199
- const visible = key === null ? undefined : conditions.get(key);
200
- if (visible === undefined || el.closest(sel('product'))) return;
201
- show(el, visible);
202
- });
203
- qsa(document, sel('checkout')).forEach((el) => {
204
- el.classList.toggle('is-disabled', !count);
205
- el.setAttribute('aria-disabled', String(!count));
206
- });
207
- }
208
-
209
- function renderFields(cart: ShopifyCart | null) {
210
- qsa(document, `textarea${sel('cart-note')},input${sel('cart-note')}`).forEach((el) => {
211
- // The visitor may be typing in it.
212
- if (document.activeElement !== el) setText(el, cart?.note ?? '');
213
- });
214
- qsa(document, sel('cart-discounts')).forEach((container) => {
215
- repeat(container, cart?.discountCodes ?? [], (node, discount) => {
216
- node.setAttribute(attr('discount-code'), discount.code);
217
- const target = node.querySelector(sel('discount', 'code')) ?? (node.firstElementChild ? null : node);
218
- if (target) setText(target, discount.code);
219
- node.classList.toggle('is-inapplicable', !discount.applicable);
220
- });
221
- });
222
- }
223
-
224
- export function renderCart(view: CartView): void {
225
- const { cart } = view;
226
- const money = (key: 'subtotalAmount' | 'totalAmount' | 'checkoutChargeAmount') =>
227
- cart ? formatMoney(cart.cost[key]) : '';
228
-
229
- syncDrawerAria(isOpen());
230
- qsa(document, sel('cart-count')).forEach((el) => {
231
- setTextTracked(el, cart?.totalQuantity ?? 0);
232
- });
233
- qsa(document, sel('cart-subtotal')).forEach((el) => {
234
- setTextTracked(el, money('subtotalAmount'));
235
- });
236
- qsa(document, sel('cart-total')).forEach((el) => {
237
- setTextTracked(el, money('totalAmount'));
238
- });
239
- qsa(document, sel('cart-checkout-charge')).forEach((el) => {
240
- setTextTracked(el, money('checkoutChargeAmount'));
241
- });
242
- renderConditions(cart, view);
243
- qsa(document, sel('cart-warning')).forEach((el) => {
244
- setTextTracked(el, [...new Set(view.warnings.map((warning) => warning.message))].join(' '));
245
- });
246
- renderFields(cart);
247
- qsa(document, sel('cart-lines')).forEach((container) => {
248
- repeatKeyed(
249
- container,
250
- cart?.lines.nodes ?? [],
251
- (line) => line.id,
252
- (node, line) => {
253
- renderLine(node, line, view);
254
- },
255
- { onEnter: cfg.onLineEnter, onLeave: cfg.onLineLeave },
256
- );
257
- });
258
- if (cart) notifyLayoutChanged('cart');
259
- }
260
-
261
- /** Shows the count a saved cart had on the last page in the cart counts the page left at 0, until it has loaded. */
262
- export function showSavedCount(count: number): void {
263
- qsa(document, sel('cart-count')).forEach((el) => {
264
- if (/^\s*0?\s*$/.test(el.textContent)) el.textContent = String(count);
265
- });
266
- }
package/src/cart/store.ts DELETED
@@ -1,279 +0,0 @@
1
- /**
2
- * The cart on this page: what Shopify last said about it (`CartStatus`), the quantities quick clicks asked for and
3
- * the one queue every change goes through. A change of what the page shows goes through
4
- * here, so the document (`renderCart`) and the listeners (`tether:cart-updated`) always see the store's cart.
5
- */
6
- import { cfg } from '../config.ts';
7
- import { emit } from '../dom.ts';
8
- import { isOpen } from './drawer.ts';
9
- import { renderCart, quantityOf, showSavedCount } from './render.ts';
10
- import { getStored, removeStored, setStored } from '../storage.ts';
11
- import type { CartWarning } from '../config.ts';
12
- import type { Image } from '../fragments.ts';
13
- import type { Money } from '../shopify-values.ts';
14
-
15
- export interface Attribute {
16
- key: string;
17
- value: string;
18
- }
19
-
20
- /** A line's `merchandise`: Shopify's `Merchandise` union has only `ProductVariant`. */
21
- export interface CartVariant {
22
- id: string;
23
- title: string;
24
- sku: string | null;
25
- availableForSale: boolean;
26
- requiresShipping: boolean;
27
- /** Absent when the store's token can't read inventory. */
28
- quantityAvailable?: number | null;
29
- image: Image | null;
30
- price: Money;
31
- compareAtPrice: Money | null;
32
- selectedOptions: { name: string; value: string }[];
33
- product: { id: string; title: string; handle: string; vendor: string; featuredImage: Image | null };
34
- }
35
-
36
- interface LinePlanAllocation {
37
- sellingPlan: {
38
- id: string;
39
- name: string;
40
- description: string | null;
41
- recurringDeliveries: boolean;
42
- options: { name: string; value: string }[];
43
- };
44
- checkoutChargeAmount: Money;
45
- priceAdjustments: { price: Money; compareAtPrice: Money; perDeliveryPrice: Money }[];
46
- }
47
-
48
- export interface ShopifyCartLine {
49
- id: string;
50
- quantity: number;
51
- attributes: Attribute[];
52
- cost: {
53
- totalAmount: Money;
54
- subtotalAmount: Money;
55
- amountPerQuantity: Money;
56
- compareAtAmountPerQuantity: Money | null;
57
- };
58
- discountAllocations: { discountedAmount: Money }[];
59
- sellingPlanAllocation: LinePlanAllocation | null;
60
- merchandise: CartVariant;
61
- /** Only on a bundle (`ComponentizableCartLine`). */
62
- lineComponents?: {
63
- id: string;
64
- quantity: number;
65
- merchandise: { id: string; title: string; product: { title: string; handle: string } };
66
- }[];
67
- }
68
-
69
- /** The fields of `fragment TetherCart`. */
70
- export interface ShopifyCart {
71
- id: string;
72
- checkoutUrl: string;
73
- totalQuantity: number;
74
- note: string | null;
75
- createdAt: string;
76
- updatedAt: string;
77
- attributes: Attribute[];
78
- buyerIdentity: { countryCode: string | null; email: string | null };
79
- discountCodes: { code: string; applicable: boolean }[];
80
- cost: { subtotalAmount: Money; totalAmount: Money; checkoutChargeAmount: Money };
81
- lines: { nodes: ShopifyCartLine[] };
82
- }
83
-
84
- /**
85
- * `unknown` until the saved cart has loaded (the page's own markup shows); `empty` without a cart (none saved, or
86
- * Shopify no longer has it); `error` after a failed load or change, with the cart as Shopify last sent it.
87
- */
88
- export type CartStatus =
89
- | { kind: 'unknown' }
90
- | { kind: 'empty' }
91
- | { kind: 'ready'; cart: ShopifyCart }
92
- | { kind: 'error'; error: unknown; cart: ShopifyCart | null };
93
-
94
- /** What one quick +/- click asked for; a newer click replaces it. */
95
- export interface PendingQuantity {
96
- quantity: number;
97
- }
98
-
99
- /** The second argument of a `subscribe` listener. */
100
- interface CartMeta {
101
- open: boolean;
102
- count: number;
103
- loaded: boolean;
104
- /** A copy of Shopify's notes on the last change. */
105
- warnings: CartWarning[];
106
- }
107
-
108
- let status: CartStatus = { kind: 'unknown' };
109
-
110
- /** Shopify's notes on the last change. */
111
- let warnings: readonly CartWarning[] = [];
112
-
113
- /** Line id → what quick +/- clicks asked for, until Shopify has answered. */
114
- export const pendingQuantities = new Map<string, PendingQuantity>();
115
-
116
- export const cartStatus = (): CartStatus => status;
117
-
118
- /** The cart as Shopify last sent it. */
119
- export function currentCart(): ShopifyCart | null {
120
- switch (status.kind) {
121
- case 'ready':
122
- case 'error':
123
- return status.cart;
124
- case 'unknown':
125
- case 'empty':
126
- return null;
127
- }
128
- }
129
-
130
- export const currentWarnings = (): readonly CartWarning[] => warnings;
131
-
132
- const statusOf = (cart: ShopifyCart | null): CartStatus => (cart ? { kind: 'ready', cart } : { kind: 'empty' });
133
-
134
- const seenKey = () => `${cfg.storageKey}:seen`;
135
-
136
- /** The id of the visitor's cart, kept across pages. */
137
- export const savedCartId = (): string | null => getStored(cfg.storageKey) || null;
138
-
139
- /** Forgets the saved cart, which Shopify no longer has. */
140
- export function forgetSavedCart(): void {
141
- removeStored(cfg.storageKey);
142
- }
143
-
144
- /** Keeps a cart Shopify sent without showing it, for an answer a newer change will replace. */
145
- export function keepQuietly(cart: ShopifyCart | null): void {
146
- status = statusOf(cart);
147
- if (cart) setStored(cfg.storageKey, cart.id);
148
- }
149
-
150
- /** Renders the document with the store's cart. */
151
- export function render(): void {
152
- renderCart({
153
- cart: currentCart(),
154
- warnings,
155
- pending: pendingQuantities,
156
- failed: status.kind === 'error',
157
- });
158
- }
159
-
160
- /** @param reason The `reason` of `tether:cart-updated`. */
161
- export function announce(reason: string): void {
162
- render();
163
- emit('cart-updated', { cart: currentCart(), reason });
164
- }
165
-
166
- /**
167
- * Keeps a cart Shopify sent after a change, shows it, and tells this page and the visitor's other tabs.
168
- *
169
- * @returns `cart`.
170
- */
171
- export function commit(cart: ShopifyCart | null, reason: string): ShopifyCart | null {
172
- keepQuietly(cart);
173
- // Other tabs reload the cart on this storage event; the next page shows the count before its cart has loaded.
174
- setStored(seenKey(), `${Date.now()}|${cart?.totalQuantity ?? 0}`);
175
- announce(reason);
176
- return cart;
177
- }
178
-
179
- /** The outcome of loading the saved cart: the cart, `null` when there is none, or the failure. */
180
- export function settleLoad(
181
- outcome: { kind: 'loaded'; cart: ShopifyCart | null } | { kind: 'failed'; error: unknown },
182
- ): void {
183
- status = outcome.kind === 'loaded' ? statusOf(outcome.cart) : { kind: 'error', error: outcome.error, cart: null };
184
- announce('load');
185
- }
186
-
187
- /**
188
- * The cart's count on the last page, from `<storageKey>:seen` (`<time>|<count>`).
189
- *
190
- * @returns `null` unless the count is a whole number: other scripts and other Tether versions write storage too.
191
- */
192
- function lastKnownCount(): number | null {
193
- const text = getStored(seenKey())?.split('|')[1] ?? '';
194
- const count = Number(text);
195
- return /^\d+$/.test(text) && Number.isSafeInteger(count) ? count : null;
196
- }
197
-
198
- /** Until the saved cart has loaded, the counts show the count it had on the last page instead of the page's 0. */
199
- export function showLastKnownCount(): void {
200
- if (status.kind !== 'unknown') return;
201
- const count = lastKnownCount();
202
- if (count !== null) showSavedCount(count);
203
- }
204
-
205
- /** Sets Shopify's notes on the last change: each message once, in the site's words (`warningText`). */
206
- export function setWarnings(notes: readonly CartWarning[]): void {
207
- warnings = notes
208
- .filter((note, index) => notes.findIndex((other) => other.message === note.message) === index)
209
- .map((note) => ({ ...note, message: cfg.warningText?.(note) || note.message }));
210
- if (warnings.length) emit('cart-warning', { warnings });
211
- }
212
-
213
- /** The queue only waits for a task: its result, or failure, reaches the caller through the promise `run` returns. */
214
- const waitOnly = () => undefined;
215
-
216
- /** @returns A runner that starts each task once the one before has settled, whether it succeeded or failed. */
217
- export function serial(): <T>(task: () => Promise<T>) => Promise<T> {
218
- let previous: Promise<unknown> = Promise.resolve();
219
- return <T>(task: () => Promise<T>) => {
220
- const run = previous.then(task);
221
- previous = run.then(waitOnly, waitOnly);
222
- return run;
223
- };
224
- }
225
-
226
- const runInOrder = serial();
227
-
228
- /**
229
- * Runs a cart change after the ones before it, so quick clicks never race. A failure sets the `error` status (shown by
230
- * `tether-show="cart-error"` until the next change succeeds), emits `tether:cart-error` and rejects.
231
- */
232
- export function enqueue<T>(task: () => Promise<T>): Promise<T> {
233
- return runInOrder(task).then(
234
- (value) => {
235
- if (status.kind === 'error') {
236
- status = statusOf(status.cart);
237
- render();
238
- }
239
- return value;
240
- },
241
- (error: unknown) => {
242
- status = { kind: 'error', error, cart: currentCart() };
243
- render();
244
- emit('cart-error', { error });
245
- throw error;
246
- },
247
- );
248
- }
249
-
250
- /** @returns The quantity to show for `line`. */
251
- export const pendingQuantityOf = (line: ShopifyCartLine): number => quantityOf(line, pendingQuantities);
252
-
253
- const meta = (): CartMeta => ({
254
- open: isOpen(),
255
- count: currentCart()?.totalQuantity ?? 0,
256
- loaded: status.kind !== 'unknown',
257
- warnings: [...warnings],
258
- });
259
-
260
- /**
261
- * Calls `listener` now and after every change of the cart or the drawer.
262
- *
263
- * @returns Stops listening.
264
- */
265
- export function subscribe(listener: (cart: ShopifyCart | null, meta: CartMeta) => void): () => void {
266
- const events = ['cart-updated', 'cart-toggled'].map((name) => `${cfg.prefix}:${name}`);
267
- const call = () => {
268
- listener(currentCart(), meta());
269
- };
270
- events.forEach((name) => {
271
- document.addEventListener(name, call);
272
- });
273
- call();
274
- return () => {
275
- events.forEach((name) => {
276
- document.removeEventListener(name, call);
277
- });
278
- };
279
- }