@occluze/tether 0.3.6 → 0.5.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 +12 -7
- package/dist/esm/astro.js +2 -0
- package/dist/esm/chunks/chunk-HHMOPDZG.js +1 -0
- package/dist/esm/chunks/chunk-HXKV6QGB.js +4 -0
- package/dist/esm/chunks/chunk-RBVRRQIU.js +1 -0
- package/dist/esm/chunks/chunk-RNZMTQTX.js +1 -0
- package/dist/esm/chunks/chunk-VC46IEJQ.js +1 -0
- package/dist/esm/chunks/diagnose-J667GHGS.js +1 -0
- package/dist/esm/chunks/markets-ui-EGWIEUOZ.js +1 -0
- package/dist/esm/index.js +1 -0
- package/dist/esm/server.js +1 -0
- package/dist/presets/barba.esm.js +4 -5
- package/dist/tether.diagnose.js +320 -0
- package/dist/tether.diagnose.min.js +1 -0
- package/dist/tether.esm.js +2 -2608
- package/dist/tether.js +637 -833
- package/dist/tether.markets.js +213 -0
- package/dist/tether.markets.min.js +1 -0
- package/dist/tether.min.js +3 -132
- package/dist/tether.min.js.map +4 -4
- package/package.json +52 -11
- package/src/astro.js +73 -0
- package/src/cart.js +79 -7
- package/src/client.js +25 -15
- package/src/config.js +4 -0
- package/src/diagnose.js +1 -1
- package/src/dom.js +32 -9
- package/src/fragments.js +12 -11
- package/src/index.js +80 -160
- package/src/initial.js +66 -0
- package/src/lazy-iife.js +43 -0
- package/src/lazy.js +9 -0
- package/src/loader.js +50 -7
- package/src/markets-ui.js +89 -0
- package/src/markets.js +1 -1
- package/src/mounting.js +129 -0
- package/src/parts/diagnose.js +3 -0
- package/src/parts/markets.js +3 -0
- package/src/product.js +86 -61
- package/src/server.js +179 -0
- package/src/status.js +2 -6
- package/types/astro.d.ts +15 -0
- package/types/barba.d.ts +16 -0
- package/types/index.d.ts +347 -0
- package/types/server.d.ts +94 -0
package/src/mounting.js
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/** Product scopes and lists that are mounted, shared by index.js and the optional markets part. */
|
|
2
|
+
import { cfg, log, warn, reportError } from './config.js';
|
|
3
|
+
import { ProductScope, scopes, rememberOptionNames } from './product.js';
|
|
4
|
+
import { listSelector } from './collection.js';
|
|
5
|
+
import { getProduct, takeStale, refreshProducts, hydrateProducts } from './loader.js';
|
|
6
|
+
import { resolveProductKey, setScopeStatus, isNotFound } from './status.js';
|
|
7
|
+
import { attr, get, qsa, emit } from './dom.js';
|
|
8
|
+
|
|
9
|
+
export const mountedProducts = new WeakSet();
|
|
10
|
+
export const mountedLists = new WeakSet();
|
|
11
|
+
// Live roots, for re-rendering after a country switch (pruned when disconnected).
|
|
12
|
+
export const liveProducts = new Set();
|
|
13
|
+
export const liveLists = new Set();
|
|
14
|
+
const requestToken = new WeakMap(); // product root -> latest request, so a slow old response can't win
|
|
15
|
+
|
|
16
|
+
export function pruneLive() {
|
|
17
|
+
liveProducts.forEach((el) => { if (!el.isConnected) liveProducts.delete(el); });
|
|
18
|
+
liveLists.forEach((l) => { if (!l.root.isConnected) liveLists.delete(l); });
|
|
19
|
+
}
|
|
20
|
+
export function within(root, selector) {
|
|
21
|
+
return [...(root.matches?.(selector) ? [root] : []), ...qsa(root, selector)];
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export /** Mounts one product root. Returns true when a product request was started. */
|
|
25
|
+
function mountProduct(el) {
|
|
26
|
+
if (mountedProducts.has(el)) return false;
|
|
27
|
+
const { key, fromUrl, raw } = resolveProductKey(el);
|
|
28
|
+
if (!key) {
|
|
29
|
+
if (fromUrl) {
|
|
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
|
+
}
|
|
38
|
+
return false;
|
|
39
|
+
}
|
|
40
|
+
mountedProducts.add(el);
|
|
41
|
+
liveProducts.add(el);
|
|
42
|
+
const token = {};
|
|
43
|
+
requestToken.set(el, token);
|
|
44
|
+
const current = () => requestToken.get(el) === token;
|
|
45
|
+
// A rendered scope keeps showing its content while it refreshes (e.g. country switch).
|
|
46
|
+
if (!scopes.get(el)) setScopeStatus(el, 'loading');
|
|
47
|
+
else el.setAttribute(attr('state'), 'updating');
|
|
48
|
+
getProduct(key)
|
|
49
|
+
.then((p) => {
|
|
50
|
+
if (!current()) return;
|
|
51
|
+
const existing = scopes.get(el);
|
|
52
|
+
if (existing) existing.update(p);
|
|
53
|
+
// eslint-disable-next-line no-new
|
|
54
|
+
else new ProductScope(el, p);
|
|
55
|
+
applyPageTitle(el, p);
|
|
56
|
+
if (cfg.revalidate !== false && takeStale(key)) queueRefresh(key);
|
|
57
|
+
})
|
|
58
|
+
.catch((err) => {
|
|
59
|
+
if (!current()) return;
|
|
60
|
+
const notFound = isNotFound(err);
|
|
61
|
+
setScopeStatus(el, notFound ? 'not-found' : 'error', err);
|
|
62
|
+
if (notFound) {
|
|
63
|
+
emit('product-not-found', { key, source: raw }, el);
|
|
64
|
+
// A typo in a URL isn't worth an alert; a CMS item pointing at a deleted product is.
|
|
65
|
+
if (fromUrl) log(`product not found: ${key}`); else reportError(err, `product ${key}`);
|
|
66
|
+
} else {
|
|
67
|
+
reportError(err, `product ${key}`);
|
|
68
|
+
emit('error', { error: err, context: 'product', key }, el);
|
|
69
|
+
}
|
|
70
|
+
});
|
|
71
|
+
return true;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** tether-page-title="{title} | My shop" on a product root sets document.title (dynamic product pages). */
|
|
75
|
+
function applyPageTitle(el, p) {
|
|
76
|
+
const tpl = get(el, 'page-title');
|
|
77
|
+
if (!tpl || typeof document === 'undefined') return;
|
|
78
|
+
document.title = tpl.replace(/\{title\}/g, p.title).replace(/\{vendor\}/g, p.vendor || '').replace(/\{type\}/g, p.productType || '');
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
/** Product scopes that failed on a network/API error: loaded again when the browser is back online. */
|
|
83
|
+
export function retryFailed() {
|
|
84
|
+
pruneLive();
|
|
85
|
+
liveProducts.forEach((el) => {
|
|
86
|
+
if (el.getAttribute(attr('state')) !== 'error' || el.getAttribute(attr('error')) === 'not-found') return;
|
|
87
|
+
mountedProducts.delete(el);
|
|
88
|
+
mountProduct(el);
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Server-rendered products: one batched background request refreshes them all.
|
|
93
|
+
let refreshQueue = new Set();
|
|
94
|
+
function queueRefresh(key) {
|
|
95
|
+
if (!refreshQueue.size) setTimeout(flushRefresh, 0);
|
|
96
|
+
refreshQueue.add(key);
|
|
97
|
+
}
|
|
98
|
+
function flushRefresh() {
|
|
99
|
+
const keys = [...refreshQueue];
|
|
100
|
+
refreshQueue = new Set();
|
|
101
|
+
refreshProducts(keys).forEach((promise, i) => promise.then((fresh) => {
|
|
102
|
+
liveProducts.forEach((el) => {
|
|
103
|
+
const scope = scopes.get(el);
|
|
104
|
+
if (!scope || !el.isConnected) return;
|
|
105
|
+
const k = resolveProductKey(el).key;
|
|
106
|
+
if (k !== keys[i] && k !== fresh.handle && k !== fresh.id) return;
|
|
107
|
+
if (JSON.stringify(scope.product) !== JSON.stringify(fresh)) scope.update(fresh);
|
|
108
|
+
});
|
|
109
|
+
}).catch((err) => reportError(err, `refresh ${keys[i]}`)));
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* <script type="application/json" data-tether-hydrate> tags written by tether/server's hydrate():
|
|
114
|
+
* their products are used without a request. Ignored when they were made for another market.
|
|
115
|
+
*/
|
|
116
|
+
const readScripts = new WeakSet();
|
|
117
|
+
export function hydrateFromPage(root = document) {
|
|
118
|
+
qsa(root.ownerDocument || root, 'script[type="application/json"][data-tether-hydrate]').forEach((s) => {
|
|
119
|
+
if (readScripts.has(s)) return;
|
|
120
|
+
readScripts.add(s);
|
|
121
|
+
try {
|
|
122
|
+
const data = JSON.parse(s.textContent);
|
|
123
|
+
rememberOptionNames(data.products); // the build's option names, also for other languages
|
|
124
|
+
const same = (a, b) => String(a || '').toUpperCase() === String(b || '').toUpperCase();
|
|
125
|
+
if (same(data.country, cfg.country) && same(data.language, cfg.language)) hydrateProducts(data.products);
|
|
126
|
+
else log('hydration skipped: other market');
|
|
127
|
+
} catch (err) { warn('bad data-tether-hydrate', err); }
|
|
128
|
+
});
|
|
129
|
+
}
|
package/src/product.js
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
import { cfg, reportError } from './config.js';
|
|
6
6
|
import {
|
|
7
7
|
attr, sel, get, has, qsa, scoped, emit, formatMoney, formatDate, setText, setImage, show,
|
|
8
|
-
setSelected, repeat, productUrl, notifyLayoutChanged, watchImages, busy, parseJSONAttr, toAttributes,
|
|
8
|
+
setSelected, repeat, productUrl, notifyLayoutChanged, watchImages, busy, parseJSONAttr, toAttributes, sameImage, hasTemplate,
|
|
9
9
|
} from './dom.js';
|
|
10
10
|
import { Cart } from './cart.js';
|
|
11
11
|
import { STATUS_KEYS, ownedBy } from './status.js';
|
|
@@ -25,6 +25,13 @@ function metafieldValue(product, ref) {
|
|
|
25
25
|
return mf || null;
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
+
// Option names as written in the markup (the build's language), by product handle. With another
|
|
29
|
+
// language Shopify translates them (Size -> Größe), so attributes are matched by position instead.
|
|
30
|
+
const knownOptionNames = new Map();
|
|
31
|
+
export function rememberOptionNames(products) {
|
|
32
|
+
(products || []).forEach((p) => { if (p?.handle && !knownOptionNames.has(p.handle)) knownOptionNames.set(p.handle, (p.options || []).map((o) => o.name)); });
|
|
33
|
+
}
|
|
34
|
+
|
|
28
35
|
export class ProductScope {
|
|
29
36
|
constructor(root, product) {
|
|
30
37
|
this.root = root;
|
|
@@ -76,6 +83,14 @@ export class ProductScope {
|
|
|
76
83
|
|
|
77
84
|
// ------------------------------------------------------------ state
|
|
78
85
|
|
|
86
|
+
/** The product's option name for a name used in the markup (same name, or same position). */
|
|
87
|
+
optName(authored) {
|
|
88
|
+
const opts = this.product?.options || [];
|
|
89
|
+
if (!authored || opts.some((o) => o.name === authored)) return authored;
|
|
90
|
+
const i = (knownOptionNames.get(this.product.handle) || []).indexOf(authored);
|
|
91
|
+
return i >= 0 && opts[i] ? opts[i].name : authored;
|
|
92
|
+
}
|
|
93
|
+
|
|
79
94
|
initSelection() {
|
|
80
95
|
const vs = this.product.variants.nodes;
|
|
81
96
|
const first = vs.find((v) => v.availableForSale) || vs[0];
|
|
@@ -208,7 +223,7 @@ export class ProductScope {
|
|
|
208
223
|
const opt = t.closest(sel('option-value'));
|
|
209
224
|
if (own(opt) && opt.closest(sel('option-list'))) {
|
|
210
225
|
e.preventDefault();
|
|
211
|
-
this.select(get(opt.closest(sel('option-list')), 'option-list'), get(opt, 'option-value'));
|
|
226
|
+
this.select(this.optName(get(opt.closest(sel('option-list')), 'option-list')), get(opt, 'option-value'));
|
|
212
227
|
return;
|
|
213
228
|
}
|
|
214
229
|
|
|
@@ -234,7 +249,7 @@ export class ProductScope {
|
|
|
234
249
|
this.root.addEventListener('change', (e) => {
|
|
235
250
|
const t = e.target;
|
|
236
251
|
if (!own(t)) return;
|
|
237
|
-
if (t.matches(`select${sel('option')}`)) this.select(get(t, 'option'), t.value);
|
|
252
|
+
if (t.matches(`select${sel('option')}`)) this.select(this.optName(get(t, 'option')), t.value);
|
|
238
253
|
else if (t.matches(`select${sel('variant-select')}`)) this.selectVariant(t.value);
|
|
239
254
|
else if (t.matches(`select${sel('plan')}`)) {
|
|
240
255
|
if (t.value) this.selectPlan(t.value); else this.setPurchase('one-time');
|
|
@@ -266,17 +281,25 @@ export class ProductScope {
|
|
|
266
281
|
// ------------------------------------------------------------ render
|
|
267
282
|
|
|
268
283
|
renderProduct() {
|
|
284
|
+
const p0 = this.product;
|
|
285
|
+
if (!knownOptionNames.has(p0.handle)) {
|
|
286
|
+
const authored = this.$(`${sel('option-list')},select${sel('option')}`).map((e) => get(e, 'option-list') || get(e, 'option'));
|
|
287
|
+
if (authored.some((n) => p0.options.some((o) => o.name === n))) rememberOptionNames([p0]);
|
|
288
|
+
}
|
|
269
289
|
const p = this.product;
|
|
270
290
|
|
|
271
291
|
this.$(sel('bind')).forEach((el) => {
|
|
272
|
-
const key = get(el, 'bind');
|
|
292
|
+
const key = get(el, 'bind') || '';
|
|
293
|
+
const txt = {
|
|
294
|
+
title: p.title,
|
|
295
|
+
'description-text': p.description,
|
|
296
|
+
vendor: p.vendor,
|
|
297
|
+
type: p.productType,
|
|
298
|
+
handle: p.handle,
|
|
299
|
+
}[key];
|
|
300
|
+
if (txt !== undefined) return setText(el, txt);
|
|
273
301
|
switch (key) {
|
|
274
|
-
case 'title': return setText(el, p.title);
|
|
275
302
|
case 'description': el.innerHTML = p.descriptionHtml || ''; return undefined;
|
|
276
|
-
case 'description-text': return setText(el, p.description);
|
|
277
|
-
case 'vendor': return setText(el, p.vendor);
|
|
278
|
-
case 'type': return setText(el, p.productType);
|
|
279
|
-
case 'handle': return setText(el, p.handle);
|
|
280
303
|
case 'published-at': return setText(el, formatDate(p.publishedAt));
|
|
281
304
|
case 'min-price': return setText(el, formatMoney(p.priceRange.minVariantPrice));
|
|
282
305
|
case 'max-price': return setText(el, formatMoney(p.priceRange.maxVariantPrice));
|
|
@@ -286,7 +309,11 @@ export class ProductScope {
|
|
|
286
309
|
el.setAttribute('href', productUrl(p.handle, p.id));
|
|
287
310
|
if (key === 'url' && !el.firstElementChild && !el.textContent.trim()) setText(el, p.title);
|
|
288
311
|
return undefined;
|
|
289
|
-
case 'images':
|
|
312
|
+
case 'images': {
|
|
313
|
+
// Server-rendered gallery with the same images: keep it as it is.
|
|
314
|
+
const imgs = [...el.children].map((n) => (n.tagName === 'IMG' ? n : n.querySelector('img')));
|
|
315
|
+
if (!hasTemplate(el) && imgs.length === p.images.nodes.length && imgs.every((im, i) => sameImage(im?.getAttribute('src'), p.images.nodes[i].url))) return undefined;
|
|
316
|
+
}
|
|
290
317
|
return repeat(el, p.images.nodes, (node, img) => {
|
|
291
318
|
const target = node.matches(`img,${sel('bind', 'image')}`) ? node : node.querySelector('img') || node;
|
|
292
319
|
setImage(target, img.url, img.altText || p.title);
|
|
@@ -297,17 +324,15 @@ export class ProductScope {
|
|
|
297
324
|
}
|
|
298
325
|
default:
|
|
299
326
|
// image:2 = the second product image (falls back to the featured image)
|
|
300
|
-
if (
|
|
301
|
-
const img = p.images.nodes[
|
|
327
|
+
if (/^image:\d+$/.test(key)) {
|
|
328
|
+
const img = p.images.nodes[key.slice(6) - 1] || p.featuredImage;
|
|
302
329
|
return setImage(el, img?.url, img?.altText || p.title);
|
|
303
330
|
}
|
|
304
|
-
if (key
|
|
305
|
-
const mf = metafieldValue(p, key.slice(
|
|
331
|
+
if (key.startsWith('metafield:')) {
|
|
332
|
+
const mf = metafieldValue(p, key.slice(10));
|
|
306
333
|
if (!mf) return setText(el, '');
|
|
307
334
|
if (mf.reference?.image) return setImage(el, mf.reference.image.url, mf.reference.image.altText);
|
|
308
|
-
if (mf.type
|
|
309
|
-
el.textContent = mf.value; el.style.whiteSpace = 'pre-line'; return undefined;
|
|
310
|
-
}
|
|
335
|
+
if (/^(rich_text|multi_line_text)_field$/.test(mf.type)) el.style.whiteSpace = 'pre-line';
|
|
311
336
|
return setText(el, mf.value);
|
|
312
337
|
}
|
|
313
338
|
return undefined;
|
|
@@ -315,7 +340,7 @@ export class ProductScope {
|
|
|
315
340
|
});
|
|
316
341
|
|
|
317
342
|
this.$(sel('option-list')).forEach((container) => {
|
|
318
|
-
const name = get(container, 'option-list');
|
|
343
|
+
const name = this.optName(get(container, 'option-list'));
|
|
319
344
|
const opt = p.options.find((o) => o.name === name);
|
|
320
345
|
if (!opt || (opt.optionValues.length <= 1 && has(container, 'hide-single'))) return show(container, false);
|
|
321
346
|
return repeat(container, opt.optionValues, (node, v) => {
|
|
@@ -329,7 +354,7 @@ export class ProductScope {
|
|
|
329
354
|
});
|
|
330
355
|
|
|
331
356
|
this.$(`select${sel('option')}`).forEach((s) => {
|
|
332
|
-
const opt = p.options.find((o) => o.name === get(s, 'option'));
|
|
357
|
+
const opt = p.options.find((o) => o.name === this.optName(get(s, 'option')));
|
|
333
358
|
if (!opt) return show(s, false);
|
|
334
359
|
s.innerHTML = '';
|
|
335
360
|
opt.optionValues.forEach((v) => s.appendChild(new Option(v.name, v.name)));
|
|
@@ -393,41 +418,41 @@ export class ProductScope {
|
|
|
393
418
|
const plan = this.plan;
|
|
394
419
|
const total = { amount: (parseFloat(price.amount) * Math.max(1, this.quantity)).toFixed(2), currencyCode: price.currencyCode };
|
|
395
420
|
|
|
421
|
+
const fm = formatMoney;
|
|
422
|
+
const pct = (n) => (n ? `${n}%` : '');
|
|
423
|
+
// Text binds: name -> value (functions, so only the binds on the page are computed).
|
|
424
|
+
const text = {
|
|
425
|
+
price: () => fm(price),
|
|
426
|
+
'total-price': () => fm(total),
|
|
427
|
+
'one-time-price': () => fm(oneTimePrice),
|
|
428
|
+
'subscription-price': () => fm(subPrice),
|
|
429
|
+
'subscription-savings': () => pct(savings.percentage),
|
|
430
|
+
'subscription-savings-amount': () => fm(savings.amount),
|
|
431
|
+
'checkout-charge': () => fm(sub ? planCheckoutCharge(v, this.planId) : price),
|
|
432
|
+
'plan-name': () => plan?.name,
|
|
433
|
+
'plan-description': () => plan?.description,
|
|
434
|
+
'plan-group': () => plan?.group?.name,
|
|
435
|
+
'compare-at-price': () => (onSale ? fm(compareAt) : ''),
|
|
436
|
+
'unit-price': () => fm(v.unitPrice),
|
|
437
|
+
'discount-percentage': () => `${discountPct}%`,
|
|
438
|
+
'discount-amount': () => (onSale ? fm({ amount: String(parseFloat(compareAt.amount) - parseFloat(price.amount)), currencyCode: price.currencyCode }) : ''),
|
|
439
|
+
'variant-title': () => v.title,
|
|
440
|
+
'variant-id': () => v.id,
|
|
441
|
+
sku: () => v.sku,
|
|
442
|
+
barcode: () => v.barcode,
|
|
443
|
+
stock: () => v.quantityAvailable,
|
|
444
|
+
quantity: () => this.quantity,
|
|
445
|
+
};
|
|
396
446
|
this.$(sel('bind')).forEach((el) => {
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
case 'one-time-price': return setText(el, formatMoney(oneTimePrice));
|
|
401
|
-
case 'subscription-price': return setText(el, formatMoney(subPrice));
|
|
402
|
-
case 'subscription-savings': return setText(el, savings.percentage ? `${savings.percentage}%` : '');
|
|
403
|
-
case 'subscription-savings-amount': return setText(el, formatMoney(savings.amount));
|
|
404
|
-
case 'checkout-charge': return setText(el, formatMoney(sub ? planCheckoutCharge(v, this.planId) : price));
|
|
405
|
-
case 'plan-name': return setText(el, plan?.name || '');
|
|
406
|
-
case 'plan-description': return setText(el, plan?.description || '');
|
|
407
|
-
case 'plan-group': return setText(el, plan?.group?.name || '');
|
|
408
|
-
case 'compare-at-price': return setText(el, onSale ? formatMoney(compareAt) : '');
|
|
409
|
-
case 'unit-price': return setText(el, formatMoney(v.unitPrice));
|
|
410
|
-
case 'discount-percentage': return setText(el, `${discountPct}%`);
|
|
411
|
-
case 'discount-amount':
|
|
412
|
-
return setText(el, onSale ? formatMoney({
|
|
413
|
-
amount: String(parseFloat(compareAt.amount) - parseFloat(price.amount)),
|
|
414
|
-
currencyCode: price.currencyCode,
|
|
415
|
-
}) : '');
|
|
416
|
-
case 'variant-title': return setText(el, v.title);
|
|
417
|
-
case 'variant-id': return setText(el, v.id);
|
|
418
|
-
case 'sku': return setText(el, v.sku);
|
|
419
|
-
case 'barcode': return setText(el, v.barcode);
|
|
420
|
-
case 'stock': return setText(el, v.quantityAvailable ?? '');
|
|
421
|
-
case 'quantity': return setText(el, this.quantity);
|
|
422
|
-
case 'variant-image': return setImage(el, v.image?.url || p.featuredImage?.url, v.image?.altText || p.title);
|
|
423
|
-
default: return undefined;
|
|
424
|
-
}
|
|
447
|
+
const k = get(el, 'bind');
|
|
448
|
+
if (k === 'variant-image') setImage(el, v.image?.url || p.featuredImage?.url, v.image?.altText || p.title);
|
|
449
|
+
else if (text[k]) setText(el, text[k]());
|
|
425
450
|
});
|
|
426
451
|
|
|
427
|
-
this.$(sel('option-selected')).forEach((el) => setText(el, this.selected[get(el, 'option-selected')] || ''));
|
|
452
|
+
this.$(sel('option-selected')).forEach((el) => setText(el, this.selected[this.optName(get(el, 'option-selected'))] || ''));
|
|
428
453
|
|
|
429
454
|
this.$(sel('option-list')).forEach((container) => {
|
|
430
|
-
const name = get(container, 'option-list');
|
|
455
|
+
const name = this.optName(get(container, 'option-list'));
|
|
431
456
|
qsa(container, sel('option-value')).forEach((node) => {
|
|
432
457
|
const value = get(node, 'option-value');
|
|
433
458
|
setSelected(node, this.selected[name] === value);
|
|
@@ -437,7 +462,7 @@ export class ProductScope {
|
|
|
437
462
|
});
|
|
438
463
|
});
|
|
439
464
|
|
|
440
|
-
this.$(`select${sel('option')}`).forEach((s) => { s.value = this.selected[get(s, 'option')] || ''; });
|
|
465
|
+
this.$(`select${sel('option')}`).forEach((s) => { s.value = this.selected[this.optName(get(s, 'option'))] || ''; });
|
|
441
466
|
this.$(`select${sel('variant-select')}`).forEach((s) => { s.value = v.id; });
|
|
442
467
|
|
|
443
468
|
// Subscription UI
|
|
@@ -458,17 +483,16 @@ export class ProductScope {
|
|
|
458
483
|
qsa(node, sel('plan-bind')).forEach((el) => {
|
|
459
484
|
const adj = planAdjustment(planObj);
|
|
460
485
|
const s = planSavings(v, id);
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
}
|
|
486
|
+
const k = get(el, 'plan-bind');
|
|
487
|
+
const val = {
|
|
488
|
+
name: () => planObj?.name,
|
|
489
|
+
description: () => planObj?.description,
|
|
490
|
+
price: () => fm(planPrice(v, id)),
|
|
491
|
+
savings: () => pct(s.percentage),
|
|
492
|
+
'savings-amount': () => fm(s.amount),
|
|
493
|
+
adjustment: () => (!adj ? '' : adj.type === 'percentage' ? `${adj.value}%` : fm(adj.value)),
|
|
494
|
+
}[k];
|
|
495
|
+
if (val) setText(el, val());
|
|
472
496
|
});
|
|
473
497
|
});
|
|
474
498
|
|
|
@@ -510,6 +534,7 @@ export class ProductScope {
|
|
|
510
534
|
this.$(sel('add-to-cart')).forEach((btn) => {
|
|
511
535
|
const disabled = !v.availableForSale;
|
|
512
536
|
btn.classList.toggle('is-disabled', disabled);
|
|
537
|
+
btn.setAttribute('aria-disabled', String(disabled));
|
|
513
538
|
if ('disabled' in btn) btn.disabled = disabled;
|
|
514
539
|
const soldOut = get(btn, 'soldout-text');
|
|
515
540
|
const subText = get(btn, 'subscribe-text');
|
package/src/server.js
ADDED
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @occluze/tether/server: Shopify product data at build time or on the server (Astro, Eleventy,
|
|
3
|
+
* Node, Cloudflare Workers). No browser APIs. Uses the same queries as the browser script, so
|
|
4
|
+
* products handed over with hydrate() are exactly what Tether expects and need no extra request.
|
|
5
|
+
*
|
|
6
|
+
* import { createStorefront } from '@occluze/tether/server';
|
|
7
|
+
* const shop = createStorefront({ domain, token, country: 'NL' });
|
|
8
|
+
* const product = await shop.getProduct('coffee-box');
|
|
9
|
+
* const all = await shop.allProducts();
|
|
10
|
+
*/
|
|
11
|
+
import { DEFAULTS } from './config.js';
|
|
12
|
+
import { request, withContext, TetherError } from './client.js';
|
|
13
|
+
import { productFragment, collectionFields } from './fragments.js';
|
|
14
|
+
import * as plans from './selling-plans.js';
|
|
15
|
+
import { imageUrl } from './dom.js';
|
|
16
|
+
import { initialState } from './initial.js';
|
|
17
|
+
|
|
18
|
+
export { imageUrl, initialState };
|
|
19
|
+
|
|
20
|
+
export { TetherError, plans, withContext };
|
|
21
|
+
|
|
22
|
+
const isGid = (v) => typeof v === 'string' && v.startsWith('gid://');
|
|
23
|
+
|
|
24
|
+
/** Handle, numeric id, gid or product URL -> handle or gid. */
|
|
25
|
+
export function productKey(raw) {
|
|
26
|
+
const v = String(raw ?? '').trim();
|
|
27
|
+
if (!v) return null;
|
|
28
|
+
if (isGid(v)) return v;
|
|
29
|
+
if (/^\d+$/.test(v)) return `gid://shopify/Product/${v}`;
|
|
30
|
+
const m = v.match(/\/products\/([^/?#]+)/);
|
|
31
|
+
return m ? m[1] : v;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Money as text, e.g. formatMoney({ amount: '30.0', currencyCode: 'EUR' }, 'nl-NL') -> "€ 30,00". */
|
|
35
|
+
export function formatMoney(money, locale) {
|
|
36
|
+
if (!money) return '';
|
|
37
|
+
const amount = parseFloat(money.amount);
|
|
38
|
+
if (Number.isNaN(amount)) return '';
|
|
39
|
+
try {
|
|
40
|
+
return new Intl.NumberFormat(locale || undefined, { style: 'currency', currency: money.currencyCode }).format(amount);
|
|
41
|
+
} catch {
|
|
42
|
+
return `${money.currencyCode} ${amount.toFixed(2)}`;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** productPath '/products' -> /products/<handle>; templates '/p?handle={handle}' and '{id}' work too. */
|
|
47
|
+
export function productUrl(handle, { productPath = '/product', id } = {}) {
|
|
48
|
+
if (!/\{(handle|id)\}/.test(productPath)) return `${productPath.replace(/\/$/, '')}/${handle}`;
|
|
49
|
+
return productPath.replace(/\{handle\}/g, encodeURIComponent(handle || '')).replace(/\{id\}/g, encodeURIComponent(id ? String(id).split('/').pop() : ''));
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** JSON for a <script> tag: escapes < so product text can never close the tag. */
|
|
53
|
+
const safeJson = (v) => JSON.stringify(v).replace(/</g, '\\u003c').replace(/\u2028/g, '\\u2028').replace(/\u2029/g, '\\u2029');
|
|
54
|
+
|
|
55
|
+
// createStorefront(import.meta.env) works too: these env names map to domain and token.
|
|
56
|
+
const ENV = {
|
|
57
|
+
domain: ['PUBLIC_SHOPIFY_DOMAIN', 'PUBLIC_SHOPIFY_STORE_DOMAIN', 'SHOPIFY_DOMAIN'],
|
|
58
|
+
token: ['PUBLIC_SHOPIFY_STOREFRONT_TOKEN', 'PUBLIC_SHOPIFY_STOREFRONT_ACCESS_TOKEN', 'SHOPIFY_STOREFRONT_TOKEN'],
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
export function createStorefront(options = {}) {
|
|
62
|
+
const known = Object.fromEntries(Object.entries(options).filter(([k]) => k in DEFAULTS || k === 'fetch' || k === 'pageSize'));
|
|
63
|
+
const conf = { ...DEFAULTS, ...known };
|
|
64
|
+
for (const [key, names] of Object.entries(ENV)) if (!conf[key]) conf[key] = names.map((n) => options[n]).find(Boolean) || '';
|
|
65
|
+
conf.domain = String(conf.domain || '').replace(/^https?:\/\//, '').replace(/\/.*$/, '');
|
|
66
|
+
if (!conf.domain || !conf.token) {
|
|
67
|
+
throw new TetherError('createStorefront needs domain and token: pass them, or import.meta.env with PUBLIC_SHOPIFY_DOMAIN and PUBLIC_SHOPIFY_STOREFRONT_TOKEN', { code: 'NOT_CONFIGURED' });
|
|
68
|
+
}
|
|
69
|
+
const pageSize = options.pageSize || 25;
|
|
70
|
+
const q = (query, variables, opts) => request(conf, query, variables, opts);
|
|
71
|
+
const fragment = () => productFragment(conf);
|
|
72
|
+
|
|
73
|
+
async function getProduct(raw) {
|
|
74
|
+
const key = productKey(raw);
|
|
75
|
+
if (!key) return null;
|
|
76
|
+
const data = isGid(key)
|
|
77
|
+
? await q(`query TetherServerProductById($id: ID!) { node(id: $id) { ... on Product { ...TetherProduct } } }\n${fragment()}`, { id: key })
|
|
78
|
+
: await q(`query TetherServerProduct($handle: String!) { product(handle: $handle) { ...TetherProduct } }\n${fragment()}`, { handle: key });
|
|
79
|
+
const p = isGid(key) ? data.node : data.product;
|
|
80
|
+
return p && p.id ? p : null;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async function getProducts({ collection, query, first = pageSize, after = null, sortKey, reverse = false, filters } = {}) {
|
|
84
|
+
if (collection) {
|
|
85
|
+
const data = await q(`query TetherServerCollection($handle: String!, $first: Int!, $after: String, $sortKey: ProductCollectionSortKeys, $reverse: Boolean, $filters: [ProductFilter!]) {
|
|
86
|
+
collection(handle: $handle) {
|
|
87
|
+
${collectionFields()}
|
|
88
|
+
products(first: $first, after: $after, sortKey: $sortKey, reverse: $reverse, filters: $filters) {
|
|
89
|
+
nodes { ...TetherProduct }
|
|
90
|
+
pageInfo { hasNextPage endCursor }
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
${fragment()}`, { handle: collection, first, after, sortKey: sortKey || undefined, reverse, filters: filters || undefined });
|
|
95
|
+
if (!data.collection) return { collection: null, products: [], pageInfo: { hasNextPage: false, endCursor: null } };
|
|
96
|
+
const { products, ...rest } = data.collection;
|
|
97
|
+
return { collection: rest, products: products.nodes, pageInfo: products.pageInfo };
|
|
98
|
+
}
|
|
99
|
+
const data = await q(`query TetherServerProducts($first: Int!, $after: String, $query: String, $sortKey: ProductSortKeys, $reverse: Boolean) {
|
|
100
|
+
products(first: $first, after: $after, query: $query, sortKey: $sortKey, reverse: $reverse) {
|
|
101
|
+
nodes { ...TetherProduct }
|
|
102
|
+
pageInfo { hasNextPage endCursor }
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
${fragment()}`, { first, after, query: query || undefined, sortKey: sortKey || undefined, reverse });
|
|
106
|
+
return { collection: null, products: data.products.nodes, pageInfo: data.products.pageInfo };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Every product (of a collection or a search), page by page. For getStaticPaths(). */
|
|
110
|
+
async function allProducts(opts = {}) {
|
|
111
|
+
const out = [];
|
|
112
|
+
let after = null;
|
|
113
|
+
for (let i = 0; i < 500; i++) {
|
|
114
|
+
const page = await getProducts({ ...opts, first: pageSize, after });
|
|
115
|
+
out.push(...page.products);
|
|
116
|
+
if (!page.pageInfo.hasNextPage) break;
|
|
117
|
+
after = page.pageInfo.endCursor;
|
|
118
|
+
}
|
|
119
|
+
return out;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
async function getCollection(handle, opts = {}) {
|
|
123
|
+
const page = await getProducts({ ...opts, collection: handle });
|
|
124
|
+
return page.collection ? { ...page.collection, products: page.products, pageInfo: page.pageInfo } : null;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
async function allCollections() {
|
|
128
|
+
const out = [];
|
|
129
|
+
let after = null;
|
|
130
|
+
for (let i = 0; i < 100; i++) {
|
|
131
|
+
const data = await q(`query TetherServerCollections($first: Int!, $after: String) {
|
|
132
|
+
collections(first: $first, after: $after) { nodes { ${collectionFields()} } pageInfo { hasNextPage endCursor } }
|
|
133
|
+
}`, { first: 100, after });
|
|
134
|
+
out.push(...data.collections.nodes);
|
|
135
|
+
if (!data.collections.pageInfo.hasNextPage) break;
|
|
136
|
+
after = data.collections.pageInfo.endCursor;
|
|
137
|
+
}
|
|
138
|
+
return out;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** By product id (gid or numeric) or handle, so it can run next to getProduct(handle). */
|
|
142
|
+
async function getRecommendations(product, intent = 'RELATED') {
|
|
143
|
+
const key = productKey(typeof product === 'object' ? product?.id : product);
|
|
144
|
+
const byId = key?.startsWith('gid://');
|
|
145
|
+
const data = await q(`query TetherServerRecs(${byId ? '$id: ID!' : '$handle: String!'}, $intent: ProductRecommendationIntent) {
|
|
146
|
+
productRecommendations(${byId ? 'productId: $id' : 'productHandle: $handle'}, intent: $intent) { ...TetherProduct }
|
|
147
|
+
}
|
|
148
|
+
${fragment()}`, byId ? { id: key, intent } : { handle: key, intent });
|
|
149
|
+
return data.productRecommendations || [];
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* A <script> tag that hands products to the browser script: Tether renders them at once,
|
|
154
|
+
* without its own request, then refreshes prices and stock in the background.
|
|
155
|
+
* Put it anywhere in the page (Astro: <Fragment set:html={shop.hydrate(products)} />).
|
|
156
|
+
*/
|
|
157
|
+
function hydrate(products) {
|
|
158
|
+
const list = (Array.isArray(products) ? products : [products]).filter(Boolean);
|
|
159
|
+
const payload = { v: 1, at: new Date().toISOString(), country: conf.country || null, language: conf.language || null, products: list };
|
|
160
|
+
return `<script type="application/json" data-tether-hydrate>${safeJson(payload)}</script>`;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
return {
|
|
164
|
+
config: conf,
|
|
165
|
+
query: q,
|
|
166
|
+
getProduct,
|
|
167
|
+
getProducts,
|
|
168
|
+
allProducts,
|
|
169
|
+
getCollection,
|
|
170
|
+
allCollections,
|
|
171
|
+
getRecommendations,
|
|
172
|
+
hydrate,
|
|
173
|
+
formatMoney: (money, locale = conf.locale) => formatMoney(money, locale),
|
|
174
|
+
imageUrl,
|
|
175
|
+
initialState: (product, opts = {}) => initialState(product, { defaultPurchase: conf.defaultPurchase, ...opts }),
|
|
176
|
+
productUrl: (handle, opts = {}) => productUrl(handle, { productPath: conf.productPath, ...opts }),
|
|
177
|
+
plans,
|
|
178
|
+
};
|
|
179
|
+
}
|
package/src/status.js
CHANGED
|
@@ -53,17 +53,13 @@ export function setScopeStatus(root, status, err) {
|
|
|
53
53
|
scoped(root, sel('show'), boundary()).forEach((el) => {
|
|
54
54
|
const k = get(el, 'show');
|
|
55
55
|
if (!STATUS_KEYS.includes(k)) return;
|
|
56
|
-
|
|
57
|
-
show(el, visible);
|
|
58
|
-
// Blocks hidden by a Webflow class (display: none) still need to appear.
|
|
59
|
-
if (visible && typeof getComputedStyle === 'function' && getComputedStyle(el).display === 'none') {
|
|
60
|
-
el.style.display = el.getAttribute(attr('display')) || 'block';
|
|
61
|
-
}
|
|
56
|
+
show(el, k === status);
|
|
62
57
|
});
|
|
63
58
|
|
|
64
59
|
if (status === 'error' || notFound) {
|
|
65
60
|
scoped(root, sel('add-to-cart'), boundary()).forEach((btn) => {
|
|
66
61
|
btn.classList.add('is-disabled');
|
|
62
|
+
btn.setAttribute('aria-disabled', 'true');
|
|
67
63
|
if ('disabled' in btn) btn.disabled = true;
|
|
68
64
|
});
|
|
69
65
|
}
|
package/types/astro.d.ts
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { TetherConfig } from './index.js';
|
|
2
|
+
|
|
3
|
+
type Serializable<T> = { [K in keyof T as T[K] extends ((...args: any[]) => any) | null | undefined ? (Exclude<T[K], null | undefined> extends (...args: any[]) => any ? never : K) : K]: T[K] };
|
|
4
|
+
|
|
5
|
+
export interface TetherAstroOptions extends Partial<Serializable<TetherConfig>> {
|
|
6
|
+
/** Add the Tether script to every page (default true). False: import and init it yourself. */
|
|
7
|
+
inject?: boolean;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Astro integration: Tether on every page, store and token from .env
|
|
12
|
+
* (PUBLIC_SHOPIFY_DOMAIN, PUBLIC_SHOPIFY_STOREFRONT_TOKEN). Functions (onError, hooks) go in
|
|
13
|
+
* window.TETHER_CONFIG in your layout.
|
|
14
|
+
*/
|
|
15
|
+
export default function tether(options?: TetherAstroOptions): { name: string; hooks: Record<string, (...args: any[]) => unknown> };
|
package/types/barba.d.ts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { TetherConfig } from './index.js';
|
|
2
|
+
|
|
3
|
+
export interface BarbaPresetOptions {
|
|
4
|
+
gsap?: () => unknown;
|
|
5
|
+
lenis?: (() => { resize?(): void; stop?(): void; start?(): void } | null | undefined) | null;
|
|
6
|
+
scrollTrigger?: () => unknown;
|
|
7
|
+
lockScroll?: ((drawer: HTMLElement) => void) | null;
|
|
8
|
+
unlockScroll?: (() => void) | null;
|
|
9
|
+
onResize?: (() => void) | null;
|
|
10
|
+
onValueChange?: ((el: HTMLElement, previousText: string) => void) | null;
|
|
11
|
+
animateLines?: boolean;
|
|
12
|
+
lineDuration?: number;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** Hooks for sites with page transitions, smooth scroll and GSAP: spread into Tether.init(). */
|
|
16
|
+
export declare function barbaPreset(options?: BarbaPresetOptions): Partial<TetherConfig>;
|