@occluze/tether 0.6.6 → 0.6.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -3
- package/dist/esm/astro.js +2 -2
- package/dist/esm/chunks/account-C3NP6HFB.js +1 -0
- package/dist/esm/chunks/account-pages-WLK374FO.js +1 -0
- package/dist/esm/chunks/analytics-G3SAWMNL.js +1 -0
- package/dist/esm/chunks/chunk-27PJJZGC.js +1 -0
- package/dist/esm/chunks/chunk-2FSXS55U.js +1 -0
- package/dist/esm/chunks/chunk-FCSWQDVV.js +1 -0
- package/dist/esm/chunks/chunk-FOMSMIAM.js +1 -0
- package/dist/esm/chunks/chunk-GZJTCLLW.js +1 -0
- package/dist/esm/chunks/chunk-HEQILZVR.js +1 -0
- package/dist/esm/chunks/chunk-JOOEXYF3.js +4 -0
- package/dist/esm/chunks/chunk-MPTQYLSM.js +1 -0
- package/dist/esm/chunks/chunk-NEHZ37TU.js +3 -0
- package/dist/esm/chunks/chunk-V74KGEJF.js +1 -0
- package/dist/esm/chunks/chunk-VT4CDUJJ.js +1 -0
- package/dist/esm/chunks/chunk-YJLWX2HK.js +1 -0
- package/dist/esm/chunks/diagnose-6VVGZNTY.js +14 -0
- package/dist/esm/chunks/extras-GJVRDFM3.js +1 -0
- package/dist/esm/chunks/markets-ui-UNB4PTA7.js +1 -0
- package/dist/esm/chunks/search-OU75RNQL.js +1 -0
- package/dist/esm/index.js +1 -1
- package/dist/esm/server.js +2 -1
- package/dist/presets/barba.esm.js +56 -36
- package/dist/tether.account-pages.js +776 -432
- package/dist/tether.account-pages.min.js +1 -1
- package/dist/tether.account.js +655 -321
- package/dist/tether.account.min.js +1 -1
- package/dist/tether.analytics.js +318 -191
- package/dist/tether.analytics.min.js +1 -1
- package/dist/tether.diagnose.js +334 -243
- package/dist/tether.diagnose.min.js +2 -2
- package/dist/tether.extras.js +78 -33
- package/dist/tether.extras.min.js +1 -1
- package/dist/tether.js +3171 -1998
- package/dist/tether.markets.js +231 -126
- package/dist/tether.markets.min.js +1 -1
- package/dist/tether.min.js +0 -0
- package/dist/tether.min.js.map +4 -4
- package/dist/tether.search.js +221 -126
- package/dist/tether.search.min.js +1 -1
- package/package.json +23 -4
- package/src/account/addresses-view.js +92 -0
- package/src/account/browser-session.js +359 -0
- package/src/account/markup.js +48 -0
- package/src/account/operations.js +180 -0
- package/src/account/order-lines.js +96 -0
- package/src/account/order-list.js +91 -0
- package/src/account/order-page.js +192 -0
- package/src/account/page-events.js +288 -0
- package/src/account/server-client.js +172 -0
- package/src/account/shared.js +111 -0
- package/src/account/shopify-auth.js +150 -0
- package/src/account/subscriptions-view.js +158 -0
- package/src/account-api.js +118 -0
- package/src/account-links.js +35 -0
- package/src/account-pages.js +68 -284
- package/src/account.js +281 -317
- package/src/amounts.js +36 -0
- package/src/analytics-shopify.js +241 -0
- package/src/analytics.js +304 -184
- package/src/api-version.js +75 -25
- package/src/astro.js +233 -68
- package/src/cart/checkout.js +97 -0
- package/src/cart/drawer.js +138 -0
- package/src/cart/mutations.js +234 -0
- package/src/cart/render.js +294 -0
- package/src/cart/store.js +326 -0
- package/src/cart.js +322 -481
- package/src/client.js +214 -78
- package/src/collection.js +126 -57
- package/src/config.js +323 -59
- package/src/controls/cart-controls.js +155 -0
- package/src/controls/discount.js +96 -0
- package/src/controls/extras.js +63 -0
- package/src/controls/navigation.js +83 -0
- package/src/diagnose-markup.js +333 -0
- package/src/diagnose.js +251 -202
- package/src/dom.js +219 -220
- package/src/extras.js +119 -37
- package/src/fragments.js +115 -22
- package/src/images.js +104 -0
- package/src/index.js +245 -285
- package/src/initial.js +78 -66
- package/src/lazy-iife.js +92 -22
- package/src/lazy.js +25 -4
- package/src/loader.js +263 -124
- package/src/market-codes.js +35 -0
- package/src/markets-ui.js +169 -55
- package/src/markets.js +286 -85
- package/src/markup.js +64 -0
- package/src/mounting.js +214 -91
- package/src/page-market.js +116 -0
- package/src/parts/account-pages.js +4 -1
- package/src/parts/account.js +4 -1
- package/src/parts/analytics.js +4 -1
- package/src/parts/diagnose.js +4 -1
- package/src/parts/extras.js +4 -1
- package/src/parts/markets.js +4 -1
- package/src/parts/search.js +4 -1
- package/src/presets/barba.js +120 -30
- package/src/product-events.js +130 -0
- package/src/product-render.js +257 -0
- package/src/product-state.js +318 -0
- package/src/product.js +233 -459
- package/src/repeaters.js +173 -0
- package/src/search.js +277 -107
- package/src/selling-plans.js +107 -47
- package/src/server.js +168 -205
- package/src/shopify-values.js +89 -0
- package/src/status.js +99 -39
- package/src/store-html.js +0 -0
- package/src/storefront/catalog.js +199 -0
- package/src/storefront/json-ld.js +167 -0
- package/src/variant-render.js +165 -0
- package/types/astro.d.ts +13 -2
- package/types/index.d.ts +178 -27
- package/types/server.d.ts +89 -17
- package/dist/esm/chunks/account-QUBHQG3C.js +0 -1
- package/dist/esm/chunks/account-pages-VWDHF6WA.js +0 -1
- package/dist/esm/chunks/analytics-SLATWPLC.js +0 -1
- package/dist/esm/chunks/chunk-64TUD3QR.js +0 -1
- package/dist/esm/chunks/chunk-AJLPU2JC.js +0 -4
- package/dist/esm/chunks/chunk-FIOZPPC5.js +0 -1
- package/dist/esm/chunks/chunk-K2YEQAIH.js +0 -1
- package/dist/esm/chunks/chunk-L2TRRKYV.js +0 -1
- package/dist/esm/chunks/chunk-RREHGH4A.js +0 -1
- package/dist/esm/chunks/chunk-VC46IEJQ.js +0 -1
- package/dist/esm/chunks/chunk-VCPI7MJ2.js +0 -1
- package/dist/esm/chunks/chunk-VPVJPNB4.js +0 -1
- package/dist/esm/chunks/chunk-ZIUIWEON.js +0 -1
- package/dist/esm/chunks/diagnose-VXQBSD3A.js +0 -14
- package/dist/esm/chunks/extras-R4X34VQL.js +0 -1
- package/dist/esm/chunks/markets-ui-F3R5T5EL.js +0 -1
- package/dist/esm/chunks/search-NILMGYXG.js +0 -1
|
@@ -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
|
+
}
|