@occluze/tether 0.4.0 → 0.6.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 (68) hide show
  1. package/README.md +12 -8
  2. package/dist/esm/astro.js +2 -0
  3. package/dist/esm/chunks/account-WAWNASZU.js +1 -0
  4. package/dist/esm/chunks/analytics-22NMIVBV.js +1 -0
  5. package/dist/esm/chunks/chunk-4UR45XTC.js +1 -0
  6. package/dist/esm/chunks/chunk-6R752OYZ.js +1 -0
  7. package/dist/esm/chunks/chunk-BGIKQY6H.js +1 -0
  8. package/dist/esm/chunks/chunk-CISGFYOF.js +1 -0
  9. package/dist/esm/chunks/chunk-KARKXC6G.js +1 -0
  10. package/dist/esm/chunks/chunk-VC46IEJQ.js +1 -0
  11. package/dist/esm/chunks/chunk-YXCXIPQV.js +4 -0
  12. package/dist/esm/chunks/{chunk-KYQ5OGBX.js → chunk-YZMBXS6Y.js} +1 -1
  13. package/dist/esm/chunks/chunk-ZVUPVSLF.js +1 -0
  14. package/dist/esm/chunks/diagnose-6ZSK5DGE.js +1 -0
  15. package/dist/esm/chunks/extras-QDCSW6VU.js +1 -0
  16. package/dist/esm/chunks/markets-ui-HJCVLCKJ.js +1 -0
  17. package/dist/esm/chunks/search-LN2H6SXS.js +1 -0
  18. package/dist/esm/index.js +1 -1
  19. package/dist/esm/server.js +1 -0
  20. package/dist/tether.account.js +374 -0
  21. package/dist/tether.account.min.js +1 -0
  22. package/dist/tether.analytics.js +259 -0
  23. package/dist/tether.analytics.min.js +1 -0
  24. package/dist/tether.diagnose.js +14 -4
  25. package/dist/tether.diagnose.min.js +1 -1
  26. package/dist/tether.extras.js +87 -0
  27. package/dist/tether.extras.min.js +1 -0
  28. package/dist/tether.js +486 -125
  29. package/dist/tether.markets.js +17 -11
  30. package/dist/tether.markets.min.js +1 -1
  31. package/dist/tether.min.js +3 -3
  32. package/dist/tether.min.js.map +3 -3
  33. package/dist/tether.search.js +176 -0
  34. package/dist/tether.search.min.js +1 -0
  35. package/package.json +31 -5
  36. package/src/account.js +313 -0
  37. package/src/analytics.js +232 -0
  38. package/src/astro.js +76 -0
  39. package/src/cart.js +119 -16
  40. package/src/client.js +30 -15
  41. package/src/collection.js +15 -10
  42. package/src/config.js +5 -0
  43. package/src/diagnose.js +12 -3
  44. package/src/dom.js +39 -10
  45. package/src/extras.js +63 -0
  46. package/src/fragments.js +13 -12
  47. package/src/index.js +114 -40
  48. package/src/initial.js +75 -0
  49. package/src/lazy.js +9 -1
  50. package/src/loader.js +53 -7
  51. package/src/markets-ui.js +15 -6
  52. package/src/markets.js +1 -1
  53. package/src/mounting.js +68 -3
  54. package/src/parts/account.js +3 -0
  55. package/src/parts/analytics.js +3 -0
  56. package/src/parts/extras.js +3 -0
  57. package/src/parts/search.js +3 -0
  58. package/src/product.js +51 -13
  59. package/src/search.js +153 -0
  60. package/src/server.js +251 -0
  61. package/src/status.js +3 -8
  62. package/types/astro.d.ts +15 -0
  63. package/types/index.d.ts +72 -4
  64. package/types/server.d.ts +102 -0
  65. package/dist/esm/chunks/chunk-GHIUMD5H.js +0 -1
  66. package/dist/esm/chunks/chunk-LI3YDMGF.js +0 -4
  67. package/dist/esm/chunks/diagnose-ZXXXJIQE.js +0 -1
  68. package/dist/esm/chunks/markets-ui-AG6Y5VUE.js +0 -1
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, formatPercent,
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];
@@ -152,7 +167,16 @@ export class ProductScope {
152
167
 
153
168
  select(name, value) {
154
169
  if (this.selected[name] === value) return;
155
- this.selected[name] = value;
170
+ const vs = this.product.variants.nodes.filter((v) => v.selectedOptions.some((o) => o.name === name && o.value === value));
171
+ if (!vs.length) return; // a value no variant has: nothing to select, keep the visitor's choice
172
+ const next = { ...this.selected, [name]: value };
173
+ if (!vs.some((v) => v.selectedOptions.every((o) => next[o.name] === o.value))) {
174
+ // That combination doesn't exist: the closest variant with this value (most other choices kept, available first).
175
+ const score = (v) => v.selectedOptions.filter((o) => o.name !== name && this.selected[o.name] === o.value).length * 2 + (v.availableForSale ? 1 : 0);
176
+ const best = vs.reduce((a, b) => (score(b) > score(a) ? b : a));
177
+ best.selectedOptions.forEach((o) => { next[o.name] = o.value; });
178
+ }
179
+ this.selected = next;
156
180
  this.renderVariant();
157
181
  }
158
182
 
@@ -208,7 +232,7 @@ export class ProductScope {
208
232
  const opt = t.closest(sel('option-value'));
209
233
  if (own(opt) && opt.closest(sel('option-list'))) {
210
234
  e.preventDefault();
211
- this.select(get(opt.closest(sel('option-list')), 'option-list'), get(opt, 'option-value'));
235
+ this.select(this.optName(get(opt.closest(sel('option-list')), 'option-list')), get(opt, 'option-value'));
212
236
  return;
213
237
  }
214
238
 
@@ -234,7 +258,7 @@ export class ProductScope {
234
258
  this.root.addEventListener('change', (e) => {
235
259
  const t = e.target;
236
260
  if (!own(t)) return;
237
- if (t.matches(`select${sel('option')}`)) this.select(get(t, 'option'), t.value);
261
+ if (t.matches(`select${sel('option')}`)) this.select(this.optName(get(t, 'option')), t.value);
238
262
  else if (t.matches(`select${sel('variant-select')}`)) this.selectVariant(t.value);
239
263
  else if (t.matches(`select${sel('plan')}`)) {
240
264
  if (t.value) this.selectPlan(t.value); else this.setPurchase('one-time');
@@ -266,6 +290,11 @@ export class ProductScope {
266
290
  // ------------------------------------------------------------ render
267
291
 
268
292
  renderProduct() {
293
+ const p0 = this.product;
294
+ if (!knownOptionNames.has(p0.handle)) {
295
+ const authored = this.$(`${sel('option-list')},select${sel('option')}`).map((e) => get(e, 'option-list') || get(e, 'option'));
296
+ if (authored.some((n) => p0.options.some((o) => o.name === n))) rememberOptionNames([p0]);
297
+ }
269
298
  const p = this.product;
270
299
 
271
300
  this.$(sel('bind')).forEach((el) => {
@@ -289,7 +318,11 @@ export class ProductScope {
289
318
  el.setAttribute('href', productUrl(p.handle, p.id));
290
319
  if (key === 'url' && !el.firstElementChild && !el.textContent.trim()) setText(el, p.title);
291
320
  return undefined;
292
- case 'images':
321
+ case 'images': {
322
+ // Server-rendered gallery with the same images: keep it as it is.
323
+ const imgs = [...el.children].map((n) => (n.tagName === 'IMG' ? n : n.querySelector('img')));
324
+ if (!hasTemplate(el) && imgs.length === p.images.nodes.length && imgs.every((im, i) => sameImage(im?.getAttribute('src'), p.images.nodes[i].url))) return undefined;
325
+ }
293
326
  return repeat(el, p.images.nodes, (node, img) => {
294
327
  const target = node.matches(`img,${sel('bind', 'image')}`) ? node : node.querySelector('img') || node;
295
328
  setImage(target, img.url, img.altText || p.title);
@@ -316,7 +349,7 @@ export class ProductScope {
316
349
  });
317
350
 
318
351
  this.$(sel('option-list')).forEach((container) => {
319
- const name = get(container, 'option-list');
352
+ const name = this.optName(get(container, 'option-list'));
320
353
  const opt = p.options.find((o) => o.name === name);
321
354
  if (!opt || (opt.optionValues.length <= 1 && has(container, 'hide-single'))) return show(container, false);
322
355
  return repeat(container, opt.optionValues, (node, v) => {
@@ -330,7 +363,7 @@ export class ProductScope {
330
363
  });
331
364
 
332
365
  this.$(`select${sel('option')}`).forEach((s) => {
333
- const opt = p.options.find((o) => o.name === get(s, 'option'));
366
+ const opt = p.options.find((o) => o.name === this.optName(get(s, 'option')));
334
367
  if (!opt) return show(s, false);
335
368
  s.innerHTML = '';
336
369
  opt.optionValues.forEach((v) => s.appendChild(new Option(v.name, v.name)));
@@ -395,7 +428,7 @@ export class ProductScope {
395
428
  const total = { amount: (parseFloat(price.amount) * Math.max(1, this.quantity)).toFixed(2), currencyCode: price.currencyCode };
396
429
 
397
430
  const fm = formatMoney;
398
- const pct = (n) => (n ? `${n}%` : '');
431
+ const pct = (n) => formatPercent(n);
399
432
  // Text binds: name -> value (functions, so only the binds on the page are computed).
400
433
  const text = {
401
434
  price: () => fm(price),
@@ -404,7 +437,8 @@ export class ProductScope {
404
437
  'subscription-price': () => fm(subPrice),
405
438
  'subscription-savings': () => pct(savings.percentage),
406
439
  'subscription-savings-amount': () => fm(savings.amount),
407
- 'checkout-charge': () => fm(sub ? planCheckoutCharge(v, this.planId) : price),
440
+ // What the visitor pays today for the chosen quantity (prepaid plans charge more up front).
441
+ 'checkout-charge': () => { const c = sub ? planCheckoutCharge(v, this.planId) : price; return c && fm({ amount: (parseFloat(c.amount) * Math.max(1, this.quantity)).toFixed(2), currencyCode: c.currencyCode }); },
408
442
  'plan-name': () => plan?.name,
409
443
  'plan-description': () => plan?.description,
410
444
  'plan-group': () => plan?.group?.name,
@@ -425,10 +459,10 @@ export class ProductScope {
425
459
  else if (text[k]) setText(el, text[k]());
426
460
  });
427
461
 
428
- this.$(sel('option-selected')).forEach((el) => setText(el, this.selected[get(el, 'option-selected')] || ''));
462
+ this.$(sel('option-selected')).forEach((el) => setText(el, this.selected[this.optName(get(el, 'option-selected'))] || ''));
429
463
 
430
464
  this.$(sel('option-list')).forEach((container) => {
431
- const name = get(container, 'option-list');
465
+ const name = this.optName(get(container, 'option-list'));
432
466
  qsa(container, sel('option-value')).forEach((node) => {
433
467
  const value = get(node, 'option-value');
434
468
  setSelected(node, this.selected[name] === value);
@@ -438,7 +472,7 @@ export class ProductScope {
438
472
  });
439
473
  });
440
474
 
441
- this.$(`select${sel('option')}`).forEach((s) => { s.value = this.selected[get(s, 'option')] || ''; });
475
+ this.$(`select${sel('option')}`).forEach((s) => { s.value = this.selected[this.optName(get(s, 'option'))] || ''; });
442
476
  this.$(`select${sel('variant-select')}`).forEach((s) => { s.value = v.id; });
443
477
 
444
478
  // Subscription UI
@@ -462,6 +496,7 @@ export class ProductScope {
462
496
  const k = get(el, 'plan-bind');
463
497
  const val = {
464
498
  name: () => planObj?.name,
499
+ option: () => (planObj?.options || []).map((o) => o.value).join(', '), // e.g. "1 month", without the store's price text
465
500
  description: () => planObj?.description,
466
501
  price: () => fm(planPrice(v, id)),
467
502
  savings: () => pct(s.percentage),
@@ -474,6 +509,7 @@ export class ProductScope {
474
509
 
475
510
  this.$(`select${sel('plan')}`).forEach((s) => { s.value = sub ? this.planId : ''; });
476
511
 
512
+ const low = v.quantityAvailable != null && v.quantityAvailable > 0 && v.quantityAvailable <= (cfg.lowStock ?? 5);
477
513
  const conds = {
478
514
  'in-stock': v.availableForSale,
479
515
  'out-of-stock': !v.availableForSale,
@@ -482,7 +518,8 @@ export class ProductScope {
482
518
  'not-on-sale': !onSale,
483
519
  'has-variants': p.variants.nodes.length > 1,
484
520
  'single-variant': p.variants.nodes.length === 1,
485
- 'low-stock': v.quantityAvailable != null && v.quantityAvailable > 0 && v.quantityAvailable <= 5,
521
+ 'low-stock': low,
522
+ 'plenty-in-stock': v.availableForSale && !low,
486
523
  'subscription-available': this.subscriptionAvailable,
487
524
  'subscription-unavailable': !this.subscriptionAvailable,
488
525
  'subscription-only': Boolean(p.requiresSellingPlan),
@@ -528,5 +565,6 @@ export class ProductScope {
528
565
  product: p, variant: v, selected: { ...this.selected },
529
566
  purchase: this.purchase, sellingPlanId: sub ? this.planId : null,
530
567
  }, this.root);
568
+
531
569
  }
532
570
  }
package/src/search.js ADDED
@@ -0,0 +1,153 @@
1
+ /**
2
+ * Search as you type (Shopify predictive search). Optional part: loaded only on pages with
3
+ * [tether-search].
4
+ *
5
+ * <form tether-search action="/search"> <!-- Enter: your full results page -->
6
+ * <input tether-search-input name="q" type="search" placeholder="Search">
7
+ * <div tether-search-results>
8
+ * <div tether-search-products><a><img tether-search-bind="image" max-width="120"><span tether-search-bind="title"></span> <span tether-search-bind="price"></span></a></div>
9
+ * <div tether-search-queries><a><span tether-search-bind="text"></span></a></div>
10
+ * <div tether-search-collections><a><span tether-search-bind="title"></span></a></div>
11
+ * <p tether-show="search-empty">No results</p>
12
+ * </div>
13
+ * </form>
14
+ *
15
+ * Each list's first child is the template. The root gets is-open while there are results (or none)
16
+ * to show, and tether-search-state="loading|ready|empty|error". Arrow keys move through the results,
17
+ * Escape closes. tether-search-limit (default 6), tether-search-min (characters, default 2).
18
+ */
19
+ import { cfg, reportError } from './config.js';
20
+ import { gql } from './client.js';
21
+ import { attr, sel, get, qsa, setText, setImage, repeat, show, formatMoney, productUrl, emit } from './dom.js';
22
+
23
+ const QUERY = `query TetherPredictive($q: String!, $limit: Int!) {
24
+ predictiveSearch(query: $q, limit: $limit, limitScope: EACH, types: [PRODUCT, COLLECTION, QUERY]) {
25
+ products { id handle title availableForSale featuredImage { url altText } priceRange { minVariantPrice { amount currencyCode } } }
26
+ collections { handle title image { url altText } }
27
+ queries { text }
28
+ }
29
+ }`;
30
+
31
+ const mounted = new WeakSet();
32
+ let uid = 0;
33
+
34
+ export function mountSearch(root = document) {
35
+ const roots = [...(root.matches?.(sel('search')) ? [root] : []), ...qsa(root, sel('search'))];
36
+ roots.forEach((el) => { if (!mounted.has(el)) { mounted.add(el); setup(el); } });
37
+ }
38
+
39
+ function setup(rootEl) {
40
+ const input = rootEl.querySelector(sel('search-input')) || rootEl.querySelector('input');
41
+ const results = rootEl.querySelector(sel('search-results'));
42
+ if (!input || !results) return;
43
+ const limit = parseInt(get(rootEl, 'search-limit') || '6', 10) || 6;
44
+ const min = parseInt(get(rootEl, 'search-min') || '2', 10) || 2;
45
+ const id = results.id || (results.id = `${cfg.prefix}-search-${++uid}`);
46
+ input.setAttribute('role', 'combobox');
47
+ input.setAttribute('aria-autocomplete', 'list');
48
+ input.setAttribute('aria-controls', id);
49
+ input.setAttribute('aria-expanded', 'false');
50
+ input.setAttribute('autocomplete', 'off');
51
+ if (!results.hasAttribute('role')) results.setAttribute('role', 'listbox');
52
+ results.hidden = true;
53
+
54
+ let token = 0;
55
+ let timer = null;
56
+ let last = '';
57
+ const cache = new Map(); // term -> results, for typing back and forth
58
+
59
+ const setState = (state) => rootEl.setAttribute(attr('search-state'), state);
60
+ const open = (yes) => {
61
+ rootEl.classList.toggle('is-open', yes);
62
+ results.hidden = !yes;
63
+ input.setAttribute('aria-expanded', String(yes));
64
+ if (!yes) input.removeAttribute('aria-activedescendant');
65
+ };
66
+ const items = () => qsa(results, '[role="option"]');
67
+
68
+ async function run(term) {
69
+ const t = ++token;
70
+ if (term.length < min) { open(false); setState('idle'); return; }
71
+ setState('loading');
72
+ try {
73
+ const data = cache.get(term) || (await gql(QUERY, { q: term, limit })).predictiveSearch;
74
+ cache.set(term, data);
75
+ if (t !== token) return; // a newer search is on its way
76
+ render(data, term);
77
+ } catch (err) {
78
+ if (t !== token) return;
79
+ setState('error');
80
+ reportError(err, 'search');
81
+ }
82
+ }
83
+
84
+ function render(data, term) {
85
+ const products = data?.products || [];
86
+ const collections = data?.collections || [];
87
+ const queries = (data?.queries || []).filter((q) => q.text.toLowerCase() !== term.toLowerCase());
88
+ const fill = (name, list, each) => qsa(results, sel(name)).forEach((c) => {
89
+ repeat(c, list, each);
90
+ show(c, list.length > 0);
91
+ });
92
+ fill('search-products', products, (node, p) => {
93
+ binds(node, { title: p.title, price: formatMoney(p.priceRange?.minVariantPrice), image: p.featuredImage });
94
+ link(node, productUrl(p.handle, p.id));
95
+ node.classList.toggle('is-sold-out', !p.availableForSale);
96
+ });
97
+ fill('search-collections', collections, (node, c) => {
98
+ binds(node, { title: c.title, image: c.image });
99
+ link(node, `${(cfg.collectionPath || '/collection').replace(/\/$/, '')}/${c.handle}`);
100
+ });
101
+ fill('search-queries', queries, (node, q) => {
102
+ binds(node, { text: q.text });
103
+ const a = node.tagName === 'A' ? node : node.querySelector('a');
104
+ const action = rootEl.getAttribute('action') || '/search';
105
+ if (a) a.setAttribute('href', `${action}${action.includes('?') ? '&' : '?'}${input.name || 'q'}=${encodeURIComponent(q.text)}`);
106
+ });
107
+ const count = products.length + collections.length + queries.length;
108
+ qsa(rootEl, sel('show')).forEach((el) => {
109
+ const k = get(el, 'show');
110
+ if (k === 'search-empty') show(el, count === 0);
111
+ if (k === 'search-results') show(el, count > 0);
112
+ });
113
+ items().forEach((el, i) => { el.id ||= `${id}-${i}`; el.setAttribute('aria-selected', 'false'); });
114
+ setState(count ? 'ready' : 'empty');
115
+ open(true);
116
+ emit('search', { term, products, collections, queries }, rootEl);
117
+ }
118
+
119
+ function binds(node, values) {
120
+ qsa(node, sel('search-bind')).forEach((el) => {
121
+ const k = get(el, 'search-bind');
122
+ if (k === 'image') setImage(el, values.image?.url, values.image?.altText || values.title);
123
+ else if (values[k] != null) setText(el, values[k]);
124
+ });
125
+ }
126
+
127
+ function link(node, href) {
128
+ const a = node.tagName === 'A' ? node : node.querySelector('a');
129
+ if (a) { a.setAttribute('href', href); a.setAttribute('role', 'option'); }
130
+ }
131
+
132
+ input.addEventListener('input', () => {
133
+ const term = input.value.trim();
134
+ if (term === last) return;
135
+ last = term;
136
+ clearTimeout(timer);
137
+ timer = setTimeout(() => run(term), cfg.searchDelay ?? 150);
138
+ });
139
+ input.addEventListener('focus', () => { if (input.value.trim().length >= min && items().length) open(true); });
140
+ input.addEventListener('keydown', (e) => {
141
+ const list = items();
142
+ const cur = list.findIndex((el) => el.getAttribute('aria-selected') === 'true');
143
+ const move = (i) => {
144
+ list.forEach((el, j) => el.setAttribute('aria-selected', String(j === i)));
145
+ if (list[i]) { input.setAttribute('aria-activedescendant', list[i].id); list[i].scrollIntoView?.({ block: 'nearest' }); }
146
+ };
147
+ if (e.key === 'ArrowDown' && list.length) { e.preventDefault(); open(true); move((cur + 1) % list.length); }
148
+ else if (e.key === 'ArrowUp' && list.length) { e.preventDefault(); move(cur <= 0 ? list.length - 1 : cur - 1); }
149
+ else if (e.key === 'Enter' && cur >= 0) { e.preventDefault(); list[cur].click(); }
150
+ else if (e.key === 'Escape') { open(false); }
151
+ });
152
+ document.addEventListener('click', (e) => { if (!rootEl.contains(e.target)) open(false); });
153
+ }
package/src/server.js ADDED
@@ -0,0 +1,251 @@
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, formatPercent } from './dom.js';
16
+ import { initialState } from './initial.js';
17
+
18
+ export { imageUrl, initialState, formatPercent };
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' || k === 'siteUrl'));
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
+ /**
158
+ * { card: true } for lists (home, collections, recommendations): about 40% smaller, without
159
+ * descriptions, extra images and variant images (cards show the main image). Price, stock and
160
+ * plans per variant stay. The full product comes with the background refresh.
161
+ */
162
+ function hydrate(products, { card = false } = {}) {
163
+ let list = (Array.isArray(products) ? products : [products]).filter(Boolean);
164
+ if (card) list = list.map(slim);
165
+ const payload = { v: 1, at: new Date().toISOString(), country: conf.country || null, language: conf.language || null, products: list };
166
+ return `<script type="application/json" data-tether-hydrate>${safeJson(payload)}</script>`;
167
+ }
168
+
169
+ /**
170
+ * schema.org data for search engines (Google's product rich results): a Product, or a ProductGroup
171
+ * with one Product per variant (price, currency, stock, sku, image, link to ?variant=).
172
+ * jsonLd(product, { url: 'https://shop.example/products/x' }) -> the <script> tag; jsonLdData() -> the object.
173
+ */
174
+ function jsonLdData(p, { url, brand } = {}) {
175
+ if (!p) return null;
176
+ const pageUrl = url || (conf.siteUrl ? new URL(productUrl(p.handle, { productPath: conf.productPath, id: p.id }), conf.siteUrl).toString() : undefined);
177
+ const vid = (v) => String(v.id).split('/').pop();
178
+ const withVariant = (v) => (pageUrl ? `${pageUrl}${pageUrl.includes('?') ? '&' : '?'}variant=${vid(v)}` : undefined);
179
+ const text = (p.description || '').trim() || undefined;
180
+ const images = (p.images?.nodes || []).map((i) => i.url).filter(Boolean);
181
+ const brandName = brand || p.vendor;
182
+ // Only buyable on a subscription: the plan price is the price (the first plan the variant has).
183
+ const priceOf = (v) => {
184
+ if (!p.requiresSellingPlan) return v.price;
185
+ const plan = plans.allPlans(p).find((pl) => plans.allocationFor(v, pl.id));
186
+ return plan ? plans.planPrice(v, plan.id) : v.price;
187
+ };
188
+ const offer = (v) => ({
189
+ '@type': 'Offer',
190
+ price: priceOf(v)?.amount,
191
+ priceCurrency: priceOf(v)?.currencyCode,
192
+ availability: `https://schema.org/${v.availableForSale ? (v.currentlyNotInStock ? 'BackOrder' : 'InStock') : 'OutOfStock'}`,
193
+ url: withVariant(v),
194
+ });
195
+ const vs = p.variants?.nodes || [];
196
+ const clean = (o) => JSON.parse(JSON.stringify(o)); // drops undefined
197
+ if (vs.length <= 1) {
198
+ const v = vs[0];
199
+ return clean({
200
+ '@context': 'https://schema.org', '@type': 'Product', name: p.title, description: text, url: pageUrl,
201
+ image: images, sku: v?.sku || undefined, brand: brandName ? { '@type': 'Brand', name: brandName } : undefined,
202
+ offers: v ? offer(v) : undefined,
203
+ });
204
+ }
205
+ const varies = { size: 'https://schema.org/size', color: 'https://schema.org/color', colour: 'https://schema.org/color', material: 'https://schema.org/material' };
206
+ return clean({
207
+ '@context': 'https://schema.org', '@type': 'ProductGroup', name: p.title, description: text, url: pageUrl,
208
+ productGroupID: String(p.id).split('/').pop(), image: images,
209
+ brand: brandName ? { '@type': 'Brand', name: brandName } : undefined,
210
+ variesBy: [...new Set((p.options || []).map((o) => varies[o.name.toLowerCase()]).filter(Boolean))],
211
+ hasVariant: vs.map((v) => ({
212
+ '@type': 'Product',
213
+ name: `${p.title} - ${v.title}`,
214
+ sku: v.sku || undefined,
215
+ image: v.image?.url || images[0],
216
+ ...Object.fromEntries((v.selectedOptions || []).filter((o) => varies[o.name.toLowerCase()]).map((o) => [varies[o.name.toLowerCase()].split('/').pop(), o.value])),
217
+ offers: offer(v),
218
+ })),
219
+ });
220
+ }
221
+ const jsonLd = (p, opts) => (p ? `<script type="application/ld+json">${safeJson(jsonLdData(p, opts))}</script>` : '');
222
+
223
+ function slim(p) {
224
+ return {
225
+ ...p,
226
+ description: '', descriptionHtml: '',
227
+ images: { nodes: (p.images?.nodes || []).slice(0, 1) },
228
+ variants: { nodes: (p.variants?.nodes || []).map((v) => ({ ...v, image: null })) },
229
+ };
230
+ }
231
+
232
+ return {
233
+ config: conf,
234
+ query: q,
235
+ getProduct,
236
+ getProducts,
237
+ allProducts,
238
+ getCollection,
239
+ allCollections,
240
+ getRecommendations,
241
+ hydrate,
242
+ formatMoney: (money, locale = conf.locale) => formatMoney(money, locale),
243
+ formatPercent: (n, locale = conf.locale) => formatPercent(n, locale),
244
+ imageUrl,
245
+ jsonLd,
246
+ jsonLdData,
247
+ initialState: (product, opts = {}) => initialState(product, { defaultPurchase: conf.defaultPurchase, lowStock: conf.lowStock, ...opts }),
248
+ productUrl: (handle, opts = {}) => productUrl(handle, { productPath: conf.productPath, ...opts }),
249
+ plans,
250
+ };
251
+ }
package/src/status.js CHANGED
@@ -11,8 +11,8 @@ import { attr, sel, get, scoped, show } from './dom.js';
11
11
  * "url:path" the last segment of the page path (/shop/coffee-box -> coffee-box)
12
12
  * Returns { key, fromUrl, raw }. key is null when the URL has no value.
13
13
  */
14
- export function resolveProductKey(el, location = window.location) {
15
- const raw = (get(el, 'product') || '').trim();
14
+ export function resolveProductKey(el, location = window.location, name = 'product') {
15
+ const raw = (get(el, name) || '').trim();
16
16
  if (!raw.toLowerCase().startsWith('url:')) return { key: raw || null, fromUrl: false, raw };
17
17
  const what = raw.slice(4).trim();
18
18
  let key = null;
@@ -53,12 +53,7 @@ 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) {
@@ -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> };