@occluze/tether 0.6.7 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (250) hide show
  1. package/README.md +11 -11
  2. package/dist/esm/astro.js +2 -2
  3. package/dist/esm/chunks/account-UIPWJQ3Y.js +1 -0
  4. package/dist/esm/chunks/account-pages-6ROA6Y4V.js +1 -0
  5. package/dist/esm/chunks/analytics-2V632IRV.js +1 -0
  6. package/dist/esm/chunks/chunk-4GCXM764.js +1 -0
  7. package/dist/esm/chunks/chunk-56FCLJJD.js +1 -0
  8. package/dist/esm/chunks/chunk-5Z5O3767.js +1 -0
  9. package/dist/esm/chunks/chunk-6OZJCAGZ.js +1 -0
  10. package/dist/esm/chunks/chunk-A5SYCVQM.js +1 -0
  11. package/dist/esm/chunks/chunk-BA4LQJJQ.js +1 -0
  12. package/dist/esm/chunks/chunk-BFTZR4OB.js +4 -0
  13. package/dist/esm/chunks/chunk-C5JYQCWH.js +1 -0
  14. package/dist/esm/chunks/chunk-E5YO6274.js +1 -0
  15. package/dist/esm/chunks/chunk-MPDWM2NN.js +1 -0
  16. package/dist/esm/chunks/chunk-T44MUL4Y.js +1 -0
  17. package/dist/esm/chunks/chunk-YLUULATY.js +1 -0
  18. package/dist/esm/chunks/diagnose-3FEKRIEK.js +14 -0
  19. package/dist/esm/chunks/extras-6POFBNOP.js +1 -0
  20. package/dist/esm/chunks/markets-ui-FD6NI2OG.js +1 -0
  21. package/dist/esm/chunks/search-UJ73YBQ5.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 +55 -39
  25. package/dist/tether.account-pages.js +781 -495
  26. package/dist/tether.account-pages.min.js +1 -1
  27. package/dist/tether.account.js +462 -381
  28. package/dist/tether.account.min.js +1 -1
  29. package/dist/tether.analytics.js +299 -194
  30. package/dist/tether.analytics.min.js +1 -1
  31. package/dist/tether.diagnose.js +354 -262
  32. package/dist/tether.diagnose.min.js +2 -2
  33. package/dist/tether.extras.js +82 -45
  34. package/dist/tether.extras.min.js +1 -1
  35. package/dist/tether.js +3653 -2116
  36. package/dist/tether.markets.js +238 -150
  37. package/dist/tether.markets.min.js +1 -1
  38. package/dist/tether.min.js +3 -3
  39. package/dist/tether.min.js.map +4 -4
  40. package/dist/tether.search.js +207 -136
  41. package/dist/tether.search.min.js +1 -1
  42. package/dist/types/server/src/shared/account-orders.d.ts +44 -0
  43. package/dist/types/server/src/shared/account-routes.d.ts +153 -0
  44. package/dist/types/server/src/shared/api-versions.d.ts +13 -0
  45. package/dist/types/server/src/shared/error-message.d.ts +2 -0
  46. package/dist/types/server/src/shared/json.d.ts +25 -0
  47. package/dist/types/server/src/shared/market-codes.d.ts +16 -0
  48. package/dist/types/src/account/addresses-view.d.ts +5 -0
  49. package/dist/types/src/account/answers.d.ts +40 -0
  50. package/dist/types/src/account/markup.d.ts +19 -0
  51. package/dist/types/src/account/operations.d.ts +41 -0
  52. package/dist/types/src/account/order-lines.d.ts +13 -0
  53. package/dist/types/src/account/order-list.d.ts +12 -0
  54. package/dist/types/src/account/order-page.d.ts +6 -0
  55. package/dist/types/src/account/page-answers.d.ts +182 -0
  56. package/dist/types/src/account/page-events.d.ts +2 -0
  57. package/dist/types/src/account/server-client.d.ts +60 -0
  58. package/dist/types/src/account/subscriptions-view.d.ts +2 -0
  59. package/dist/types/src/account-api.d.ts +18 -0
  60. package/dist/types/src/account-marks.d.ts +15 -0
  61. package/dist/types/src/account-pages.d.ts +8 -0
  62. package/dist/types/src/account.d.ts +64 -0
  63. package/dist/types/src/amounts.d.ts +12 -0
  64. package/dist/types/src/analytics-shopify.d.ts +41 -0
  65. package/dist/types/src/analytics.d.ts +2 -0
  66. package/dist/types/src/api-version.d.ts +28 -0
  67. package/dist/types/src/astro.d.ts +42 -0
  68. package/dist/types/src/cart/cart-shape.d.ts +43 -0
  69. package/dist/types/src/cart/checkout.d.ts +16 -0
  70. package/dist/types/src/cart/drawer.d.ts +18 -0
  71. package/dist/types/src/cart/mutations.d.ts +79 -0
  72. package/dist/types/src/cart/render.d.ts +17 -0
  73. package/dist/types/src/cart/store.d.ts +182 -0
  74. package/dist/types/src/cart.d.ts +106 -0
  75. package/dist/types/src/client.d.ts +100 -0
  76. package/dist/types/src/collection.d.ts +25 -0
  77. package/dist/types/src/config-sections.d.ts +40 -0
  78. package/dist/types/src/config.d.ts +185 -0
  79. package/dist/types/src/controls/cart-controls.d.ts +2 -0
  80. package/dist/types/src/controls/discount.d.ts +2 -0
  81. package/dist/types/src/controls/extras.d.ts +5 -0
  82. package/dist/types/src/controls/navigation.d.ts +12 -0
  83. package/dist/types/src/diagnose-markup.d.ts +19 -0
  84. package/dist/types/src/diagnose.d.ts +60 -0
  85. package/dist/types/src/dom.d.ts +75 -0
  86. package/dist/types/src/extras.d.ts +21 -0
  87. package/dist/types/src/fragments.d.ts +158 -0
  88. package/dist/types/src/globals.d.ts +15 -0
  89. package/dist/types/src/images.d.ts +20 -0
  90. package/dist/types/src/index.d.ts +167 -0
  91. package/dist/types/src/initial.d.ts +56 -0
  92. package/dist/types/src/lazy.d.ts +28 -0
  93. package/dist/types/src/loader.d.ts +84 -0
  94. package/dist/types/src/market-codes.d.ts +6 -0
  95. package/dist/types/src/markets-ui.d.ts +32 -0
  96. package/dist/types/src/markets.d.ts +72 -0
  97. package/dist/types/src/markup.d.ts +131 -0
  98. package/dist/types/src/mounting.d.ts +46 -0
  99. package/dist/types/src/page-market.d.ts +27 -0
  100. package/dist/types/src/presets/barba.d.ts +56 -0
  101. package/dist/types/src/product-events.d.ts +2 -0
  102. package/dist/types/src/product-render.d.ts +17 -0
  103. package/dist/types/src/product-shape.d.ts +88 -0
  104. package/dist/types/src/product-state.d.ts +110 -0
  105. package/dist/types/src/product.d.ts +66 -0
  106. package/dist/types/src/repeaters.d.ts +16 -0
  107. package/dist/types/src/search.d.ts +33 -0
  108. package/dist/types/src/selling-plans.d.ts +64 -0
  109. package/dist/types/src/server.d.ts +88 -0
  110. package/dist/types/src/shopify-values.d.ts +46 -0
  111. package/dist/types/src/status.d.ts +33 -0
  112. package/dist/types/src/storage.d.ts +12 -0
  113. package/dist/types/src/store-html.d.ts +39 -0
  114. package/dist/types/src/storefront/catalog.d.ts +38 -0
  115. package/dist/types/src/storefront/json-ld.d.ts +20 -0
  116. package/dist/types/src/variant-render.d.ts +14 -0
  117. package/package.json +42 -22
  118. package/src/account/addresses-view.ts +58 -0
  119. package/src/account/answers.ts +79 -0
  120. package/src/account/markup.ts +36 -0
  121. package/src/account/operations.ts +145 -0
  122. package/src/account/order-lines.ts +75 -0
  123. package/src/account/order-list.ts +107 -0
  124. package/src/account/order-page.ts +137 -0
  125. package/src/account/page-answers.ts +317 -0
  126. package/src/account/page-events.ts +253 -0
  127. package/src/account/server-client.ts +183 -0
  128. package/src/account/subscriptions-view.ts +115 -0
  129. package/src/account-api.ts +98 -0
  130. package/src/account-marks.ts +35 -0
  131. package/src/account-pages.ts +81 -0
  132. package/src/account.ts +284 -0
  133. package/src/amounts.ts +64 -0
  134. package/src/analytics-shopify.ts +246 -0
  135. package/src/analytics.ts +315 -0
  136. package/src/api-version.ts +79 -0
  137. package/src/astro.ts +272 -0
  138. package/src/cart/cart-shape.ts +25 -0
  139. package/src/cart/checkout.ts +101 -0
  140. package/src/cart/drawer.ts +125 -0
  141. package/src/cart/mutations.ts +237 -0
  142. package/src/cart/render.ts +266 -0
  143. package/src/cart/store.ts +279 -0
  144. package/src/cart.ts +345 -0
  145. package/src/client.ts +391 -0
  146. package/src/collection.ts +175 -0
  147. package/src/config-sections.ts +284 -0
  148. package/src/config.ts +480 -0
  149. package/src/controls/cart-controls.ts +123 -0
  150. package/src/controls/discount.ts +75 -0
  151. package/src/controls/extras.ts +54 -0
  152. package/src/controls/navigation.ts +77 -0
  153. package/src/diagnose-markup.ts +298 -0
  154. package/src/diagnose.ts +294 -0
  155. package/src/dom.ts +268 -0
  156. package/src/extras.ts +136 -0
  157. package/src/fragments.ts +261 -0
  158. package/src/globals.d.ts +15 -0
  159. package/src/images.ts +91 -0
  160. package/src/index.ts +415 -0
  161. package/src/initial.ts +101 -0
  162. package/src/lazy-iife.ts +197 -0
  163. package/src/lazy.ts +42 -0
  164. package/src/loader.ts +363 -0
  165. package/src/market-codes.ts +6 -0
  166. package/src/markets-ui.ts +227 -0
  167. package/src/markets.ts +246 -0
  168. package/src/markup.ts +127 -0
  169. package/src/mounting.ts +264 -0
  170. package/src/page-market.ts +110 -0
  171. package/src/part-integrity.ts +7 -0
  172. package/src/parts/account-pages.ts +7 -0
  173. package/src/parts/account.ts +7 -0
  174. package/src/parts/analytics.ts +7 -0
  175. package/src/parts/diagnose.ts +7 -0
  176. package/src/parts/extras.ts +7 -0
  177. package/src/parts/markets.ts +7 -0
  178. package/src/parts/search.ts +7 -0
  179. package/src/presets/barba.ts +194 -0
  180. package/src/product-events.ts +115 -0
  181. package/src/product-render.ts +218 -0
  182. package/src/product-shape.ts +49 -0
  183. package/src/product-state.ts +318 -0
  184. package/src/product.ts +308 -0
  185. package/src/repeaters.ts +161 -0
  186. package/src/search.ts +305 -0
  187. package/src/selling-plans.ts +145 -0
  188. package/src/server.ts +211 -0
  189. package/src/shopify-values.ts +91 -0
  190. package/src/status.ts +106 -0
  191. package/src/storage.ts +34 -0
  192. package/src/store-html.ts +359 -0
  193. package/src/storefront/catalog.ts +196 -0
  194. package/src/storefront/json-ld.ts +144 -0
  195. package/src/variant-render.ts +139 -0
  196. package/dist/esm/chunks/account-Q6E2TFEV.js +0 -1
  197. package/dist/esm/chunks/account-pages-UZXR4R5F.js +0 -1
  198. package/dist/esm/chunks/analytics-IJBKATUP.js +0 -1
  199. package/dist/esm/chunks/chunk-4QJKLPYR.js +0 -1
  200. package/dist/esm/chunks/chunk-64TUD3QR.js +0 -1
  201. package/dist/esm/chunks/chunk-AJLPU2JC.js +0 -4
  202. package/dist/esm/chunks/chunk-FIOZPPC5.js +0 -1
  203. package/dist/esm/chunks/chunk-K2YEQAIH.js +0 -1
  204. package/dist/esm/chunks/chunk-KIDH76B7.js +0 -1
  205. package/dist/esm/chunks/chunk-PAYT5ZD7.js +0 -1
  206. package/dist/esm/chunks/chunk-RREHGH4A.js +0 -1
  207. package/dist/esm/chunks/chunk-SSJT43CJ.js +0 -1
  208. package/dist/esm/chunks/chunk-VC46IEJQ.js +0 -1
  209. package/dist/esm/chunks/diagnose-3WVAWNTX.js +0 -14
  210. package/dist/esm/chunks/extras-7I7WJX35.js +0 -1
  211. package/dist/esm/chunks/markets-ui-545XLFTE.js +0 -1
  212. package/dist/esm/chunks/search-NILMGYXG.js +0 -1
  213. package/src/account-pages.js +0 -318
  214. package/src/account.js +0 -385
  215. package/src/analytics.js +0 -232
  216. package/src/api-version.js +0 -35
  217. package/src/astro.js +0 -101
  218. package/src/cart.js +0 -559
  219. package/src/client.js +0 -131
  220. package/src/collection.js +0 -114
  221. package/src/config.js +0 -85
  222. package/src/diagnose.js +0 -246
  223. package/src/dom.js +0 -288
  224. package/src/extras.js +0 -63
  225. package/src/fragments.js +0 -144
  226. package/src/index.js +0 -395
  227. package/src/initial.js +0 -75
  228. package/src/lazy-iife.js +0 -46
  229. package/src/lazy.js +0 -18
  230. package/src/loader.js +0 -198
  231. package/src/markets-ui.js +0 -90
  232. package/src/markets.js +0 -133
  233. package/src/mounting.js +0 -154
  234. package/src/parts/account-pages.js +0 -3
  235. package/src/parts/account.js +0 -3
  236. package/src/parts/analytics.js +0 -3
  237. package/src/parts/diagnose.js +0 -3
  238. package/src/parts/extras.js +0 -3
  239. package/src/parts/markets.js +0 -3
  240. package/src/parts/search.js +0 -3
  241. package/src/presets/barba.js +0 -100
  242. package/src/product.js +0 -570
  243. package/src/search.js +0 -153
  244. package/src/selling-plans.js +0 -94
  245. package/src/server.js +0 -252
  246. package/src/status.js +0 -66
  247. package/types/astro.d.ts +0 -17
  248. package/types/barba.d.ts +0 -16
  249. package/types/index.d.ts +0 -426
  250. package/types/server.d.ts +0 -110
