@occluze/tether 0.6.7 → 0.6.8

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 (135) hide show
  1. package/README.md +3 -3
  2. package/dist/esm/astro.js +2 -2
  3. package/dist/esm/chunks/account-C3NP6HFB.js +1 -0
  4. package/dist/esm/chunks/account-pages-WLK374FO.js +1 -0
  5. package/dist/esm/chunks/analytics-G3SAWMNL.js +1 -0
  6. package/dist/esm/chunks/chunk-27PJJZGC.js +1 -0
  7. package/dist/esm/chunks/chunk-2FSXS55U.js +1 -0
  8. package/dist/esm/chunks/chunk-FCSWQDVV.js +1 -0
  9. package/dist/esm/chunks/chunk-FOMSMIAM.js +1 -0
  10. package/dist/esm/chunks/chunk-GZJTCLLW.js +1 -0
  11. package/dist/esm/chunks/chunk-HEQILZVR.js +1 -0
  12. package/dist/esm/chunks/chunk-JOOEXYF3.js +4 -0
  13. package/dist/esm/chunks/chunk-MPTQYLSM.js +1 -0
  14. package/dist/esm/chunks/chunk-NEHZ37TU.js +3 -0
  15. package/dist/esm/chunks/chunk-V74KGEJF.js +1 -0
  16. package/dist/esm/chunks/chunk-VT4CDUJJ.js +1 -0
  17. package/dist/esm/chunks/chunk-YJLWX2HK.js +1 -0
  18. package/dist/esm/chunks/diagnose-6VVGZNTY.js +14 -0
  19. package/dist/esm/chunks/extras-GJVRDFM3.js +1 -0
  20. package/dist/esm/chunks/markets-ui-UNB4PTA7.js +1 -0
  21. package/dist/esm/chunks/search-OU75RNQL.js +1 -0
  22. package/dist/esm/index.js +1 -1
  23. package/dist/esm/server.js +2 -1
  24. package/dist/presets/barba.esm.js +56 -36
  25. package/dist/tether.account-pages.js +773 -442
  26. package/dist/tether.account-pages.min.js +1 -1
  27. package/dist/tether.account.js +648 -318
  28. package/dist/tether.account.min.js +1 -1
  29. package/dist/tether.analytics.js +318 -191
  30. package/dist/tether.analytics.min.js +1 -1
  31. package/dist/tether.diagnose.js +331 -242
  32. package/dist/tether.diagnose.min.js +2 -2
  33. package/dist/tether.extras.js +78 -33
  34. package/dist/tether.extras.min.js +1 -1
  35. package/dist/tether.js +3176 -2015
  36. package/dist/tether.markets.js +231 -126
  37. package/dist/tether.markets.min.js +1 -1
  38. package/dist/tether.min.js +0 -0
  39. package/dist/tether.min.js.map +4 -4
  40. package/dist/tether.search.js +221 -126
  41. package/dist/tether.search.min.js +1 -1
  42. package/package.json +23 -4
  43. package/src/account/addresses-view.js +92 -0
  44. package/src/account/browser-session.js +359 -0
  45. package/src/account/markup.js +48 -0
  46. package/src/account/operations.js +180 -0
  47. package/src/account/order-lines.js +96 -0
  48. package/src/account/order-list.js +91 -0
  49. package/src/account/order-page.js +192 -0
  50. package/src/account/page-events.js +288 -0
  51. package/src/account/server-client.js +172 -0
  52. package/src/account/shared.js +111 -0
  53. package/src/account/shopify-auth.js +150 -0
  54. package/src/account/subscriptions-view.js +158 -0
  55. package/src/account-api.js +118 -0
  56. package/src/account-links.js +35 -0
  57. package/src/account-pages.js +69 -298
  58. package/src/account.js +280 -323
  59. package/src/amounts.js +36 -0
  60. package/src/analytics-shopify.js +241 -0
  61. package/src/analytics.js +304 -184
  62. package/src/api-version.js +75 -25
  63. package/src/astro.js +233 -68
  64. package/src/cart/checkout.js +97 -0
  65. package/src/cart/drawer.js +138 -0
  66. package/src/cart/mutations.js +234 -0
  67. package/src/cart/render.js +294 -0
  68. package/src/cart/store.js +326 -0
  69. package/src/cart.js +320 -488
  70. package/src/client.js +214 -78
  71. package/src/collection.js +126 -57
  72. package/src/config.js +323 -59
  73. package/src/controls/cart-controls.js +155 -0
  74. package/src/controls/discount.js +96 -0
  75. package/src/controls/extras.js +63 -0
  76. package/src/controls/navigation.js +83 -0
  77. package/src/diagnose-markup.js +333 -0
  78. package/src/diagnose.js +251 -202
  79. package/src/dom.js +219 -220
  80. package/src/extras.js +119 -37
  81. package/src/fragments.js +115 -22
  82. package/src/images.js +104 -0
  83. package/src/index.js +245 -287
  84. package/src/initial.js +78 -66
  85. package/src/lazy-iife.js +92 -22
  86. package/src/lazy.js +25 -4
  87. package/src/loader.js +263 -124
  88. package/src/market-codes.js +35 -0
  89. package/src/markets-ui.js +169 -55
  90. package/src/markets.js +286 -85
  91. package/src/markup.js +64 -0
  92. package/src/mounting.js +214 -91
  93. package/src/page-market.js +116 -0
  94. package/src/parts/account-pages.js +4 -1
  95. package/src/parts/account.js +4 -1
  96. package/src/parts/analytics.js +4 -1
  97. package/src/parts/diagnose.js +4 -1
  98. package/src/parts/extras.js +4 -1
  99. package/src/parts/markets.js +4 -1
  100. package/src/parts/search.js +4 -1
  101. package/src/presets/barba.js +120 -30
  102. package/src/product-events.js +130 -0
  103. package/src/product-render.js +257 -0
  104. package/src/product-state.js +318 -0
  105. package/src/product.js +233 -459
  106. package/src/repeaters.js +173 -0
  107. package/src/search.js +277 -107
  108. package/src/selling-plans.js +107 -47
  109. package/src/server.js +168 -205
  110. package/src/shopify-values.js +89 -0
  111. package/src/status.js +99 -39
  112. package/src/store-html.js +0 -0
  113. package/src/storefront/catalog.js +199 -0
  114. package/src/storefront/json-ld.js +167 -0
  115. package/src/variant-render.js +165 -0
  116. package/types/astro.d.ts +13 -2
  117. package/types/index.d.ts +178 -27
  118. package/types/server.d.ts +89 -17
  119. package/dist/esm/chunks/account-Q6E2TFEV.js +0 -1
  120. package/dist/esm/chunks/account-pages-UZXR4R5F.js +0 -1
  121. package/dist/esm/chunks/analytics-IJBKATUP.js +0 -1
  122. package/dist/esm/chunks/chunk-4QJKLPYR.js +0 -1
  123. package/dist/esm/chunks/chunk-64TUD3QR.js +0 -1
  124. package/dist/esm/chunks/chunk-AJLPU2JC.js +0 -4
  125. package/dist/esm/chunks/chunk-FIOZPPC5.js +0 -1
  126. package/dist/esm/chunks/chunk-K2YEQAIH.js +0 -1
  127. package/dist/esm/chunks/chunk-KIDH76B7.js +0 -1
  128. package/dist/esm/chunks/chunk-PAYT5ZD7.js +0 -1
  129. package/dist/esm/chunks/chunk-RREHGH4A.js +0 -1
  130. package/dist/esm/chunks/chunk-SSJT43CJ.js +0 -1
  131. package/dist/esm/chunks/chunk-VC46IEJQ.js +0 -1
  132. package/dist/esm/chunks/diagnose-3WVAWNTX.js +0 -14
  133. package/dist/esm/chunks/extras-7I7WJX35.js +0 -1
  134. package/dist/esm/chunks/markets-ui-545XLFTE.js +0 -1
  135. package/dist/esm/chunks/search-NILMGYXG.js +0 -1
