@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.
Files changed (68) hide show
  1. package/README.md +12 -8
  2. package/dist/esm/astro.js +2 -0
  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-VC46IEJQ.js +1 -0
  11. package/dist/esm/chunks/chunk-YXCXIPQV.js +4 -0
  12. package/dist/esm/chunks/{chunk-KYQ5OGBX.js → chunk-YZMBXS6Y.js} +1 -1
  13. package/dist/esm/chunks/chunk-ZVUPVSLF.js +1 -0
  14. package/dist/esm/chunks/diagnose-6ZSK5DGE.js +1 -0
  15. package/dist/esm/chunks/extras-QDCSW6VU.js +1 -0
  16. package/dist/esm/chunks/markets-ui-HJCVLCKJ.js +1 -0
  17. package/dist/esm/chunks/search-LN2H6SXS.js +1 -0
  18. package/dist/esm/index.js +1 -1
  19. package/dist/esm/server.js +1 -0
  20. package/dist/tether.account.js +374 -0
  21. package/dist/tether.account.min.js +1 -0
  22. package/dist/tether.analytics.js +259 -0
  23. package/dist/tether.analytics.min.js +1 -0
  24. package/dist/tether.diagnose.js +14 -4
  25. package/dist/tether.diagnose.min.js +1 -1
  26. package/dist/tether.extras.js +87 -0
  27. package/dist/tether.extras.min.js +1 -0
  28. package/dist/tether.js +486 -125
  29. package/dist/tether.markets.js +17 -11
  30. package/dist/tether.markets.min.js +1 -1
  31. package/dist/tether.min.js +3 -3
  32. package/dist/tether.min.js.map +3 -3
  33. package/dist/tether.search.js +176 -0
  34. package/dist/tether.search.min.js +1 -0
  35. package/package.json +31 -5
  36. package/src/account.js +313 -0
  37. package/src/analytics.js +232 -0
  38. package/src/astro.js +76 -0
  39. package/src/cart.js +119 -16
  40. package/src/client.js +30 -15
  41. package/src/collection.js +15 -10
  42. package/src/config.js +5 -0
  43. package/src/diagnose.js +12 -3
  44. package/src/dom.js +39 -10
  45. package/src/extras.js +63 -0
  46. package/src/fragments.js +13 -12
  47. package/src/index.js +114 -40
  48. package/src/initial.js +75 -0
  49. package/src/lazy.js +9 -1
  50. package/src/loader.js +53 -7
  51. package/src/markets-ui.js +15 -6
  52. package/src/markets.js +1 -1
  53. package/src/mounting.js +68 -3
  54. package/src/parts/account.js +3 -0
  55. package/src/parts/analytics.js +3 -0
  56. package/src/parts/extras.js +3 -0
  57. package/src/parts/search.js +3 -0
  58. package/src/product.js +51 -13
  59. package/src/search.js +153 -0
  60. package/src/server.js +251 -0
  61. package/src/status.js +3 -8
  62. package/types/astro.d.ts +15 -0
  63. package/types/index.d.ts +72 -4
  64. package/types/server.d.ts +102 -0
  65. package/dist/esm/chunks/chunk-GHIUMD5H.js +0 -1
  66. package/dist/esm/chunks/chunk-LI3YDMGF.js +0 -4
  67. package/dist/esm/chunks/diagnose-ZXXXJIQE.js +0 -1
  68. package/dist/esm/chunks/markets-ui-AG6Y5VUE.js +0 -1
