@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
package/src/account.js CHANGED
@@ -1,377 +1,341 @@
1
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).
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 in progress, or the visitor may be logged in.
4
4
  *
5
- * window.TETHER_CONFIG = { …, account: { clientId: '…' } } // Headless channel → Customer Account API
5
+ * window.TETHER_CONFIG = { …, account: { server: '/_tether/account' } } // or { clientId: '…' } for browser mode
6
6
  *
7
7
  * <button tether-account-login>Log in</button>
8
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
9
+ * <span tether-account-bind="first-name"></span> first-name, last-name, name, email, address
10
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>
11
+ * <a tether-order-bind="url"><span tether-order-bind="name"></span> · <span tether-order-bind="date"></span></a>
13
12
  * </div>
14
13
  * <p tether-show="logged-out">…</p>
15
14
  *
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).
15
+ * Two modes, one interface: server mode (`account/server-client.js`), where the site's Worker holds the tokens, and
16
+ * browser mode (`account/browser-session.js`), where this browser does the OAuth login itself. Shopify needs HTTPS
17
+ * (no localhost: use a tunnel) and the site's origin, callback and logout URLs registered in the Headless channel's
18
+ * Customer Account API settings. The order page, profile, addresses, subscriptions and returns are the
19
+ * `account-pages` part, loaded only on pages that use them.
21
20
  */
22
21
  import { cfg, log, reportError } from './config.js';
23
22
  import { TetherError } from './client.js';
24
- import { attr, sel, get, qsa, setText, setImage, repeat, show, formatMoney, emit } from './dom.js';
23
+ import { sel, get, qsa, setText, show, emit } from './dom.js';
25
24
  import { Cart } from './cart.js';
26
25
  import { loadPart } from './lazy.js';
26
+ import { ORDERS_QUERY } from '../server/src/shared/account-documents.js';
27
+ import {
28
+ browserCustomer,
29
+ browserLoginRedirect,
30
+ browserLogout,
31
+ forgetBrowserCustomer,
32
+ getToken,
33
+ handleCallback,
34
+ hasBrowserLogin,
35
+ query as browserQuery,
36
+ } from './account/browser-session.js';
37
+ import {
38
+ forgetServerLogin,
39
+ isServerLoggedIn,
40
+ serverBase,
41
+ serverCheckoutUrl,
42
+ serverFetch,
43
+ serverLogin,
44
+ serverLoginRedirect,
45
+ serverLogout,
46
+ } from './account/server-client.js';
47
+ import { renderOrderList } from './account/order-list.js';
48
+ import { ownValue } from './account/markup.js';
49
+ /** @import { AccountCustomer } from './account/browser-session.js' */
50
+ /** @import { AccountOrder } from './account/order-list.js' */
51
+ /** @import { Parts } from './lazy.js' */
52
+ /** @import { AddressFields, ReturnLine } from './account/operations.js' */
53
+ /** @import { SubscriptionAction } from '../server/src/shared/account-ops.js' */
27
54
 
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 */ } };
55
+ export { getToken, handleCallback };
37
56
 
38
- let discovery = null;
39
- let session = null; // { access, expiresAt, refresh, idToken }
40
- let refreshing = null;
41
- let customerCache = null;
57
+ /**
58
+ * @typedef {{ kind: 'logged-in' } | { kind: 'logged-out' } | { kind: 'unknown', error: unknown }} LoginState
59
+ * `unknown` when the Worker or Shopify can't say: the page shows its logged-out content, and the account pages their
60
+ * error state.
61
+ */
42
62
 
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
- }
63
+ /** The attributes of the account pages, which are their own part. */
64
+ const PAGE_ATTRIBUTES = ['order', 'addresses', 'subscriptions', 'profile-form', 'address-form'];
64
65
 
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;
66
+ /** @returns {boolean} True while a customer is logged in, as far as this page knows without asking. */
67
+ export function isLoggedIn() {
68
+ return serverBase() ? isServerLoggedIn() : hasBrowserLogin();
76
69
  }
77
70
 
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). */
71
+ /**
72
+ * To Shopify's login page; the visitor comes back to `account.callbackUrl` (default: this page).
73
+ *
74
+ * @param {{ returnTo?: string, locale?: string }} [options]
75
+ * @returns {Promise<void>}
76
+ * @throws {TetherError} `NOT_CONFIGURED` in browser mode without `account.clientId`.
77
+ */
83
78
  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;
