@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.
- package/README.md +7 -5
- package/dist/esm/astro.js +2 -0
- package/dist/esm/chunks/chunk-HHMOPDZG.js +1 -0
- package/dist/esm/chunks/chunk-HXKV6QGB.js +4 -0
- package/dist/esm/chunks/{chunk-KYQ5OGBX.js → chunk-RBVRRQIU.js} +1 -1
- package/dist/esm/chunks/chunk-RNZMTQTX.js +1 -0
- package/dist/esm/chunks/chunk-VC46IEJQ.js +1 -0
- package/dist/esm/chunks/diagnose-J667GHGS.js +1 -0
- package/dist/esm/chunks/markets-ui-EGWIEUOZ.js +1 -0
- package/dist/esm/index.js +1 -1
- package/dist/esm/server.js +1 -0
- package/dist/tether.diagnose.js +4 -3
- package/dist/tether.diagnose.min.js +1 -1
- package/dist/tether.js +229 -61
- package/dist/tether.markets.js +16 -10
- package/dist/tether.markets.min.js +1 -1
- package/dist/tether.min.js +3 -3
- package/dist/tether.min.js.map +3 -3
- package/package.json +27 -4
- package/src/astro.js +73 -0
- package/src/cart.js +47 -7
- package/src/client.js +25 -15
- package/src/config.js +2 -0
- package/src/diagnose.js +1 -1
- package/src/dom.js +32 -9
- package/src/fragments.js +12 -11
- package/src/index.js +18 -6
- package/src/initial.js +66 -0
- package/src/loader.js +49 -6
- package/src/markets-ui.js +13 -5
- package/src/markets.js +1 -1
- package/src/mounting.js +42 -2
- package/src/product.js +33 -9
- package/src/server.js +179 -0
- package/src/status.js +1 -6
- package/types/astro.d.ts +15 -0
- package/types/index.d.ts +17 -2
- package/types/server.d.ts +94 -0
- package/dist/esm/chunks/chunk-GHIUMD5H.js +0 -1
- package/dist/esm/chunks/chunk-LI3YDMGF.js +0 -4
- package/dist/esm/chunks/diagnose-ZXXXJIQE.js +0 -1
- 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.
|
|
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
|
|
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
|
|
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
|
-
|
|
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')
|
|
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://${
|
|
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
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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
|
-
|
|
43
|
-
|
|
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':
|
|
61
|
+
'X-Shopify-Storefront-Access-Token': conf.token,
|
|
58
62
|
},
|
|
59
63
|
body,
|
|
60
64
|
});
|
|
61
65
|
} catch (err) {
|
|
62
|
-
if (attempt++ <
|
|
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++ <
|
|
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
|
|
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++ <
|
|
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
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
if (!
|
|
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 (
|
|
65
|
-
if (
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
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: ${
|
|
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: ${
|
|
68
|
+
${metafieldsField(o)}
|
|
69
|
+
sellingPlanGroups(first: ${o.sellingPlanGroupsFirst}) {
|
|
69
70
|
nodes {
|
|
70
71
|
name appName
|
|
71
72
|
options { name values }
|
|
72
|
-
sellingPlans(first: ${
|
|
73
|
+
sellingPlans(first: ${o.sellingPlansFirst}) { nodes { ${SELLING_PLAN_FIELDS} } }
|
|
73
74
|
}
|
|
74
75
|
}
|
|
75
|
-
variants(first: ${
|
|
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: ${
|
|
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.
|
|
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
|
|
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
|
-
|
|
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 });
|