package/src/analytics.js CHANGED
@@ -4,229 +4,349 @@
4
4
  * analytics: {
5
5
  * dataLayer: true, // GA4 / Google Tag Manager ecommerce events (window.dataLayer)
6
6
  * shopify: true, // Shopify's own analytics (experimental, see docs/analytics.md)
7
- * onEvent(name, data) {}, // any other tool: Meta, TikTok, Plausible, …
7
+ * onEvent(name, data) {}, // any other tool: Meta, TikTok, Plausible
8
8
  * }
9
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.
10
+ * Events: view_item (the page's product), view_item_list (lists), add_to_cart / remove_from_cart (from the cart's
11
+ * changes, so quick +/- and JS calls count too), view_cart (cart opened), begin_checkout, search (search as you type).
12
+ * Purchases happen on Shopify's checkout: use Shopify's Google & YouTube app or a checkout pixel for those.
13
+ * Shopify's analytics are set up in analytics-shopify.js.
18
14
  */
19
- import { cfg, log, reportError } from './config.js';
20
- import { gql } from './client.js';
15
+ import { cfg, reportError } from './config.js';
21
16
  import { sel } from './dom.js';
22
17
  import { Cart } from './cart.js';
18
+ import { startShopifyAnalytics } from './analytics-shopify.js';
19
+ /** @import { Money } from './shopify-values.js' */
20
+ /** @import { Product, ProductVariant } from './fragments.js' */
21
+ /** @import { AnalyticsBus, EventPayload } from './analytics-shopify.js' */
22
+
23
+ /**
24
+ * cart.js has no types yet; these are the fields of its cart (`fragment TetherCart`) this module reads.
25
+ *
26
+ * @typedef {object} LineProduct
27
+ * @property {string} id
28
+ * @property {string} title
29
+ * @property {string} handle
30
+ * @property {string} vendor
31
+ * @typedef {{ id: string, title: string, sku: string | null, price: Money, product: LineProduct }} LineVariant
32
+ * @typedef {{ id: string, quantity: number, merchandise: LineVariant }} CartLine
33
+ * @typedef {object} AnalyticsCart
34
+ * @property {{ nodes: CartLine[] }} lines
35
+ * @property {{ totalAmount: Money }} cost
36
+ * @property {{ code: string, applicable: boolean }[]} discountCodes
37
+ */
38
+
39
+ /**
40
+ * @typedef {object} ItemProduct What a GA4 item is built from: a product, or a cart line's product.
41
+ * @property {string} id
42
+ * @property {string} title
43
+ * @property {string} [vendor]
44
+ * @property {string} [productType]
45
+ * @property {{ minVariantPrice: Money }} [priceRange]
46
+ */
47
+ /** @typedef {Pick<ProductVariant, 'id' | 'title' | 'sku' | 'price'>} ItemVariant */
48
+ /**
49
+ * @typedef {object} AnalyticsItem A GA4 ecommerce item.
50
+ * @property {string} item_id
51
+ * @property {string} item_name
52
+ * @property {string | undefined} item_variant
53
+ * @property {string | undefined} item_brand
54
+ * @property {string | undefined} item_category
55
+ * @property {number} price
56
+ * @property {number} quantity
57
+ * @property {number} [index]
58
+ */
59
+ /** @typedef {{ id: string, handle: string, title: string }} ListCollection */
60
+ /** @typedef {{ product: Product, scope: { root: Element, variant: ProductVariant | null } | null }} ProductReadyDetail */
61
+ /** @typedef {{ products: Product[] | undefined, collection: ListCollection | null }} ListReadyDetail */
62
+ /** @typedef {{ cart: AnalyticsCart | null, reason: string }} CartUpdatedDetail */
23
63
 
