@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
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
|
-
*
|
|
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: {
|
|
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
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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
|
-
|
|
44
|
-
|
|
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
|
-
/**
|
|
66
|
-
|
|
67
|
-
|
|
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
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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 (
|
|
85
|
-
|
|
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
|
-
|
|
122
|
-
|
|
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
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
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
|
-
/**
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
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
|
-
|
|
157
|
-
await
|
|
158
|
-
|
|
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
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
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
|
-
/**
|
|
191
|
-
async function
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
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
|
-
/**
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
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
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
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 (
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
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
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
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
|
-
/**
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
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
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
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
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
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
|
-
|
|
283
|
-
|
|
284
|
-
|
|
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
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
}
|
|
319
|
-
|
|
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
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
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
|
|
353
|
-
|
|
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 (!
|
|
356
|
-
|
|
357
|
-
document.addEventListener('click',
|
|
358
|
-
|
|
359
|
-
|
|
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);
|