@occluze/tether 0.5.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 (66) hide show
  1. package/README.md +9 -7
  2. package/dist/esm/astro.js +1 -1
  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-YXCXIPQV.js +4 -0
  11. package/dist/esm/chunks/{chunk-RBVRRQIU.js → chunk-YZMBXS6Y.js} +1 -1
  12. package/dist/esm/chunks/chunk-ZVUPVSLF.js +1 -0
  13. package/dist/esm/chunks/diagnose-6ZSK5DGE.js +1 -0
  14. package/dist/esm/chunks/extras-QDCSW6VU.js +1 -0
  15. package/dist/esm/chunks/markets-ui-HJCVLCKJ.js +1 -0
  16. package/dist/esm/chunks/search-LN2H6SXS.js +1 -0
  17. package/dist/esm/index.js +1 -1
  18. package/dist/esm/server.js +1 -1
  19. package/dist/tether.account.js +374 -0
  20. package/dist/tether.account.min.js +1 -0
  21. package/dist/tether.analytics.js +259 -0
  22. package/dist/tether.analytics.min.js +1 -0
  23. package/dist/tether.diagnose.js +11 -2
  24. package/dist/tether.diagnose.min.js +1 -1
  25. package/dist/tether.extras.js +87 -0
  26. package/dist/tether.extras.min.js +1 -0
  27. package/dist/tether.js +268 -75
  28. package/dist/tether.markets.js +3 -3
  29. package/dist/tether.markets.min.js +1 -1
  30. package/dist/tether.min.js +3 -3
  31. package/dist/tether.min.js.map +3 -3
  32. package/dist/tether.search.js +176 -0
  33. package/dist/tether.search.min.js +1 -0
  34. package/package.json +5 -2
  35. package/src/account.js +313 -0
  36. package/src/analytics.js +232 -0
  37. package/src/astro.js +4 -1
  38. package/src/cart.js +74 -11
  39. package/src/client.js +8 -3
  40. package/src/collection.js +15 -10
  41. package/src/config.js +3 -0
  42. package/src/diagnose.js +11 -2
  43. package/src/dom.js +7 -1
  44. package/src/extras.js +63 -0
  45. package/src/fragments.js +1 -1
  46. package/src/index.js +103 -41
  47. package/src/initial.js +13 -4
  48. package/src/lazy.js +9 -1
  49. package/src/loader.js +4 -1
  50. package/src/markets-ui.js +2 -1
  51. package/src/mounting.js +27 -2
  52. package/src/parts/account.js +3 -0
  53. package/src/parts/analytics.js +3 -0
  54. package/src/parts/extras.js +3 -0
  55. package/src/parts/search.js +3 -0
  56. package/src/product.js +19 -5
  57. package/src/search.js +153 -0
  58. package/src/server.js +78 -6
  59. package/src/status.js +2 -2
  60. package/types/index.d.ts +56 -3
  61. package/types/server.d.ts +10 -2
  62. package/dist/esm/chunks/chunk-HHMOPDZG.js +0 -1
  63. package/dist/esm/chunks/chunk-HXKV6QGB.js +0 -4
  64. package/dist/esm/chunks/chunk-RNZMTQTX.js +0 -1
  65. package/dist/esm/chunks/diagnose-J667GHGS.js +0 -1
  66. package/dist/esm/chunks/markets-ui-EGWIEUOZ.js +0 -1
