@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@occluze/tether",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "description": "Free, open-source alternative to Smootify and Storesynk (Shopyflow): Shopify products, cart and subscriptions in Webflow with HTML attributes. Optional Shopify to Webflow CMS sync.",
5
5
  "type": "module",
6
6
  "main": "dist/esm/index.js",
@@ -10,6 +10,14 @@
10
10
  "types": "./types/index.d.ts",
11
11
  "default": "./dist/esm/index.js"
12
12
  },
13
+ "./server": {
14
+ "types": "./types/server.d.ts",
15
+ "default": "./dist/esm/server.js"
16
+ },
17
+ "./astro": {
18
+ "types": "./types/astro.d.ts",
19
+ "default": "./dist/esm/astro.js"
20
+ },
13
21
  "./presets/barba": {
14
22
  "types": "./types/barba.d.ts",
15
23
  "default": "./dist/presets/barba.esm.js"
@@ -22,7 +30,9 @@
22
30
  "LICENSE",
23
31
  "README.md",
24
32
  "types/index.d.ts",
25
- "types/barba.d.ts"
33
+ "types/barba.d.ts",
34
+ "types/server.d.ts",
35
+ "types/astro.d.ts"
26
36
  ],
27
37
  "scripts": {
28
38
  "build": "node scripts/build.mjs",
@@ -53,25 +63,38 @@
53
63
  "url": "https://github.com/ravi-klaassens/tether/issues"
54
64
  },
55
65
  "keywords": [
66
+ "astro",
67
+ "astro-integration",
56
68
  "cart",
57
69
  "ecommerce",
58
70
  "headless",
71
+ "headless-commerce",
59
72
  "headless-shopify",
60
73
  "selling-plans",
61
74
  "shopify",
62
75
  "shopify-subscriptions",
63
76
  "shopyflow-alternative",
64
77
  "smootify-alternative",
78
+ "ssr",
65
79
  "storefront-api",
66
80
  "storesynk-alternative",
67
81
  "subscriptions",
68
82
  "webflow",
69
83
  "webflow-cms",
70
84
  "webflow-ecommerce",
71
- "webflow-shopify"
85
+ "webflow-shopify",
86
+ "withastro"
72
87
  ],
73
88
  "publishConfig": {
74
89
  "access": "public"
75
90
  },
76
- "types": "types/index.d.ts"
91
+ "types": "types/index.d.ts",
92
+ "peerDependencies": {
93
+ "astro": ">=4"
94
+ },
95
+ "peerDependenciesMeta": {
96
+ "astro": {
97
+ "optional": true
98
+ }
99
+ }
77
100
  }
