@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.
Files changed (66) hide show
  1. package/README.md +9 -7
  2. package/dist/esm/astro.js +1 -1
  3. package/dist/esm/chunks/account-WAWNASZU.js +1 -0
  4. package/dist/esm/chunks/analytics-22NMIVBV.js +1 -0
  5. package/dist/esm/chunks/chunk-4UR45XTC.js +1 -0
  6. package/dist/esm/chunks/chunk-6R752OYZ.js +1 -0
  7. package/dist/esm/chunks/chunk-BGIKQY6H.js +1 -0
  8. package/dist/esm/chunks/chunk-CISGFYOF.js +1 -0
  9. package/dist/esm/chunks/chunk-KARKXC6G.js +1 -0
  10. package/dist/esm/chunks/chunk-YXCXIPQV.js +4 -0
  11. package/dist/esm/chunks/{chunk-RBVRRQIU.js → chunk-YZMBXS6Y.js} +1 -1
  12. package/dist/esm/chunks/chunk-ZVUPVSLF.js +1 -0
  13. package/dist/esm/chunks/diagnose-6ZSK5DGE.js +1 -0
  14. package/dist/esm/chunks/extras-QDCSW6VU.js +1 -0
  15. package/dist/esm/chunks/markets-ui-HJCVLCKJ.js +1 -0
  16. package/dist/esm/chunks/search-LN2H6SXS.js +1 -0
  17. package/dist/esm/index.js +1 -1
  18. package/dist/esm/server.js +1 -1
  19. package/dist/tether.account.js +374 -0
  20. package/dist/tether.account.min.js +1 -0
  21. package/dist/tether.analytics.js +259 -0
  22. package/dist/tether.analytics.min.js +1 -0
  23. package/dist/tether.diagnose.js +11 -2
  24. package/dist/tether.diagnose.min.js +1 -1
  25. package/dist/tether.extras.js +87 -0
  26. package/dist/tether.extras.min.js +1 -0
  27. package/dist/tether.js +268 -75
  28. package/dist/tether.markets.js +3 -3
  29. package/dist/tether.markets.min.js +1 -1
  30. package/dist/tether.min.js +3 -3
  31. package/dist/tether.min.js.map +3 -3
  32. package/dist/tether.search.js +176 -0
  33. package/dist/tether.search.min.js +1 -0
  34. package/package.json +5 -2
  35. package/src/account.js +313 -0
  36. package/src/analytics.js +232 -0
  37. package/src/astro.js +4 -1
  38. package/src/cart.js +74 -11
  39. package/src/client.js +8 -3
  40. package/src/collection.js +15 -10
  41. package/src/config.js +3 -0
  42. package/src/diagnose.js +11 -2
  43. package/src/dom.js +7 -1
  44. package/src/extras.js +63 -0
  45. package/src/fragments.js +1 -1
  46. package/src/index.js +103 -41
  47. package/src/initial.js +13 -4
  48. package/src/lazy.js +9 -1
  49. package/src/loader.js +4 -1
  50. package/src/markets-ui.js +2 -1
  51. package/src/mounting.js +27 -2
  52. package/src/parts/account.js +3 -0
  53. package/src/parts/analytics.js +3 -0
  54. package/src/parts/extras.js +3 -0
  55. package/src/parts/search.js +3 -0
  56. package/src/product.js +19 -5
  57. package/src/search.js +153 -0
  58. package/src/server.js +78 -6
  59. package/src/status.js +2 -2
  60. package/types/index.d.ts +56 -3
  61. package/types/server.d.ts +10 -2
  62. package/dist/esm/chunks/chunk-HHMOPDZG.js +0 -1
  63. package/dist/esm/chunks/chunk-HXKV6QGB.js +0 -4
  64. package/dist/esm/chunks/chunk-RNZMTQTX.js +0 -1
  65. package/dist/esm/chunks/diagnose-J667GHGS.js +0 -1
  66. 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.5.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.updateLine(id, current.quantity + 1));
106
- if (current && t.closest(sel('line-minus'))) return run(() => Cart.updateLine(id, current.quantity - 1));
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).catch((err) => reportError(err, 'cart line'));
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 }).catch((err) => reportError(err, 'cart line plan'));
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
- const cart = await Cart.applyDiscount(code);
147
- const ok = (cart?.discountCodes || []).find((d) => d.code.toLowerCase() === code.toLowerCase())?.applicable;
148
- // An invalid code doesn't stay in the cart (Shopify would warn about it on every change).
149
- if (!ok) await Cart.removeDiscount(code).catch(() => {});
150
- form.classList.toggle('is-invalid', !ok);
151
- emit(ok ? 'discount-applied' : 'discount-invalid', { code }, form);
152
- if (ok && input) input.value = '';
153
- } catch (err) { form.classList.add('is-invalid'); reportError(err, 'discount'); }
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) => { e.target.closest?.(sel('discount-form'))?.classList.remove('is-invalid'); });
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
- document.addEventListener('astro:page-load', () => mount(document));
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 async function addGroup(btn) {
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': q != null && q > 0 && q <= 5,
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) => !vs.some((x) => x.availableForSale && x.selectedOptions.every((o) =>
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
- export const loadPart = (name) => (name === 'diagnose' ? import('./diagnose.js') : import('./markets-ui.js'));
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
- if (gen === generation) productCache.delete(k); // don't cache misses: a fix in Shopify works on next load
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
- const lang = language === undefined ? cfg.language : toLanguage(language);
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 log('hydration skipped: other market');
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
  }
@@ -0,0 +1,3 @@
1
+ import * as part from '../account.js';
2
+
3
+ window.Tether.__parts.account = part;
@@ -0,0 +1,3 @@
1
+ import * as part from '../analytics.js';
2
+
3
+ window.Tether.__parts.analytics = part;
@@ -0,0 +1,3 @@
1
+ import * as part from '../extras.js';
2
+
3
+ window.Tether.__parts.extras = part;
@@ -0,0 +1,3 @@
1
+ import * as part from '../search.js';
2
+
3
+ window.Tether.__parts.search = part;
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.selected[name] = value;
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 ? `${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
- 'checkout-charge': () => fm(sub ? planCheckoutCharge(v, this.planId) : price),
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': v.quantityAvailable != null && v.quantityAvailable > 0 && v.quantityAvailable <= 5,
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
+ }