@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
|
@@ -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,318 +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') { const ok = https(t.url); if (ok) b.setAttribute('href', t.url); show(b, ok); return; } // no tracking link: hidden
|
|
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': el.dataset.tetherReturned !== '1' && (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) {
|
|
152
|
-
reportError(err, 'addresses'); // not "no addresses yet": keep what's shown
|
|
153
|
-
showGlobal(root, { 'addresses-loading': false, 'addresses-error': true });
|
|
154
|
-
return;
|
|
155
|
-
}
|
|
156
|
-
showGlobal(root, { 'addresses-loading': false, 'addresses-empty': list.length === 0, 'addresses-error': false });
|
|
157
|
-
boxes.forEach((box) => repeat(box, list, (row, a) => {
|
|
158
|
-
row.dataset.tetherAddress = a.id;
|
|
159
|
-
qsa(row, sel('address-bind')).concat(row.matches(sel('address-bind')) ? [row] : []).forEach((b) => {
|
|
160
|
-
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')];
|
|
161
|
-
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;
|
|
162
42
|
});
|
|
163
|
-
|
|
164
|
-
})
|
|
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
|
+
});
|
|
165
50
|
}
|
|
166
51
|
|
|
167
|
-
|
|
168
|
-
function subConds(c) {
|
|
169
|
-
const next = c.upcomingBillingCycles?.nodes?.[0];
|
|
170
|
-
const st = c.status;
|
|
171
|
-
return {
|
|
172
|
-
active: st === 'ACTIVE', paused: st === 'PAUSED', cancelled: st === 'CANCELLED', expired: st === 'EXPIRED', failed: st === 'FAILED',
|
|
173
|
-
skipped: Boolean(next?.skipped), 'can-pause': st === 'ACTIVE', 'can-resume': st === 'PAUSED' || st === 'FAILED',
|
|
174
|
-
'can-skip': st === 'ACTIVE' && Boolean(next) && !next.skipped, 'can-unskip': st === 'ACTIVE' && Boolean(next?.skipped), 'can-cancel': st === 'ACTIVE' || st === 'PAUSED' || st === 'FAILED',
|
|
175
|
-
};
|
|
176
|
-
}
|
|
177
|
-
async function renderSubscriptions(root) {
|
|
178
|
-
const boxes = qsa(root, sel('subscriptions'));
|
|
179
|
-
if (!boxes.length) return;
|
|
180
|
-
showGlobal(root, { 'subscriptions-loading': true, 'subscriptions-empty': false });
|
|
181
|
-
let list;
|
|
182
|
-
try { list = await subscriptions(); } catch (err) {
|
|
183
|
-
reportError(err, 'subscriptions');
|
|
184
|
-
showGlobal(root, { 'subscriptions-loading': false, 'subscriptions-error': true });
|
|
185
|
-
return;
|
|
186
|
-
}
|
|
187
|
-
showGlobal(root, { 'subscriptions-loading': false, 'subscriptions-empty': list.length === 0, 'subscriptions-error': false });
|
|
188
|
-
boxes.forEach((box) => repeat(box, list, (row, c) => {
|
|
189
|
-
row.dataset.tetherSubscription = c.id;
|
|
190
|
-
row.setAttribute(attr('subscription-status'), lower(c.status));
|
|
191
|
-
const ls = c.lines?.nodes || [];
|
|
192
|
-
const n = c.deliveryPolicy?.intervalCount?.count || 1;
|
|
193
|
-
const unit = lower(c.deliveryPolicy?.interval);
|
|
194
|
-
const total = ls.reduce((sum, l) => sum + parseFloat(l.lineDiscountedPrice?.amount || 0), 0);
|
|
195
|
-
qsa(row, sel('subscription-bind')).forEach((b) => {
|
|
196
|
-
const k = get(b, 'subscription-bind');
|
|
197
|
-
const v = {
|
|
198
|
-
status: lower(c.status), next: c.nextBillingDate ? date(c.nextBillingDate) : '', interval: unit ? (n === 1 ? unit : `${n} ${unit}s`) : '',
|
|
199
|
-
products: ls.map((l) => `${l.quantity}× ${l.title}`).join(', '), total: ls.length ? formatMoney({ amount: String(total), currencyCode: c.currencyCode }) : '',
|
|
200
|
-
delivery: c.deliveryPrice ? formatMoney(c.deliveryPrice) : '',
|
|
201
|
-
}[k];
|
|
202
|
-
if (v != null) setText(b, v);
|
|
203
|
-
});
|
|
204
|
-
qsa(row, sel('subscription-lines')).forEach((lb) => repeat(lb, ls, (lr, l) => {
|
|
205
|
-
qsa(lr, sel('subscription-line')).concat(lr.matches(sel('subscription-line')) ? [lr] : []).forEach((b) => {
|
|
206
|
-
const k = get(b, 'subscription-line');
|
|
207
|
-
if (k === 'image') { if (l.image) setImage(b, l.image.url, l.image.altText || l.title); return; }
|
|
208
|
-
const v = { title: l.title, 'variant-title': l.variantTitle || '', quantity: String(l.quantity), price: formatMoney(l.currentPrice) }[k];
|
|
209
|
-
if (v != null) setText(b, v);
|
|
210
|
-
});
|
|
211
|
-
}));
|
|
212
|
-
const conds = subConds(c);
|
|
213
|
-
showIn(row, 'subscription-show', conds);
|
|
214
|
-
qsa(row, sel('subscription-action')).forEach((btn) => {
|
|
215
|
-
const a = get(btn, 'subscription-action');
|
|
216
|
-
const ok = conds[`can-${a}`];
|
|
217
|
-
btn.disabled = !ok;
|
|
218
|
-
btn.classList.toggle('is-disabled', !ok);
|
|
219
|
-
});
|
|
220
|
-
}));
|
|
221
|
-
}
|
|
52
|
+
let listening = false;
|
|
222
53
|
|
|
223
|
-
/**
|
|
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
|
+
*/
|
|
224
60
|
export async function renderPages(root = document) {
|
|
225
|
-
if (!
|
|
61
|
+
if (!listening) {
|
|
62
|
+
listening = true;
|
|
63
|
+
listenToPages();
|
|
64
|
+
}
|
|
226
65
|
await Promise.all([
|
|
227
66
|
...qsa(root, sel('order')).map((el) => renderOrder(el)),
|
|
228
|
-
...qsa(root, sel('profile-form')).map((
|
|
67
|
+
...qsa(root, sel('profile-form')).map((form) => renderProfile(form)),
|
|
229
68
|
renderAddresses(root),
|
|
230
69
|
renderSubscriptions(root),
|
|
231
70
|
]);
|
|
232
71
|
}
|
|
233
72
|
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
el
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
return undefined;
|
|
250
|
-
} finally { el.classList.remove('is-busy'); el.removeAttribute('aria-busy'); }
|
|
251
|
-
}
|
|
252
|
-
|
|
253
|
-
function bindPages() {
|
|
254
|
-
document.addEventListener('submit', (e) => {
|
|
255
|
-
const f = e.target;
|
|
256
|
-
if (f.matches?.(sel('profile-form'))) {
|
|
257
|
-
e.preventDefault();
|
|
258
|
-
act(f, async () => { await updateProfile(fieldsOf(f)); await ctx.bindCustomer?.(document); });
|
|
259
|
-
} else if (f.matches?.(sel('address-form'))) {
|
|
260
|
-
e.preventDefault();
|
|
261
|
-
const id = f.dataset.tetherAddressId || undefined;
|
|
262
|
-
const isDefault = Boolean(f.elements?.isDefault?.checked);
|
|
263
|
-
act(f, async () => { await saveAddress(fieldsOf(f), { id, isDefault }); f.reset(); delete f.dataset.tetherAddressId; await renderAddresses(document); });
|
|
264
|
-
}
|
|
265
|
-
}, true); // capture: before a router (Astro's ClientRouter) turns the submit into a navigation with the fields in the URL
|
|
266
|
-
document.addEventListener('input', (e) => { if (e.target.closest?.(sel('profile-form'))) e.target.dataset.tetherTouched = '1'; });
|
|
267
|
-
document.addEventListener('click', (e) => {
|
|
268
|
-
const t = e.target;
|
|
269
|
-
const row = t.closest?.('[data-tether-address]');
|
|
270
|
-
const confirmOk = (btn) => { const q = get(btn, 'confirm'); return !q || window.confirm(q); };
|
|
271
|
-
let btn;
|
|
272
|
-
if ((btn = t.closest?.(sel('address-delete'))) && row) {
|
|
273
|
-
e.preventDefault();
|
|
274
|
-
if (confirmOk(btn)) act(row, async () => { await deleteAddress(row.dataset.tetherAddress); await renderAddresses(document); });
|
|
275
|
-
} else if ((btn = t.closest?.(sel('address-default'))) && row) {
|
|
276
|
-
e.preventDefault();
|
|
277
|
-
act(row, async () => { await saveAddress(null, { id: row.dataset.tetherAddress, isDefault: true }); await renderAddresses(document); });
|
|
278
|
-
} else if ((btn = t.closest?.(sel('address-edit'))) && row) {
|
|
279
|
-
e.preventDefault();
|
|
280
|
-
const a = (addressCache || []).find((x) => x.id === row.dataset.tetherAddress);
|
|
281
|
-
const f = document.querySelector(sel('address-form'));
|
|
282
|
-
if (a && f) {
|
|
283
|
-
f.dataset.tetherAddressId = a.id;
|
|
284
|
-
[...f.elements].forEach((i) => { if (i.name && i.name in a) i.value = a[i.name] ?? ''; if (i.name === 'isDefault') i.checked = a.isDefault; });
|
|
285
|
-
f.scrollIntoView?.({ block: 'nearest' });
|
|
286
|
-
}
|
|
287
|
-
} else if ((btn = t.closest?.(sel('address-new')))) {
|
|
288
|
-
const f = document.querySelector(sel('address-form'));
|
|
289
|
-
if (f) { f.reset(); delete f.dataset.tetherAddressId; }
|
|
290
|
-
} else if ((btn = t.closest?.(sel('subscription-action')))) {
|
|
291
|
-
e.preventDefault();
|
|
292
|
-
const sub = btn.closest('[data-tether-subscription]');
|
|
293
|
-
const a = get(btn, 'subscription-action');
|
|
294
|
-
if (!sub || !SUB_ACTIONS.includes(a) || btn.disabled || !confirmOk(btn)) return;
|
|
295
|
-
const id = sub.dataset.tetherSubscription;
|
|
296
|
-
sub.setAttribute(attr('subscription-state'), 'pending');
|
|
297
|
-
act(sub, async () => { await subscriptionAction(id, a); await renderSubscriptions(document); }).then(() => {
|
|
298
|
-
const state = sub.getAttribute(attr('form-state')) === 'error' ? 'error' : 'done';
|
|
299
|
-
// The re-render may have replaced the row: mark the one that shows this subscription now.
|
|
300
|
-
qsa(document, '[data-tether-subscription]').filter((r) => r === sub || r.dataset.tetherSubscription === id)
|
|
301
|
-
.forEach((r) => r.setAttribute(attr('subscription-state'), state));
|
|
302
|
-
});
|
|
303
|
-
} else if ((btn = t.closest?.(sel('order-return')))) {
|
|
304
|
-
e.preventDefault();
|
|
305
|
-
const scope = btn.closest(SCOPE.order);
|
|
306
|
-
if (!scope?.dataset.tetherOrderId) return;
|
|
307
|
-
const req = qsa(scope, '[data-tether-line]').map((r) => ({
|
|
308
|
-
lineItemId: r.dataset.tetherLine,
|
|
309
|
-
quantity: parseInt(r.querySelector(sel('return-quantity'))?.value || '0', 10),
|
|
310
|
-
reasonId: r.querySelector(sel('return-reason'))?.value || undefined,
|
|
311
|
-
note: r.querySelector(sel('return-note'))?.value || undefined,
|
|
312
|
-
})).filter((l) => l.quantity > 0);
|
|
313
|
-
if (!req.length || !confirmOk(btn)) return;
|
|
314
|
-
act(scope, async () => { await requestReturn(scope.dataset.tetherOrderId, req); scope.dataset.tetherReturned = '1'; await renderOrder(scope); });
|
|
315
|
-
}
|
|
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');
|
|
82
|
+
});
|
|
83
|
+
showIn(root, 'show', {
|
|
84
|
+
'addresses-loading': false,
|
|
85
|
+
'addresses-error': true,
|
|
86
|
+
'subscriptions-loading': false,
|
|
87
|
+
'subscriptions-error': true,
|
|
316
88
|
});
|
|
317
89
|
}
|
|
318
|
-
|