@occluze/tether 0.6.6 → 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.
- package/README.md +3 -3
- package/dist/esm/astro.js +2 -2
- package/dist/esm/chunks/account-C3NP6HFB.js +1 -0
- package/dist/esm/chunks/account-pages-WLK374FO.js +1 -0
- package/dist/esm/chunks/analytics-G3SAWMNL.js +1 -0
- package/dist/esm/chunks/chunk-27PJJZGC.js +1 -0
- package/dist/esm/chunks/chunk-2FSXS55U.js +1 -0
- package/dist/esm/chunks/chunk-FCSWQDVV.js +1 -0
- package/dist/esm/chunks/chunk-FOMSMIAM.js +1 -0
- package/dist/esm/chunks/chunk-GZJTCLLW.js +1 -0
- package/dist/esm/chunks/chunk-HEQILZVR.js +1 -0
- package/dist/esm/chunks/chunk-JOOEXYF3.js +4 -0
- package/dist/esm/chunks/chunk-MPTQYLSM.js +1 -0
- package/dist/esm/chunks/chunk-NEHZ37TU.js +3 -0
- package/dist/esm/chunks/chunk-V74KGEJF.js +1 -0
- package/dist/esm/chunks/chunk-VT4CDUJJ.js +1 -0
- package/dist/esm/chunks/chunk-YJLWX2HK.js +1 -0
- package/dist/esm/chunks/diagnose-6VVGZNTY.js +14 -0
- package/dist/esm/chunks/extras-GJVRDFM3.js +1 -0
- package/dist/esm/chunks/markets-ui-UNB4PTA7.js +1 -0
- package/dist/esm/chunks/search-OU75RNQL.js +1 -0
- package/dist/esm/index.js +1 -1
- package/dist/esm/server.js +2 -1
- package/dist/presets/barba.esm.js +56 -36
- package/dist/tether.account-pages.js +776 -432
- package/dist/tether.account-pages.min.js +1 -1
- package/dist/tether.account.js +655 -321
- package/dist/tether.account.min.js +1 -1
- package/dist/tether.analytics.js +318 -191
- package/dist/tether.analytics.min.js +1 -1
- package/dist/tether.diagnose.js +334 -243
- package/dist/tether.diagnose.min.js +2 -2
- package/dist/tether.extras.js +78 -33
- package/dist/tether.extras.min.js +1 -1
- package/dist/tether.js +3171 -1998
- package/dist/tether.markets.js +231 -126
- package/dist/tether.markets.min.js +1 -1
- package/dist/tether.min.js +0 -0
- package/dist/tether.min.js.map +4 -4
- package/dist/tether.search.js +221 -126
- package/dist/tether.search.min.js +1 -1
- package/package.json +23 -4
- package/src/account/addresses-view.js +92 -0
- package/src/account/browser-session.js +359 -0
- package/src/account/markup.js +48 -0
- package/src/account/operations.js +180 -0
- package/src/account/order-lines.js +96 -0
- package/src/account/order-list.js +91 -0
- package/src/account/order-page.js +192 -0
- package/src/account/page-events.js +288 -0
- package/src/account/server-client.js +172 -0
- package/src/account/shared.js +111 -0
- package/src/account/shopify-auth.js +150 -0
- package/src/account/subscriptions-view.js +158 -0
- package/src/account-api.js +118 -0
- package/src/account-links.js +35 -0
- package/src/account-pages.js +68 -284
- package/src/account.js +281 -317
- package/src/amounts.js +36 -0
- package/src/analytics-shopify.js +241 -0
- package/src/analytics.js +304 -184
- package/src/api-version.js +75 -25
- package/src/astro.js +233 -68
- package/src/cart/checkout.js +97 -0
- package/src/cart/drawer.js +138 -0
- package/src/cart/mutations.js +234 -0
- package/src/cart/render.js +294 -0
- package/src/cart/store.js +326 -0
- package/src/cart.js +322 -481
- package/src/client.js +214 -78
- package/src/collection.js +126 -57
- package/src/config.js +323 -59
- package/src/controls/cart-controls.js +155 -0
- package/src/controls/discount.js +96 -0
- package/src/controls/extras.js +63 -0
- package/src/controls/navigation.js +83 -0
- package/src/diagnose-markup.js +333 -0
- package/src/diagnose.js +251 -202
- package/src/dom.js +219 -220
- package/src/extras.js +119 -37
- package/src/fragments.js +115 -22
- package/src/images.js +104 -0
- package/src/index.js +245 -285
- package/src/initial.js +78 -66
- package/src/lazy-iife.js +92 -22
- package/src/lazy.js +25 -4
- package/src/loader.js +263 -124
- package/src/market-codes.js +35 -0
- package/src/markets-ui.js +169 -55
- package/src/markets.js +286 -85
- package/src/markup.js +64 -0
- package/src/mounting.js +214 -91
- package/src/page-market.js +116 -0
- package/src/parts/account-pages.js +4 -1
- package/src/parts/account.js +4 -1
- package/src/parts/analytics.js +4 -1
- package/src/parts/diagnose.js +4 -1
- package/src/parts/extras.js +4 -1
- package/src/parts/markets.js +4 -1
- package/src/parts/search.js +4 -1
- package/src/presets/barba.js +120 -30
- package/src/product-events.js +130 -0
- package/src/product-render.js +257 -0
- package/src/product-state.js +318 -0
- package/src/product.js +233 -459
- package/src/repeaters.js +173 -0
- package/src/search.js +277 -107
- package/src/selling-plans.js +107 -47
- package/src/server.js +168 -205
- package/src/shopify-values.js +89 -0
- package/src/status.js +99 -39
- package/src/store-html.js +0 -0
- package/src/storefront/catalog.js +199 -0
- package/src/storefront/json-ld.js +167 -0
- package/src/variant-render.js +165 -0
- package/types/astro.d.ts +13 -2
- package/types/index.d.ts +178 -27
- package/types/server.d.ts +89 -17
- package/dist/esm/chunks/account-QUBHQG3C.js +0 -1
- package/dist/esm/chunks/account-pages-VWDHF6WA.js +0 -1
- package/dist/esm/chunks/analytics-SLATWPLC.js +0 -1
- package/dist/esm/chunks/chunk-64TUD3QR.js +0 -1
- package/dist/esm/chunks/chunk-AJLPU2JC.js +0 -4
- package/dist/esm/chunks/chunk-FIOZPPC5.js +0 -1
- package/dist/esm/chunks/chunk-K2YEQAIH.js +0 -1
- package/dist/esm/chunks/chunk-L2TRRKYV.js +0 -1
- package/dist/esm/chunks/chunk-RREHGH4A.js +0 -1
- package/dist/esm/chunks/chunk-VC46IEJQ.js +0 -1
- package/dist/esm/chunks/chunk-VCPI7MJ2.js +0 -1
- package/dist/esm/chunks/chunk-VPVJPNB4.js +0 -1
- package/dist/esm/chunks/chunk-ZIUIWEON.js +0 -1
- package/dist/esm/chunks/diagnose-VXQBSD3A.js +0 -14
- package/dist/esm/chunks/extras-R4X34VQL.js +0 -1
- package/dist/esm/chunks/markets-ui-F3R5T5EL.js +0 -1
- 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
|
-
*
|
|
12
|
-
*
|
|
13
|
-
* Shopify's
|
|
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,
|
|
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
|
-
|
|
25
|
-
const
|
|
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
|
-
|
|
70
|
+
/** @param {Money | null | undefined} money */
|
|
71
|
+
const amountOf = (money) => (money ? parseFloat(money.amount) : 0);
|
|
28
72
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
52
|
-
|
|
53
|
-
const
|
|
54
|
-
|
|
55
|
-
|
|
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
|
-
|
|
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
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
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
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
const
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
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
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
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
|
-
|
|
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
|
-
|
|
162
|
-
|
|
163
|
-
|
|
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
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
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
|
-
|
|
173
|
-
|
|
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
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
});
|
|
203
|
-
|
|
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
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
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
|
-
/**
|
|
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
|
|
229
|
-
|
|
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 = (
|
|
348
|
+
queue.push = (...events) => {
|
|
349
|
+
events.forEach(handle);
|
|
350
|
+
return queue.length;
|
|
351
|
+
};
|
|
232
352
|
}
|
package/src/api-version.js
CHANGED
|
@@ -1,35 +1,85 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Shopify API versions are quarterly (YYYY-01, -04, -07, -10) and supported for 12 months.
|
|
3
|
-
*
|
|
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
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
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
|
-
/**
|
|
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')
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
const
|
|
28
|
-
|
|
29
|
-
const
|
|
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 (
|
|
32
|
-
else if (monthsLeft <= 0)
|
|
33
|
-
|
|
34
|
-
|
|
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
|
}
|