@occluze/tether 0.6.7 → 0.7.0
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 +11 -11
- package/dist/esm/astro.js +2 -2
- package/dist/esm/chunks/account-UIPWJQ3Y.js +1 -0
- package/dist/esm/chunks/account-pages-6ROA6Y4V.js +1 -0
- package/dist/esm/chunks/analytics-2V632IRV.js +1 -0
- package/dist/esm/chunks/chunk-4GCXM764.js +1 -0
- package/dist/esm/chunks/chunk-56FCLJJD.js +1 -0
- package/dist/esm/chunks/chunk-5Z5O3767.js +1 -0
- package/dist/esm/chunks/chunk-6OZJCAGZ.js +1 -0
- package/dist/esm/chunks/chunk-A5SYCVQM.js +1 -0
- package/dist/esm/chunks/chunk-BA4LQJJQ.js +1 -0
- package/dist/esm/chunks/chunk-BFTZR4OB.js +4 -0
- package/dist/esm/chunks/chunk-C5JYQCWH.js +1 -0
- package/dist/esm/chunks/chunk-E5YO6274.js +1 -0
- package/dist/esm/chunks/chunk-MPDWM2NN.js +1 -0
- package/dist/esm/chunks/chunk-T44MUL4Y.js +1 -0
- package/dist/esm/chunks/chunk-YLUULATY.js +1 -0
- package/dist/esm/chunks/diagnose-3FEKRIEK.js +14 -0
- package/dist/esm/chunks/extras-6POFBNOP.js +1 -0
- package/dist/esm/chunks/markets-ui-FD6NI2OG.js +1 -0
- package/dist/esm/chunks/search-UJ73YBQ5.js +1 -0
- package/dist/esm/index.js +1 -1
- package/dist/esm/server.js +2 -1
- package/dist/presets/barba.esm.js +55 -39
- package/dist/tether.account-pages.js +781 -495
- package/dist/tether.account-pages.min.js +1 -1
- package/dist/tether.account.js +462 -381
- package/dist/tether.account.min.js +1 -1
- package/dist/tether.analytics.js +299 -194
- package/dist/tether.analytics.min.js +1 -1
- package/dist/tether.diagnose.js +354 -262
- package/dist/tether.diagnose.min.js +2 -2
- package/dist/tether.extras.js +82 -45
- package/dist/tether.extras.min.js +1 -1
- package/dist/tether.js +3653 -2116
- package/dist/tether.markets.js +238 -150
- package/dist/tether.markets.min.js +1 -1
- package/dist/tether.min.js +3 -3
- package/dist/tether.min.js.map +4 -4
- package/dist/tether.search.js +207 -136
- package/dist/tether.search.min.js +1 -1
- package/dist/types/server/src/shared/account-orders.d.ts +44 -0
- package/dist/types/server/src/shared/account-routes.d.ts +153 -0
- package/dist/types/server/src/shared/api-versions.d.ts +13 -0
- package/dist/types/server/src/shared/error-message.d.ts +2 -0
- package/dist/types/server/src/shared/json.d.ts +25 -0
- package/dist/types/server/src/shared/market-codes.d.ts +16 -0
- package/dist/types/src/account/addresses-view.d.ts +5 -0
- package/dist/types/src/account/answers.d.ts +40 -0
- package/dist/types/src/account/markup.d.ts +19 -0
- package/dist/types/src/account/operations.d.ts +41 -0
- package/dist/types/src/account/order-lines.d.ts +13 -0
- package/dist/types/src/account/order-list.d.ts +12 -0
- package/dist/types/src/account/order-page.d.ts +6 -0
- package/dist/types/src/account/page-answers.d.ts +182 -0
- package/dist/types/src/account/page-events.d.ts +2 -0
- package/dist/types/src/account/server-client.d.ts +60 -0
- package/dist/types/src/account/subscriptions-view.d.ts +2 -0
- package/dist/types/src/account-api.d.ts +18 -0
- package/dist/types/src/account-marks.d.ts +15 -0
- package/dist/types/src/account-pages.d.ts +8 -0
- package/dist/types/src/account.d.ts +64 -0
- package/dist/types/src/amounts.d.ts +12 -0
- package/dist/types/src/analytics-shopify.d.ts +41 -0
- package/dist/types/src/analytics.d.ts +2 -0
- package/dist/types/src/api-version.d.ts +28 -0
- package/dist/types/src/astro.d.ts +42 -0
- package/dist/types/src/cart/cart-shape.d.ts +43 -0
- package/dist/types/src/cart/checkout.d.ts +16 -0
- package/dist/types/src/cart/drawer.d.ts +18 -0
- package/dist/types/src/cart/mutations.d.ts +79 -0
- package/dist/types/src/cart/render.d.ts +17 -0
- package/dist/types/src/cart/store.d.ts +182 -0
- package/dist/types/src/cart.d.ts +106 -0
- package/dist/types/src/client.d.ts +100 -0
- package/dist/types/src/collection.d.ts +25 -0
- package/dist/types/src/config-sections.d.ts +40 -0
- package/dist/types/src/config.d.ts +185 -0
- package/dist/types/src/controls/cart-controls.d.ts +2 -0
- package/dist/types/src/controls/discount.d.ts +2 -0
- package/dist/types/src/controls/extras.d.ts +5 -0
- package/dist/types/src/controls/navigation.d.ts +12 -0
- package/dist/types/src/diagnose-markup.d.ts +19 -0
- package/dist/types/src/diagnose.d.ts +60 -0
- package/dist/types/src/dom.d.ts +75 -0
- package/dist/types/src/extras.d.ts +21 -0
- package/dist/types/src/fragments.d.ts +158 -0
- package/dist/types/src/globals.d.ts +15 -0
- package/dist/types/src/images.d.ts +20 -0
- package/dist/types/src/index.d.ts +167 -0
- package/dist/types/src/initial.d.ts +56 -0
- package/dist/types/src/lazy.d.ts +28 -0
- package/dist/types/src/loader.d.ts +84 -0
- package/dist/types/src/market-codes.d.ts +6 -0
- package/dist/types/src/markets-ui.d.ts +32 -0
- package/dist/types/src/markets.d.ts +72 -0
- package/dist/types/src/markup.d.ts +131 -0
- package/dist/types/src/mounting.d.ts +46 -0
- package/dist/types/src/page-market.d.ts +27 -0
- package/dist/types/src/presets/barba.d.ts +56 -0
- package/dist/types/src/product-events.d.ts +2 -0
- package/dist/types/src/product-render.d.ts +17 -0
- package/dist/types/src/product-shape.d.ts +88 -0
- package/dist/types/src/product-state.d.ts +110 -0
- package/dist/types/src/product.d.ts +66 -0
- package/dist/types/src/repeaters.d.ts +16 -0
- package/dist/types/src/search.d.ts +33 -0
- package/dist/types/src/selling-plans.d.ts +64 -0
- package/dist/types/src/server.d.ts +88 -0
- package/dist/types/src/shopify-values.d.ts +46 -0
- package/dist/types/src/status.d.ts +33 -0
- package/dist/types/src/storage.d.ts +12 -0
- package/dist/types/src/store-html.d.ts +39 -0
- package/dist/types/src/storefront/catalog.d.ts +38 -0
- package/dist/types/src/storefront/json-ld.d.ts +20 -0
- package/dist/types/src/variant-render.d.ts +14 -0
- package/package.json +42 -22
- package/src/account/addresses-view.ts +58 -0
- package/src/account/answers.ts +79 -0
- package/src/account/markup.ts +36 -0
- package/src/account/operations.ts +145 -0
- package/src/account/order-lines.ts +75 -0
- package/src/account/order-list.ts +107 -0
- package/src/account/order-page.ts +137 -0
- package/src/account/page-answers.ts +317 -0
- package/src/account/page-events.ts +253 -0
- package/src/account/server-client.ts +183 -0
- package/src/account/subscriptions-view.ts +115 -0
- package/src/account-api.ts +98 -0
- package/src/account-marks.ts +35 -0
- package/src/account-pages.ts +81 -0
- package/src/account.ts +284 -0
- package/src/amounts.ts +64 -0
- package/src/analytics-shopify.ts +246 -0
- package/src/analytics.ts +315 -0
- package/src/api-version.ts +79 -0
- package/src/astro.ts +272 -0
- package/src/cart/cart-shape.ts +25 -0
- package/src/cart/checkout.ts +101 -0
- package/src/cart/drawer.ts +125 -0
- package/src/cart/mutations.ts +237 -0
- package/src/cart/render.ts +266 -0
- package/src/cart/store.ts +279 -0
- package/src/cart.ts +345 -0
- package/src/client.ts +391 -0
- package/src/collection.ts +175 -0
- package/src/config-sections.ts +284 -0
- package/src/config.ts +480 -0
- package/src/controls/cart-controls.ts +123 -0
- package/src/controls/discount.ts +75 -0
- package/src/controls/extras.ts +54 -0
- package/src/controls/navigation.ts +77 -0
- package/src/diagnose-markup.ts +298 -0
- package/src/diagnose.ts +294 -0
- package/src/dom.ts +268 -0
- package/src/extras.ts +136 -0
- package/src/fragments.ts +261 -0
- package/src/globals.d.ts +15 -0
- package/src/images.ts +91 -0
- package/src/index.ts +415 -0
- package/src/initial.ts +101 -0
- package/src/lazy-iife.ts +197 -0
- package/src/lazy.ts +42 -0
- package/src/loader.ts +363 -0
- package/src/market-codes.ts +6 -0
- package/src/markets-ui.ts +227 -0
- package/src/markets.ts +246 -0
- package/src/markup.ts +127 -0
- package/src/mounting.ts +264 -0
- package/src/page-market.ts +110 -0
- package/src/part-integrity.ts +7 -0
- package/src/parts/account-pages.ts +7 -0
- package/src/parts/account.ts +7 -0
- package/src/parts/analytics.ts +7 -0
- package/src/parts/diagnose.ts +7 -0
- package/src/parts/extras.ts +7 -0
- package/src/parts/markets.ts +7 -0
- package/src/parts/search.ts +7 -0
- package/src/presets/barba.ts +194 -0
- package/src/product-events.ts +115 -0
- package/src/product-render.ts +218 -0
- package/src/product-shape.ts +49 -0
- package/src/product-state.ts +318 -0
- package/src/product.ts +308 -0
- package/src/repeaters.ts +161 -0
- package/src/search.ts +305 -0
- package/src/selling-plans.ts +145 -0
- package/src/server.ts +211 -0
- package/src/shopify-values.ts +91 -0
- package/src/status.ts +106 -0
- package/src/storage.ts +34 -0
- package/src/store-html.ts +359 -0
- package/src/storefront/catalog.ts +196 -0
- package/src/storefront/json-ld.ts +144 -0
- package/src/variant-render.ts +139 -0
- package/dist/esm/chunks/account-Q6E2TFEV.js +0 -1
- package/dist/esm/chunks/account-pages-UZXR4R5F.js +0 -1
- package/dist/esm/chunks/analytics-IJBKATUP.js +0 -1
- package/dist/esm/chunks/chunk-4QJKLPYR.js +0 -1
- package/dist/esm/chunks/chunk-64TUD3QR.js +0 -1
- package/dist/esm/chunks/chunk-AJLPU2JC.js +0 -4
- package/dist/esm/chunks/chunk-FIOZPPC5.js +0 -1
- package/dist/esm/chunks/chunk-K2YEQAIH.js +0 -1
- package/dist/esm/chunks/chunk-KIDH76B7.js +0 -1
- package/dist/esm/chunks/chunk-PAYT5ZD7.js +0 -1
- package/dist/esm/chunks/chunk-RREHGH4A.js +0 -1
- package/dist/esm/chunks/chunk-SSJT43CJ.js +0 -1
- package/dist/esm/chunks/chunk-VC46IEJQ.js +0 -1
- package/dist/esm/chunks/diagnose-3WVAWNTX.js +0 -14
- package/dist/esm/chunks/extras-7I7WJX35.js +0 -1
- package/dist/esm/chunks/markets-ui-545XLFTE.js +0 -1
- package/dist/esm/chunks/search-NILMGYXG.js +0 -1
- package/src/account-pages.js +0 -318
- package/src/account.js +0 -385
- package/src/analytics.js +0 -232
- package/src/api-version.js +0 -35
- package/src/astro.js +0 -101
- package/src/cart.js +0 -559
- package/src/client.js +0 -131
- package/src/collection.js +0 -114
- package/src/config.js +0 -85
- package/src/diagnose.js +0 -246
- package/src/dom.js +0 -288
- package/src/extras.js +0 -63
- package/src/fragments.js +0 -144
- package/src/index.js +0 -395
- package/src/initial.js +0 -75
- package/src/lazy-iife.js +0 -46
- package/src/lazy.js +0 -18
- package/src/loader.js +0 -198
- package/src/markets-ui.js +0 -90
- package/src/markets.js +0 -133
- package/src/mounting.js +0 -154
- package/src/parts/account-pages.js +0 -3
- package/src/parts/account.js +0 -3
- package/src/parts/analytics.js +0 -3
- package/src/parts/diagnose.js +0 -3
- package/src/parts/extras.js +0 -3
- package/src/parts/markets.js +0 -3
- package/src/parts/search.js +0 -3
- package/src/presets/barba.js +0 -100
- package/src/product.js +0 -570
- package/src/search.js +0 -153
- package/src/selling-plans.js +0 -94
- package/src/server.js +0 -252
- package/src/status.js +0 -66
- package/types/astro.d.ts +0 -17
- package/types/barba.d.ts +0 -16
- package/types/index.d.ts +0 -426
- package/types/server.d.ts +0 -110
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A product scope's clicks and changes: option and plan buttons, purchase toggles, quantity steppers, the selects and
|
|
3
|
+
* add to cart. One delegated listener of each kind on the root, so cards a list re-renders need no new listeners.
|
|
4
|
+
*/
|
|
5
|
+
import { reportError } from './config.ts';
|
|
6
|
+
import { sel, get, emit, setText, busy } from './dom.ts';
|
|
7
|
+
import { addButtonLabel } from './variant-render.ts';
|
|
8
|
+
import type { ProductScope } from './product.ts';
|
|
9
|
+
import type { Purchase } from './product-state.ts';
|
|
10
|
+
|
|
11
|
+
const productSelector = () => sel('product');
|
|
12
|
+
|
|
13
|
+
const isField = (el: Element): el is HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement =>
|
|
14
|
+
el.tagName === 'INPUT' || el.tagName === 'SELECT' || el.tagName === 'TEXTAREA';
|
|
15
|
+
|
|
16
|
+
const isElement = (target: EventTarget | null): target is Element => target instanceof window.Element;
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* @returns The nearest `selector` around `target` that belongs to the scope, not to a nested one. Typed as an HTML
|
|
20
|
+
* element, as `qsa` types its matches.
|
|
21
|
+
*/
|
|
22
|
+
function closestOwn(scope: ProductScope, target: Element, selector: string): HTMLElement | null {
|
|
23
|
+
const el = target.closest<HTMLElement>(selector);
|
|
24
|
+
return el?.closest(productSelector()) === scope.root ? el : null;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Adds to the cart with `aria-busy` on the button and `tether-adding-text` as its label meanwhile. */
|
|
28
|
+
function onAddClick(scope: ProductScope, button: HTMLElement): void {
|
|
29
|
+
const { label, original } = addButtonLabel(button);
|
|
30
|
+
const addingText = get(button, 'adding-text');
|
|
31
|
+
busy(button, async () => {
|
|
32
|
+
if (addingText) setText(label, addingText);
|
|
33
|
+
try {
|
|
34
|
+
await scope.addToCart();
|
|
35
|
+
} catch (error) {
|
|
36
|
+
reportError(error, 'add to cart failed');
|
|
37
|
+
emit('error', { error, context: 'add-to-cart' }, scope.root);
|
|
38
|
+
} finally {
|
|
39
|
+
if (addingText) setText(label, original);
|
|
40
|
+
}
|
|
41
|
+
})
|
|
42
|
+
.then(() => {
|
|
43
|
+
// The mode-dependent labels (tether-subscribe-text, tether-soldout-text) wait for aria-busy to clear.
|
|
44
|
+
scope.renderVariant();
|
|
45
|
+
})
|
|
46
|
+
.catch((error: unknown) => {
|
|
47
|
+
reportError(error, 'add to cart render');
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function onClick(scope: ProductScope, event: Event, target: Element): void {
|
|
52
|
+
const optionValue = closestOwn(scope, target, sel('option-value'));
|
|
53
|
+
const optionList = optionValue?.closest(sel('option-list'));
|
|
54
|
+
if (optionValue && optionList) {
|
|
55
|
+
event.preventDefault();
|
|
56
|
+
scope.select(scope.optName(get(optionList, 'option-list')), get(optionValue, 'option-value') ?? '');
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
const plan = closestOwn(scope, target, sel('plan-value'));
|
|
60
|
+
if (plan) {
|
|
61
|
+
event.preventDefault();
|
|
62
|
+
scope.selectPlan(get(plan, 'plan-value'));
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
const purchase = closestOwn(scope, target, `${sel('purchase')}:not(select)`);
|
|
66
|
+
if (purchase) {
|
|
67
|
+
// A radio or checkbox input keeps its own click, so it shows checked.
|
|
68
|
+
if (purchase.tagName !== 'INPUT') event.preventDefault();
|
|
69
|
+
scope.setPurchase(purchaseKind(get(purchase, 'purchase')));
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
const plus = closestOwn(scope, target, sel('qty-plus'));
|
|
73
|
+
const minus = plus ? null : closestOwn(scope, target, sel('qty-minus'));
|
|
74
|
+
if (plus || minus) {
|
|
75
|
+
event.preventDefault();
|
|
76
|
+
scope.setQuantity(scope.quantity + (plus ? 1 : -1));
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
const add = closestOwn(scope, target, sel('add-to-cart'));
|
|
80
|
+
if (add) {
|
|
81
|
+
event.preventDefault();
|
|
82
|
+
onAddClick(scope, add);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** @returns The kind a `tether-purchase` value asks for: `subscription`, else buying once. */
|
|
87
|
+
const purchaseKind = (value: string | null): Purchase['kind'] =>
|
|
88
|
+
value === 'subscription' ? 'subscription' : 'one-time';
|
|
89
|
+
|
|
90
|
+
/** @param target A form field in the scope. */
|
|
91
|
+
function onChange(scope: ProductScope, target: Element): void {
|
|
92
|
+
const value = isField(target) ? target.value : '';
|
|
93
|
+
if (target.matches(`select${sel('option')}`)) {
|
|
94
|
+
scope.select(scope.optName(get(target, 'option')), value);
|
|
95
|
+
} else if (target.matches(`select${sel('variant-select')}`)) {
|
|
96
|
+
scope.selectVariant(value);
|
|
97
|
+
} else if (target.matches(`select${sel('plan')}`)) {
|
|
98
|
+
if (value) scope.selectPlan(value);
|
|
99
|
+
else scope.setPurchase('one-time');
|
|
100
|
+
} else if (target.matches(`select${sel('purchase')}`)) {
|
|
101
|
+
scope.setPurchase(purchaseKind(value));
|
|
102
|
+
} else if (target.matches(sel('qty'))) {
|
|
103
|
+
scope.setQuantity(value);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function bindProductEvents(scope: ProductScope): void {
|
|
108
|
+
scope.root.addEventListener('click', (event) => {
|
|
109
|
+
if (isElement(event.target)) onClick(scope, event, event.target);
|
|
110
|
+
});
|
|
111
|
+
scope.root.addEventListener('change', (event) => {
|
|
112
|
+
const { target } = event;
|
|
113
|
+
if (isElement(target) && target.closest(productSelector()) === scope.root) onChange(scope, target);
|
|
114
|
+
});
|
|
115
|
+
}
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The DOM writes of a product scope that depend on the product alone, once per product: its binds, the option and
|
|
3
|
+
* plan lists and the options of its selects. `variant-render.ts` writes what follows the visitor's choice.
|
|
4
|
+
*/
|
|
5
|
+
import {
|
|
6
|
+
attr,
|
|
7
|
+
sel,
|
|
8
|
+
get,
|
|
9
|
+
has,
|
|
10
|
+
qsa,
|
|
11
|
+
formatMoney,
|
|
12
|
+
formatDate,
|
|
13
|
+
setText,
|
|
14
|
+
setImage,
|
|
15
|
+
setStoreHtml,
|
|
16
|
+
show,
|
|
17
|
+
repeat,
|
|
18
|
+
hasTemplate,
|
|
19
|
+
sameImage,
|
|
20
|
+
productUrl,
|
|
21
|
+
} from './dom.ts';
|
|
22
|
+
import type { Product } from './fragments.ts';
|
|
23
|
+
import type { PlanInGroup } from './selling-plans.ts';
|
|
24
|
+
|
|
25
|
+
/** The product scope as its renders see it. */
|
|
26
|
+
export interface RenderScope {
|
|
27
|
+
readonly root: HTMLElement;
|
|
28
|
+
readonly product: Product;
|
|
29
|
+
readonly plans: PlanInGroup[];
|
|
30
|
+
/** Matches in this scope, the root included. */
|
|
31
|
+
$: (selector: string) => HTMLElement[];
|
|
32
|
+
/** The product's option name for one written in the markup. */
|
|
33
|
+
optName: (authored: string | null) => string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** `tether-bind="image:2"`: the second product image. */
|
|
37
|
+
const NTH_IMAGE_BIND = /^image:(\d+)$/;
|
|
38
|
+
const METAFIELD_BIND = 'metafield:';
|
|
39
|
+
const MULTI_LINE_METAFIELD = /^(rich_text|multi_line_text)_field$/;
|
|
40
|
+
/** `tether-plan-list` values that list every plan, not one group: Webflow's Designer can't always leave it empty. */
|
|
41
|
+
const ALL_PLAN_GROUPS = ['', 'true', 'all', '*'];
|
|
42
|
+
|
|
43
|
+
export const isSelect = (el: Element): el is HTMLSelectElement => el.tagName === 'SELECT';
|
|
44
|
+
|
|
45
|
+
/** @returns The `<select>` elements in the scope that match `selector`. */
|
|
46
|
+
export const selectsIn = (scope: RenderScope, selector: string): HTMLSelectElement[] =>
|
|
47
|
+
scope.$(`select${selector}`).filter(isSelect);
|
|
48
|
+
|
|
49
|
+
/** Leaves a server-rendered gallery with the same images as it is. */
|
|
50
|
+
function renderGallery(el: HTMLElement, product: Product): void {
|
|
51
|
+
const images = product.images.nodes;
|
|
52
|
+
const rendered = [...el.children].map((node) => (node.tagName === 'IMG' ? node : node.querySelector('img')));
|
|
53
|
+
const same =
|
|
54
|
+
!hasTemplate(el) &&
|
|
55
|
+
rendered.length === images.length &&
|
|
56
|
+
rendered.every((img, index) => sameImage(img?.getAttribute('src'), images[index]?.url));
|
|
57
|
+
if (same) return;
|
|
58
|
+
repeat(el, images, (node, image) => {
|
|
59
|
+
const target = node.matches(`img,${sel('bind', 'image')}`) ? node : (qsa(node, 'img')[0] ?? node);
|
|
60
|
+
setImage(target, image.url, image.altText || product.title);
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** @param ref `namespace.key` */
|
|
65
|
+
function renderMetafield(el: HTMLElement, product: Product, ref: string): void {
|
|
66
|
+
const dot = ref.indexOf('.');
|
|
67
|
+
const namespace = ref.slice(0, dot);
|
|
68
|
+
const key = ref.slice(dot + 1);
|
|
69
|
+
const metafield = (product.metafields ?? []).find((field) => field?.namespace === namespace && field.key === key);
|
|
70
|
+
if (!metafield) {
|
|
71
|
+
setText(el, '');
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
const image = metafield.reference?.image;
|
|
75
|
+
if (image) {
|
|
76
|
+
setImage(el, image.url, image.altText);
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
if (MULTI_LINE_METAFIELD.test(metafield.type)) el.style.whiteSpace = 'pre-line';
|
|
80
|
+
setText(el, metafield.value);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** The product binds written as text. */
|
|
84
|
+
const PRODUCT_TEXTS = new Map<string, (product: Product) => string>(
|
|
85
|
+
Object.entries({
|
|
86
|
+
title: (product) => product.title,
|
|
87
|
+
'description-text': (product) => product.description,
|
|
88
|
+
vendor: (product) => product.vendor,
|
|
89
|
+
type: (product) => product.productType,
|
|
90
|
+
handle: (product) => product.handle,
|
|
91
|
+
'published-at': (product) => formatDate(product.publishedAt),
|
|
92
|
+
'min-price': (product) => formatMoney(product.priceRange.minVariantPrice),
|
|
93
|
+
'max-price': (product) => formatMoney(product.priceRange.maxVariantPrice),
|
|
94
|
+
}),
|
|
95
|
+
);
|
|
96
|
+
|
|
97
|
+
function renderTags(el: HTMLElement, product: Product): void {
|
|
98
|
+
const exclude = (el.getAttribute('exclude') ?? '')
|
|
99
|
+
.split(',')
|
|
100
|
+
.map((tag) => tag.trim())
|
|
101
|
+
.filter(Boolean);
|
|
102
|
+
const tags = product.tags.filter((tag) => !exclude.includes(tag));
|
|
103
|
+
repeat(el, tags, (node, tag) => {
|
|
104
|
+
setText(node, tag);
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* A `tether-bind` of the product itself; binds of the variant are left to `writeVariant`.
|
|
110
|
+
*/
|
|
111
|
+
function renderProductBind(el: HTMLElement, key: string, product: Product): void {
|
|
112
|
+
const text = PRODUCT_TEXTS.get(key);
|
|
113
|
+
if (text) {
|
|
114
|
+
setText(el, text(product));
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
switch (key) {
|
|
118
|
+
case 'description':
|
|
119
|
+
setStoreHtml(el, product.descriptionHtml);
|
|
120
|
+
break;
|
|
121
|
+
case 'featured-image':
|
|
122
|
+
setImage(el, product.featuredImage?.url, product.featuredImage?.altText || product.title);
|
|
123
|
+
break;
|
|
124
|
+
case 'url':
|
|
125
|
+
case 'link': {
|
|
126
|
+
const url = productUrl(product.handle, product.id);
|
|
127
|
+
if (url) el.setAttribute('href', url);
|
|
128
|
+
if (key === 'url' && !el.firstElementChild && !el.textContent.trim()) setText(el, product.title);
|
|
129
|
+
break;
|
|
130
|
+
}
|
|
131
|
+
case 'images':
|
|
132
|
+
renderGallery(el, product);
|
|
133
|
+
break;
|
|
134
|
+
case 'tags':
|
|
135
|
+
renderTags(el, product);
|
|
136
|
+
break;
|
|
137
|
+
default:
|
|
138
|
+
renderIndexedBind(el, key, product);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** `image:<n>` (falling back to the featured image) and `metafield:<namespace.key>`. */
|
|
143
|
+
function renderIndexedBind(el: HTMLElement, key: string, product: Product): void {
|
|
144
|
+
const nth = NTH_IMAGE_BIND.exec(key);
|
|
145
|
+
if (nth) {
|
|
146
|
+
const image = product.images.nodes[Number(nth[1]) - 1] ?? product.featuredImage;
|
|
147
|
+
setImage(el, image?.url, image?.altText || product.title);
|
|
148
|
+
} else if (key.startsWith(METAFIELD_BIND)) {
|
|
149
|
+
renderMetafield(el, product, key.slice(METAFIELD_BIND.length));
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function renderOptionLists(scope: RenderScope): void {
|
|
154
|
+
const { product } = scope;
|
|
155
|
+
scope.$(sel('option-list')).forEach((container) => {
|
|
156
|
+
const name = scope.optName(get(container, 'option-list'));
|
|
157
|
+
const option = product.options.find((candidate) => candidate.name === name);
|
|
158
|
+
if (!option || (option.optionValues.length <= 1 && has(container, 'hide-single'))) {
|
|
159
|
+
show(container, false);
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
repeat(container, option.optionValues, (node, value) => {
|
|
163
|
+
node.setAttribute(attr('option-value'), value.name);
|
|
164
|
+
const text = qsa(node, sel('bind', 'option-value'))[0];
|
|
165
|
+
if (text) setText(text, value.name);
|
|
166
|
+
else if (!node.firstElementChild) setText(node, value.name);
|
|
167
|
+
const swatchEl = qsa(node, sel('bind', 'swatch'))[0];
|
|
168
|
+
const { swatch } = value;
|
|
169
|
+
if (!swatchEl || !swatch) return;
|
|
170
|
+
if (swatch.color) swatchEl.style.backgroundColor = swatch.color;
|
|
171
|
+
if (swatch.image?.previewImage) setImage(swatchEl, swatch.image.previewImage.url, value.name);
|
|
172
|
+
});
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function renderSelects(scope: RenderScope): void {
|
|
177
|
+
const { product } = scope;
|
|
178
|
+
selectsIn(scope, sel('option')).forEach((select) => {
|
|
179
|
+
const option = product.options.find((candidate) => candidate.name === scope.optName(get(select, 'option')));
|
|
180
|
+
if (!option) {
|
|
181
|
+
show(select, false);
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
select.replaceChildren(...option.optionValues.map((value) => new Option(value.name, value.name)));
|
|
185
|
+
});
|
|
186
|
+
selectsIn(scope, sel('variant-select')).forEach((select) => {
|
|
187
|
+
select.replaceChildren(...product.variants.nodes.map((variant) => new Option(variant.title, variant.id)));
|
|
188
|
+
});
|
|
189
|
+
selectsIn(scope, sel('plan')).forEach((select) => {
|
|
190
|
+
const plans = scope.plans.map((plan) => new Option(plan.name, plan.id));
|
|
191
|
+
const oneTime = !product.requiresSellingPlan && get(select, 'plan') !== 'subscription-only';
|
|
192
|
+
const oneTimeLabel = select.getAttribute(attr('one-time-label')) || 'One-time purchase';
|
|
193
|
+
select.replaceChildren(...(oneTime ? [new Option(oneTimeLabel, '')] : []), ...plans);
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function renderPlanLists(scope: RenderScope): void {
|
|
198
|
+
scope.$(sel('plan-list')).forEach((container) => {
|
|
199
|
+
// The value is a selling plan group's name, or one of ALL_PLAN_GROUPS.
|
|
200
|
+
const raw = (get(container, 'plan-list') ?? '').trim();
|
|
201
|
+
const group = ALL_PLAN_GROUPS.includes(raw.toLowerCase()) ? null : raw;
|
|
202
|
+
const plans = scope.plans.filter((plan) => !group || plan.group.name === group);
|
|
203
|
+
repeat(container, plans, (node, plan) => {
|
|
204
|
+
node.setAttribute(attr('plan-value'), plan.id);
|
|
205
|
+
if (!node.firstElementChild && !node.querySelector(sel('plan-bind'))) setText(node, plan.name);
|
|
206
|
+
});
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** Writes what depends on the product alone: its binds, option and plan lists and the selects' options. */
|
|
211
|
+
export function writeProduct(scope: RenderScope): void {
|
|
212
|
+
scope.$(sel('bind')).forEach((el) => {
|
|
213
|
+
renderProductBind(el, get(el, 'bind') ?? '', scope.product);
|
|
214
|
+
});
|
|
215
|
+
renderOptionLists(scope);
|
|
216
|
+
renderSelects(scope);
|
|
217
|
+
renderPlanLists(scope);
|
|
218
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The structure of Tether's product fragment, checked on a value read from the page or from a Storefront answer
|
|
3
|
+
* before it is used as a `Product`. Rendering walks the product's lists and prices without checking each step, so a
|
|
4
|
+
* truncated or hand-written entry, or an answer missing fields, would otherwise fail deep inside a render instead of
|
|
5
|
+
* being skipped or refused.
|
|
6
|
+
*/
|
|
7
|
+
import { matches } from './client.ts';
|
|
8
|
+
import { field, isRecord } from '../server/src/shared/json.ts';
|
|
9
|
+
import type { Shape } from './client.ts';
|
|
10
|
+
import type { Product } from './fragments.ts';
|
|
11
|
+
import type { PageInfo } from './loader.ts';
|
|
12
|
+
|
|
13
|
+
/** A page of a GraphQL connection of products. */
|
|
14
|
+
export interface ProductConnection {
|
|
15
|
+
nodes: Product[];
|
|
16
|
+
pageInfo: PageInfo;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** A Storefront API `MoneyV2`. */
|
|
20
|
+
export const MONEY = { amount: 'string', currencyCode: 'string' } as const satisfies Shape;
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* A product's id, handle and title, and every list and price a render walks without a fallback: images, options,
|
|
24
|
+
* variants (each with an id, a price and its options) and the price range. Text fields are written as text, and the
|
|
25
|
+
* selling plan lists are read with a fallback, so they need no check.
|
|
26
|
+
*/
|
|
27
|
+
export const PRODUCT = {
|
|
28
|
+
id: 'string',
|
|
29
|
+
handle: 'string',
|
|
30
|
+
title: 'string',
|
|
31
|
+
options: ['object'],
|
|
32
|
+
images: { nodes: ['object'] },
|
|
33
|
+
priceRange: { minVariantPrice: MONEY, maxVariantPrice: MONEY },
|
|
34
|
+
variants: { nodes: [{ id: 'string', price: MONEY, selectedOptions: ['object'] }] },
|
|
35
|
+
} as const satisfies Shape;
|
|
36
|
+
|
|
37
|
+
/** A page of products, with the `pageInfo` paging reads. */
|
|
38
|
+
export const PRODUCT_CONNECTION = { nodes: [PRODUCT], pageInfo: { hasNextPage: 'boolean' } } as const satisfies Shape;
|
|
39
|
+
|
|
40
|
+
/** True for a value with the structure of `PRODUCT` and a non-empty id and handle. */
|
|
41
|
+
export const isProductShape = (value: unknown): value is Product =>
|
|
42
|
+
field(value, 'id') !== '' && field(value, 'handle') !== '' && matches(value, PRODUCT);
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* A node a lookup by gid answers: a product, or a node of another type, for which `... on Product` selects no field
|
|
46
|
+
* (no `id`).
|
|
47
|
+
*/
|
|
48
|
+
export const PRODUCT_NODE = (node: unknown): boolean =>
|
|
49
|
+
isRecord(node) && (node['id'] === undefined || matches(node, PRODUCT));
|