@@ -0,0 +1,232 @@
1
+ /**
2
+ * Analytics, as an optional part (loaded when config.analytics is set).
3
+ *
4
+ * analytics: {
5
+ * dataLayer: true, // GA4 / Google Tag Manager ecommerce events (window.dataLayer)
6
+ * shopify: true, // Shopify's own analytics (experimental, see docs/analytics.md)
7
+ * onEvent(name, data) {}, // any other tool: Meta, TikTok, Plausible, …
8
+ * }
9
+ *
10
+ * Events: view_item (the page's product), view_item_list (lists), add_to_cart / remove_from_cart
11
+ * (from the cart's changes, so quick +/- and JS calls count too), view_cart (cart opened),
12
+ * begin_checkout, search (search as you type). Purchases happen on Shopify's checkout: use
13
+ * Shopify's Google & YouTube app or a checkout pixel for those.
14
+ *
15
+ * Shopify analytics: Tether sets up the same event bus Hydrogen uses (window.Shopify.analytics) and
16
+ * loads Shopify's consent script and shopify.js, which build and send the events. Nothing is sent
17
+ * before the Customer Privacy API allows analytics.
18
+ */
19
+ import { cfg, log, reportError } from './config.js';
20
+ import { gql } from './client.js';
21
+ import { sel } from './dom.js';
22
+ import { Cart } from './cart.js';
23
+
24
+ const num = (m) => (m ? parseFloat(m.amount) : 0);
25
+ const id = (gid) => String(gid || '').split('/').pop();
26
+
27
+ // ---------------------------------------------------------------- shapes
28
+
29
+ function itemFromVariant(p, v, quantity = 1, index) {
30
+ return {
31
+ item_id: v?.sku || id(v?.id) || id(p.id),
32
+ item_name: p.title,
33
+ item_variant: v?.title === 'Default Title' ? undefined : v?.title,
34
+ item_brand: p.vendor || undefined,
35
+ item_category: p.productType || undefined,
36
+ price: num(v?.price || p.priceRange?.minVariantPrice),
37
+ quantity,
38
+ ...(index != null ? { index } : {}),
39
+ };
40
+ }
41
+
42
+ function itemFromLine(l, quantity = l.quantity) {
43
+ const m = l.merchandise || {};
44
+ return itemFromVariant({ ...(m.product || {}), title: m.product?.title }, m, quantity);
45
+ }
46
+
47
+ const value = (items) => Math.round(items.reduce((s, i) => s + (i.price || 0) * (i.quantity || 1), 0) * 100) / 100;
48
+
49
+ // ---------------------------------------------------------------- GA4 / GTM
50
+
51
+ function ga(event, ecommerce) {
52
+ const name = typeof cfg.analytics.dataLayer === 'string' ? cfg.analytics.dataLayer : 'dataLayer';
53
+ const dl = (window[name] = window[name] || []);
54
+ if (ecommerce) dl.push({ ecommerce: null }); // Google: clear the previous ecommerce object first
55
+ dl.push(ecommerce ? { event, ecommerce } : { event });
56
+ }
57
+
58
+ // ---------------------------------------------------------------- Shopify analytics (Hydrogen's bus)
59
+
60
+ let bus = null;
61
+
62
+ function shopifyBus(shopId) {
63
+ const destinations = [];
64
+ const buffer = [];
65
+ const privacy = () => window.Shopify?.customerPrivacy;
66
+ const canTrack = () => {
67
+ try {
68
+ const p = privacy();
69
+ return p?.consentStatus === 'loaded' && p.currentVisitorConsent?.()?.analytics !== 'no' && Boolean(p.analyticsProcessingAllowed?.());
70
+ } catch { return false; }
71
+ };
72
+ const config = { shop: { shopId, channel: 'headless' }, consent: { mode: cfg.analytics.shopify?.consent || 'no-banner' } };
73
+ const deliver = () => {
74
+ if (!canTrack()) return;
75
+ destinations.forEach((d) => {
76
+ while (d.next < buffer.length) {
77
+ const { event, payload } = buffer[d.next++];
78
+ (d.subs.get(event) || []).forEach((cb) => { try { cb(payload, d.context); } catch (err) { reportError(err, `analytics ${d.name}`); } });
79
+ }
80
+ });
81
+ };
82
+ const api = {
83
+ getConfig: () => config,
84
+ publish(event, payload = {}) {
85
+ buffer.push({ event, payload: { shop: config.shop, url: window.location.href, ...payload } });
86
+ if (buffer.length > 500) { buffer.shift(); destinations.forEach((d) => { d.next = Math.max(0, d.next - 1); }); }
87
+ deliver();
88
+ },
89
+ addDestination(dest) {
90
+ const tag = `hydrogen:${dest.name}`;
91
+ const d = {
92
+ name: dest.name, next: 0, subs: new Map(),
93
+ context: {
94
+ getTrackingValues: () => {
95
+ const i = canTrack() ? privacy()?.__internal : null;
96
+ return { uniqueToken: i?.uniqueToken?.({ generateFallback: true, tag }) ?? '', visitToken: i?.visitToken?.({ generateFallback: true, tag }) ?? '' };
97
+ },
98
+ },
99
+ };
100
+ const subscribe = (event, cb) => {
101
+ if (!d.subs.has(event)) d.subs.set(event, new Set());
102
+ d.subs.get(event).add(cb);
103
+ return () => d.subs.get(event)?.delete(cb);
104
+ };
105
+ Promise.resolve(dest.setup?.({ subscribe, getConfig: api.getConfig })).then(() => { destinations.push(d); deliver(); })
106
+ .catch((err) => reportError(err, `analytics ${dest.name}`));
107
+ return () => { const i = destinations.indexOf(d); if (i >= 0) destinations.splice(i, 1); };
108
+ },
109
+ };
110
+ document.addEventListener('consentTrackingApiLoaded', deliver);
111
+ document.addEventListener('visitorConsentCollected', deliver);
112
+ return api;
113
+ }
114
+
115
+ function loadScript(src) {
116
+ const s = document.createElement('script');
117
+ s.src = src;
118
+ s.async = true;
119
+ document.head.appendChild(s);
120
+ }
121
+
122
+ async function startShopify() {
123
+ if (window.Shopify?.analytics) { bus = window.Shopify.analytics; return; } // already set up (e.g. by the theme)
124
+ const data = await gql('query TetherShopId { shop { id } }', {}, { context: false });
125
+ const country = String(cfg.country || 'US').toUpperCase();
126
+ const S = (window.Shopify = window.Shopify || {});
127
+ Object.assign(S, {
128
+ country,
129
+ locale: (cfg.locale || 'en').split('-')[0].toLowerCase(), // required by Shopify's script
130
+ shop: cfg.domain,
131
+ routes: { root: '/', match: () => ({ pageTemplateName: mainProduct() ? 'product' : 'page' }), ...(S.routes || {}) },
132
+ });
133
+ const proxy = cfg.analytics.shopify?.proxy; // same-origin Storefront API path, for exact visitor counts
134
+ S.customerPrivacy = {
135
+ ...(S.customerPrivacy || {}),
136
+ config: proxy
137
+ ? { isHeadless: true, asyncConsent: true, asyncVisitorState: true, consentDomain: window.location.host }
138
+ : { isHeadless: true, storefrontAccessToken: cfg.token, checkoutRootDomain: cfg.domain },
139
+ };
140
+ bus = shopifyBus(data.shop.id);
141
+ S.analytics = bus;
142
+ loadScript(cfg.analytics.shopify?.consent === 'default-banner'
143
+ ? 'https://cdn.shopify.com/shopifycloud/privacy-banner/storefront-banner.js'
144
+ : 'https://cdn.shopify.com/shopifycloud/consent-tracking-api/v0.2/consent-tracking-api.js');
145
+ loadScript('https://cdn.shopify.com/storefront/analytics/shopify.js');
146
+ log('shopify analytics ready', data.shop.id);
147
+ }
148
+
149
+ const productPayload = (p, v, quantity = 1) => ({
150
+ id: p.id, title: p.title, price: v.price, vendor: p.vendor || '', variantId: v.id, variantTitle: v.title,
151
+ quantity, sku: v.sku || undefined, productType: p.productType || undefined,
152
+ });
153
+
154
+ function analyticsLine(l) {
155
+ const m = l.merchandise || {};
156
+ return { id: l.id, quantity: l.quantity, merchandise: { id: m.id, title: m.title, price: m.price, sku: m.sku, product: { id: m.product?.id, title: m.product?.title, vendor: m.product?.vendor || '', handle: m.product?.handle } } };
157
+ }
158
+
159
+ // ---------------------------------------------------------------- what happened on the page
160
+
161
+ function mainProduct() {
162
+ const el = document.querySelector(sel('product'));
163
+ return el && !el.closest(`${sel('collection')},${sel('products')},${sel('recommendations')}`) ? window.Tether?.getScope(el) : null;
164
+ }
165
+
166
+ const send = (name, data, shopifyEvent, shopifyPayload) => {
167
+ if (cfg.analytics.dataLayer) ga(name, data);
168
+ if (bus && shopifyEvent) bus.publish(shopifyEvent, shopifyPayload);
169
+ try { cfg.analytics.onEvent?.(name, data); } catch (err) { reportError(err, 'analytics onEvent'); }
170
+ };
171
+
172
+ let lastLines = null; // variant id -> line, to turn cart changes into add/remove events
173
+ let viewed = new WeakSet();
174
+
175
+ function handle(e) {
176
+ const name = e.type.slice(cfg.prefix.length + 1);
177
+ const d = e.detail || {};
178
+ if (name === 'product-ready') {
179
+ const scope = d.scope;
180
+ if (!scope || viewed.has(scope.root) || mainProduct() !== scope) return;
181
+ viewed.add(scope.root);
182
+ const v = scope.variant || d.product.variants.nodes[0];
183
+ const items = [itemFromVariant(d.product, v)];
184
+ send('view_item', { currency: v?.price?.currencyCode, value: value(items), items }, 'product_viewed', { products: [productPayload(d.product, v)] });
185
+ } else if (name === 'list-ready') {
186
+ const items = (d.products || []).slice(0, 200).map((p, i) => itemFromVariant(p, p.variants?.nodes?.[0], 1, i));
187
+ const list = { item_list_id: d.collection?.handle || 'products', item_list_name: d.collection?.title || 'Products' };
188
+ send('view_item_list', { ...list, items }, d.collection ? 'collection_viewed' : null, d.collection ? { collection: { id: d.collection.id, handle: d.collection.handle } } : null);
189
+ } else if (name === 'cart-updated') {
190
+ const cart = d.cart;
191
+ const now = new Map((cart?.lines?.nodes || []).map((l) => [l.merchandise?.id, l]));
192
+ if (lastLines && d.reason !== 'load' && d.reason !== 'pending') {
193
+ const currency = cart?.cost?.totalAmount?.currencyCode;
194
+ now.forEach((l, k) => {
195
+ const diff = l.quantity - (lastLines.get(k)?.quantity || 0);
196
+ if (diff > 0) {
197
+ const items = [itemFromLine(l, diff)];
198
+ send('add_to_cart', { currency, value: value(items), items }, 'product_added_to_cart', {
199
+ cart, prevCart: lastCart, currentLine: analyticsLine(l), prevLine: lastLines.get(k) ? analyticsLine(lastLines.get(k)) : undefined,
200
+ });
201
+ }
202
+ });
203
+ lastLines.forEach((l, k) => {
204
+ const diff = l.quantity - (now.get(k)?.quantity || 0);
205
+ if (diff > 0) { const items = [itemFromLine(l, diff)]; send('remove_from_cart', { currency, value: value(items), items }); }
206
+ });
207
+ }
208
+ lastLines = now;
209
+ lastCart = cart;
210
+ } else if (name === 'cart-toggled' && d.open) {
211
+ const items = Cart.lines.map((l) => itemFromLine(l));
212
+ send('view_cart', { currency: Cart.state?.cost?.totalAmount?.currencyCode, value: value(items), items });
213
+ } else if (name === 'checkout') {
214
+ const items = (d.cart?.lines?.nodes || []).map((l) => itemFromLine(l));
215
+ const coupon = (d.cart?.discountCodes || []).filter((c) => c.applicable).map((c) => c.code).join(',') || undefined;
216
+ send('begin_checkout', { currency: d.cart?.cost?.totalAmount?.currencyCode, value: value(items), coupon, items });
217
+ } else if (name === 'search' && d.term) {
218
+ send('search', { search_term: d.term }, 'search_viewed', { searchTerm: d.term });
219
+ } else if (name === 'page') {
220
+ viewed = new WeakSet();
221
+ if (bus) bus.publish('page_viewed', {});
222
+ }
223
+ }
224
+ let lastCart = null;
225
+
226
+ /** Called by the core with the events it buffered while this part loaded. */
227
+ export async function startAnalytics(queue) {
228
+ if (cfg.analytics.shopify) await startShopify().catch((err) => reportError(err, 'shopify analytics'));
229
+ if (bus) bus.publish('page_viewed', {});
230
+ queue.splice(0).forEach(handle);
231
+ queue.push = (e) => { handle(e); return 0; }; // from now on, handle events as they come
232
+ }
package/src/astro.js CHANGED
@@ -65,7 +65,10 @@ export default function tether(options = {}) {
65
65
  injectScript('head-inline', 'window.TETHER_INTEGRATION="astro";');
66
66
  injectScript('page', [
67
67
  "import Tether from '@occluze/tether';",
68
- `Tether.init({ ...${JSON.stringify({ autoMount: false, ...config })}, ...(window.TETHER_CONFIG || {}) }).then(() => Tether.mount(document));`,
68
+ // As the base config (like window.TETHER_CONFIG), not as overrides: a visitor's saved market
69
+ // (country, money format) then wins over the defaults, as on a plain script-tag site.
70
+ `window.TETHER_CONFIG = { ...${JSON.stringify({ autoMount: false, ...config })}, ...(window.TETHER_CONFIG || {}) };`,
71
+ 'Tether.init().then(() => Tether.mount(document));',
69
72
  ].join('\n'));
70
73
  },