package/src/account.js DELETED
@@ -1,385 +0,0 @@
1
- /**
2
- * Customer accounts (Shopify Customer Account API), as an optional part: loaded only when
3
- * config.account is set and the page uses it (or a login is in progress / the visitor is logged in).
4
- *
5
- * window.TETHER_CONFIG = { …, account: { clientId: '…' } } // Headless channel → Customer Account API
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
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> ·
12
- * <span tether-order-bind="total"></span> · <span tether-order-bind="status"></span></a>
13
- * </div>
14
- * <p tether-show="logged-out">…</p>
15
- *
16
- * Everything runs between the visitor's browser and Shopify: login on Shopify's own page (OAuth with
17
- * PKCE), tokens kept in this browser (window.sessionStorage; account.remember: true for window.localStorage), the
18
- * cart linked to the customer so checkout knows them. No other server sees the customer's data.
19
- * Shopify needs HTTPS (no localhost: use a tunnel) and the site's origin, callback and logout URLs
20
- * registered in the Headless channel's Customer Account API settings (client type: Public).
21
- */
22
- import { cfg, log, reportError } from './config.js';
23
- import { TetherError } from './client.js';
24
- import { attr, sel, get, qsa, setText, setImage, repeat, show, formatMoney, emit } from './dom.js';
25
- import { Cart } from './cart.js';
26
- import { loadPart } from './lazy.js';
27
-
28
- const KEY = () => `${cfg.prefix}:account:${cfg.domain}`;
29
- const PENDING = () => `${cfg.prefix}:account-login:${cfg.domain}`;
30
- const store = () => (cfg.account?.remember ? window.localStorage : window.sessionStorage);
31
- const b64url = (bytes) => btoa(String.fromCharCode(...new Uint8Array(bytes))).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
32
- const random = () => b64url(crypto.getRandomValues(new Uint8Array(32)));
33
- const sha256 = async (s) => b64url(await crypto.subtle.digest('SHA-256', new TextEncoder().encode(s)));
34
- const go = (url) => (cfg.account?.navigate || ((u) => window.location.assign(u)))(url); // overridable (popup, tests)
35
- const read = (s, k) => { try { return JSON.parse(s.getItem(k) || 'null'); } catch { return null; } };
36
- const write = (s, k, v) => { try { if (v) s.setItem(k, JSON.stringify(v)); else s.removeItem(k); } catch { /* private mode */ } };
37
-
38
- let discovery = null;
39
- let session = null; // { access, expiresAt, refresh, idToken }
40
- let refreshing = null;
41
- let customerCache = null;
42
-
43
- // Server mode (account.server): the Worker holds the tokens, this browser only a session cookie it
44
- // can't read. Recommended for live sites.
45
- // A full URL or a path on this site ('/_tether/account').
46
- const server = () => (cfg.account?.server ? new URL(String(cfg.account.server), window.location.href).href.replace(/\/$/, '') : '');
47
- const HINT = () => `${cfg.prefix}:account-hint:${cfg.domain}`; // "may be logged in": loads this part, no secret
48
- let serverState = null; // { loggedIn, customer }
49
- async function serverFetch(path, { method = 'GET', body } = {}) {
50
- const res = await fetch(`${server()}${path}`, {
51
- method, credentials: 'include',
52
- headers: method === 'POST' ? { 'Content-Type': 'application/json', 'X-Tether-Account': '1' } : {},
53
- body: body ? JSON.stringify(body) : undefined,
54
- });
55
- const data = await res.json().catch(() => ({}));
56
- if (!res.ok && res.status !== 401) throw new TetherError(`Customer accounts: ${data.error || res.status}`, { status: res.status, userErrors: data.userErrors });
57
- return data;
58
- }
59
- async function serverMe() {
60
- serverState ||= await serverFetch('/me');
61
- try { if (serverState.loggedIn) window.localStorage.setItem(HINT(), '1'); else window.localStorage.removeItem(HINT()); } catch { /* private mode */ }
62
- return serverState;
63
- }
64
-
65
- /** Shopify's endpoints for this shop (they differ for shops with their own accounts domain). */
66
- async function discover() {
67
- if (discovery) return discovery;
68
- const shop = cfg.account?.shopDomain || cfg.domain;
69
- const get = (p) => fetch(`https://${shop}/.well-known/${p}`).then((r) => {
70
- if (!r.ok) throw new TetherError(`Customer accounts: ${p} answered ${r.status}`, { status: r.status });
71
- return r.json();
72
- });
73
- const [oidc, api] = await Promise.all([get('openid-configuration'), get('customer-account-api')]);
74
- discovery = { ...oidc, graphql: api.graphql_api };
75
- return discovery;
76
- }
77
-
78
- const callbackUrl = () => cfg.account?.callbackUrl || `${window.location.origin}${window.location.pathname}`;
79
-
80
- export function isLoggedIn() { return server() ? Boolean(serverState?.loggedIn) : Boolean(session?.refresh || (session?.access && session.expiresAt > Date.now())); }
81
-
82
- /** Redirects to Shopify's login page; the visitor comes back to account.callbackUrl (default: this page). */
83
- export async function login({ returnTo = window.location.href, locale } = {}) {
84
- if (server()) {
85
- const u = new URL(`${server()}/login`);
86
- u.searchParams.set('returnTo', returnTo); // the cart is linked later, through the Worker's protected POST /cart
87
- const lang = locale || cfg.account.locale || (cfg.locale || '').split('-')[0];
88
- if (lang) u.searchParams.set('locale', lang);
89
- if (cfg.country) u.searchParams.set('country', String(cfg.country).toUpperCase());
90
- go(u.toString());
91
- return;
92
- }
93
- if (!cfg.account?.clientId) throw new TetherError('Customer accounts: set account.clientId', { code: 'NOT_CONFIGURED' });
94
- const d = await discover();
95
- const state = random();
96
- const nonce = random();
97
- const verifier = random();
98
- const redirect = callbackUrl();
99
- write(window.sessionStorage, PENDING(), { state, nonce, verifier, redirect, returnTo, at: Date.now() });
100
- const u = new URL(d.authorization_endpoint);
101
- const p = {
102
- client_id: cfg.account.clientId, scope: 'openid email customer-account-api:full', response_type: 'code',
103
- redirect_uri: redirect, state, nonce, code_challenge: await sha256(verifier), code_challenge_method: 'S256',
104
- };
105
- const lang = locale || cfg.account.locale || (cfg.locale || '').split('-')[0];
106
- if (lang) p.locale = lang;
107
- if (cfg.country) p.region_country = String(cfg.country).toUpperCase();
108
- Object.entries(p).forEach(([k, v]) => u.searchParams.set(k, v));
109
- go(u.toString());
110
- }
111
-
112
- /** Token endpoint: from the browser (a "simple" request), or account.tokenUrl on your own server. */
113
- async function tokenRequest(params) {
114
- const d = await discover();
115
- const res = await fetch(cfg.account.tokenUrl || d.token_endpoint, { method: 'POST', body: new URLSearchParams(params) });
116
- const data = await res.json().catch(() => ({}));
117
- if (!res.ok || !data.access_token) throw new TetherError(`Customer accounts: token ${res.status} ${data.error || ''}`.trim(), { status: res.status, code: res.status === 400 || res.status === 401 ? 'SESSION_ENDED' : 'TOKEN_FAILED' });
118
- return data;
119
- }
120
-
121
- function decodeJwt(t) {
122
- try { return JSON.parse(atob(t.split('.')[1].replace(/-/g, '+').replace(/_/g, '/'))); } catch { return null; }
123
- }
124
-
125
- function keep(data, previous = session) {
126
- session = {
127
- access: data.access_token,
128
- expiresAt: Date.now() + Math.max(0, (Number(data.expires_in) || 3600) - 120) * 1000,
129
- refresh: data.refresh_token || previous?.refresh,
130
- idToken: data.id_token || previous?.idToken,
131
- };
132
- write(store(), KEY(), session);
133
- }
134
-
135
- /** On the callback page: ?code=&state= from Shopify. Returns true when a login finished. */
136
- export async function handleCallback() {
137
- const q = new URLSearchParams(window.location.search);
138
- const pending = read(window.sessionStorage, PENDING());
139
- if (!pending || (!q.has('code') && !q.has('error')) || q.get('state') !== pending.state) return false;
140
- write(window.sessionStorage, PENDING(), null);
141
- const clean = new URL(window.location.href);
142
- ['code', 'state', 'iss', 'error', 'error_description'].forEach((k) => clean.searchParams.delete(k));
143
- window.history.replaceState(window.history.state, '', clean);
144
- if (q.has('error')) { emit('account-error', { error: q.get('error') }); return false; }
145
- if (Date.now() - pending.at > 10 * 60000) throw new TetherError('Customer accounts: the login took too long, please try again');
146
- const data = await tokenRequest({
147
- grant_type: 'authorization_code', client_id: cfg.account.clientId, redirect_uri: pending.redirect,
148
- code: q.get('code'), code_verifier: pending.verifier,
149
- });
150
- const d = await discover();
151
- const claims = decodeJwt(data.id_token || '');
152
- const aud = [].concat(claims?.aud || []);
153
- if (!claims || claims.nonce !== pending.nonce || claims.iss !== d.issuer || !aud.includes(cfg.account.clientId) || claims.exp * 1000 < Date.now()) {
154
- throw new TetherError('Customer accounts: the login answer did not check out (nonce/issuer/audience)');
155
- }
156
- keep(data, null);
157
- await linkCart();
158
- emit('account-login', {});
159
- const back = pending.returnTo && new URL(pending.returnTo, window.location.href);
160
- if (back && back.origin === window.location.origin && back.href !== clean.href) go(back.href);
161
- return true;
162
- }
163
-
164
- /** A valid access token (refreshed when needed, one refresh at a time), or null when logged out. */
165
- export async function getToken() {
166
- if (server()) return null; // server mode: tokens never come to the browser
167
- if (!session) session = read(store(), KEY());
168
- if (!session) return null;
169
- if (session.access && session.expiresAt > Date.now()) return session.access;
170
- if (!session.refresh) { endSession(); return null; }
171
- refreshing ||= (async () => {
172
- try {
173
- keep(await tokenRequest({ grant_type: 'refresh_token', client_id: cfg.account.clientId, refresh_token: session.refresh }));
174
- await linkCart();
175
- } catch (err) {
176
- if (err.code === 'SESSION_ENDED') endSession(); else throw err;
177
- } finally { refreshing = null; }
178
- })();
179
- await refreshing;
180
- return session?.access || null;
181
- }
182
-
183
- function endSession() {
184
- session = null;
185
- customerCache = null;
186
- write(store(), KEY(), null);
187
- Cart.customerAccessToken = null;
188
- }
189
-
190
- /** The customer's checkout is theirs: saved addresses, vaulted cards, order window.history. */
191
- async function linkCart() {
192
- Cart.customerAccessToken = session?.access || null;
193
- if (Cart.id() && session?.access) {
194
- await Cart.setBuyerIdentity({ customerAccessToken: session.access }, { create: false }).catch((err) => reportError(err, 'account cart'));
195
- }
196
- }
197
-
198
- /** Any Customer Account API query. */
199
- export async function query(q, variables = {}) {
200
- if (server()) throw new TetherError('Customer accounts in server mode only answer customer() and orders()', { code: 'NOT_ALLOWED' });
201
- const token = await getToken();
202
- if (!token) throw new TetherError('Not logged in', { code: 'NOT_LOGGED_IN' });
203
- const d = await discover();
204
- const run = (t) => fetch(d.graphql, { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: t }, body: JSON.stringify({ query: q, variables }) });
205
- let res = await run(token);
206
- if (res.status === 401 && session?.refresh) { session.expiresAt = 0; res = await run(await getToken()); }
207
- const json = await res.json().catch(() => ({}));
208
- if (!res.ok || json.errors?.length) throw new TetherError(json.errors?.map((e) => e.message).join(' | ') || `Customer accounts: ${res.status}`, { status: res.status });
209
- return json.data;
210
- }
211
-
212
- export async function customer() {
213
- if (server()) {
214
- const me = await serverMe();
215
- return me.loggedIn ? { ...me.customer, emailAddress: { emailAddress: me.customer.email }, defaultAddress: { formatted: me.customer.address } } : null;
216
- }
217
- if (customerCache) return customerCache;
218
- const data = await query(`query TetherCustomer { customer { id firstName lastName displayName emailAddress { emailAddress } defaultAddress { formatted } } }`);
219
- customerCache = data.customer;
220
- return customerCache;
221
- }
222
-
223
- export async function orders({ first = 10 } = {}) {
224
- if (server()) return (await serverFetch(`/orders?first=${first}`)).orders || [];
225
- const data = await query(`query TetherOrders($first: Int!) { customer { orders(first: $first, sortKey: PROCESSED_AT, reverse: true) {
226
- nodes { id name processedAt financialStatus fulfillmentStatus statusPageUrl totalPrice { amount currencyCode }
227
- lineItems(first: 20) { nodes { title variantTitle quantity image { url altText } } } } } } }`, { first });
228
- return data.customer?.orders?.nodes || [];
229
- }
230
-
231
-
232
- /** Logs out here and at Shopify, unlinks the cart. */
233
- export async function logout({ returnTo = cfg.account?.logoutUrl || `${window.location.origin}/` } = {}) {
234
- if (server()) {
235
- const r = await serverFetch('/logout', { method: 'POST', body: { returnTo, cartId: Cart.id() } });
236
- serverState = { loggedIn: false };
237
- try { window.localStorage.removeItem(HINT()); } catch { /* private mode */ }
238
- emit('account-logout', {});
239
- if (r.logoutUrl) go(r.logoutUrl); else render(document);
240
- return;
241
- }
242
- const idToken = session?.idToken || read(store(), KEY())?.idToken;
243
- if (Cart.id()) await Cart.setBuyerIdentity({ customerAccessToken: null }, { create: false }).catch(() => {});
244
- endSession();
245
- emit('account-logout', {});
246
- if (!idToken) { render(document); return; }
247
- const d = await discover();
248
- const u = new URL(d.end_session_endpoint);
249
- u.searchParams.set('id_token_hint', idToken);
250
- u.searchParams.set('post_logout_redirect_uri', returnTo);
251
- go(u.toString());
252
- }
253
-
254
- // ---------------------------------------------------------------- markup
255
-
256
- const https = (u) => { try { return new URL(u).protocol === 'https:'; } catch { return false; } }; // links from Shopify: https only
257
- const date = (s) => { try { return new Date(s).toLocaleDateString(cfg.locale || undefined); } catch { return s; } };
258
-
259
- export async function render(root = document) {
260
- const logged = server() ? Boolean((await serverMe().catch(() => ({ loggedIn: false }))).loggedIn) : isLoggedIn() || Boolean(await getToken().catch(() => null));
261
- qsa(root, sel('show')).forEach((el) => {
262
- const k = get(el, 'show');
263
- if (k === 'logged-in') show(el, logged);
264
- if (k === 'logged-out') show(el, !logged);
265
- });
266
- // Shopify's own pages, for what has to stay there: tether-account-link="subscriptions" / "payment"
267
- // (account.subscriptionsUrl / paymentUrl), or the account itself.
268
- qsa(root, sel('account-link')).forEach((a) => {
269
- const k = get(a, 'account-link');
270
- const u = (k === 'payment' && (cfg.account?.paymentUrl || cfg.account?.subscriptionsUrl)) || (k === 'subscriptions' && cfg.account?.subscriptionsUrl);
271
- a.setAttribute('href', u || `https://${cfg.domain}/account`);
272
- });
273
- if (!logged) return;
274
- await bindCustomer(root);
275
- // The order list and the account pages (order, addresses, subscriptions) load side by side.
276
- await Promise.all([renderOrderList(root), PAGE_ATTRS.some((n) => qsa(root, sel(n)).length) ? pages().then((m) => m.renderPages(root)) : null]);
277
- }
278
-
279
- async function renderOrderList(root) {
280
- const lists = qsa(root, sel('orders'));
281
- if (lists.length) {
282
- // tether-show="orders-loading" / "orders-empty" (no orders yet).
283
- const ordersShow = (loading, empty) => qsa(root, sel('show')).forEach((el) => {
284
- const k = get(el, 'show');
285
- if (k === 'orders-loading') show(el, loading);
286
- if (k === 'orders-empty') show(el, empty);
287
- });
288
- ordersShow(true, false);
289
- let failed = false;
290
- const list = await orders({ first: parseInt(get(lists[0], 'limit') || '10', 10) }).catch((err) => { reportError(err, 'orders'); failed = true; return []; });
291
- ordersShow(false, !failed && list.length === 0);
292
- lists.forEach((c) => repeat(c, list, (node, o) => {
293
- const items = o.lineItems?.nodes || [];
294
- qsa(node, sel('order-bind')).forEach((el) => {
295
- const k = get(el, 'order-bind');
296
- if (k === 'url') { const u = orderUrl(o); if (u) el.setAttribute('href', u); return; }
297
- if (k === 'image') { const im = items.find((l) => l.image)?.image; if (im) setImage(el, im.url, im.altText || items[0]?.title || ''); return; }
298
- const v = { name: o.name, date: date(o.processedAt), total: formatMoney(o.totalPrice), status: o.financialStatus?.toLowerCase(), fulfillment: o.fulfillmentStatus?.toLowerCase(), items: items.map((l) => `${l.quantity}× ${l.title}`).join(', ') }[k];
299
- if (v != null) setText(el, v);
300
- });
301
- // Per product of the order: <div tether-order-lines><div><img tether-order-line="image"> <span tether-order-line="title"></span></div></div>
302
- qsa(node, sel('order-lines')).forEach((box) => fillLines(box, items));
303
- if (get(node, 'order-bind') === 'url' && orderUrl(o)) node.setAttribute('href', orderUrl(o));
304
- }));
305
- }
306
- }
307
-
308
-
309
- const nid = (g) => String(g || '').split('/').pop().split('?')[0];
310
- /** The order's page: your own (account.orderUrl: '/account/order?id={id}') or Shopify's status page. */
311
- function orderUrl(o) {
312
- const t = cfg.account?.orderUrl;
313
- if (t) return String(t).replace('{id}', encodeURIComponent(nid(o.id)));
314
- return https(o.statusPageUrl) ? o.statusPageUrl : null;
315
- }
316
- function fillLines(box, items) {
317
- repeat(box, items, (row, l) => {
318
- qsa(row, sel('order-line')).concat(row.matches(sel('order-line')) ? [row] : []).forEach((el) => {
319
- const k = get(el, 'order-line');
320
- if (k === 'image') { if (l.image) setImage(el, l.image.url, l.image.altText || l.title); return; }
321
- const v = { title: l.title, 'variant-title': l.variantTitle || '', quantity: String(l.quantity) }[k];
322
- if (v != null) setText(el, v);
323
- });
324
- });
325
- }
326
-
327
- // Order page, profile, addresses, subscriptions, returns: their own part (account-pages), loaded
328
- // only on pages that use them. It gets this module's login state, so both share one session.
329
- const PAGE_ATTRS = ['order', 'addresses', 'subscriptions', 'profile-form', 'address-form'];
330
- let pagesPart = null;
331
- /** tether-account-bind: the customer's name, email, default address (again after a profile change). */
332
- async function bindCustomer(root) {
333
- const binds = qsa(root, sel('account-bind'));
334
- if (!binds.length) return;
335
- const c = await customer().catch((err) => { reportError(err, 'account'); return null; });
336
- binds.forEach((el) => {
337
- const v = { 'first-name': c?.firstName, 'last-name': c?.lastName, name: c?.displayName, email: c?.emailAddress?.emailAddress, address: c?.defaultAddress?.formatted?.join(', ') }[get(el, 'account-bind')];
338
- if (v != null) setText(el, v);
339
- });
340
- }
341
- function pages() {
342
- pagesPart ||= loadPart('account-pages').then((m) => { m.setup({ server, serverFetch, query, customer, bindCustomer, reset: () => { customerCache = null; serverState = null; } }); return m; });
343
- return pagesPart;
344
- }
345
- const viaPages = (name) => (...a) => pages().then((m) => m[name](...a));
346
- /** One of the customer's orders, in full (lines, totals, addresses, fulfillments, returns). */
347
- export const order = viaPages('order');
348
- export const updateProfile = viaPages('updateProfile');
349
- export const addresses = viaPages('addresses');
350
- /** saveAddress({ address1, city, zip, countryCode, … }, { id, isDefault }): creates, or updates with an id. */
351
- export const saveAddress = viaPages('saveAddress');
352
- export const deleteAddress = viaPages('deleteAddress');
353
- export const subscriptions = viaPages('subscriptions');
354
- /** subscriptionAction(id, 'pause' | 'resume' | 'skip' | 'unskip' | 'cancel') */
355
- export const subscriptionAction = viaPages('subscriptionAction');
356
- /** requestReturn(orderId, [{ lineItemId, quantity, reasonId, note }]) */
357
- export const requestReturn = viaPages('requestReturn');
358
-
359
- let bound = false;
360
- /** Called by the core on mount: finish a login that's coming back, then fill the page. */
361
- export async function mountAccount(root = document) {
362
- if (!bound) {
363
- bound = true;
364
- document.addEventListener('click', (e) => {
365
- const t = e.target;
366
- if (t.closest?.(sel('account-login'))) { e.preventDefault(); login().catch((err) => reportError(err, 'login')); }
367
- else if (t.closest?.(sel('account-logout'))) { e.preventDefault(); logout().catch((err) => reportError(err, 'logout')); }
368
- });
369
- if (server()) {
370
- // Back from the Worker's login: drop ?account=in; at checkout the Worker links the cart.
371
- const u = new URL(window.location.href);
372
- if (u.searchParams.get('account')) { u.searchParams.delete('account'); window.history.replaceState(window.history.state, '', u); emit('account-login', {}); }
373
- Cart.beforeCheckout = async () => {
374
- if (!(await serverMe()).loggedIn || !Cart.id()) return null;
375
- const r = await serverFetch('/cart', { method: 'POST', body: { cartId: Cart.id() } }).catch(() => null);
376
- return r?.checkoutUrl ? `${r.checkoutUrl}${r.checkoutUrl.includes('?') ? '&' : '?'}logged_in=true` : null;
377
- };
378
- } else {
379
- try { await handleCallback(); } catch (err) { reportError(err, 'login'); }
380
- if (!Cart.customerAccessToken && (await getToken().catch(() => null))) Cart.customerAccessToken = session.access;
381
- }
382
- log('account', isLoggedIn() ? 'logged in' : 'logged out');
383
- }
384
- await render(root);
385
- }
package/src/analytics.js DELETED
@@ -1,232 +0,0 @@
1
- /**
2
- * Analytics, as an optional part (loaded when config.analytics is set).
3
- *
4
- * analytics: {
5
- * dataLayer: true, // GA4 / Google Tag Manager ecommerce events (window.dataLayer)
6
- * shopify: true, // Shopify's own analytics (experimental, see docs/analytics.md)
7
- * onEvent(name, data) {}, // any other tool: Meta, TikTok, Plausible, …
8
- * }
9
- *
10
- * Events: view_item (the page's product), view_item_list (lists), add_to_cart / remove_from_cart
11
- * (from the cart's changes, so quick +/- and JS calls count too), view_cart (cart opened),
12
- * begin_checkout, search (search as you type). Purchases happen on Shopify's checkout: use
13
- * Shopify's Google & YouTube app or a checkout pixel for those.
14
- *
15
- * Shopify analytics: Tether sets up the same event bus Hydrogen uses (window.Shopify.analytics) and
16
- * loads Shopify's consent script and shopify.js, which build and send the events. Nothing is sent
17
- * before the Customer Privacy API allows analytics.
18
- */
19
- import { cfg, log, reportError } from './config.js';
20
- import { gql } from './client.js';
21
- import { sel } from './dom.js';
22
- import { Cart } from './cart.js';
23
-
24
- const num = (m) => (m ? parseFloat(m.amount) : 0);
25
- const id = (gid) => String(gid || '').split('/').pop();
26
-
27
- // ---------------------------------------------------------------- shapes
28
-
29
- function itemFromVariant(p, v, quantity = 1, index) {
30
- return {
31
- item_id: v?.sku || id(v?.id) || id(p.id),
32
- item_name: p.title,
33
- item_variant: v?.title === 'Default Title' ? undefined : v?.title,
34
- item_brand: p.vendor || undefined,
35
- item_category: p.productType || undefined,
36
- price: num(v?.price || p.priceRange?.minVariantPrice),
37
- quantity,
38
- ...(index != null ? { index } : {}),
39
- };
40
- }
41
-
42
- function itemFromLine(l, quantity = l.quantity) {
43
- const m = l.merchandise || {};
44
- return itemFromVariant({ ...(m.product || {}), title: m.product?.title }, m, quantity);
45
- }
46
-
47
- const value = (items) => Math.round(items.reduce((s, i) => s + (i.price || 0) * (i.quantity || 1), 0) * 100) / 100;
48
-
49
- // ---------------------------------------------------------------- GA4 / GTM
50
-
51
- function ga(event, ecommerce) {
52
- const name = typeof cfg.analytics.dataLayer === 'string' ? cfg.analytics.dataLayer : 'dataLayer';
53
- const dl = (window[name] = window[name] || []);
54
- if (ecommerce) dl.push({ ecommerce: null }); // Google: clear the previous ecommerce object first
55
- dl.push(ecommerce ? { event, ecommerce } : { event });
56
- }
57
-
58
- // ---------------------------------------------------------------- Shopify analytics (Hydrogen's bus)
59
-
60
- let bus = null;
61
-
62
- function shopifyBus(shopId) {
63
- const destinations = [];
64
- const buffer = [];
65
- const privacy = () => window.Shopify?.customerPrivacy;
66
- const canTrack = () => {
67
- try {
68
- const p = privacy();
69
- return p?.consentStatus === 'loaded' && p.currentVisitorConsent?.()?.analytics !== 'no' && Boolean(p.analyticsProcessingAllowed?.());
70
- } catch { return false; }
71
- };
72
- const config = { shop: { shopId, channel: 'headless' }, consent: { mode: cfg.analytics.shopify?.consent || 'no-banner' } };
73
- const deliver = () => {
74
- if (!canTrack()) return;
75
- destinations.forEach((d) => {
76
- while (d.next < buffer.length) {
77
- const { event, payload } = buffer[d.next++];
78
- (d.subs.get(event) || []).forEach((cb) => { try { cb(payload, d.context); } catch (err) { reportError(err, `analytics ${d.name}`); } });
79
- }
80
- });
81
- };
82
- const api = {
83
- getConfig: () => config,
84
- publish(event, payload = {}) {
85
- buffer.push({ event, payload: { shop: config.shop, url: window.location.href, ...payload } });
86
- if (buffer.length > 500) { buffer.shift(); destinations.forEach((d) => { d.next = Math.max(0, d.next - 1); }); }
87
- deliver();
88
- },
89
- addDestination(dest) {
90
- const tag = `hydrogen:${dest.name}`;
91
- const d = {
92
- name: dest.name, next: 0, subs: new Map(),
93
- context: {
94
- getTrackingValues: () => {
95
- const i = canTrack() ? privacy()?.__internal : null;
96
- return { uniqueToken: i?.uniqueToken?.({ generateFallback: true, tag }) ?? '', visitToken: i?.visitToken?.({ generateFallback: true, tag }) ?? '' };
97
- },
98
- },
99
- };
100
- const subscribe = (event, cb) => {
101
- if (!d.subs.has(event)) d.subs.set(event, new Set());
102
- d.subs.get(event).add(cb);
103
- return () => d.subs.get(event)?.delete(cb);
104
- };
105
- Promise.resolve(dest.setup?.({ subscribe, getConfig: api.getConfig })).then(() => { destinations.push(d); deliver(); })
106
- .catch((err) => reportError(err, `analytics ${dest.name}`));
107
- return () => { const i = destinations.indexOf(d); if (i >= 0) destinations.splice(i, 1); };
108
- },
109
- };
110
- document.addEventListener('consentTrackingApiLoaded', deliver);
111
- document.addEventListener('visitorConsentCollected', deliver);
112
- return api;
113
- }
114
-
115
- function loadScript(src) {
116
- const s = document.createElement('script');
117
- s.src = src;
118
- s.async = true;
119
- document.head.appendChild(s);
120
- }
121
-
122
- async function startShopify() {
123
- if (window.Shopify?.analytics) { bus = window.Shopify.analytics; return; } // already set up (e.g. by the theme)
124
- const data = await gql('query TetherShopId { shop { id } }', {}, { context: false });
125
- const country = String(cfg.country || 'US').toUpperCase();
126
- const S = (window.Shopify = window.Shopify || {});
127
- Object.assign(S, {
128
- country,
129
- locale: (cfg.locale || 'en').split('-')[0].toLowerCase(), // required by Shopify's script
130
- shop: cfg.domain,
131
- routes: { root: '/', match: () => ({ pageTemplateName: mainProduct() ? 'product' : 'page' }), ...(S.routes || {}) },
132
- });
133
- const proxy = cfg.analytics.shopify?.proxy; // same-origin Storefront API path, for exact visitor counts
134
- S.customerPrivacy = {
135
- ...(S.customerPrivacy || {}),
136
- config: proxy
137
- ? { isHeadless: true, asyncConsent: true, asyncVisitorState: true, consentDomain: window.location.host }
138
- : { isHeadless: true, storefrontAccessToken: cfg.token, checkoutRootDomain: cfg.domain },
139
- };
140
- bus = shopifyBus(data.shop.id);
141
- S.analytics = bus;
142
- loadScript(cfg.analytics.shopify?.consent === 'default-banner'
143
- ? 'https://cdn.shopify.com/shopifycloud/privacy-banner/storefront-banner.js'
144
- : 'https://cdn.shopify.com/shopifycloud/consent-tracking-api/v0.2/consent-tracking-api.js');
145
- loadScript('https://cdn.shopify.com/storefront/analytics/shopify.js');
146
- log('shopify analytics ready', data.shop.id);
147
- }
148
-
149
- const productPayload = (p, v, quantity = 1) => ({
150
- id: p.id, title: p.title, price: v.price, vendor: p.vendor || '', variantId: v.id, variantTitle: v.title,
151
- quantity, sku: v.sku || undefined, productType: p.productType || undefined,
152
- });
153
-
154
- function analyticsLine(l) {
155
- const m = l.merchandise || {};
156
- return { id: l.id, quantity: l.quantity, merchandise: { id: m.id, title: m.title, price: m.price, sku: m.sku, product: { id: m.product?.id, title: m.product?.title, vendor: m.product?.vendor || '', handle: m.product?.handle } } };
157
- }
158
-
159
- // ---------------------------------------------------------------- what happened on the page
160
-
161
- function mainProduct() {
162
- const el = document.querySelector(sel('product'));
163
- return el && !el.closest(`${sel('collection')},${sel('products')},${sel('recommendations')}`) ? window.Tether?.getScope(el) : null;
164
- }
165
-
166
- const send = (name, data, shopifyEvent, shopifyPayload) => {
167
- if (cfg.analytics.dataLayer) ga(name, data);
168
- if (bus && shopifyEvent) bus.publish(shopifyEvent, shopifyPayload);
169
- try { cfg.analytics.onEvent?.(name, data); } catch (err) { reportError(err, 'analytics onEvent'); }
170
- };
171
-
172
- let lastLines = null; // variant id -> line, to turn cart changes into add/remove events
173
- let viewed = new WeakSet();
174
-
175
- function handle(e) {
176
- const name = e.type.slice(cfg.prefix.length + 1);
177
- const d = e.detail || {};
178
- if (name === 'product-ready') {
179
- const scope = d.scope;
180
- if (!scope || viewed.has(scope.root) || mainProduct() !== scope) return;
181
- viewed.add(scope.root);
182
- const v = scope.variant || d.product.variants.nodes[0];
183
- const items = [itemFromVariant(d.product, v)];
184
- send('view_item', { currency: v?.price?.currencyCode, value: value(items), items }, 'product_viewed', { products: [productPayload(d.product, v)] });
185
- } else if (name === 'list-ready') {
186
- const items = (d.products || []).slice(0, 200).map((p, i) => itemFromVariant(p, p.variants?.nodes?.[0], 1, i));
187
- const list = { item_list_id: d.collection?.handle || 'products', item_list_name: d.collection?.title || 'Products' };
188
- send('view_item_list', { ...list, items }, d.collection ? 'collection_viewed' : null, d.collection ? { collection: { id: d.collection.id, handle: d.collection.handle } } : null);
189
- } else if (name === 'cart-updated') {
190
- const cart = d.cart;
191
- const now = new Map((cart?.lines?.nodes || []).map((l) => [l.merchandise?.id, l]));
192
- if (lastLines && d.reason !== 'load' && d.reason !== 'pending') {
193
- const currency = cart?.cost?.totalAmount?.currencyCode;
194
- now.forEach((l, k) => {
195
- const diff = l.quantity - (lastLines.get(k)?.quantity || 0);
196
- if (diff > 0) {
197
- const items = [itemFromLine(l, diff)];
198
- send('add_to_cart', { currency, value: value(items), items }, 'product_added_to_cart', {
199
- cart, prevCart: lastCart, currentLine: analyticsLine(l), prevLine: lastLines.get(k) ? analyticsLine(lastLines.get(k)) : undefined,
200
- });
201
- }
202
- });
203
- lastLines.forEach((l, k) => {
204
- const diff = l.quantity - (now.get(k)?.quantity || 0);
205
- if (diff > 0) { const items = [itemFromLine(l, diff)]; send('remove_from_cart', { currency, value: value(items), items }); }
206
- });
207
- }
208
- lastLines = now;
209
- lastCart = cart;
210
- } else if (name === 'cart-toggled' && d.open) {
211
- const items = Cart.lines.map((l) => itemFromLine(l));
212
- send('view_cart', { currency: Cart.state?.cost?.totalAmount?.currencyCode, value: value(items), items });
213
- } else if (name === 'checkout') {
214
- const items = (d.cart?.lines?.nodes || []).map((l) => itemFromLine(l));
215
- const coupon = (d.cart?.discountCodes || []).filter((c) => c.applicable).map((c) => c.code).join(',') || undefined;
216
- send('begin_checkout', { currency: d.cart?.cost?.totalAmount?.currencyCode, value: value(items), coupon, items });
217
- } else if (name === 'search' && d.term) {
218
- send('search', { search_term: d.term }, 'search_viewed', { searchTerm: d.term });
219
- } else if (name === 'page') {
220
- viewed = new WeakSet();
221
- if (bus) bus.publish('page_viewed', {});
222
- }
223
- }
224
- let lastCart = null;
225
-
226
- /** Called by the core with the events it buffered while this part loaded. */
227
- export async function startAnalytics(queue) {
228
- if (cfg.analytics.shopify) await startShopify().catch((err) => reportError(err, 'shopify analytics'));
229
- if (bus) bus.publish('page_viewed', {});
230
- queue.splice(0).forEach(handle);
231
- queue.push = (e) => { handle(e); return 0; }; // from now on, handle events as they come
232
- }
@@ -1,35 +0,0 @@
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).
4
- */
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')}`;
11
-
12
- export const isDatedVersion = (v) => Boolean(parse(v));
13
-
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);
20
- }
21
-
22
- /** Support window of a version, relative to `now`. */
23
- 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);
30
- 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 };
35
- }