@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/client.js CHANGED
@@ -15,8 +15,8 @@ export class TetherError extends Error {
15
15
  }
16
16
  }
17
17
 
18
- export function endpoint() {
19
- return `https://${cfg.domain}/api/${cfg.apiVersion}/graphql.json`;
18
+ export function endpoint(conf = cfg) {
19
+ return `https://${conf.domain}/api/${conf.apiVersion}/graphql.json`;
20
20
  }
21
21
 
22
22
  /**
@@ -31,42 +31,51 @@ export function withContext(query, ctx = cfg) {
31
31
  if (ctx.language) args.push(`language: ${String(ctx.language).toUpperCase().replace('-', '_')}`);
32
32
  if (!args.length || /@inContext/.test(query)) return query;
33
33
 
34
- return query.replace(
35
- /^(\s*(?:query|mutation)\s+\w+\s*(?:\([^)]*\))?)/,
36
- `$1 @inContext(${args.join(', ')})`,
37
- );
34
+ const tag = `@inContext(${args.join(', ')})`;
35
+ // Named (query Name($x: T)), anonymous (query { … }) and shorthand ({ … }) queries.
36
+ if (/^\s*\{/.test(query)) return `query ${tag} ${query.trimStart()}`;
37
+ return query.replace(/^(\s*(?:query|mutation)(?:\s+\w+)?\s*(?:\([^)]*\))?)/, `$1 ${tag}`);
38
38
  }
39
39
 
40
40
  const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
41
41
 
42
- export async function gql(query, variables = {}, { context = true } = {}) {
43
- if (!cfg.domain || !cfg.token) {
42
+ /**
43
+ * One Storefront API request with retries, for any config object (the browser's shared config,
44
+ * or the server module's own). gql() below is the browser version.
45
+ */
46
+ export async function request(conf, query, variables = {}, { context = true } = {}) {
47
+ if (!conf.domain || !conf.token) {
44
48
  throw new TetherError('Tether is not configured: domain and token are required', { code: 'NOT_CONFIGURED' });
45
49
  }
46
50
 
47
- const body = JSON.stringify({ query: context ? withContext(query) : query, variables });
51
+ // Shopify caches the response of an identical query text (prices included, for hours after a
52
+ // change). A time bucket in a comment keeps answers at most maxAge seconds old (default 60).
53
+ let text = context ? withContext(query, conf) : query;
54
+ const age = conf.maxAge ?? 60;
55
+ if (age > 0 && !/^\s*mutation\b/.test(text)) text += ` #${Math.floor(Date.now() / (age * 1000))}`;
56
+ const body = JSON.stringify({ query: text, variables });
48
57
  let attempt = 0;
49
58
 
50
59
  for (;;) {
51
60
  let res;
52
61
  try {
53
- res = await fetch(endpoint(), {
62
+ res = await (conf.fetch || fetch)(endpoint(conf), {
54
63
  method: 'POST',
55
64
  headers: {
56
65
  'Content-Type': 'application/json',
57
- 'X-Shopify-Storefront-Access-Token': cfg.token,
66
+ 'X-Shopify-Storefront-Access-Token': conf.token,
58
67
  },
59
68
  body,
60
69
  });
61
70
  } catch (err) {
62
- if (attempt++ < cfg.retries) { await sleep(300 * 2 ** attempt); continue; }
71
+ if (attempt++ < conf.retries) { await sleep(300 * 2 ** attempt); continue; }
63
72
  throw new TetherError(`Network error: ${err.message}`, { code: 'NETWORK' });
64
73
  }
65
74
 
66
75
  if (res.status === 430) throw new TetherError('Shopify security rejection (430)', { status: 430 });
67
76
 
68
77
  const retryable = res.status === 429 || res.status >= 500;
69
- if (retryable && attempt++ < cfg.retries) {
78
+ if (retryable && attempt++ < conf.retries) {
70
79
  log('retry', res.status, attempt);
71
80
  await sleep(300 * 2 ** attempt);
72
81
  continue;
@@ -82,10 +91,14 @@ export async function gql(query, variables = {}, { context = true } = {}) {
82
91
  const inventoryDenied = json.errors.some((e) => /quantityAvailable/.test(e.message || ''));
83
92
  if (inventoryDenied && /\bquantityAvailable\b/.test(body)) {
84
93
  log('no inventory permission, retrying without quantityAvailable');
85
- return gql(query.replace(/\bquantityAvailable\b/g, ''), variables, { context });
94
+ return request(conf, query.replace(/\bquantityAvailable\b/g, ''), variables, { context });
95
+ }
96
+ // An older apiVersion without cart warnings (before 2024-07): retry without them.
97
+ if (json.errors.some((e) => /'warnings'/.test(e.message || '')) && /warnings \{ code message target \}/.test(query)) {
98
+ return request(conf, query.replace(/ warnings \{ code message target \}/g, ''), variables, { context });
86
99
  }
87
100
  const throttled = json.errors.some((e) => e.extensions?.code === 'THROTTLED');
88
- if (throttled && attempt++ < cfg.retries) { await sleep(500 * 2 ** attempt); continue; }
101
+ if (throttled && attempt++ < conf.retries) { await sleep(500 * 2 ** attempt); continue; }
89
102
  throw new TetherError(json.errors.map((e) => e.message).join(' | '), {
90
103
  status: res.status, errors: json.errors,
91
104
  });
@@ -96,6 +109,8 @@ export async function gql(query, variables = {}, { context = true } = {}) {
96
109
  }
97
110
  }
98
111
 
112
+ export const gql = (query, variables, opts) => request(cfg, query, variables, opts);
113
+
99
114
  /** Throws on mutation userErrors, returns the payload otherwise. */
100
115
  export function unwrap(payload, name) {
101
116
  if (!payload) throw new TetherError(`${name}: empty response`);
package/src/collection.js CHANGED
@@ -5,9 +5,10 @@
5
5
  * quick-add with a subscription toggle).
6
6
  */
7
7
  import { reportError } from './config.js';
8
- import { attr, sel, get, has, scoped, emit, setText, setImage, repeat, notifyLayoutChanged, busy } from './dom.js';
9
- import { getProducts, getRecommendations, getProduct } from './loader.js';
10
- import { setScopeStatus, isNotFound } from './status.js';
8
+ import { TetherError } from './client.js';
9
+ import { attr, sel, get, has, scoped, emit, setText, setImage, repeat, notifyLayoutChanged, busy, show } from './dom.js';
10
+ import { getProducts, getRecommendations } from './loader.js';
11
+ import { setScopeStatus, isNotFound, resolveProductKey } from './status.js';
11
12
 
12
13
  export const listSelector = () => `${sel('collection')},${sel('products')},${sel('recommendations')}`;
13
14
 
@@ -46,9 +47,12 @@ export class ListScope {
46
47
  const stale = () => token !== this.loadToken;
47
48
  try {
48
49
  if (has(this.root, 'recommendations')) {
49
- const key = get(this.root, 'recommendations');
50
- const base = await getProduct(key);
51
- const recs = await getRecommendations(base.id, get(this.root, 'intent') || 'RELATED');
50
+ // A handle, url:handle (one dynamic product page), or empty inside a product: that product.
51
+ let { key } = resolveProductKey(this.root, window.location, 'recommendations');
52
+ const scope = this.root.closest(sel('product'));
53
+ if (!key && scope) ({ key } = resolveProductKey(scope));
54
+ if (!key) throw new TetherError('No product for recommendations', { code: 'NOT_FOUND' });
55
+ const recs = await getRecommendations(key, get(this.root, 'intent') || 'RELATED');
52
56
  if (stale()) return;
53
57
  const limit = parseInt(get(this.root, 'limit') || '', 10);
54
58
  this.products = limit ? recs.slice(0, limit) : recs;
@@ -66,7 +70,8 @@ export class ListScope {
66
70
  if (stale()) return;
67
71
  // Keep what's already rendered on a failed "load more"; otherwise show the error state.
68
72
  if (!more) setScopeStatus(this.root, isNotFound(err) ? 'not-found' : 'error', err);
69
- reportError(err, 'list load failed');
73
+ // A missing product for recommendations is a page detail, not an error to report.
74
+ if (!(isNotFound(err) && has(this.root, 'recommendations'))) reportError(err, 'list load failed');
70
75
  emit('error', { error: err, context: 'list' }, this.root);
71
76
  }
72
77
  }
@@ -95,10 +100,10 @@ export class ListScope {
95
100
  const empty = this.products.length === 0;
96
101
  this.$(sel('show')).forEach((el) => {
97
102
  const k = get(el, 'show');
98
- if (k === 'list-empty') el.style.display = empty ? '' : 'none';
99
- if (k === 'list-filled') el.style.display = empty ? 'none' : '';
103
+ if (k === 'list-empty') show(el, empty);
104
+ if (k === 'list-filled') show(el, !empty);
100
105
  });
101
- this.$(sel('load-more')).forEach((el) => { el.style.display = this.pageInfo?.hasNextPage ? '' : 'none'; });
106
+ this.$(sel('load-more')).forEach((el) => show(el, Boolean(this.pageInfo?.hasNextPage)));
102
107
  notifyLayoutChanged('list');
103
108
  }
104
109
  }
package/src/config.js CHANGED
@@ -25,6 +25,7 @@ export const DEFAULTS = {
25
25
  sellingPlansFirst: 25,
26
26
  collectionFirst: 24,
27
27
  retries: 2,
28
+ revalidate: true, // products from the server (tether/server hydrate()): refresh once in the background
28
29
 
29
30
  // Cart
30
31
  storageKey: null, // defaults to `tether:cart:<domain>`
@@ -35,10 +36,14 @@ export const DEFAULTS = {
35
36
 
36
37
  // Subscriptions
37
38
  defaultPurchase: 'one-time', // 'one-time' | 'subscription'
39
+ variantUrl: false, // true: ?variant= follows the visitor's choice on the main product (shareable links)
40
+ maxAge: 60, // seconds a Shopify answer may be cached (0 = Shopify's own caching, can be hours)
41
+ lowStock: 5, // tether-show="low-stock" at this many or fewer (needs inventory access)
38
42
 
39
43
  // Mounting
40
44
  autoMount: true, // mount document on DOMContentLoaded
41
45
  observe: false, // auto-mount nodes added later (CMS load-more, filters)
46
+ astro: true, // mount on Astro View Transitions (astro:page-load); false if you do it yourself
42
47
  partsUrl: null, // folder of tether.markets/diagnose(.min).js for the <script> build (default: next to it)
43
48
 
44
49
  // Formatting
package/src/diagnose.js CHANGED
@@ -63,10 +63,10 @@ export function checkMarkup(root = document) {
63
63
  });
64
64
 
65
65
  const known = new Set([
66
- 'in-stock', 'out-of-stock', 'backorder', 'low-stock', 'on-sale', 'not-on-sale', 'has-variants', 'single-variant',
66
+ 'in-stock', 'out-of-stock', 'backorder', 'low-stock', 'plenty-in-stock', 'on-sale', 'not-on-sale', 'has-variants', 'single-variant',
67
67
  'subscription-available', 'subscription-unavailable', 'subscription-only', 'one-time-available',
68
68
  'subscription-selected', 'one-time-selected', 'has-savings', 'loading', 'ready', 'error', 'not-found',
69
- 'cart-empty', 'cart-filled', 'cart-has-subscription', 'cart-no-subscription', 'cart-has-discount', 'cart-error',
69
+ 'cart-empty', 'cart-filled', 'cart-has-subscription', 'cart-no-subscription', 'cart-has-discount', 'cart-error', 'cart-warning',
70
70
  'list-empty', 'list-filled',
71
71
  ]);
72
72
  qsa(root, sel('show')).forEach((el) => {
@@ -107,7 +107,8 @@ export async function diagnose({ root = document, silent = false, version, now =
107
107
  autoMount: cfg.autoMount, observe: cfg.observe, onError: typeof cfg.onError === 'function',
108
108
  },
109
109
  apiVersion: apiVersionStatus(cfg.apiVersion, now),
110
- market: cfg.markets?.detect ? detectMarket(cfg.markets) : null,
110
+ // The market in use (a visitor's choice included), plus what detection would pick.
111
+ market: { country: cfg.country || null, language: cfg.language || null, locale: cfg.locale || null, detected: cfg.markets?.detect ? detectMarket(cfg.markets) : null },
111
112
  reachable: false,
112
113
  shop: null,
113
114
  latencyMs: null,
@@ -141,6 +142,14 @@ export async function diagnose({ root = document, silent = false, version, now =
141
142
  const plans = allPlans(p);
142
143
  report.products.push({ key: keys[i], handle: p.handle, title: p.title, plans: plans.length, requiresSellingPlan: p.requiresSellingPlan });
143
144
  plans.forEach((pl) => report.sellingPlans.push({ product: p.handle, group: pl.group?.name, plan: pl.name, id: pl.id }));
145
+ // Options a visitor can't choose on this page (they get the first available variant's value).
146
+ qsa(root, sel('product')).filter((el) => resolveProductKey(el).key === keys[i] && el.querySelector(sel('add-to-cart'))).forEach((el) => {
147
+ const shown = new Set(qsa(el, `${sel('option-list')},select${sel('option')}`).map((x) => get(x, 'option-list') || get(x, 'option')));
148
+ if (!shown.size && qsa(el, `select${sel('variant-select')}`).length) return;
149
+ (p.options || []).filter((o) => o.optionValues.length > 1 && !shown.has(o.name)).forEach((o) => {
150
+ report.problems.push({ level: 'warn', message: `Product "${p.handle}": option "${o.name}" (${o.optionValues.length} values) has no tether-option-list, so visitors can't choose it.` });
151
+ });
152
+ });
144
153
  } else {
145
154
  report.products.push({ key: keys[i], error: r.reason?.message });
146
155
  report.problems.push({ level: 'error', message: `Product "${keys[i]}": ${r.reason?.message}. Is it published to the Headless channel?` });
package/src/dom.js CHANGED
@@ -33,6 +33,12 @@ export function emit(name, detail, target = document) {
33
33
  target.dispatchEvent(new CustomEvent(`${cfg.prefix}:${name}`, { detail, bubbles: true }));
34
34
  }
35
35
 
36
+ /** 30 -> "30%" (en, nl) or "30 %" (de, fr), in the configured locale. */
37
+ export function formatPercent(n, locale = cfg.locale) {
38
+ if (!n) return '';
39
+ try { return new Intl.NumberFormat(locale || undefined, { style: 'percent', maximumFractionDigits: 0 }).format(n / 100); } catch { return `${n}%`; }
40
+ }
41
+
36
42
  export function formatMoney(money) {
37
43
  if (!money) return '';
38
44
  if (typeof cfg.formatMoney === 'function') return cfg.formatMoney(money, cfg.locale);
@@ -53,24 +59,37 @@ export function formatDate(iso) {
53
59
  }
54
60
 
55
61
  /** Shopify CDN resize via max-width / max-height / crop attributes on the element. */
56
- export function cdnUrl(url, el) {
57
- if (!url || !el) return url;
58
- const w = el.getAttribute('max-width');
59
- const h = el.getAttribute('max-height');
60
- const crop = el.getAttribute('crop');
61
- if (!w && !h) return url;
62
+ /**
63
+ * Shopify CDN image at a size: imageUrl(url, { width: 800 }). Use the same width as the element's
64
+ * max-width attribute, so the browser script keeps the image instead of loading it again.
65
+ */
66
+ export function imageUrl(url, { width, height, crop } = {}) {
67
+ if (!url) return url;
62
68
  try {
63
69
  const u = new URL(url);
64
- if (w) u.searchParams.set('width', w);
65
- if (h) u.searchParams.set('height', h);
70
+ if (width) u.searchParams.set('width', String(width));
71
+ if (height) u.searchParams.set('height', String(height));
66
72
  if (crop) u.searchParams.set('crop', crop);
67
73
  return u.toString();
68
74
  } catch { return url; }
69
75
  }
70
76
 
77
+ export function cdnUrl(url, el) {
78
+ if (!url || !el) return url;
79
+ const g = (k) => el.getAttribute(k);
80
+ return imageUrl(url, { width: g('max-width'), height: g('max-height'), crop: g('max-width') || g('max-height') ? g('crop') : null });
81
+ }
82
+
83
+ /** Same image, whatever size parameters: the server-rendered one is kept (no second download). */
84
+ export const sameImage = (a, b) => Boolean(a && b) && a.split('?')[0] === b.split('?')[0];
85
+
71
86
  export function setImage(el, url, alt) {
72
87
  if (!url) return;
73
88
  const src = cdnUrl(url, el);
89
+ if (el.tagName === 'IMG' && sameImage(el.getAttribute('src'), src)) {
90
+ if (alt != null && !el.getAttribute('alt')) el.setAttribute('alt', alt);
91
+ return;
92
+ }
74
93
  if (el.tagName === 'IMG') {
75
94
  el.removeAttribute('srcset');
76
95
  el.removeAttribute('sizes');
@@ -104,14 +123,24 @@ export function setTextTracked(el, v) {
104
123
  export function show(el, visible) {
105
124
  const k = 'tetherPrevDisplay';
106
125
  if (visible) {
126
+ if (el.dataset.tetherShown !== undefined) return; // already visible, nothing to check
127
+ el.dataset.tetherShown = '';
128
+ el.removeAttribute('hidden');
107
129
  if (el.dataset[k] !== undefined) {
108
130
  el.style.display = el.dataset[k];
109
131
  delete el.dataset[k];
110
132
  } else if (el.style.display === 'none') {
111
133
  el.style.display = '';
112
134
  }
135
+ // Hidden before the script ran (a class, an inline display:none, a hidden attribute): show it
136
+ // as block or the value of tether-display.
137
+ if (typeof getComputedStyle === 'function' && getComputedStyle(el).display === 'none') {
138
+ el.style.display = el.getAttribute(`${cfg.prefix}-display`) || 'block';
139
+ }
113
140
  } else {
114
- if (el.dataset[k] === undefined) el.dataset[k] = el.style.display || '';
141
+ delete el.dataset.tetherShown;
142
+ // Remember a real display value to restore, never 'none'.
143
+ if (el.dataset[k] === undefined && el.style.display !== 'none') el.dataset[k] = el.style.display || '';
115
144
  el.style.display = 'none';
116
145
  }
117
146
  }
@@ -145,7 +174,7 @@ function template(container) {
145
174
  /** Rebuilds the list on every render. Fine for images, tags, options. */
146
175
  export function repeat(container, items, render) {
147
176
  const tpl = template(container);
148
- if (!tpl) { warn('repeater without a template child', container); return; }
177
+ if (!tpl) { if (items.length) warn('repeater without a template child', container); return; }
149
178
  container.innerHTML = '';
150
179
  items.forEach((item, i) => {
151
180
  const node = tpl.cloneNode(true);
package/src/extras.js ADDED
@@ -0,0 +1,63 @@
1
+ /**
2
+ * Optional part with features that load when first needed: bundles (on the first "add set"
3
+ * click), tab sync (on the first change in another tab) and shareable variant links (on the
4
+ * first variant change, with variantUrl or tether-variant-url).
5
+ */
6
+ import { cfg, warn, reportError } from './config.js';
7
+ import { sel, get, has, qsa, emit, parseJSONAttr, toAttributes } from './dom.js';
8
+ import { scopes } from './product.js';
9
+ import { Cart } from './cart.js';
10
+
11
+ /**
12
+ * [tether-group] + [tether-group-add]: adds every product scope inside the group in one request.
13
+ * Bundles, build-a-box, "complete the set". Lines get attribute _group (and tether-line-attributes).
14
+ */
15
+ export async function addGroup(btn) {
16
+ const group = btn.closest(sel('group'));
17
+ if (!group) return null;
18
+ const name = get(group, 'group');
19
+ const extra = toAttributes(parseJSONAttr(group, 'line-attributes'));
20
+ const groupAttrs = name ? [{ key: '_group', value: name }, ...extra] : extra;
21
+ const lines = qsa(group, sel('product'))
22
+ .map((el) => scopes.get(el))
23
+ .filter((s) => s && s.variant?.availableForSale && s.quantity > 0)
24
+ .map((s) => {
25
+ const l = s.getLine();
26
+ l.attributes = [...(l.attributes || []), ...groupAttrs];
27
+ return l;
28
+ });
29
+ if (!lines.length) { warn('group-add: nothing selected'); return null; }
30
+ try {
31
+ const cart = await Cart.add(lines);
32
+ emit('group-added', { group: name, lines }, group);
33
+ return cart;
34
+ } catch (err) { reportError(err, 'group add'); return null; }
35
+ }
36
+
37
+ /** Another tab changed the cart (reload it here) or the visitor's market (follow it). */
38
+ export function onStorage(e) {
39
+ if (e.key === cfg.storageKey || e.key === `${cfg.storageKey}:seen`) Cart.init();
40
+ else if (e.key === `${cfg.prefix}:market:${cfg.domain}` && e.newValue) {
41
+ try {
42
+ const m = JSON.parse(e.newValue);
43
+ if (m?.country) window.Tether?.setCountry(m.country, { language: m.language ?? null, locale: m.locale, remember: false }).catch(() => {});
44
+ } catch { /* ignore */ }
45
+ }
46
+ }
47
+
48
+ const urlKeys = new WeakMap(); // scope root -> last variant|plan, so the first render doesn't write
49
+
50
+ /** ?variant= (and selling_plan=) follow the visitor's choice. */
51
+ export function writeVariantUrl(root, { variant, sellingPlanId }) {
52
+ const key = `${variant.id}|${sellingPlanId || ''}`;
53
+ const before = urlKeys.get(root);
54
+ urlKeys.set(root, key);
55
+ if (!before || before === key) return;
56
+ if (!has(root, 'variant-url') && !(cfg.variantUrl && document.querySelector(sel('product')) === root)) return;
57
+ try {
58
+ const u = new URL(window.location.href);
59
+ u.searchParams.set('variant', variant.id.split('/').pop());
60
+ if (sellingPlanId) u.searchParams.set('selling_plan', sellingPlanId.split('/').pop()); else u.searchParams.delete('selling_plan');
61
+ window.history.replaceState(window.history.state, '', u);
62
+ } catch { /* ignore */ }
63
+ }
package/src/fragments.js CHANGED
@@ -7,8 +7,8 @@ import { cfg } from './config.js';
7
7
  const MONEY = 'amount currencyCode';
8
8
  const IMAGE = 'url altText width height';
9
9
 
10
- export function metafieldIdentifiers() {
11
- return (cfg.metafields || []).map((m) => {
10
+ export function metafieldIdentifiers(o = cfg) {
11
+ return (o.metafields || []).map((m) => {
12
12
  if (typeof m === 'string') {
13
13
  const i = m.indexOf('.');
14
14
  return { namespace: m.slice(0, i), key: m.slice(i + 1) };
@@ -17,8 +17,8 @@ export function metafieldIdentifiers() {
17
17
  }).filter((m) => m.namespace && m.key);
18
18
  }
19
19
 
20
- function metafieldsField() {
21
- const ids = metafieldIdentifiers();
20
+ function metafieldsField(o = cfg) {
21
+ const ids = metafieldIdentifiers(o);
22
22
  if (!ids.length) return '';
23
23
  const list = ids
24
24
  .map((m) => `{namespace: ${JSON.stringify(m.namespace)}, key: ${JSON.stringify(m.key)}}`)
@@ -53,26 +53,27 @@ export const ALLOCATION_FIELDS = `
53
53
  unitPrice { ${MONEY} }
54
54
  }`;
55
55
 
56
- export function productFragment() {
56
+ /** The product fields Tether renders. `o` = config (counts, metafields); the server module passes its own. */
57
+ export function productFragment(o = cfg) {
57
58
  return `
58
59
  fragment TetherProduct on Product {
59
60
  id handle title description descriptionHtml productType vendor tags
60
61
  availableForSale publishedAt onlineStoreUrl requiresSellingPlan
61
62
  seo { title description }
62
63
  featuredImage { ${IMAGE} }
63
- images(first: ${cfg.imagesFirst}) { nodes { ${IMAGE} } }
64
+ images(first: ${o.imagesFirst}) { nodes { ${IMAGE} } }
64
65
  options { name optionValues { name swatch { color image { previewImage { ${IMAGE} } } } } }
65
66
  priceRange { minVariantPrice { ${MONEY} } maxVariantPrice { ${MONEY} } }
66
67
  compareAtPriceRange { minVariantPrice { ${MONEY} } maxVariantPrice { ${MONEY} } }
67
- ${metafieldsField()}
68
- sellingPlanGroups(first: ${cfg.sellingPlanGroupsFirst}) {
68
+ ${metafieldsField(o)}
69
+ sellingPlanGroups(first: ${o.sellingPlanGroupsFirst}) {
69
70
  nodes {
70
71
  name appName
71
72
  options { name values }
72
- sellingPlans(first: ${cfg.sellingPlansFirst}) { nodes { ${SELLING_PLAN_FIELDS} } }
73
+ sellingPlans(first: ${o.sellingPlansFirst}) { nodes { ${SELLING_PLAN_FIELDS} } }
73
74
  }
74
75
  }
75
- variants(first: ${cfg.variantsFirst}) {
76
+ variants(first: ${o.variantsFirst}) {
76
77
  nodes {
77
78
  id title availableForSale currentlyNotInStock quantityAvailable sku barcode
78
79
  selectedOptions { name value }
@@ -80,7 +81,7 @@ fragment TetherProduct on Product {
80
81
  price { ${MONEY} }
81
82
  compareAtPrice { ${MONEY} }
82
83
  unitPrice { ${MONEY} }
83
- sellingPlanAllocations(first: ${cfg.sellingPlansFirst}) { nodes { ${ALLOCATION_FIELDS} } }
84
+ sellingPlanAllocations(first: ${o.sellingPlansFirst}) { nodes { ${ALLOCATION_FIELDS} } }
84
85
  }
85
86
  }
86
87
  }`;
@@ -116,7 +117,7 @@ fragment TetherCart on Cart {
116
117
  }
117
118
  merchandise {
118
119
  ... on ProductVariant {
119
- id title sku availableForSale requiresShipping
120
+ id title sku availableForSale requiresShipping quantityAvailable
120
121
  image { ${IMAGE} }
121
122
  price { ${MONEY} }
122
123
  compareAtPrice { ${MONEY} }