@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/amounts.js
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shopify sends money amounts as decimal strings (`'27.50'`). Comparing them as floats can misjudge two amounts that
|
|
3
|
+
* differ only past float precision, so they are compared digit by digit, as integers of the smallest unit present.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
const DECIMAL = /^(-?)(\d+)(?:\.(\d+))?$/;
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* @param {string} amount
|
|
10
|
+
* @param {number} fractionDigits Digits after the point to scale to.
|
|
11
|
+
* @returns {bigint | null} The amount in units of 10^-fractionDigits, `null` when it isn't a decimal number.
|
|
12
|
+
*/
|
|
13
|
+
function toScaledInteger(amount, fractionDigits) {
|
|
14
|
+
const match = DECIMAL.exec(amount.trim());
|
|
15
|
+
if (!match) return null;
|
|
16
|
+
const [, sign = '', whole = '0', fraction = ''] = match;
|
|
17
|
+
const digits = BigInt(`${whole}${fraction.padEnd(fractionDigits, '0')}`);
|
|
18
|
+
return sign === '-' ? -digits : digits;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** @param {string} amount */
|
|
22
|
+
const fractionLength = (amount) => amount.split('.')[1]?.length ?? 0;
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* @param {string | null | undefined} amount
|
|
26
|
+
* @param {string | null | undefined} other
|
|
27
|
+
* @returns {boolean} True when both are decimal numbers and `amount` is the larger one.
|
|
28
|
+
*/
|
|
29
|
+
export function isGreaterAmount(amount, other) {
|
|
30
|
+
if (amount == null || other == null) return false;
|
|
31
|
+
const fractionDigits = Math.max(fractionLength(amount.trim()), fractionLength(other.trim()));
|
|
32
|
+
const scaled = toScaledInteger(amount, fractionDigits);
|
|
33
|
+
const scaledOther = toScaledInteger(other, fractionDigits);
|
|
34
|
+
if (scaled === null || scaledOther === null) return false;
|
|
35
|
+
return scaled > scaledOther;
|
|
36
|
+
}
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shopify's own analytics for the analytics part. Tether sets up the same event bus Hydrogen uses
|
|
3
|
+
* (`window.Shopify.analytics`) and loads Shopify's consent script and shopify.js, which build and send the events.
|
|
4
|
+
* Nothing is delivered before the Customer Privacy API allows analytics.
|
|
5
|
+
*/
|
|
6
|
+
import { cfg, log, reportError } from './config.js';
|
|
7
|
+
import { gql } from './client.js';
|
|
8
|
+
|
|
9
|
+
/** @typedef {Record<string, unknown>} EventPayload */
|
|
10
|
+
/** @typedef {{ uniqueToken: string, visitToken: string }} TrackingValues */
|
|
11
|
+
/** @typedef {{ getTrackingValues: () => TrackingValues }} DestinationContext */
|
|
12
|
+
/** @typedef {(payload: EventPayload, context: DestinationContext) => void} Subscriber */
|
|
13
|
+
/** @typedef {(event: string, subscriber: Subscriber) => () => void} Subscribe */
|
|
14
|
+
/** @typedef {{ shop: { shopId: string, channel: 'headless' }, consent: { mode: string } }} BusConfig */
|
|
15
|
+
/**
|
|
16
|
+
* @typedef {object} DestinationSpec What shopify.js registers.
|
|
17
|
+
* @property {string} name
|
|
18
|
+
* @property {(api: { subscribe: Subscribe, getConfig: () => BusConfig }) => unknown} [setup]
|
|
19
|
+
*/
|
|
20
|
+
/**
|
|
21
|
+
* @typedef {object} AnalyticsBus Hydrogen's `window.Shopify.analytics`.
|
|
22
|
+
* @property {() => BusConfig} getConfig
|
|
23
|
+
* @property {(event: string, payload?: EventPayload) => void} publish
|
|
24
|
+
* @property {(destination: DestinationSpec) => () => void} addDestination
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* The parts of Shopify's Customer Privacy API (`window.Shopify.customerPrivacy`) that decide tracking.
|
|
29
|
+
*
|
|
30
|
+
* @typedef {object} CustomerPrivacy
|
|
31
|
+
* @property {string} [consentStatus]
|
|
32
|
+
* @property {() => { analytics?: string } | undefined} [currentVisitorConsent]
|
|
33
|
+
* @property {() => boolean} [analyticsProcessingAllowed]
|
|
34
|
+
* @property {{ uniqueToken?: TokenReader, visitToken?: TokenReader }} [__internal]
|
|
35
|
+
* @property {object} [config]
|
|
36
|
+
*/
|
|
37
|
+
/** @typedef {(options: { generateFallback: boolean, tag: string }) => string} TokenReader */
|
|
38
|
+
/**
|
|
39
|
+
* `window.Shopify`, which Shopify's scripts read and extend.
|
|
40
|
+
*
|
|
41
|
+
* @typedef {object} ShopifyGlobal
|
|
42
|
+
* @property {AnalyticsBus} [analytics]
|
|
43
|
+
* @property {CustomerPrivacy} [customerPrivacy]
|
|
44
|
+
* @property {string} [country]
|
|
45
|
+
* @property {string} [locale]
|
|
46
|
+
* @property {string} [shop]
|
|
47
|
+
* @property {object} [routes]
|
|
48
|
+
*/
|
|
49
|
+
|
|
50
|
+
/** @typedef {{ event: string, payload: EventPayload }} BufferedEvent */
|
|
51
|
+
/**
|
|
52
|
+
* @typedef {object} Destination
|
|
53
|
+
* @property {string} name
|
|
54
|
+
* @property {number} next The index of the next buffered event it gets.
|
|
55
|
+
* @property {Map<string, Set<Subscriber>>} subscribers
|
|
56
|
+
* @property {DestinationContext} context
|
|
57
|
+
*/
|
|
58
|
+
|
|
59
|
+
/** Events kept for destinations that register late (shopify.js loads after the first page view). */
|
|
60
|
+
const BUFFER_SIZE = 500;
|
|
61
|
+
const CONSENT_SCRIPTS = {
|
|
62
|
+
'default-banner': 'https://cdn.shopify.com/shopifycloud/privacy-banner/storefront-banner.js',
|
|
63
|
+
other: 'https://cdn.shopify.com/shopifycloud/consent-tracking-api/v0.2/consent-tracking-api.js',
|
|
64
|
+
};
|
|
65
|
+
const ANALYTICS_SCRIPT = 'https://cdn.shopify.com/storefront/analytics/shopify.js';
|
|
66
|
+
|
|
67
|
+
/** @returns {ShopifyGlobal | undefined} `window.Shopify`, whose shape Shopify's scripts define. */
|
|
68
|
+
function shopifyGlobal() {
|
|
69
|
+
/** @type {unknown} */
|
|
70
|
+
const shopify = Reflect.get(window, 'Shopify');
|
|
71
|
+
return /** @type {ShopifyGlobal | undefined} */ (shopify);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** @returns {{ consent?: string, proxy?: string }} The `analytics.shopify` options; `true` sets none. */
|
|
75
|
+
function shopifyOptions() {
|
|
76
|
+
const shopify = cfg.analytics?.shopify;
|
|
77
|
+
return typeof shopify === 'object' ? shopify : {};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Shopify's privacy API can throw while its script is still setting up. An answer it can't give counts as "not
|
|
82
|
+
* allowed", so nothing is sent without consent; the next consent event asks again.
|
|
83
|
+
*
|
|
84
|
+
* @returns {boolean}
|
|
85
|
+
*/
|
|
86
|
+
function trackingAllowed() {
|
|
87
|
+
try {
|
|
88
|
+
const privacy = shopifyGlobal()?.customerPrivacy;
|
|
89
|
+
return (
|
|
90
|
+
privacy?.consentStatus === 'loaded' &&
|
|
91
|
+
privacy.currentVisitorConsent?.()?.analytics !== 'no' &&
|
|
92
|
+
Boolean(privacy.analyticsProcessingAllowed?.())
|
|
93
|
+
);
|
|
94
|
+
} catch {
|
|
95
|
+
return false;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* @param {string} name The destination's name, Hydrogen tags its tokens with it.
|
|
101
|
+
* @returns {DestinationContext}
|
|
102
|
+
*/
|
|
103
|
+
function destinationContext(name) {
|
|
104
|
+
const tag = `hydrogen:${name}`;
|
|
105
|
+
return {
|
|
106
|
+
getTrackingValues: () => {
|
|
107
|
+
const internal = trackingAllowed() ? shopifyGlobal()?.customerPrivacy?.__internal : undefined;
|
|
108
|
+
return {
|
|
109
|
+
uniqueToken: internal?.uniqueToken?.({ generateFallback: true, tag }) ?? '',
|
|
110
|
+
visitToken: internal?.visitToken?.({ generateFallback: true, tag }) ?? '',
|
|
111
|
+
};
|
|
112
|
+
},
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* @param {Destination} destination
|
|
118
|
+
* @returns {Subscribe}
|
|
119
|
+
*/
|
|
120
|
+
const subscriberOf = (destination) => (event, subscriber) => {
|
|
121
|
+
const subscribers = destination.subscribers.get(event) ?? new Set();
|
|
122
|
+
destination.subscribers.set(event, subscribers);
|
|
123
|
+
subscribers.add(subscriber);
|
|
124
|
+
return () => destination.subscribers.get(event)?.delete(subscriber);
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* @param {string} shopId
|
|
129
|
+
* @returns {AnalyticsBus} A bus that buffers events and delivers them to each destination once consent allows.
|
|
130
|
+
*/
|
|
131
|
+
function createBus(shopId) {
|
|
132
|
+
/** @type {Destination[]} */
|
|
133
|
+
const destinations = [];
|
|
134
|
+
/** @type {BufferedEvent[]} */
|
|
135
|
+
const buffer = [];
|
|
136
|
+
/** @type {BusConfig} */
|
|
137
|
+
const config = {
|
|
138
|
+
shop: { shopId, channel: 'headless' },
|
|
139
|
+
consent: { mode: shopifyOptions().consent || 'no-banner' },
|
|
140
|
+
};
|
|
141
|
+
const deliver = () => {
|
|
142
|
+
if (!trackingAllowed()) return;
|
|
143
|
+
for (const destination of destinations) {
|
|
144
|
+
for (; destination.next < buffer.length; destination.next += 1) {
|
|
145
|
+
const buffered = buffer[destination.next];
|
|
146
|
+
if (buffered) deliverTo(destination, buffered);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
};
|
|
150
|
+
/** @type {AnalyticsBus} */
|
|
151
|
+
const bus = {
|
|
152
|
+
getConfig: () => config,
|
|
153
|
+
publish(event, payload = {}) {
|
|
154
|
+
buffer.push({ event, payload: { shop: config.shop, url: window.location.href, ...payload } });
|
|
155
|
+
if (buffer.length > BUFFER_SIZE) {
|
|
156
|
+
buffer.shift();
|
|
157
|
+
for (const destination of destinations) destination.next = Math.max(0, destination.next - 1);
|
|
158
|
+
}
|
|
159
|
+
deliver();
|
|
160
|
+
},
|
|
161
|
+
addDestination(spec) {
|
|
162
|
+
/** @type {Destination} */
|
|
163
|
+
const destination = { name: spec.name, next: 0, subscribers: new Map(), context: destinationContext(spec.name) };
|
|
164
|
+
Promise.resolve(spec.setup?.({ subscribe: subscriberOf(destination), getConfig: bus.getConfig }))
|
|
165
|
+
.then(() => {
|
|
166
|
+
destinations.push(destination);
|
|
167
|
+
deliver();
|
|
168
|
+
})
|
|
169
|
+
.catch((/** @type {unknown} */ error) => {
|
|
170
|
+
reportError(error, `analytics ${spec.name}`);
|
|
171
|
+
});
|
|
172
|
+
return () => {
|
|
173
|
+
const index = destinations.indexOf(destination);
|
|
174
|
+
if (index >= 0) destinations.splice(index, 1);
|
|
175
|
+
};
|
|
176
|
+
},
|
|
177
|
+
};
|
|
178
|
+
document.addEventListener('consentTrackingApiLoaded', deliver);
|
|
179
|
+
document.addEventListener('visitorConsentCollected', deliver);
|
|
180
|
+
return bus;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* @param {Destination} destination
|
|
185
|
+
* @param {BufferedEvent} buffered
|
|
186
|
+
*/
|
|
187
|
+
function deliverTo(destination, { event, payload }) {
|
|
188
|
+
destination.subscribers.get(event)?.forEach((subscriber) => {
|
|
189
|
+
try {
|
|
190
|
+
subscriber(payload, destination.context);
|
|
191
|
+
} catch (error) {
|
|
192
|
+
reportError(error, `analytics ${destination.name}`);
|
|
193
|
+
}
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** @param {string} src */
|
|
198
|
+
function loadScript(src) {
|
|
199
|
+
const script = document.createElement('script');
|
|
200
|
+
script.src = src;
|
|
201
|
+
script.async = true;
|
|
202
|
+
document.head.appendChild(script);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Sets up `window.Shopify` for Shopify's scripts and loads them.
|
|
207
|
+
*
|
|
208
|
+
* @param {() => boolean} isProductPage Asked by Shopify's script for the page template.
|
|
209
|
+
* @returns {Promise<AnalyticsBus>} The bus, the page's own when a theme already set one up.
|
|
210
|
+
*/
|
|
211
|
+
export async function startShopifyAnalytics(isProductPage) {
|
|
212
|
+
const existing = shopifyGlobal();
|
|
213
|
+
if (existing?.analytics) return existing.analytics;
|
|
214
|
+
/** @type {{ shop: { id: string } }} */
|
|
215
|
+
const data = await gql('query TetherShopId { shop { id } }', {}, { context: false });
|
|
216
|
+
const shopify = shopifyGlobal() ?? {};
|
|
217
|
+
Reflect.set(window, 'Shopify', shopify);
|
|
218
|
+
shopify.country = (cfg.country || 'US').toUpperCase();
|
|
219
|
+
// Shopify's script requires a language, not a full locale.
|
|
220
|
+
shopify.locale = (cfg.locale || 'en').split('-')[0]?.toLowerCase() ?? 'en';
|
|
221
|
+
shopify.shop = cfg.domain;
|
|
222
|
+
shopify.routes = {
|
|
223
|
+
root: '/',
|
|
224
|
+
match: () => ({ pageTemplateName: isProductPage() ? 'product' : 'page' }),
|
|
225
|
+
...shopify.routes,
|
|
226
|
+
};
|
|
227
|
+
// A same-origin Storefront API path gives Shopify exact visitor counts.
|
|
228
|
+
const { proxy, consent } = shopifyOptions();
|
|
229
|
+
shopify.customerPrivacy = {
|
|
230
|
+
...shopify.customerPrivacy,
|
|
231
|
+
config: proxy
|
|
232
|
+
? { isHeadless: true, asyncConsent: true, asyncVisitorState: true, consentDomain: window.location.host }
|
|
233
|
+
: { isHeadless: true, storefrontAccessToken: cfg.token, checkoutRootDomain: cfg.domain },
|
|
234
|
+
};
|
|
235
|
+
const bus = createBus(data.shop.id);
|
|
236
|
+
shopify.analytics = bus;
|
|
237
|
+
loadScript(consent === 'default-banner' ? CONSENT_SCRIPTS['default-banner'] : CONSENT_SCRIPTS.other);
|
|
238
|
+
loadScript(ANALYTICS_SCRIPT);
|
|
239
|
+
log('shopify analytics ready', data.shop.id);
|
|
240
|
+
return bus;
|
|
241
|
+
}
|