@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.
Files changed (135) hide show
  1. package/README.md +3 -3
  2. package/dist/esm/astro.js +2 -2
  3. package/dist/esm/chunks/account-C3NP6HFB.js +1 -0
  4. package/dist/esm/chunks/account-pages-WLK374FO.js +1 -0
  5. package/dist/esm/chunks/analytics-G3SAWMNL.js +1 -0
  6. package/dist/esm/chunks/chunk-27PJJZGC.js +1 -0
  7. package/dist/esm/chunks/chunk-2FSXS55U.js +1 -0
  8. package/dist/esm/chunks/chunk-FCSWQDVV.js +1 -0
  9. package/dist/esm/chunks/chunk-FOMSMIAM.js +1 -0
  10. package/dist/esm/chunks/chunk-GZJTCLLW.js +1 -0
  11. package/dist/esm/chunks/chunk-HEQILZVR.js +1 -0
  12. package/dist/esm/chunks/chunk-JOOEXYF3.js +4 -0
  13. package/dist/esm/chunks/chunk-MPTQYLSM.js +1 -0
  14. package/dist/esm/chunks/chunk-NEHZ37TU.js +3 -0
  15. package/dist/esm/chunks/chunk-V74KGEJF.js +1 -0
  16. package/dist/esm/chunks/chunk-VT4CDUJJ.js +1 -0
  17. package/dist/esm/chunks/chunk-YJLWX2HK.js +1 -0
  18. package/dist/esm/chunks/diagnose-6VVGZNTY.js +14 -0
  19. package/dist/esm/chunks/extras-GJVRDFM3.js +1 -0
  20. package/dist/esm/chunks/markets-ui-UNB4PTA7.js +1 -0
  21. package/dist/esm/chunks/search-OU75RNQL.js +1 -0
  22. package/dist/esm/index.js +1 -1
  23. package/dist/esm/server.js +2 -1
  24. package/dist/presets/barba.esm.js +56 -36
  25. package/dist/tether.account-pages.js +776 -432
  26. package/dist/tether.account-pages.min.js +1 -1
  27. package/dist/tether.account.js +655 -321
  28. package/dist/tether.account.min.js +1 -1
  29. package/dist/tether.analytics.js +318 -191
  30. package/dist/tether.analytics.min.js +1 -1
  31. package/dist/tether.diagnose.js +334 -243
  32. package/dist/tether.diagnose.min.js +2 -2
  33. package/dist/tether.extras.js +78 -33
  34. package/dist/tether.extras.min.js +1 -1
  35. package/dist/tether.js +3171 -1998
  36. package/dist/tether.markets.js +231 -126
  37. package/dist/tether.markets.min.js +1 -1
  38. package/dist/tether.min.js +0 -0
  39. package/dist/tether.min.js.map +4 -4
  40. package/dist/tether.search.js +221 -126
  41. package/dist/tether.search.min.js +1 -1
  42. package/package.json +23 -4
  43. package/src/account/addresses-view.js +92 -0
  44. package/src/account/browser-session.js +359 -0
  45. package/src/account/markup.js +48 -0
  46. package/src/account/operations.js +180 -0
  47. package/src/account/order-lines.js +96 -0
  48. package/src/account/order-list.js +91 -0
  49. package/src/account/order-page.js +192 -0
  50. package/src/account/page-events.js +288 -0
  51. package/src/account/server-client.js +172 -0
  52. package/src/account/shared.js +111 -0
  53. package/src/account/shopify-auth.js +150 -0
  54. package/src/account/subscriptions-view.js +158 -0
  55. package/src/account-api.js +118 -0
  56. package/src/account-links.js +35 -0
  57. package/src/account-pages.js +68 -284
  58. package/src/account.js +281 -317
  59. package/src/amounts.js +36 -0
  60. package/src/analytics-shopify.js +241 -0
  61. package/src/analytics.js +304 -184
  62. package/src/api-version.js +75 -25
  63. package/src/astro.js +233 -68
  64. package/src/cart/checkout.js +97 -0
  65. package/src/cart/drawer.js +138 -0
  66. package/src/cart/mutations.js +234 -0
  67. package/src/cart/render.js +294 -0
  68. package/src/cart/store.js +326 -0
  69. package/src/cart.js +322 -481
  70. package/src/client.js +214 -78
  71. package/src/collection.js +126 -57
  72. package/src/config.js +323 -59
  73. package/src/controls/cart-controls.js +155 -0
  74. package/src/controls/discount.js +96 -0
  75. package/src/controls/extras.js +63 -0
  76. package/src/controls/navigation.js +83 -0
  77. package/src/diagnose-markup.js +333 -0
  78. package/src/diagnose.js +251 -202
  79. package/src/dom.js +219 -220
  80. package/src/extras.js +119 -37
  81. package/src/fragments.js +115 -22
  82. package/src/images.js +104 -0
  83. package/src/index.js +245 -285
  84. package/src/initial.js +78 -66
  85. package/src/lazy-iife.js +92 -22
  86. package/src/lazy.js +25 -4
  87. package/src/loader.js +263 -124
  88. package/src/market-codes.js +35 -0
  89. package/src/markets-ui.js +169 -55
  90. package/src/markets.js +286 -85
  91. package/src/markup.js +64 -0
  92. package/src/mounting.js +214 -91
  93. package/src/page-market.js +116 -0
  94. package/src/parts/account-pages.js +4 -1
  95. package/src/parts/account.js +4 -1
  96. package/src/parts/analytics.js +4 -1
  97. package/src/parts/diagnose.js +4 -1
  98. package/src/parts/extras.js +4 -1
  99. package/src/parts/markets.js +4 -1
  100. package/src/parts/search.js +4 -1
  101. package/src/presets/barba.js +120 -30
  102. package/src/product-events.js +130 -0
  103. package/src/product-render.js +257 -0
  104. package/src/product-state.js +318 -0
  105. package/src/product.js +233 -459
  106. package/src/repeaters.js +173 -0
  107. package/src/search.js +277 -107
  108. package/src/selling-plans.js +107 -47
  109. package/src/server.js +168 -205
  110. package/src/shopify-values.js +89 -0
  111. package/src/status.js +99 -39
  112. package/src/store-html.js +0 -0
  113. package/src/storefront/catalog.js +199 -0
  114. package/src/storefront/json-ld.js +167 -0
  115. package/src/variant-render.js +165 -0
  116. package/types/astro.d.ts +13 -2
  117. package/types/index.d.ts +178 -27
  118. package/types/server.d.ts +89 -17
  119. package/dist/esm/chunks/account-QUBHQG3C.js +0 -1
  120. package/dist/esm/chunks/account-pages-VWDHF6WA.js +0 -1
  121. package/dist/esm/chunks/analytics-SLATWPLC.js +0 -1
  122. package/dist/esm/chunks/chunk-64TUD3QR.js +0 -1
  123. package/dist/esm/chunks/chunk-AJLPU2JC.js +0 -4
  124. package/dist/esm/chunks/chunk-FIOZPPC5.js +0 -1
  125. package/dist/esm/chunks/chunk-K2YEQAIH.js +0 -1
  126. package/dist/esm/chunks/chunk-L2TRRKYV.js +0 -1
  127. package/dist/esm/chunks/chunk-RREHGH4A.js +0 -1
  128. package/dist/esm/chunks/chunk-VC46IEJQ.js +0 -1
  129. package/dist/esm/chunks/chunk-VCPI7MJ2.js +0 -1
  130. package/dist/esm/chunks/chunk-VPVJPNB4.js +0 -1
  131. package/dist/esm/chunks/chunk-ZIUIWEON.js +0 -1
  132. package/dist/esm/chunks/diagnose-VXQBSD3A.js +0 -14
  133. package/dist/esm/chunks/extras-R4X34VQL.js +0 -1
  134. package/dist/esm/chunks/markets-ui-F3R5T5EL.js +0 -1
  135. package/dist/esm/chunks/search-NILMGYXG.js +0 -1
