@occluze/tether 0.3.6 → 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 +12 -7
- 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-RBVRRQIU.js +1 -0
- 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 -0
- package/dist/esm/server.js +1 -0
- package/dist/presets/barba.esm.js +4 -5
- package/dist/tether.diagnose.js +320 -0
- package/dist/tether.diagnose.min.js +1 -0
- package/dist/tether.esm.js +2 -2608
- package/dist/tether.js +637 -833
- package/dist/tether.markets.js +213 -0
- package/dist/tether.markets.min.js +1 -0
- package/dist/tether.min.js +3 -132
- package/dist/tether.min.js.map +4 -4
- package/package.json +52 -11
- package/src/astro.js +73 -0
- package/src/cart.js +79 -7
- package/src/client.js +25 -15
- package/src/config.js +4 -0
- package/src/diagnose.js +1 -1
- package/src/dom.js +32 -9
- package/src/fragments.js +12 -11
- package/src/index.js +80 -160
- package/src/initial.js +66 -0
- package/src/lazy-iife.js +43 -0
- package/src/lazy.js +9 -0
- package/src/loader.js +50 -7
- package/src/markets-ui.js +89 -0
- package/src/markets.js +1 -1
- package/src/mounting.js +129 -0
- package/src/parts/diagnose.js +3 -0
- package/src/parts/markets.js +3 -0
- package/src/product.js +86 -61
- package/src/server.js +179 -0
- package/src/status.js +2 -6
- package/types/astro.d.ts +15 -0
- package/types/barba.d.ts +16 -0
- package/types/index.d.ts +347 -0
- package/types/server.d.ts +94 -0
package/package.json
CHANGED
|
@@ -1,32 +1,59 @@
|
|
|
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
|
-
"main": "dist/
|
|
7
|
-
"module": "dist/
|
|
6
|
+
"main": "dist/esm/index.js",
|
|
7
|
+
"module": "dist/esm/index.js",
|
|
8
8
|
"exports": {
|
|
9
|
-
".":
|
|
10
|
-
|
|
9
|
+
".": {
|
|
10
|
+
"types": "./types/index.d.ts",
|
|
11
|
+
"default": "./dist/esm/index.js"
|
|
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
|
+
},
|
|
21
|
+
"./presets/barba": {
|
|
22
|
+
"types": "./types/barba.d.ts",
|
|
23
|
+
"default": "./dist/presets/barba.esm.js"
|
|
24
|
+
},
|
|
25
|
+
"./dist/*": "./dist/*"
|
|
11
26
|
},
|
|
12
27
|
"files": [
|
|
13
28
|
"dist",
|
|
14
29
|
"src",
|
|
15
30
|
"LICENSE",
|
|
16
|
-
"README.md"
|
|
31
|
+
"README.md",
|
|
32
|
+
"types/index.d.ts",
|
|
33
|
+
"types/barba.d.ts",
|
|
34
|
+
"types/server.d.ts",
|
|
35
|
+
"types/astro.d.ts"
|
|
17
36
|
],
|
|
18
37
|
"scripts": {
|
|
19
38
|
"build": "node scripts/build.mjs",
|
|
20
39
|
"dev": "node scripts/build.mjs --watch",
|
|
21
|
-
"test": "node --test test/*.test.js server/test/*.test.js",
|
|
22
|
-
"smoke": "node scripts/smoke.mjs"
|
|
40
|
+
"test": "node --test test/*.test.js server/test/*.test.js && npm run test:types",
|
|
41
|
+
"smoke": "node scripts/smoke.mjs",
|
|
42
|
+
"pretest": "node scripts/build.mjs",
|
|
43
|
+
"presmoke": "node scripts/build.mjs",
|
|
44
|
+
"test:types": "tsc -p types/tsconfig.json",
|
|
45
|
+
"release": "node scripts/release.mjs"
|
|
23
46
|
},
|
|
24
47
|
"devDependencies": {
|
|
25
48
|
"esbuild": "^0.25.0",
|
|
26
|
-
"jsdom": "^26.0.0"
|
|
49
|
+
"jsdom": "^26.0.0",
|
|
50
|
+
"typescript": "^5.9.3"
|
|
27
51
|
},
|
|
28
52
|
"license": "MIT",
|
|
29
|
-
"author":
|
|
53
|
+
"author": {
|
|
54
|
+
"name": "Ravi Klaassens",
|
|
55
|
+
"url": "https://occluze.com"
|
|
56
|
+
},
|
|
30
57
|
"repository": {
|
|
31
58
|
"type": "git",
|
|
32
59
|
"url": "git+https://github.com/ravi-klaassens/tether.git"
|
|
@@ -36,24 +63,38 @@
|
|
|
36
63
|
"url": "https://github.com/ravi-klaassens/tether/issues"
|
|
37
64
|
},
|
|
38
65
|
"keywords": [
|
|
66
|
+
"astro",
|
|
67
|
+
"astro-integration",
|
|
39
68
|
"cart",
|
|
40
69
|
"ecommerce",
|
|
41
70
|
"headless",
|
|
71
|
+
"headless-commerce",
|
|
42
72
|
"headless-shopify",
|
|
43
73
|
"selling-plans",
|
|
44
74
|
"shopify",
|
|
45
75
|
"shopify-subscriptions",
|
|
46
76
|
"shopyflow-alternative",
|
|
47
77
|
"smootify-alternative",
|
|
78
|
+
"ssr",
|
|
48
79
|
"storefront-api",
|
|
49
80
|
"storesynk-alternative",
|
|
50
81
|
"subscriptions",
|
|
51
82
|
"webflow",
|
|
52
83
|
"webflow-cms",
|
|
53
84
|
"webflow-ecommerce",
|
|
54
|
-
"webflow-shopify"
|
|
85
|
+
"webflow-shopify",
|
|
86
|
+
"withastro"
|
|
55
87
|
],
|
|
56
88
|
"publishConfig": {
|
|
57
89
|
"access": "public"
|
|
90
|
+
},
|
|
91
|
+
"types": "types/index.d.ts",
|
|
92
|
+
"peerDependencies": {
|
|
93
|
+
"astro": ">=4"
|
|
94
|
+
},
|
|
95
|
+
"peerDependenciesMeta": {
|
|
96
|
+
"astro": {
|
|
97
|
+
"optional": true
|
|
98
|
+
}
|
|
58
99
|
}
|
|
59
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,11 +29,28 @@ 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
|
}
|
|
36
36
|
|
|
37
|
+
const FOCUSABLE = 'a[href],button:not([disabled]),input:not([disabled]),select:not([disabled]),textarea:not([disabled]),[tabindex]:not([tabindex="-1"])';
|
|
38
|
+
let returnFocus = null;
|
|
39
|
+
|
|
40
|
+
/** Focusable elements inside an open drawer, in order (visible ones only). */
|
|
41
|
+
export const focusables = (d) => qsa(d, FOCUSABLE).filter((el) => el.offsetParent !== null || el === document.activeElement);
|
|
42
|
+
|
|
43
|
+
/** aria-expanded on the open buttons; dialog semantics on drawers (unless the markup has its own). */
|
|
44
|
+
function ariaState(open) {
|
|
45
|
+
qsa(document, sel('cart-open')).forEach((b) => b.setAttribute('aria-expanded', String(open)));
|
|
46
|
+
qsa(document, sel('cart-drawer')).forEach((d) => {
|
|
47
|
+
if (!d.hasAttribute('role')) d.setAttribute('role', 'dialog');
|
|
48
|
+
if (!d.hasAttribute('aria-label') && !d.hasAttribute('aria-labelledby')) d.setAttribute('aria-label', get(d, 'cart-label') || 'Cart');
|
|
49
|
+
d.setAttribute('aria-modal', String(open));
|
|
50
|
+
if (!d.classList.contains(cfg.cartDrawerOpenClass)) d.setAttribute('aria-hidden', 'true');
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
|
|
37
54
|
const isMissingCart = (err) => /does not exist|not found|invalid id/i.test(err?.message || '');
|
|
38
55
|
|
|
39
56
|
export const Cart = {
|
|
@@ -52,6 +69,17 @@ export const Cart = {
|
|
|
52
69
|
get count() { return this.state?.totalQuantity || 0; },
|
|
53
70
|
|
|
54
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
|
+
},
|
|
55
83
|
|
|
56
84
|
_queue(fn) {
|
|
57
85
|
const run = this._chain.then(fn, fn).then((value) => {
|
|
@@ -78,7 +106,7 @@ export const Cart = {
|
|
|
78
106
|
|
|
79
107
|
async init() {
|
|
80
108
|
const id = this.id();
|
|
81
|
-
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; }
|
|
82
110
|
try {
|
|
83
111
|
const data = await gql(`query TetherCart($id: ID!) { cart(id: $id) { ...TetherCart } }\n${cartFragment()}`, { id });
|
|
84
112
|
if (!data.cart) { this._setId(null); this.state = null; } else this.state = data.cart;
|
|
@@ -89,7 +117,9 @@ export const Cart = {
|
|
|
89
117
|
if (isMissingCart(err)) this._setId(null);
|
|
90
118
|
this.state = null;
|
|
91
119
|
}
|
|
120
|
+
this.loaded = true;
|
|
92
121
|
this.render();
|
|
122
|
+
emit('cart-updated', { cart: this.state, reason: 'load' }); // islands (cart.subscribe) hear the loaded cart
|
|
93
123
|
return this.state;
|
|
94
124
|
},
|
|
95
125
|
|
|
@@ -99,7 +129,7 @@ export const Cart = {
|
|
|
99
129
|
mutation('cartCreate', '$input: CartInput!', 'input: $input'),
|
|
100
130
|
{ input: { buyerIdentity, ...input } },
|
|
101
131
|
);
|
|
102
|
-
return
|
|
132
|
+
return this._take(data.cartCreate, 'cartCreate');
|
|
103
133
|
},
|
|
104
134
|
|
|
105
135
|
/** Runs a cart mutation; creates a fresh cart if ours expired. */
|
|
@@ -108,7 +138,7 @@ export const Cart = {
|
|
|
108
138
|
if (!cartId) return createInput ? this._create(createInput) : null;
|
|
109
139
|
try {
|
|
110
140
|
const data = await gql(mutation(name, `$cartId: ID!, ${args}`, `cartId: $cartId, ${inner}`), { ...variables, cartId });
|
|
111
|
-
return
|
|
141
|
+
return this._take(data[name], name);
|
|
112
142
|
} catch (err) {
|
|
113
143
|
if (createInput && isMissingCart(err)) {
|
|
114
144
|
log('cart expired, creating a new one');
|
|
@@ -203,7 +233,8 @@ export const Cart = {
|
|
|
203
233
|
},
|
|
204
234
|
|
|
205
235
|
removeDiscount(code) {
|
|
206
|
-
|
|
236
|
+
const k = String(code).toLowerCase();
|
|
237
|
+
return this.setDiscountCodes((this.state?.discountCodes || []).map((d) => d.code).filter((c) => c.toLowerCase() !== k));
|
|
207
238
|
},
|
|
208
239
|
|
|
209
240
|
/** { countryCode, email, phone } — changes currency/prices for that market. */
|
|
@@ -226,7 +257,7 @@ export const Cart = {
|
|
|
226
257
|
*/
|
|
227
258
|
async checkout(target = cfg.checkoutTarget) {
|
|
228
259
|
const id = this.id();
|
|
229
|
-
if (!id) return null;
|
|
260
|
+
if (!id || (this.state && !this.count)) return null; // nothing to check out
|
|
230
261
|
const tab = target === '_blank' ? window.open('', '_blank') : null;
|
|
231
262
|
if (tab) { try { tab.opener = null; } catch { /* ignore */ } }
|
|
232
263
|
|
|
@@ -255,6 +286,16 @@ export const Cart = {
|
|
|
255
286
|
if (this.isOpen()) return;
|
|
256
287
|
const ds = this.drawers().filter((d) => get(d, 'cart-drawer') !== 'manual');
|
|
257
288
|
ds.forEach((d) => { d.classList.add(cfg.cartDrawerOpenClass); d.setAttribute('aria-hidden', 'false'); });
|
|
289
|
+
ariaState(true);
|
|
290
|
+
if (cfg.cartFocus !== false && ds[0]) {
|
|
291
|
+
// Move focus into the cart (after the open transition has made it visible).
|
|
292
|
+
returnFocus = document.activeElement;
|
|
293
|
+
setTimeout(() => {
|
|
294
|
+
const target = focusables(ds[0])[0] || ds[0];
|
|
295
|
+
if (target === ds[0] && !ds[0].hasAttribute('tabindex')) ds[0].tabIndex = -1;
|
|
296
|
+
target.focus?.({ preventScroll: true });
|
|
297
|
+
}, 60);
|
|
298
|
+
}
|
|
258
299
|
if (typeof cfg.onCartOpen === 'function') cfg.onCartOpen(ds);
|
|
259
300
|
emit('cart-toggled', { open: true });
|
|
260
301
|
},
|
|
@@ -262,13 +303,29 @@ export const Cart = {
|
|
|
262
303
|
close() {
|
|
263
304
|
if (!this.isOpen()) return;
|
|
264
305
|
const ds = this.drawers();
|
|
306
|
+
const hadFocus = ds.some((d) => d.contains(document.activeElement));
|
|
265
307
|
ds.forEach((d) => { d.classList.remove(cfg.cartDrawerOpenClass); d.setAttribute('aria-hidden', 'true'); });
|
|
308
|
+
ariaState(false);
|
|
309
|
+
if (hadFocus && returnFocus?.isConnected) returnFocus.focus({ preventScroll: true });
|
|
310
|
+
returnFocus = null;
|
|
266
311
|
if (typeof cfg.onCartClose === 'function') cfg.onCartClose(ds);
|
|
267
312
|
emit('cart-toggled', { open: false });
|
|
268
313
|
},
|
|
269
314
|
|
|
270
315
|
toggle() { if (this.isOpen()) this.close(); else this.open(); },
|
|
271
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
|
+
|
|
272
329
|
// -------------------------------------------------------------- render
|
|
273
330
|
|
|
274
331
|
render() {
|
|
@@ -276,6 +333,7 @@ export const Cart = {
|
|
|
276
333
|
const count = cart ? cart.totalQuantity : 0;
|
|
277
334
|
const money = (k) => (cart ? formatMoney(cart.cost?.[k]) : '');
|
|
278
335
|
|
|
336
|
+
ariaState(this.isOpen());
|
|
279
337
|
qsa(document, sel('cart-count')).forEach((el) => setTextTracked(el, count));
|
|
280
338
|
qsa(document, sel('cart-subtotal')).forEach((el) => setTextTracked(el, money('subtotalAmount')));
|
|
281
339
|
qsa(document, sel('cart-total')).forEach((el) => setTextTracked(el, money('totalAmount')));
|
|
@@ -289,11 +347,17 @@ export const Cart = {
|
|
|
289
347
|
'cart-no-subscription': !hasSub,
|
|
290
348
|
'cart-has-discount': (cart?.discountCodes || []).some((d) => d.applicable),
|
|
291
349
|
'cart-error': Boolean(this.lastError),
|
|
350
|
+
'cart-warning': this.warnings.length > 0,
|
|
292
351
|
};
|
|
352
|
+
qsa(document, sel('cart-warning')).forEach((el) => setTextTracked(el, [...new Set(this.warnings.map((w) => w.message))].join(' ')));
|
|
293
353
|
qsa(document, sel('show')).forEach((el) => {
|
|
294
354
|
const k = get(el, 'show');
|
|
295
355
|
if (k in conds && !el.closest(sel('product'))) show(el, conds[k]);
|
|
296
356
|
});
|
|
357
|
+
qsa(document, sel('checkout')).forEach((el) => {
|
|
358
|
+
el.classList.toggle('is-disabled', !count);
|
|
359
|
+
el.setAttribute('aria-disabled', String(!count));
|
|
360
|
+
});
|
|
297
361
|
|
|
298
362
|
qsa(document, `textarea${sel('cart-note')},input${sel('cart-note')}`).forEach((el) => {
|
|
299
363
|
if (document.activeElement !== el) el.value = cart?.note || '';
|
|
@@ -356,7 +420,15 @@ function renderLine(node, line) {
|
|
|
356
420
|
qsa(node, sel('line')).forEach((el) => {
|
|
357
421
|
const key = get(el, 'line');
|
|
358
422
|
if (key === 'image') return setImage(el, m.image?.url || m.product?.featuredImage?.url, m.product?.title);
|
|
359
|
-
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
|
+
}
|
|
360
432
|
const v = lineValue(line, key);
|
|
361
433
|
if (v !== undefined) setTextTracked(el, v);
|
|
362
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,11 +25,13 @@ 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>`
|
|
31
32
|
cartDrawerOpenClass: 'is-open',
|
|
32
33
|
openCartOnAdd: true,
|
|
34
|
+
cartFocus: true, // move focus into the open cart, keep Tab inside it, restore it on close
|
|
33
35
|
checkoutTarget: '_self', // '_self' or '_blank'
|
|
34
36
|
|
|
35
37
|
// Subscriptions
|
|
@@ -38,6 +40,8 @@ export const DEFAULTS = {
|
|
|
38
40
|
// Mounting
|
|
39
41
|
autoMount: true, // mount document on DOMContentLoaded
|
|
40
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
|
|
44
|
+
partsUrl: null, // folder of tether.markets/diagnose(.min).js for the <script> build (default: next to it)
|
|
41
45
|
|
|
42
46
|
// Formatting
|
|
43
47
|
formatMoney: null, // (money, locale) => string
|
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
|
}`;
|