package/src/astro.js ADDED
@@ -0,0 +1,73 @@
1
+ /**
2
+ * Astro integration: Tether on every page, configured from .env.
3
+ *
4
+ * // astro.config.mjs
5
+ * import { defineConfig } from 'astro/config';
6
+ * import tether from '@occluze/tether/astro';
7
+ * export default defineConfig({ integrations: [tether({ country: 'NL', locale: 'nl-NL' })] });
8
+ *
9
+ * // .env
10
+ * PUBLIC_SHOPIFY_DOMAIN=your-store.myshopify.com
11
+ * PUBLIC_SHOPIFY_STOREFRONT_TOKEN=your-public-storefront-token
12
+ *
13
+ * Options are Tether's config (domain and token override .env). Functions (onError, formatMoney,
14
+ * hooks) can't be passed here: set them in window.TETHER_CONFIG in your layout, it is merged in.
15
+ * View Transitions (<ClientRouter />) work without extra code.
16
+ */
17
+
18
+ const ENV_KEYS = {
19
+ domain: ['PUBLIC_SHOPIFY_DOMAIN', 'PUBLIC_SHOPIFY_STORE_DOMAIN', 'SHOPIFY_DOMAIN'],
20
+ token: ['PUBLIC_SHOPIFY_STOREFRONT_TOKEN', 'PUBLIC_SHOPIFY_STOREFRONT_ACCESS_TOKEN', 'SHOPIFY_STOREFRONT_TOKEN'],
21
+ };
22
+
23
+ /** .env files the way Vite reads them (later files win), plus process.env on top. */
24
+ async function readEnv(command, root) {
25
+ const mode = command === 'dev' ? 'development' : 'production';
26
+ const out = {};
27
+ try {
28
+ const { readFile } = await import('node:fs/promises');
29
+ const { join } = await import('node:path');
30
+ for (const name of ['.env', '.env.local', `.env.${mode}`, `.env.${mode}.local`]) {
31
+ let text;
32
+ try { text = await readFile(join(root, name), 'utf8'); } catch { continue; }
33
+ for (const line of text.split(/\r?\n/)) {
34
+ const m = /^\s*(?:export\s+)?([\w.-]+)\s*=\s*(.*?)\s*$/.exec(line);
35
+ if (!m || line.trim().startsWith('#')) continue;
36
+ let v = m[2];
37
+ const q = /^(["'])(.*?)\1/.exec(v); // "quoted" (a # comment may follow)
38
+ v = q ? q[2] : v.replace(/\s+#.*$/, '');
39
+ out[m[1]] = v;
40
+ }
41
+ }
42
+ } catch { /* not in Node: nothing to read */ }
43
+ return { ...out, ...process.env };
44
+ }
45
+
46
+ export default function tether(options = {}) {
47
+ const { inject = true, ...config } = options;
48
+ return {
49
+ name: '@occluze/tether',
50
+ hooks: {
51
+ 'astro:config:setup': async ({ injectScript, command, config: astroConfig, logger }) => {
52
+ const root = astroConfig?.root ? decodeURIComponent(new URL(astroConfig.root).pathname) : process.cwd();
53
+ const env = await readEnv(command, root);
54
+ for (const [key, names] of Object.entries(ENV_KEYS)) {
55
+ if (!config[key]) config[key] = names.map((n) => env[n]).find(Boolean);
56
+ }
57
+ const bad = Object.entries(config).filter(([, v]) => typeof v === 'function').map(([k]) => k);
58
+ bad.forEach((k) => delete config[k]);
59
+ if (bad.length) logger.warn(`${bad.join(', ')}: functions can't be passed here. Set them in window.TETHER_CONFIG in your layout.`);
60
+ if (!config.domain || !config.token) {
61
+ logger.warn('No Shopify store configured: set PUBLIC_SHOPIFY_DOMAIN and PUBLIC_SHOPIFY_STOREFRONT_TOKEN in .env (the public Storefront API token).');
62
+ }
63
+ if (!inject) return;
64
+ // Before any module runs: window.TETHER_CONFIG in the layout doesn't auto-start a second init.
65
+ injectScript('head-inline', 'window.TETHER_INTEGRATION="astro";');
66
+ injectScript('page', [
67
+ "import Tether from '@occluze/tether';",
68
+ `Tether.init({ ...${JSON.stringify({ autoMount: false, ...config })}, ...(window.TETHER_CONFIG || {}) }).then(() => Tether.mount(document));`,
69
+ ].join('\n'));
70
+ },
71
+ },
72
+ };
73
+ }
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 } }
32
+ ${name}(${inner}) { cart { ...TetherCart } userErrors { field message } warnings { code message } }
33
33
  }
