@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.
Files changed (135) hide show
  1. package/README.md +3 -3
  2. package/dist/esm/astro.js +2 -2
  3. package/dist/esm/chunks/account-C3NP6HFB.js +1 -0
  4. package/dist/esm/chunks/account-pages-WLK374FO.js +1 -0
  5. package/dist/esm/chunks/analytics-G3SAWMNL.js +1 -0
  6. package/dist/esm/chunks/chunk-27PJJZGC.js +1 -0
  7. package/dist/esm/chunks/chunk-2FSXS55U.js +1 -0
  8. package/dist/esm/chunks/chunk-FCSWQDVV.js +1 -0
  9. package/dist/esm/chunks/chunk-FOMSMIAM.js +1 -0
  10. package/dist/esm/chunks/chunk-GZJTCLLW.js +1 -0
  11. package/dist/esm/chunks/chunk-HEQILZVR.js +1 -0
  12. package/dist/esm/chunks/chunk-JOOEXYF3.js +4 -0
  13. package/dist/esm/chunks/chunk-MPTQYLSM.js +1 -0
  14. package/dist/esm/chunks/chunk-NEHZ37TU.js +3 -0
  15. package/dist/esm/chunks/chunk-V74KGEJF.js +1 -0
  16. package/dist/esm/chunks/chunk-VT4CDUJJ.js +1 -0
  17. package/dist/esm/chunks/chunk-YJLWX2HK.js +1 -0
  18. package/dist/esm/chunks/diagnose-6VVGZNTY.js +14 -0
  19. package/dist/esm/chunks/extras-GJVRDFM3.js +1 -0
  20. package/dist/esm/chunks/markets-ui-UNB4PTA7.js +1 -0
  21. package/dist/esm/chunks/search-OU75RNQL.js +1 -0
  22. package/dist/esm/index.js +1 -1
  23. package/dist/esm/server.js +2 -1
  24. package/dist/presets/barba.esm.js +56 -36
  25. package/dist/tether.account-pages.js +773 -442
  26. package/dist/tether.account-pages.min.js +1 -1
  27. package/dist/tether.account.js +648 -318
  28. package/dist/tether.account.min.js +1 -1
  29. package/dist/tether.analytics.js +318 -191
  30. package/dist/tether.analytics.min.js +1 -1
  31. package/dist/tether.diagnose.js +331 -242
  32. package/dist/tether.diagnose.min.js +2 -2
  33. package/dist/tether.extras.js +78 -33
  34. package/dist/tether.extras.min.js +1 -1
  35. package/dist/tether.js +3176 -2015
  36. package/dist/tether.markets.js +231 -126
  37. package/dist/tether.markets.min.js +1 -1
  38. package/dist/tether.min.js +0 -0
  39. package/dist/tether.min.js.map +4 -4
  40. package/dist/tether.search.js +221 -126
  41. package/dist/tether.search.min.js +1 -1
  42. package/package.json +23 -4
  43. package/src/account/addresses-view.js +92 -0
  44. package/src/account/browser-session.js +359 -0
  45. package/src/account/markup.js +48 -0
  46. package/src/account/operations.js +180 -0
  47. package/src/account/order-lines.js +96 -0
  48. package/src/account/order-list.js +91 -0
  49. package/src/account/order-page.js +192 -0
  50. package/src/account/page-events.js +288 -0
  51. package/src/account/server-client.js +172 -0
  52. package/src/account/shared.js +111 -0
  53. package/src/account/shopify-auth.js +150 -0
  54. package/src/account/subscriptions-view.js +158 -0
  55. package/src/account-api.js +118 -0
  56. package/src/account-links.js +35 -0
  57. package/src/account-pages.js +69 -298
  58. package/src/account.js +280 -323
  59. package/src/amounts.js +36 -0
  60. package/src/analytics-shopify.js +241 -0
  61. package/src/analytics.js +304 -184
  62. package/src/api-version.js +75 -25
  63. package/src/astro.js +233 -68
  64. package/src/cart/checkout.js +97 -0
  65. package/src/cart/drawer.js +138 -0
  66. package/src/cart/mutations.js +234 -0
  67. package/src/cart/render.js +294 -0
  68. package/src/cart/store.js +326 -0
  69. package/src/cart.js +320 -488
  70. package/src/client.js +214 -78
  71. package/src/collection.js +126 -57
  72. package/src/config.js +323 -59
  73. package/src/controls/cart-controls.js +155 -0
  74. package/src/controls/discount.js +96 -0
  75. package/src/controls/extras.js +63 -0
  76. package/src/controls/navigation.js +83 -0
  77. package/src/diagnose-markup.js +333 -0
  78. package/src/diagnose.js +251 -202
  79. package/src/dom.js +219 -220
  80. package/src/extras.js +119 -37
  81. package/src/fragments.js +115 -22
  82. package/src/images.js +104 -0
  83. package/src/index.js +245 -287
  84. package/src/initial.js +78 -66
  85. package/src/lazy-iife.js +92 -22
  86. package/src/lazy.js +25 -4
  87. package/src/loader.js +263 -124
  88. package/src/market-codes.js +35 -0
  89. package/src/markets-ui.js +169 -55
  90. package/src/markets.js +286 -85
  91. package/src/markup.js +64 -0
  92. package/src/mounting.js +214 -91
  93. package/src/page-market.js +116 -0
  94. package/src/parts/account-pages.js +4 -1
  95. package/src/parts/account.js +4 -1
  96. package/src/parts/analytics.js +4 -1
  97. package/src/parts/diagnose.js +4 -1
  98. package/src/parts/extras.js +4 -1
  99. package/src/parts/markets.js +4 -1
  100. package/src/parts/search.js +4 -1
  101. package/src/presets/barba.js +120 -30
  102. package/src/product-events.js +130 -0
  103. package/src/product-render.js +257 -0
  104. package/src/product-state.js +318 -0
  105. package/src/product.js +233 -459
  106. package/src/repeaters.js +173 -0
  107. package/src/search.js +277 -107
  108. package/src/selling-plans.js +107 -47
  109. package/src/server.js +168 -205
  110. package/src/shopify-values.js +89 -0
  111. package/src/status.js +99 -39
  112. package/src/store-html.js +0 -0
  113. package/src/storefront/catalog.js +199 -0
  114. package/src/storefront/json-ld.js +167 -0
  115. package/src/variant-render.js +165 -0
  116. package/types/astro.d.ts +13 -2
  117. package/types/index.d.ts +178 -27
  118. package/types/server.d.ts +89 -17
  119. package/dist/esm/chunks/account-Q6E2TFEV.js +0 -1
  120. package/dist/esm/chunks/account-pages-UZXR4R5F.js +0 -1
  121. package/dist/esm/chunks/analytics-IJBKATUP.js +0 -1
  122. package/dist/esm/chunks/chunk-4QJKLPYR.js +0 -1
  123. package/dist/esm/chunks/chunk-64TUD3QR.js +0 -1
  124. package/dist/esm/chunks/chunk-AJLPU2JC.js +0 -4
  125. package/dist/esm/chunks/chunk-FIOZPPC5.js +0 -1
  126. package/dist/esm/chunks/chunk-K2YEQAIH.js +0 -1
  127. package/dist/esm/chunks/chunk-KIDH76B7.js +0 -1
  128. package/dist/esm/chunks/chunk-PAYT5ZD7.js +0 -1
  129. package/dist/esm/chunks/chunk-RREHGH4A.js +0 -1
  130. package/dist/esm/chunks/chunk-SSJT43CJ.js +0 -1
  131. package/dist/esm/chunks/chunk-VC46IEJQ.js +0 -1
  132. package/dist/esm/chunks/diagnose-3WVAWNTX.js +0 -14
  133. package/dist/esm/chunks/extras-7I7WJX35.js +0 -1
  134. package/dist/esm/chunks/markets-ui-545XLFTE.js +0 -1
  135. 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
