@occluze/tether 0.7.0 → 0.7.1

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 (132) hide show
  1. package/README.md +21 -21
  2. package/dist/esm/chunks/{account-UIPWJQ3Y.js → account-CXMA5RPZ.js} +1 -1
  3. package/dist/esm/chunks/{account-pages-6ROA6Y4V.js → account-pages-IZO7FEOZ.js} +1 -1
  4. package/dist/esm/chunks/{analytics-2V632IRV.js → analytics-PG5FPRDN.js} +1 -1
  5. package/dist/esm/chunks/{chunk-4GCXM764.js → chunk-4HD4J5JC.js} +1 -1
  6. package/dist/esm/chunks/{chunk-BFTZR4OB.js → chunk-6TFMLT4A.js} +2 -2
  7. package/dist/esm/chunks/{chunk-T44MUL4Y.js → chunk-BBVUVPMO.js} +1 -1
  8. package/dist/esm/chunks/chunk-EW5SYO6L.js +1 -0
  9. package/dist/esm/chunks/chunk-HJY2JSNB.js +1 -0
  10. package/dist/esm/chunks/chunk-MQYB6BH4.js +1 -0
  11. package/dist/esm/chunks/{chunk-5Z5O3767.js → chunk-N3V45JPS.js} +1 -1
  12. package/dist/esm/chunks/{chunk-YLUULATY.js → chunk-RPMBCAGS.js} +1 -1
  13. package/dist/esm/chunks/{chunk-MPDWM2NN.js → chunk-VFNHIGCS.js} +1 -1
  14. package/dist/esm/chunks/{chunk-6OZJCAGZ.js → chunk-WT7RGGJU.js} +1 -1
  15. package/dist/esm/chunks/chunk-YN6LEBEN.js +1 -0
  16. package/dist/esm/chunks/chunk-Z6HS3H7K.js +1 -0
  17. package/dist/esm/chunks/{diagnose-3FEKRIEK.js → diagnose-XCEBQKBO.js} +1 -1
  18. package/dist/esm/chunks/extras-4HU3VSBQ.js +1 -0
  19. package/dist/esm/chunks/{markets-ui-FD6NI2OG.js → markets-ui-M4IQ62CT.js} +1 -1
  20. package/dist/esm/chunks/search-A5DS2IOS.js +1 -0
  21. package/dist/esm/index.js +1 -1
  22. package/dist/esm/server.js +2 -2
  23. package/dist/tether.account-pages.js +13 -3
  24. package/dist/tether.account-pages.min.js +1 -1
  25. package/dist/tether.account.js +13 -3
  26. package/dist/tether.account.min.js +1 -1
  27. package/dist/tether.analytics.js +18 -3
  28. package/dist/tether.analytics.min.js +1 -1
  29. package/dist/tether.diagnose.js +18 -3
  30. package/dist/tether.diagnose.min.js +2 -2
  31. package/dist/tether.extras.js +19 -6
  32. package/dist/tether.extras.min.js +1 -1
  33. package/dist/tether.js +162 -112
  34. package/dist/tether.markets.js +18 -3
  35. package/dist/tether.markets.min.js +1 -1
  36. package/dist/tether.min.js +3 -3
  37. package/dist/tether.min.js.map +4 -4
  38. package/dist/tether.search.js +18 -3
  39. package/dist/tether.search.min.js +1 -1
  40. package/dist/types/server/src/shared/cart-id.d.ts +2 -0
  41. package/dist/types/src/cart/mutations.d.ts +6 -0
  42. package/dist/types/src/cart/store.d.ts +5 -2
  43. package/dist/types/src/cart.d.ts +2 -2
  44. package/dist/types/src/client.d.ts +53 -19
  45. package/dist/types/src/config.d.ts +6 -1
  46. package/dist/types/src/index.d.ts +2 -2
  47. package/dist/types/src/product-shape.d.ts +3 -1
  48. package/package.json +1 -2
  49. package/dist/esm/chunks/chunk-56FCLJJD.js +0 -1
  50. package/dist/esm/chunks/chunk-A5SYCVQM.js +0 -1
  51. package/dist/esm/chunks/chunk-BA4LQJJQ.js +0 -1
  52. package/dist/esm/chunks/chunk-C5JYQCWH.js +0 -1
  53. package/dist/esm/chunks/extras-6POFBNOP.js +0 -1
  54. package/dist/esm/chunks/search-UJ73YBQ5.js +0 -1
  55. package/src/account/addresses-view.ts +0 -58
  56. package/src/account/answers.ts +0 -79
  57. package/src/account/markup.ts +0 -36
  58. package/src/account/operations.ts +0 -145
  59. package/src/account/order-lines.ts +0 -75
  60. package/src/account/order-list.ts +0 -107
  61. package/src/account/order-page.ts +0 -137
  62. package/src/account/page-answers.ts +0 -317
  63. package/src/account/page-events.ts +0 -253
  64. package/src/account/server-client.ts +0 -183
  65. package/src/account/subscriptions-view.ts +0 -115
  66. package/src/account-api.ts +0 -98
  67. package/src/account-marks.ts +0 -35
  68. package/src/account-pages.ts +0 -81
  69. package/src/account.ts +0 -284
  70. package/src/amounts.ts +0 -64
  71. package/src/analytics-shopify.ts +0 -246
  72. package/src/analytics.ts +0 -315
  73. package/src/api-version.ts +0 -79
  74. package/src/astro.ts +0 -272
  75. package/src/cart/cart-shape.ts +0 -25
  76. package/src/cart/checkout.ts +0 -101
  77. package/src/cart/drawer.ts +0 -125
  78. package/src/cart/mutations.ts +0 -237
  79. package/src/cart/render.ts +0 -266
  80. package/src/cart/store.ts +0 -279
  81. package/src/cart.ts +0 -345
  82. package/src/client.ts +0 -391
  83. package/src/collection.ts +0 -175
  84. package/src/config-sections.ts +0 -284
  85. package/src/config.ts +0 -480
  86. package/src/controls/cart-controls.ts +0 -123
  87. package/src/controls/discount.ts +0 -75
  88. package/src/controls/extras.ts +0 -54
  89. package/src/controls/navigation.ts +0 -77
  90. package/src/diagnose-markup.ts +0 -298
  91. package/src/diagnose.ts +0 -294
  92. package/src/dom.ts +0 -268
  93. package/src/extras.ts +0 -136
  94. package/src/fragments.ts +0 -261
  95. package/src/globals.d.ts +0 -15
  96. package/src/images.ts +0 -91
  97. package/src/index.ts +0 -415
  98. package/src/initial.ts +0 -101
  99. package/src/lazy-iife.ts +0 -197
  100. package/src/lazy.ts +0 -42
  101. package/src/loader.ts +0 -363
  102. package/src/market-codes.ts +0 -6
  103. package/src/markets-ui.ts +0 -227
  104. package/src/markets.ts +0 -246
  105. package/src/markup.ts +0 -127
  106. package/src/mounting.ts +0 -264
  107. package/src/page-market.ts +0 -110
  108. package/src/part-integrity.ts +0 -7
  109. package/src/parts/account-pages.ts +0 -7
  110. package/src/parts/account.ts +0 -7
  111. package/src/parts/analytics.ts +0 -7
  112. package/src/parts/diagnose.ts +0 -7
  113. package/src/parts/extras.ts +0 -7
  114. package/src/parts/markets.ts +0 -7
  115. package/src/parts/search.ts +0 -7
  116. package/src/presets/barba.ts +0 -194
  117. package/src/product-events.ts +0 -115
  118. package/src/product-render.ts +0 -218
  119. package/src/product-shape.ts +0 -49
  120. package/src/product-state.ts +0 -318
  121. package/src/product.ts +0 -308
  122. package/src/repeaters.ts +0 -161
  123. package/src/search.ts +0 -305
  124. package/src/selling-plans.ts +0 -145
  125. package/src/server.ts +0 -211
  126. package/src/shopify-values.ts +0 -91
  127. package/src/status.ts +0 -106
  128. package/src/storage.ts +0 -34
  129. package/src/store-html.ts +0 -359
  130. package/src/storefront/catalog.ts +0 -196
  131. package/src/storefront/json-ld.ts +0 -144
  132. 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
- }