@occluze/tether 0.5.0 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +9 -7
- package/dist/esm/astro.js +1 -1
- 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-YXCXIPQV.js +4 -0
- package/dist/esm/chunks/{chunk-RBVRRQIU.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 -1
- 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 +11 -2
- 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 +268 -75
- package/dist/tether.markets.js +3 -3
- 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 +5 -2
- package/src/account.js +313 -0
- package/src/analytics.js +232 -0
- package/src/astro.js +4 -1
- package/src/cart.js +74 -11
- package/src/client.js +8 -3
- package/src/collection.js +15 -10
- package/src/config.js +3 -0
- package/src/diagnose.js +11 -2
- package/src/dom.js +7 -1
- package/src/extras.js +63 -0
- package/src/fragments.js +1 -1
- package/src/index.js +103 -41
- package/src/initial.js +13 -4
- package/src/lazy.js +9 -1
- package/src/loader.js +4 -1
- package/src/markets-ui.js +2 -1
- package/src/mounting.js +27 -2
- 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 +19 -5
- package/src/search.js +153 -0
- package/src/server.js +78 -6
- package/src/status.js +2 -2
- package/types/index.d.ts +56 -3
- package/types/server.d.ts +10 -2
- package/dist/esm/chunks/chunk-HHMOPDZG.js +0 -1
- package/dist/esm/chunks/chunk-HXKV6QGB.js +0 -4
- package/dist/esm/chunks/chunk-RNZMTQTX.js +0 -1
- package/dist/esm/chunks/diagnose-J667GHGS.js +0 -1
- package/dist/esm/chunks/markets-ui-EGWIEUOZ.js +0 -1
package/src/fragments.js
CHANGED
|
@@ -117,7 +117,7 @@ fragment TetherCart on Cart {
|
|
|
117
117
|
}
|
|
118
118
|
merchandise {
|
|
119
119
|
... on ProductVariant {
|
|
120
|
-
id title sku availableForSale requiresShipping
|
|
120
|
+
id title sku availableForSale requiresShipping quantityAvailable
|
|
121
121
|
image { ${IMAGE} }
|
|
122
122
|
price { ${MONEY} }
|
|
123
123
|
compareAtPrice { ${MONEY} }
|
package/src/index.js
CHANGED
|
@@ -19,9 +19,9 @@ import * as plans from './selling-plans.js';
|
|
|
19
19
|
import { resolveProductKey } from './status.js';
|
|
20
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, hydrateFromPage } from './mounting.js';
|
|
22
|
+
import { mountedLists, liveLists, pruneLive, within, mountProduct, retryFailed, hydrateFromPage, settleMarket, trackMarket } from './mounting.js';
|
|
23
23
|
|
|
24
|
-
export const VERSION = '0.
|
|
24
|
+
export const VERSION = '0.6.0';
|
|
25
25
|
|
|
26
26
|
let initPromise = null;
|
|
27
27
|
let observer = null;
|
|
@@ -40,7 +40,7 @@ export function mount(root = document) {
|
|
|
40
40
|
const list = new ListScope(el);
|
|
41
41
|
liveLists.add(list);
|
|
42
42
|
el.addEventListener(`${cfg.prefix}:list-rendered`, (e) => mount(e.detail.container));
|
|
43
|
-
list.load();
|
|
43
|
+
trackMarket(list.load());
|
|
44
44
|
});
|
|
45
45
|
|
|
46
46
|
within(root, sel('product')).forEach((el) => { if (mountProduct(el)) count++; });
|
|
@@ -50,10 +50,22 @@ export function mount(root = document) {
|
|
|
50
50
|
loadPart('markets').then((m) => m.renderCountrySelects(root)).catch((err) => reportError(err, 'markets'));
|
|
51
51
|
}
|
|
52
52
|
|
|
53
|
+
// Customer accounts: their own optional part, when configured and the page (or a login) needs it.
|
|
54
|
+
if ((cfg.account?.clientId || cfg.account?.server) && (within(root, `[${attr('account-login')}],[${attr('account-bind')}],[${attr('orders')}],[${attr('show')}^="logged-"]`).length
|
|
55
|
+
|| /[?&]code=/.test(window.location.search) || hasAccountSession())) {
|
|
56
|
+
loadPart('account').then((m) => m.mountAccount(root)).catch((err) => reportError(err, 'account'));
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Search as you type: its own optional part, only for pages that have it.
|
|
60
|
+
if (within(root, sel('search')).length) {
|
|
61
|
+
loadPart('search').then((m) => m.mountSearch(root)).catch((err) => reportError(err, 'search'));
|
|
62
|
+
}
|
|
63
|
+
|
|
53
64
|
qsa(root, sel('account-link')).forEach((a) => {
|
|
54
65
|
a.setAttribute('href', `https://${cfg.domain}/account`);
|
|
55
66
|
});
|
|
56
67
|
|
|
68
|
+
settleMarket();
|
|
57
69
|
Cart.render();
|
|
58
70
|
log(`mounted ${count} scope(s)`, root);
|
|
59
71
|
return count;
|
|
@@ -101,9 +113,9 @@ function bindControls() {
|
|
|
101
113
|
const id = line.getAttribute(`data-${cfg.prefix}-key`);
|
|
102
114
|
const current = Cart.lines.find((l) => l.id === id);
|
|
103
115
|
const run = (fn) => { e.preventDefault(); fn().catch((err) => reportError(err, 'cart line')); };
|
|
104
|
-
if (t.closest(sel('line-remove'))) return run(() => Cart.removeLine(id));
|
|
105
|
-
if (current && t.closest(sel('line-plus'))) return run(() => Cart.
|
|
106
|
-
if (current && t.closest(sel('line-minus'))) return run(() => Cart.
|
|
116
|
+
if (t.closest(sel('line-remove'))) { line.classList.add('is-busy'); return run(() => Cart.removeLine(id)); }
|
|
117
|
+
if (current && t.closest(sel('line-plus'))) return run(() => Cart.changeQuantity(id, 1));
|
|
118
|
+
if (current && t.closest(sel('line-minus'))) return run(() => Cart.changeQuantity(id, -1));
|
|
107
119
|
}
|
|
108
120
|
|
|
109
121
|
const removeCode = t.closest(sel('discount-remove'));
|
|
@@ -123,10 +135,12 @@ function bindControls() {
|
|
|
123
135
|
if (!t?.closest) return;
|
|
124
136
|
const lineEl = t.closest(dataKeySel());
|
|
125
137
|
const id = lineEl?.getAttribute(`data-${cfg.prefix}-key`);
|
|
138
|
+
// The line shows is-busy / aria-busy until Shopify has answered (removed by the next render).
|
|
139
|
+
const busyLine = (p, what) => { lineEl.classList.add('is-busy'); lineEl.setAttribute('aria-busy', 'true'); p.catch((err) => reportError(err, what)); };
|
|
126
140
|
if (id && t.matches(`input${sel('line', 'quantity')}`)) {
|
|
127
|
-
Cart.updateLine(id, parseInt(t.value, 10) || 0)
|
|
141
|
+
busyLine(Cart.updateLine(id, parseInt(t.value, 10) || 0), 'cart line');
|
|
128
142
|
} else if (id && t.matches(`select${sel('line-plan')}`)) {
|
|
129
|
-
Cart.updateLine(id, { sellingPlanId: t.value || null })
|
|
143
|
+
busyLine(Cart.updateLine(id, { sellingPlanId: t.value || null }), 'cart line plan');
|
|
130
144
|
} else if (t.matches(`select${sel('country-select')}`)) {
|
|
131
145
|
if (t.value) setCountry(t.value).catch((err) => reportError(err, 'country'));
|
|
132
146
|
} else if (t.matches(sel('cart-note'))) {
|
|
@@ -142,22 +156,53 @@ function bindControls() {
|
|
|
142
156
|
const code = input?.value.trim();
|
|
143
157
|
if (!code) return;
|
|
144
158
|
busy(form, async () => {
|
|
159
|
+
const mark = (bad) => {
|
|
160
|
+
form.classList.toggle('is-invalid', bad);
|
|
161
|
+
if (input) { if (bad) input.setAttribute('aria-invalid', 'true'); else input.removeAttribute('aria-invalid'); }
|
|
162
|
+
};
|
|
145
163
|
try {
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
if (
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
if (
|
|
153
|
-
}
|
|
164
|
+
await Cart.applyDiscount(code);
|
|
165
|
+
mark(false);
|
|
166
|
+
emit('discount-applied', { code }, form);
|
|
167
|
+
if (input) input.value = '';
|
|
168
|
+
} catch (err) {
|
|
169
|
+
mark(true);
|
|
170
|
+
if (err?.code === 'INVALID_DISCOUNT') emit('discount-invalid', { code }, form); else reportError(err, 'discount');
|
|
171
|
+
}
|
|
154
172
|
});
|
|
155
173
|
}, true); // capture: before a router (Astro's ClientRouter) turns the submit into a navigation
|
|
156
|
-
document.addEventListener('input', (e) => {
|
|
174
|
+
document.addEventListener('input', (e) => {
|
|
175
|
+
const f = e.target.closest?.(sel('discount-form'));
|
|
176
|
+
if (f) { f.classList.remove('is-invalid'); e.target.removeAttribute?.('aria-invalid'); }
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
// Other tabs: the cart or the market changed there (handled by the extras part, loaded then).
|
|
180
|
+
window.addEventListener('storage', (e) => {
|
|
181
|
+
if (cfg.domain && e.key?.startsWith(`${cfg.prefix}:`)) loadPart('extras').then((m) => m.onStorage(e)).catch(() => {});
|
|
182
|
+
});
|
|
183
|
+
// Shareable variant links: only when turned on.
|
|
184
|
+
document.addEventListener(`${cfg.prefix}:variant-changed`, (e) => {
|
|
185
|
+
const root = e.target;
|
|
186
|
+
if (cfg.variantUrl || root.hasAttribute?.(attr('variant-url'))) loadPart('extras').then((m) => m.writeVariantUrl(root, e.detail)).catch(() => {});
|
|
187
|
+
});
|
|
157
188
|
|
|
158
189
|
// Astro View Transitions (<ClientRouter />): mount every new page, close the cart while leaving.
|
|
159
190
|
if (cfg.astro !== false) {
|
|
160
|
-
|
|
191
|
+
// Astro fires this on the first load too: wait for init (the visitor's saved market) first.
|
|
192
|
+
document.addEventListener('astro:page-load', () => (initPromise || Promise.resolve()).then(() => {
|
|
193
|
+
// Navigated to a page of another market (e.g. /de/): switch first, then mount in that market.
|
|
194
|
+
// Back on a normal page after one: the visitor's own choice, else the default market.
|
|
195
|
+
let target = pageMarket();
|
|
196
|
+
if (!target && onPageMarket) {
|
|
197
|
+
try { target = JSON.parse(localStorage.getItem(`${cfg.prefix}:market:${cfg.domain}`) || 'null'); } catch { target = null; }
|
|
198
|
+
target = target?.country ? target : baseMarket;
|
|
199
|
+
}
|
|
200
|
+
onPageMarket = Boolean(pageMarket());
|
|
201
|
+
const differs = target?.country && (String(target.country).toUpperCase() !== String(cfg.country || '').toUpperCase()
|
|
202
|
+
|| String(target.language || '').toUpperCase() !== String(cfg.language || '').toUpperCase());
|
|
203
|
+
if (differs) setCountry(target.country, { language: target.language ?? null, locale: target.locale, remember: false }).then(() => mount(document), () => mount(document));
|
|
204
|
+
else mount(document);
|
|
205
|
+
}));
|
|
161
206
|
document.addEventListener('astro:before-swap', () => unmount());
|
|
162
207
|
}
|
|
163
208
|
|
|
@@ -191,34 +236,14 @@ function dataKeySel() { return `[data-${cfg.prefix}-key]`; }
|
|
|
191
236
|
* group in one request. Bundles, build-a-box, "complete the set".
|
|
192
237
|
* Lines get attribute _group (and the group's tether-line-attributes).
|
|
193
238
|
*/
|
|
194
|
-
export
|
|
195
|
-
const group = btn.closest(sel('group'));
|
|
196
|
-
if (!group) return null;
|
|
197
|
-
const name = get(group, 'group');
|
|
198
|
-
const extra = toAttributes(parseJSONAttr(group, 'line-attributes'));
|
|
199
|
-
const groupAttrs = name ? [{ key: '_group', value: name }, ...extra] : extra;
|
|
200
|
-
|
|
201
|
-
const lines = qsa(group, sel('product'))
|
|
202
|
-
.map((el) => scopes.get(el))
|
|
203
|
-
.filter((s) => s && s.variant?.availableForSale && s.quantity > 0)
|
|
204
|
-
.map((s) => {
|
|
205
|
-
const l = s.getLine();
|
|
206
|
-
l.attributes = [...(l.attributes || []), ...groupAttrs];
|
|
207
|
-
return l;
|
|
208
|
-
});
|
|
209
|
-
|
|
210
|
-
if (!lines.length) { warn('group-add: nothing selected'); return null; }
|
|
211
|
-
try {
|
|
212
|
-
const cart = await Cart.add(lines);
|
|
213
|
-
emit('group-added', { group: name, lines }, group);
|
|
214
|
-
return cart;
|
|
215
|
-
} catch (err) { reportError(err, 'group add'); return null; }
|
|
216
|
-
}
|
|
239
|
+
export const addGroup = (btn) => loadPart('extras').then((m) => m.addGroup(btn));
|
|
217
240
|
|
|
218
241
|
// ---------------------------------------------------------------------------
|
|
219
242
|
// Init
|
|
220
243
|
// ---------------------------------------------------------------------------
|
|
221
244
|
|
|
245
|
+
let analyticsQueue = null;
|
|
246
|
+
|
|
222
247
|
export function init(overrides = {}) {
|
|
223
248
|
if (initPromise && !Object.keys(overrides).length) return initPromise;
|
|
224
249
|
configure({ ...(window.TETHER_CONFIG || {}), ...overrides });
|
|
@@ -231,6 +256,13 @@ export function init(overrides = {}) {
|
|
|
231
256
|
|
|
232
257
|
bindControls();
|
|
233
258
|
if (cfg.observe) startObserver();
|
|
259
|
+
// Analytics (optional part): buffer Tether's events until it has loaded, so none are missed.
|
|
260
|
+
if (cfg.analytics && !analyticsQueue) {
|
|
261
|
+
analyticsQueue = [];
|
|
262
|
+
['product-ready', 'list-ready', 'cart-updated', 'cart-toggled', 'checkout', 'search'].forEach((n) => document.addEventListener(`${cfg.prefix}:${n}`, (e) => analyticsQueue.push(e)));
|
|
263
|
+
document.addEventListener('astro:page-load', () => analyticsQueue.push(new CustomEvent(`${cfg.prefix}:page`)));
|
|
264
|
+
loadPart('analytics').then((m) => m.startAnalytics(analyticsQueue)).catch((err) => reportError(err, 'analytics'));
|
|
265
|
+
}
|
|
234
266
|
|
|
235
267
|
initPromise = applyMarket(overrides).then(() => Cart.init()).then(async (cart) => {
|
|
236
268
|
// A saved cart from another market: move it to this one, so prices match the page.
|
|
@@ -249,8 +281,30 @@ export function init(overrides = {}) {
|
|
|
249
281
|
* Without markets.detect only the visitor's saved choice applies, read here without loading
|
|
250
282
|
* the markets part.
|
|
251
283
|
*/
|
|
284
|
+
/**
|
|
285
|
+
* A page built for one market (e.g. /de/ pages): <meta name="tether-page-country" content="DE">,
|
|
286
|
+
* tether-page-language, tether-page-locale. Wins over the visitor's saved choice, isn't remembered.
|
|
287
|
+
*/
|
|
288
|
+
function pageMarket(doc = document) {
|
|
289
|
+
const get = (n) => doc.querySelector(`meta[name="${cfg.prefix}-page-${n}"]`)?.getAttribute('content') || undefined;
|
|
290
|
+
const country = get('country');
|
|
291
|
+
return country ? { country: country.toUpperCase(), language: get('language'), locale: get('locale') } : null;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
let baseMarket = null; // the configured market, before a visitor's or page's market applied
|
|
295
|
+
let onPageMarket = false;
|
|
296
|
+
|
|
252
297
|
async function applyMarket(overrides) {
|
|
253
298
|
const m = cfg.markets || {};
|
|
299
|
+
baseMarket = { country: cfg.country, language: cfg.language, locale: cfg.locale };
|
|
300
|
+
const page = pageMarket();
|
|
301
|
+
onPageMarket = Boolean(page);
|
|
302
|
+
if (page) {
|
|
303
|
+
cfg.country = page.country;
|
|
304
|
+
cfg.language = page.language ? page.language.toUpperCase() : undefined;
|
|
305
|
+
if (page.locale) cfg.locale = page.locale;
|
|
306
|
+
return;
|
|
307
|
+
}
|
|
254
308
|
if (m.detect === false) return;
|
|
255
309
|
let market = null;
|
|
256
310
|
if (m.detect) {
|
|
@@ -266,6 +320,13 @@ async function applyMarket(overrides) {
|
|
|
266
320
|
log('market', market);
|
|
267
321
|
}
|
|
268
322
|
|
|
323
|
+
function hasAccountSession() {
|
|
324
|
+
try { return Boolean(sessionStorage[`${cfg.prefix}:account:${cfg.domain}`] || localStorage[`${cfg.prefix}:account:${cfg.domain}`] || localStorage[`${cfg.prefix}:account-hint:${cfg.domain}`]); } catch { return false; }
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/** Customer accounts (optional part, loaded on first use). See src/account.js. */
|
|
328
|
+
const accountApi = new Proxy({}, { get: (_, n) => (...a) => loadPart('account').then((m) => m[n](...a)) });
|
|
329
|
+
|
|
269
330
|
/** Tether.setCountry('DE'): loads the markets part on first use. */
|
|
270
331
|
export function setCountry(code, opts) {
|
|
271
332
|
return loadPart('markets').then((m) => m.setCountry(code, opts));
|
|
@@ -298,6 +359,7 @@ export const api = {
|
|
|
298
359
|
getRecommendations,
|
|
299
360
|
getScope,
|
|
300
361
|
addGroup,
|
|
362
|
+
account: accountApi,
|
|
301
363
|
primeProduct,
|
|
302
364
|
normalizeProductKey,
|
|
303
365
|
clearProductCache,
|
package/src/initial.js
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
*/
|
|
7
7
|
import { GID, allPlans, allocationFor, findPlan, planPrice, planSavings } from './selling-plans.js';
|
|
8
8
|
|
|
9
|
-
export function initialState(product, { variant, sellingPlan, defaultPurchase = 'one-time' } = {}) {
|
|
9
|
+
export function initialState(product, { variant, sellingPlan, defaultPurchase = 'one-time', lowStock = 5 } = {}) {
|
|
10
10
|
const vs = product?.variants?.nodes || [];
|
|
11
11
|
const gid = variant ? GID.variant(variant) : null;
|
|
12
12
|
const v = (gid && vs.find((x) => x.id === gid)) || vs.find((x) => x.availableForSale) || vs[0] || null;
|
|
@@ -26,6 +26,8 @@ export function initialState(product, { variant, sellingPlan, defaultPurchase =
|
|
|
26
26
|
const compareAt = sub ? (allocationFor(v, plan.id)?.priceAdjustments?.[0]?.compareAtPrice || v.compareAtPrice) : v.compareAtPrice;
|
|
27
27
|
const onSale = Boolean(compareAt && parseFloat(compareAt.amount) > parseFloat(price.amount));
|
|
28
28
|
const q = v.quantityAvailable;
|
|
29
|
+
const low = q != null && q > 0 && q <= lowStock;
|
|
30
|
+
const savingsNow = sub ? planSavings(v, plan.id).percentage : 0;
|
|
29
31
|
|
|
30
32
|
return {
|
|
31
33
|
variant: v,
|
|
@@ -51,16 +53,23 @@ export function initialState(product, { variant, sellingPlan, defaultPurchase =
|
|
|
51
53
|
'not-on-sale': !onSale,
|
|
52
54
|
'has-variants': vs.length > 1,
|
|
53
55
|
'single-variant': vs.length === 1,
|
|
54
|
-
'low-stock':
|
|
56
|
+
'low-stock': low,
|
|
57
|
+
'plenty-in-stock': v.availableForSale && !low,
|
|
55
58
|
'subscription-available': subscriptionAvailable,
|
|
56
59
|
'subscription-unavailable': !subscriptionAvailable,
|
|
57
60
|
'subscription-only': Boolean(product.requiresSellingPlan),
|
|
58
61
|
'one-time-available': !product.requiresSellingPlan,
|
|
59
62
|
'subscription-selected': sub,
|
|
60
63
|
'one-time-selected': !sub,
|
|
64
|
+
'has-savings': sub ? savingsNow > 0 : onSale,
|
|
61
65
|
},
|
|
66
|
+
/** Option values without an available variant, as plain data (for frameworks that serialise props). */
|
|
67
|
+
unavailable: Object.fromEntries((product.options || []).map((o) => [o.name, o.optionValues.map((x) => x.name).filter((n) => isUnavail(o.name, n))])),
|
|
62
68
|
/** Option values with no available variant (given the other selected options): class is-unavailable. */
|
|
63
|
-
isUnavailable: (name, value) =>
|
|
64
|
-
(o.name === name ? o.value === value : selected[o.name] === o.value))),
|
|
69
|
+
isUnavailable: (name, value) => isUnavail(name, value),
|
|
65
70
|
};
|
|
71
|
+
function isUnavail(name, value) {
|
|
72
|
+
return !vs.some((x) => x.availableForSale && x.selectedOptions.every((o) =>
|
|
73
|
+
(o.name === name ? o.value === value : selected[o.name] === o.value)));
|
|
74
|
+
}
|
|
66
75
|
}
|
package/src/lazy.js
CHANGED
|
@@ -3,7 +3,15 @@
|
|
|
3
3
|
* ESM / Node: dynamic import (bundlers split these into their own chunks).
|
|
4
4
|
* The <script> build swaps this file for lazy-iife.js (see scripts/build.mjs).
|
|
5
5
|
*/
|
|
6
|
-
|
|
6
|
+
const PARTS = {
|
|
7
|
+
diagnose: () => import('./diagnose.js'),
|
|
8
|
+
markets: () => import('./markets-ui.js'),
|
|
9
|
+
search: () => import('./search.js'),
|
|
10
|
+
extras: () => import('./extras.js'),
|
|
11
|
+
account: () => import('./account.js'),
|
|
12
|
+
analytics: () => import('./analytics.js'),
|
|
13
|
+
};
|
|
14
|
+
export const loadPart = (name) => PARTS[name]();
|
|
7
15
|
|
|
8
16
|
// Only the <script> build shares its modules with separately loaded parts.
|
|
9
17
|
export const coreModules = undefined;
|
package/src/loader.js
CHANGED
|
@@ -95,7 +95,10 @@ async function flush() {
|
|
|
95
95
|
if (gen === generation) { primeProduct(p); freshAt.set(p.handle, Date.now()); freshAt.set(p.id, Date.now()); }
|
|
96
96
|
r.resolve(p);
|
|
97
97
|
} else {
|
|
98
|
-
|
|
98
|
+
// A miss is remembered for a few seconds (one page load asking twice makes one request),
|
|
99
|
+
// not longer: a product published in Shopify shows up on the next visit.
|
|
100
|
+
const miss = productCache.get(k);
|
|
101
|
+
if (gen === generation) setTimeout(() => { if (productCache.get(k) === miss) productCache.delete(k); }, 10000);
|
|
99
102
|
r.reject(new TetherError(`Product not found: ${k}`, { code: 'NOT_FOUND' }));
|
|
100
103
|
}
|
|
101
104
|
}));
|
package/src/markets-ui.js
CHANGED
|
@@ -59,7 +59,8 @@ export async function setCountry(code, { language, locale, remember = true } = {
|
|
|
59
59
|
const entry = Object.values(cfg.markets?.locales || {}).find((m) => toCountry(m?.country) === country);
|
|
60
60
|
const own = locale || entry?.locale;
|
|
61
61
|
const loc = own || cfg.locale;
|
|
62
|
-
|
|
62
|
+
// Language: the option, else the markets.locales entry for this country (which may have none), else unchanged.
|
|
63
|
+
const lang = language !== undefined ? toLanguage(language) : entry ? (entry.language ? toLanguage(entry.language) : undefined) : cfg.language;
|
|
63
64
|
const changed = country !== String(cfg.country || '').toUpperCase() || lang !== cfg.language || loc !== cfg.locale;
|
|
64
65
|
cfg.country = country;
|
|
65
66
|
cfg.language = lang || undefined;
|
package/src/mounting.js
CHANGED
|
@@ -45,7 +45,9 @@ function mountProduct(el) {
|
|
|
45
45
|
// A rendered scope keeps showing its content while it refreshes (e.g. country switch).
|
|
46
46
|
if (!scopes.get(el)) setScopeStatus(el, 'loading');
|
|
47
47
|
else el.setAttribute(attr('state'), 'updating');
|
|
48
|
-
getProduct(key)
|
|
48
|
+
const loading = getProduct(key);
|
|
49
|
+
marketPending?.add(loading);
|
|
50
|
+
loading
|
|
49
51
|
.then((p) => {
|
|
50
52
|
if (!current()) return;
|
|
51
53
|
const existing = scopes.get(el);
|
|
@@ -114,6 +116,24 @@ function flushRefresh() {
|
|
|
114
116
|
* their products are used without a request. Ignored when they were made for another market.
|
|
115
117
|
*/
|
|
116
118
|
const readScripts = new WeakSet();
|
|
119
|
+
let marketPending = null; // product loads to wait for before html.tether-market-pending goes
|
|
120
|
+
|
|
121
|
+
/** Lists (and anything else that loads prices) wait too. */
|
|
122
|
+
export const trackMarket = (p) => { marketPending?.add(p); return p; };
|
|
123
|
+
|
|
124
|
+
/** After a mount: drop html.tether-market-pending once this page's products are live. */
|
|
125
|
+
export function settleMarket() {
|
|
126
|
+
if (!marketPending) return;
|
|
127
|
+
const waits = [...marketPending];
|
|
128
|
+
Promise.allSettled(waits).then(() => {
|
|
129
|
+
if (!marketPending || waits.some((w) => !marketPending.has(w))) return;
|
|
130
|
+
if (waits.length === marketPending.size) {
|
|
131
|
+
marketPending = null;
|
|
132
|
+
document.documentElement.classList.remove(`${cfg.prefix}-market-pending`);
|
|
133
|
+
} else settleMarket(); // more products came in meanwhile
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
|
|
117
137
|
export function hydrateFromPage(root = document) {
|
|
118
138
|
qsa(root.ownerDocument || root, 'script[type="application/json"][data-tether-hydrate]').forEach((s) => {
|
|
119
139
|
if (readScripts.has(s)) return;
|
|
@@ -123,7 +143,12 @@ export function hydrateFromPage(root = document) {
|
|
|
123
143
|
rememberOptionNames(data.products); // the build's option names, also for other languages
|
|
124
144
|
const same = (a, b) => String(a || '').toUpperCase() === String(b || '').toUpperCase();
|
|
125
145
|
if (same(data.country, cfg.country) && same(data.language, cfg.language)) hydrateProducts(data.products);
|
|
126
|
-
else
|
|
146
|
+
else {
|
|
147
|
+
// The page was built for another market: its prices aren't this visitor's. Mark the page
|
|
148
|
+
// (html.tether-market-pending) until the live ones are in, so CSS can hide prices meanwhile.
|
|
149
|
+
log('hydration skipped: other market');
|
|
150
|
+
if (!marketPending) { marketPending = new Set(); document.documentElement.classList.add(`${cfg.prefix}-market-pending`); }
|
|
151
|
+
}
|
|
127
152
|
} catch (err) { warn('bad data-tether-hydrate', err); }
|
|
128
153
|
});
|
|
129
154
|
}
|
package/src/product.js
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
import { cfg, reportError } from './config.js';
|
|
6
6
|
import {
|
|
7
7
|
attr, sel, get, has, qsa, scoped, emit, formatMoney, formatDate, setText, setImage, show,
|
|
8
|
-
setSelected, repeat, productUrl, notifyLayoutChanged, watchImages, busy, parseJSONAttr, toAttributes, sameImage, hasTemplate,
|
|
8
|
+
setSelected, repeat, productUrl, notifyLayoutChanged, watchImages, busy, parseJSONAttr, toAttributes, sameImage, hasTemplate, formatPercent,
|
|
9
9
|
} from './dom.js';
|
|
10
10
|
import { Cart } from './cart.js';
|
|
11
11
|
import { STATUS_KEYS, ownedBy } from './status.js';
|
|
@@ -167,7 +167,16 @@ export class ProductScope {
|
|
|
167
167
|
|
|
168
168
|
select(name, value) {
|
|
169
169
|
if (this.selected[name] === value) return;
|
|
170
|
-
this.
|
|
170
|
+
const vs = this.product.variants.nodes.filter((v) => v.selectedOptions.some((o) => o.name === name && o.value === value));
|
|
171
|
+
if (!vs.length) return; // a value no variant has: nothing to select, keep the visitor's choice
|
|
172
|
+
const next = { ...this.selected, [name]: value };
|
|
173
|
+
if (!vs.some((v) => v.selectedOptions.every((o) => next[o.name] === o.value))) {
|
|
174
|
+
// That combination doesn't exist: the closest variant with this value (most other choices kept, available first).
|
|
175
|
+
const score = (v) => v.selectedOptions.filter((o) => o.name !== name && this.selected[o.name] === o.value).length * 2 + (v.availableForSale ? 1 : 0);
|
|
176
|
+
const best = vs.reduce((a, b) => (score(b) > score(a) ? b : a));
|
|
177
|
+
best.selectedOptions.forEach((o) => { next[o.name] = o.value; });
|
|
178
|
+
}
|
|
179
|
+
this.selected = next;
|
|
171
180
|
this.renderVariant();
|
|
172
181
|
}
|
|
173
182
|
|
|
@@ -419,7 +428,7 @@ export class ProductScope {
|
|
|
419
428
|
const total = { amount: (parseFloat(price.amount) * Math.max(1, this.quantity)).toFixed(2), currencyCode: price.currencyCode };
|
|
420
429
|
|
|
421
430
|
const fm = formatMoney;
|
|
422
|
-
const pct = (n) => (n
|
|
431
|
+
const pct = (n) => formatPercent(n);
|
|
423
432
|
// Text binds: name -> value (functions, so only the binds on the page are computed).
|
|
424
433
|
const text = {
|
|
425
434
|
price: () => fm(price),
|
|
@@ -428,7 +437,8 @@ export class ProductScope {
|
|
|
428
437
|
'subscription-price': () => fm(subPrice),
|
|
429
438
|
'subscription-savings': () => pct(savings.percentage),
|
|
430
439
|
'subscription-savings-amount': () => fm(savings.amount),
|
|
431
|
-
|
|
440
|
+
// What the visitor pays today for the chosen quantity (prepaid plans charge more up front).
|
|
441
|
+
'checkout-charge': () => { const c = sub ? planCheckoutCharge(v, this.planId) : price; return c && fm({ amount: (parseFloat(c.amount) * Math.max(1, this.quantity)).toFixed(2), currencyCode: c.currencyCode }); },
|
|
432
442
|
'plan-name': () => plan?.name,
|
|
433
443
|
'plan-description': () => plan?.description,
|
|
434
444
|
'plan-group': () => plan?.group?.name,
|
|
@@ -486,6 +496,7 @@ export class ProductScope {
|
|
|
486
496
|
const k = get(el, 'plan-bind');
|
|
487
497
|
const val = {
|
|
488
498
|
name: () => planObj?.name,
|
|
499
|
+
option: () => (planObj?.options || []).map((o) => o.value).join(', '), // e.g. "1 month", without the store's price text
|
|
489
500
|
description: () => planObj?.description,
|
|
490
501
|
price: () => fm(planPrice(v, id)),
|
|
491
502
|
savings: () => pct(s.percentage),
|
|
@@ -498,6 +509,7 @@ export class ProductScope {
|
|
|
498
509
|
|
|
499
510
|
this.$(`select${sel('plan')}`).forEach((s) => { s.value = sub ? this.planId : ''; });
|
|
500
511
|
|
|
512
|
+
const low = v.quantityAvailable != null && v.quantityAvailable > 0 && v.quantityAvailable <= (cfg.lowStock ?? 5);
|
|
501
513
|
const conds = {
|
|
502
514
|
'in-stock': v.availableForSale,
|
|
503
515
|
'out-of-stock': !v.availableForSale,
|
|
@@ -506,7 +518,8 @@ export class ProductScope {
|
|
|
506
518
|
'not-on-sale': !onSale,
|
|
507
519
|
'has-variants': p.variants.nodes.length > 1,
|
|
508
520
|
'single-variant': p.variants.nodes.length === 1,
|
|
509
|
-
'low-stock':
|
|
521
|
+
'low-stock': low,
|
|
522
|
+
'plenty-in-stock': v.availableForSale && !low,
|
|
510
523
|
'subscription-available': this.subscriptionAvailable,
|
|
511
524
|
'subscription-unavailable': !this.subscriptionAvailable,
|
|
512
525
|
'subscription-only': Boolean(p.requiresSellingPlan),
|
|
@@ -552,5 +565,6 @@ export class ProductScope {
|
|
|
552
565
|
product: p, variant: v, selected: { ...this.selected },
|
|
553
566
|
purchase: this.purchase, sellingPlanId: sub ? this.planId : null,
|
|
554
567
|
}, this.root);
|
|
568
|
+
|
|
555
569
|
}
|
|
556
570
|
}
|
package/src/search.js
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Search as you type (Shopify predictive search). Optional part: loaded only on pages with
|
|
3
|
+
* [tether-search].
|
|
4
|
+
*
|
|
5
|
+
* <form tether-search action="/search"> <!-- Enter: your full results page -->
|
|
6
|
+
* <input tether-search-input name="q" type="search" placeholder="Search">
|
|
7
|
+
* <div tether-search-results>
|
|
8
|
+
* <div tether-search-products><a><img tether-search-bind="image" max-width="120"><span tether-search-bind="title"></span> <span tether-search-bind="price"></span></a></div>
|
|
9
|
+
* <div tether-search-queries><a><span tether-search-bind="text"></span></a></div>
|
|
10
|
+
* <div tether-search-collections><a><span tether-search-bind="title"></span></a></div>
|
|
11
|
+
* <p tether-show="search-empty">No results</p>
|
|
12
|
+
* </div>
|
|
13
|
+
* </form>
|
|
14
|
+
*
|
|
15
|
+
* Each list's first child is the template. The root gets is-open while there are results (or none)
|
|
16
|
+
* to show, and tether-search-state="loading|ready|empty|error". Arrow keys move through the results,
|
|
17
|
+
* Escape closes. tether-search-limit (default 6), tether-search-min (characters, default 2).
|
|
18
|
+
*/
|
|
19
|
+
import { cfg, reportError } from './config.js';
|
|
20
|
+
import { gql } from './client.js';
|
|
21
|
+
import { attr, sel, get, qsa, setText, setImage, repeat, show, formatMoney, productUrl, emit } from './dom.js';
|
|
22
|
+
|
|
23
|
+
const QUERY = `query TetherPredictive($q: String!, $limit: Int!) {
|
|
24
|
+
predictiveSearch(query: $q, limit: $limit, limitScope: EACH, types: [PRODUCT, COLLECTION, QUERY]) {
|
|
25
|
+
products { id handle title availableForSale featuredImage { url altText } priceRange { minVariantPrice { amount currencyCode } } }
|
|
26
|
+
collections { handle title image { url altText } }
|
|
27
|
+
queries { text }
|
|
28
|
+
}
|
|
29
|
+
}`;
|
|
30
|
+
|
|
31
|
+
const mounted = new WeakSet();
|
|
32
|
+
let uid = 0;
|
|
33
|
+
|
|
34
|
+
export function mountSearch(root = document) {
|
|
35
|
+
const roots = [...(root.matches?.(sel('search')) ? [root] : []), ...qsa(root, sel('search'))];
|
|
36
|
+
roots.forEach((el) => { if (!mounted.has(el)) { mounted.add(el); setup(el); } });
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function setup(rootEl) {
|
|
40
|
+
const input = rootEl.querySelector(sel('search-input')) || rootEl.querySelector('input');
|
|
41
|
+
const results = rootEl.querySelector(sel('search-results'));
|
|
42
|
+
if (!input || !results) return;
|
|
43
|
+
const limit = parseInt(get(rootEl, 'search-limit') || '6', 10) || 6;
|
|
44
|
+
const min = parseInt(get(rootEl, 'search-min') || '2', 10) || 2;
|
|
45
|
+
const id = results.id || (results.id = `${cfg.prefix}-search-${++uid}`);
|
|
46
|
+
input.setAttribute('role', 'combobox');
|
|
47
|
+
input.setAttribute('aria-autocomplete', 'list');
|
|
48
|
+
input.setAttribute('aria-controls', id);
|
|
49
|
+
input.setAttribute('aria-expanded', 'false');
|
|
50
|
+
input.setAttribute('autocomplete', 'off');
|
|
51
|
+
if (!results.hasAttribute('role')) results.setAttribute('role', 'listbox');
|
|
52
|
+
results.hidden = true;
|
|
53
|
+
|
|
54
|
+
let token = 0;
|
|
55
|
+
let timer = null;
|
|
56
|
+
let last = '';
|
|
57
|
+
const cache = new Map(); // term -> results, for typing back and forth
|
|
58
|
+
|
|
59
|
+
const setState = (state) => rootEl.setAttribute(attr('search-state'), state);
|
|
60
|
+
const open = (yes) => {
|
|
61
|
+
rootEl.classList.toggle('is-open', yes);
|
|
62
|
+
results.hidden = !yes;
|
|
63
|
+
input.setAttribute('aria-expanded', String(yes));
|
|
64
|
+
if (!yes) input.removeAttribute('aria-activedescendant');
|
|
65
|
+
};
|
|
66
|
+
const items = () => qsa(results, '[role="option"]');
|
|
67
|
+
|
|
68
|
+
async function run(term) {
|
|
69
|
+
const t = ++token;
|
|
70
|
+
if (term.length < min) { open(false); setState('idle'); return; }
|
|
71
|
+
setState('loading');
|
|
72
|
+
try {
|
|
73
|
+
const data = cache.get(term) || (await gql(QUERY, { q: term, limit })).predictiveSearch;
|
|
74
|
+
cache.set(term, data);
|
|
75
|
+
if (t !== token) return; // a newer search is on its way
|
|
76
|
+
render(data, term);
|
|
77
|
+
} catch (err) {
|
|
78
|
+
if (t !== token) return;
|
|
79
|
+
setState('error');
|
|
80
|
+
reportError(err, 'search');
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function render(data, term) {
|
|
85
|
+
const products = data?.products || [];
|
|
86
|
+
const collections = data?.collections || [];
|
|
87
|
+
const queries = (data?.queries || []).filter((q) => q.text.toLowerCase() !== term.toLowerCase());
|
|
88
|
+
const fill = (name, list, each) => qsa(results, sel(name)).forEach((c) => {
|
|
89
|
+
repeat(c, list, each);
|
|
90
|
+
show(c, list.length > 0);
|
|
91
|
+
});
|
|
92
|
+
fill('search-products', products, (node, p) => {
|
|
93
|
+
binds(node, { title: p.title, price: formatMoney(p.priceRange?.minVariantPrice), image: p.featuredImage });
|
|
94
|
+
link(node, productUrl(p.handle, p.id));
|
|
95
|
+
node.classList.toggle('is-sold-out', !p.availableForSale);
|
|
96
|
+
});
|
|
97
|
+
fill('search-collections', collections, (node, c) => {
|
|
98
|
+
binds(node, { title: c.title, image: c.image });
|
|
99
|
+
link(node, `${(cfg.collectionPath || '/collection').replace(/\/$/, '')}/${c.handle}`);
|
|
100
|
+
});
|
|
101
|
+
fill('search-queries', queries, (node, q) => {
|
|
102
|
+
binds(node, { text: q.text });
|
|
103
|
+
const a = node.tagName === 'A' ? node : node.querySelector('a');
|
|
104
|
+
const action = rootEl.getAttribute('action') || '/search';
|
|
105
|
+
if (a) a.setAttribute('href', `${action}${action.includes('?') ? '&' : '?'}${input.name || 'q'}=${encodeURIComponent(q.text)}`);
|
|
106
|
+
});
|
|
107
|
+
const count = products.length + collections.length + queries.length;
|
|
108
|
+
qsa(rootEl, sel('show')).forEach((el) => {
|
|
109
|
+
const k = get(el, 'show');
|
|
110
|
+
if (k === 'search-empty') show(el, count === 0);
|
|
111
|
+
if (k === 'search-results') show(el, count > 0);
|
|
112
|
+
});
|
|
113
|
+
items().forEach((el, i) => { el.id ||= `${id}-${i}`; el.setAttribute('aria-selected', 'false'); });
|
|
114
|
+
setState(count ? 'ready' : 'empty');
|
|
115
|
+
open(true);
|
|
116
|
+
emit('search', { term, products, collections, queries }, rootEl);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function binds(node, values) {
|
|
120
|
+
qsa(node, sel('search-bind')).forEach((el) => {
|
|
121
|
+
const k = get(el, 'search-bind');
|
|
122
|
+
if (k === 'image') setImage(el, values.image?.url, values.image?.altText || values.title);
|
|
123
|
+
else if (values[k] != null) setText(el, values[k]);
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function link(node, href) {
|
|
128
|
+
const a = node.tagName === 'A' ? node : node.querySelector('a');
|
|
129
|
+
if (a) { a.setAttribute('href', href); a.setAttribute('role', 'option'); }
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
input.addEventListener('input', () => {
|
|
133
|
+
const term = input.value.trim();
|
|
134
|
+
if (term === last) return;
|
|
135
|
+
last = term;
|
|
136
|
+
clearTimeout(timer);
|
|
137
|
+
timer = setTimeout(() => run(term), cfg.searchDelay ?? 150);
|
|
138
|
+
});
|
|
139
|
+
input.addEventListener('focus', () => { if (input.value.trim().length >= min && items().length) open(true); });
|
|
140
|
+
input.addEventListener('keydown', (e) => {
|
|
141
|
+
const list = items();
|
|
142
|
+
const cur = list.findIndex((el) => el.getAttribute('aria-selected') === 'true');
|
|
143
|
+
const move = (i) => {
|
|
144
|
+
list.forEach((el, j) => el.setAttribute('aria-selected', String(j === i)));
|
|
145
|
+
if (list[i]) { input.setAttribute('aria-activedescendant', list[i].id); list[i].scrollIntoView?.({ block: 'nearest' }); }
|
|
146
|
+
};
|
|
147
|
+
if (e.key === 'ArrowDown' && list.length) { e.preventDefault(); open(true); move((cur + 1) % list.length); }
|
|
148
|
+
else if (e.key === 'ArrowUp' && list.length) { e.preventDefault(); move(cur <= 0 ? list.length - 1 : cur - 1); }
|
|
149
|
+
else if (e.key === 'Enter' && cur >= 0) { e.preventDefault(); list[cur].click(); }
|
|
150
|
+
else if (e.key === 'Escape') { open(false); }
|
|
151
|
+
});
|
|
152
|
+
document.addEventListener('click', (e) => { if (!rootEl.contains(e.target)) open(false); });
|
|
153
|
+
}
|