24
- const num = (m) => (m ? parseFloat(m.amount) : 0);
25
- const id = (gid) => String(gid || '').split('/').pop();
64
+ /** Shopify's title for the one variant of a product without options. */
65
+ const DEFAULT_VARIANT_TITLE = 'Default Title';
66
+ const CENTS_PER_UNIT = 100;
67
+ /** GA4 drops an event with more items than this. */
68
+ const MAX_LIST_ITEMS = 200;
26
69
 
27
- // ---------------------------------------------------------------- shapes
70
+ /** @param {Money | null | undefined} money */
71
+ const amountOf = (money) => (money ? parseFloat(money.amount) : 0);
28
72
 
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),
73
+ /**
74
+ * @param {string | null | undefined} gid
75
+ * @returns {string} The numeric part of a gid.
76
+ */
77
+ const numericId = (gid) => (gid ?? '').split('/').pop() ?? '';
78
+
79
+ /**
80
+ * @param {ItemProduct} product
81
+ * @param {ItemVariant | null | undefined} variant
82
+ * @param {number} [quantity]
83
+ * @param {number} [index] The position in a list.
84
+ * @returns {AnalyticsItem}
85
+ */
86
+ function itemFromVariant(product, variant, quantity = 1, index) {
87
+ /** @type {AnalyticsItem} */
88
+ const item = {
89
+ item_id: variant?.sku || numericId(variant?.id) || numericId(product.id),
90
+ item_name: product.title,
91
+ item_variant: variant?.title === DEFAULT_VARIANT_TITLE ? undefined : variant?.title,
92
+ item_brand: product.vendor || undefined,
93
+ item_category: product.productType || undefined,
94
+ price: amountOf(variant?.price ?? product.priceRange?.minVariantPrice),
37
95
  quantity,
38
- ...(index != null ? { index } : {}),
39
96
  };
97
+ if (index != null) item.index = index;
98
+ return item;
40
99
  }
