@occluze/tether 0.4.0 → 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 (42) hide show
  1. package/README.md +7 -5
  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-KYQ5OGBX.js → chunk-RBVRRQIU.js} +1 -1
  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 -1
  11. package/dist/esm/server.js +1 -0
  12. package/dist/tether.diagnose.js +4 -3
  13. package/dist/tether.diagnose.min.js +1 -1
  14. package/dist/tether.js +229 -61
  15. package/dist/tether.markets.js +16 -10
  16. package/dist/tether.markets.min.js +1 -1
  17. package/dist/tether.min.js +3 -3
  18. package/dist/tether.min.js.map +3 -3
  19. package/package.json +27 -4
  20. package/src/astro.js +73 -0
  21. package/src/cart.js +47 -7
  22. package/src/client.js +25 -15
  23. package/src/config.js +2 -0
  24. package/src/diagnose.js +1 -1
  25. package/src/dom.js +32 -9
  26. package/src/fragments.js +12 -11
  27. package/src/index.js +18 -6
  28. package/src/initial.js +66 -0
  29. package/src/loader.js +49 -6
  30. package/src/markets-ui.js +13 -5
  31. package/src/markets.js +1 -1
  32. package/src/mounting.js +42 -2
  33. package/src/product.js +33 -9
  34. package/src/server.js +179 -0
  35. package/src/status.js +1 -6
  36. package/types/astro.d.ts +15 -0
  37. package/types/index.d.ts +17 -2
  38. package/types/server.d.ts +94 -0
  39. package/dist/esm/chunks/chunk-GHIUMD5H.js +0 -1
  40. package/dist/esm/chunks/chunk-LI3YDMGF.js +0 -4
  41. package/dist/esm/chunks/diagnose-ZXXXJIQE.js +0 -1
  42. package/dist/esm/chunks/markets-ui-AG6Y5VUE.js +0 -1