71
74
  },
package/src/cart.js CHANGED
@@ -29,7 +29,7 @@ export function toLineInput(input, quantity = 1, opts = {}) {
29
29
 
30
30
  function mutation(name, args, inner) {
31
31
  return `mutation Tether_${name}(${args}) {
32
- ${name}(${inner}) { cart { ...TetherCart } userErrors { field message } warnings { code message } }
32
+ ${name}(${inner}) { cart { ...TetherCart } userErrors { field message } warnings { code message target } }
33
33
  }
34
34
  ${cartFragment()}`;
35
35
  }
@@ -47,7 +47,8 @@ function ariaState(open) {
47
47
  if (!d.hasAttribute('role')) d.setAttribute('role', 'dialog');
48
48
  if (!d.hasAttribute('aria-label') && !d.hasAttribute('aria-labelledby')) d.setAttribute('aria-label', get(d, 'cart-label') || 'Cart');
49
49
  d.setAttribute('aria-modal', String(open));
50
- if (!d.classList.contains(cfg.cartDrawerOpenClass)) d.setAttribute('aria-hidden', 'true');
50
+ // Closed: hidden from screen readers and out of the Tab order (inert), like a closed dialog.
51
+ if (!d.classList.contains(cfg.cartDrawerOpenClass)) { d.setAttribute('aria-hidden', 'true'); if (cfg.cartFocus !== false) d.inert = true; }
51
52
  });