41
100
 
42
- function itemFromLine(l, quantity = l.quantity) {
43
- const m = l.merchandise || {};
44
- return itemFromVariant({ ...(m.product || {}), title: m.product?.title }, m, quantity);
101
+ /**
102
+ * @param {CartLine} line
103
+ * @param {number} [quantity]
104
+ */
105
+ const itemFromLine = (line, quantity = line.quantity) =>
106
+ itemFromVariant(line.merchandise.product, line.merchandise, quantity);
107
+
108
+ /**
109
+ * @param {AnalyticsItem[]} items
110
+ * @returns {number} The items' total, rounded to cents.
111
+ */
112
+ function totalValue(items) {
113
+ const total = items.reduce((sum, item) => sum + item.price * (item.quantity || 1), 0);
114
+ return Math.round(total * CENTS_PER_UNIT) / CENTS_PER_UNIT;
45
115
  }
46
116
 
47
- const value = (items) => Math.round(items.reduce((s, i) => s + (i.price || 0) * (i.quantity || 1), 0) * 100) / 100;
117
+ /**
118
+ * @param {Product} product
119
+ * @param {ItemVariant} variant
120
+ * @param {number} [quantity]
121
+ */
122
+ const productPayload = (product, variant, quantity = 1) => ({
123
+ id: product.id,
124
+ title: product.title,
125
+ price: variant.price,
126
+ vendor: product.vendor || '',
127
+ variantId: variant.id,
128
+ variantTitle: variant.title,
129
+ quantity,
130
+ sku: variant.sku || undefined,
131
+ productType: product.productType || undefined,
132
+ });
48
133
 
49
- // ---------------------------------------------------------------- GA4 / GTM
134
+ /** @param {CartLine} line */
135
+ const linePayload = (line) => ({
136
+ id: line.id,
137
+ quantity: line.quantity,
138
+ merchandise: {
139
+ id: line.merchandise.id,
140
+ title: line.merchandise.title,
141
+ price: line.merchandise.price,
142
+ sku: line.merchandise.sku,
143
+ product: {
144
+ id: line.merchandise.product.id,
145
+ title: line.merchandise.product.title,
146
+ vendor: line.merchandise.product.vendor || '',
147
+ handle: line.merchandise.product.handle,
148
+ },
149
+ },
150
+ });
50
151
 
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 });
152
+ /** @returns {unknown[]} The GA4 / GTM data layer, created when the page has none. */
153
+ function dataLayer() {
154
+ const { dataLayer: option } = cfg.analytics ?? {};
155
+ const name = typeof option === 'string' ? option : 'dataLayer';
156
+ /** @type {unknown} */
157
+ const existing = Reflect.get(window, name);
158
+ if (Array.isArray(existing)) return existing;
159
+ /** @type {unknown[]} */
160
+ const created = [];
161
+ Reflect.set(window, name, created);
162
+ return created;
56
163
  }