@@ -0,0 +1,232 @@
1
+ /**
2
+ * Analytics, as an optional part (loaded when config.analytics is set).
3
+ *
4
+ * analytics: {
5
+ * dataLayer: true, // GA4 / Google Tag Manager ecommerce events (window.dataLayer)
6
+ * shopify: true, // Shopify's own analytics (experimental, see docs/analytics.md)
7
+ * onEvent(name, data) {}, // any other tool: Meta, TikTok, Plausible, …
8
+ * }
9
+ *
10
+ * Events: view_item (the page's product), view_item_list (lists), add_to_cart / remove_from_cart
11
+ * (from the cart's changes, so quick +/- and JS calls count too), view_cart (cart opened),
12
+ * begin_checkout, search (search as you type). Purchases happen on Shopify's checkout: use
13
+ * Shopify's Google & YouTube app or a checkout pixel for those.
14
+ *
15
+ * Shopify analytics: Tether sets up the same event bus Hydrogen uses (window.Shopify.analytics) and
16
+ * loads Shopify's consent script and shopify.js, which build and send the events. Nothing is sent
17
+ * before the Customer Privacy API allows analytics.
18
+ */
19
+ import { cfg, log, reportError } from './config.js';
20
+ import { gql } from './client.js';
21
+ import { sel } from './dom.js';
22
+ import { Cart } from './cart.js';
23
+
24
+ const num = (m) => (m ? parseFloat(m.amount) : 0);
25
+ const id = (gid) => String(gid || '').split('/').pop();
26
+
27
+ // ---------------------------------------------------------------- shapes
28
+
29
+ function itemFromVariant(p, v, quantity = 1, index) {
30
+ return {
31
+ item_id: v?.sku || id(v?.id) || id(p.id),
32
+ item_name: p.title,
33
+ item_variant: v?.title === 'Default Title' ? undefined : v?.title,
34
+ item_brand: p.vendor || undefined,
35
+ item_category: p.productType || undefined,
36
+ price: num(v?.price || p.priceRange?.minVariantPrice),
37
+ quantity,
38
+ ...(index != null ? { index } : {}),
39
+ };
40
+ }
41
+
42
+ function itemFromLine(l, quantity = l.quantity) {
43
+ const m = l.merchandise || {};
44
+ return itemFromVariant({ ...(m.product || {}), title: m.product?.title }, m, quantity);
45
+ }
46
+
47
+ const value = (items) => Math.round(items.reduce((s, i) => s + (i.price || 0) * (i.quantity || 1), 0) * 100) / 100;
48
+
49
+ // ---------------------------------------------------------------- GA4 / GTM
50
+
51
+ function ga(event, ecommerce) {
52
+ const name = typeof cfg.analytics.dataLayer === 'string' ? cfg.analytics.dataLayer : 'dataLayer';
53
+ const dl = (window[name] = window[name] || []);
54
+ if (ecommerce) dl.push({ ecommerce: null }); // Google: clear the previous ecommerce object first
55
+ dl.push(ecommerce ? { event, ecommerce } : { event });
56
+ }
57
+
58
+ // ---------------------------------------------------------------- Shopify analytics (Hydrogen's bus)
59
+
60
+ let bus = null;
61
+
62
+ function shopifyBus(shopId) {
63
+ const destinations = [];
64
+ const buffer = [];
65
+ const privacy = () => window.Shopify?.customerPrivacy;
66
+ const canTrack = () => {
67
+ try {
68
+ const p = privacy();
69
+ return p?.consentStatus === 'loaded' && p.currentVisitorConsent?.()?.analytics !== 'no' && Boolean(p.analyticsProcessingAllowed?.());
70
+ } catch { return false; }
71
+ };
72
+ const config = { shop: { shopId, channel: 'headless' }, consent: { mode: cfg.analytics.shopify?.consent || 'no-banner' } };
73
+ const deliver = () => {
74
+ if (!canTrack()) return;
75
+ destinations.forEach((d) => {
76
+ while (d.next < buffer.length) {
77
+ const { event, payload } = buffer[d.next++];
78
+ (d.subs.get(event) || []).forEach((cb) => { try { cb(payload, d.context); } catch (err) { reportError(err, `analytics ${d.name}`); } });
79
+ }
80
+ });
81
+ };
82
+ const api = {
83
+ getConfig: () => config,
84
+ publish(event, payload = {}) {
85
+ buffer.push({ event, payload: { shop: config.shop, url: window.location.href, ...payload } });
86
+ if (buffer.length > 500) { buffer.shift(); destinations.forEach((d) => { d.next = Math.max(0, d.next - 1); }); }
87
+ deliver();
88
+ },
89
+ addDestination(dest) {
90
+ const tag = `hydrogen:${dest.name}`;
91
+ const d = {
92
+ name: dest.name, next: 0, subs: new Map(),
93
+ context: {
94
+ getTrackingValues: () => {
95
+ const i = canTrack() ? privacy()?.__internal : null;
96
+ return { uniqueToken: i?.uniqueToken?.({ generateFallback: true, tag }) ?? '', visitToken: i?.visitToken?.({ generateFallback: true, tag }) ?? '' };
97
+ },
98
+ },
99
+ };
100
+ const subscribe = (event, cb) => {
101
+ if (!d.subs.has(event)) d.subs.set(event, new Set());
102
+ d.subs.get(event).add(cb);
103
+ return () => d.subs.get(event)?.delete(cb);
104
+ };
105
+ Promise.resolve(dest.setup?.({ subscribe, getConfig: api.getConfig })).then(() => { destinations.push(d); deliver(); })
106
+ .catch((err) => reportError(err, `analytics ${dest.name}`));
107
+ return () => { const i = destinations.indexOf(d); if (i >= 0) destinations.splice(i, 1); };
108
+ },
109
+ };
110
+ document.addEventListener('consentTrackingApiLoaded', deliver);
111
+ document.addEventListener('visitorConsentCollected', deliver);
112
+ return api;
113
+ }
114
+
115
+ function loadScript(src) {
116
+ const s = document.createElement('script');
117
+ s.src = src;
118
+ s.async = true;
119
+ document.head.appendChild(s);
120
+ }
121
+
122
+ async function startShopify() {
123
+ if (window.Shopify?.analytics) { bus = window.Shopify.analytics; return; } // already set up (e.g. by the theme)
124
+ const data = await gql('query TetherShopId { shop { id } }', {}, { context: false });
125
+ const country = String(cfg.country || 'US').toUpperCase();
126
+ const S = (window.Shopify = window.Shopify || {});
127
+ Object.assign(S, {
128
+ country,
129
+ locale: (cfg.locale || 'en').split('-')[0].toLowerCase(), // required by Shopify's script
130
+ shop: cfg.domain,
131
+ routes: { root: '/', match: () => ({ pageTemplateName: mainProduct() ? 'product' : 'page' }), ...(S.routes || {}) },
132
+ });
133
+ const proxy = cfg.analytics.shopify?.proxy; // same-origin Storefront API path, for exact visitor counts
134
+ S.customerPrivacy = {
135
+ ...(S.customerPrivacy || {}),
136
+ config: proxy
137
+ ? { isHeadless: true, asyncConsent: true, asyncVisitorState: true, consentDomain: window.location.host }
138
+ : { isHeadless: true, storefrontAccessToken: cfg.token, checkoutRootDomain: cfg.domain },
139
+ };
140
+ bus = shopifyBus(data.shop.id);
141
+ S.analytics = bus;
142
+ loadScript(cfg.analytics.shopify?.consent === 'default-banner'
143
+ ? 'https://cdn.shopify.com/shopifycloud/privacy-banner/storefront-banner.js'
144
+ : 'https://cdn.shopify.com/shopifycloud/consent-tracking-api/v0.2/consent-tracking-api.js');
145
+ loadScript('https://cdn.shopify.com/storefront/analytics/shopify.js');
146
+ log('shopify analytics ready', data.shop.id);
147
+ }
148
+
149
+ const productPayload = (p, v, quantity = 1) => ({
150
+ id: p.id, title: p.title, price: v.price, vendor: p.vendor || '', variantId: v.id, variantTitle: v.title,
151
+ quantity, sku: v.sku || undefined, productType: p.productType || undefined,
152
+ });
153
+
154
+ function analyticsLine(l) {
155
+ const m = l.merchandise || {};
156
+ return { id: l.id, quantity: l.quantity, merchandise: { id: m.id, title: m.title, price: m.price, sku: m.sku, product: { id: m.product?.id, title: m.product?.title, vendor: m.product?.vendor || '', handle: m.product?.handle } } };
157
+ }
158
+
159
+ // ---------------------------------------------------------------- what happened on the page
160
+
161
+ function mainProduct() {
162
+ const el = document.querySelector(sel('product'));
163
+ return el && !el.closest(`${sel('collection')},${sel('products')},${sel('recommendations')}`) ? window.Tether?.getScope(el) : null;
164
+ }
165
+
166
+ const send = (name, data, shopifyEvent, shopifyPayload) => {
167
+ if (cfg.analytics.dataLayer) ga(name, data);
168
+ if (bus && shopifyEvent) bus.publish(shopifyEvent, shopifyPayload);
169
+ try { cfg.analytics.onEvent?.(name, data); } catch (err) { reportError(err, 'analytics onEvent'); }
170
+ };
171
+
172
+ let lastLines = null; // variant id -> line, to turn cart changes into add/remove events
173
+ let viewed = new WeakSet();
174
+
175
+ function handle(e) {
176
+ const name = e.type.slice(cfg.prefix.length + 1);
177
+ const d = e.detail || {};
178
+ if (name === 'product-ready') {
179
+ const scope = d.scope;
180
+ if (!scope || viewed.has(scope.root) || mainProduct() !== scope) return;
181
+ viewed.add(scope.root);
182
+ const v = scope.variant || d.product.variants.nodes[0];
183
+ const items = [itemFromVariant(d.product, v)];
184
+ send('view_item', { currency: v?.price?.currencyCode, value: value(items), items }, 'product_viewed', { products: [productPayload(d.product, v)] });
185
+ } else if (name === 'list-ready') {
186
+ const items = (d.products || []).slice(0, 200).map((p, i) => itemFromVariant(p, p.variants?.nodes?.[0], 1, i));
187
+ const list = { item_list_id: d.collection?.handle || 'products', item_list_name: d.collection?.title || 'Products' };
188
+ send('view_item_list', { ...list, items }, d.collection ? 'collection_viewed' : null, d.collection ? { collection: { id: d.collection.id, handle: d.collection.handle } } : null);
189
+ } else if (name === 'cart-updated') {
190
+ const cart = d.cart;
191
+ const now = new Map((cart?.lines?.nodes || []).map((l) => [l.merchandise?.id, l]));
192
+ if (lastLines && d.reason !== 'load' && d.reason !== 'pending') {
193
+ const currency = cart?.cost?.totalAmount?.currencyCode;
194
+ now.forEach((l, k) => {
195
+ const diff = l.quantity - (lastLines.get(k)?.quantity || 0);
196
+ if (diff > 0) {
197
+ const items = [itemFromLine(l, diff)];
198
+ send('add_to_cart', { currency, value: value(items), items }, 'product_added_to_cart', {
199
+ cart, prevCart: lastCart, currentLine: analyticsLine(l), prevLine: lastLines.get(k) ? analyticsLine(lastLines.get(k)) : undefined,
200
+ });
201
+ }
202
+ });
203
+ lastLines.forEach((l, k) => {
204
+ const diff = l.quantity - (now.get(k)?.quantity || 0);
205
+ if (diff > 0) { const items = [itemFromLine(l, diff)]; send('remove_from_cart', { currency, value: value(items), items }); }
206
+ });
207
+ }
208
+ lastLines = now;
209
+ lastCart = cart;
210
+ } else if (name === 'cart-toggled' && d.open) {
211
+ const items = Cart.lines.map((l) => itemFromLine(l));
212
+ send('view_cart', { currency: Cart.state?.cost?.totalAmount?.currencyCode, value: value(items), items });
213
+ } else if (name === 'checkout') {
214
+ const items = (d.cart?.lines?.nodes || []).map((l) => itemFromLine(l));
215
+ const coupon = (d.cart?.discountCodes || []).filter((c) => c.applicable).map((c) => c.code).join(',') || undefined;
216
+ send('begin_checkout', { currency: d.cart?.cost?.totalAmount?.currencyCode, value: value(items), coupon, items });
217
+ } else if (name === 'search' && d.term) {
218
+ send('search', { search_term: d.term }, 'search_viewed', { searchTerm: d.term });
219
+ } else if (name === 'page') {
220
+ viewed = new WeakSet();
221
+ if (bus) bus.publish('page_viewed', {});
222
+ }
223
+ }
224
+ let lastCart = null;
225
+
226
+ /** Called by the core with the events it buffered while this part loaded. */
227
+ export async function startAnalytics(queue) {
228
+ if (cfg.analytics.shopify) await startShopify().catch((err) => reportError(err, 'shopify analytics'));
229
+ if (bus) bus.publish('page_viewed', {});
230
+ queue.splice(0).forEach(handle);
231
+ queue.push = (e) => { handle(e); return 0; }; // from now on, handle events as they come
232
+ }
package/src/astro.js ADDED
@@ -0,0 +1,76 @@
1
+ /**
2
+ * Astro integration: Tether on every page, configured from .env.
3
+ *
4
+ * // astro.config.mjs
5
+ * import { defineConfig } from 'astro/config';
6
+ * import tether from '@occluze/tether/astro';
7
+ * export default defineConfig({ integrations: [tether({ country: 'NL', locale: 'nl-NL' })] });
8
+ *
9
+ * // .env
10
+ * PUBLIC_SHOPIFY_DOMAIN=your-store.myshopify.com
11
+ * PUBLIC_SHOPIFY_STOREFRONT_TOKEN=your-public-storefront-token
12
+ *
13
+ * Options are Tether's config (domain and token override .env). Functions (onError, formatMoney,
14
+ * hooks) can't be passed here: set them in window.TETHER_CONFIG in your layout, it is merged in.
15
+ * View Transitions (<ClientRouter />) work without extra code.
16
+ */
17
+
18
+ const ENV_KEYS = {
19
+ domain: ['PUBLIC_SHOPIFY_DOMAIN', 'PUBLIC_SHOPIFY_STORE_DOMAIN', 'SHOPIFY_DOMAIN'],
20
+ token: ['PUBLIC_SHOPIFY_STOREFRONT_TOKEN', 'PUBLIC_SHOPIFY_STOREFRONT_ACCESS_TOKEN', 'SHOPIFY_STOREFRONT_TOKEN'],
21
+ };
22
+
23
+ /** .env files the way Vite reads them (later files win), plus process.env on top. */
24
+ async function readEnv(command, root) {
25
+ const mode = command === 'dev' ? 'development' : 'production';
26
+ const out = {};
27
+ try {
28
+ const { readFile } = await import('node:fs/promises');
29
+ const { join } = await import('node:path');
30
+ for (const name of ['.env', '.env.local', `.env.${mode}`, `.env.${mode}.local`]) {
31
+ let text;
32
+ try { text = await readFile(join(root, name), 'utf8'); } catch { continue; }
33
+ for (const line of text.split(/\r?\n/)) {
34
+ const m = /^\s*(?:export\s+)?([\w.-]+)\s*=\s*(.*?)\s*$/.exec(line);
35
+ if (!m || line.trim().startsWith('#')) continue;
36
+ let v = m[2];
37
+ const q = /^(["'])(.*?)\1/.exec(v); // "quoted" (a # comment may follow)
38
+ v = q ? q[2] : v.replace(/\s+#.*$/, '');
39
+ out[m[1]] = v;
40
+ }
41
+ }
42
+ } catch { /* not in Node: nothing to read */ }
43
+ return { ...out, ...process.env };
44
+ }
45
+
46
+ export default function tether(options = {}) {
47
+ const { inject = true, ...config } = options;
48
+ return {
49
+ name: '@occluze/tether',
50
+ hooks: {
51
+ 'astro:config:setup': async ({ injectScript, command, config: astroConfig, logger }) => {
52
+ const root = astroConfig?.root ? decodeURIComponent(new URL(astroConfig.root).pathname) : process.cwd();
53
+ const env = await readEnv(command, root);
54
+ for (const [key, names] of Object.entries(ENV_KEYS)) {
55
+ if (!config[key]) config[key] = names.map((n) => env[n]).find(Boolean);
56
+ }
57
+ const bad = Object.entries(config).filter(([, v]) => typeof v === 'function').map(([k]) => k);
58
+ bad.forEach((k) => delete config[k]);
59
+ if (bad.length) logger.warn(`${bad.join(', ')}: functions can't be passed here. Set them in window.TETHER_CONFIG in your layout.`);
60
+ if (!config.domain || !config.token) {
61
+ logger.warn('No Shopify store configured: set PUBLIC_SHOPIFY_DOMAIN and PUBLIC_SHOPIFY_STOREFRONT_TOKEN in .env (the public Storefront API token).');
62
+ }
63
+ if (!inject) return;
64
+ // Before any module runs: window.TETHER_CONFIG in the layout doesn't auto-start a second init.
65
+ injectScript('head-inline', 'window.TETHER_INTEGRATION="astro";');
66
+ injectScript('page', [
67
+ "import Tether from '@occluze/tether';",
68
+ // As the base config (like window.TETHER_CONFIG), not as overrides: a visitor's saved market
69
+ // (country, money format) then wins over the defaults, as on a plain script-tag site.
70
+ `window.TETHER_CONFIG = { ...${JSON.stringify({ autoMount: false, ...config })}, ...(window.TETHER_CONFIG || {}) };`,
71
+ 'Tether.init().then(() => Tether.mount(document));',
72
+ ].join('\n'));
73
+ },
74
+ },
75
+ };
76
+ }
package/src/cart.js CHANGED
@@ -29,7 +29,7 @@ export function toLineInput(input, quantity = 1, opts = {}) {
29
29
 
30
30
  function mutation(name, args, inner) {
31
31
  return `mutation Tether_${name}(${args}) {
32
- ${name}(${inner}) { cart { ...TetherCart } userErrors { field message } }
32
+ ${name}(${inner}) { cart { ...TetherCart } userErrors { field message } warnings { code message target } }
33
33
  }
34
34
  ${cartFragment()}`;
35
35
  }
@@ -47,7 +47,8 @@ function ariaState(open) {
47
47
  if (!d.hasAttribute('role')) d.setAttribute('role', 'dialog');
48
48
  if (!d.hasAttribute('aria-label') && !d.hasAttribute('aria-labelledby')) d.setAttribute('aria-label', get(d, 'cart-label') || 'Cart');
49
49
  d.setAttribute('aria-modal', String(open));
50
- if (!d.classList.contains(cfg.cartDrawerOpenClass)) d.setAttribute('aria-hidden', 'true');
50
+ // Closed: hidden from screen readers and out of the Tab order (inert), like a closed dialog.
51
+ if (!d.classList.contains(cfg.cartDrawerOpenClass)) { d.setAttribute('aria-hidden', 'true'); if (cfg.cartFocus !== false) d.inert = true; }
51
52
  });
52
53
  }
53
54
 
@@ -69,6 +70,23 @@ export const Cart = {
69
70
  get count() { return this.state?.totalQuantity || 0; },
70
71
 
71
72
  lastError: null,
73
+ /** Set by the account part while a customer is logged in (Customer Account API token). */
74
+ customerAccessToken: null,
75
+ /** line id -> { qty, done }: what quick +/- clicks asked for, shown until Shopify has answered. */
76
+ pendingQty: new Map(),
77
+ /** Shopify's notes on the last change, e.g. less stock than asked: [{ code, message }]. */
78
+ warnings: [],
79
+ /** true once the saved cart (or the absence of one) is known. */
80
+ loaded: false,
81
+
82
+ _take(payload, name) {
83
+ const p = unwrap(payload, name);
84
+ // Shopify can repeat a warning per line or code: each message once.
85
+ this.warnings = (p.warnings || []).filter((w, i, all) => all.findIndex((x) => x.message === w.message) === i)
86
+ .map((w) => ({ ...w, message: (typeof cfg.warningText === 'function' && cfg.warningText(w)) || w.message }));
87
+ if (this.warnings.length) emit('cart-warning', { warnings: this.warnings });
88
+ return p.cart;
89
+ },
72
90
 
73
91
  _queue(fn) {
74
92
  const run = this._chain.then(fn, fn).then((value) => {
@@ -88,6 +106,8 @@ export const Cart = {
88
106
  _commit(cart, reason) {
89
107
  this.state = cart;
90
108
  if (cart?.id) this._setId(cart.id);
109
+ // Other tabs reload the cart (storage event); the count also shows at once on the next page.
110
+ try { localStorage.setItem(`${cfg.storageKey}:seen`, `${Date.now()}|${cart?.totalQuantity || 0}`); } catch { /* private mode */ }
91
111
  this.render();
92
112
  emit('cart-updated', { cart, reason });
93
113
  return cart;
@@ -95,7 +115,14 @@ export const Cart = {
95
115
 
96
116
  async init() {
97
117
  const id = this.id();
98
- if (!id) { this.state = null; this.render(); return null; }
118
+ if (!id) { this.state = null; this.loaded = true; this.render(); emit('cart-updated', { cart: null, reason: 'load' }); return null; }
119
+ // Until the saved cart has loaded, show its last known count instead of the page's 0.
120
+ if (!this.loaded) {
121
+ try {
122
+ const n = (localStorage.getItem(`${cfg.storageKey}:seen`) || '').split('|')[1];
123
+ if (n) qsa(document, sel('cart-count')).forEach((el) => { if (/^\s*0?\s*$/.test(el.textContent)) el.textContent = n; });
124
+ } catch { /* private mode */ }
125
+ }
99
126
  try {
100
127
  const data = await gql(`query TetherCart($id: ID!) { cart(id: $id) { ...TetherCart } }\n${cartFragment()}`, { id });
101
128
  if (!data.cart) { this._setId(null); this.state = null; } else this.state = data.cart;
@@ -106,17 +133,22 @@ export const Cart = {
106
133
  if (isMissingCart(err)) this._setId(null);
107
134
  this.state = null;
108
135
  }
136
+ this.loaded = true;
109
137
  this.render();
138
+ emit('cart-updated', { cart: this.state, reason: 'load' }); // islands (cart.subscribe) hear the loaded cart
110
139
  return this.state;
111
140
  },
112
141
 
113
142
  async _create(input = {}) {
114
- const buyerIdentity = cfg.country ? { countryCode: String(cfg.country).toUpperCase() } : undefined;
143
+ const buyerIdentity = cfg.country || this.customerAccessToken ? {
144
+ ...(cfg.country ? { countryCode: String(cfg.country).toUpperCase() } : {}),
145
+ ...(this.customerAccessToken ? { customerAccessToken: this.customerAccessToken } : {}),
146
+ } : undefined;
115
147
  const data = await gql(
116
148
  mutation('cartCreate', '$input: CartInput!', 'input: $input'),
117
149
  { input: { buyerIdentity, ...input } },
118
150
  );
119
- return unwrap(data.cartCreate, 'cartCreate').cart;
151
+ return this._take(data.cartCreate, 'cartCreate');
120
152
  },
121
153
 
122
154
  /** Runs a cart mutation; creates a fresh cart if ours expired. */
@@ -125,7 +157,7 @@ export const Cart = {
125
157
  if (!cartId) return createInput ? this._create(createInput) : null;
126
158
  try {
127
159
  const data = await gql(mutation(name, `$cartId: ID!, ${args}`, `cartId: $cartId, ${inner}`), { ...variables, cartId });
128
- return unwrap(data[name], name).cart;
160
+ return this._take(data[name], name);
129
161
  } catch (err) {
130
162
  if (createInput && isMissingCart(err)) {
131
163
  log('cart expired, creating a new one');
@@ -173,6 +205,26 @@ export const Cart = {
173
205
  ));
174
206
  },
175
207
 
208
+ /**
209
+ * +1 / -1 for a line. Quick clicks add up: each builds on the quantity the last click asked for,
210
+ * which shows at once (tether-line="quantity", Cart.lines) until Shopify has answered.
211
+ */
212
+ changeQuantity(lineId, delta) {
213
+ const current = this.lines.find((l) => l.id === lineId);
214
+ if (!current) return Promise.resolve(this.state);
215
+ const target = Math.max(0, (this.pendingQty.get(lineId)?.qty ?? current.quantity) + delta);
216
+ const job = { qty: target, done: false };
217
+ this.pendingQty.set(lineId, job);
218
+ this.render();
219
+ emit('cart-updated', { cart: this.state, reason: 'pending' });
220
+ return this.updateLine(lineId, target).finally(() => {
221
+ if (this.pendingQty.get(lineId) === job) { this.pendingQty.delete(lineId); this.render(); emit('cart-updated', { cart: this.state, reason: 'settled' }); }
222
+ });
223
+ },
224
+
225
+ /** Quantity to show for a line: the one asked for while an update is on its way. */
226
+ quantityOf(line) { return this.pendingQty.get(line.id)?.qty ?? line.quantity; },
227
+
176
228
  removeLine(lineIds) {
177
229
  const ids = Array.isArray(lineIds) ? lineIds : [lineIds];
178
230
  return this._queue(async () => this._commit(
@@ -214,13 +266,22 @@ export const Cart = {
214
266
  ));
215
267
  },
216
268
 
217
- applyDiscount(code) {
269
+ /**
270
+ * Adds a code. A code Shopify doesn't accept isn't kept in the cart (it would warn on every change):
271
+ * it's removed again and the promise rejects with code INVALID_DISCOUNT.
272
+ */
273
+ async applyDiscount(code) {
218
274
  const current = (this.state?.discountCodes || []).map((d) => d.code);
219
- return this.setDiscountCodes([...new Set([...current, code])]);
275
+ const cart = await this.setDiscountCodes([...new Set([...current, code])]);
276
+ const hit = (cart?.discountCodes || []).find((d) => d.code.toLowerCase() === String(code).toLowerCase());
277
+ if (hit?.applicable) return cart;
278
+ await this.removeDiscount(code).catch(() => {});
279
+ throw new TetherError(`Discount code not valid: ${code}`, { code: 'INVALID_DISCOUNT' });
220
280
  },
221
281
 
222
282
  removeDiscount(code) {
223
- return this.setDiscountCodes((this.state?.discountCodes || []).map((d) => d.code).filter((c) => c !== code));
283
+ const k = String(code).toLowerCase();
284
+ return this.setDiscountCodes((this.state?.discountCodes || []).map((d) => d.code).filter((c) => c.toLowerCase() !== k));
224
285
  },
225
286
 
226
287
  /** { countryCode, email, phone } — changes currency/prices for that market. */
@@ -243,13 +304,14 @@ export const Cart = {
243
304
  */
244
305
  async checkout(target = cfg.checkoutTarget) {
245
306
  const id = this.id();
246
- if (!id) return null;
307
+ if (!id || (this.state && !this.count)) return null; // nothing to check out
247
308
  const tab = target === '_blank' ? window.open('', '_blank') : null;
248
309
  if (tab) { try { tab.opener = null; } catch { /* ignore */ } }
249
310
 
250
311
  try {
251
312
  const data = await gql('query TetherCheckout($id: ID!) { cart(id: $id) { checkoutUrl } }', { id });
252
- const url = data.cart?.checkoutUrl;
313
+ let url = (this.beforeCheckout && await this.beforeCheckout().catch(() => null)) || data.cart?.checkoutUrl;
314
+ if (url && this.customerAccessToken) url += `${url.includes('?') ? '&' : '?'}logged_in=true`;
253
315
  if (!url) throw new TetherError('No checkoutUrl (cart empty or expired)');
254
316
  emit('checkout', { url, cart: this.state });
255
317
  if (tab && !tab.closed) tab.location.href = url;
@@ -266,12 +328,18 @@ export const Cart = {
266
328
 
267
329
  drawers() { return qsa(document, sel('cart-drawer')); },
268
330
 
269
- isOpen() { return this.drawers().some((d) => d.classList.contains(cfg.cartDrawerOpenClass)); },
331
+ /** Without a tether-cart-drawer (a React/Svelte/Vue cart), open/close still work: as state for subscribe. */
332
+ _open: false,
333
+ isOpen() {
334
+ const ds = this.drawers();
335
+ return ds.length ? ds.some((d) => d.classList.contains(cfg.cartDrawerOpenClass)) : this._open;
336
+ },
270
337
 
271
338
  open() {
272
339
  if (this.isOpen()) return;
340
+ this._open = true;
273
341
  const ds = this.drawers().filter((d) => get(d, 'cart-drawer') !== 'manual');
274
- ds.forEach((d) => { d.classList.add(cfg.cartDrawerOpenClass); d.setAttribute('aria-hidden', 'false'); });
342
+ ds.forEach((d) => { d.inert = false; d.classList.add(cfg.cartDrawerOpenClass); d.setAttribute('aria-hidden', 'false'); });
275
343
  ariaState(true);
276
344
  if (cfg.cartFocus !== false && ds[0]) {
277
345
  // Move focus into the cart (after the open transition has made it visible).
@@ -288,9 +356,10 @@ export const Cart = {
288
356
 
289
357
  close() {
290
358
  if (!this.isOpen()) return;
359
+ this._open = false;
291
360
  const ds = this.drawers();
292
361
  const hadFocus = ds.some((d) => d.contains(document.activeElement));
293
- ds.forEach((d) => { d.classList.remove(cfg.cartDrawerOpenClass); d.setAttribute('aria-hidden', 'true'); });
362
+ ds.forEach((d) => { d.classList.remove(cfg.cartDrawerOpenClass); d.setAttribute('aria-hidden', 'true'); if (cfg.cartFocus !== false) d.inert = true; });
294
363
  ariaState(false);
295
364
  if (hadFocus && returnFocus?.isConnected) returnFocus.focus({ preventScroll: true });
296
365
  returnFocus = null;
@@ -300,6 +369,18 @@ export const Cart = {
300
369
 
301
370
  toggle() { if (this.isOpen()) this.close(); else this.open(); },
302
371
 
372
+ /**
373
+ * For framework islands (React, Svelte, Vue): fn(cart, { open }) now and on every change.
374
+ * Returns a function that stops listening.
375
+ */
376
+ subscribe(fn) {
377
+ const call = () => fn(this.state, { open: this.isOpen(), count: this.count, loaded: this.loaded, warnings: this.warnings });
378
+ const h = () => call();
379
+ ['cart-updated', 'cart-toggled'].forEach((e) => document.addEventListener(`${cfg.prefix}:${e}`, h));
380
+ call();
381
+ return () => ['cart-updated', 'cart-toggled'].forEach((e) => document.removeEventListener(`${cfg.prefix}:${e}`, h));
382
+ },
383
+
303
384
  // -------------------------------------------------------------- render
304
385
 
305
386
  render() {
@@ -321,11 +402,17 @@ export const Cart = {
321
402
  'cart-no-subscription': !hasSub,
322
403
  'cart-has-discount': (cart?.discountCodes || []).some((d) => d.applicable),
323
404
  'cart-error': Boolean(this.lastError),
405
+ 'cart-warning': this.warnings.length > 0,
324
406
  };
407
+ qsa(document, sel('cart-warning')).forEach((el) => setTextTracked(el, [...new Set(this.warnings.map((w) => w.message))].join(' ')));
325
408
  qsa(document, sel('show')).forEach((el) => {
326
409
  const k = get(el, 'show');
327
410
  if (k in conds && !el.closest(sel('product'))) show(el, conds[k]);
328
411
  });
412
+ qsa(document, sel('checkout')).forEach((el) => {
413
+ el.classList.toggle('is-disabled', !count);
414
+ el.setAttribute('aria-disabled', String(!count));
415
+ });
329
416
 
330
417
  qsa(document, `textarea${sel('cart-note')},input${sel('cart-note')}`).forEach((el) => {
331
418
  if (document.activeElement !== el) el.value = cart?.note || '';
@@ -365,7 +452,7 @@ function lineValue(line, key) {
365
452
  case 'compare-at-price': return formatMoney(line.cost?.compareAtAmountPerQuantity || m.compareAtPrice);
366
453
  case 'line-total': return formatMoney(line.cost?.totalAmount);
367
454
  case 'line-subtotal': return formatMoney(line.cost?.subtotalAmount);
368
- case 'quantity': return line.quantity;
455
+ case 'quantity': return Cart.quantityOf(line);
369
456
  case 'plan': return plan?.name || '';
370
457
  case 'plan-description': return plan?.description || '';
371
458
  case 'components': return (line.lineComponents || [])
@@ -388,17 +475,33 @@ function renderLine(node, line) {
388
475
  qsa(node, sel('line')).forEach((el) => {
389
476
  const key = get(el, 'line');
390
477
  if (key === 'image') return setImage(el, m.image?.url || m.product?.featuredImage?.url, m.product?.title);
391
- if (key === 'url') return el.setAttribute('href', productUrl(m.product?.handle, m.product?.id));
478
+ if (key === 'url') {
479
+ const u = productUrl(m.product?.handle, m.product?.id);
480
+ const q = new URLSearchParams();
481
+ if (m.id) q.set('variant', String(m.id).split('/').pop());
482
+ const plan = line.sellingPlanAllocation?.sellingPlan?.id;
483
+ if (plan) q.set('selling_plan', plan.split('/').pop());
484
+ const qs = q.toString();
485
+ return el.setAttribute('href', qs ? `${u}${u.includes('?') ? '&' : '?'}${qs}` : u);
486
+ }
392
487
  const v = lineValue(line, key);
393
488
  if (v !== undefined) setTextTracked(el, v);
394
489
  return undefined;
395
490
  });
396
491
 
492
+ // Shopify's note about this line (e.g. less stock than asked): class is-warning, tether-line="warning".
493
+ const warning = Cart.warnings.find((w) => w.target === line.id);
494
+ node.classList.toggle('is-warning', Boolean(warning));
495
+ const pending = Cart.pendingQty.has(line.id);
496
+ node.classList.toggle('is-busy', pending);
497
+ if (pending) node.setAttribute('aria-busy', 'true'); else node.removeAttribute('aria-busy');
498
+ qsa(node, sel('line', 'warning')).forEach((el) => setTextTracked(el, warning?.message || ''));
397
499
  const lineConds = {
398
500
  subscription: isSub,
399
501
  'one-time': !isSub,
400
502
  'on-sale': parseFloat(line.cost?.compareAtAmountPerQuantity?.amount) > parseFloat(line.cost?.amountPerQuantity?.amount),
401
503
  'has-components': (line.lineComponents || []).length > 0,
504
+ warning: Boolean(warning),
402
505
  };
403
506
  qsa(node, sel('line-show')).forEach((el) => {
404
507
  const k = get(el, 'line-show');