34
34
  ${cartFragment()}`;
35
35
  }
@@ -69,6 +69,17 @@ export const Cart = {
69
69
  get count() { return this.state?.totalQuantity || 0; },
70
70
 
71
71
  lastError: null,
72
+ /** Shopify's notes on the last change, e.g. less stock than asked: [{ code, message }]. */
73
+ warnings: [],
74
+ /** true once the saved cart (or the absence of one) is known. */
75
+ loaded: false,
76
+
77
+ _take(payload, name) {
78
+ const p = unwrap(payload, name);
79
+ this.warnings = p.warnings || [];
80
+ if (this.warnings.length) emit('cart-warning', { warnings: this.warnings });
81
+ return p.cart;
82
+ },
72
83
 
73
84
  _queue(fn) {
74
85
  const run = this._chain.then(fn, fn).then((value) => {
@@ -95,7 +106,7 @@ export const Cart = {
95
106
 
96
107
  async init() {
97
108
  const id = this.id();
98
- if (!id) { this.state = null; this.render(); return null; }
109
+ if (!id) { this.state = null; this.loaded = true; this.render(); emit('cart-updated', { cart: null, reason: 'load' }); return null; }
99
110
  try {
100
111
  const data = await gql(`query TetherCart($id: ID!) { cart(id: $id) { ...TetherCart } }\n${cartFragment()}`, { id });
101
112
  if (!data.cart) { this._setId(null); this.state = null; } else this.state = data.cart;
@@ -106,7 +117,9 @@ export const Cart = {
106
117
  if (isMissingCart(err)) this._setId(null);
107
118
  this.state = null;
108
119
  }
120
+ this.loaded = true;
109
121
  this.render();
122
+ emit('cart-updated', { cart: this.state, reason: 'load' }); // islands (cart.subscribe) hear the loaded cart
110
123
  return this.state;
111
124
  },
112
125
 
@@ -116,7 +129,7 @@ export const Cart = {
116
129
  mutation('cartCreate', '$input: CartInput!', 'input: $input'),
117
130
  { input: { buyerIdentity, ...input } },
118
131
  );
119
- return unwrap(data.cartCreate, 'cartCreate').cart;
132
+ return this._take(data.cartCreate, 'cartCreate');
120
133
  },
121
134
 
122
135
  /** Runs a cart mutation; creates a fresh cart if ours expired. */
@@ -125,7 +138,7 @@ export const Cart = {
125
138
  if (!cartId) return createInput ? this._create(createInput) : null;
126
139
  try {
127
140
  const data = await gql(mutation(name, `$cartId: ID!, ${args}`, `cartId: $cartId, ${inner}`), { ...variables, cartId });
128
- return unwrap(data[name], name).cart;
141
+ return this._take(data[name], name);
129
142
  } catch (err) {
130
143
  if (createInput && isMissingCart(err)) {
131
144
  log('cart expired, creating a new one');
@@ -220,7 +233,8 @@ export const Cart = {
220
233
  },
221
234
 
222
235
  removeDiscount(code) {
223
- return this.setDiscountCodes((this.state?.discountCodes || []).map((d) => d.code).filter((c) => c !== code));
236
+ const k = String(code).toLowerCase();
237
+ return this.setDiscountCodes((this.state?.discountCodes || []).map((d) => d.code).filter((c) => c.toLowerCase() !== k));
224
238
  },
225
239
 
226
240
  /** { countryCode, email, phone } — changes currency/prices for that market. */
@@ -243,7 +257,7 @@ export const Cart = {
243
257
  */
244
258
  async checkout(target = cfg.checkoutTarget) {
245
259
  const id = this.id();
246
- if (!id) return null;
260
+ if (!id || (this.state && !this.count)) return null; // nothing to check out
247
261
  const tab = target === '_blank' ? window.open('', '_blank') : null;
248
262
  if (tab) { try { tab.opener = null; } catch { /* ignore */ } }
249
263
 
@@ -300,6 +314,18 @@ export const Cart = {
300
314
 
301
315
  toggle() { if (this.isOpen()) this.close(); else this.open(); },
302
316
 
317
+ /**
318
+ * For framework islands (React, Svelte, Vue): fn(cart, { open }) now and on every change.
319
+ * Returns a function that stops listening.
320
+ */
321
+ subscribe(fn) {
322
+ const call = () => fn(this.state, { open: this.isOpen(), count: this.count, loaded: this.loaded, warnings: this.warnings });
323
+ const h = () => call();
324
+ ['cart-updated', 'cart-toggled'].forEach((e) => document.addEventListener(`${cfg.prefix}:${e}`, h));
325
+ call();
326
+ return () => ['cart-updated', 'cart-toggled'].forEach((e) => document.removeEventListener(`${cfg.prefix}:${e}`, h));
327
+ },
328
+
303
329
  // -------------------------------------------------------------- render
304
330
 
305
331
  render() {
@@ -321,11 +347,17 @@ export const Cart = {
321
347
  'cart-no-subscription': !hasSub,
322
348
  'cart-has-discount': (cart?.discountCodes || []).some((d) => d.applicable),
323
349
  'cart-error': Boolean(this.lastError),
350
+ 'cart-warning': this.warnings.length > 0,
324
351
  };
352
+ qsa(document, sel('cart-warning')).forEach((el) => setTextTracked(el, [...new Set(this.warnings.map((w) => w.message))].join(' ')));
325
353
  qsa(document, sel('show')).forEach((el) => {
326
354
  const k = get(el, 'show');
327
355
  if (k in conds && !el.closest(sel('product'))) show(el, conds[k]);
328
356
  });
357
+ qsa(document, sel('checkout')).forEach((el) => {
358
+ el.classList.toggle('is-disabled', !count);
359
+ el.setAttribute('aria-disabled', String(!count));
360
+ });
329
361
 
330
362
  qsa(document, `textarea${sel('cart-note')},input${sel('cart-note')}`).forEach((el) => {
331
363
  if (document.activeElement !== el) el.value = cart?.note || '';
@@ -388,7 +420,15 @@ function renderLine(node, line) {
388
420
  qsa(node, sel('line')).forEach((el) => {
389
421
  const key = get(el, 'line');
390
422
  if (key === 'image') return setImage(el, m.image?.url || m.product?.featuredImage?.url, m.product?.title);
391
- if (key === 'url') return el.setAttribute('href', productUrl(m.product?.handle, m.product?.id));
423
+ if (key === 'url') {
424
+ const u = productUrl(m.product?.handle, m.product?.id);
425
+ const q = new URLSearchParams();
426
+ if (m.id) q.set('variant', String(m.id).split('/').pop());
427
+ const plan = line.sellingPlanAllocation?.sellingPlan?.id;
428
+ if (plan) q.set('selling_plan', plan.split('/').pop());
429
+ const qs = q.toString();
430
+ return el.setAttribute('href', qs ? `${u}${u.includes('?') ? '&' : '?'}${qs}` : u);
431
+ }
392
432
  const v = lineValue(line, key);
393
433
  if (v !== undefined) setTextTracked(el, v);
394
434
  return undefined;
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,46 @@ 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
+ const body = JSON.stringify({ query: context ? withContext(query, conf) : query, variables });
48
52
  let attempt = 0;
49
53
 
50
54
  for (;;) {
51
55
  let res;
52
56
  try {
53
- res = await fetch(endpoint(), {
57
+ res = await (conf.fetch || fetch)(endpoint(conf), {
54
58
  method: 'POST',
55
59
  headers: {
56
60
  'Content-Type': 'application/json',
57
- 'X-Shopify-Storefront-Access-Token': cfg.token,
61
+ 'X-Shopify-Storefront-Access-Token': conf.token,
58
62
  },
59
63
  body,
60
64
  });
61
65
  } catch (err) {
62
- if (attempt++ < cfg.retries) { await sleep(300 * 2 ** attempt); continue; }
66
+ if (attempt++ < conf.retries) { await sleep(300 * 2 ** attempt); continue; }
63
67
  throw new TetherError(`Network error: ${err.message}`, { code: 'NETWORK' });
64
68
  }
65
69
 
66
70
  if (res.status === 430) throw new TetherError('Shopify security rejection (430)', { status: 430 });
67
71
 
68
72
  const retryable = res.status === 429 || res.status >= 500;
69
- if (retryable && attempt++ < cfg.retries) {
73
+ if (retryable && attempt++ < conf.retries) {
70
74
  log('retry', res.status, attempt);
71
75
  await sleep(300 * 2 ** attempt);
72
76
  continue;
@@ -82,10 +86,14 @@ export async function gql(query, variables = {}, { context = true } = {}) {
82
86
  const inventoryDenied = json.errors.some((e) => /quantityAvailable/.test(e.message || ''));
83
87
  if (inventoryDenied && /\bquantityAvailable\b/.test(body)) {
84
88
  log('no inventory permission, retrying without quantityAvailable');
85
- return gql(query.replace(/\bquantityAvailable\b/g, ''), variables, { context });
89
+ return request(conf, query.replace(/\bquantityAvailable\b/g, ''), variables, { context });
90
+ }
91
+ // 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 });
86
94
  }
87
95
  const throttled = json.errors.some((e) => e.extensions?.code === 'THROTTLED');
88
- if (throttled && attempt++ < cfg.retries) { await sleep(500 * 2 ** attempt); continue; }
96
+ if (throttled && attempt++ < conf.retries) { await sleep(500 * 2 ** attempt); continue; }
89
97
  throw new TetherError(json.errors.map((e) => e.message).join(' | '), {
90
98
  status: res.status, errors: json.errors,
91
99
  });
@@ -96,6 +104,8 @@ export async function gql(query, variables = {}, { context = true } = {}) {
96
104
  }
97
105
  }
98
106
 
107
+ export const gql = (query, variables, opts) => request(cfg, query, variables, opts);
108
+
99
109
  /** Throws on mutation userErrors, returns the payload otherwise. */
100
110
  export function unwrap(payload, name) {
101
111
  if (!payload) throw new TetherError(`${name}: empty response`);
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>`
@@ -39,6 +40,7 @@ export const DEFAULTS = {
39
40
  // Mounting
40
41
  autoMount: true, // mount document on DOMContentLoaded
41
42
  observe: false, // auto-mount nodes added later (CMS load-more, filters)
43
+ astro: true, // mount on Astro View Transitions (astro:page-load); false if you do it yourself
42
44
  partsUrl: null, // folder of tether.markets/diagnose(.min).js for the <script> build (default: next to it)
43
45
 
44
46
  // Formatting
package/src/diagnose.js CHANGED
@@ -66,7 +66,7 @@ export function checkMarkup(root = document) {
66
66
  'in-stock', 'out-of-stock', 'backorder', 'low-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) => {
package/src/dom.js CHANGED
@@ -53,24 +53,37 @@ export function formatDate(iso) {
53
53
  }
54
54
 
55
55
  /** 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;
56
+ /**
57
+ * Shopify CDN image at a size: imageUrl(url, { width: 800 }). Use the same width as the element's
58
+ * max-width attribute, so the browser script keeps the image instead of loading it again.
59
+ */
60
+ export function imageUrl(url, { width, height, crop } = {}) {
61
+ if (!url) return url;
62
62
  try {
63
63
  const u = new URL(url);
64
- if (w) u.searchParams.set('width', w);
65
- if (h) u.searchParams.set('height', h);
64
+ if (width) u.searchParams.set('width', String(width));
65
+ if (height) u.searchParams.set('height', String(height));
66
66
  if (crop) u.searchParams.set('crop', crop);
67
67
  return u.toString();
68
68
  } catch { return url; }
69
69
  }
70
70
 
71
+ export function cdnUrl(url, el) {
72
+ if (!url || !el) return url;
73
+ const g = (k) => el.getAttribute(k);
74
+ return imageUrl(url, { width: g('max-width'), height: g('max-height'), crop: g('max-width') || g('max-height') ? g('crop') : null });
75
+ }
76
+
77
+ /** Same image, whatever size parameters: the server-rendered one is kept (no second download). */
78
+ export const sameImage = (a, b) => Boolean(a && b) && a.split('?')[0] === b.split('?')[0];
79
+
71
80
  export function setImage(el, url, alt) {
72
81
  if (!url) return;
73
82
  const src = cdnUrl(url, el);
83
+ if (el.tagName === 'IMG' && sameImage(el.getAttribute('src'), src)) {
84
+ if (alt != null && !el.getAttribute('alt')) el.setAttribute('alt', alt);
85
+ return;
86
+ }
74
87
  if (el.tagName === 'IMG') {
75
88
  el.removeAttribute('srcset');
76
89
  el.removeAttribute('sizes');
@@ -104,14 +117,24 @@ export function setTextTracked(el, v) {
104
117
  export function show(el, visible) {
105
118
  const k = 'tetherPrevDisplay';
106
119
  if (visible) {
120
+ if (el.dataset.tetherShown !== undefined) return; // already visible, nothing to check
121
+ el.dataset.tetherShown = '';
122
+ el.removeAttribute('hidden');
107
123
  if (el.dataset[k] !== undefined) {
108
124
  el.style.display = el.dataset[k];
109
125
  delete el.dataset[k];
110
126
  } else if (el.style.display === 'none') {
111
127
  el.style.display = '';
112
128
  }
129
+ // Hidden before the script ran (a class, an inline display:none, a hidden attribute): show it
130
+ // as block or the value of tether-display.
131
+ if (typeof getComputedStyle === 'function' && getComputedStyle(el).display === 'none') {
132
+ el.style.display = el.getAttribute(`${cfg.prefix}-display`) || 'block';
133
+ }
113
134
  } else {
114
- if (el.dataset[k] === undefined) el.dataset[k] = el.style.display || '';
135
+ delete el.dataset.tetherShown;
136
+ // Remember a real display value to restore, never 'none'.
137
+ if (el.dataset[k] === undefined && el.style.display !== 'none') el.dataset[k] = el.style.display || '';
115
138
  el.style.display = 'none';
116
139
  }
117
140
  }
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
  }`;
package/src/index.js CHANGED
@@ -17,11 +17,11 @@ import {
17
17
  } from './loader.js';
18
18
  import * as plans from './selling-plans.js';
19
19
  import { resolveProductKey } from './status.js';
20
- import { attr, sel, get, qsa, emit, formatMoney, busy, parseJSONAttr, toAttributes } from './dom.js';
20
+ import { attr, sel, get, qsa, emit, formatMoney, busy, parseJSONAttr, toAttributes, imageUrl } from './dom.js';
21
21
  import { loadPart, coreModules } from './lazy.js';
22
- import { mountedLists, liveLists, pruneLive, within, mountProduct, retryFailed } from './mounting.js';
22
+ import { mountedLists, liveLists, pruneLive, within, mountProduct, retryFailed, hydrateFromPage } from './mounting.js';
23
23
 
24
- export const VERSION = '0.4.0';
24
+ export const VERSION = '0.5.0';
25
25
 
26
26
  let initPromise = null;
27
27
  let observer = null;
@@ -31,6 +31,7 @@ export function mount(root = document) {
31
31
  if (!isConfigured()) return 0;
32
32
  let count = 0;
33
33
  pruneLive(); // page transitions detach old roots: don't keep them alive
34
+ hydrateFromPage(root); // products the server already rendered: no request needed
34
35
 
35
36
  within(root, listSelector()).forEach((el) => {
36
37
  if (mountedLists.has(el)) return;
@@ -144,12 +145,21 @@ function bindControls() {
144
145
  try {
145
146
  const cart = await Cart.applyDiscount(code);
146
147
  const ok = (cart?.discountCodes || []).find((d) => d.code.toLowerCase() === code.toLowerCase())?.applicable;
148
+ // An invalid code doesn't stay in the cart (Shopify would warn about it on every change).
149
+ if (!ok) await Cart.removeDiscount(code).catch(() => {});
147
150
  form.classList.toggle('is-invalid', !ok);
148
151
  emit(ok ? 'discount-applied' : 'discount-invalid', { code }, form);
149
152
  if (ok && input) input.value = '';
150
153
  } catch (err) { form.classList.add('is-invalid'); reportError(err, 'discount'); }
151
154
  });
152
- });
155
+ }, true); // capture: before a router (Astro's ClientRouter) turns the submit into a navigation
156
+ document.addEventListener('input', (e) => { e.target.closest?.(sel('discount-form'))?.classList.remove('is-invalid'); });
157
+
158
+ // Astro View Transitions (<ClientRouter />): mount every new page, close the cart while leaving.
159
+ if (cfg.astro !== false) {
160
+ document.addEventListener('astro:page-load', () => mount(document));
161
+ document.addEventListener('astro:before-swap', () => unmount());
162
+ }
153
163
 
154
164
  // Back online after a failed load: try the failed products and lists again.
155
165
  window.addEventListener('online', () => {
@@ -252,7 +262,7 @@ async function applyMarket(overrides) {
252
262
  if (!market) return;
253
263
  if (market.country) cfg.country = market.country;
254
264
  if (market.language) cfg.language = market.language;
255
- if (market.locale && !overrides.locale && !window.TETHER_CONFIG?.locale) cfg.locale = market.locale;
265
+ if (market.locale && !overrides.locale) cfg.locale = market.locale; // the visitor's market wins over the default
256
266
  log('market', market);
257
267
  }
258
268
 
@@ -292,6 +302,7 @@ export const api = {
292
302
  normalizeProductKey,
293
303
  clearProductCache,
294
304
  formatMoney,
305
+ imageUrl,
295
306
  plans,
296
307
  TetherError,
297
308
  resolveProductKey,
@@ -313,7 +324,8 @@ export default api;
313
324
  // Auto-start for the plain <script> setup.
314
325
  if (typeof window !== 'undefined' && typeof document !== 'undefined') {
315
326
  window.Tether = api;
316
- const auto = window.TETHER_CONFIG && window.TETHER_CONFIG.autoMount !== false;
327
+ // Not with a framework integration (Astro), which starts Tether itself.
328
+ const auto = window.TETHER_CONFIG && window.TETHER_CONFIG.autoMount !== false && !window.TETHER_INTEGRATION;
317
329
  if (auto) {
318
330
  const start = () => init().then(() => mount(document));
319
331
  if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', start, { once: true });