57
164
 
58
- // ---------------------------------------------------------------- Shopify analytics (Hydrogen's bus)
165
+ /**
166
+ * @param {string} event
167
+ * @param {Record<string, unknown> | undefined} ecommerce
168
+ */
169
+ function pushToDataLayer(event, ecommerce) {
170
+ const layer = dataLayer();
171
+ if (!ecommerce) {
172
+ layer.push({ event });
173
+ return;
174
+ }
175
+ // Google asks for the previous ecommerce object to be cleared first.
176
+ layer.push({ ecommerce: null });
177
+ layer.push({ event, ecommerce });
178
+ }
59
179
 
180
+ /** @type {AnalyticsBus | null} */
60
181
  let bus = null;
182
+ /** @type {Map<string, CartLine> | null} Variant id to line, to turn cart changes into add and remove events. */
183
+ let lastLines = null;
184
+ /** @type {AnalyticsCart | null} */
185
+ let lastCart = null;
186
+ /** @type {WeakSet<Element>} Product roots whose view_item was sent on this page. */
187
+ let viewed = new WeakSet();
61
188
 
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;
189
+ /**
190
+ * @param {string} name The GA4 event.
191
+ * @param {Record<string, unknown>} data
192
+ * @param {string} [shopifyEvent]
193
+ * @param {EventPayload} [shopifyPayload]
194
+ */
195
+ function send(name, data, shopifyEvent, shopifyPayload) {
196
+ if (cfg.analytics?.dataLayer) pushToDataLayer(name, data);
197
+ if (bus && shopifyEvent) bus.publish(shopifyEvent, shopifyPayload);
198
+ try {
199
+ cfg.analytics?.onEvent?.(name, data);
200
+ } catch (error) {
201
+ reportError(error, 'analytics onEvent');
202
+ }
113
203
  }
114
204
 
115
- function loadScript(src) {
116
- const s = document.createElement('script');
117
- s.src = src;
118
- s.async = true;
119
- document.head.appendChild(s);
205
+ /** @returns {unknown} The scope of the page's own product: a product root outside every list. */
206
+ function mainProduct() {
207
+ const el = document.querySelector(sel('product'));
208
+ if (!el || el.closest(`${sel('collection')},${sel('products')},${sel('recommendations')}`)) return null;
209
+ /** @type {unknown} */
210
+ const tether = Reflect.get(window, 'Tether');
211
+ // The core sets window.Tether before it loads this part.
212
+ return /** @type {{ getScope: (el: Element) => unknown }} */ (tether).getScope(el);
120
213
  }
121
214
 
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 || {}) },
215
+ /** @param {ProductReadyDetail} detail */
216
+ function onProductReady({ product, scope }) {
217
+ if (!scope || viewed.has(scope.root) || mainProduct() !== scope) return;
218
+ viewed.add(scope.root);
219
+ const variant = scope.variant ?? product.variants.nodes[0];
220
+ const items = [itemFromVariant(product, variant)];
221
+ const products = variant ? [productPayload(product, variant)] : [];
222
+ send('view_item', { currency: variant?.price.currencyCode, value: totalValue(items), items }, 'product_viewed', {
223
+ products,
132
224
  });
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
225
  }
148
226
 
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 } } };
227
+ /** @param {ListReadyDetail} detail */
228
+ function onListReady({ products = [], collection }) {
229
+ const items = products
230
+ .slice(0, MAX_LIST_ITEMS)
231
+ .map((product, index) => itemFromVariant(product, product.variants.nodes[0], 1, index));
232
+ const list = { item_list_id: collection?.handle || 'products', item_list_name: collection?.title || 'Products' };
233
+ if (!collection) {
234
+ send('view_item_list', { ...list, items });
235
+ return;
236
+ }
237
+ send('view_item_list', { ...list, items }, 'collection_viewed', {
238
+ collection: { id: collection.id, handle: collection.handle },
239
+ });
157
240
  }