52
53
  }
53
54
 
@@ -69,6 +70,10 @@ export const Cart = {
69
70
  get count() { return this.state?.totalQuantity || 0; },
70
71
 
71
72
  lastError: null,
73
+ /** Set by the account part while a customer is logged in (Customer Account API token). */
74
+ customerAccessToken: null,
75
+ /** line id -> { qty, done }: what quick +/- clicks asked for, shown until Shopify has answered. */
76
+ pendingQty: new Map(),
72
77
  /** Shopify's notes on the last change, e.g. less stock than asked: [{ code, message }]. */
73
78
  warnings: [],
74
79
  /** true once the saved cart (or the absence of one) is known. */
@@ -76,7 +81,9 @@ export const Cart = {
76
81
 
77
82
  _take(payload, name) {
78
83
  const p = unwrap(payload, name);
79
- this.warnings = p.warnings || [];
84
+ // Shopify can repeat a warning per line or code: each message once.
85
+ this.warnings = (p.warnings || []).filter((w, i, all) => all.findIndex((x) => x.message === w.message) === i)
86
+ .map((w) => ({ ...w, message: (typeof cfg.warningText === 'function' && cfg.warningText(w)) || w.message }));
80
87
  if (this.warnings.length) emit('cart-warning', { warnings: this.warnings });
81
88
  return p.cart;
82
89
  },
@@ -99,6 +106,8 @@ export const Cart = {
99
106
  _commit(cart, reason) {
100
107
  this.state = cart;
101
108
  if (cart?.id) this._setId(cart.id);
109
+ // Other tabs reload the cart (storage event); the count also shows at once on the next page.
110
+ try { localStorage.setItem(`${cfg.storageKey}:seen`, `${Date.now()}|${cart?.totalQuantity || 0}`); } catch { /* private mode */ }
102
111
  this.render();
103
112
  emit('cart-updated', { cart, reason });
104
113
  return cart;
@@ -107,6 +116,13 @@ export const Cart = {
107
116
  async init() {
108
117
  const id = this.id();
109
118
  if (!id) { this.state = null; this.loaded = true; this.render(); emit('cart-updated', { cart: null, reason: 'load' }); return null; }
119
+ // Until the saved cart has loaded, show its last known count instead of the page's 0.
120
+ if (!this.loaded) {
121
+ try {
122
+ const n = (localStorage.getItem(`${cfg.storageKey}:seen`) || '').split('|')[1];
123
+ if (n) qsa(document, sel('cart-count')).forEach((el) => { if (/^\s*0?\s*$/.test(el.textContent)) el.textContent = n; });
124
+ } catch { /* private mode */ }
125
+ }
110
126
  try {
111
127
  const data = await gql(`query TetherCart($id: ID!) { cart(id: $id) { ...TetherCart } }\n${cartFragment()}`, { id });
112
128
  if (!data.cart) { this._setId(null); this.state = null; } else this.state = data.cart;
@@ -124,7 +140,10 @@ export const Cart = {
124
140
  },
125
141
 
126
142
  async _create(input = {}) {
127
- const buyerIdentity = cfg.country ? { countryCode: String(cfg.country).toUpperCase() } : undefined;
143
+ const buyerIdentity = cfg.country || this.customerAccessToken ? {
144
+ ...(cfg.country ? { countryCode: String(cfg.country).toUpperCase() } : {}),
145
+ ...(this.customerAccessToken ? { customerAccessToken: this.customerAccessToken } : {}),
146
+ } : undefined;
128
147
  const data = await gql(
129
148
  mutation('cartCreate', '$input: CartInput!', 'input: $input'),
130
149
  { input: { buyerIdentity, ...input } },
@@ -186,6 +205,26 @@ export const Cart = {
186
205
  ));
187
206
  },
188
207
 
208
+ /**
209
+ * +1 / -1 for a line. Quick clicks add up: each builds on the quantity the last click asked for,
210
+ * which shows at once (tether-line="quantity", Cart.lines) until Shopify has answered.
211
+ */
212
+ changeQuantity(lineId, delta) {
213
+ const current = this.lines.find((l) => l.id === lineId);
214
+ if (!current) return Promise.resolve(this.state);
215
+ const target = Math.max(0, (this.pendingQty.get(lineId)?.qty ?? current.quantity) + delta);
216
+ const job = { qty: target, done: false };
217
+ this.pendingQty.set(lineId, job);
218
+ this.render();
219
+ emit('cart-updated', { cart: this.state, reason: 'pending' });
220
+ return this.updateLine(lineId, target).finally(() => {
221
+ if (this.pendingQty.get(lineId) === job) { this.pendingQty.delete(lineId); this.render(); emit('cart-updated', { cart: this.state, reason: 'settled' }); }
222
+ });
223
+ },
224
+
225
+ /** Quantity to show for a line: the one asked for while an update is on its way. */
226
+ quantityOf(line) { return this.pendingQty.get(line.id)?.qty ?? line.quantity; },
227
+
189
228
  removeLine(lineIds) {
190
229
  const ids = Array.isArray(lineIds) ? lineIds : [lineIds];
191
230
  return this._queue(async () => this._commit(
@@ -227,9 +266,17 @@ export const Cart = {
227
266
  ));
228
267
  },
229
268
 
230
- applyDiscount(code) {
269
+ /**
270
+ * Adds a code. A code Shopify doesn't accept isn't kept in the cart (it would warn on every change):
271
+ * it's removed again and the promise rejects with code INVALID_DISCOUNT.
272
+ */
273
+ async applyDiscount(code) {
231
274
  const current = (this.state?.discountCodes || []).map((d) => d.code);
232
- return this.setDiscountCodes([...new Set([...current, code])]);
275
+ const cart = await this.setDiscountCodes([...new Set([...current, code])]);
276
+ const hit = (cart?.discountCodes || []).find((d) => d.code.toLowerCase() === String(code).toLowerCase());
277
+ if (hit?.applicable) return cart;
278
+ await this.removeDiscount(code).catch(() => {});
279
+ throw new TetherError(`Discount code not valid: ${code}`, { code: 'INVALID_DISCOUNT' });
233
280
  },
234
281
 
235
282
  removeDiscount(code) {
@@ -263,7 +310,8 @@ export const Cart = {
263
310
 
264
311
  try {
265
312
  const data = await gql('query TetherCheckout($id: ID!) { cart(id: $id) { checkoutUrl } }', { id });
266
- const url = data.cart?.checkoutUrl;
313
+ let url = (this.beforeCheckout && await this.beforeCheckout().catch(() => null)) || data.cart?.checkoutUrl;
314
+ if (url && this.customerAccessToken) url += `${url.includes('?') ? '&' : '?'}logged_in=true`;
267
315
  if (!url) throw new TetherError('No checkoutUrl (cart empty or expired)');
268
316
  emit('checkout', { url, cart: this.state });
269
317
  if (tab && !tab.closed) tab.location.href = url;
@@ -280,12 +328,18 @@ export const Cart = {
280
328
 
281
329
  drawers() { return qsa(document, sel('cart-drawer')); },
282
330
 
283
- isOpen() { return this.drawers().some((d) => d.classList.contains(cfg.cartDrawerOpenClass)); },
331
+ /** Without a tether-cart-drawer (a React/Svelte/Vue cart), open/close still work: as state for subscribe. */
332
+ _open: false,
333
+ isOpen() {
334
+ const ds = this.drawers();
335
+ return ds.length ? ds.some((d) => d.classList.contains(cfg.cartDrawerOpenClass)) : this._open;
336
+ },
284
337
 
285
338
  open() {
286
339
  if (this.isOpen()) return;
340
+ this._open = true;
287
341
  const ds = this.drawers().filter((d) => get(d, 'cart-drawer') !== 'manual');
288
- ds.forEach((d) => { d.classList.add(cfg.cartDrawerOpenClass); d.setAttribute('aria-hidden', 'false'); });
342
+ ds.forEach((d) => { d.inert = false; d.classList.add(cfg.cartDrawerOpenClass); d.setAttribute('aria-hidden', 'false'); });
289
343
  ariaState(true);
290
344
  if (cfg.cartFocus !== false && ds[0]) {
291
345
  // Move focus into the cart (after the open transition has made it visible).
@@ -302,9 +356,10 @@ export const Cart = {
302
356
 
303
357
  close() {
304
358
  if (!this.isOpen()) return;
359
+ this._open = false;
305
360
  const ds = this.drawers();
306
361
  const hadFocus = ds.some((d) => d.contains(document.activeElement));
307
- ds.forEach((d) => { d.classList.remove(cfg.cartDrawerOpenClass); d.setAttribute('aria-hidden', 'true'); });
362
+ ds.forEach((d) => { d.classList.remove(cfg.cartDrawerOpenClass); d.setAttribute('aria-hidden', 'true'); if (cfg.cartFocus !== false) d.inert = true; });
308
363
  ariaState(false);
309
364
  if (hadFocus && returnFocus?.isConnected) returnFocus.focus({ preventScroll: true });
310
365
  returnFocus = null;
@@ -397,7 +452,7 @@ function lineValue(line, key) {
397
452
  case 'compare-at-price': return formatMoney(line.cost?.compareAtAmountPerQuantity || m.compareAtPrice);
398
453
  case 'line-total': return formatMoney(line.cost?.totalAmount);
399
454
  case 'line-subtotal': return formatMoney(line.cost?.subtotalAmount);
400
- case 'quantity': return line.quantity;
455
+ case 'quantity': return Cart.quantityOf(line);
401
456
  case 'plan': return plan?.name || '';
402
457
  case 'plan-description': return plan?.description || '';
403
458
  case 'components': return (line.lineComponents || [])
@@ -434,11 +489,19 @@ function renderLine(node, line) {
434
489
  return undefined;
435
490
  });
436
491
 
492
+ // Shopify's note about this line (e.g. less stock than asked): class is-warning, tether-line="warning".
493
+ const warning = Cart.warnings.find((w) => w.target === line.id);
494
+ node.classList.toggle('is-warning', Boolean(warning));
495
+ const pending = Cart.pendingQty.has(line.id);
496
+ node.classList.toggle('is-busy', pending);
497
+ if (pending) node.setAttribute('aria-busy', 'true'); else node.removeAttribute('aria-busy');
498
+ qsa(node, sel('line', 'warning')).forEach((el) => setTextTracked(el, warning?.message || ''));
437
499
  const lineConds = {
438
500
  subscription: isSub,
439
501
  'one-time': !isSub,
440
502
  'on-sale': parseFloat(line.cost?.compareAtAmountPerQuantity?.amount) > parseFloat(line.cost?.amountPerQuantity?.amount),
441
503
  'has-components': (line.lineComponents || []).length > 0,
504
+ warning: Boolean(warning),
442
505
  };
443
506
  qsa(node, sel('line-show')).forEach((el) => {
444
507
  const k = get(el, 'line-show');
package/src/client.js CHANGED
@@ -48,7 +48,12 @@ export async function request(conf, query, variables = {}, { context = true } =
48
48
  throw new TetherError('Tether is not configured: domain and token are required', { code: 'NOT_CONFIGURED' });
49
49
  }
50
50
 
51
- const body = JSON.stringify({ query: context ? withContext(query, conf) : 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 });
52
57
  let attempt = 0;
53
58
 
54
59
  for (;;) {
@@ -89,8 +94,8 @@ export async function request(conf, query, variables = {}, { context = true } =
89
94
  return request(conf, query.replace(/\bquantityAvailable\b/g, ''), variables, { context });
90
95
  }
91
96
  // An older apiVersion without cart warnings (before 2024-07): retry without them.
92
- if (json.errors.some((e) => /'warnings'/.test(e.message || '')) && /warnings \{ code message \}/.test(query)) {
93
- return request(conf, query.replace(/ warnings \{ code message \}/g, ''), variables, { context });
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 });
94
99
  }
95
100
  const throttled = json.errors.some((e) => e.extensions?.code === 'THROTTLED');
96
101
  if (throttled && attempt++ < conf.retries) { await sleep(500 * 2 ** attempt); continue; }
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
@@ -36,6 +36,9 @@ export const DEFAULTS = {
36
36
 
37
37
  // Subscriptions
38
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)
39
42
 
40
43
  // Mounting
41
44
  autoMount: true, // mount document on DOMContentLoaded
package/src/diagnose.js CHANGED
@@ -63,7 +63,7 @@ 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
69
  'cart-empty', 'cart-filled', 'cart-has-subscription', 'cart-no-subscription', 'cart-has-discount', 'cart-error', 'cart-warning',
@@ -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);
@@ -168,7 +174,7 @@ function template(container) {
168
174
  /** Rebuilds the list on every render. Fine for images, tags, options. */
169
175
  export function repeat(container, items, render) {
170
176
  const tpl = template(container);
171
- if (!tpl) { warn('repeater without a template child', container); return; }
177
+ if (!tpl) { if (items.length) warn('repeater without a template child', container); return; }
172
178
  container.innerHTML = '';
173
179
  items.forEach((item, i) => {
174
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
+ }