@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/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,
|
|
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';
|
|
@@ -25,6 +25,13 @@ function metafieldValue(product, ref) {
|
|
|
25
25
|
return mf || null;
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
+
// Option names as written in the markup (the build's language), by product handle. With another
|
|
29
|
+
// language Shopify translates them (Size -> Größe), so attributes are matched by position instead.
|
|
30
|
+
const knownOptionNames = new Map();
|
|
31
|
+
export function rememberOptionNames(products) {
|
|
32
|
+
(products || []).forEach((p) => { if (p?.handle && !knownOptionNames.has(p.handle)) knownOptionNames.set(p.handle, (p.options || []).map((o) => o.name)); });
|
|
33
|
+
}
|
|
34
|
+
|
|
28
35
|
export class ProductScope {
|
|
29
36
|
constructor(root, product) {
|
|
30
37
|
this.root = root;
|
|
@@ -76,6 +83,14 @@ export class ProductScope {
|
|
|
76
83
|
|
|
77
84
|
// ------------------------------------------------------------ state
|
|
78
85
|
|
|
86
|
+
/** The product's option name for a name used in the markup (same name, or same position). */
|
|
87
|
+
optName(authored) {
|
|
88
|
+
const opts = this.product?.options || [];
|
|
89
|
+
if (!authored || opts.some((o) => o.name === authored)) return authored;
|
|
90
|
+
const i = (knownOptionNames.get(this.product.handle) || []).indexOf(authored);
|
|
91
|
+
return i >= 0 && opts[i] ? opts[i].name : authored;
|
|
92
|
+
}
|
|
93
|
+
|
|
79
94
|
initSelection() {
|
|
80
95
|
const vs = this.product.variants.nodes;
|
|
81
96
|
const first = vs.find((v) => v.availableForSale) || vs[0];
|
|
@@ -152,7 +167,16 @@ export class ProductScope {
|
|
|
152
167
|
|
|
153
168
|
select(name, value) {
|
|
154
169
|
if (this.selected[name] === value) return;
|
|
155
|
-
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;
|
|
156
180
|
this.renderVariant();
|
|
157
181
|
}
|
|
158
182
|
|
|
@@ -208,7 +232,7 @@ export class ProductScope {
|
|
|
208
232
|
const opt = t.closest(sel('option-value'));
|
|
209
233
|
if (own(opt) && opt.closest(sel('option-list'))) {
|
|
210
234
|
e.preventDefault();
|
|
211
|
-
this.select(get(opt.closest(sel('option-list')), 'option-list'), get(opt, 'option-value'));
|
|
235
|
+
this.select(this.optName(get(opt.closest(sel('option-list')), 'option-list')), get(opt, 'option-value'));
|
|
212
236
|
return;
|
|
213
237
|
}
|
|
214
238
|
|
|
@@ -234,7 +258,7 @@ export class ProductScope {
|
|
|
234
258
|
this.root.addEventListener('change', (e) => {
|
|
235
259
|
const t = e.target;
|
|
236
260
|
if (!own(t)) return;
|
|
237
|
-
if (t.matches(`select${sel('option')}`)) this.select(get(t, 'option'), t.value);
|
|
261
|
+
if (t.matches(`select${sel('option')}`)) this.select(this.optName(get(t, 'option')), t.value);
|
|
238
262
|
else if (t.matches(`select${sel('variant-select')}`)) this.selectVariant(t.value);
|
|
239
263
|
else if (t.matches(`select${sel('plan')}`)) {
|
|
240
264
|
if (t.value) this.selectPlan(t.value); else this.setPurchase('one-time');
|
|
@@ -266,6 +290,11 @@ export class ProductScope {
|
|
|
266
290
|
// ------------------------------------------------------------ render
|
|
267
291
|
|
|
268
292
|
renderProduct() {
|
|
293
|
+
const p0 = this.product;
|
|
294
|
+
if (!knownOptionNames.has(p0.handle)) {
|
|
295
|
+
const authored = this.$(`${sel('option-list')},select${sel('option')}`).map((e) => get(e, 'option-list') || get(e, 'option'));
|
|
296
|
+
if (authored.some((n) => p0.options.some((o) => o.name === n))) rememberOptionNames([p0]);
|
|
297
|
+
}
|
|
269
298
|
const p = this.product;
|
|
270
299
|
|
|
271
300
|
this.$(sel('bind')).forEach((el) => {
|
|
@@ -289,7 +318,11 @@ export class ProductScope {
|
|
|
289
318
|
el.setAttribute('href', productUrl(p.handle, p.id));
|
|
290
319
|
if (key === 'url' && !el.firstElementChild && !el.textContent.trim()) setText(el, p.title);
|
|
291
320
|
return undefined;
|
|
292
|
-
case 'images':
|
|
321
|
+
case 'images': {
|
|
322
|
+
// Server-rendered gallery with the same images: keep it as it is.
|
|
323
|
+
const imgs = [...el.children].map((n) => (n.tagName === 'IMG' ? n : n.querySelector('img')));
|
|
324
|
+
if (!hasTemplate(el) && imgs.length === p.images.nodes.length && imgs.every((im, i) => sameImage(im?.getAttribute('src'), p.images.nodes[i].url))) return undefined;
|
|
325
|
+
}
|
|
293
326
|
return repeat(el, p.images.nodes, (node, img) => {
|
|
294
327
|
const target = node.matches(`img,${sel('bind', 'image')}`) ? node : node.querySelector('img') || node;
|
|
295
328
|
setImage(target, img.url, img.altText || p.title);
|
|
@@ -316,7 +349,7 @@ export class ProductScope {
|
|
|
316
349
|
});
|
|
317
350
|
|
|
318
351
|
this.$(sel('option-list')).forEach((container) => {
|
|
319
|
-
const name = get(container, 'option-list');
|
|
352
|
+
const name = this.optName(get(container, 'option-list'));
|
|
320
353
|
const opt = p.options.find((o) => o.name === name);
|
|
321
354
|
if (!opt || (opt.optionValues.length <= 1 && has(container, 'hide-single'))) return show(container, false);
|
|
322
355
|
return repeat(container, opt.optionValues, (node, v) => {
|
|
@@ -330,7 +363,7 @@ export class ProductScope {
|
|
|
330
363
|
});
|
|
331
364
|
|
|
332
365
|
this.$(`select${sel('option')}`).forEach((s) => {
|
|
333
|
-
const opt = p.options.find((o) => o.name === get(s, 'option'));
|
|
366
|
+
const opt = p.options.find((o) => o.name === this.optName(get(s, 'option')));
|
|
334
367
|
if (!opt) return show(s, false);
|
|
335
368
|
s.innerHTML = '';
|
|
336
369
|
opt.optionValues.forEach((v) => s.appendChild(new Option(v.name, v.name)));
|
|
@@ -395,7 +428,7 @@ export class ProductScope {
|
|
|
395
428
|
const total = { amount: (parseFloat(price.amount) * Math.max(1, this.quantity)).toFixed(2), currencyCode: price.currencyCode };
|
|
396
429
|
|
|
397
430
|
const fm = formatMoney;
|
|
398
|
-
const pct = (n) => (n
|
|
431
|
+
const pct = (n) => formatPercent(n);
|
|
399
432
|
// Text binds: name -> value (functions, so only the binds on the page are computed).
|
|
400
433
|
const text = {
|
|
401
434
|
price: () => fm(price),
|
|
@@ -404,7 +437,8 @@ export class ProductScope {
|
|
|
404
437
|
'subscription-price': () => fm(subPrice),
|
|
405
438
|
'subscription-savings': () => pct(savings.percentage),
|
|
406
439
|
'subscription-savings-amount': () => fm(savings.amount),
|
|
407
|
-
|
|
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 }); },
|
|
408
442
|
'plan-name': () => plan?.name,
|
|
409
443
|
'plan-description': () => plan?.description,
|
|
410
444
|
'plan-group': () => plan?.group?.name,
|
|
@@ -425,10 +459,10 @@ export class ProductScope {
|
|
|
425
459
|
else if (text[k]) setText(el, text[k]());
|
|
426
460
|
});
|
|
427
461
|
|
|
428
|
-
this.$(sel('option-selected')).forEach((el) => setText(el, this.selected[get(el, 'option-selected')] || ''));
|
|
462
|
+
this.$(sel('option-selected')).forEach((el) => setText(el, this.selected[this.optName(get(el, 'option-selected'))] || ''));
|
|
429
463
|
|
|
430
464
|
this.$(sel('option-list')).forEach((container) => {
|
|
431
|
-
const name = get(container, 'option-list');
|
|
465
|
+
const name = this.optName(get(container, 'option-list'));
|
|
432
466
|
qsa(container, sel('option-value')).forEach((node) => {
|
|
433
467
|
const value = get(node, 'option-value');
|
|
434
468
|
setSelected(node, this.selected[name] === value);
|
|
@@ -438,7 +472,7 @@ export class ProductScope {
|
|
|
438
472
|
});
|
|
439
473
|
});
|
|
440
474
|
|
|
441
|
-
this.$(`select${sel('option')}`).forEach((s) => { s.value = this.selected[get(s, 'option')] || ''; });
|
|
475
|
+
this.$(`select${sel('option')}`).forEach((s) => { s.value = this.selected[this.optName(get(s, 'option'))] || ''; });
|
|
442
476
|
this.$(`select${sel('variant-select')}`).forEach((s) => { s.value = v.id; });
|
|
443
477
|
|
|
444
478
|
// Subscription UI
|
|
@@ -462,6 +496,7 @@ export class ProductScope {
|
|
|
462
496
|
const k = get(el, 'plan-bind');
|
|
463
497
|
const val = {
|
|
464
498
|
name: () => planObj?.name,
|
|
499
|
+
option: () => (planObj?.options || []).map((o) => o.value).join(', '), // e.g. "1 month", without the store's price text
|
|
465
500
|
description: () => planObj?.description,
|
|
466
501
|
price: () => fm(planPrice(v, id)),
|
|
467
502
|
savings: () => pct(s.percentage),
|
|
@@ -474,6 +509,7 @@ export class ProductScope {
|
|
|
474
509
|
|
|
475
510
|
this.$(`select${sel('plan')}`).forEach((s) => { s.value = sub ? this.planId : ''; });
|
|
476
511
|
|
|
512
|
+
const low = v.quantityAvailable != null && v.quantityAvailable > 0 && v.quantityAvailable <= (cfg.lowStock ?? 5);
|
|
477
513
|
const conds = {
|
|
478
514
|
'in-stock': v.availableForSale,
|
|
479
515
|
'out-of-stock': !v.availableForSale,
|
|
@@ -482,7 +518,8 @@ export class ProductScope {
|
|
|
482
518
|
'not-on-sale': !onSale,
|
|
483
519
|
'has-variants': p.variants.nodes.length > 1,
|
|
484
520
|
'single-variant': p.variants.nodes.length === 1,
|
|
485
|
-
'low-stock':
|
|
521
|
+
'low-stock': low,
|
|
522
|
+
'plenty-in-stock': v.availableForSale && !low,
|
|
486
523
|
'subscription-available': this.subscriptionAvailable,
|
|
487
524
|
'subscription-unavailable': !this.subscriptionAvailable,
|
|
488
525
|
'subscription-only': Boolean(p.requiresSellingPlan),
|
|
@@ -528,5 +565,6 @@ export class ProductScope {
|
|
|
528
565
|
product: p, variant: v, selected: { ...this.selected },
|
|
529
566
|
purchase: this.purchase, sellingPlanId: sub ? this.planId : null,
|
|
530
567
|
}, this.root);
|
|
568
|
+
|
|
531
569
|
}
|
|
532
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
|
+
}
|
package/src/server.js
ADDED
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @occluze/tether/server: Shopify product data at build time or on the server (Astro, Eleventy,
|
|
3
|
+
* Node, Cloudflare Workers). No browser APIs. Uses the same queries as the browser script, so
|
|
4
|
+
* products handed over with hydrate() are exactly what Tether expects and need no extra request.
|
|
5
|
+
*
|
|
6
|
+
* import { createStorefront } from '@occluze/tether/server';
|
|
7
|
+
* const shop = createStorefront({ domain, token, country: 'NL' });
|
|
8
|
+
* const product = await shop.getProduct('coffee-box');
|
|
9
|
+
* const all = await shop.allProducts();
|
|
10
|
+
*/
|
|
11
|
+
import { DEFAULTS } from './config.js';
|
|
12
|
+
import { request, withContext, TetherError } from './client.js';
|
|
13
|
+
import { productFragment, collectionFields } from './fragments.js';
|
|
14
|
+
import * as plans from './selling-plans.js';
|
|
15
|
+
import { imageUrl, formatPercent } from './dom.js';
|
|
16
|
+
import { initialState } from './initial.js';
|
|
17
|
+
|
|
18
|
+
export { imageUrl, initialState, formatPercent };
|
|
19
|
+
|
|
20
|
+
export { TetherError, plans, withContext };
|
|
21
|
+
|
|
22
|
+
const isGid = (v) => typeof v === 'string' && v.startsWith('gid://');
|
|
23
|
+
|
|
24
|
+
/** Handle, numeric id, gid or product URL -> handle or gid. */
|
|
25
|
+
export function productKey(raw) {
|
|
26
|
+
const v = String(raw ?? '').trim();
|
|
27
|
+
if (!v) return null;
|
|
28
|
+
if (isGid(v)) return v;
|
|
29
|
+
if (/^\d+$/.test(v)) return `gid://shopify/Product/${v}`;
|
|
30
|
+
const m = v.match(/\/products\/([^/?#]+)/);
|
|
31
|
+
return m ? m[1] : v;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Money as text, e.g. formatMoney({ amount: '30.0', currencyCode: 'EUR' }, 'nl-NL') -> "€ 30,00". */
|
|
35
|
+
export function formatMoney(money, locale) {
|
|
36
|
+
if (!money) return '';
|
|
37
|
+
const amount = parseFloat(money.amount);
|
|
38
|
+
if (Number.isNaN(amount)) return '';
|
|
39
|
+
try {
|
|
40
|
+
return new Intl.NumberFormat(locale || undefined, { style: 'currency', currency: money.currencyCode }).format(amount);
|
|
41
|
+
} catch {
|
|
42
|
+
return `${money.currencyCode} ${amount.toFixed(2)}`;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** productPath '/products' -> /products/<handle>; templates '/p?handle={handle}' and '{id}' work too. */
|
|
47
|
+
export function productUrl(handle, { productPath = '/product', id } = {}) {
|
|
48
|
+
if (!/\{(handle|id)\}/.test(productPath)) return `${productPath.replace(/\/$/, '')}/${handle}`;
|
|
49
|
+
return productPath.replace(/\{handle\}/g, encodeURIComponent(handle || '')).replace(/\{id\}/g, encodeURIComponent(id ? String(id).split('/').pop() : ''));
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** JSON for a <script> tag: escapes < so product text can never close the tag. */
|
|
53
|
+
const safeJson = (v) => JSON.stringify(v).replace(/</g, '\\u003c').replace(/\u2028/g, '\\u2028').replace(/\u2029/g, '\\u2029');
|
|
54
|
+
|
|
55
|
+
// createStorefront(import.meta.env) works too: these env names map to domain and token.
|
|
56
|
+
const ENV = {
|
|
57
|
+
domain: ['PUBLIC_SHOPIFY_DOMAIN', 'PUBLIC_SHOPIFY_STORE_DOMAIN', 'SHOPIFY_DOMAIN'],
|
|
58
|
+
token: ['PUBLIC_SHOPIFY_STOREFRONT_TOKEN', 'PUBLIC_SHOPIFY_STOREFRONT_ACCESS_TOKEN', 'SHOPIFY_STOREFRONT_TOKEN'],
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
export function createStorefront(options = {}) {
|
|
62
|
+
const known = Object.fromEntries(Object.entries(options).filter(([k]) => k in DEFAULTS || k === 'fetch' || k === 'pageSize' || k === 'siteUrl'));
|
|
63
|
+
const conf = { ...DEFAULTS, ...known };
|
|
64
|
+
for (const [key, names] of Object.entries(ENV)) if (!conf[key]) conf[key] = names.map((n) => options[n]).find(Boolean) || '';
|
|
65
|
+
conf.domain = String(conf.domain || '').replace(/^https?:\/\//, '').replace(/\/.*$/, '');
|
|
66
|
+
if (!conf.domain || !conf.token) {
|
|
67
|
+
throw new TetherError('createStorefront needs domain and token: pass them, or import.meta.env with PUBLIC_SHOPIFY_DOMAIN and PUBLIC_SHOPIFY_STOREFRONT_TOKEN', { code: 'NOT_CONFIGURED' });
|
|
68
|
+
}
|
|
69
|
+
const pageSize = options.pageSize || 25;
|
|
70
|
+
const q = (query, variables, opts) => request(conf, query, variables, opts);
|
|
71
|
+
const fragment = () => productFragment(conf);
|
|
72
|
+
|
|
73
|
+
async function getProduct(raw) {
|
|
74
|
+
const key = productKey(raw);
|
|
75
|
+
if (!key) return null;
|
|
76
|
+
const data = isGid(key)
|
|
77
|
+
? await q(`query TetherServerProductById($id: ID!) { node(id: $id) { ... on Product { ...TetherProduct } } }\n${fragment()}`, { id: key })
|
|
78
|
+
: await q(`query TetherServerProduct($handle: String!) { product(handle: $handle) { ...TetherProduct } }\n${fragment()}`, { handle: key });
|
|
79
|
+
const p = isGid(key) ? data.node : data.product;
|
|
80
|
+
return p && p.id ? p : null;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async function getProducts({ collection, query, first = pageSize, after = null, sortKey, reverse = false, filters } = {}) {
|
|
84
|
+
if (collection) {
|
|
85
|
+
const data = await q(`query TetherServerCollection($handle: String!, $first: Int!, $after: String, $sortKey: ProductCollectionSortKeys, $reverse: Boolean, $filters: [ProductFilter!]) {
|
|
86
|
+
collection(handle: $handle) {
|
|
87
|
+
${collectionFields()}
|
|
88
|
+
products(first: $first, after: $after, sortKey: $sortKey, reverse: $reverse, filters: $filters) {
|
|
89
|
+
nodes { ...TetherProduct }
|
|
90
|
+
pageInfo { hasNextPage endCursor }
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
${fragment()}`, { handle: collection, first, after, sortKey: sortKey || undefined, reverse, filters: filters || undefined });
|
|
95
|
+
if (!data.collection) return { collection: null, products: [], pageInfo: { hasNextPage: false, endCursor: null } };
|
|
96
|
+
const { products, ...rest } = data.collection;
|
|
97
|
+
return { collection: rest, products: products.nodes, pageInfo: products.pageInfo };
|
|
98
|
+
}
|
|
99
|
+
const data = await q(`query TetherServerProducts($first: Int!, $after: String, $query: String, $sortKey: ProductSortKeys, $reverse: Boolean) {
|
|
100
|
+
products(first: $first, after: $after, query: $query, sortKey: $sortKey, reverse: $reverse) {
|
|
101
|
+
nodes { ...TetherProduct }
|
|
102
|
+
pageInfo { hasNextPage endCursor }
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
${fragment()}`, { first, after, query: query || undefined, sortKey: sortKey || undefined, reverse });
|
|
106
|
+
return { collection: null, products: data.products.nodes, pageInfo: data.products.pageInfo };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Every product (of a collection or a search), page by page. For getStaticPaths(). */
|
|
110
|
+
async function allProducts(opts = {}) {
|
|
111
|
+
const out = [];
|
|
112
|
+
let after = null;
|
|
113
|
+
for (let i = 0; i < 500; i++) {
|
|
114
|
+
const page = await getProducts({ ...opts, first: pageSize, after });
|
|
115
|
+
out.push(...page.products);
|
|
116
|
+
if (!page.pageInfo.hasNextPage) break;
|
|
117
|
+
after = page.pageInfo.endCursor;
|
|
118
|
+
}
|
|
119
|
+
return out;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
async function getCollection(handle, opts = {}) {
|
|
123
|
+
const page = await getProducts({ ...opts, collection: handle });
|
|
124
|
+
return page.collection ? { ...page.collection, products: page.products, pageInfo: page.pageInfo } : null;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
async function allCollections() {
|
|
128
|
+
const out = [];
|
|
129
|
+
let after = null;
|
|
130
|
+
for (let i = 0; i < 100; i++) {
|
|
131
|
+
const data = await q(`query TetherServerCollections($first: Int!, $after: String) {
|
|
132
|
+
collections(first: $first, after: $after) { nodes { ${collectionFields()} } pageInfo { hasNextPage endCursor } }
|
|
133
|
+
}`, { first: 100, after });
|
|
134
|
+
out.push(...data.collections.nodes);
|
|
135
|
+
if (!data.collections.pageInfo.hasNextPage) break;
|
|
136
|
+
after = data.collections.pageInfo.endCursor;
|
|
137
|
+
}
|
|
138
|
+
return out;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** By product id (gid or numeric) or handle, so it can run next to getProduct(handle). */
|
|
142
|
+
async function getRecommendations(product, intent = 'RELATED') {
|
|
143
|
+
const key = productKey(typeof product === 'object' ? product?.id : product);
|
|
144
|
+
const byId = key?.startsWith('gid://');
|
|
145
|
+
const data = await q(`query TetherServerRecs(${byId ? '$id: ID!' : '$handle: String!'}, $intent: ProductRecommendationIntent) {
|
|
146
|
+
productRecommendations(${byId ? 'productId: $id' : 'productHandle: $handle'}, intent: $intent) { ...TetherProduct }
|
|
147
|
+
}
|
|
148
|
+
${fragment()}`, byId ? { id: key, intent } : { handle: key, intent });
|
|
149
|
+
return data.productRecommendations || [];
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* A <script> tag that hands products to the browser script: Tether renders them at once,
|
|
154
|
+
* without its own request, then refreshes prices and stock in the background.
|
|
155
|
+
* Put it anywhere in the page (Astro: <Fragment set:html={shop.hydrate(products)} />).
|
|
156
|
+
*/
|
|
157
|
+
/**
|
|
158
|
+
* { card: true } for lists (home, collections, recommendations): about 40% smaller, without
|
|
159
|
+
* descriptions, extra images and variant images (cards show the main image). Price, stock and
|
|
160
|
+
* plans per variant stay. The full product comes with the background refresh.
|
|
161
|
+
*/
|
|
162
|
+
function hydrate(products, { card = false } = {}) {
|
|
163
|
+
let list = (Array.isArray(products) ? products : [products]).filter(Boolean);
|
|
164
|
+
if (card) list = list.map(slim);
|
|
165
|
+
const payload = { v: 1, at: new Date().toISOString(), country: conf.country || null, language: conf.language || null, products: list };
|
|
166
|
+
return `<script type="application/json" data-tether-hydrate>${safeJson(payload)}</script>`;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* schema.org data for search engines (Google's product rich results): a Product, or a ProductGroup
|
|
171
|
+
* with one Product per variant (price, currency, stock, sku, image, link to ?variant=).
|
|
172
|
+
* jsonLd(product, { url: 'https://shop.example/products/x' }) -> the <script> tag; jsonLdData() -> the object.
|
|
173
|
+
*/
|
|
174
|
+
function jsonLdData(p, { url, brand } = {}) {
|
|
175
|
+
if (!p) return null;
|
|
176
|
+
const pageUrl = url || (conf.siteUrl ? new URL(productUrl(p.handle, { productPath: conf.productPath, id: p.id }), conf.siteUrl).toString() : undefined);
|
|
177
|
+
const vid = (v) => String(v.id).split('/').pop();
|
|
178
|
+
const withVariant = (v) => (pageUrl ? `${pageUrl}${pageUrl.includes('?') ? '&' : '?'}variant=${vid(v)}` : undefined);
|
|
179
|
+
const text = (p.description || '').trim() || undefined;
|
|
180
|
+
const images = (p.images?.nodes || []).map((i) => i.url).filter(Boolean);
|
|
181
|
+
const brandName = brand || p.vendor;
|
|
182
|
+
// Only buyable on a subscription: the plan price is the price (the first plan the variant has).
|
|
183
|
+
const priceOf = (v) => {
|
|
184
|
+
if (!p.requiresSellingPlan) return v.price;
|
|
185
|
+
const plan = plans.allPlans(p).find((pl) => plans.allocationFor(v, pl.id));
|
|
186
|
+
return plan ? plans.planPrice(v, plan.id) : v.price;
|
|
187
|
+
};
|
|
188
|
+
const offer = (v) => ({
|
|
189
|
+
'@type': 'Offer',
|
|
190
|
+
price: priceOf(v)?.amount,
|
|
191
|
+
priceCurrency: priceOf(v)?.currencyCode,
|
|
192
|
+
availability: `https://schema.org/${v.availableForSale ? (v.currentlyNotInStock ? 'BackOrder' : 'InStock') : 'OutOfStock'}`,
|
|
193
|
+
url: withVariant(v),
|
|
194
|
+
});
|
|
195
|
+
const vs = p.variants?.nodes || [];
|
|
196
|
+
const clean = (o) => JSON.parse(JSON.stringify(o)); // drops undefined
|
|
197
|
+
if (vs.length <= 1) {
|
|
198
|
+
const v = vs[0];
|
|
199
|
+
return clean({
|
|
200
|
+
'@context': 'https://schema.org', '@type': 'Product', name: p.title, description: text, url: pageUrl,
|
|
201
|
+
image: images, sku: v?.sku || undefined, brand: brandName ? { '@type': 'Brand', name: brandName } : undefined,
|
|
202
|
+
offers: v ? offer(v) : undefined,
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
const varies = { size: 'https://schema.org/size', color: 'https://schema.org/color', colour: 'https://schema.org/color', material: 'https://schema.org/material' };
|
|
206
|
+
return clean({
|
|
207
|
+
'@context': 'https://schema.org', '@type': 'ProductGroup', name: p.title, description: text, url: pageUrl,
|
|
208
|
+
productGroupID: String(p.id).split('/').pop(), image: images,
|
|
209
|
+
brand: brandName ? { '@type': 'Brand', name: brandName } : undefined,
|
|
210
|
+
variesBy: [...new Set((p.options || []).map((o) => varies[o.name.toLowerCase()]).filter(Boolean))],
|
|
211
|
+
hasVariant: vs.map((v) => ({
|
|
212
|
+
'@type': 'Product',
|
|
213
|
+
name: `${p.title} - ${v.title}`,
|
|
214
|
+
sku: v.sku || undefined,
|
|
215
|
+
image: v.image?.url || images[0],
|
|
216
|
+
...Object.fromEntries((v.selectedOptions || []).filter((o) => varies[o.name.toLowerCase()]).map((o) => [varies[o.name.toLowerCase()].split('/').pop(), o.value])),
|
|
217
|
+
offers: offer(v),
|
|
218
|
+
})),
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
const jsonLd = (p, opts) => (p ? `<script type="application/ld+json">${safeJson(jsonLdData(p, opts))}</script>` : '');
|
|
222
|
+
|
|
223
|
+
function slim(p) {
|
|
224
|
+
return {
|
|
225
|
+
...p,
|
|
226
|
+
description: '', descriptionHtml: '',
|
|
227
|
+
images: { nodes: (p.images?.nodes || []).slice(0, 1) },
|
|
228
|
+
variants: { nodes: (p.variants?.nodes || []).map((v) => ({ ...v, image: null })) },
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
return {
|
|
233
|
+
config: conf,
|
|
234
|
+
query: q,
|
|
235
|
+
getProduct,
|
|
236
|
+
getProducts,
|
|
237
|
+
allProducts,
|
|
238
|
+
getCollection,
|
|
239
|
+
allCollections,
|
|
240
|
+
getRecommendations,
|
|
241
|
+
hydrate,
|
|
242
|
+
formatMoney: (money, locale = conf.locale) => formatMoney(money, locale),
|
|
243
|
+
formatPercent: (n, locale = conf.locale) => formatPercent(n, locale),
|
|
244
|
+
imageUrl,
|
|
245
|
+
jsonLd,
|
|
246
|
+
jsonLdData,
|
|
247
|
+
initialState: (product, opts = {}) => initialState(product, { defaultPurchase: conf.defaultPurchase, lowStock: conf.lowStock, ...opts }),
|
|
248
|
+
productUrl: (handle, opts = {}) => productUrl(handle, { productPath: conf.productPath, ...opts }),
|
|
249
|
+
plans,
|
|
250
|
+
};
|
|
251
|
+
}
|
package/src/status.js
CHANGED
|
@@ -11,8 +11,8 @@ import { attr, sel, get, scoped, show } from './dom.js';
|
|
|
11
11
|
* "url:path" the last segment of the page path (/shop/coffee-box -> coffee-box)
|
|
12
12
|
* Returns { key, fromUrl, raw }. key is null when the URL has no value.
|
|
13
13
|
*/
|
|
14
|
-
export function resolveProductKey(el, location = window.location) {
|
|
15
|
-
const raw = (get(el,
|
|
14
|
+
export function resolveProductKey(el, location = window.location, name = 'product') {
|
|
15
|
+
const raw = (get(el, name) || '').trim();
|
|
16
16
|
if (!raw.toLowerCase().startsWith('url:')) return { key: raw || null, fromUrl: false, raw };
|
|
17
17
|
const what = raw.slice(4).trim();
|
|
18
18
|
let key = null;
|
|
@@ -53,12 +53,7 @@ export function setScopeStatus(root, status, err) {
|
|
|
53
53
|
scoped(root, sel('show'), boundary()).forEach((el) => {
|
|
54
54
|
const k = get(el, 'show');
|
|
55
55
|
if (!STATUS_KEYS.includes(k)) return;
|
|
56
|
-
|
|
57
|
-
show(el, visible);
|
|
58
|
-
// Blocks hidden by a Webflow class (display: none) still need to appear.
|
|
59
|
-
if (visible && typeof getComputedStyle === 'function' && getComputedStyle(el).display === 'none') {
|
|
60
|
-
el.style.display = el.getAttribute(attr('display')) || 'block';
|
|
61
|
-
}
|
|
56
|
+
show(el, k === status);
|
|
62
57
|
});
|
|
63
58
|
|
|
64
59
|
if (status === 'error' || notFound) {
|
package/types/astro.d.ts
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { TetherConfig } from './index.js';
|
|
2
|
+
|
|
3
|
+
type Serializable<T> = { [K in keyof T as T[K] extends ((...args: any[]) => any) | null | undefined ? (Exclude<T[K], null | undefined> extends (...args: any[]) => any ? never : K) : K]: T[K] };
|
|
4
|
+
|
|
5
|
+
export interface TetherAstroOptions extends Partial<Serializable<TetherConfig>> {
|
|
6
|
+
/** Add the Tether script to every page (default true). False: import and init it yourself. */
|
|
7
|
+
inject?: boolean;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Astro integration: Tether on every page, store and token from .env
|
|
12
|
+
* (PUBLIC_SHOPIFY_DOMAIN, PUBLIC_SHOPIFY_STOREFRONT_TOKEN). Functions (onError, hooks) go in
|
|
13
|
+
* window.TETHER_CONFIG in your layout.
|
|
14
|
+
*/
|
|
15
|
+
export default function tether(options?: TetherAstroOptions): { name: string; hooks: Record<string, (...args: any[]) => unknown> };
|