@occluze/tether 0.6.7 → 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 +773 -442
- package/dist/tether.account-pages.min.js +1 -1
- package/dist/tether.account.js +648 -318
- 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 +331 -242
- 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 +3176 -2015
- 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 +69 -298
- package/src/account.js +280 -323
- 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 +320 -488
- 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 -287
- 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-Q6E2TFEV.js +0 -1
- package/dist/esm/chunks/account-pages-UZXR4R5F.js +0 -1
- package/dist/esm/chunks/analytics-IJBKATUP.js +0 -1
- package/dist/esm/chunks/chunk-4QJKLPYR.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-KIDH76B7.js +0 -1
- package/dist/esm/chunks/chunk-PAYT5ZD7.js +0 -1
- package/dist/esm/chunks/chunk-RREHGH4A.js +0 -1
- package/dist/esm/chunks/chunk-SSJT43CJ.js +0 -1
- package/dist/esm/chunks/chunk-VC46IEJQ.js +0 -1
- package/dist/esm/chunks/diagnose-3WVAWNTX.js +0 -14
- package/dist/esm/chunks/extras-7I7WJX35.js +0 -1
- package/dist/esm/chunks/markets-ui-545XLFTE.js +0 -1
- package/dist/esm/chunks/search-NILMGYXG.js +0 -1
package/src/account.js
CHANGED
|
@@ -1,384 +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;
|
|
126
|
+
/** @type {{ customer: { orders: { nodes: AccountOrder[] } } | null }} */
|
|
127
|
+
const data = await browserQuery(ORDERS_QUERY, { first });
|
|
128
|
+
return data.customer?.orders.nodes ?? [];
|
|
162
129
|
}
|
|
163
130
|
|
|
164
|
-
/**
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
keep(await tokenRequest({ grant_type: 'refresh_token', client_id: cfg.account.clientId, refresh_token: session.refresh }));
|
|
174
|
-
await linkCart();
|
|
175
|
-
} catch (err) {
|
|
176
|
-
if (err.code === 'SESSION_ENDED') endSession(); else throw err;
|
|
177
|
-
} finally { refreshing = null; }
|
|
178
|
-
})();
|
|
179
|
-
await refreshing;
|
|
180
|
-
return session?.access || null;
|
|
181
|
-
}
|
|
182
|
-
|
|
183
|
-
function endSession() {
|
|
184
|
-
session = null;
|
|
185
|
-
customerCache = null;
|
|
186
|
-
write(store(), KEY(), null);
|
|
187
|
-
Cart.customerAccessToken = null;
|
|
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
|
-
if (server()) throw new TetherError('Customer accounts in server mode only answer customer() and orders()', { code: 'NOT_ALLOWED' });
|
|
201
|
-
const token = await getToken();
|
|
202
|
-
if (!token) throw new TetherError('Not logged in', { code: 'NOT_LOGGED_IN' });
|
|
203
|
-
const d = await discover();
|
|
204
|
-
const run = (t) => fetch(d.graphql, { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: t }, body: JSON.stringify({ query: q, variables }) });
|
|
205
|
-
let res = await run(token);
|
|
206
|
-
if (res.status === 401 && session?.refresh) { session.expiresAt = 0; res = await run(await getToken()); }
|
|
207
|
-
const json = await res.json().catch(() => ({}));
|
|
208
|
-
if (!res.ok || json.errors?.length) throw new TetherError(json.errors?.map((e) => e.message).join(' | ') || `Customer accounts: ${res.status}`, { status: res.status });
|
|
209
|
-
return json.data;
|
|
210
|
-
}
|
|
153
|
+
/** @param {ParentNode} root */
|
|
154
|
+
const usesPages = (root) => PAGE_ATTRIBUTES.some((name) => qsa(root, sel(name)).length > 0);
|
|
211
155
|
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
lineItems(first: 20) { nodes { title variantTitle quantity image { url altText } } } } } } }`, { first });
|
|
228
|
-
return data.customer?.orders?.nodes || [];
|
|
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
|
+
});
|
|
229
171
|
}
|
|
230
172
|
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
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);
|
|
240
192
|
return;
|
|
241
193
|
}
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
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());
|
|
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
|
+
]);
|
|
252
200
|
}
|
|
253
201
|
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
qsa(root, sel('
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
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;
|
|
265
214
|
});
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
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);
|
|
272
226
|
});
|
|
273
|
-
if (!logged) return;
|
|
274
|
-
await bindCustomer(root);
|
|
275
|
-
// The order list and the account pages (order, addresses, subscriptions) load side by side.
|
|
276
|
-
await Promise.all([renderOrderList(root), PAGE_ATTRS.some((n) => qsa(root, sel(n)).length) ? pages().then((m) => m.renderPages(root)) : null]);
|
|
277
227
|
}
|
|
278
228
|
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
229
|
+
/** @type {Promise<Parts['account-pages']> | null} */
|
|
230
|
+
let pagesPart = null;
|
|
231
|
+
|
|
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
|
+
},
|
|
287
245
|
});
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
ordersShow(false, !failed && list.length === 0);
|
|
292
|
-
lists.forEach((c) => repeat(c, list, (node, o) => {
|
|
293
|
-
const items = o.lineItems?.nodes || [];
|
|
294
|
-
qsa(node, sel('order-bind')).forEach((el) => {
|
|
295
|
-
const k = get(el, 'order-bind');
|
|
296
|
-
if (k === 'url') { const u = orderUrl(o); if (u) el.setAttribute('href', u); return; }
|
|
297
|
-
if (k === 'image') { const im = items.find((l) => l.image)?.image; if (im) setImage(el, im.url, im.altText || items[0]?.title || ''); return; }
|
|
298
|
-
const v = { name: o.name, date: date(o.processedAt), total: formatMoney(o.totalPrice), status: o.financialStatus?.toLowerCase(), fulfillment: o.fulfillmentStatus?.toLowerCase(), items: items.map((l) => `${l.quantity}× ${l.title}`).join(', ') }[k];
|
|
299
|
-
if (v != null) setText(el, v);
|
|
300
|
-
});
|
|
301
|
-
// Per product of the order: <div tether-order-lines><div><img tether-order-line="image"> <span tether-order-line="title"></span></div></div>
|
|
302
|
-
qsa(node, sel('order-lines')).forEach((box) => fillLines(box, items));
|
|
303
|
-
if (get(node, 'order-bind') === 'url' && orderUrl(o)) node.setAttribute('href', orderUrl(o));
|
|
304
|
-
}));
|
|
305
|
-
}
|
|
246
|
+
return part;
|
|
247
|
+
});
|
|
248
|
+
return pagesPart;
|
|
306
249
|
}
|
|
307
250
|
|
|
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));
|
|
308
280
|
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
if (
|
|
314
|
-
|
|
315
|
-
}
|
|
316
|
-
|
|
317
|
-
repeat(box, items, (row, l) => {
|
|
318
|
-
qsa(row, sel('order-line')).concat(row.matches(sel('order-line')) ? [row] : []).forEach((el) => {
|
|
319
|
-
const k = get(el, 'order-line');
|
|
320
|
-
if (k === 'image') { if (l.image) setImage(el, l.image.url, l.image.altText || l.title); return; }
|
|
321
|
-
const v = { title: l.title, 'variant-title': l.variantTitle || '', quantity: String(l.quantity) }[k];
|
|
322
|
-
if (v != null) setText(el, v);
|
|
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');
|
|
323
289
|
});
|
|
324
|
-
})
|
|
290
|
+
} else if (target.closest(sel('account-logout'))) {
|
|
291
|
+
event.preventDefault();
|
|
292
|
+
logout().catch((/** @type {unknown} */ error) => {
|
|
293
|
+
reportError(error, 'logout');
|
|
294
|
+
});
|
|
295
|
+
}
|
|
325
296
|
}
|
|
326
297
|
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
const
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
binds.forEach((el) => {
|
|
337
|
-
const v = { 'first-name': c?.firstName, 'last-name': c?.lastName, name: c?.displayName, email: c?.emailAddress?.emailAddress, address: c?.defaultAddress?.formatted?.join(', ') }[get(el, 'account-bind')];
|
|
338
|
-
if (v != null) setText(el, v);
|
|
339
|
-
});
|
|
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 });
|
|
340
307
|
}
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
308
|
+
|
|
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
|
+
}
|
|
344
323
|
}
|
|
345
|
-
const viaPages = (name) => (...a) => pages().then((m) => m[name](...a));
|
|
346
|
-
/** One of the customer's orders, in full (lines, totals, addresses, fulfillments, returns). */
|
|
347
|
-
export const order = viaPages('order');
|
|
348
|
-
export const updateProfile = viaPages('updateProfile');
|
|
349
|
-
export const addresses = viaPages('addresses');
|
|
350
|
-
/** saveAddress({ address1, city, zip, countryCode, … }, { id, isDefault }): creates, or updates with an id. */
|
|
351
|
-
export const saveAddress = viaPages('saveAddress');
|
|
352
|
-
export const deleteAddress = viaPages('deleteAddress');
|
|
353
|
-
export const subscriptions = viaPages('subscriptions');
|
|
354
|
-
/** subscriptionAction(id, 'pause' | 'resume' | 'skip' | 'unskip' | 'cancel') */
|
|
355
|
-
export const subscriptionAction = viaPages('subscriptionAction');
|
|
356
|
-
/** requestReturn(orderId, [{ lineItemId, quantity, reasonId, note }]) */
|
|
357
|
-
export const requestReturn = viaPages('requestReturn');
|
|
358
324
|
|
|
359
|
-
let
|
|
360
|
-
|
|
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
|
+
*/
|
|
361
333
|
export async function mountAccount(root = document) {
|
|
362
|
-
if (!
|
|
363
|
-
|
|
364
|
-
document.addEventListener('click',
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
else if (t.closest?.(sel('account-logout'))) { e.preventDefault(); logout().catch((err) => reportError(err, 'logout')); }
|
|
368
|
-
});
|
|
369
|
-
if (server()) {
|
|
370
|
-
// Back from the Worker's login: drop ?account=in; at checkout the Worker links the cart.
|
|
371
|
-
const u = new URL(window.location.href);
|
|
372
|
-
if (u.searchParams.get('account')) { u.searchParams.delete('account'); window.history.replaceState(window.history.state, '', u); emit('account-login', {}); }
|
|
373
|
-
Cart.beforeCheckout = async () => {
|
|
374
|
-
if (!(await serverMe()).loggedIn || !Cart.id()) return null;
|
|
375
|
-
const r = await serverFetch('/cart', { method: 'POST', body: { cartId: Cart.id() } }).catch(() => null);
|
|
376
|
-
return r?.checkoutUrl ? `${r.checkoutUrl}${r.checkoutUrl.includes('?') ? '&' : '?'}logged_in=true` : null;
|
|
377
|
-
};
|
|
378
|
-
} else {
|
|
379
|
-
try { await handleCallback(); } catch (err) { reportError(err, 'login'); }
|
|
380
|
-
if (!Cart.customerAccessToken && (await getToken().catch(() => null))) Cart.customerAccessToken = session.access;
|
|
381
|
-
}
|
|
334
|
+
if (!started) {
|
|
335
|
+
started = true;
|
|
336
|
+
document.addEventListener('click', onClick);
|
|
337
|
+
if (serverBase()) startServerMode();
|
|
338
|
+
else await startBrowserMode();
|
|
382
339
|
log('account', isLoggedIn() ? 'logged in' : 'logged out');
|
|
383
340
|
}
|
|
384
341
|
await render(root);
|