@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.
- package/README.md +51 -109
- package/dist/esm/astro.js +1 -1
- package/dist/esm/chunks/{account-UIPWJQ3Y.js → account-W4FBRKXA.js} +1 -1
- package/dist/esm/chunks/{account-pages-6ROA6Y4V.js → account-pages-IM3OQTOP.js} +1 -1
- package/dist/esm/chunks/analytics-QDVOLZY3.js +1 -0
- package/dist/esm/chunks/{chunk-T44MUL4Y.js → chunk-AUVAN3KZ.js} +1 -1
- package/dist/esm/chunks/chunk-BMDYNMRC.js +1 -0
- package/dist/esm/chunks/chunk-CUL66KRA.js +1 -0
- package/dist/esm/chunks/{chunk-6OZJCAGZ.js → chunk-CVNRODH3.js} +1 -1
- package/dist/esm/chunks/{chunk-5Z5O3767.js → chunk-CXHPBT7N.js} +1 -1
- package/dist/esm/chunks/chunk-GF6JTI65.js +1 -0
- package/dist/esm/chunks/chunk-MRSDJPBC.js +1 -0
- package/dist/esm/chunks/chunk-NYDZPTLP.js +1 -0
- package/dist/esm/chunks/chunk-QDHLXXAZ.js +1 -0
- package/dist/esm/chunks/{chunk-4GCXM764.js → chunk-QUECUXHD.js} +1 -1
- package/dist/esm/chunks/chunk-RJY76ESL.js +1 -0
- package/dist/esm/chunks/chunk-RPI5C3EJ.js +1 -0
- package/dist/esm/chunks/{chunk-BFTZR4OB.js → chunk-YC4JXWRG.js} +2 -2
- package/dist/esm/chunks/{diagnose-3FEKRIEK.js → diagnose-3XHPZZNP.js} +1 -1
- package/dist/esm/chunks/extras-C724KKG6.js +1 -0
- package/dist/esm/chunks/{markets-ui-FD6NI2OG.js → markets-ui-WBWWQAGU.js} +1 -1
- package/dist/esm/chunks/{search-UJ73YBQ5.js → search-5NFIZBPJ.js} +1 -1
- package/dist/esm/index.js +1 -1
- package/dist/esm/server.js +2 -2
- package/dist/tether.account-pages.js +13 -3
- package/dist/tether.account-pages.min.js +1 -1
- package/dist/tether.account.js +13 -3
- package/dist/tether.account.min.js +1 -1
- package/dist/tether.analytics.js +18 -3
- package/dist/tether.analytics.min.js +1 -1
- package/dist/tether.diagnose.js +20 -5
- package/dist/tether.diagnose.min.js +2 -2
- package/dist/tether.extras.js +52 -28
- package/dist/tether.extras.min.js +1 -1
- package/dist/tether.js +353 -288
- package/dist/tether.markets.js +31 -5
- package/dist/tether.markets.min.js +1 -1
- package/dist/tether.min.js +2 -2
- package/dist/tether.min.js.map +4 -4
- package/dist/tether.search.js +18 -3
- package/dist/tether.search.min.js +1 -1
- package/dist/types/server/src/shared/cart-id.d.ts +2 -0
- package/dist/types/src/cart/mutations.d.ts +6 -0
- package/dist/types/src/cart/store.d.ts +5 -2
- package/dist/types/src/cart.d.ts +3 -2
- package/dist/types/src/client.d.ts +61 -22
- package/dist/types/src/config.d.ts +6 -1
- package/dist/types/src/index.d.ts +2 -2
- package/dist/types/src/markets.d.ts +9 -2
- package/dist/types/src/product-shape.d.ts +3 -1
- package/dist/types/src/uri.d.ts +6 -0
- package/package.json +54 -6
- package/dist/esm/chunks/analytics-2V632IRV.js +0 -1
- package/dist/esm/chunks/chunk-56FCLJJD.js +0 -1
- package/dist/esm/chunks/chunk-A5SYCVQM.js +0 -1
- package/dist/esm/chunks/chunk-BA4LQJJQ.js +0 -1
- package/dist/esm/chunks/chunk-C5JYQCWH.js +0 -1
- package/dist/esm/chunks/chunk-E5YO6274.js +0 -1
- package/dist/esm/chunks/chunk-MPDWM2NN.js +0 -1
- package/dist/esm/chunks/chunk-YLUULATY.js +0 -1
- package/dist/esm/chunks/extras-6POFBNOP.js +0 -1
- package/src/account/addresses-view.ts +0 -58
- package/src/account/answers.ts +0 -79
- package/src/account/markup.ts +0 -36
- package/src/account/operations.ts +0 -145
- package/src/account/order-lines.ts +0 -75
- package/src/account/order-list.ts +0 -107
- package/src/account/order-page.ts +0 -137
- package/src/account/page-answers.ts +0 -317
- package/src/account/page-events.ts +0 -253
- package/src/account/server-client.ts +0 -183
- package/src/account/subscriptions-view.ts +0 -115
- package/src/account-api.ts +0 -98
- package/src/account-marks.ts +0 -35
- package/src/account-pages.ts +0 -81
- package/src/account.ts +0 -284
- package/src/amounts.ts +0 -64
- package/src/analytics-shopify.ts +0 -246
- package/src/analytics.ts +0 -315
- package/src/api-version.ts +0 -79
- package/src/astro.ts +0 -272
- package/src/cart/cart-shape.ts +0 -25
- package/src/cart/checkout.ts +0 -101
- package/src/cart/drawer.ts +0 -125
- package/src/cart/mutations.ts +0 -237
- package/src/cart/render.ts +0 -266
- package/src/cart/store.ts +0 -279
- package/src/cart.ts +0 -345
- package/src/client.ts +0 -391
- package/src/collection.ts +0 -175
- package/src/config-sections.ts +0 -284
- package/src/config.ts +0 -480
- package/src/controls/cart-controls.ts +0 -123
- package/src/controls/discount.ts +0 -75
- package/src/controls/extras.ts +0 -54
- package/src/controls/navigation.ts +0 -77
- package/src/diagnose-markup.ts +0 -298
- package/src/diagnose.ts +0 -294
- package/src/dom.ts +0 -268
- package/src/extras.ts +0 -136
- package/src/fragments.ts +0 -261
- package/src/globals.d.ts +0 -15
- package/src/images.ts +0 -91
- package/src/index.ts +0 -415
- package/src/initial.ts +0 -101
- package/src/lazy-iife.ts +0 -197
- package/src/lazy.ts +0 -42
- package/src/loader.ts +0 -363
- package/src/market-codes.ts +0 -6
- package/src/markets-ui.ts +0 -227
- package/src/markets.ts +0 -246
- package/src/markup.ts +0 -127
- package/src/mounting.ts +0 -264
- package/src/page-market.ts +0 -110
- package/src/part-integrity.ts +0 -7
- package/src/parts/account-pages.ts +0 -7
- package/src/parts/account.ts +0 -7
- package/src/parts/analytics.ts +0 -7
- package/src/parts/diagnose.ts +0 -7
- package/src/parts/extras.ts +0 -7
- package/src/parts/markets.ts +0 -7
- package/src/parts/search.ts +0 -7
- package/src/presets/barba.ts +0 -194
- package/src/product-events.ts +0 -115
- package/src/product-render.ts +0 -218
- package/src/product-shape.ts +0 -49
- package/src/product-state.ts +0 -318
- package/src/product.ts +0 -308
- package/src/repeaters.ts +0 -161
- package/src/search.ts +0 -305
- package/src/selling-plans.ts +0 -145
- package/src/server.ts +0 -211
- package/src/shopify-values.ts +0 -91
- package/src/status.ts +0 -106
- package/src/storage.ts +0 -34
- package/src/store-html.ts +0 -359
- package/src/storefront/catalog.ts +0 -196
- package/src/storefront/json-ld.ts +0 -144
- package/src/variant-render.ts +0 -139
package/src/cart/render.ts
DELETED
|
@@ -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
|
-
}
|