79
+ if (serverBase()) serverLoginRedirect({ returnTo, locale });
80
+ else await browserLoginRedirect({ returnTo, locale });
119
81
  }
120
82
 
121
- function decodeJwt(t) {
122
- try { return JSON.parse(atob(t.split('.')[1].replace(/-/g, '+').replace(/_/g, '/'))); } catch { return null; }
83
+ /**
84
+ * Any Customer Account API query, in browser mode.
85
+ *
86
+ * @template T The shape of the query's `data`.
87
+ * @param {string} document
88
+ * @param {Record<string, unknown>} [variables]
89
+ * @returns {Promise<T>}
90
+ * @throws {TetherError} `NOT_ALLOWED` in server mode, which answers only its fixed operations; `NOT_LOGGED_IN`.
91
+ */
92
+ export async function query(document, variables = {}) {
93
+ if (serverBase()) {
94
+ throw new TetherError('Customer accounts in server mode only answer customer() and orders()', {
95
+ code: 'NOT_ALLOWED',
96
+ });
97
+ }
98
+ return browserQuery(document, variables);
123
99
  }
124
100
 
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,
101
+ /** @returns {Promise<AccountCustomer | null>} The logged-in customer, `null` when nobody is. */
102
+ export async function customer() {
103
+ if (!serverBase()) return browserCustomer();
104
+ const me = await serverLogin();
105
+ if (!me.loggedIn) return null;
106
+ return {
107
+ ...me.customer,
108
+ emailAddress: { emailAddress: me.customer.email },
109
+ defaultAddress: { formatted: me.customer.address },
131
110
  };
132
- write(store(), KEY(), session);
133
111
  }
134
112
 
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)');
113
+ /**
114
+ * @param {{ first?: number }} [options]
115
+ * @returns {Promise<AccountOrder[]>} The customer's most recent orders, newest first; none when nobody is logged in
116
+ * (server mode).
117
+ */
118
+ export async function orders({ first = 10 } = {}) {
119
+ if (serverBase()) {
120
+ const answer = await serverFetch(`/orders?first=${first}`);
121
+ if (answer.kind === 'logged-out') return [];
122
+ const list = answer.data['orders'];
123
+ // The Worker sends the nodes of the same `ORDERS_QUERY`.
124
+ return Array.isArray(list) ? /** @type {AccountOrder[]} */ (list) : [];
155
125
  }
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;
126
+ /** @type {{ customer: { orders: { nodes: AccountOrder[] } } | null }} */
127
+ const data = await browserQuery(ORDERS_QUERY, { first });
128
+ return data.customer?.orders.nodes ?? [];
181
129
  }
182
130
 
