@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,118 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The core's side of customer accounts, which are an optional part: whether a page needs the part, and
|
|
3
|
+
* `Tether.account`, whose functions load it on first call. Every function returns a promise, the part's own sync ones
|
|
4
|
+
* (`isLoggedIn`) too, because the part may still have to load.
|
|
5
|
+
*/
|
|
6
|
+
import { cfg, reportError } from './config.js';
|
|
7
|
+
import { attr } from './dom.js';
|
|
8
|
+
import { loadPart } from './lazy.js';
|
|
9
|
+
import { within } from './mounting.js';
|
|
10
|
+
/** @import { Parts } from './lazy.js' */
|
|
11
|
+
|
|
12
|
+
/** @typedef {Parts['account']} AccountPart */
|
|
13
|
+
/**
|
|
14
|
+
* @template {keyof AccountPart} Name
|
|
15
|
+
* @typedef {Awaited<ReturnType<AccountPart[Name]>>} AccountResult
|
|
16
|
+
*/
|
|
17
|
+
/**
|
|
18
|
+
* @template {keyof AccountPart} Name
|
|
19
|
+
* @typedef {(...args: Parameters<AccountPart[Name]>) => Promise<AccountResult<Name>>} AccountFunction
|
|
20
|
+
*/
|
|
21
|
+
/** @typedef {{ [Name in keyof AccountPart]: AccountFunction<Name> }} AccountApi */
|
|
22
|
+
|
|
23
|
+
/** The attributes of the account markup: a page with one of them loads the part. */
|
|
24
|
+
const ACCOUNT_ATTRIBUTES = [
|
|
25
|
+
'account-login',
|
|
26
|
+
'account-bind',
|
|
27
|
+
'account-link',
|
|
28
|
+
'orders',
|
|
29
|
+
'order',
|
|
30
|
+
'addresses',
|
|
31
|
+
'subscriptions',
|
|
32
|
+
'profile-form',
|
|
33
|
+
'address-form',
|
|
34
|
+
];
|
|
35
|
+
|
|
36
|
+
/** @returns {string} A selector for every element the account part fills in or shows. */
|
|
37
|
+
const accountSelector = () =>
|
|
38
|
+
[...ACCOUNT_ATTRIBUTES.map((name) => `[${attr(name)}]`), `[${attr('show')}^="logged-"]`].join(',');
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Whether this browser keeps an account session or the hint of one, so a page without account markup still finishes
|
|
42
|
+
* a login. Storage throws where the browser blocks it, and then there is no session.
|
|
43
|
+
*
|
|
44
|
+
* @returns {boolean}
|
|
45
|
+
*/
|
|
46
|
+
function hasSavedSession() {
|
|
47
|
+
const sessionKey = `${cfg.prefix}:account:${cfg.domain}`;
|
|
48
|
+
const hintKey = `${cfg.prefix}:account-hint:${cfg.domain}`;
|
|
49
|
+
try {
|
|
50
|
+
return Boolean(
|
|
51
|
+
sessionStorage.getItem(sessionKey) || localStorage.getItem(sessionKey) || localStorage.getItem(hintKey),
|
|
52
|
+
);
|
|
53
|
+
} catch {
|
|
54
|
+
return false;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Loads the account part and mounts it on `root`, when accounts are configured and the page needs them: account
|
|
60
|
+
* markup in `root`, a login coming back from Shopify (`?code=`), or a saved session.
|
|
61
|
+
*
|
|
62
|
+
* @param {ParentNode} root
|
|
63
|
+
*/
|
|
64
|
+
export function mountAccountIfUsed(root) {
|
|
65
|
+
if (!cfg.account?.clientId && !cfg.account?.server) return;
|
|
66
|
+
const needed =
|
|
67
|
+
within(root, accountSelector()).length > 0 || /[?&]code=/.test(window.location.search) || hasSavedSession();
|
|
68
|
+
if (!needed) return;
|
|
69
|
+
loadPart('account')
|
|
70
|
+
.then((part) => part.mountAccount(root))
|
|
71
|
+
.catch((/** @type {unknown} */ error) => {
|
|
72
|
+
reportError(error, 'account');
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* @template {keyof AccountPart} Name
|
|
78
|
+
* @param {Name} name
|
|
79
|
+
* @returns {AccountFunction<Name>} `name` of the account part, called once the part has loaded.
|
|
80
|
+
*/
|
|
81
|
+
function fromPart(name) {
|
|
82
|
+
return (...args) =>
|
|
83
|
+
loadPart('account').then((part) => {
|
|
84
|
+
// `Name` picks one function of the part, and `args` are that function's parameters.
|
|
85
|
+
const call =
|
|
86
|
+
/**
|
|
87
|
+
* @type {(...callArgs: Parameters<AccountPart[Name]>) => AccountResult<Name> | PromiseLike<AccountResult<Name>>}
|
|
88
|
+
*/ (part[name]);
|
|
89
|
+
return call(...args);
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* `Tether.account`: the account part's functions, each loading the part on first call. A plain object, so `await
|
|
95
|
+
* Tether.account` and `Object.keys(Tether.account)` behave as for any object.
|
|
96
|
+
*
|
|
97
|
+
* @type {AccountApi}
|
|
98
|
+
*/
|
|
99
|
+
export const accountApi = {
|
|
100
|
+
login: fromPart('login'),
|
|
101
|
+
logout: fromPart('logout'),
|
|
102
|
+
handleCallback: fromPart('handleCallback'),
|
|
103
|
+
getToken: fromPart('getToken'),
|
|
104
|
+
isLoggedIn: fromPart('isLoggedIn'),
|
|
105
|
+
query: fromPart('query'),
|
|
106
|
+
customer: fromPart('customer'),
|
|
107
|
+
orders: fromPart('orders'),
|
|
108
|
+
order: fromPart('order'),
|
|
109
|
+
updateProfile: fromPart('updateProfile'),
|
|
110
|
+
addresses: fromPart('addresses'),
|
|
111
|
+
saveAddress: fromPart('saveAddress'),
|
|
112
|
+
deleteAddress: fromPart('deleteAddress'),
|
|
113
|
+
subscriptions: fromPart('subscriptions'),
|
|
114
|
+
subscriptionAction: fromPart('subscriptionAction'),
|
|
115
|
+
requestReturn: fromPart('requestReturn'),
|
|
116
|
+
render: fromPart('render'),
|
|
117
|
+
mountAccount: fromPart('mountAccount'),
|
|
118
|
+
};
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/** Order links and dates, shared by the account part's order list and the account pages part. */
|
|
2
|
+
import { cfg } from './config.js';
|
|
3
|
+
import { isHttpsUrl } from './shopify-values.js';
|
|
4
|
+
|
|
5
|
+
export { isHttpsUrl };
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* An ISO date in the configured locale; the input itself for a locale `Intl` doesn't know (a RangeError).
|
|
9
|
+
*
|
|
10
|
+
* @param {string} iso
|
|
11
|
+
*/
|
|
12
|
+
export function localDate(iso) {
|
|
13
|
+
try {
|
|
14
|
+
return new Date(iso).toLocaleDateString(cfg.locale);
|
|
15
|
+
} catch (error) {
|
|
16
|
+
if (error instanceof RangeError) return iso;
|
|
17
|
+
throw error;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* The order's page: the site's own (`account.orderUrl`, e.g. `/account/order?id={id}`, with the numeric order id),
|
|
23
|
+
* else Shopify's status page when it is https.
|
|
24
|
+
*
|
|
25
|
+
* @param {{ id: string, statusPageUrl?: string | null }} order
|
|
26
|
+
* @returns {string | null}
|
|
27
|
+
*/
|
|
28
|
+
export function orderUrl(order) {
|
|
29
|
+
const template = cfg.account?.orderUrl;
|
|
30
|
+
if (template) {
|
|
31
|
+
const numericId = order.id.split('/').pop()?.split('?')[0] ?? '';
|
|
32
|
+
return template.replace('{id}', encodeURIComponent(numericId));
|
|
33
|
+
}
|
|
34
|
+
return isHttpsUrl(order.statusPageUrl) ? (order.statusPageUrl ?? null) : null;
|
|
35
|
+
}
|
package/src/account-pages.js
CHANGED
|
@@ -1,305 +1,89 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Customer account pages
|
|
3
|
-
*
|
|
4
|
-
*
|
|
2
|
+
* Customer account pages, an optional part the account part loads on pages that use them: the order page with
|
|
3
|
+
* returns, the profile form, addresses and subscriptions. The operations and their input checks are shared with the
|
|
4
|
+
* Worker (server/src/shared/account-ops.js), so both modes do the same.
|
|
5
5
|
*/
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
9
|
-
import {
|
|
6
|
+
import { reportError } from './config.js';
|
|
7
|
+
import { qsa, sel } from './dom.js';
|
|
8
|
+
import { pagesContext } from './account/operations.js';
|
|
9
|
+
import { renderOrder, showOrderState } from './account/order-page.js';
|
|
10
|
+
import { renderAddresses } from './account/addresses-view.js';
|
|
11
|
+
import { renderSubscriptions } from './account/subscriptions-view.js';
|
|
12
|
+
import { listenToPages } from './account/page-events.js';
|
|
13
|
+
import { showIn } from './account/markup.js';
|
|
10
14
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
15
|
+
export {
|
|
16
|
+
setup,
|
|
17
|
+
order,
|
|
18
|
+
updateProfile,
|
|
19
|
+
addresses,
|
|
20
|
+
saveAddress,
|
|
21
|
+
deleteAddress,
|
|
22
|
+
subscriptions,
|
|
23
|
+
subscriptionAction,
|
|
24
|
+
requestReturn,
|
|
25
|
+
} from './account/operations.js';
|
|
14
26
|
|
|
15
|
-
|
|
16
|
-
const
|
|
17
|
-
// ---------------------------------------------------------------- order detail, profile, addresses, subscriptions, returns
|
|
18
|
-
|
|
19
|
-
// The same fixed operations in both modes: the Worker's routes in server mode, the Customer
|
|
20
|
-
// Account API directly (with the same documents and input checks) in browser mode.
|
|
21
|
-
const ROUTE = {
|
|
22
|
-
order: (i) => ['GET', `/order?id=${encodeURIComponent(i.id)}`],
|
|
23
|
-
addresses: () => ['GET', '/addresses'],
|
|
24
|
-
subscriptions: () => ['GET', '/subscriptions'],
|
|
25
|
-
profile: () => ['POST', '/profile'],
|
|
26
|
-
saveAddress: () => ['POST', '/address'],
|
|
27
|
-
deleteAddress: () => ['POST', '/address/delete'],
|
|
28
|
-
subscription: () => ['POST', '/subscription'],
|
|
29
|
-
requestReturn: () => ['POST', '/return'],
|
|
30
|
-
};
|
|
31
|
-
async function run(name, input = {}) {
|
|
32
|
-
const { server, serverFetch, query } = ctx;
|
|
33
|
-
if (server()) {
|
|
34
|
-
const [method, path] = ROUTE[name](input);
|
|
35
|
-
const data = await serverFetch(path, method === 'POST' ? { method, body: input } : {});
|
|
36
|
-
if (data.loggedIn === false) throw new TetherError('Not logged in', { code: 'NOT_LOGGED_IN', status: 401 });
|
|
37
|
-
return data;
|
|
38
|
-
}
|
|
39
|
-
try { return await ops[name]((q, v) => query(q, v), input); } catch (err) {
|
|
40
|
-
if (err instanceof InputError) throw new TetherError(err.message, { code: 'INVALID', userErrors: err.userErrors });
|
|
41
|
-
throw err;
|
|
42
|
-
}
|
|
43
|
-
}
|
|
44
|
-
const changed = (what) => { ctx.reset(); emit('account-updated', { what }); };
|
|
45
|
-
|
|
46
|
-
/** One of the customer's orders, in full (lines, totals, addresses, fulfillments, returns). */
|
|
47
|
-
export const order = (id) => run('order', { id }).then((r) => r.order);
|
|
48
|
-
export const updateProfile = ({ firstName, lastName } = {}) => run('profile', { firstName, lastName }).then((r) => { changed('profile'); return r.customer; });
|
|
49
|
-
export const addresses = () => run('addresses').then((r) => r.addresses);
|
|
50
|
-
/** saveAddress({ address1, city, zip, countryCode, … }, { id, isDefault }): creates, or updates with an id. */
|
|
51
|
-
export const saveAddress = (address, { id, isDefault } = {}) => run('saveAddress', { id, address, isDefault }).then((r) => { changed('addresses'); return r.address; });
|
|
52
|
-
export const deleteAddress = (id) => run('deleteAddress', { id }).then((r) => { changed('addresses'); return r.deleted; });
|
|
53
|
-
export const subscriptions = () => run('subscriptions').then((r) => r.subscriptions);
|
|
54
|
-
/** subscriptionAction(id, 'pause' | 'resume' | 'skip' | 'unskip' | 'cancel') */
|
|
55
|
-
export const subscriptionAction = (id, action) => run('subscription', { id, action }).then((r) => { changed('subscriptions'); return r; });
|
|
56
|
-
/** requestReturn(orderId, [{ lineItemId, quantity, reasonId, note }]) */
|
|
57
|
-
export const requestReturn = (orderId, lines) => run('requestReturn', { orderId, lines }).then((r) => { changed('orders'); return r.return; });
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
const nid = (g) => String(g || '').split('/').pop().split('?')[0];
|
|
61
|
-
/** The order's page: your own (account.orderUrl: '/account/order?id={id}') or Shopify's status page. */
|
|
62
|
-
function orderUrl(o) {
|
|
63
|
-
const t = cfg.account?.orderUrl;
|
|
64
|
-
if (t) return String(t).replace('{id}', encodeURIComponent(nid(o.id)));
|
|
65
|
-
return https(o.statusPageUrl) ? o.statusPageUrl : null;
|
|
66
|
-
}
|
|
67
|
-
const lower = (v) => (v ? String(v).toLowerCase().replace(/_/g, ' ') : '');
|
|
68
|
-
const lines = (a) => (a?.formatted || []).join(', ');
|
|
69
|
-
const showIn = (root, name, conds) => qsa(root, sel(name)).forEach((el) => { const k = get(el, name); if (k in conds) show(el, Boolean(conds[k])); });
|
|
70
|
-
const showGlobal = (root, conds) => showIn(root, 'show', conds);
|
|
71
|
-
|
|
72
|
-
function fillLines(box, items, returnable = null) {
|
|
73
|
-
repeat(box, items, (row, l) => {
|
|
74
|
-
const can = returnable ? returnable.get(l.id) || 0 : 0;
|
|
75
|
-
row.dataset.tetherLine = l.id;
|
|
76
|
-
qsa(row, sel('order-line')).concat(row.matches(sel('order-line')) ? [row] : []).forEach((el) => {
|
|
77
|
-
const k = get(el, 'order-line');
|
|
78
|
-
if (k === 'image') { if (l.image) setImage(el, l.image.url, l.image.altText || l.title); return; }
|
|
79
|
-
const v = { title: l.title || l.name, 'variant-title': l.variantTitle || '', quantity: String(l.quantity), price: l.price ? formatMoney(l.price) : '', total: l.totalPrice ? formatMoney(l.totalPrice) : '', sku: l.sku || '', returnable: String(can) }[k];
|
|
80
|
-
if (v != null) setText(el, v);
|
|
81
|
-
});
|
|
82
|
-
showIn(row, 'order-line-show', { returnable: can > 0 });
|
|
83
|
-
qsa(row, sel('return-quantity')).forEach((input) => { input.max = String(can); input.min = '0'; if (!input.value) input.value = '0'; input.disabled = can < 1; });
|
|
84
|
-
qsa(row, `select${sel('return-reason')}`).forEach((select) => {
|
|
85
|
-
const reasons = l.suggestedReturnReasonDefinitions?.nodes || [];
|
|
86
|
-
if (select.dataset.tetherFilled !== l.id) {
|
|
87
|
-
select.dataset.tetherFilled = l.id;
|
|
88
|
-
reasons.forEach((r) => select.appendChild(new Option(r.name, r.id)));
|
|
89
|
-
}
|
|
90
|
-
});
|
|
91
|
-
});
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
const SCOPE = { order: sel('order') };
|
|
95
|
-
async function renderOrder(el) {
|
|
96
|
-
const raw = get(el, 'order') || '';
|
|
97
|
-
const key = raw.startsWith('url:') ? new URL(window.location.href).searchParams.get(raw.slice(4) || 'id') : raw;
|
|
98
|
-
const state = (s) => el.setAttribute(attr('order-state'), s);
|
|
99
|
-
if (!key) { state('not-found'); showIn(el, 'order-show', { 'not-found': true }); return; }
|
|
100
|
-
state('loading');
|
|
101
|
-
showIn(el, 'order-show', { loading: true, ready: false, 'not-found': false, error: false });
|
|
102
|
-
let o;
|
|
103
|
-
try { o = await order(key); } catch (err) {
|
|
104
|
-
reportError(err, 'order'); state('error'); showIn(el, 'order-show', { loading: false, error: true }); return;
|
|
105
|
-
}
|
|
106
|
-
if (!o) { state('not-found'); showIn(el, 'order-show', { loading: false, 'not-found': true }); return; }
|
|
107
|
-
el.dataset.tetherOrderId = o.id;
|
|
108
|
-
const returnable = new Map((o.returnInformation?.returnableLineItems?.nodes || []).map((r) => [r.lineItem?.id, r.quantity]));
|
|
109
|
-
const ships = o.fulfillments?.nodes || [];
|
|
110
|
-
qsa(el, sel('order-bind')).forEach((b) => {
|
|
111
|
-
const k = get(b, 'order-bind');
|
|
112
|
-
if (k === 'url') { if (https(o.statusPageUrl)) b.setAttribute('href', o.statusPageUrl); return; }
|
|
113
|
-
const v = {
|
|
114
|
-
name: o.name, date: date(o.processedAt), total: formatMoney(o.totalPrice), subtotal: o.subtotal ? formatMoney(o.subtotal) : '',
|
|
115
|
-
shipping: o.totalShipping ? formatMoney(o.totalShipping) : '', tax: o.totalTax ? formatMoney(o.totalTax) : '',
|
|
116
|
-
status: lower(o.financialStatus), fulfillment: lower(o.fulfillmentStatus), 'shipping-address': lines(o.shippingAddress),
|
|
117
|
-
'billing-address': lines(o.billingAddress), items: (o.lineItems?.nodes || []).map((l) => `${l.quantity}× ${l.title || l.name}`).join(', '),
|
|
118
|
-
cancelled: o.cancelledAt ? date(o.cancelledAt) : '',
|
|
119
|
-
}[k];
|
|
120
|
-
if (v != null) setText(b, v);
|
|
121
|
-
});
|
|
122
|
-
qsa(el, sel('order-lines')).forEach((box) => fillLines(box, o.lineItems?.nodes || [], returnable));
|
|
123
|
-
qsa(el, sel('order-fulfillments')).forEach((box) => repeat(box, ships, (row, f) => {
|
|
124
|
-
const t = f.trackingInformation?.[0] || {};
|
|
125
|
-
qsa(row, sel('fulfillment-bind')).concat(row.matches(sel('fulfillment-bind')) ? [row] : []).forEach((b) => {
|
|
126
|
-
const k = get(b, 'fulfillment-bind');
|
|
127
|
-
if (k === 'url') { if (https(t.url)) b.setAttribute('href', t.url); return; }
|
|
128
|
-
const v = { status: lower(f.latestShipmentStatus || f.status), date: date(f.createdAt), eta: f.estimatedDeliveryAt ? date(f.estimatedDeliveryAt) : '', company: t.company || '', number: t.number || '' }[k];
|
|
129
|
-
if (v != null) setText(b, v);
|
|
130
|
-
});
|
|
131
|
-
}));
|
|
132
|
-
showIn(el, 'order-show', {
|
|
133
|
-
loading: false, ready: true, error: false, 'not-found': false, cancelled: Boolean(o.cancelledAt), shipped: ships.length > 0,
|
|
134
|
-
returnable: [...returnable.values()].some((q) => q > 0), 'has-returns': (o.returns?.nodes || []).length > 0, 'return-requested': el.dataset.tetherReturned === '1',
|
|
135
|
-
});
|
|
136
|
-
state('ready');
|
|
137
|
-
}
|
|
27
|
+
/** The profile fields a `tether-profile-form` is filled with. */
|
|
28
|
+
const PROFILE_FIELDS = /** @type {const} */ (['firstName', 'lastName']);
|
|
138
29
|
|
|
30
|
+
/**
|
|
31
|
+
* Fills the profile form with the customer's name, leaving a field the visitor has typed in alone.
|
|
32
|
+
*
|
|
33
|
+
* @param {HTMLElement} form
|
|
34
|
+
*/
|
|
139
35
|
async function renderProfile(form) {
|
|
140
|
-
const
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
async function renderAddresses(root) {
|
|
147
|
-
const boxes = qsa(root, sel('addresses'));
|
|
148
|
-
if (!boxes.length) return;
|
|
149
|
-
showGlobal(root, { 'addresses-loading': true, 'addresses-empty': false });
|
|
150
|
-
let list = [];
|
|
151
|
-
try { list = addressCache = await addresses(); } catch (err) { reportError(err, 'addresses'); }
|
|
152
|
-
showGlobal(root, { 'addresses-loading': false, 'addresses-empty': list.length === 0 });
|
|
153
|
-
boxes.forEach((box) => repeat(box, list, (row, a) => {
|
|
154
|
-
row.dataset.tetherAddress = a.id;
|
|
155
|
-
qsa(row, sel('address-bind')).concat(row.matches(sel('address-bind')) ? [row] : []).forEach((b) => {
|
|
156
|
-
const v = { formatted: lines(a), name: [a.firstName, a.lastName].filter(Boolean).join(' '), company: a.company || '', address1: a.address1 || '', address2: a.address2 || '', city: a.city || '', zip: a.zip || '', province: a.province || '', country: a.country || '', phone: a.phoneNumber || '' }[get(b, 'address-bind')];
|
|
157
|
-
if (v != null) setText(b, v);
|
|
36
|
+
const customer = await pagesContext()
|
|
37
|
+
.customer()
|
|
38
|
+
.catch((/** @type {unknown} */ error) => {
|
|
39
|
+
// The form stays empty and still saves: only the prefill is missing.
|
|
40
|
+
reportError(error, 'account');
|
|
41
|
+
return null;
|
|
158
42
|
});
|
|
159
|
-
|
|
160
|
-
})
|
|
43
|
+
if (!customer || form.tagName !== 'FORM') return;
|
|
44
|
+
const fields = /** @type {HTMLFormElement} */ (form).elements;
|
|
45
|
+
PROFILE_FIELDS.forEach((name) => {
|
|
46
|
+
const input = fields.namedItem(name);
|
|
47
|
+
const isTextField = input instanceof window.HTMLInputElement || input instanceof window.HTMLTextAreaElement;
|
|
48
|
+
if (isTextField && !input.dataset['tetherTouched']) input.value = customer[name] ?? '';
|
|
49
|
+
});
|
|
161
50
|
}
|
|
162
51
|
|
|
163
|
-
|
|
164
|
-
function subConds(c) {
|
|
165
|
-
const next = c.upcomingBillingCycles?.nodes?.[0];
|
|
166
|
-
const st = c.status;
|
|
167
|
-
return {
|
|
168
|
-
active: st === 'ACTIVE', paused: st === 'PAUSED', cancelled: st === 'CANCELLED', expired: st === 'EXPIRED', failed: st === 'FAILED',
|
|
169
|
-
skipped: Boolean(next?.skipped), 'can-pause': st === 'ACTIVE', 'can-resume': st === 'PAUSED' || st === 'FAILED',
|
|
170
|
-
'can-skip': st === 'ACTIVE' && Boolean(next) && !next.skipped, 'can-unskip': st === 'ACTIVE' && Boolean(next?.skipped), 'can-cancel': st === 'ACTIVE' || st === 'PAUSED' || st === 'FAILED',
|
|
171
|
-
};
|
|
172
|
-
}
|
|
173
|
-
async function renderSubscriptions(root) {
|
|
174
|
-
const boxes = qsa(root, sel('subscriptions'));
|
|
175
|
-
if (!boxes.length) return;
|
|
176
|
-
showGlobal(root, { 'subscriptions-loading': true, 'subscriptions-empty': false });
|
|
177
|
-
let list = [];
|
|
178
|
-
try { list = await subscriptions(); } catch (err) { reportError(err, 'subscriptions'); }
|
|
179
|
-
showGlobal(root, { 'subscriptions-loading': false, 'subscriptions-empty': list.length === 0 });
|
|
180
|
-
boxes.forEach((box) => repeat(box, list, (row, c) => {
|
|
181
|
-
row.dataset.tetherSubscription = c.id;
|
|
182
|
-
row.setAttribute(attr('subscription-status'), lower(c.status));
|
|
183
|
-
const ls = c.lines?.nodes || [];
|
|
184
|
-
const n = c.deliveryPolicy?.intervalCount?.count || 1;
|
|
185
|
-
const unit = lower(c.deliveryPolicy?.interval);
|
|
186
|
-
const total = ls.reduce((sum, l) => sum + parseFloat(l.lineDiscountedPrice?.amount || 0), 0);
|
|
187
|
-
qsa(row, sel('subscription-bind')).forEach((b) => {
|
|
188
|
-
const k = get(b, 'subscription-bind');
|
|
189
|
-
const v = {
|
|
190
|
-
status: lower(c.status), next: c.nextBillingDate ? date(c.nextBillingDate) : '', interval: unit ? (n === 1 ? unit : `${n} ${unit}s`) : '',
|
|
191
|
-
products: ls.map((l) => `${l.quantity}× ${l.title}`).join(', '), total: ls.length ? formatMoney({ amount: String(total), currencyCode: c.currencyCode }) : '',
|
|
192
|
-
delivery: c.deliveryPrice ? formatMoney(c.deliveryPrice) : '',
|
|
193
|
-
}[k];
|
|
194
|
-
if (v != null) setText(b, v);
|
|
195
|
-
});
|
|
196
|
-
qsa(row, sel('subscription-lines')).forEach((lb) => repeat(lb, ls, (lr, l) => {
|
|
197
|
-
qsa(lr, sel('subscription-line')).concat(lr.matches(sel('subscription-line')) ? [lr] : []).forEach((b) => {
|
|
198
|
-
const k = get(b, 'subscription-line');
|
|
199
|
-
if (k === 'image') { if (l.image) setImage(b, l.image.url, l.image.altText || l.title); return; }
|
|
200
|
-
const v = { title: l.title, 'variant-title': l.variantTitle || '', quantity: String(l.quantity), price: formatMoney(l.currentPrice) }[k];
|
|
201
|
-
if (v != null) setText(b, v);
|
|
202
|
-
});
|
|
203
|
-
}));
|
|
204
|
-
const conds = subConds(c);
|
|
205
|
-
showIn(row, 'subscription-show', conds);
|
|
206
|
-
qsa(row, sel('subscription-action')).forEach((btn) => {
|
|
207
|
-
const a = get(btn, 'subscription-action');
|
|
208
|
-
const ok = conds[`can-${a}`];
|
|
209
|
-
btn.disabled = !ok;
|
|
210
|
-
btn.classList.toggle('is-disabled', !ok);
|
|
211
|
-
});
|
|
212
|
-
}));
|
|
213
|
-
}
|
|
52
|
+
let listening = false;
|
|
214
53
|
|
|
215
|
-
/**
|
|
54
|
+
/**
|
|
55
|
+
* Renders the order page, profile form, addresses and subscriptions in `root`: only what it has.
|
|
56
|
+
*
|
|
57
|
+
* @param {ParentNode} [root]
|
|
58
|
+
* @returns {Promise<void>}
|
|
59
|
+
*/
|
|
216
60
|
export async function renderPages(root = document) {
|
|
217
|
-
if (!
|
|
61
|
+
if (!listening) {
|
|
62
|
+
listening = true;
|
|
63
|
+
listenToPages();
|
|
64
|
+
}
|
|
218
65
|
await Promise.all([
|
|
219
66
|
...qsa(root, sel('order')).map((el) => renderOrder(el)),
|
|
220
|
-
...qsa(root, sel('profile-form')).map((
|
|
67
|
+
...qsa(root, sel('profile-form')).map((form) => renderProfile(form)),
|
|
221
68
|
renderAddresses(root),
|
|
222
69
|
renderSubscriptions(root),
|
|
223
70
|
]);
|
|
224
71
|
}
|
|
225
72
|
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
} catch (err) {
|
|
236
|
-
el.setAttribute(attr('form-state'), 'error');
|
|
237
|
-
const msg = err?.userErrors?.map((u) => u.message).join(' ') || err?.message || 'Something went wrong';
|
|
238
|
-
qsa(el, sel('form-error')).forEach((e) => setText(e, msg.replace(/^Customer accounts: /, '')));
|
|
239
|
-
if (error) reportError(err, error);
|
|
240
|
-
return undefined;
|
|
241
|
-
} finally { el.classList.remove('is-busy'); el.removeAttribute('aria-busy'); }
|
|
242
|
-
}
|
|
243
|
-
|
|
244
|
-
function bindPages() {
|
|
245
|
-
document.addEventListener('submit', (e) => {
|
|
246
|
-
const f = e.target;
|
|
247
|
-
if (f.matches?.(sel('profile-form'))) {
|
|
248
|
-
e.preventDefault();
|
|
249
|
-
act(f, () => updateProfile(fieldsOf(f)));
|
|
250
|
-
} else if (f.matches?.(sel('address-form'))) {
|
|
251
|
-
e.preventDefault();
|
|
252
|
-
const id = f.dataset.tetherAddressId || undefined;
|
|
253
|
-
const isDefault = Boolean(f.elements?.isDefault?.checked);
|
|
254
|
-
act(f, async () => { await saveAddress(fieldsOf(f), { id, isDefault }); f.reset(); delete f.dataset.tetherAddressId; await renderAddresses(document); });
|
|
255
|
-
}
|
|
73
|
+
/**
|
|
74
|
+
* The error state of every account page in `root`, for when nobody can say whether a customer is logged in (the
|
|
75
|
+
* Worker or Shopify can't be reached).
|
|
76
|
+
*
|
|
77
|
+
* @param {ParentNode} root
|
|
78
|
+
*/
|
|
79
|
+
export function showUnavailable(root) {
|
|
80
|
+
qsa(root, sel('order')).forEach((el) => {
|
|
81
|
+
showOrderState(el, 'error');
|
|
256
82
|
});
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
let btn;
|
|
263
|
-
if ((btn = t.closest?.(sel('address-delete'))) && row) {
|
|
264
|
-
e.preventDefault();
|
|
265
|
-
if (confirmOk(btn)) act(row, async () => { await deleteAddress(row.dataset.tetherAddress); await renderAddresses(document); });
|
|
266
|
-
} else if ((btn = t.closest?.(sel('address-default'))) && row) {
|
|
267
|
-
e.preventDefault();
|
|
268
|
-
act(row, async () => { await saveAddress(null, { id: row.dataset.tetherAddress, isDefault: true }); await renderAddresses(document); });
|
|
269
|
-
} else if ((btn = t.closest?.(sel('address-edit'))) && row) {
|
|
270
|
-
e.preventDefault();
|
|
271
|
-
const a = (addressCache || []).find((x) => x.id === row.dataset.tetherAddress);
|
|
272
|
-
const f = document.querySelector(sel('address-form'));
|
|
273
|
-
if (a && f) {
|
|
274
|
-
f.dataset.tetherAddressId = a.id;
|
|
275
|
-
[...f.elements].forEach((i) => { if (i.name && i.name in a) i.value = a[i.name] ?? ''; if (i.name === 'isDefault') i.checked = a.isDefault; });
|
|
276
|
-
f.scrollIntoView?.({ block: 'nearest' });
|
|
277
|
-
}
|
|
278
|
-
} else if ((btn = t.closest?.(sel('address-new')))) {
|
|
279
|
-
const f = document.querySelector(sel('address-form'));
|
|
280
|
-
if (f) { f.reset(); delete f.dataset.tetherAddressId; }
|
|
281
|
-
} else if ((btn = t.closest?.(sel('subscription-action')))) {
|
|
282
|
-
e.preventDefault();
|
|
283
|
-
const sub = btn.closest('[data-tether-subscription]');
|
|
284
|
-
const a = get(btn, 'subscription-action');
|
|
285
|
-
if (!sub || !SUB_ACTIONS.includes(a) || btn.disabled || !confirmOk(btn)) return;
|
|
286
|
-
sub.setAttribute(attr('subscription-state'), 'pending');
|
|
287
|
-
act(sub, async () => { await subscriptionAction(sub.dataset.tetherSubscription, a); await renderSubscriptions(document); }).then(() => {
|
|
288
|
-
sub.setAttribute(attr('subscription-state'), sub.getAttribute(attr('form-state')) === 'error' ? 'error' : 'done');
|
|
289
|
-
});
|
|
290
|
-
} else if ((btn = t.closest?.(sel('order-return')))) {
|
|
291
|
-
e.preventDefault();
|
|
292
|
-
const scope = btn.closest(SCOPE.order);
|
|
293
|
-
if (!scope?.dataset.tetherOrderId) return;
|
|
294
|
-
const req = qsa(scope, '[data-tether-line]').map((r) => ({
|
|
295
|
-
lineItemId: r.dataset.tetherLine,
|
|
296
|
-
quantity: parseInt(r.querySelector(sel('return-quantity'))?.value || '0', 10),
|
|
297
|
-
reasonId: r.querySelector(sel('return-reason'))?.value || undefined,
|
|
298
|
-
note: r.querySelector(sel('return-note'))?.value || undefined,
|
|
299
|
-
})).filter((l) => l.quantity > 0);
|
|
300
|
-
if (!req.length || !confirmOk(btn)) return;
|
|
301
|
-
act(scope, async () => { await requestReturn(scope.dataset.tetherOrderId, req); scope.dataset.tetherReturned = '1'; await renderOrder(scope); });
|
|
302
|
-
}
|
|
83
|
+
showIn(root, 'show', {
|
|
84
|
+
'addresses-loading': false,
|
|
85
|
+
'addresses-error': true,
|
|
86
|
+
'subscriptions-loading': false,
|
|
87
|
+
'subscriptions-error': true,
|
|
303
88
|
});
|
|
304
89
|
}
|
|
305
|
-
|