@@ -0,0 +1,359 @@
1
+ /**
2
+ * Browser mode (`account.clientId`): the login runs between this browser and Shopify, OAuth with PKCE, and the tokens
3
+ * stay in this browser (sessionStorage, or localStorage with `account.remember`). The cart is linked to the customer,
4
+ * so checkout knows them. No other server sees the customer's data.
5
+ */
6
+ import { cfg, reportError } from '../config.js';
7
+ import { TetherError } from '../client.js';
8
+ import { emit } from '../dom.js';
9
+ import { Cart } from '../cart.js';
10
+ import { randomToken, toBase64Url } from '../../server/src/shared/base64url.js';
11
+ import {
12
+ isRecord,
13
+ loginCountry,
14
+ loginLanguage,
15
+ navigate,
16
+ pendingLoginKey,
17
+ readJsonIfAllowed,
18
+ readJsonObjectOrEmpty,
19
+ sessionArea,
20
+ sessionKey,
21
+ textOrNull,
22
+ writeJsonIfAllowed,
23
+ } from './shared.js';
24
+ import { answersLogin, discover, requestToken } from './shopify-auth.js';
25
+ import { CUSTOMER_QUERY } from '../../server/src/shared/account-documents.js';
26
+ /** @import { TokenAnswer } from './shopify-auth.js' */
27
+
28
+ /**
29
+ * @typedef {object} BrowserSession The customer's tokens, as kept in storage.
30
+ * @property {string} access The Customer Account API access token.
31
+ * @property {number} expiresAt When `access` is to be refreshed, in milliseconds since the epoch.
32
+ * @property {string | null} refresh
33
+ * @property {string | null} idToken For Shopify's logout.
34
+ */
35
+ /**
36
+ * @typedef {object} PendingLogin What the callback checks Shopify's answer against.
37
+ * @property {string} state
38
+ * @property {string} nonce
39
+ * @property {string} verifier The PKCE code verifier.
40
+ * @property {string} redirect The callback URL the authorization request named.
41
+ * @property {string} returnTo
42
+ * @property {number} at When the visitor left for Shopify, in milliseconds since the epoch.
43
+ */
44
+
45
+ /** An access token is refreshed this long before Shopify says it expires, so a request never carries a dead one. */
46
+ const REFRESH_MARGIN_SECONDS = 120;
47
+ /** A login that comes back later than this is refused: its `state` and verifier have been lying in storage. */
48
+ const LOGIN_TIMEOUT_MS = 10 * 60 * 1000;
49
+
50
+ /**
51
+ * @typedef {object} AccountCustomer The customer as `CUSTOMER_QUERY` (account-ops.js) reads them.
52
+ * @property {string} [id] Left out in server mode, whose Worker keeps the id to itself.
53
+ * @property {string | null} firstName
54
+ * @property {string | null} lastName
55
+ * @property {string | null} displayName
56
+ * @property {{ emailAddress: string | null } | null} emailAddress
57
+ * @property {{ formatted: string[] | null } | null} defaultAddress
58
+ */
59
+
60
+ /** @type {BrowserSession | null} */
61
+ let session = null;
62
+ /** @type {AccountCustomer | null} The logged-in customer, until a change of their data or the end of the session. */
63
+ let customerProfile = null;
64
+ /** @type {Promise<void> | null} The refresh on its way: one at a time, whoever asks. */
65
+ let refreshing = null;
66
+
67
+ /**
68
+ * @param {unknown} value
69
+ * @returns {BrowserSession | null}
70
+ */
71
+ function toSession(value) {
72
+ if (!isRecord(value)) return null;
73
+ const access = value['access'];
74
+ const expiresAt = value['expiresAt'];
75
+ if (typeof access !== 'string' || typeof expiresAt !== 'number') return null;
76
+ return { access, expiresAt, refresh: textOrNull(value['refresh']), idToken: textOrNull(value['idToken']) };
77
+ }
78
+
79
+ /**
80
+ * @param {unknown} value
81
+ * @returns {PendingLogin | null}
82
+ */
83
+ function toPendingLogin(value) {
84
+ if (!isRecord(value)) return null;
85
+ const { state, nonce, verifier, redirect, returnTo, at } = value;
86
+ if (typeof state !== 'string' || typeof nonce !== 'string' || typeof verifier !== 'string') return null;
87
+ if (typeof redirect !== 'string' || typeof returnTo !== 'string' || typeof at !== 'number') return null;
88
+ return { state, nonce, verifier, redirect, returnTo, at };
89
+ }
90
+
91
+ /** @returns {BrowserSession | null} The session of this page, else the stored one. */
92
+ const currentSession = () => session ?? toSession(readJsonIfAllowed(sessionArea(), sessionKey()));
93
+
94
+ /** @returns {string | null} The session's access token, which a refresh may have replaced or ended. */
95
+ const accessToken = () => session?.access ?? null;
96
+
97
+ /** @returns {boolean} True while there is an access token that hasn't expired, or a refresh token. */
98
+ export const hasBrowserLogin = () =>
99
+ Boolean(session && (session.refresh || (session.access && session.expiresAt > Date.now())));
100
+
101
+ /**
102
+ * @returns {string} The configured client id.
103
+ * @throws {TetherError} `NOT_CONFIGURED` without one.
104
+ */
105
+ function clientId() {
106
+ const id = cfg.account?.clientId;
107
+ if (!id) throw new TetherError('Customer accounts: set account.clientId', { code: 'NOT_CONFIGURED' });
108
+ return id;
109
+ }
110
+
111
+ /** @param {string} text */
112
+ const sha256 = async (text) => toBase64Url(await crypto.subtle.digest('SHA-256', new TextEncoder().encode(text)));
113
+
114
+ /**
115
+ * To Shopify's login page; the visitor comes back to `account.callbackUrl` (default: this page).
116
+ *
117
+ * @param {{ returnTo: string, locale?: string | undefined }} options
118
+ * @throws {TetherError} `NOT_CONFIGURED` without `account.clientId`.
119
+ */
120
+ export async function browserLoginRedirect({ returnTo, locale }) {
121
+ const client = clientId();
122
+ const endpoints = await discover();
123
+ /** @type {PendingLogin} */
124
+ const pending = {
125
+ state: randomToken(),
126
+ nonce: randomToken(),
127
+ verifier: randomToken(),
128
+ redirect: cfg.account?.callbackUrl || `${window.location.origin}${window.location.pathname}`,
129
+ returnTo,
130
+ at: Date.now(),
131
+ };
132
+ writeJsonIfAllowed('session', pendingLoginKey(), pending);
133
+ const url = new URL(endpoints.authorizationEndpoint);
134
+ /** @type {Record<string, string>} */
135
+ const params = {
136
+ client_id: client,
137
+ scope: 'openid email customer-account-api:full',
138
+ response_type: 'code',
139
+ redirect_uri: pending.redirect,
140
+ state: pending.state,
141
+ nonce: pending.nonce,
142
+ code_challenge: await sha256(pending.verifier),
143
+ code_challenge_method: 'S256',
144
+ };
145
+ const language = loginLanguage(locale);
146
+ if (language) params['locale'] = language;
147
+ const country = loginCountry();
148
+ if (country) params['region_country'] = country;
149
+ Object.entries(params).forEach(([name, value]) => {
150
+ url.searchParams.set(name, value);
151
+ });
152
+ navigate(url.toString());
153
+ }
154
+
155
+ /**
156
+ * @param {TokenAnswer} answer
157
+ * @param {BrowserSession | null} previous A refresh answer may leave out the refresh and id tokens: these are kept.
158
+ */
159
+ function keep(answer, previous) {
160
+ session = {
161
+ access: answer.access,
162
+ expiresAt: Date.now() + Math.max(0, answer.expiresInSeconds - REFRESH_MARGIN_SECONDS) * 1000,
163
+ refresh: answer.refresh ?? previous?.refresh ?? null,
164
+ idToken: answer.idToken ?? previous?.idToken ?? null,
165
+ };
166
+ writeJsonIfAllowed(sessionArea(), sessionKey(), session);
167
+ }
168
+
169
+ /**
170
+ * On the callback page: finishes the login Shopify sends back (`?code=&state=`), and goes on to its `returnTo`.
171
+ * Sends `tether:account-login`, or `tether:account-error` with Shopify's `error`.
172
+ *
173
+ * @returns {Promise<boolean>} True when a login finished.
174
+ * @throws {TetherError} When the login took too long, or Shopify's answer doesn't check out.
175
+ */
176
+ export async function handleCallback() {
177
+ const params = new URLSearchParams(window.location.search);
178
+ const pending = toPendingLogin(readJsonIfAllowed('session', pendingLoginKey()));
179
+ if (!pending || (!params.has('code') && !params.has('error')) || params.get('state') !== pending.state) return false;
180
+ writeJsonIfAllowed('session', pendingLoginKey(), null);
181
+ const clean = new URL(window.location.href);
182
+ ['code', 'state', 'iss', 'error', 'error_description'].forEach((name) => {
183
+ clean.searchParams.delete(name);
184
+ });
185
+ window.history.replaceState(window.history.state, '', clean);
186
+ const error = params.get('error');
187
+ if (error !== null) {
188
+ emit('account-error', { error });
189
+ return false;
190
+ }
191
+ if (Date.now() - pending.at > LOGIN_TIMEOUT_MS) {
192
+ throw new TetherError('Customer accounts: the login took too long, please try again');
193
+ }
194
+ const client = clientId();
195
+ const answer = await requestToken({
196
+ grant_type: 'authorization_code',
197
+ client_id: client,
198
+ redirect_uri: pending.redirect,
199
+ code: params.get('code') ?? '',
200
+ code_verifier: pending.verifier,
201
+ });
202
+ const { issuer } = await discover();
203
+ if (!answersLogin(answer.idToken, { nonce: pending.nonce, issuer, client })) {
204
+ throw new TetherError('Customer accounts: the login answer did not check out (nonce/issuer/audience)');
205
+ }
206
+ keep(answer, null);
207
+ await linkCart();
208
+ emit('account-login', {});
209
+ const back = pending.returnTo ? new URL(pending.returnTo, window.location.href) : null;
210
+ if (back?.origin === window.location.origin && back.href !== clean.href) navigate(back.href);
211
+ return true;
212
+ }
213
+
214
+ /** Forgets the session here; the next `getToken()` is `null`. */
215
+ function endSession() {
216
+ session = null;
217
+ customerProfile = null;
218
+ writeJsonIfAllowed(sessionArea(), sessionKey(), null);
219
+ Cart.setCustomer({ accessToken: null });
220
+ }
221
+
222
+ /** @param {BrowserSession} expired A session with a refresh token. */
223
+ async function refresh(expired) {
224
+ try {
225
+ const answer = await requestToken({
226
+ grant_type: 'refresh_token',
227
+ client_id: clientId(),
228
+ refresh_token: expired.refresh ?? '',
229
+ });
230
+ keep(answer, expired);
231
+ await linkCart();
232
+ } catch (error) {
233
+ if (error instanceof TetherError && error.code === 'SESSION_ENDED') endSession();
234
+ else throw error;
235
+ }
236
+ }
237
+
238
+ /**
239
+ * A valid access token: refreshed when needed, one refresh at a time.
240
+ *
241
+ * @returns {Promise<string | null>} `null` when nobody is logged in, or Shopify ended the login.
242
+ * @throws {TetherError} When a refresh fails for another reason (the network): the login may still be good.
243
+ */
244
+ export async function getToken() {
245
+ session = currentSession();
246
+ if (!session) return null;
247
+ if (session.access && session.expiresAt > Date.now()) return session.access;
248
+ if (!session.refresh) {
249
+ endSession();
250
+ return null;
251
+ }
252
+ const expired = session;
253
+ refreshing ??= refresh(expired).finally(() => {
254
+ refreshing = null;
255
+ });
256
+ await refreshing;
257
+ return accessToken();
258
+ }
259
+
260
+ /** The customer's checkout is theirs: saved addresses, vaulted cards, the order in their order history. */
261
+ async function linkCart() {
262
+ const token = accessToken();
263
+ Cart.setCustomer({ accessToken: token });
264
+ if (!Cart.id() || !token) return;
265
+ try {
266
+ await Cart.setBuyerIdentity({ customerAccessToken: token }, { create: false });
267
+ } catch (error) {
268
+ reportError(error, 'account cart');
269
+ }
270
+ }
271
+
272
+ /**
273
+ * Unlinks the cart, so the next person on this device doesn't check out as this customer. When Shopify can't be
274
+ * told, the cart is forgotten here instead: the next visitor starts a new one.
275
+ */
276
+ async function unlinkCart() {
277
+ if (!Cart.id()) return;
278
+ try {
279
+ await Cart.setBuyerIdentity({ customerAccessToken: null }, { create: false });
280
+ } catch (error) {
281
+ reportError(error, 'account cart unlink');
282
+ // The cart id is kept under the `storageKey` option. The cart's own module isn't shared with the parts of the
283
+ // `<script>` build, so the id is removed here.
284
+ writeJsonIfAllowed('local', cfg.storageKey, null);
285
+ }
286
+ }
287
+
288
+ /**
289
+ * Logs out here, unlinks the cart, then goes on to Shopify's logout. Sends `tether:account-logout`.
290
+ *
291
+ * @param {string} returnTo Where Shopify sends the visitor afterwards.
292
+ * @returns {Promise<'redirected' | 'stayed'>} `stayed` without an id token, which Shopify's logout needs.
293
+ */
294
+ export async function browserLogout(returnTo) {
295
+ const idToken = currentSession()?.idToken ?? null;
296
+ await unlinkCart();
297
+ endSession();
298
+ emit('account-logout', {});
299
+ if (!idToken) return 'stayed';
300
+ const url = new URL((await discover()).endSessionEndpoint);
301
+ url.searchParams.set('id_token_hint', idToken);
302
+ url.searchParams.set('post_logout_redirect_uri', returnTo);
303
+ navigate(url.toString());
304
+ return 'redirected';
305
+ }
306
+
307
+ /**
308
+ * Any Customer Account API query, with the customer's token; a token Shopify refuses is refreshed once.
309
+ *
310
+ * @template T The shape of the query's `data`, which the query text decides.
311
+ * @param {string} document
312
+ * @param {Record<string, unknown>} [variables]
313
+ * @returns {Promise<T>}
314
+ * @throws {TetherError} `NOT_LOGGED_IN` without a login, `HTTP_<status>` for a failed request or GraphQL errors.
315
+ */
316
+ export async function query(document, variables = {}) {
317
+ const token = await getToken();
318
+ if (!token) throw new TetherError('Not logged in', { code: 'NOT_LOGGED_IN' });
319
+ const { graphql } = await discover();
320
+ /** @param {string} accessToken */
321
+ const send = (accessToken) =>
322
+ fetch(graphql, {
323
+ method: 'POST',
324
+ headers: { 'Content-Type': 'application/json', Authorization: accessToken },
325
+ body: JSON.stringify({ query: document, variables }),
326
+ });
327
+ let response = await send(token);
328
+ if (response.status === 401 && session?.refresh) {
329
+ session.expiresAt = 0;
330
+ response = await send((await getToken()) ?? '');
331
+ }
332
+ const json = await readJsonObjectOrEmpty(response);
333
+ const errors = Array.isArray(json['errors']) ? json['errors'] : [];
334
+ if (!response.ok || errors.length) {
335
+ const messages = errors.map((error) => (isRecord(error) ? textOrNull(error['message']) : null)).filter(Boolean);
336
+ throw new TetherError(messages.join(' | ') || `Customer accounts: ${response.status}`, {
337
+ status: response.status,
338
+ });
339
+ }
340
+ // The query text decides the shape of `data`; the caller's `T` states it.
341
+ return /** @type {T} */ (json['data']);
342
+ }
343
+
344
+ /**
345
+ * @returns {Promise<AccountCustomer | null>} The logged-in customer, asked once.
346
+ * @throws {TetherError} As `query`.
347
+ */
348
+ export async function browserCustomer() {
349
+ if (customerProfile) return customerProfile;
350
+ /** @type {{ customer: AccountCustomer | null }} */
351
+ const data = await query(CUSTOMER_QUERY);
352
+ customerProfile = data.customer;
353
+ return customerProfile;
354
+ }
355
+
356
+ /** Asks Shopify again on the next `browserCustomer()`, after a change of the customer's data. */
357
+ export function forgetBrowserCustomer() {
358
+ customerProfile = null;
359
+ }
@@ -0,0 +1,48 @@
1
+ /**
2
+ * The account markup's shared vocabulary: `tether-*-show` conditions, binds on a template row (the row itself may
3
+ * carry one), and how Shopify's enum values and formatted addresses read on the page.
4
+ */
5
+ import { get, qsa, sel, show } from '../dom.js';
6
+
7
+ /**
8
+ * Shows or hides each `name` element whose value is one of `conditions`' keys; other values are left alone.
9
+ *
10
+ * @param {ParentNode} root
11
+ * @param {string} name `show`, `order-show`, `address-show`…
12
+ * @param {Readonly<Record<string, boolean>>} conditions
13
+ */
14
+ export function showIn(root, name, conditions) {
15
+ qsa(root, sel(name)).forEach((el) => {
16
+ const key = get(el, name) ?? '';
17
+ if (Object.hasOwn(conditions, key)) show(el, conditions[key] === true);
18
+ });
19
+ }
20
+
21
+ /**
22
+ * @param {HTMLElement} row A copy of a repeater's template.
23
+ * @param {string} name
24
+ * @returns {HTMLElement[]} The `name` elements in `row`, and `row` itself when it carries `name`.
25
+ */
26
+ export const bindsIn = (row, name) => qsa(row, sel(name)).concat(row.matches(sel(name)) ? [row] : []);
27
+
28
+ /**
29
+ * @param {string | null | undefined} value
30
+ * @returns {string} A Shopify enum as text: `PARTIALLY_FULFILLED` → `partially fulfilled`.
31
+ */
32
+ export const enumText = (value) => (value ? value.toLowerCase().replace(/_/g, ' ') : '');
33
+
34
+ /**
35
+ * @param {{ formatted?: string[] | null } | null | undefined} address
36
+ * @returns {string} Shopify's formatted address on one line.
37
+ */
38
+ export const addressText = (address) => (address?.formatted ?? []).join(', ');
39
+
40
+ /**
41
+ * A bind's value, looked up by the attribute's text: only the table's own keys, never `constructor` and the like.
42
+ *
43
+ * @template T
44
+ * @param {Readonly<Record<string, T>>} values
45
+ * @param {string} key
46
+ * @returns {T | undefined}
47
+ */
48
+ export const ownValue = (values, key) => (Object.hasOwn(values, key) ? values[key] : undefined);
@@ -0,0 +1,180 @@
1
+ /**
2
+ * The account pages' operations, the same fixed ones in both modes: the Worker's routes in server mode, the Customer
3
+ * Account API directly in browser mode, with the documents and input checks of account-ops.js either way. Each
4
+ * result has the shape of its document there, which the typedefs of the views that render it state.
5
+ */
6
+ import { TetherError } from '../client.js';
7
+ import { emit } from '../dom.js';
8
+ import { ops, InputError } from '../../server/src/shared/account-ops.js';
9
+ /** @import { ServerAnswer, ServerRequest } from './server-client.js' */
10
+ /** @import { AccountCustomer } from './browser-session.js' */
11
+ /** @import { OrderDetail } from './order-page.js' */
12
+ /** @import { CustomerAddress } from './addresses-view.js' */
13
+ /** @import { SubscriptionContract } from './subscriptions-view.js' */
14
+ /** @import { SubscriptionAction } from '../../server/src/shared/account-ops.js' */
15
+
16
+ /**
17
+ * @typedef {object} PagesContext The account part's session, shared so both parts act as one customer.
18
+ * @property {() => string} serverBase `''` in browser mode.
19
+ * @property {(path: string, request?: ServerRequest) => Promise<ServerAnswer>} serverFetch
20
+ * @property {(document: string, variables?: Record<string, unknown>) => Promise<unknown>} query
21
+ * @property {() => Promise<AccountCustomer | null>} customer
22
+ * @property {(root: ParentNode) => Promise<void>} bindCustomer
23
+ * @property {() => void} reset Forgets the cached customer after a change of their data.
24
+ */
25
+ /** @typedef {Record<string, unknown>} AddressFields `CustomerAddressInput` fields; account-ops.js keeps only those. */
26
+ /**
27
+ * @typedef {object} ReturnLine
28
+ * @property {string} lineItemId
29
+ * @property {number} quantity
30
+ * @property {string} [reasonId]
31
+ * @property {string} [note]
32
+ */
33
+ /** @typedef {'order' | 'addresses' | 'subscriptions' | 'profile' | 'saveAddress' | 'deleteAddress' | 'subscription' | 'requestReturn'} OperationName */
34
+ /** @typedef {'profile' | 'addresses' | 'subscriptions' | 'orders'} ChangedData `tether:account-updated`'s `what`. */
35
+
36
+ /** @type {PagesContext | null} */
37
+ let context = null;
38
+
39
+ /**
40
+ * Called by the account part once, when it loads this part.
41
+ *
42
+ * @param {PagesContext} shared
43
+ */
44
+ export function setup(shared) {
45
+ context = shared;
46
+ }
47
+
48
+ /**
49
+ * @returns {PagesContext}
50
+ * @throws {Error} Before `setup`: this part is only ever loaded by the account part, which calls it first.
51
+ */
52
+ export function pagesContext() {
53
+ if (!context) throw new Error('account-pages: loaded without the account part');
54
+ return context;
55
+ }
56
+
57
+ /** @type {Record<OperationName, (input: Record<string, unknown>) => ['GET' | 'POST', string]>} */
58
+ const ROUTE = {
59
+ order: (input) => ['GET', `/order?id=${encodeURIComponent(String(input['id']))}`],
60
+ addresses: () => ['GET', '/addresses'],
61
+ subscriptions: () => ['GET', '/subscriptions'],
62
+ profile: () => ['POST', '/profile'],
63
+ saveAddress: () => ['POST', '/address'],
64
+ deleteAddress: () => ['POST', '/address/delete'],
65
+ subscription: () => ['POST', '/subscription'],
66
+ requestReturn: () => ['POST', '/return'],
67
+ };
68
+
69
+ /**
70
+ * @param {OperationName} name
71
+ * @param {Record<string, unknown>} [input]
72
+ * @returns {Promise<Record<string, unknown>>} The operation's result fields.
73
+ * @throws {TetherError} `NOT_LOGGED_IN` (status 401), `INVALID` for input account-ops.js refuses (with Shopify's
74
+ * `userErrors`), or the request's failure.
75
+ */
76
+ async function run(name, input = {}) {
77
+ const { serverBase, serverFetch, query } = pagesContext();
78
+ if (serverBase()) {
79
+ const [method, path] = ROUTE[name](input);
80
+ const answer = await serverFetch(path, method === 'POST' ? { method, body: input } : {});
81
+ if (answer.kind === 'logged-out') throw new TetherError('Not logged in', { code: 'NOT_LOGGED_IN', status: 401 });
82
+ return answer.data;
83
+ }
84
+ const operation = ops[name];
85
+ if (!operation) throw new TetherError(`Customer accounts: no operation ${name}`);
86
+ try {
87
+ // account-ops.js's `Ask` reads every answer field by field, so the answer's shape needn't be stated here.
88
+ return await operation(
89
+ async (document, variables) => /** @type {Record<string, unknown> | null} */ (await query(document, variables)),
90
+ input,
91
+ );
92
+ } catch (error) {
93
+ if (!(error instanceof InputError)) throw error;
94
+ throw new TetherError(error.message, { code: 'INVALID', userErrors: error.userErrors });
95
+ }
96
+ }
97
+
98
+ /** @param {ChangedData} what */
99
+ function changed(what) {
100
+ pagesContext().reset();
101
+ emit('account-updated', { what });
102
+ }
103
+
104
+ /**
105
+ * One of the customer's orders, in full (lines, totals, addresses, fulfillments, returns).
106
+ *
107
+ * @param {string | number} id A gid or the numeric order id.
108
+ * @returns {Promise<OrderDetail | null>} `null` when the customer has no such order.
109
+ */
110
+ export async function order(id) {
111
+ const result = await run('order', { id });
112
+ return /** @type {OrderDetail | null} */ (result['order'] ?? null);
113
+ }
114
+
115
+ /**
116
+ * @param {{ firstName?: string, lastName?: string }} [fields]
117
+ * @returns {Promise<{ firstName: string | null, lastName: string | null }>}
118
+ */
119
+ export async function updateProfile({ firstName, lastName } = {}) {
120
+ const result = await run('profile', { firstName, lastName });
121
+ changed('profile');
122
+ return /** @type {{ firstName: string | null, lastName: string | null }} */ (result['customer']);
123
+ }
124
+
125
+ /** @returns {Promise<CustomerAddress[]>} The customer's addresses, the default one marked `isDefault`. */
126
+ export async function addresses() {
127
+ const result = await run('addresses');
128
+ return /** @type {CustomerAddress[]} */ (result['addresses']);
129
+ }
130
+
131
+ /**
132
+ * Creates an address, or updates the one with `id`; without fields, only makes it the default.
133
+ *
134
+ * @param {AddressFields | null} address
135
+ * @param {{ id?: string | undefined, isDefault?: boolean }} [options]
136
+ * @returns {Promise<CustomerAddress>}
137
+ */
138
+ export async function saveAddress(address, { id, isDefault } = {}) {
139
+ const result = await run('saveAddress', { id, address, isDefault });
140
+ changed('addresses');
141
+ return /** @type {CustomerAddress} */ (result['address']);
142
+ }
143
+
144
+ /**
145
+ * @param {string} id
146
+ * @returns {Promise<string>} The deleted address's id.
147
+ */
148
+ export async function deleteAddress(id) {
149
+ const result = await run('deleteAddress', { id });
150
+ changed('addresses');
151
+ return /** @type {string} */ (result['deleted']);
152
+ }
153
+
154
+ /** @returns {Promise<SubscriptionContract[]>} */
155
+ export async function subscriptions() {
156
+ const result = await run('subscriptions');
157
+ return /** @type {SubscriptionContract[]} */ (result['subscriptions']);
158
+ }
159
+
160
+ /**
161
+ * @param {string | number} id
162
+ * @param {SubscriptionAction} action
163
+ * @returns {Promise<Record<string, unknown>>} The changed contract or billing cycle.
164
+ */
165
+ export async function subscriptionAction(id, action) {
166
+ const result = await run('subscription', { id, action });
167
+ changed('subscriptions');
168
+ return result;
169
+ }
170
+
171
+ /**
172
+ * @param {string | number} orderId
173
+ * @param {ReturnLine[]} lines
174
+ * @returns {Promise<{ id: string, status: string }>} The return Shopify opened.
175
+ */
176
+ export async function requestReturn(orderId, lines) {
177
+ const result = await run('requestReturn', { orderId, lines });
178
+ changed('orders');
179
+ return /** @type {{ id: string, status: string }} */ (result['return']);
180
+ }
@@ -0,0 +1,96 @@
1
+ /**
2
+ * The products of an order (`tether-order-lines`), for the order list and the order page alike. The order page's
3
+ * lines also carry price, total, sku and what can be returned; the order list's document has none of those, so they
4
+ * render empty there, and its rows get no return controls.
5
+ */
6
+ import { formatMoney, get, qsa, repeat, sel, setImage, setText } from '../dom.js';
7
+ import { bindsIn, ownValue, showIn } from './markup.js';
8
+ /** @import { Money } from '../shopify-values.js' */
9
+
10
+ /**
11
+ * @typedef {object} OrderLine A line of `ORDERS_QUERY` or of the order page's document (account-ops.js).
12
+ * @property {string} [id] Only on the order page.
13
+ * @property {string} [name]
14
+ * @property {string} title
15
+ * @property {string | null} [variantTitle]
16
+ * @property {number} quantity
17
+ * @property {string | null} [sku]
18
+ * @property {{ url: string, altText: string | null } | null} [image]
19
+ * @property {Money | null} [price]
20
+ * @property {Money | null} [totalPrice]
21
+ * @property {{ nodes: { id: string, name: string }[] }} [suggestedReturnReasonDefinitions]
22
+ */
23
+ /** @typedef {ReadonlyMap<string, number>} Returnable Line id → how many of it can still be returned. */
24
+
25
+ /**
26
+ * @param {OrderLine} line
27
+ * @param {number | null} returnable `null` in the order list, which knows nothing of returns.
28
+ * @returns {Record<string, string | undefined>} The text of each `tether-order-line` value.
29
+ */
30
+ const lineTexts = (line, returnable) => ({
31
+ title: line.title || line.name,
32
+ 'variant-title': line.variantTitle || '',
33
+ quantity: String(line.quantity),
34
+ price: formatMoney(line.price),
35
+ total: formatMoney(line.totalPrice),
36
+ sku: line.sku || '',
37
+ returnable: returnable === null ? undefined : String(returnable),
38
+ });
39
+
40
+ /**
41
+ * @param {HTMLElement} el
42
+ * @returns {el is HTMLInputElement}
43
+ */
44
+ const isInput = (el) => el.tagName === 'INPUT';
45
+
46
+ /**
47
+ * The return controls of one line: how many can be returned, and Shopify's suggested reasons.
48
+ *
49
+ * @param {HTMLElement} row
50
+ * @param {OrderLine & { id: string }} line
51
+ * @param {number} returnable
52
+ */
53
+ function fillReturnControls(row, line, returnable) {
54
+ showIn(row, 'order-line-show', { returnable: returnable > 0 });
55
+ qsa(row, sel('return-quantity')).forEach((input) => {
56
+ if (!isInput(input)) return;
57
+ input.max = String(returnable);
58
+ input.min = '0';
59
+ if (!input.value) input.value = '0';
60
+ input.disabled = returnable < 1;
61
+ });
62
+ qsa(row, `select${sel('return-reason')}`).forEach((select) => {
63
+ // A select keeps its options across renders of the same line: they are added once.
64
+ if (select.dataset['tetherFilled'] === line.id) return;
65
+ select.dataset['tetherFilled'] = line.id;
66
+ (line.suggestedReturnReasonDefinitions?.nodes ?? []).forEach((reason) => {
67
+ select.appendChild(new Option(reason.name, reason.id));
68
+ });
69
+ });
70
+ }
71
+
72
+ /**
73
+ * Renders `lines` into a `tether-order-lines` repeater.
74
+ *
75
+ * @param {HTMLElement} box
76
+ * @param {readonly OrderLine[]} lines
77
+ * @param {Returnable | null} returns `null` for the order list.
78
+ */
79
+ export function fillLines(box, lines, returns) {
80
+ repeat(box, lines, (row, line) => {
81
+ const id = line.id;
82
+ const returnable = returns && id ? (returns.get(id) ?? 0) : null;
83
+ if (id) row.dataset['tetherLine'] = id;
84
+ const texts = lineTexts(line, returnable);
85
+ bindsIn(row, 'order-line').forEach((el) => {
86
+ const key = get(el, 'order-line') ?? '';
87
+ if (key === 'image') {
88
+ if (line.image) setImage(el, line.image.url, line.image.altText || line.title);
89
+ return;
90
+ }
91
+ const text = ownValue(texts, key);
92
+ if (text !== undefined) setText(el, text);
93
+ });
94
+ if (id && returnable !== null) fillReturnControls(row, { ...line, id }, returnable);
95
+ });
96
+ }