@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/analytics.js
ADDED
|
@@ -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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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')
|
|
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');
|