183
- function endSession() {
184
- session = null;
185
- customerCache = null;
186
- write(store(), KEY(), null);
187
- Cart.customerAccessToken = null;
131
+ /**
132
+ * Logs out here and at Shopify, and unlinks the cart. Sends `tether:account-logout`.
133
+ *
134
+ * @param {{ returnTo?: string }} [options] Where Shopify sends the visitor afterwards.
135
+ * @returns {Promise<void>}
136
+ */
137
+ export async function logout({ returnTo = cfg.account?.logoutUrl || `${window.location.origin}/` } = {}) {
138
+ const outcome = serverBase() ? await serverLogout(returnTo) : await browserLogout(returnTo);
139
+ if (outcome === 'stayed') await render(document);
188
140
  }
189
141
 
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'));
142
+ /** @returns {Promise<LoginState>} */
143
+ async function loginState() {
144
+ try {
145
+ if (serverBase()) return (await serverLogin()).loggedIn ? { kind: 'logged-in' } : { kind: 'logged-out' };
146
+ if (hasBrowserLogin() || (await getToken())) return { kind: 'logged-in' };
147
+ return { kind: 'logged-out' };
148
+ } catch (error) {
149
+ return { kind: 'unknown', error };
195
150
  }
196
151
  }
197
152
 
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;
153
+ /** @param {ParentNode} root */
154
+ const usesPages = (root) => PAGE_ATTRIBUTES.some((name) => qsa(root, sel(name)).length > 0);
155
+
156
+ /**
157
+ * `tether-account-link`: Shopify's own pages, for what has to stay there: `subscriptions` and `payment`
158
+ * (`account.subscriptionsUrl` / `paymentUrl`), or the account itself.
159
+ *
160
+ * @param {ParentNode} root
161
+ */
162
+ function linkShopifyPages(root) {
163
+ const account = cfg.account;
164
+ qsa(root, sel('account-link')).forEach((link) => {
165
+ const page = get(link, 'account-link');
166
+ const subscriptions = account?.subscriptionsUrl;
167
+ const url =
168
+ (page === 'payment' && (account?.paymentUrl || subscriptions)) || (page === 'subscriptions' && subscriptions);
169
+ link.setAttribute('href', url || `https://${cfg.domain}/account`);
170
+ });
210
171
  }
211
172
 
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;
173
+ /**
174
+ * Fills the account markup in `root`: what shows when logged in or out, the customer, their orders, and the account
175
+ * pages.
176
+ *
177
+ * @param {ParentNode} [root]
178
+ * @returns {Promise<void>}
179
+ */
180
+ export async function render(root = document) {
181
+ const state = await loginState();
182
+ if (state.kind === 'unknown') reportError(state.error, 'account');
183
+ const loggedIn = state.kind === 'logged-in';
184
+ qsa(root, sel('show')).forEach((el) => {
185
+ const condition = get(el, 'show');
186
+ if (condition === 'logged-in') show(el, loggedIn);
187
+ if (condition === 'logged-out') show(el, !loggedIn);
188
+ });
189
+ linkShopifyPages(root);
190
+ if (state.kind === 'unknown' && usesPages(root)) {
191
+ (await pages()).showUnavailable(root);
192
+ return;
216
193
  }
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;
194
+ if (!loggedIn) return;
195
+ await bindCustomer(root);
196
+ await Promise.all([
197
+ renderOrderList(root, (first) => orders({ first })),
198
+ usesPages(root) ? pages().then((part) => part.renderPages(root)) : null,
199
+ ]);
221
200
  }
222
201
 
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 || [];
202
+ /**
203
+ * `tether-account-bind`: the customer's name, email, default address. A failure is reported and leaves the binds as
204
+ * they are.
205
+ *
206
+ * @param {ParentNode} root
207
+ */
208
+ async function bindCustomer(root) {
209
+ const binds = qsa(root, sel('account-bind'));
210
+ if (!binds.length) return;
211
+ const profile = await customer().catch((/** @type {unknown} */ error) => {
212
+ reportError(error, 'account');
213
+ return null;
214
+ });
215
+ /** @type {Record<string, string | null | undefined>} */
216
+ const texts = {
217
+ 'first-name': profile?.firstName,
218
+ 'last-name': profile?.lastName,
219
+ name: profile?.displayName,
220
+ email: profile?.emailAddress?.emailAddress,
221
+ address: profile?.defaultAddress?.formatted?.join(', '),
222
+ };
223
+ binds.forEach((el) => {
224
+ const text = ownValue(texts, get(el, 'account-bind') ?? '');
225
+ if (text != null) setText(el, text);
226
+ });
229
227
  }
230
228
 
229
+ /** @type {Promise<Parts['account-pages']> | null} */
230
+ let pagesPart = null;
231
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());
232
+ /** @returns {Promise<Parts['account-pages']>} The account pages part, sharing this part's session. */
233
+ function pages() {
234
+ pagesPart ??= loadPart('account-pages').then((part) => {
235
+ part.setup({
236
+ serverBase,
237
+ serverFetch,
238
+ query,
239
+ customer,
240
+ bindCustomer,
241
+ reset: () => {
242
+ forgetBrowserCustomer();
243
+ forgetServerLogin();
244
+ },
245
+ });
246
+ return part;
247
+ });
248
+ return pagesPart;
252
249
  }
