@occluze/tether 0.7.0 → 0.7.1

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 (132) hide show
  1. package/README.md +21 -21
  2. package/dist/esm/chunks/{account-UIPWJQ3Y.js → account-CXMA5RPZ.js} +1 -1
  3. package/dist/esm/chunks/{account-pages-6ROA6Y4V.js → account-pages-IZO7FEOZ.js} +1 -1
  4. package/dist/esm/chunks/{analytics-2V632IRV.js → analytics-PG5FPRDN.js} +1 -1
  5. package/dist/esm/chunks/{chunk-4GCXM764.js → chunk-4HD4J5JC.js} +1 -1
  6. package/dist/esm/chunks/{chunk-BFTZR4OB.js → chunk-6TFMLT4A.js} +2 -2
  7. package/dist/esm/chunks/{chunk-T44MUL4Y.js → chunk-BBVUVPMO.js} +1 -1
  8. package/dist/esm/chunks/chunk-EW5SYO6L.js +1 -0
  9. package/dist/esm/chunks/chunk-HJY2JSNB.js +1 -0
  10. package/dist/esm/chunks/chunk-MQYB6BH4.js +1 -0
  11. package/dist/esm/chunks/{chunk-5Z5O3767.js → chunk-N3V45JPS.js} +1 -1
  12. package/dist/esm/chunks/{chunk-YLUULATY.js → chunk-RPMBCAGS.js} +1 -1
  13. package/dist/esm/chunks/{chunk-MPDWM2NN.js → chunk-VFNHIGCS.js} +1 -1
  14. package/dist/esm/chunks/{chunk-6OZJCAGZ.js → chunk-WT7RGGJU.js} +1 -1
  15. package/dist/esm/chunks/chunk-YN6LEBEN.js +1 -0
  16. package/dist/esm/chunks/chunk-Z6HS3H7K.js +1 -0
  17. package/dist/esm/chunks/{diagnose-3FEKRIEK.js → diagnose-XCEBQKBO.js} +1 -1
  18. package/dist/esm/chunks/extras-4HU3VSBQ.js +1 -0
  19. package/dist/esm/chunks/{markets-ui-FD6NI2OG.js → markets-ui-M4IQ62CT.js} +1 -1
  20. package/dist/esm/chunks/search-A5DS2IOS.js +1 -0
  21. package/dist/esm/index.js +1 -1
  22. package/dist/esm/server.js +2 -2
  23. package/dist/tether.account-pages.js +13 -3
  24. package/dist/tether.account-pages.min.js +1 -1
  25. package/dist/tether.account.js +13 -3
  26. package/dist/tether.account.min.js +1 -1
  27. package/dist/tether.analytics.js +18 -3
  28. package/dist/tether.analytics.min.js +1 -1
  29. package/dist/tether.diagnose.js +18 -3
  30. package/dist/tether.diagnose.min.js +2 -2
  31. package/dist/tether.extras.js +19 -6
  32. package/dist/tether.extras.min.js +1 -1
  33. package/dist/tether.js +162 -112
  34. package/dist/tether.markets.js +18 -3
  35. package/dist/tether.markets.min.js +1 -1
  36. package/dist/tether.min.js +3 -3
  37. package/dist/tether.min.js.map +4 -4
  38. package/dist/tether.search.js +18 -3
  39. package/dist/tether.search.min.js +1 -1
  40. package/dist/types/server/src/shared/cart-id.d.ts +2 -0
  41. package/dist/types/src/cart/mutations.d.ts +6 -0
  42. package/dist/types/src/cart/store.d.ts +5 -2
  43. package/dist/types/src/cart.d.ts +2 -2
  44. package/dist/types/src/client.d.ts +53 -19
  45. package/dist/types/src/config.d.ts +6 -1
  46. package/dist/types/src/index.d.ts +2 -2
  47. package/dist/types/src/product-shape.d.ts +3 -1
  48. package/package.json +1 -2
  49. package/dist/esm/chunks/chunk-56FCLJJD.js +0 -1
  50. package/dist/esm/chunks/chunk-A5SYCVQM.js +0 -1
  51. package/dist/esm/chunks/chunk-BA4LQJJQ.js +0 -1
  52. package/dist/esm/chunks/chunk-C5JYQCWH.js +0 -1
  53. package/dist/esm/chunks/extras-6POFBNOP.js +0 -1
  54. package/dist/esm/chunks/search-UJ73YBQ5.js +0 -1
  55. package/src/account/addresses-view.ts +0 -58
  56. package/src/account/answers.ts +0 -79
  57. package/src/account/markup.ts +0 -36
  58. package/src/account/operations.ts +0 -145
  59. package/src/account/order-lines.ts +0 -75
  60. package/src/account/order-list.ts +0 -107
  61. package/src/account/order-page.ts +0 -137
  62. package/src/account/page-answers.ts +0 -317
  63. package/src/account/page-events.ts +0 -253
  64. package/src/account/server-client.ts +0 -183
  65. package/src/account/subscriptions-view.ts +0 -115
  66. package/src/account-api.ts +0 -98
  67. package/src/account-marks.ts +0 -35
  68. package/src/account-pages.ts +0 -81
  69. package/src/account.ts +0 -284
  70. package/src/amounts.ts +0 -64
  71. package/src/analytics-shopify.ts +0 -246
  72. package/src/analytics.ts +0 -315
  73. package/src/api-version.ts +0 -79
  74. package/src/astro.ts +0 -272
  75. package/src/cart/cart-shape.ts +0 -25
  76. package/src/cart/checkout.ts +0 -101
  77. package/src/cart/drawer.ts +0 -125
  78. package/src/cart/mutations.ts +0 -237
  79. package/src/cart/render.ts +0 -266
  80. package/src/cart/store.ts +0 -279
  81. package/src/cart.ts +0 -345
  82. package/src/client.ts +0 -391
  83. package/src/collection.ts +0 -175
  84. package/src/config-sections.ts +0 -284
  85. package/src/config.ts +0 -480
  86. package/src/controls/cart-controls.ts +0 -123
  87. package/src/controls/discount.ts +0 -75
  88. package/src/controls/extras.ts +0 -54
  89. package/src/controls/navigation.ts +0 -77
  90. package/src/diagnose-markup.ts +0 -298
  91. package/src/diagnose.ts +0 -294
  92. package/src/dom.ts +0 -268
  93. package/src/extras.ts +0 -136
  94. package/src/fragments.ts +0 -261
  95. package/src/globals.d.ts +0 -15
  96. package/src/images.ts +0 -91
  97. package/src/index.ts +0 -415
  98. package/src/initial.ts +0 -101
  99. package/src/lazy-iife.ts +0 -197
  100. package/src/lazy.ts +0 -42
  101. package/src/loader.ts +0 -363
  102. package/src/market-codes.ts +0 -6
  103. package/src/markets-ui.ts +0 -227
  104. package/src/markets.ts +0 -246
  105. package/src/markup.ts +0 -127
  106. package/src/mounting.ts +0 -264
  107. package/src/page-market.ts +0 -110
  108. package/src/part-integrity.ts +0 -7
  109. package/src/parts/account-pages.ts +0 -7
  110. package/src/parts/account.ts +0 -7
  111. package/src/parts/analytics.ts +0 -7
  112. package/src/parts/diagnose.ts +0 -7
  113. package/src/parts/extras.ts +0 -7
  114. package/src/parts/markets.ts +0 -7
  115. package/src/parts/search.ts +0 -7
  116. package/src/presets/barba.ts +0 -194
  117. package/src/product-events.ts +0 -115
  118. package/src/product-render.ts +0 -218
  119. package/src/product-shape.ts +0 -49
  120. package/src/product-state.ts +0 -318
  121. package/src/product.ts +0 -308
  122. package/src/repeaters.ts +0 -161
  123. package/src/search.ts +0 -305
  124. package/src/selling-plans.ts +0 -145
  125. package/src/server.ts +0 -211
  126. package/src/shopify-values.ts +0 -91
  127. package/src/status.ts +0 -106
  128. package/src/storage.ts +0 -34
  129. package/src/store-html.ts +0 -359
  130. package/src/storefront/catalog.ts +0 -196
  131. package/src/storefront/json-ld.ts +0 -144
  132. package/src/variant-render.ts +0 -139