158
241
 
159
- // ---------------------------------------------------------------- what happened on the page
242
+ /**
243
+ * @param {AnalyticsCart | null} cart
244
+ * @param {Map<string, CartLine>} now
245
+ * @param {Map<string, CartLine>} before
246
+ */
247
+ function sendCartChanges(cart, now, before) {
248
+ const currency = cart?.cost.totalAmount.currencyCode;
249
+ now.forEach((line, variantId) => {
250
+ const previous = before.get(variantId);
251
+ const added = line.quantity - (previous?.quantity ?? 0);
252
+ if (added <= 0) return;
253
+ const items = [itemFromLine(line, added)];
254
+ send('add_to_cart', { currency, value: totalValue(items), items }, 'product_added_to_cart', {
255
+ cart,
256
+ prevCart: lastCart,
257
+ currentLine: linePayload(line),
258
+ prevLine: previous ? linePayload(previous) : undefined,
259
+ });
260
+ });
261
+ before.forEach((line, variantId) => {
262
+ const removed = line.quantity - (now.get(variantId)?.quantity ?? 0);
263
+ if (removed <= 0) return;
264
+ const items = [itemFromLine(line, removed)];
265
+ send('remove_from_cart', { currency, value: totalValue(items), items });
266
+ });
267
+ }
160
268
 
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;
269
+ /** @param {CartUpdatedDetail} detail */
270
+ function onCartUpdated({ cart, reason }) {
271
+ const now = new Map((cart?.lines.nodes ?? []).map((line) => [line.merchandise.id, line]));
272
+ // Loading the saved cart and showing a pending change aren't changes the visitor made.
273
+ if (lastLines && reason !== 'load' && reason !== 'pending') sendCartChanges(cart, now, lastLines);
274
+ lastLines = now;
275
+ lastCart = cart;
164
276
  }
165
277
 
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
- };
278
+ function onCartOpened() {
279
+ const items = Cart.lines.map((line) => itemFromLine(line));
280
+ send('view_cart', { currency: Cart.state?.cost.totalAmount.currencyCode, value: totalValue(items), items });
281
+ }
171
282
 
172
- let lastLines = null; // variant id -> line, to turn cart changes into add/remove events
173
- let viewed = new WeakSet();
283
+ /** @param {{ cart: AnalyticsCart }} detail */
284
+ function onCheckout({ cart }) {
285
+ const items = cart.lines.nodes.map((line) => itemFromLine(line));
286
+ const coupons = cart.discountCodes.filter((discount) => discount.applicable).map((discount) => discount.code);
287
+ send('begin_checkout', {
288
+ currency: cart.cost.totalAmount.currencyCode,
289
+ value: totalValue(items),
290
+ coupon: coupons.join(',') || undefined,
291
+ items,
292
+ });
293
+ }
174
294
 
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
- });
295
+ /**
296
+ * Turns one Tether event into analytics events. The details are Tether's own (`emit`), typed per event here.
297
+ *
298
+ * @param {Event} event `<prefix>:<name>`.
299
+ */
300
+ function handle(event) {
301
+ const name = event.type.slice(cfg.prefix.length + 1);
302
+ /** @type {unknown} */
303
+ const detail = Reflect.get(event, 'detail');
304
+ switch (name) {
305
+ case 'product-ready':
306
+ onProductReady(/** @type {ProductReadyDetail} */ (detail));
307
+ break;
308
+ case 'list-ready':
309
+ onListReady(/** @type {ListReadyDetail} */ (detail));
310
+ break;
311
+ case 'cart-updated':
312
+ onCartUpdated(/** @type {CartUpdatedDetail} */ (detail));
313
+ break;
314
+ case 'cart-toggled':
315
+ if (/** @type {{ open: boolean }} */ (detail).open) onCartOpened();
316
+ break;
317
+ case 'checkout':
318
+ onCheckout(/** @type {{ cart: AnalyticsCart }} */ (detail));
319
+ break;
320
+ case 'search': {
321
+ const { term } = /** @type {{ term: string }} */ (detail);
322
+ if (term) send('search', { search_term: term }, 'search_viewed', { searchTerm: term });
323
+ break;
207
324
  }
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', {});
325
+ case 'page':
326
+ viewed = new WeakSet();
327
+ bus?.publish('page_viewed', {});
328
+ break;
329
+ default:
330
+ break;
222
331
  }
223
332
  }