package/src/initial.js ADDED
@@ -0,0 +1,66 @@
1
+ /**
2
+ * What the browser script will show first for a product, so a server can render the same
3
+ * (no flash when Tether takes over): the selected variant, price, purchase mode and conditions.
4
+ * Same rules as src/product.js: first available variant (or the one in ?variant=), subscription
5
+ * when the product requires a plan or defaultPurchase is 'subscription', low stock at 5 or fewer.
6
+ */
7
+ import { GID, allPlans, allocationFor, findPlan, planPrice, planSavings } from './selling-plans.js';
8
+
9
+ export function initialState(product, { variant, sellingPlan, defaultPurchase = 'one-time' } = {}) {
10
+ const vs = product?.variants?.nodes || [];
11
+ const gid = variant ? GID.variant(variant) : null;
12
+ const v = (gid && vs.find((x) => x.id === gid)) || vs.find((x) => x.availableForSale) || vs[0] || null;
13
+ if (!v) return null;
14
+ const selected = Object.fromEntries((v.selectedOptions || []).map((o) => [o.name, o.value]));
15
+
16
+ const plans = allPlans(product);
17
+ const fromUrl = findPlan(product, sellingPlan);
18
+ let plan = plans.length ? (fromUrl || plans[0]) : null;
19
+ if (plan && !allocationFor(v, plan.id)) plan = plans.find((p) => allocationFor(v, p.id)) || plan;
20
+ const wantsSub = product.requiresSellingPlan || fromUrl || defaultPurchase === 'subscription';
21
+ const subscriptionAvailable = plans.some((p) => allocationFor(v, p.id));
22
+ const purchase = plans.length && wantsSub ? 'subscription' : 'one-time';
23
+ const sub = purchase === 'subscription' && subscriptionAvailable;
24
+
25
+ const price = sub ? planPrice(v, plan.id) : v.price;
26
+ const compareAt = sub ? (allocationFor(v, plan.id)?.priceAdjustments?.[0]?.compareAtPrice || v.compareAtPrice) : v.compareAtPrice;
27
+ const onSale = Boolean(compareAt && parseFloat(compareAt.amount) > parseFloat(price.amount));
28
+ const q = v.quantityAvailable;
29
+
30
+ return {
31
+ variant: v,
32
+ selected,
33
+ purchase,
34
+ plan: sub ? plan : null,
35
+ /** The plan list (tether-plan-list="all"): selected one gets is-selected, unavailable ones is-unavailable. */
36
+ plans: plans.map((p) => ({
37
+ id: p.id, name: p.name, price: planPrice(v, p.id), savings: planSavings(v, p.id).percentage,
38
+ selected: sub && p.id === plan.id, available: Boolean(allocationFor(v, p.id)),
39
+ })),
40
+ /** Savings of the selected plan in % (tether-bind="subscription-savings"), 0 without one. */
41
+ savings: plan ? planSavings(v, plan.id).percentage : 0,
42
+ price,
43
+ compareAtPrice: onSale ? compareAt : null,
44
+ available: v.availableForSale,
45
+ /** The tether-show conditions, e.g. hidden={!state.show['out-of-stock']}. */
46
+ show: {
47
+ 'in-stock': v.availableForSale,
48
+ 'out-of-stock': !v.availableForSale,
49
+ backorder: Boolean(v.availableForSale && v.currentlyNotInStock),
50
+ 'on-sale': onSale,
51
+ 'not-on-sale': !onSale,
52
+ 'has-variants': vs.length > 1,
53
+ 'single-variant': vs.length === 1,
54
+ 'low-stock': q != null && q > 0 && q <= 5,
55
+ 'subscription-available': subscriptionAvailable,
56
+ 'subscription-unavailable': !subscriptionAvailable,
57
+ 'subscription-only': Boolean(product.requiresSellingPlan),
58
+ 'one-time-available': !product.requiresSellingPlan,
59
+ 'subscription-selected': sub,
60
+ 'one-time-selected': !sub,
61
+ },
62
+ /** Option values with no available variant (given the other selected options): class is-unavailable. */
63
+ isUnavailable: (name, value) => !vs.some((x) => x.availableForSale && x.selectedOptions.every((o) =>
64
+ (o.name === name ? o.value === value : selected[o.name] === o.value))),
65
+ };
66
+ }
package/src/loader.js CHANGED
@@ -90,7 +90,11 @@ async function flush() {
90
90
  handles.forEach((h, i) => { if (data[`h${i}`]) got.set(h, data[`h${i}`]); });
91
91
 
92
92
  keys.forEach((k) => settle(k, (r) => {
93
- if (got.has(k)) { if (gen === generation) primeProduct(got.get(k)); r.resolve(got.get(k)); } else {
93
+ if (got.has(k)) {
94
+ const p = got.get(k);
95
+ if (gen === generation) { primeProduct(p); freshAt.set(p.handle, Date.now()); freshAt.set(p.id, Date.now()); }
96
+ r.resolve(p);
97
+ } else {
94
98
  if (gen === generation) productCache.delete(k); // don't cache misses: a fix in Shopify works on next load
95
99
  r.reject(new TetherError(`Product not found: ${k}`, { code: 'NOT_FOUND' }));
96
100
  }
@@ -100,7 +104,44 @@ async function flush() {
100
104
  }
101
105
  }
102
106
 
103
- export function clearProductCache() { productCache.clear(); generation++; }
107
+ export function clearProductCache() { productCache.clear(); generation++; stale.clear(); freshAt.clear(); }
108
+
109
+ // ---------------------------------------------------------------------------
110
+ // Hydration: products rendered on the server (tether/server hydrate()) are used at once and
111
+ // refreshed once in the background, so prices and stock are live without a loading state.
112
+ // ---------------------------------------------------------------------------
113
+
114
+ const stale = new Set(); // keys of products that came from the server and weren't refreshed yet
115
+ const freshAt = new Map(); // key -> when it was last loaded live (this page session)
116
+ const FRESH_MS = 60000;
117
+
118
+ export function hydrateProducts(products) {
119
+ (products || []).forEach((p) => {
120
+ if (!p?.id || !p.handle) return;
121
+ // Loaded live in the last minute (an earlier page of a View Transition visit): that copy is newer.
122
+ if (Date.now() - (freshAt.get(p.handle) || 0) < FRESH_MS && productCache.has(p.handle)) return;
123
+ primeProduct(p);
124
+ stale.add(p.handle);
125
+ stale.add(p.id);
126
+ });
127
+ }
128
+
129
+ /** True once per server-provided product: the caller refreshes it. */
130
+ export function takeStale(key) {
131
+ const k = normalizeProductKey(key);
132
+ if (!stale.has(k)) return false;
133
+ const p = productCache.get(k);
134
+ stale.delete(k);
135
+ // Both keys point to the same product: refresh it once.
136
+ p?.then?.((x) => { stale.delete(x.handle); stale.delete(x.id); });
137
+ return true;
138
+ }
139
+
140
+ /** Fetch these products again (bypassing the cache). */
141
+ export function refreshProducts(keys) {
142
+ keys.forEach((k) => productCache.delete(normalizeProductKey(k)));
143
+ return keys.map((k) => getProduct(k));
144
+ }
104
145
 
105
146
  /**
106
147
  * A page of products from a collection (by handle) or from a search query.
@@ -141,11 +182,13 @@ ${fragment}`, { first, after, query: query || undefined, sortKey: sortKey || und
141
182
  return { collection: null, products: data.products.nodes, pageInfo: data.products.pageInfo };
142
183
  }
143
184
 
144
- export async function getRecommendations(productId, intent = 'RELATED') {
145
- const data = await gql(`query TetherRecs($id: ID!, $intent: ProductRecommendationIntent) {
146
- productRecommendations(productId: $id, intent: $intent) { ...TetherProduct }
185
+ export async function getRecommendations(product, intent = 'RELATED') {
186
+ const key = normalizeProductKey(typeof product === 'object' ? product?.id : product);
187
+ const byId = key?.startsWith('gid://');
188
+ const data = await gql(`query TetherRecs(${byId ? '$id: ID!' : '$handle: String!'}, $intent: ProductRecommendationIntent) {
189
+ productRecommendations(${byId ? 'productId: $id' : 'productHandle: $handle'}, intent: $intent) { ...TetherProduct }
147
190
  }
148
- ${productFragment()}`, { id: productId, intent });
191
+ ${productFragment()}`, byId ? { id: key, intent } : { handle: key, intent });
149
192
  const list = data.productRecommendations || [];
150
193
  list.forEach(primeProduct);
151
194
  return list;
package/src/markets-ui.js CHANGED
@@ -30,11 +30,14 @@ function renderCountrySelects(root = document) {
30
30
  .replace(/\{currency\}/g, c.currency?.isoCode || '').replace(/\{symbol\}/g, c.currency?.symbol || '');
31
31
  selects.forEach((s) => {
32
32
  const tpl = s.getAttribute(attr('country-label')) || '{name} ({currency})';
33
+ // tether-countries="NL,DE,BE": only these (in your order); otherwise every country of the store.
34
+ const only = (s.getAttribute(attr('countries')) || '').toUpperCase().split(/[\s,]+/).filter(Boolean);
35
+ const list = only.length ? only.map((c) => countries.find((x) => x.isoCode === c)).filter(Boolean) : countries;
33
36
  s.innerHTML = '';
34
- countries.forEach((c) => s.appendChild(new Option(fill(tpl, c), c.isoCode)));
37
+ list.forEach((c) => s.appendChild(new Option(fill(tpl, c), c.isoCode)));
35
38
  s.value = current;
36
39
  if (s.value !== current) s.selectedIndex = -1; // a country the store doesn't sell to
37
- s.disabled = countries.length < 2 && !s.hasAttribute(attr('always-enabled'));
40
+ s.disabled = list.length < 2 && !s.hasAttribute(attr('always-enabled'));
38
41
  });
39
42
  binds.forEach((el) => {
40
43
  const k = get(el, 'country-bind') || 'name';
@@ -49,14 +52,19 @@ function renderCountrySelects(root = document) {
49
52
  * buyerIdentity.countryCode is updated, and every product and list on the page re-renders.
50
53
  * Tether.setCountry('DE') Tether.setCountry('BE', { language: 'FR' })
51
54
  */
52
- export async function setCountry(code, { language, remember = true } = {}) {
55
+ export async function setCountry(code, { language, locale, remember = true } = {}) {
53
56
  const country = toCountry(code);
54
57
  if (!country) throw new TetherError(`Not a country code: ${code}`, { code: 'INVALID_COUNTRY' });
58
+ // Money format: the option, else a markets.locales entry for this country, else unchanged.
59
+ const entry = Object.values(cfg.markets?.locales || {}).find((m) => toCountry(m?.country) === country);
60
+ const own = locale || entry?.locale;
61
+ const loc = own || cfg.locale;
55
62
  const lang = language === undefined ? cfg.language : toLanguage(language);
56
- const changed = country !== String(cfg.country || '').toUpperCase() || lang !== cfg.language;
63
+ const changed = country !== String(cfg.country || '').toUpperCase() || lang !== cfg.language || loc !== cfg.locale;
57
64
  cfg.country = country;
58
65
  cfg.language = lang || undefined;
59
- if (remember) rememberMarket({ country, language: lang });
66
+ cfg.locale = loc;
67
+ if (remember) rememberMarket({ country, language: lang, locale: own });
60
68
  if (!changed) { renderCountrySelects(); return Cart.state; }
61
69
 
62
70
  clearProductCache();
package/src/markets.js CHANGED
@@ -112,7 +112,7 @@ export function detectMarket(markets = cfg.markets, env = defaultEnv()) {
112
112
 
113
113
  export function rememberMarket(market) {
114
114
  if (cfg.markets?.remember === false) return;
115
- try { localStorage.setItem(storageKey(), JSON.stringify({ country: market.country, language: market.language || undefined })); } catch { /* private mode */ }
115
+ try { localStorage.setItem(storageKey(), JSON.stringify({ country: market.country, language: market.language || undefined, locale: market.locale || undefined })); } catch { /* private mode */ }
116
116
  }
117
117
 
118
118
  let localizationPromise = null;
package/src/mounting.js CHANGED
@@ -1,8 +1,8 @@
1
1
  /** Product scopes and lists that are mounted, shared by index.js and the optional markets part. */
2
2
  import { cfg, log, warn, reportError } from './config.js';
3
- import { ProductScope, scopes } from './product.js';
3
+ import { ProductScope, scopes, rememberOptionNames } from './product.js';
4
4
  import { listSelector } from './collection.js';
5
- import { getProduct } from './loader.js';
5
+ import { getProduct, takeStale, refreshProducts, hydrateProducts } from './loader.js';
6
6
  import { resolveProductKey, setScopeStatus, isNotFound } from './status.js';
7
7
  import { attr, get, qsa, emit } from './dom.js';
8
8
 
@@ -53,6 +53,7 @@ function mountProduct(el) {
53
53
  // eslint-disable-next-line no-new
54
54
  else new ProductScope(el, p);
55
55
  applyPageTitle(el, p);
56
+ if (cfg.revalidate !== false && takeStale(key)) queueRefresh(key);
56
57
  })
57
58
  .catch((err) => {
58
59
  if (!current()) return;
@@ -87,3 +88,42 @@ export function retryFailed() {
87
88
  mountProduct(el);
88
89
  });
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,6 +281,11 @@ 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) => {
@@ -289,7 +309,11 @@ export class ProductScope {
289
309
  el.setAttribute('href', productUrl(p.handle, p.id));
290
310
  if (key === 'url' && !el.firstElementChild && !el.textContent.trim()) setText(el, p.title);
291
311
  return undefined;
292
- 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
+ }
293
317
  return repeat(el, p.images.nodes, (node, img) => {
294
318
  const target = node.matches(`img,${sel('bind', 'image')}`) ? node : node.querySelector('img') || node;
295
319
  setImage(target, img.url, img.altText || p.title);
@@ -316,7 +340,7 @@ export class ProductScope {
316
340
  });
317
341
 
318
342
  this.$(sel('option-list')).forEach((container) => {
319
- const name = get(container, 'option-list');
343
+ const name = this.optName(get(container, 'option-list'));
320
344
  const opt = p.options.find((o) => o.name === name);
321
345
  if (!opt || (opt.optionValues.length <= 1 && has(container, 'hide-single'))) return show(container, false);
322
346
  return repeat(container, opt.optionValues, (node, v) => {
@@ -330,7 +354,7 @@ export class ProductScope {
330
354
  });
331
355
 
332
356
  this.$(`select${sel('option')}`).forEach((s) => {
333
- 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')));
334
358
  if (!opt) return show(s, false);
335
359
  s.innerHTML = '';
336
360
  opt.optionValues.forEach((v) => s.appendChild(new Option(v.name, v.name)));
@@ -425,10 +449,10 @@ export class ProductScope {
425
449
  else if (text[k]) setText(el, text[k]());
426
450
  });
427
451
 
428
- 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'))] || ''));
429
453
 
430
454
  this.$(sel('option-list')).forEach((container) => {
431
- const name = get(container, 'option-list');
455
+ const name = this.optName(get(container, 'option-list'));
432
456
  qsa(container, sel('option-value')).forEach((node) => {
433
457
  const value = get(node, 'option-value');
434
458
  setSelected(node, this.selected[name] === value);
@@ -438,7 +462,7 @@ export class ProductScope {
438
462
  });
439
463
  });
440
464
 
441
- 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'))] || ''; });
442
466
  this.$(`select${sel('variant-select')}`).forEach((s) => { s.value = v.id; });
443
467
 
444
468
  // Subscription UI
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,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> };
package/types/index.d.ts CHANGED
@@ -60,7 +60,7 @@ export interface ShopifyCartLine {
60
60
  merchandise: ShopifyVariant & { product: Pick<ShopifyProduct, 'id' | 'handle' | 'title'> & Record<string, unknown> };
61
61
  sellingPlanAllocation?: { sellingPlan: { id: string; name: string } } | null;
62
62
  attributes?: { key: string; value: string }[];
63
- cost: Record<string, Money | null | undefined>;
63
+ cost: { totalAmount: Money; subtotalAmount: Money; amountPerQuantity: Money; compareAtAmountPerQuantity?: Money | null };
64
64
  [k: string]: unknown;
65
65
  }
66
66
 
@@ -117,6 +117,10 @@ export interface TetherConfig {
117
117
  sellingPlansFirst?: number;
118
118
  collectionFirst?: number;
119
119
  retries?: number;
120
+ /** Products handed over by the server (tether/server hydrate()): refresh once in the background (default true). */
121
+ revalidate?: boolean;
122
+ /** Mount on Astro View Transitions (astro:page-load) and close the cart on navigation (default true). */
123
+ astro?: boolean;
120
124
  storageKey?: string | null;
121
125
  cartDrawerOpenClass?: string;
122
126
  openCartOnAdd?: boolean;
@@ -163,11 +167,17 @@ export interface LineInput {
163
167
  attributes?: Attributes;
164
168
  }
165
169
 
170
+ export interface CartWarning { code: string; message: string }
171
+
166
172
  export interface Cart {
167
173
  readonly state: ShopifyCart | null;
168
174
  readonly lines: ShopifyCartLine[];
169
175
  readonly count: number;
170
176
  lastError: Error | null;
177
+ /** Shopify's notes on the last change, e.g. less stock than asked (code MERCHANDISE_NOT_ENOUGH_STOCK). */
178
+ readonly warnings: CartWarning[];
179
+ /** true once the saved cart (or the absence of one) is known. */
180
+ readonly loaded: boolean;
171
181
  id(): string | null;
172
182
  init(): Promise<ShopifyCart | null>;
173
183
  /** add(variantId, qty) · add({ variantId, quantity, sellingPlanId, attributes }) · add([line, line]) */
@@ -189,6 +199,8 @@ export interface Cart {
189
199
  isOpen(): boolean;
190
200
  drawers(): HTMLElement[];
191
201
  render(): void;
202
+ /** For framework islands: fn(cart, meta) now, when the saved cart has loaded and after every change. Returns unsubscribe. */
203
+ subscribe(fn: (cart: ShopifyCart | null, meta: { open: boolean; count: number; loaded: boolean; warnings: CartWarning[] }) => void): () => void;
192
204
  }
193
205
 
194
206
  export interface ProductScope {
@@ -262,6 +274,7 @@ export interface TetherEvents {
262
274
  'product-added': { product: ShopifyProduct; variant: ShopifyVariant; line: LineInput };
263
275
  added: { cart: ShopifyCart; lines: LineInput[] };
264
276
  'group-added': { group: string | null; lines: LineInput[] };
277
+ 'cart-warning': { warnings: CartWarning[] };
265
278
  'cart-updated': { cart: ShopifyCart | null; reason: string };
266
279
  'cart-toggled': { open: boolean };
267
280
  'cart-error': { error: Error };
@@ -290,13 +303,15 @@ export interface Tether {
290
303
  cart: Cart;
291
304
  getProduct(handleOrId: string): Promise<ShopifyProduct>;
292
305
  getProducts(opts?: { collection?: string; query?: string; first?: number; after?: string | null; sortKey?: string; reverse?: boolean; filters?: unknown[] }): Promise<ProductPage>;
293
- getRecommendations(productId: string, intent?: 'RELATED' | 'COMPLEMENTARY'): Promise<ShopifyProduct[]>;
306
+ getRecommendations(product: string | Pick<ShopifyProduct, 'id'>, intent?: 'RELATED' | 'COMPLEMENTARY'): Promise<ShopifyProduct[]>;
294
307
  getScope(el: Element | null | undefined): ProductScope | null;
295
308
  addGroup(button: Element): Promise<ShopifyCart | null>;
296
309
  primeProduct(product: ShopifyProduct): void;
297
310
  normalizeProductKey(raw: string | number): string | null;
298
311
  clearProductCache(): void;
299
312
  formatMoney(money: Money | null | undefined): string;
313
+ /** Shopify CDN image at a size: imageUrl(line.merchandise.image?.url, { width: 160 }). */
314
+ imageUrl(url: string | null | undefined, size?: { width?: number; height?: number; crop?: string }): string;
300
315
  plans: SellingPlanHelpers;
301
316
  TetherError: typeof TetherError;
302
317
  resolveProductKey(el: Element, location?: Location | URL): { key: string | null; fromUrl: boolean; raw: string };