@occluze/tether 0.6.6 → 0.6.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -3
- package/dist/esm/astro.js +2 -2
- package/dist/esm/chunks/account-C3NP6HFB.js +1 -0
- package/dist/esm/chunks/account-pages-WLK374FO.js +1 -0
- package/dist/esm/chunks/analytics-G3SAWMNL.js +1 -0
- package/dist/esm/chunks/chunk-27PJJZGC.js +1 -0
- package/dist/esm/chunks/chunk-2FSXS55U.js +1 -0
- package/dist/esm/chunks/chunk-FCSWQDVV.js +1 -0
- package/dist/esm/chunks/chunk-FOMSMIAM.js +1 -0
- package/dist/esm/chunks/chunk-GZJTCLLW.js +1 -0
- package/dist/esm/chunks/chunk-HEQILZVR.js +1 -0
- package/dist/esm/chunks/chunk-JOOEXYF3.js +4 -0
- package/dist/esm/chunks/chunk-MPTQYLSM.js +1 -0
- package/dist/esm/chunks/chunk-NEHZ37TU.js +3 -0
- package/dist/esm/chunks/chunk-V74KGEJF.js +1 -0
- package/dist/esm/chunks/chunk-VT4CDUJJ.js +1 -0
- package/dist/esm/chunks/chunk-YJLWX2HK.js +1 -0
- package/dist/esm/chunks/diagnose-6VVGZNTY.js +14 -0
- package/dist/esm/chunks/extras-GJVRDFM3.js +1 -0
- package/dist/esm/chunks/markets-ui-UNB4PTA7.js +1 -0
- package/dist/esm/chunks/search-OU75RNQL.js +1 -0
- package/dist/esm/index.js +1 -1
- package/dist/esm/server.js +2 -1
- package/dist/presets/barba.esm.js +56 -36
- package/dist/tether.account-pages.js +776 -432
- package/dist/tether.account-pages.min.js +1 -1
- package/dist/tether.account.js +655 -321
- package/dist/tether.account.min.js +1 -1
- package/dist/tether.analytics.js +318 -191
- package/dist/tether.analytics.min.js +1 -1
- package/dist/tether.diagnose.js +334 -243
- package/dist/tether.diagnose.min.js +2 -2
- package/dist/tether.extras.js +78 -33
- package/dist/tether.extras.min.js +1 -1
- package/dist/tether.js +3171 -1998
- package/dist/tether.markets.js +231 -126
- package/dist/tether.markets.min.js +1 -1
- package/dist/tether.min.js +0 -0
- package/dist/tether.min.js.map +4 -4
- package/dist/tether.search.js +221 -126
- package/dist/tether.search.min.js +1 -1
- package/package.json +23 -4
- package/src/account/addresses-view.js +92 -0
- package/src/account/browser-session.js +359 -0
- package/src/account/markup.js +48 -0
- package/src/account/operations.js +180 -0
- package/src/account/order-lines.js +96 -0
- package/src/account/order-list.js +91 -0
- package/src/account/order-page.js +192 -0
- package/src/account/page-events.js +288 -0
- package/src/account/server-client.js +172 -0
- package/src/account/shared.js +111 -0
- package/src/account/shopify-auth.js +150 -0
- package/src/account/subscriptions-view.js +158 -0
- package/src/account-api.js +118 -0
- package/src/account-links.js +35 -0
- package/src/account-pages.js +68 -284
- package/src/account.js +281 -317
- package/src/amounts.js +36 -0
- package/src/analytics-shopify.js +241 -0
- package/src/analytics.js +304 -184
- package/src/api-version.js +75 -25
- package/src/astro.js +233 -68
- package/src/cart/checkout.js +97 -0
- package/src/cart/drawer.js +138 -0
- package/src/cart/mutations.js +234 -0
- package/src/cart/render.js +294 -0
- package/src/cart/store.js +326 -0
- package/src/cart.js +322 -481
- package/src/client.js +214 -78
- package/src/collection.js +126 -57
- package/src/config.js +323 -59
- package/src/controls/cart-controls.js +155 -0
- package/src/controls/discount.js +96 -0
- package/src/controls/extras.js +63 -0
- package/src/controls/navigation.js +83 -0
- package/src/diagnose-markup.js +333 -0
- package/src/diagnose.js +251 -202
- package/src/dom.js +219 -220
- package/src/extras.js +119 -37
- package/src/fragments.js +115 -22
- package/src/images.js +104 -0
- package/src/index.js +245 -285
- package/src/initial.js +78 -66
- package/src/lazy-iife.js +92 -22
- package/src/lazy.js +25 -4
- package/src/loader.js +263 -124
- package/src/market-codes.js +35 -0
- package/src/markets-ui.js +169 -55
- package/src/markets.js +286 -85
- package/src/markup.js +64 -0
- package/src/mounting.js +214 -91
- package/src/page-market.js +116 -0
- package/src/parts/account-pages.js +4 -1
- package/src/parts/account.js +4 -1
- package/src/parts/analytics.js +4 -1
- package/src/parts/diagnose.js +4 -1
- package/src/parts/extras.js +4 -1
- package/src/parts/markets.js +4 -1
- package/src/parts/search.js +4 -1
- package/src/presets/barba.js +120 -30
- package/src/product-events.js +130 -0
- package/src/product-render.js +257 -0
- package/src/product-state.js +318 -0
- package/src/product.js +233 -459
- package/src/repeaters.js +173 -0
- package/src/search.js +277 -107
- package/src/selling-plans.js +107 -47
- package/src/server.js +168 -205
- package/src/shopify-values.js +89 -0
- package/src/status.js +99 -39
- package/src/store-html.js +0 -0
- package/src/storefront/catalog.js +199 -0
- package/src/storefront/json-ld.js +167 -0
- package/src/variant-render.js +165 -0
- package/types/astro.d.ts +13 -2
- package/types/index.d.ts +178 -27
- package/types/server.d.ts +89 -17
- package/dist/esm/chunks/account-QUBHQG3C.js +0 -1
- package/dist/esm/chunks/account-pages-VWDHF6WA.js +0 -1
- package/dist/esm/chunks/analytics-SLATWPLC.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-L2TRRKYV.js +0 -1
- package/dist/esm/chunks/chunk-RREHGH4A.js +0 -1
- package/dist/esm/chunks/chunk-VC46IEJQ.js +0 -1
- package/dist/esm/chunks/chunk-VCPI7MJ2.js +0 -1
- package/dist/esm/chunks/chunk-VPVJPNB4.js +0 -1
- package/dist/esm/chunks/chunk-ZIUIWEON.js +0 -1
- package/dist/esm/chunks/diagnose-VXQBSD3A.js +0 -14
- package/dist/esm/chunks/extras-R4X34VQL.js +0 -1
- package/dist/esm/chunks/markets-ui-F3R5T5EL.js +0 -1
- package/dist/esm/chunks/search-NILMGYXG.js +0 -1
package/src/mounting.js
CHANGED
|
@@ -1,87 +1,144 @@
|
|
|
1
|
-
/**
|
|
1
|
+
/**
|
|
2
|
+
* The product scopes and lists on the page: mounting a product root, the live roots a country switch re-renders, and
|
|
3
|
+
* the products the server rendered into the page. Shared by `index.js` and the optional markets part.
|
|
4
|
+
*/
|
|
2
5
|
import { cfg, log, warn, reportError } from './config.js';
|
|
3
6
|
import { ProductScope, scopes, rememberOptionNames } from './product.js';
|
|
4
7
|
import { listSelector } from './collection.js';
|
|
5
8
|
import { getProduct, takeStale, refreshProducts, hydrateProducts } from './loader.js';
|
|
6
9
|
import { resolveProductKey, setScopeStatus, isNotFound } from './status.js';
|
|
7
10
|
import { attr, get, qsa, emit } from './dom.js';
|
|
11
|
+
/** @import { Product } from './fragments.js' */
|
|
12
|
+
|
|
13
|
+
/** @typedef {{ root: Element, load: (more?: boolean) => Promise<unknown> }} MountedList A mounted `ListScope`. */
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* The marker on the `<script type="application/json">` tags `tether/server`'s `hydrate()` writes. It is fixed, not
|
|
17
|
+
* `<prefix>-hydrate`, because the server writes it without knowing the browser's prefix.
|
|
18
|
+
*/
|
|
19
|
+
const HYDRATE_SELECTOR = 'script[type="application/json"][data-tether-hydrate]';
|
|
8
20
|
|
|
21
|
+
/** @type {WeakSet<Element>} */
|
|
9
22
|
export const mountedProducts = new WeakSet();
|
|
23
|
+
/** @type {WeakSet<Element>} */
|
|
10
24
|
export const mountedLists = new WeakSet();
|
|
11
|
-
|
|
25
|
+
/** @type {Set<HTMLElement>} Product roots to re-render after a country switch; pruned once disconnected. */
|
|
12
26
|
export const liveProducts = new Set();
|
|
27
|
+
/** @type {Set<MountedList>} Lists to reload after a country switch; pruned once disconnected. */
|
|
13
28
|
export const liveLists = new Set();
|
|
14
|
-
|
|
29
|
+
/** @type {WeakMap<Element, object>} Each product root's latest request, so a slow older response can't win. */
|
|
30
|
+
const latestRequest = new WeakMap();
|
|
15
31
|
|
|
32
|
+
/** Forgets roots a page transition removed, so they aren't kept alive. */
|
|
16
33
|
export function pruneLive() {
|
|
17
|
-
liveProducts.forEach((el) => {
|
|
18
|
-
|
|
34
|
+
liveProducts.forEach((el) => {
|
|
35
|
+
if (!el.isConnected) liveProducts.delete(el);
|
|
36
|
+
});
|
|
37
|
+
liveLists.forEach((list) => {
|
|
38
|
+
if (!list.root.isConnected) liveLists.delete(list);
|
|
39
|
+
});
|
|
19
40
|
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* @param {ParentNode & Partial<Pick<Element, 'matches'>>} root
|
|
44
|
+
* @param {string} selector
|
|
45
|
+
* @returns {HTMLElement[]} `root` itself when it matches, then the matches inside it.
|
|
46
|
+
*/
|
|
20
47
|
export function within(root, selector) {
|
|
21
|
-
|
|
48
|
+
const self = root.matches?.(selector) ? [/** @type {HTMLElement} */ (root)] : [];
|
|
49
|
+
return [...self, ...qsa(root, selector)];
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* `tether-page-title="{title} | My shop"` on a product root sets `document.title`, for dynamic product pages.
|
|
54
|
+
*
|
|
55
|
+
* @param {Element} el
|
|
56
|
+
* @param {Product} product
|
|
57
|
+
*/
|
|
58
|
+
function applyPageTitle(el, product) {
|
|
59
|
+
const template = get(el, 'page-title');
|
|
60
|
+
if (!template || typeof document === 'undefined') return;
|
|
61
|
+
document.title = template
|
|
62
|
+
.replace(/\{title\}/g, product.title)
|
|
63
|
+
.replace(/\{vendor\}/g, product.vendor || '')
|
|
64
|
+
.replace(/\{type\}/g, product.productType || '');
|
|
22
65
|
}
|
|
23
66
|
|
|
24
|
-
|
|
25
|
-
|
|
67
|
+
/**
|
|
68
|
+
* A product root without a key: on a dynamic page opened without `?handle=` (or with an empty path) it is a product
|
|
69
|
+
* that isn't there; inside a list it gets its handle when the list renders.
|
|
70
|
+
*
|
|
71
|
+
* @param {HTMLElement} el
|
|
72
|
+
* @param {{ fromUrl: boolean, raw: string }} source
|
|
73
|
+
*/
|
|
74
|
+
function mountWithoutKey(el, { fromUrl, raw }) {
|
|
75
|
+
if (fromUrl) {
|
|
76
|
+
mountedProducts.add(el);
|
|
77
|
+
setScopeStatus(el, 'not-found');
|
|
78
|
+
emit('product-not-found', { key: null, source: raw }, el);
|
|
79
|
+
} else if (!el.closest(listSelector())) {
|
|
80
|
+
warn(`[${attr('product')}] without a value. Bind it to the Shopify handle field, or use "url:handle".`, el);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* @param {HTMLElement} el
|
|
86
|
+
* @param {string} key
|
|
87
|
+
* @param {{ fromUrl: boolean, raw: string }} source
|
|
88
|
+
* @param {unknown} error
|
|
89
|
+
*/
|
|
90
|
+
function showLoadFailure(el, key, { fromUrl, raw }, error) {
|
|
91
|
+
const notFound = isNotFound(error);
|
|
92
|
+
setScopeStatus(el, notFound ? 'not-found' : 'error', error);
|
|
93
|
+
if (!notFound) {
|
|
94
|
+
reportError(error, `product ${key}`);
|
|
95
|
+
emit('error', { error, context: 'product', key }, el);
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
emit('product-not-found', { key, source: raw }, el);
|
|
99
|
+
// A typo in a URL isn't worth an alert; a CMS item pointing at a deleted product is.
|
|
100
|
+
if (fromUrl) log(`product not found: ${key}`);
|
|
101
|
+
else reportError(error, `product ${key}`);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Mounts one product root.
|
|
106
|
+
*
|
|
107
|
+
* @param {HTMLElement} el
|
|
108
|
+
* @returns {boolean} True when a product request was started.
|
|
109
|
+
*/
|
|
110
|
+
export function mountProduct(el) {
|
|
26
111
|
if (mountedProducts.has(el)) return false;
|
|
27
|
-
const { key,
|
|
112
|
+
const { key, ...source } = resolveProductKey(el);
|
|
28
113
|
if (!key) {
|
|
29
|
-
|
|
30
|
-
// Dynamic product page opened without ?handle= (or an empty path).
|
|
31
|
-
mountedProducts.add(el);
|
|
32
|
-
setScopeStatus(el, 'not-found');
|
|
33
|
-
emit('product-not-found', { key: null, source: raw }, el);
|
|
34
|
-
} else if (!el.closest(listSelector())) {
|
|
35
|
-
// List cards get their handle when the list renders.
|
|
36
|
-
warn(`[${attr('product')}] without a value. Bind it to the Shopify handle field, or use "url:handle".`, el);
|
|
37
|
-
}
|
|
114
|
+
mountWithoutKey(el, source);
|
|
38
115
|
return false;
|
|
39
116
|
}
|
|
40
117
|
mountedProducts.add(el);
|
|
41
118
|
liveProducts.add(el);
|
|
42
|
-
const
|
|
43
|
-
|
|
44
|
-
const
|
|
45
|
-
// A rendered scope keeps showing its content while it refreshes (
|
|
46
|
-
if (
|
|
47
|
-
else el
|
|
48
|
-
const loading = getProduct(key);
|
|
49
|
-
marketPending?.add(loading);
|
|
119
|
+
const request = {};
|
|
120
|
+
latestRequest.set(el, request);
|
|
121
|
+
const isLatest = () => latestRequest.get(el) === request;
|
|
122
|
+
// A rendered scope keeps showing its content while it refreshes (a country switch).
|
|
123
|
+
if (scopes.get(el)) el.setAttribute(attr('state'), 'updating');
|
|
124
|
+
else setScopeStatus(el, 'loading');
|
|
125
|
+
const loading = trackMarket(getProduct(key));
|
|
50
126
|
loading
|
|
51
|
-
.then((
|
|
52
|
-
if (!
|
|
127
|
+
.then((product) => {
|
|
128
|
+
if (!isLatest()) return;
|
|
53
129
|
const existing = scopes.get(el);
|
|
54
|
-
if (existing) existing.update(
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
if (cfg.revalidate !== false && takeStale(key)) queueRefresh(key);
|
|
130
|
+
if (existing) existing.update(product);
|
|
131
|
+
else new ProductScope(el, product);
|
|
132
|
+
applyPageTitle(el, product);
|
|
133
|
+
if (cfg.revalidate && takeStale(key)) queueRefresh(key);
|
|
59
134
|
})
|
|
60
|
-
.catch((
|
|
61
|
-
if (
|
|
62
|
-
const notFound = isNotFound(err);
|
|
63
|
-
setScopeStatus(el, notFound ? 'not-found' : 'error', err);
|
|
64
|
-
if (notFound) {
|
|
65
|
-
emit('product-not-found', { key, source: raw }, el);
|
|
66
|
-
// A typo in a URL isn't worth an alert; a CMS item pointing at a deleted product is.
|
|
67
|
-
if (fromUrl) log(`product not found: ${key}`); else reportError(err, `product ${key}`);
|
|
68
|
-
} else {
|
|
69
|
-
reportError(err, `product ${key}`);
|
|
70
|
-
emit('error', { error: err, context: 'product', key }, el);
|
|
71
|
-
}
|
|
135
|
+
.catch((/** @type {unknown} */ error) => {
|
|
136
|
+
if (isLatest()) showLoadFailure(el, key, source, error);
|
|
72
137
|
});
|
|
73
138
|
return true;
|
|
74
139
|
}
|
|
75
140
|
|
|
76
|
-
/**
|
|
77
|
-
function applyPageTitle(el, p) {
|
|
78
|
-
const tpl = get(el, 'page-title');
|
|
79
|
-
if (!tpl || typeof document === 'undefined') return;
|
|
80
|
-
document.title = tpl.replace(/\{title\}/g, p.title).replace(/\{vendor\}/g, p.vendor || '').replace(/\{type\}/g, p.productType || '');
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
/** Product scopes that failed on a network/API error: loaded again when the browser is back online. */
|
|
141
|
+
/** Loads again the product scopes that failed on a network or API error, for when the browser is back online. */
|
|
85
142
|
export function retryFailed() {
|
|
86
143
|
pruneLive();
|
|
87
144
|
liveProducts.forEach((el) => {
|
|
@@ -91,64 +148,130 @@ export function retryFailed() {
|
|
|
91
148
|
});
|
|
92
149
|
}
|
|
93
150
|
|
|
94
|
-
|
|
151
|
+
/** @type {Set<string>} Server-rendered products to refresh in the next batch. */
|
|
95
152
|
let refreshQueue = new Set();
|
|
153
|
+
|
|
154
|
+
/** @param {string} key */
|
|
96
155
|
function queueRefresh(key) {
|
|
97
156
|
if (!refreshQueue.size) setTimeout(flushRefresh, 0);
|
|
98
157
|
refreshQueue.add(key);
|
|
99
158
|
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* @param {string} key The key the refresh was asked for.
|
|
162
|
+
* @param {Product} fresh
|
|
163
|
+
*/
|
|
164
|
+
function applyRefresh(key, fresh) {
|
|
165
|
+
liveProducts.forEach((el) => {
|
|
166
|
+
const scope = scopes.get(el);
|
|
167
|
+
if (!scope || !el.isConnected) return;
|
|
168
|
+
const rootKey = resolveProductKey(el).key;
|
|
169
|
+
if (rootKey !== key && rootKey !== fresh.handle && rootKey !== fresh.id) return;
|
|
170
|
+
if (JSON.stringify(scope.product) !== JSON.stringify(fresh)) scope.update(fresh);
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** One batched background request refreshes every server-rendered product queued. */
|
|
100
175
|
function flushRefresh() {
|
|
101
176
|
const keys = [...refreshQueue];
|
|
102
177
|
refreshQueue = new Set();
|
|
103
|
-
refreshProducts(keys).forEach((promise,
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
178
|
+
refreshProducts(keys).forEach((promise, index) => {
|
|
179
|
+
const key = /** @type {string} */ (keys[index]);
|
|
180
|
+
promise
|
|
181
|
+
.then((fresh) => {
|
|
182
|
+
applyRefresh(key, fresh);
|
|
183
|
+
})
|
|
184
|
+
.catch((/** @type {unknown} */ error) => {
|
|
185
|
+
reportError(error, `refresh ${key}`);
|
|
186
|
+
});
|
|
187
|
+
});
|
|
112
188
|
}
|
|
113
189
|
|
|
114
190
|
/**
|
|
115
|
-
* <
|
|
116
|
-
*
|
|
191
|
+
* @type {Set<Promise<unknown>> | null} While the page's server-rendered prices are another market's: the loads to
|
|
192
|
+
* wait for before `html.tether-market-pending` goes. `null` when no market is pending.
|
|
117
193
|
*/
|
|
118
|
-
|
|
119
|
-
let marketPending = null; // product loads to wait for before html.tether-market-pending goes
|
|
194
|
+
let marketPending = null;
|
|
120
195
|
|
|
121
|
-
/**
|
|
122
|
-
|
|
196
|
+
/**
|
|
197
|
+
* Lists, and anything else that loads prices, hold `html.tether-market-pending` until they are in.
|
|
198
|
+
*
|
|
199
|
+
* @template T
|
|
200
|
+
* @param {Promise<T>} loading
|
|
201
|
+
* @returns {Promise<T>} `loading` itself.
|
|
202
|
+
*/
|
|
203
|
+
export const trackMarket = (loading) => {
|
|
204
|
+
marketPending?.add(loading);
|
|
205
|
+
return loading;
|
|
206
|
+
};
|
|
123
207
|
|
|
124
|
-
/** After a mount:
|
|
208
|
+
/** After a mount: drops `html.tether-market-pending` once this page's products are live. */
|
|
125
209
|
export function settleMarket() {
|
|
126
210
|
if (!marketPending) return;
|
|
127
211
|
const waits = [...marketPending];
|
|
128
|
-
Promise.allSettled(waits).then(() => {
|
|
129
|
-
if (!marketPending || waits.some((
|
|
130
|
-
if (waits.length
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
}
|
|
212
|
+
void Promise.allSettled(waits).then(() => {
|
|
213
|
+
if (!marketPending || waits.some((wait) => !marketPending?.has(wait))) return;
|
|
214
|
+
if (waits.length !== marketPending.size) {
|
|
215
|
+
settleMarket(); // more loads came in meanwhile
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
marketPending = null;
|
|
219
|
+
document.documentElement.classList.remove(`${cfg.prefix}-market-pending`);
|
|
134
220
|
});
|
|
135
221
|
}
|
|
136
222
|
|
|
223
|
+
/**
|
|
224
|
+
* What `tether/server`'s `hydrate()` writes. The tag is the site's own markup, so its shape is trusted; a product
|
|
225
|
+
* without an id or a handle is skipped.
|
|
226
|
+
*
|
|
227
|
+
* @typedef {{ country?: string | null, language?: string | null, products?: (Product | null)[] }} HydratePayload
|
|
228
|
+
*/
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* @param {Product | null} product
|
|
232
|
+
* @returns {product is Product}
|
|
233
|
+
*/
|
|
234
|
+
const isUsable = (product) => Boolean(product?.id && product.handle);
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* @param {string | null | undefined} a
|
|
238
|
+
* @param {string | null | undefined} b
|
|
239
|
+
*/
|
|
240
|
+
const sameCode = (a, b) => (a ?? '').toUpperCase() === (b ?? '').toUpperCase();
|
|
241
|
+
|
|
242
|
+
/** @type {WeakSet<Element>} Hydrate tags already read. */
|
|
243
|
+
const readScripts = new WeakSet();
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Uses the products in the page's hydrate tags (`tether/server` `hydrate()`) without a request. A tag made for another
|
|
247
|
+
* market is skipped, and marks the page (`html.tether-market-pending`) until the live prices are in, so CSS can hide
|
|
248
|
+
* prices meanwhile.
|
|
249
|
+
*
|
|
250
|
+
* @param {Document | Element} [root]
|
|
251
|
+
*/
|
|
137
252
|
export function hydrateFromPage(root = document) {
|
|
138
|
-
qsa(root.ownerDocument
|
|
139
|
-
if (readScripts.has(
|
|
140
|
-
readScripts.add(
|
|
253
|
+
qsa(root.ownerDocument ?? root, HYDRATE_SELECTOR).forEach((script) => {
|
|
254
|
+
if (readScripts.has(script)) return;
|
|
255
|
+
readScripts.add(script);
|
|
256
|
+
/** @type {unknown} */
|
|
257
|
+
let json;
|
|
141
258
|
try {
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
259
|
+
json = JSON.parse(script.textContent);
|
|
260
|
+
} catch (error) {
|
|
261
|
+
warn('bad data-tether-hydrate', error);
|
|
262
|
+
return;
|
|
263
|
+
}
|
|
264
|
+
const { country, language, products } = /** @type {HydratePayload | null} */ (json) ?? {};
|
|
265
|
+
const usable = Array.isArray(products) ? products.filter(isUsable) : [];
|
|
266
|
+
rememberOptionNames(usable); // the build's option names, also for other languages
|
|
267
|
+
if (sameCode(country, cfg.country) && sameCode(language, cfg.language)) {
|
|
268
|
+
hydrateProducts(usable);
|
|
269
|
+
return;
|
|
270
|
+
}
|
|
271
|
+
log('hydration skipped: other market');
|
|
272
|
+
if (!marketPending) {
|
|
273
|
+
marketPending = new Set();
|
|
274
|
+
document.documentElement.classList.add(`${cfg.prefix}-market-pending`);
|
|
275
|
+
}
|
|
153
276
|
});
|
|
154
277
|
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The market the page uses: a page built for one market (`<meta name="tether-page-country">`) wins, then the visitor's
|
|
3
|
+
* saved or detected market, then the configured one. Init applies it to `cfg` once. Astro's ClientRouter keeps the
|
|
4
|
+
* script running across pages, so each arrival asks again: the next page may be built for another market, or for none.
|
|
5
|
+
*/
|
|
6
|
+
import { cfg, log, reportError } from './config.js';
|
|
7
|
+
import { loadPart } from './lazy.js';
|
|
8
|
+
import { toCountry, toLanguage } from './market-codes.js';
|
|
9
|
+
import { readSavedMarket } from './markets.js';
|
|
10
|
+
/** @import { TetherOptions } from './config.js' */
|
|
11
|
+
/** @import { Market, DetectedMarket } from './markets.js' */
|
|
12
|
+
/** @import { SetCountryOptions } from './markets-ui.js' */
|
|
13
|
+
/** @import { ShopifyCart } from './cart/store.js' */
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* @typedef {object} ConfiguredMarket The market of the config, before a page's or visitor's market applied.
|
|
17
|
+
* @property {string | undefined} country
|
|
18
|
+
* @property {string | undefined} language
|
|
19
|
+
* @property {string | undefined} locale
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
/** @type {ConfiguredMarket} */
|
|
23
|
+
let configuredMarket = { country: undefined, language: undefined, locale: undefined };
|
|
24
|
+
|
|
25
|
+
/** Whether the page arrived at last was built for its own market, which the next page without one switches back from. */
|
|
26
|
+
let lastPageHadOwnMarket = false;
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* `<meta name="tether-page-country" content="DE">`, with `tether-page-language` and `tether-page-locale`. Never
|
|
30
|
+
* remembered: it belongs to the page, not to the visitor.
|
|
31
|
+
*
|
|
32
|
+
* @returns {Market | null} `null` when the page has no valid country of its own.
|
|
33
|
+
*/
|
|
34
|
+
function pageMarket() {
|
|
35
|
+
/** @param {string} name */
|
|
36
|
+
const meta = (name) =>
|
|
37
|
+
document.querySelector(`meta[name="${cfg.prefix}-page-${name}"]`)?.getAttribute('content') || undefined;
|
|
38
|
+
const country = toCountry(meta('country'));
|
|
39
|
+
if (!country) return null;
|
|
40
|
+
return { country, language: toLanguage(meta('language')) ?? undefined, locale: meta('locale') };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Without `markets.detect` only the visitor's saved choice applies, read without loading the markets part.
|
|
45
|
+
*
|
|
46
|
+
* @returns {Promise<Market | DetectedMarket | null>}
|
|
47
|
+
*/
|
|
48
|
+
async function visitorMarket() {
|
|
49
|
+
const markets = cfg.markets ?? {};
|
|
50
|
+
if (markets.detect === false) return null;
|
|
51
|
+
if (!markets.detect) return markets.remember === false ? null : readSavedMarket();
|
|
52
|
+
try {
|
|
53
|
+
return (await loadPart('markets')).detectMarket(markets);
|
|
54
|
+
} catch (error) {
|
|
55
|
+
reportError(error, 'markets');
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Sets `cfg`'s market for the first page, over the configured one.
|
|
62
|
+
*
|
|
63
|
+
* @param {TetherOptions} overrides What `init()` was called with: a `locale` there wins over the visitor's.
|
|
64
|
+
* @returns {Promise<void>}
|
|
65
|
+
*/
|
|
66
|
+
export async function applyStartMarket(overrides) {
|
|
67
|
+
configuredMarket = { country: cfg.country, language: cfg.language, locale: cfg.locale };
|
|
68
|
+
const page = pageMarket();
|
|
69
|
+
lastPageHadOwnMarket = page !== null;
|
|
70
|
+
if (page) {
|
|
71
|
+
cfg.country = page.country;
|
|
72
|
+
cfg.language = page.language;
|
|
73
|
+
if (page.locale) cfg.locale = page.locale;
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
const market = await visitorMarket();
|
|
77
|
+
if (!market) return;
|
|
78
|
+
if (market.country) cfg.country = market.country;
|
|
79
|
+
if (market.language) cfg.language = market.language;
|
|
80
|
+
if (market.locale && !overrides.locale) cfg.locale = market.locale;
|
|
81
|
+
log('market', market);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* @param {string | undefined} a
|
|
86
|
+
* @param {string | undefined} b
|
|
87
|
+
*/
|
|
88
|
+
const sameCode = (a, b) => (a ?? '').toUpperCase() === (b ?? '').toUpperCase();
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* The market to switch to on arriving at the current page. A page built for a market switches to it; the first page
|
|
92
|
+
* without one after such a page switches back to the visitor's own choice, else to the configured market.
|
|
93
|
+
*
|
|
94
|
+
* @returns {{ country: string, language: string | undefined, locale: string | undefined } | null} `null` when the page
|
|
95
|
+
* stays in the market in use.
|
|
96
|
+
*/
|
|
97
|
+
export function marketForArrival() {
|
|
98
|
+
const page = pageMarket();
|
|
99
|
+
const leftOwnMarket = page === null && lastPageHadOwnMarket;
|
|
100
|
+
lastPageHadOwnMarket = page !== null;
|
|
101
|
+
const target = page ?? (leftOwnMarket ? (readSavedMarket() ?? configuredMarket) : null);
|
|
102
|
+
if (!target?.country) return null;
|
|
103
|
+
if (sameCode(target.country, cfg.country) && sameCode(target.language, cfg.language)) return null;
|
|
104
|
+
return { country: target.country, language: target.language, locale: target.locale };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* `Tether.setCountry('DE')`: loads the markets part on first use.
|
|
109
|
+
*
|
|
110
|
+
* @param {string} code
|
|
111
|
+
* @param {SetCountryOptions} [options]
|
|
112
|
+
* @returns {Promise<ShopifyCart | null>} The cart once everything is in the new market.
|
|
113
|
+
*/
|
|
114
|
+
export function setCountry(code, options) {
|
|
115
|
+
return loadPart('markets').then((part) => part.setCountry(code, options));
|
|
116
|
+
}
|
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
// The <script> build's tether.account-pages.js: registers the part where the core script's loader (lazy-iife.js) finds it.
|
|
1
2
|
import * as part from '../account-pages.js';
|
|
2
3
|
|
|
3
|
-
|
|
4
|
+
const { Tether } = /** @type {Window & { Tether?: { __parts: Record<string, unknown> } }} */ (window);
|
|
5
|
+
if (!Tether) throw new Error('tether.account-pages.js runs after the Tether script, which loads it');
|
|
6
|
+
Tether.__parts['account-pages'] = part;
|
package/src/parts/account.js
CHANGED
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
// The <script> build's tether.account.js: registers the part where the core script's loader (lazy-iife.js) finds it.
|
|
1
2
|
import * as part from '../account.js';
|
|
2
3
|
|
|
3
|
-
|
|
4
|
+
const { Tether } = /** @type {Window & { Tether?: { __parts: Record<string, unknown> } }} */ (window);
|
|
5
|
+
if (!Tether) throw new Error('tether.account.js runs after the Tether script, which loads it');
|
|
6
|
+
Tether.__parts['account'] = part;
|
package/src/parts/analytics.js
CHANGED
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
// The <script> build's tether.analytics.js: registers the part where the core script's loader (lazy-iife.js) finds it.
|
|
1
2
|
import * as part from '../analytics.js';
|
|
2
3
|
|
|
3
|
-
|
|
4
|
+
const { Tether } = /** @type {Window & { Tether?: { __parts: Record<string, unknown> } }} */ (window);
|
|
5
|
+
if (!Tether) throw new Error('tether.analytics.js runs after the Tether script, which loads it');
|
|
6
|
+
Tether.__parts['analytics'] = part;
|
package/src/parts/diagnose.js
CHANGED
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
// The <script> build's tether.diagnose.js: registers the part where the core script's loader (lazy-iife.js) finds it.
|
|
1
2
|
import * as part from '../diagnose.js';
|
|
2
3
|
|
|
3
|
-
|
|
4
|
+
const { Tether } = /** @type {Window & { Tether?: { __parts: Record<string, unknown> } }} */ (window);
|
|
5
|
+
if (!Tether) throw new Error('tether.diagnose.js runs after the Tether script, which loads it');
|
|
6
|
+
Tether.__parts['diagnose'] = part;
|
package/src/parts/extras.js
CHANGED
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
// The <script> build's tether.extras.js: registers the part where the core script's loader (lazy-iife.js) finds it.
|
|
1
2
|
import * as part from '../extras.js';
|
|
2
3
|
|
|
3
|
-
|
|
4
|
+
const { Tether } = /** @type {Window & { Tether?: { __parts: Record<string, unknown> } }} */ (window);
|
|
5
|
+
if (!Tether) throw new Error('tether.extras.js runs after the Tether script, which loads it');
|
|
6
|
+
Tether.__parts['extras'] = part;
|
package/src/parts/markets.js
CHANGED
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
// The <script> build's tether.markets.js: registers the part where the core script's loader (lazy-iife.js) finds it.
|
|
1
2
|
import * as part from '../markets-ui.js';
|
|
2
3
|
|
|
3
|
-
|
|
4
|
+
const { Tether } = /** @type {Window & { Tether?: { __parts: Record<string, unknown> } }} */ (window);
|
|
5
|
+
if (!Tether) throw new Error('tether.markets.js runs after the Tether script, which loads it');
|
|
6
|
+
Tether.__parts['markets'] = part;
|
package/src/parts/search.js
CHANGED
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
// The <script> build's tether.search.js: registers the part where the core script's loader (lazy-iife.js) finds it.
|
|
1
2
|
import * as part from '../search.js';
|
|
2
3
|
|
|
3
|
-
|
|
4
|
+
const { Tether } = /** @type {Window & { Tether?: { __parts: Record<string, unknown> } }} */ (window);
|
|
5
|
+
if (!Tether) throw new Error('tether.search.js runs after the Tether script, which loads it');
|
|
6
|
+
Tether.__parts['search'] = part;
|