224
- let lastCart = null;
225
333
 
226
- /** Called by the core with the events it buffered while this part loaded. */
334
+ /**
335
+ * Called by the core with the events it buffered while this part loaded; the queue then handles events as they come.
336
+ *
337
+ * @param {Event[]} queue
338
+ */
227
339
  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', {});
340
+ if (cfg.analytics?.shopify) {
341
+ bus = await startShopifyAnalytics(() => mainProduct() != null).catch((/** @type {unknown} */ error) => {
342
+ reportError(error, 'shopify analytics');
343
+ return null;
344
+ });
345
+ }
346
+ bus?.publish('page_viewed', {});
230
347
  queue.splice(0).forEach(handle);
231
- queue.push = (e) => { handle(e); return 0; }; // from now on, handle events as they come
348
+ queue.push = (...events) => {
349
+ events.forEach(handle);
350
+ return queue.length;
351
+ };
232
352
  }
@@ -1,35 +1,85 @@
1
1
  /**
2
- * Shopify API versions are quarterly (YYYY-01, -04, -07, -10) and supported for 12 months.
3
- * Tether pins a dated version and never targets `unstable` (it changes without notice).
2
+ * Shopify API versions are quarterly (YYYY-01, -04, -07, -10) and supported for 12 months. Tether pins a dated version
3
+ * and never targets `unstable` (it changes without notice). Used by the diagnose part and the release scripts.
4
4
  */
5
5
 
6
- const parse = (v) => {
7
- const m = /^(\d{4})-(01|04|07|10)$/.exec(String(v || ''));
8
- return m ? { y: +m[1], m: +m[2] } : null;
9
- };
10
- const fmt = (y, m) => `${y}-${String(m).padStart(2, '0')}`;
6
+ const QUARTERLY_VERSION = /^(\d{4})-(01|04|07|10)$/;
7
+ const MONTHS_PER_QUARTER = 3;
8
+ const QUARTERS_PER_YEAR = 4;
9
+ const MONTHS_SUPPORTED = 12;
10
+ /** Months left at which the status starts asking for an upgrade. */
11
+ const UPGRADE_WARNING_MONTHS = 3;
12
+ const AVERAGE_MONTH_MS = 30.44 * 24 * 3600 * 1000;
11
13
 
12
- export const isDatedVersion = (v) => Boolean(parse(v));
14
+ /**
15
+ * @typedef {object} ApiVersionStatus
16
+ * @property {string} version
17
+ * @property {boolean} ok Released and still supported.
18
+ * @property {string} note What to do about it, for people.
19
+ * @property {number} [monthsLeft] Months of support left, rounded; negative once support ended.
20
+ * @property {string} [next] The version after this one.
21
+ */
13
22
 
