@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.
Files changed (45) hide show
  1. package/README.md +12 -7
  2. package/dist/esm/astro.js +2 -0
  3. package/dist/esm/chunks/chunk-HHMOPDZG.js +1 -0
  4. package/dist/esm/chunks/chunk-HXKV6QGB.js +4 -0
  5. package/dist/esm/chunks/chunk-RBVRRQIU.js +1 -0
  6. package/dist/esm/chunks/chunk-RNZMTQTX.js +1 -0
  7. package/dist/esm/chunks/chunk-VC46IEJQ.js +1 -0
  8. package/dist/esm/chunks/diagnose-J667GHGS.js +1 -0
  9. package/dist/esm/chunks/markets-ui-EGWIEUOZ.js +1 -0
  10. package/dist/esm/index.js +1 -0
  11. package/dist/esm/server.js +1 -0
  12. package/dist/presets/barba.esm.js +4 -5
  13. package/dist/tether.diagnose.js +320 -0
  14. package/dist/tether.diagnose.min.js +1 -0
  15. package/dist/tether.esm.js +2 -2608
  16. package/dist/tether.js +637 -833
  17. package/dist/tether.markets.js +213 -0
  18. package/dist/tether.markets.min.js +1 -0
  19. package/dist/tether.min.js +3 -132
  20. package/dist/tether.min.js.map +4 -4
  21. package/package.json +52 -11
  22. package/src/astro.js +73 -0
  23. package/src/cart.js +79 -7
  24. package/src/client.js +25 -15
  25. package/src/config.js +4 -0
  26. package/src/diagnose.js +1 -1
  27. package/src/dom.js +32 -9
  28. package/src/fragments.js +12 -11
  29. package/src/index.js +80 -160
  30. package/src/initial.js +66 -0
  31. package/src/lazy-iife.js +43 -0
  32. package/src/lazy.js +9 -0
  33. package/src/loader.js +50 -7
  34. package/src/markets-ui.js +89 -0
  35. package/src/markets.js +1 -1
  36. package/src/mounting.js +129 -0
  37. package/src/parts/diagnose.js +3 -0
  38. package/src/parts/markets.js +3 -0
  39. package/src/product.js +86 -61
  40. package/src/server.js +179 -0
  41. package/src/status.js +2 -6
  42. package/types/astro.d.ts +15 -0
  43. package/types/barba.d.ts +16 -0
  44. package/types/index.d.ts +347 -0
  45. package/types/server.d.ts +94 -0
@@ -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
+ }
@@ -0,0 +1,3 @@
1
+ import * as part from '../diagnose.js';
2
+
3
+ window.Tether.__parts.diagnose = part;
@@ -0,0 +1,3 @@
1
+ import * as part from '../markets-ui.js';
2
+
3
+ window.Tether.__parts.markets = part;
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 (key && /^image:\d+$/.test(key)) {
301
- const img = p.images.nodes[parseInt(key.slice(6), 10) - 1] || p.featuredImage;
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 && key.startsWith('metafield:')) {
305
- const mf = metafieldValue(p, key.slice('metafield:'.length));
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 === 'rich_text_field' || mf.type === 'multi_line_text_field') {
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
- switch (get(el, 'bind')) {
398
- case 'price': return setText(el, formatMoney(price));
399
- case 'total-price': return setText(el, formatMoney(total));
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
- switch (get(el, 'plan-bind')) {
462
- case 'name': return setText(el, planObj?.name);
463
- case 'description': return setText(el, planObj?.description || '');
464
- case 'price': return setText(el, formatMoney(planPrice(v, id)));
465
- case 'savings': return setText(el, s.percentage ? `${s.percentage}%` : '');
466
- case 'savings-amount': return setText(el, formatMoney(s.amount));
467
- case 'adjustment':
468
- if (!adj) return setText(el, '');
469
- return setText(el, adj.type === 'percentage' ? `${adj.value}%` : formatMoney(adj.value));
470
- default: return undefined;
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
- const visible = k === status;
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
  }
@@ -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> };
@@ -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>;