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