14
- /** '2026-07' -> '2026-10', '2026-10' -> '2027-01'. */
15
- export function nextApiVersion(v, steps = 1) {
16
- const p = parse(v);
17
- if (!p) throw new Error(`not a quarterly Shopify API version: ${v}`);
18
- const q = p.y * 4 + (p.m - 1) / 3 + steps;
19
- return fmt(Math.floor(q / 4), (q % 4) * 3 + 1);
23
+ /**
24
+ * @param {unknown} version
25
+ * @returns {{ year: number, month: number } | null}
26
+ */
27
+ function parseVersion(version) {
28
+ const match = QUARTERLY_VERSION.exec(typeof version === 'string' ? version : '');
29
+ if (!match) return null;
30
+ return { year: Number(match[1]), month: Number(match[2]) };
20
31
  }
21
32
 
22
- /** Support window of a version, relative to `now`. */
33
+ /**
34
+ * @param {number} year
35
+ * @param {number} month 1 to 12.
36
+ */
37
+ const formatVersion = (year, month) => `${year}-${String(month).padStart(2, '0')}`;
38
+
39
+ /**
40
+ * `'2026-07'` → `'2026-10'`, `'2026-10'` → `'2027-01'`.
41
+ *
42
+ * @param {string} version
43
+ * @param {number} [steps] Quarters ahead.
44
+ * @returns {string}
45
+ * @throws {Error} When `version` isn't a quarterly version.
46
+ */
47
+ export function nextApiVersion(version, steps = 1) {
48
+ const parsed = parseVersion(version);
49
+ if (!parsed) throw new Error(`not a quarterly Shopify API version: ${version}`);
50
+ const quarter = parsed.year * QUARTERS_PER_YEAR + (parsed.month - 1) / MONTHS_PER_QUARTER + steps;
51
+ return formatVersion(Math.floor(quarter / QUARTERS_PER_YEAR), (quarter % QUARTERS_PER_YEAR) * MONTHS_PER_QUARTER + 1);
52
+ }
53
+
54
+ /**
55
+ * The support window of `version`, relative to `now`.
56
+ *
57
+ * @param {string} version
58
+ * @param {Date} [now]
59
+ * @returns {ApiVersionStatus}
60
+ */
23
61
  export function apiVersionStatus(version, now = new Date()) {
24
- if (version === 'unstable') return { version, ok: false, note: '"unstable" changes without notice: pin a dated version such as 2026-07' };
25
- const p = parse(version);
26
- if (!p) return { version, ok: false, note: 'not a quarterly version (YYYY-01, -04, -07 or -10)' };
27
- const released = new Date(Date.UTC(p.y, p.m - 1, 1));
28
- const supportedUntil = new Date(Date.UTC(p.y + 1, p.m - 1, 1));
29
- const monthsLeft = (supportedUntil - now) / (30.44 * 24 * 3600 * 1000);
62
+ if (version === 'unstable') {
63
+ return { version, ok: false, note: '"unstable" changes without notice: pin a dated version such as 2026-07' };
64
+ }
65
+ const parsed = parseVersion(version);
66
+ if (!parsed) return { version, ok: false, note: 'not a quarterly version (YYYY-01, -04, -07 or -10)' };
67
+ const released = new Date(Date.UTC(parsed.year, parsed.month - 1, 1));
68
+ const supportedUntil = new Date(Date.UTC(parsed.year, parsed.month - 1 + MONTHS_SUPPORTED, 1));
69
+ const monthsLeft = (supportedUntil.getTime() - now.getTime()) / AVERAGE_MONTH_MS;
70
+ const isReleased = released <= now;
30
71
  let note = `supported until about ${supportedUntil.toISOString().slice(0, 7)}`;
31
- if (released > now) note = 'not released yet (release candidate at best)';
32
- else if (monthsLeft <= 0) note = 'no longer supported: Shopify falls back to the oldest supported version. Upgrade (see README)';
33
- else if (monthsLeft < 3) note = `${note}: plan the upgrade to ${nextApiVersion(version)} or later (see README)`;
34
- return { version, ok: released <= now && monthsLeft > 0, monthsLeft: Math.round(monthsLeft), next: nextApiVersion(version), note };
72
+ if (!isReleased) note = 'not released yet (release candidate at best)';
73
+ else if (monthsLeft <= 0) {
74
+ note = 'no longer supported: Shopify falls back to the oldest supported version. Upgrade (see README)';
75
+ } else if (monthsLeft < UPGRADE_WARNING_MONTHS) {
76
+ note = `${note}: plan the upgrade to ${nextApiVersion(version)} or later (see README)`;
77
+ }
78
+ return {
79
+ version,
80
+ ok: isReleased && monthsLeft > 0,
81
+ monthsLeft: Math.round(monthsLeft),
82
+ next: nextApiVersion(version),
83
+ note,
84
+ };
35
85
  }