+ }
@@ -1,318 +1,89 @@
1
1
  /**
2
- * Customer account pages (optional part, loaded by the account part on pages that use them):
3
- * order detail with returns, profile, addresses, subscriptions. The operations and their input
4
- * checks are shared with the Worker (server/src/account-ops.js), so both modes do the same.
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 { cfg, reportError } from './config.js';
7
- import { TetherError } from './client.js';
8
- import { attr, sel, get, qsa, setText, setImage, repeat, show, formatMoney, emit } from './dom.js';
9
- import { ops, InputError } from '../server/src/account-ops.js';
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
- let ctx = null; // the account part's server(), serverFetch(), query(), customer(), reset()
12
- let bound = false;
13
- export function setup(c) { ctx = c; }
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
- const https = (u) => { try { return new URL(u).protocol === 'https:'; } catch { return false; } };
16
- const date = (s) => { try { return new Date(s).toLocaleDateString(cfg.locale || undefined); } catch { return s; } };
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 c = await ctx.customer().catch(() => null);
141
- if (!c) return;
142
- ['firstName', 'lastName'].forEach((n) => { const i = form.elements?.[n]; if (i && !i.dataset.tetherTouched) i.value = c[n] || ''; });
143
- }
144
-
145
- let addressCache = null;
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
- showIn(row, 'address-show', { default: a.isDefault, 'not-default': !a.isDefault });
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
- const SUB_ACTIONS = ['pause', 'resume', 'skip', 'unskip', 'cancel'];
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
- /** Order page, profile form, addresses, subscriptions: only what the page has. */
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 (!bound) { bound = true; bindPages(); }
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((f) => renderProfile(f)),
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
- const fieldsOf = (form) => Object.fromEntries([...form.elements].filter((i) => i.name && i.type !== 'submit' && i.type !== 'button' && i.type !== 'checkbox').map((i) => [i.name, i.value]));
235
- async function act(el, fn, { error } = {}) {
236
- if (el.getAttribute('aria-busy') === 'true') return undefined; // already sending: no second request (Enter, double click)
237
- el.classList.add('is-busy'); el.setAttribute('aria-busy', 'true');
238
- el.setAttribute(attr('form-state'), 'pending');
239
- qsa(el, sel('form-error')).forEach((e) => setText(e, ''));
240
- try {
241
- const out = await fn();
242
- el.setAttribute(attr('form-state'), 'saved');
243
- return out;
244
- } catch (err) {
245
- el.setAttribute(attr('form-state'), 'error');
246
- const msg = err?.userErrors?.map((u) => u.message).join(' ') || err?.message || 'Something went wrong';
247
- qsa(el, sel('form-error')).forEach((e) => setText(e, msg.replace(/^Customer accounts: /, '')));
248
- if (error) reportError(err, error);
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
-