package/src/account.ts DELETED
@@ -1,284 +0,0 @@
1
- /**
2
- * Customer accounts (Shopify Customer Account API), as an optional part: loaded only when `account` is configured
3
- * and the page uses it, or a login is coming back, or the visitor may be logged in.
4
- *
5
- * window.TETHER_CONFIG = { …, account: { server: '/_tether/account' } }
6
- *
7
- * <button tether-account-login>Log in</button>
8
- * <button tether-account-logout tether-show="logged-in">Log out</button>
9
- * <span tether-account-bind="first-name"></span> first-name, last-name, name, email, address
10
- * <div tether-orders> the customer's orders (first child = template)
11
- * <a tether-order-bind="url"><span tether-order-bind="name"></span> · <span tether-order-bind="date"></span></a>
12
- * </div>
13
- * <p tether-show="logged-out">…</p>
14
- *
15
- * The site's Worker logs the customer in and holds the tokens (`account/server-client.ts` is this browser's side of
16
- * it), so no token is ever in the browser. The order page, profile, addresses, subscriptions and returns are the
17
- * `account-pages` part, loaded only on pages that use them.
18
- */
19
- import { cfg, log, reportError } from './config.ts';
20
- import { TetherError } from './client.ts';
21
- import { sel, get, qsa, setText, show, emit } from './dom.ts';
22
- import { Cart } from './cart.ts';
23
- import { loadPart } from './lazy.ts';
24
- import type { Parts } from './lazy.ts';
25
- import {
26
- forgetServerLogin,
27
- isServerLoggedIn,
28
- serverCheckoutUrl,
29
- serverFetch,
30
- serverLogin,
31
- serverLoginRedirect,
32
- serverLogout,
33
- } from './account/server-client.ts';
34
- import { DEFAULT_ORDER_COUNT, MAX_ORDER_COUNT, isOrderCount, renderOrderList } from './account/order-list.ts';
35
- import { ownValue } from './account/markup.ts';
36
- import { toAccountOrders } from './account/answers.ts';
37
- import type { AccountCustomer } from './account/answers.ts';
38
- import { isLoginOutcome } from './account-marks.ts';
39
- import type { AccountOrder } from '../server/src/shared/account-orders.ts';
40
- import type { AddressFields, ReturnLine } from './account/operations.ts';
41
- import type {
42
- AccountAddress,
43
- CustomerAddress,
44
- OpenedReturn,
45
- OrderDetail,
46
- Profile,
47
- SubscriptionChange,
48
- SubscriptionContract,
49
- } from './account/page-answers.ts';
50
- import type { SubscriptionAction } from '../server/src/shared/account-routes.ts';
51
-
52
- /**
53
- * `unknown` when the Worker or Shopify can't say: the page shows its logged-out content, and the account pages their
54
- * error state.
55
- */
56
- type LoginState = { kind: 'logged-in' } | { kind: 'logged-out' } | { kind: 'unknown'; error: unknown };
57
-
58
- /** The attributes of the account pages, which are their own part. */
59
- const PAGE_ATTRIBUTES = ['order', 'addresses', 'subscriptions', 'profile-form', 'address-form'];
60
-
61
- /** @returns True when the Worker last said a customer is logged in; false before it has been asked. */
62
- export const isLoggedIn = isServerLoggedIn;
63
-
64
- /**
65
- * Through the Worker to Shopify's login page; the visitor comes back to `returnTo`.
66
- *
67
- * @throws {TetherError} `NOT_CONFIGURED` without `account.server`.
68
- */
69
- export function login({
70
- returnTo = window.location.href,
71
- locale,
72
- }: { returnTo?: string; locale?: string } = {}): Promise<void> {
73
- // In a promise, so a missing `account.server` rejects as it does for every other account function.
74
- return new Promise((resolve) => {
75
- serverLoginRedirect({ returnTo, locale });
76
- resolve();
77
- });
78
- }
79
-
80
- /**
81
- * @returns The logged-in customer, `null` when nobody is.
82
- * @throws {TetherError} When the Worker can't be reached or fails.
83
- */
84
- export async function customer(): Promise<AccountCustomer | null> {
85
- const me = await serverLogin();
86
- return me.loggedIn ? me.customer : null;
87
- }
88
-
89
- /**
90
- * @param options `first`: a whole number from 1 to `MAX_ORDER_COUNT`.
91
- * @returns The customer's most recent orders, newest first; none when nobody is logged in.
92
- * @throws {TetherError} `INVALID` for another `first`, which is refused before any request; else when the Worker or
93
- * Shopify can't answer.
94
- */
95
- export async function orders({ first = DEFAULT_ORDER_COUNT }: { first?: number } = {}): Promise<AccountOrder[]> {
96
- if (!isOrderCount(first)) {
97
- throw new TetherError(`Customer accounts: orders() takes first from 1 to ${String(MAX_ORDER_COUNT)}`, {
98
- code: 'INVALID',
99
- });
100
- }
101
- const answer = await serverFetch(`/orders?${new URLSearchParams({ first: String(first) }).toString()}`);
102
- return answer.kind === 'logged-out' ? [] : toAccountOrders(answer.data['orders']);
103
- }
104
-
105
- /**
106
- * Logs out here and at Shopify, and unlinks the cart. Sends `tether:account-logout`.
107
- *
108
- * @param options `returnTo`: where Shopify sends the visitor afterwards.
109
- */
110
- export async function logout({
111
- returnTo = cfg.account?.logoutUrl || `${window.location.origin}/`,
112
- }: { returnTo?: string } = {}): Promise<void> {
113
- if ((await serverLogout(returnTo)) === 'stayed') await render(document);
114
- }
115
-
116
- async function loginState(): Promise<LoginState> {
117
- try {
118
- return (await serverLogin()).loggedIn ? { kind: 'logged-in' } : { kind: 'logged-out' };
119
- } catch (error) {
120
- return { kind: 'unknown', error };
121
- }
122
- }
123
-
124
- const usesPages = (root: ParentNode): boolean => PAGE_ATTRIBUTES.some((name) => qsa(root, sel(name)).length > 0);
125
-
126
- /**
127
- * `tether-account-link`: Shopify's own pages, for what has to stay there: `subscriptions` and `payment`
128
- * (`account.subscriptionsUrl` / `paymentUrl`), or the account itself.
129
- */
130
- function linkShopifyPages(root: ParentNode): void {
131
- qsa(root, sel('account-link')).forEach((link) => {
132
- link.setAttribute('href', shopifyPageUrl(get(link, 'account-link')));
133
- });
134
- }
135
-
136
- /**
137
- * @param page The `tether-account-link` value.
138
- * @returns The configured page for it, else the shop's account.
139
- */
140
- function shopifyPageUrl(page: string | null): string {
141
- const account = cfg.account;
142
- const fallback = `https://${cfg.domain}/account`;
143
- if (page === 'payment') return account?.paymentUrl || account?.subscriptionsUrl || fallback;
144
- if (page === 'subscriptions') return account?.subscriptionsUrl || fallback;
145
- return fallback;
146
- }
147
-
148
- /**
149
- * Fills the account markup in `root`: what shows when logged in or out, the customer, their orders, and the account
150
- * pages.
151
- */
152
- export async function render(root: ParentNode = document): Promise<void> {
153
- const state = await loginState();
154
- if (state.kind === 'unknown') reportError(state.error, 'account');
155
- const loggedIn = state.kind === 'logged-in';
156
- qsa(root, sel('show')).forEach((el) => {
157
- const condition = get(el, 'show');
158
- if (condition === 'logged-in') show(el, loggedIn);
159
- if (condition === 'logged-out') show(el, !loggedIn);
160
- });
161
- linkShopifyPages(root);
162
- if (state.kind === 'unknown' && usesPages(root)) {
163
- (await pages()).showUnavailable(root);
164
- return;
165
- }
166
- if (!loggedIn) return;
167
- await bindCustomer(root);
168
- await Promise.all([
169
- renderOrderList(root, (first) => orders({ first })),
170
- usesPages(root) ? pages().then((part) => part.renderPages(root)) : null,
171
- ]);
172
- }
173
-
174
- /**
175
- * `tether-account-bind`: the customer's name, email, default address. A failure is reported and leaves the binds as
176
- * they are.
177
- */
178
- async function bindCustomer(root: ParentNode): Promise<void> {
179
- const binds = qsa(root, sel('account-bind'));
180
- if (!binds.length) return;
181
- let profile: AccountCustomer | null;
182
- try {
183
- profile = await customer();
184
- } catch (error) {
185
- reportError(error, 'account');
186
- return;
187
- }
188
- if (!profile) return;
189
- const texts: Record<string, string | null | undefined> = {
190
- 'first-name': profile.firstName,
191
- 'last-name': profile.lastName,
192
- name: profile.displayName,
193
- email: profile.emailAddress?.emailAddress,
194
- address: profile.defaultAddress?.formatted.join(', '),
195
- };
196
- binds.forEach((el) => {
197
- const text = ownValue(texts, get(el, 'account-bind') ?? '');
198
- if (text != null) setText(el, text);
199
- });
200
- }
201
-
202
- let pagesPart: Promise<Parts['account-pages']> | null = null;
203
-
204
- /** @returns The account pages part, sharing this part's session. */
205
- function pages(): Promise<Parts['account-pages']> {
206
- pagesPart ??= loadPart('account-pages').then((part) => {
207
- part.setup({ serverFetch, customer, bindCustomer, reset: forgetServerLogin });
208
- return part;
209
- });
210
- return pagesPart;
211
- }
212
-
213
- /** One of the customer's orders, in full (lines, totals, addresses, fulfillments, returns). */
214
- export const order = (id: string | number): Promise<OrderDetail | null> => pages().then((part) => part.order(id));
215
- export const updateProfile = (fields?: { firstName?: string; lastName?: string }): Promise<Profile> =>
216
- pages().then((part) => part.updateProfile(fields));
217
- export const addresses = (): Promise<CustomerAddress[]> => pages().then((part) => part.addresses());
218
- /** Creates an address, or updates the one with `id`. */
219
- export const saveAddress = (
220
- address: AddressFields | null,
221
- options?: { id?: string; isDefault?: boolean },
222
- ): Promise<AccountAddress> => pages().then((part) => part.saveAddress(address, options));
223
- export const deleteAddress = (id: string): Promise<string> => pages().then((part) => part.deleteAddress(id));
224
- export const subscriptions = (): Promise<SubscriptionContract[]> => pages().then((part) => part.subscriptions());
225
- export const subscriptionAction = (id: string | number, action: SubscriptionAction): Promise<SubscriptionChange> =>
226
- pages().then((part) => part.subscriptionAction(id, action));
227
- export const requestReturn = (orderId: string | number, lines: ReturnLine[]): Promise<OpenedReturn> =>
228
- pages().then((part) => part.requestReturn(orderId, lines));
229
-
230
- function onClick(event: MouseEvent): void {
231
- const target = event.target;
232
- if (!(target instanceof window.Element)) return;
233
- if (target.closest(sel('account-login'))) {
234
- event.preventDefault();
235
- login().catch((error: unknown) => {
236
- reportError(error, 'login');
237
- });
238
- } else if (target.closest(sel('account-logout'))) {
239
- event.preventDefault();
240
- logout().catch((error: unknown) => {
241
- reportError(error, 'logout');
242
- });
243
- }
244
- }
245
-
246
- /**
247
- * Back from the Worker's login (`?account=`): drops the mark and sends `tether:account-login`, or
248
- * `tether:account-error` with the outcome as its `error` when the login didn't finish.
249
- */
250
- function finishServerLogin(): void {
251
- const url = new URL(window.location.href);
252
- const outcome = url.searchParams.get('account');
253
- if (!isLoginOutcome(outcome)) return;
254
- url.searchParams.delete('account');
255
- window.history.replaceState(window.history.state, '', url);
256
- switch (outcome) {
257
- case 'in':
258
- emit('account-login', {});
259
- break;
260
- case 'expired':
261
- case 'error':
262
- emit('account-error', { error: outcome });
263
- break;
264
- }
265
- }
266
-
267
- let started = false;
268
-
269
- /**
270
- * Called by the core on mount: on the first one, finishes a login that's coming back and lets the Worker link the
271
- * cart at checkout; then fills the page.
272
- */
273
- export async function mountAccount(root: ParentNode = document): Promise<void> {
274
- if (started) {
275
- await render(root);
276
- return;
277
- }
278
- started = true;
279
- document.addEventListener('click', onClick);
280
- finishServerLogin();
281
- Cart.setCheckoutResolver(serverCheckoutUrl);
282
- await render(root);
283
- log('account', isLoggedIn() ? 'logged in' : 'logged out');
284
- }
package/src/amounts.ts DELETED
@@ -1,64 +0,0 @@
1
- /**
2
- * Arithmetic on Shopify's money amounts, which are decimal strings (`'27.50'`). Comparisons go digit by digit, as
3
- * integers of the smallest unit present, so two amounts that differ only past float precision aren't misjudged. Sums
4
- * and differences are taken in the currency's own smallest unit, so JPY stays whole and KWD keeps its three digits.
5
- */
6
- import type { Money } from './shopify-values.ts';
7
-
8
- const DECIMAL = /^(-?)(\d+)(?:\.(\d+))?$/;
9
-
10
- /**
11
- * @param fractionDigits Digits after the point to scale to.
12
- * @returns The amount in units of 10^-fractionDigits, `null` when it isn't a decimal number.
13
- */
14
- function toScaledInteger(amount: string, fractionDigits: number): bigint | null {
15
- const match = DECIMAL.exec(amount.trim());
16
- if (!match) return null;
17
- const [, sign = '', whole = '0', fraction = ''] = match;
18
- const digits = BigInt(`${whole}${fraction.padEnd(fractionDigits, '0')}`);
19
- return sign === '-' ? -digits : digits;
20
- }
21
-
22
- const fractionLength = (amount: string) => amount.split('.')[1]?.length ?? 0;
23
-
24
- /** @returns True when both are decimal numbers and `amount` is the larger one. */
25
- export function isGreaterAmount(amount: string | null | undefined, other: string | null | undefined): boolean {
26
- if (amount == null || other == null) return false;
27
- const fractionDigits = Math.max(fractionLength(amount.trim()), fractionLength(other.trim()));
28
- const scaled = toScaledInteger(amount, fractionDigits);
29
- const scaledOther = toScaledInteger(other, fractionDigits);
30
- if (scaled === null || scaledOther === null) return false;
31
- return scaled > scaledOther;
32
- }
33
-
34
- /** For a currency code Intl doesn't know: most currencies have two digits, and `formatMoney` then shows two too. */
35
- const DEFAULT_FRACTION_DIGITS = 2;
36
-
37
- /** Currency code → its fraction digits, which never change on a page. */
38
- const fractionDigitsByCurrency = new Map<string, number>();
39
-
40
- /** @returns The digits after the point the currency has: 0 for JPY, 2 for EUR, 3 for KWD. */
41
- function currencyFractionDigits(currencyCode: string): number {
42
- const known = fractionDigitsByCurrency.get(currencyCode);
43
- if (known !== undefined) return known;
44
- let digits = DEFAULT_FRACTION_DIGITS;
45
- try {
46
- const format = new Intl.NumberFormat('en', { style: 'currency', currency: currencyCode });
47
- digits = format.resolvedOptions().maximumFractionDigits ?? DEFAULT_FRACTION_DIGITS;
48
- } catch {
49
- // A code Intl doesn't know (RangeError): see DEFAULT_FRACTION_DIGITS.
50
- }
51
- fractionDigitsByCurrency.set(currencyCode, digits);
52
- return digits;
53
- }
54
-
55
- /** @returns The amount in the currency's smallest unit (cents, yen, fils), rounded to a whole one. */
56
- export function toMinorUnits(money: Money): number {
57
- return Math.round(parseFloat(money.amount) * 10 ** currencyFractionDigits(money.currencyCode));
58
- }
59
-
60
- /** @returns The amount written with the currency's own number of digits. */
61
- export function fromMinorUnits(minorUnits: number, currencyCode: string): Money {
62
- const digits = currencyFractionDigits(currencyCode);
63
- return { amount: (minorUnits / 10 ** digits).toFixed(digits), currencyCode };
64
- }
@@ -1,246 +0,0 @@
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.ts';
7
- import { gql, shaped } from './client.ts';
8
- import type { AnalyticsConfig } from './config.ts';
9
-
10
- export type EventPayload = Record<string, unknown>;
11
-
12
- interface TrackingValues {
13
- uniqueToken: string;
14
- visitToken: string;
15
- }
16
-
17
- interface DestinationContext {
18
- getTrackingValues: () => TrackingValues;
19
- }
20
-
21
- type Subscriber = (payload: EventPayload, context: DestinationContext) => void;
22
- type Subscribe = (event: string, subscriber: Subscriber) => () => void;
23
-
24
- interface BusConfig {
25
- shop: { shopId: string; channel: 'headless' };
26
- consent: { mode: string };
27
- }
28
-
29
- /** What shopify.js registers. */
30
- interface DestinationSpec {
31
- name: string;
32
- setup?: (api: { subscribe: Subscribe; getConfig: () => BusConfig }) => unknown;
33
- }
34
-
35
- /** Hydrogen's `window.Shopify.analytics`. */
36
- export interface AnalyticsBus {
37
- getConfig: () => BusConfig;
38
- publish: (event: string, payload?: EventPayload) => void;
39
- addDestination: (destination: DestinationSpec) => () => void;
40
- }
41
-
42
- type TokenReader = (options: { generateFallback: boolean; tag: string }) => string;
43
-
44
- /** The parts of Shopify's Customer Privacy API (`window.Shopify.customerPrivacy`) that decide tracking. */
45
- interface CustomerPrivacy {
46
- consentStatus?: string;
47
- currentVisitorConsent?: () => { analytics?: string } | undefined;
48
- analyticsProcessingAllowed?: () => boolean;
49
- __internal?: { uniqueToken?: TokenReader; visitToken?: TokenReader };
50
- config?: object;
51
- }
52
-
53
- /** `window.Shopify`, which Shopify's scripts read and extend. */
54
- interface ShopifyGlobal {
55
- analytics?: AnalyticsBus;
56
- customerPrivacy?: CustomerPrivacy;
57
- country?: string;
58
- locale?: string;
59
- shop?: string;
60
- routes?: object;
61
- }
62
-
63
- interface BufferedEvent {
64
- event: string;
65
- payload: EventPayload;
66
- }
67
-
68
- interface Destination {
69
- name: string;
70
- /** The index of the next buffered event it gets. */
71
- next: number;
72
- subscribers: Map<string, Set<Subscriber>>;
73
- context: DestinationContext;
74
- }
75
-
76
- /** Events kept for destinations that register late (shopify.js loads after the first page view). */
77
- const BUFFER_SIZE = 500;
78
- const CONSENT_SCRIPTS = {
79
- 'default-banner': 'https://cdn.shopify.com/shopifycloud/privacy-banner/storefront-banner.js',
80
- other: 'https://cdn.shopify.com/shopifycloud/consent-tracking-api/v0.2/consent-tracking-api.js',
81
- };
82
- const ANALYTICS_SCRIPT = 'https://cdn.shopify.com/storefront/analytics/shopify.js';
83
-
84
- /** The page, where Shopify's scripts keep `window.Shopify` in the shape they define. */
85
- const page = (): Window & { Shopify?: ShopifyGlobal } => window;
86
-
87
- const shopifyGlobal = (): ShopifyGlobal | undefined => page().Shopify;
88
-
89
- /** The `analytics.shopify` options; `true` sets none. */
90
- function shopifyOptions(): Exclude<NonNullable<AnalyticsConfig['shopify']>, boolean> {
91
- const shopify = cfg.analytics?.shopify;
92
- return typeof shopify === 'object' ? shopify : {};
93
- }
94
-
95
- /**
96
- * Shopify's privacy API can throw while its script is still setting up. An answer it can't give counts as "not
97
- * allowed", so nothing is sent without consent; the next consent event asks again.
98
- */
99
- function trackingAllowed(): boolean {
100
- try {
101
- const privacy = shopifyGlobal()?.customerPrivacy;
102
- return (
103
- privacy?.consentStatus === 'loaded' &&
104
- privacy.currentVisitorConsent?.()?.analytics !== 'no' &&
105
- Boolean(privacy.analyticsProcessingAllowed?.())
106
- );
107
- } catch {
108
- return false;
109
- }
110
- }
111
-
112
- /** @param name The destination's name, Hydrogen tags its tokens with it. */
113
- function destinationContext(name: string): DestinationContext {
114
- const tag = `hydrogen:${name}`;
115
- return {
116
- getTrackingValues: () => {
117
- const internal = trackingAllowed() ? shopifyGlobal()?.customerPrivacy?.__internal : undefined;
118
- return {
119
- uniqueToken: internal?.uniqueToken?.({ generateFallback: true, tag }) ?? '',
120
- visitToken: internal?.visitToken?.({ generateFallback: true, tag }) ?? '',
121
- };
122
- },
123
- };
124
- }
125
-
126
- const subscriberOf =
127
- (destination: Destination): Subscribe =>
128
- (event, subscriber) => {
129
- const subscribers = destination.subscribers.get(event) ?? new Set();
130
- destination.subscribers.set(event, subscribers);
131
- subscribers.add(subscriber);
132
- return () => destination.subscribers.get(event)?.delete(subscriber);
133
- };
134
-
135
- /** A bus that buffers events and delivers them to each destination once consent allows. */
136
- function createBus(shopId: string): AnalyticsBus {
137
- const destinations: Destination[] = [];
138
- const buffer: BufferedEvent[] = [];
139
- const config: BusConfig = {
140
- shop: { shopId, channel: 'headless' },
141
- consent: { mode: shopifyOptions().consent || 'no-banner' },
142
- };
143
- const deliver = () => {
144
- if (!trackingAllowed()) return;
145
- for (const destination of destinations) {
146
- for (; destination.next < buffer.length; destination.next += 1) {
147
- const buffered = buffer[destination.next];
148
- if (buffered) deliverTo(destination, buffered);
149
- }
150
- }
151
- };
152
- const bus: AnalyticsBus = {
153
- getConfig: () => config,
154
- publish(event, payload = {}) {
155
- buffer.push({ event, payload: { shop: config.shop, url: window.location.href, ...payload } });
156
- if (buffer.length > BUFFER_SIZE) {
157
- buffer.shift();
158
- for (const destination of destinations) destination.next = Math.max(0, destination.next - 1);
159
- }
160
- deliver();
161
- },
162
- addDestination(spec) {
163
- const destination: Destination = {
164
- name: spec.name,
165
- next: 0,
166
- subscribers: new Map(),
167
- context: destinationContext(spec.name),
168
- };
169
- Promise.resolve(spec.setup?.({ subscribe: subscriberOf(destination), getConfig: bus.getConfig }))
170
- .then(() => {
171
- destinations.push(destination);
172
- deliver();
173
- })
174
- .catch((error: unknown) => {
175
- reportError(error, `analytics ${spec.name}`);
176
- });
177
- return () => {
178
- const index = destinations.indexOf(destination);
179
- if (index >= 0) destinations.splice(index, 1);
180
- };
181
- },
182
- };
183
- document.addEventListener('consentTrackingApiLoaded', deliver);
184
- document.addEventListener('visitorConsentCollected', deliver);
185
- return bus;
186
- }
187
-
188
- function deliverTo(destination: Destination, { event, payload }: BufferedEvent): void {
189
- destination.subscribers.get(event)?.forEach((subscriber) => {
190
- try {
191
- subscriber(payload, destination.context);
192
- } catch (error) {
193
- reportError(error, `analytics ${destination.name}`);
194
- }
195
- });
196
- }
197
-
198
- function loadScript(src: string): void {
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 isProductPage Asked by Shopify's script for the page template.
209
- * @returns The bus, the page's own when a theme already set one up.
210
- */
211
- export async function startShopifyAnalytics(isProductPage: () => boolean): Promise<AnalyticsBus> {
212
- const existing = shopifyGlobal();
213
- if (existing?.analytics) return existing.analytics;
214
- const data = await gql(
215
- 'query TetherShopId { shop { id } }',
216
- {},
217
- shaped<{ shop: { id: string } }>({ shop: { id: 'string' } }),
218
- { context: false },
219
- );
220
- const shopify = shopifyGlobal() ?? {};
221
- page().Shopify = shopify;
222
- shopify.country = cfg.country ?? 'US';
223
- // Shopify's script requires a language, not a full locale.
224
- const [language = 'en'] = (cfg.locale || 'en').split('-');
225
- shopify.locale = language.toLowerCase();
226
- shopify.shop = cfg.domain;
227
- shopify.routes = {
228
- root: '/',
229
- match: () => ({ pageTemplateName: isProductPage() ? 'product' : 'page' }),
230
- ...shopify.routes,
231
- };
232
- // A same-origin Storefront API path gives Shopify exact visitor counts.
233
- const { proxy, consent } = shopifyOptions();
234
- shopify.customerPrivacy = {
235
- ...shopify.customerPrivacy,
236
- config: proxy
237
- ? { isHeadless: true, asyncConsent: true, asyncVisitorState: true, consentDomain: window.location.host }
238
- : { isHeadless: true, storefrontAccessToken: cfg.token, checkoutRootDomain: cfg.domain },
239
- };
240
- const bus = createBus(data.shop.id);
241
- shopify.analytics = bus;
242
- loadScript(consent === 'default-banner' ? CONSENT_SCRIPTS['default-banner'] : CONSENT_SCRIPTS.other);
243
- loadScript(ANALYTICS_SCRIPT);
244
- log('shopify analytics ready', data.shop.id);
245
- return bus;
246
- }