253
250
 
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; } };
251
+ /**
252
+ * One of the customer's orders, in full (lines, totals, addresses, fulfillments, returns).
253
+ *
254
+ * @param {string | number} id
255
+ */
256
+ export const order = (id) => pages().then((part) => part.order(id));
257
+ /** @param {{ firstName?: string, lastName?: string }} [fields] */
258
+ export const updateProfile = (fields) => pages().then((part) => part.updateProfile(fields));
259
+ export const addresses = () => pages().then((part) => part.addresses());
260
+ /**
261
+ * Creates an address, or updates the one with `id`.
262
+ *
263
+ * @param {AddressFields | null} address
264
+ * @param {{ id?: string, isDefault?: boolean }} [options]
265
+ */
266
+ export const saveAddress = (address, options) => pages().then((part) => part.saveAddress(address, options));
267
+ /** @param {string} id */
268
+ export const deleteAddress = (id) => pages().then((part) => part.deleteAddress(id));
269
+ export const subscriptions = () => pages().then((part) => part.subscriptions());
270
+ /**
271
+ * @param {string | number} id
272
+ * @param {SubscriptionAction} action
273
+ */
274
+ export const subscriptionAction = (id, action) => pages().then((part) => part.subscriptionAction(id, action));
275
+ /**
276
+ * @param {string | number} orderId
277
+ * @param {ReturnLine[]} lines
278
+ */
279
+ export const requestReturn = (orderId, lines) => pages().then((part) => part.requestReturn(orderId, lines));
258
280
 
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
- const binds = qsa(root, sel('account-bind'));
275
- if (binds.length) {
276
- const c = await customer().catch((err) => { reportError(err, 'account'); return null; });
277
- binds.forEach((el) => {
278
- 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')];
279
- if (v != null) setText(el, v);
281
+ /** @param {MouseEvent} event */
282
+ function onClick(event) {
283
+ const target = event.target;
284
+ if (!(target instanceof window.Element)) return;
285
+ if (target.closest(sel('account-login'))) {
286
+ event.preventDefault();
287
+ login().catch((/** @type {unknown} */ error) => {
288
+ reportError(error, 'login');
280
289
  });
281
- }
282
- const lists = qsa(root, sel('orders'));
283
- if (lists.length) {
284
- // tether-show="orders-loading" / "orders-empty" (no orders yet).
285
- const ordersShow = (loading, empty) => qsa(root, sel('show')).forEach((el) => {
286
- const k = get(el, 'show');
287
- if (k === 'orders-loading') show(el, loading);
288
- if (k === 'orders-empty') show(el, empty);
290
+ } else if (target.closest(sel('account-logout'))) {
291
+ event.preventDefault();
292
+ logout().catch((/** @type {unknown} */ error) => {
293
+ reportError(error, 'logout');
289
294
  });
290
- ordersShow(true, false);
291
- let failed = false;
292
- const list = await orders({ first: parseInt(get(lists[0], 'limit') || '10', 10) }).catch((err) => { reportError(err, 'orders'); failed = true; return []; });
293
- ordersShow(false, !failed && list.length === 0);
294
- lists.forEach((c) => repeat(c, list, (node, o) => {
295
- const items = o.lineItems?.nodes || [];
296
- qsa(node, sel('order-bind')).forEach((el) => {
297
- const k = get(el, 'order-bind');
298
- if (k === 'url') { const u = orderUrl(o); if (u) el.setAttribute('href', u); return; }
299
- if (k === 'image') { const im = items.find((l) => l.image)?.image; if (im) setImage(el, im.url, im.altText || items[0]?.title || ''); return; }
300
- 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];
301
- if (v != null) setText(el, v);
302
- });
303
- // Per product of the order: <div tether-order-lines><div><img tether-order-line="image"> <span tether-order-line="title"></span></div></div>
304
- qsa(node, sel('order-lines')).forEach((box) => fillLines(box, items));
305
- if (get(node, 'order-bind') === 'url' && orderUrl(o)) node.setAttribute('href', orderUrl(o));
306
- }));
307
295
  }
308
- if (PAGE_ATTRS.some((n) => qsa(root, sel(n)).length)) await (await pages()).renderPages(root);
309
296
  }
310
297
 
311
-
312
- const nid = (g) => String(g || '').split('/').pop().split('?')[0];
313
- /** The order's page: your own (account.orderUrl: '/account/order?id={id}') or Shopify's status page. */
314
- function orderUrl(o) {
315
- const t = cfg.account?.orderUrl;
316
- if (t) return String(t).replace('{id}', encodeURIComponent(nid(o.id)));
317
- return https(o.statusPageUrl) ? o.statusPageUrl : null;
318
- }
319
- function fillLines(box, items) {
320
- repeat(box, items, (row, l) => {
321
- qsa(row, sel('order-line')).concat(row.matches(sel('order-line')) ? [row] : []).forEach((el) => {
322
- const k = get(el, 'order-line');
323
- if (k === 'image') { if (l.image) setImage(el, l.image.url, l.image.altText || l.title); return; }
324
- const v = { title: l.title, 'variant-title': l.variantTitle || '', quantity: String(l.quantity) }[k];
325
- if (v != null) setText(el, v);
326
- });
327
- });
298
+ /** Back from the Worker's login (`?account=in`): drops the mark, and lets the Worker link the cart at checkout. */
299
+ function startServerMode() {
300
+ const url = new URL(window.location.href);
301
+ if (url.searchParams.get('account')) {
302
+ url.searchParams.delete('account');
303
+ window.history.replaceState(window.history.state, '', url);
304
+ emit('account-login', {});
305
+ }
306
+ Cart.setCustomer({ beforeCheckout: serverCheckoutUrl });
328
307
  }
329
308
 
330
- // Order page, profile, addresses, subscriptions, returns: their own part (account-pages), loaded
331
- // only on pages that use them. It gets this module's login state, so both share one session.
332
- const PAGE_ATTRS = ['order', 'addresses', 'subscriptions', 'profile-form', 'address-form'];
333
- let pagesPart = null;
334
- function pages() {
335
- pagesPart ||= loadPart('account-pages').then((m) => { m.setup({ server, serverFetch, query, customer, reset: () => { customerCache = null; serverState = null; } }); return m; });
336
- return pagesPart;
309
+ /** Finishes a login that's coming back from Shopify, and links the cart of a customer who is logged in. */
310
+ async function startBrowserMode() {
311
+ try {
312
+ await handleCallback();
313
+ } catch (error) {
314
+ reportError(error, 'login');
315
+ }
316
+ if (Cart.customerAccessToken) return;
317
+ try {
318
+ const accessToken = await getToken();
319
+ if (accessToken) Cart.setCustomer({ accessToken });
320
+ } catch (error) {
321
+ reportError(error, 'account');
322
+ }
337
323
  }
338
- const viaPages = (name) => (...a) => pages().then((m) => m[name](...a));
339
- /** One of the customer's orders, in full (lines, totals, addresses, fulfillments, returns). */
340
- export const order = viaPages('order');
341
- export const updateProfile = viaPages('updateProfile');
342
- export const addresses = viaPages('addresses');
343
- /** saveAddress({ address1, city, zip, countryCode, … }, { id, isDefault }): creates, or updates with an id. */
344
- export const saveAddress = viaPages('saveAddress');
345
- export const deleteAddress = viaPages('deleteAddress');
346
- export const subscriptions = viaPages('subscriptions');
347
- /** subscriptionAction(id, 'pause' | 'resume' | 'skip' | 'unskip' | 'cancel') */
348
- export const subscriptionAction = viaPages('subscriptionAction');
349
- /** requestReturn(orderId, [{ lineItemId, quantity, reasonId, note }]) */
350
- export const requestReturn = viaPages('requestReturn');
351
324
 
352
- let bound = false;
353
- /** Called by the core on mount: finish a login that's coming back, then fill the page. */
325
+ let started = false;
326
+
327
+ /**
328
+ * Called by the core on mount: on the first one, finishes a login that's coming back; then fills the page.
329
+ *
330
+ * @param {ParentNode} [root]
331
+ * @returns {Promise<void>}
332
+ */
354
333
  export async function mountAccount(root = document) {
355
- if (!bound) {
356
- bound = true;
357
- document.addEventListener('click', (e) => {
358
- const t = e.target;
359
- if (t.closest?.(sel('account-login'))) { e.preventDefault(); login().catch((err) => reportError(err, 'login')); }
360
- else if (t.closest?.(sel('account-logout'))) { e.preventDefault(); logout().catch((err) => reportError(err, 'logout')); }
361
- });
362
- if (server()) {
363
- // Back from the Worker's login: drop ?account=in; at checkout the Worker links the cart.
364
- const u = new URL(window.location.href);
365
- if (u.searchParams.get('account')) { u.searchParams.delete('account'); window.history.replaceState(window.history.state, '', u); emit('account-login', {}); }
366
- Cart.beforeCheckout = async () => {
367
- if (!(await serverMe()).loggedIn || !Cart.id()) return null;
368
- const r = await serverFetch('/cart', { method: 'POST', body: { cartId: Cart.id() } }).catch(() => null);
369
- return r?.checkoutUrl ? `${r.checkoutUrl}${r.checkoutUrl.includes('?') ? '&' : '?'}logged_in=true` : null;
370
- };
371
- } else {
372
- try { await handleCallback(); } catch (err) { reportError(err, 'login'); }
373
- if (!Cart.customerAccessToken && (await getToken().catch(() => null))) Cart.customerAccessToken = session.access;
374
- }
334
+ if (!started) {
335
+ started = true;
336
+ document.addEventListener('click', onClick);
337
+ if (serverBase()) startServerMode();
338
+ else await startBrowserMode();
375
339
  log('account', isLoggedIn() ? 'logged in' : 'logged out');
376
340
  }
377
341
  await render(root);