@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,91 @@
1
+ /**
2
+ * The customer's recent orders (`tether-orders`), with `tether-show="orders-loading"` while they load and
3
+ * `orders-empty` when there are none yet.
4
+ */
5
+ import { reportError } from '../config.js';
6
+ import { orderUrl, localDate } from '../account-links.js';
7
+ import { formatMoney, get, qsa, repeat, sel, setImage, setText } from '../dom.js';
8
+ import { ownValue, showIn } from './markup.js';
9
+ import { fillLines } from './order-lines.js';
10
+ /** @import { Money } from '../shopify-values.js' */
11
+ /** @import { OrderLine } from './order-lines.js' */
12
+
13
+ /**
14
+ * @typedef {object} AccountOrder A node of `ORDERS_QUERY` (account-ops.js).
15
+ * @property {string} id
16
+ * @property {string} name
17
+ * @property {string} processedAt
18
+ * @property {string | null} financialStatus
19
+ * @property {string | null} fulfillmentStatus
20
+ * @property {string | null} statusPageUrl
21
+ * @property {Money} totalPrice
22
+ * @property {{ nodes: OrderLine[] }} lineItems
23
+ */
24
+
25
+ /** How many orders a `tether-orders` list shows without `tether-limit`. */
26
+ const DEFAULT_ORDER_COUNT = 10;
27
+
28
+ /**
29
+ * @param {AccountOrder} order
30
+ * @returns {Record<string, string | undefined>} The text of each `tether-order-bind` value.
31
+ */
32
+ const orderTexts = (order) => ({
33
+ name: order.name,
34
+ date: localDate(order.processedAt),
35
+ total: formatMoney(order.totalPrice),
36
+ status: order.financialStatus?.toLowerCase(),
37
+ fulfillment: order.fulfillmentStatus?.toLowerCase(),
38
+ items: order.lineItems.nodes.map((line) => `${line.quantity}× ${line.title}`).join(', '),
39
+ });
40
+
41
+ /**
42
+ * @param {HTMLElement} node A copy of the list's template.
43
+ * @param {AccountOrder} order
44
+ */
45
+ function fillOrder(node, order) {
46
+ const lines = order.lineItems.nodes;
47
+ const url = orderUrl(order);
48
+ const texts = orderTexts(order);
49
+ qsa(node, sel('order-bind')).forEach((el) => {
50
+ const key = get(el, 'order-bind') ?? '';
51
+ if (key === 'url') {
52
+ if (url) el.setAttribute('href', url);
53
+ return;
54
+ }
55
+ if (key === 'image') {
56
+ const image = lines.find((line) => line.image)?.image;
57
+ if (image) setImage(el, image.url, image.altText || lines[0]?.title || '');
58
+ return;
59
+ }
60
+ const text = ownValue(texts, key);
61
+ if (text !== undefined) setText(el, text);
62
+ });
63
+ qsa(node, sel('order-lines')).forEach((box) => {
64
+ fillLines(box, lines, null);
65
+ });
66
+ if (get(node, 'order-bind') === 'url' && url) node.setAttribute('href', url);
67
+ }
68
+
69
+ /**
70
+ * Fills every `tether-orders` list in `root`. A failure is reported and leaves the lists empty, without claiming the
71
+ * customer has no orders.
72
+ *
73
+ * @param {ParentNode} root
74
+ * @param {(first: number) => Promise<AccountOrder[]>} loadOrders
75
+ */
76
+ export async function renderOrderList(root, loadOrders) {
77
+ const lists = qsa(root, sel('orders'));
78
+ const first = lists[0];
79
+ if (!first) return;
80
+ showIn(root, 'show', { 'orders-loading': true, 'orders-empty': false });
81
+ const limit = Number.parseInt(get(first, 'limit') || String(DEFAULT_ORDER_COUNT), 10) || DEFAULT_ORDER_COUNT;
82
+ /** @type {AccountOrder[] | null} `null` after a failure. */
83
+ const orders = await loadOrders(limit).catch((/** @type {unknown} */ error) => {
84
+ reportError(error, 'orders');
85
+ return null;
86
+ });
87
+ showIn(root, 'show', { 'orders-loading': false, 'orders-empty': orders?.length === 0 });
88
+ lists.forEach((list) => {
89
+ repeat(list, orders ?? [], fillOrder);
90
+ });
91
+ }
@@ -0,0 +1,192 @@
1
+ /**
2
+ * The order page (`tether-order`): one order in full, with its lines, totals, addresses, shipments and what can be
3
+ * returned. `tether-order-state` and `tether-order-show` follow the load: `loading`, then `ready`, `not-found` or
4
+ * `error`.
5
+ */
6
+ import { reportError } from '../config.js';
7
+ import { isHttpsUrl, localDate } from '../account-links.js';
8
+ import { attr, formatMoney, get, qsa, repeat, sel, setText, show } from '../dom.js';
9
+ import { addressText, bindsIn, enumText, ownValue, showIn } from './markup.js';
10
+ import { fillLines } from './order-lines.js';
11
+ import { order } from './operations.js';
12
+ /** @import { Money } from '../shopify-values.js' */
13
+ /** @import { OrderLine } from './order-lines.js' */
14
+
15
+ /**
16
+ * @typedef {object} Fulfillment
17
+ * @property {string} status
18
+ * @property {string} createdAt
19
+ * @property {string | null} estimatedDeliveryAt
20
+ * @property {string | null} latestShipmentStatus
21
+ * @property {{ company: string | null, number: string | null, url: string | null }[]} trackingInformation
22
+ */
23
+ /**
24
+ * @typedef {object} OrderDetail The order page's document in account-ops.js.
25
+ * @property {string} id
26
+ * @property {string} name
27
+ * @property {string} processedAt
28
+ * @property {string | null} cancelledAt
29
+ * @property {string | null} financialStatus
30
+ * @property {string | null} fulfillmentStatus
31
+ * @property {string | null} statusPageUrl
32
+ * @property {Money} totalPrice
33
+ * @property {Money | null} subtotal
34
+ * @property {Money | null} totalShipping
35
+ * @property {Money | null} totalTax
36
+ * @property {{ formatted: string[] } | null} shippingAddress
37
+ * @property {{ formatted: string[] } | null} billingAddress
38
+ * @property {{ nodes: (OrderLine & { id: string })[] }} lineItems
39
+ * @property {{ nodes: Fulfillment[] }} fulfillments
40
+ * @property {{ returnableLineItems: { nodes: { quantity: number, lineItem: { id: string } | null }[] } } | null}
41
+ * returnInformation
42
+ * @property {{ nodes: { id: string, status: string }[] }} returns
43
+ */
44
+ /** @typedef {'loading' | 'ready' | 'not-found' | 'error'} OrderState */
45
+
46
+ /**
47
+ * @param {HTMLElement} el
48
+ * @returns {string | null} The order `tether-order` names: an id, or `url:<param>` for one in the page's URL.
49
+ */
50
+ function orderKey(el) {
51
+ const raw = get(el, 'order') || '';
52
+ if (!raw.startsWith('url:')) return raw;
53
+ return new URL(window.location.href).searchParams.get(raw.slice('url:'.length) || 'id');
54
+ }
55
+
56
+ /**
57
+ * Every state but `ready`, which shows with the order.
58
+ *
59
+ * @param {HTMLElement} el
60
+ * @param {Exclude<OrderState, 'ready'>} state
61
+ */
62
+ export function showOrderState(el, state) {
63
+ el.setAttribute(attr('order-state'), state);
64
+ showIn(el, 'order-show', {
65
+ loading: state === 'loading',
66
+ ready: false,
67
+ 'not-found': state === 'not-found',
68
+ error: state === 'error',
69
+ });
70
+ }
71
+
72
+ /**
73
+ * @param {OrderDetail} detail
74
+ * @returns {Record<string, string>} The text of each `tether-order-bind` value.
75
+ */
76
+ const orderTexts = (detail) => ({
77
+ name: detail.name,
78
+ date: localDate(detail.processedAt),
79
+ total: formatMoney(detail.totalPrice),
80
+ subtotal: formatMoney(detail.subtotal),
81
+ shipping: formatMoney(detail.totalShipping),
82
+ tax: formatMoney(detail.totalTax),
83
+ status: enumText(detail.financialStatus),
84
+ fulfillment: enumText(detail.fulfillmentStatus),
85
+ 'shipping-address': addressText(detail.shippingAddress),
86
+ 'billing-address': addressText(detail.billingAddress),
87
+ items: detail.lineItems.nodes.map((line) => `${line.quantity}× ${line.title || line.name}`).join(', '),
88
+ cancelled: detail.cancelledAt ? localDate(detail.cancelledAt) : '',
89
+ });
90
+
91
+ /**
92
+ * One shipment row: its status and dates, and its tracking link, hidden without an https one.
93
+ *
94
+ * @param {HTMLElement} row
95
+ * @param {Fulfillment} fulfillment
96
+ */
97
+ function fillFulfillment(row, fulfillment) {
98
+ const tracking = fulfillment.trackingInformation[0];
99
+ /** @type {Record<string, string>} */
100
+ const texts = {
101
+ status: enumText(fulfillment.latestShipmentStatus || fulfillment.status),
102
+ date: localDate(fulfillment.createdAt),
103
+ eta: fulfillment.estimatedDeliveryAt ? localDate(fulfillment.estimatedDeliveryAt) : '',
104
+ company: tracking?.company || '',
105
+ number: tracking?.number || '',
106
+ };
107
+ bindsIn(row, 'fulfillment-bind').forEach((el) => {
108
+ const key = get(el, 'fulfillment-bind') ?? '';
109
+ if (key === 'url') {
110
+ const url = tracking?.url;
111
+ const linked = isHttpsUrl(url);
112
+ if (linked && url) el.setAttribute('href', url);
113
+ show(el, linked);
114
+ return;
115
+ }
116
+ const text = ownValue(texts, key);
117
+ if (text !== undefined) setText(el, text);
118
+ });
119
+ }
120
+
121
+ /**
122
+ * @param {HTMLElement} el
123
+ * @param {OrderDetail} detail
124
+ */
125
+ function fillOrder(el, detail) {
126
+ const returnable = new Map(
127
+ (detail.returnInformation?.returnableLineItems.nodes ?? []).map((entry) => [
128
+ entry.lineItem?.id ?? '',
129
+ entry.quantity,
130
+ ]),
131
+ );
132
+ const shipments = detail.fulfillments.nodes;
133
+ const texts = orderTexts(detail);
134
+ qsa(el, sel('order-bind')).forEach((bind) => {
135
+ const key = get(bind, 'order-bind') ?? '';
136
+ if (key === 'url') {
137
+ if (detail.statusPageUrl && isHttpsUrl(detail.statusPageUrl)) bind.setAttribute('href', detail.statusPageUrl);
138
+ return;
139
+ }
140
+ const text = ownValue(texts, key);
141
+ if (text !== undefined) setText(bind, text);
142
+ });
143
+ qsa(el, sel('order-lines')).forEach((box) => {
144
+ fillLines(box, detail.lineItems.nodes, returnable);
145
+ });
146
+ qsa(el, sel('order-fulfillments')).forEach((box) => {
147
+ repeat(box, shipments, fillFulfillment);
148
+ });
149
+ const returnRequested = el.dataset['tetherReturned'] === '1';
150
+ showIn(el, 'order-show', {
151
+ loading: false,
152
+ ready: true,
153
+ error: false,
154
+ 'not-found': false,
155
+ cancelled: Boolean(detail.cancelledAt),
156
+ shipped: shipments.length > 0,
157
+ returnable: [...returnable.values()].some((quantity) => quantity > 0),
158
+ 'has-returns': !returnRequested && detail.returns.nodes.length > 0,
159
+ 'return-requested': returnRequested,
160
+ });
161
+ el.setAttribute(attr('order-state'), 'ready');
162
+ }
163
+
164
+ /**
165
+ * Loads and renders the order `el` names. A failure is reported and shows the error state.
166
+ *
167
+ * @param {HTMLElement} el
168
+ */
169
+ export async function renderOrder(el) {
170
+ const key = orderKey(el);
171
+ if (!key) {
172
+ el.setAttribute(attr('order-state'), 'not-found');
173
+ showIn(el, 'order-show', { 'not-found': true });
174
+ return;
175
+ }
176
+ showOrderState(el, 'loading');
177
+ /** @type {OrderDetail | null} */
178
+ let detail;
179
+ try {
180
+ detail = await order(key);
181
+ } catch (error) {
182
+ reportError(error, 'order');
183
+ showOrderState(el, 'error');
184
+ return;
185
+ }
186
+ if (!detail) {
187
+ showOrderState(el, 'not-found');
188
+ return;
189
+ }
190
+ el.dataset['tetherOrderId'] = detail.id;
191
+ fillOrder(el, detail);
192
+ }
@@ -0,0 +1,288 @@
1
+ /**
2
+ * What the visitor does on the account pages: the profile and address forms, the address buttons, subscription
3
+ * actions and return requests. Each change marks its form or row busy (`aria-busy`, `is-busy`), then sets
4
+ * `tether-form-state` to `saved` or `error` with Shopify's message in `tether-form-error`.
5
+ */
6
+ import { attr, get, qsa, sel, setText } from '../dom.js';
7
+ import {
8
+ saveAddress,
9
+ deleteAddress,
10
+ subscriptionAction,
11
+ requestReturn,
12
+ updateProfile,
13
+ pagesContext,
14
+ } from './operations.js';
15
+ import { renderAddresses, shownAddress } from './addresses-view.js';
16
+ import { renderSubscriptions } from './subscriptions-view.js';
17
+ import { renderOrder } from './order-page.js';
18
+ import { TetherError } from '../client.js';
19
+ /** @import { ReturnLine } from './operations.js' */
20
+ /** @import { SubscriptionAction } from '../../server/src/shared/account-ops.js' */
21
+
22
+ /** @type {readonly SubscriptionAction[]} */
23
+ const SUBSCRIPTION_ACTIONS = ['pause', 'resume', 'skip', 'unskip', 'cancel'];
24
+
25
+ /**
26
+ * @param {string | null} value
27
+ * @returns {value is SubscriptionAction}
28
+ */
29
+ const isSubscriptionAction = (value) => SUBSCRIPTION_ACTIONS.some((action) => action === value);
30
+
31
+ /**
32
+ * @param {Element} el
33
+ * @returns {el is HTMLInputElement}
34
+ */
35
+ const isInput = (el) => el.tagName === 'INPUT';
36
+
37
+ /**
38
+ * @param {Element} el
39
+ * @returns {el is HTMLFormElement}
40
+ */
41
+ const isForm = (el) => el.tagName === 'FORM';
42
+
43
+ /**
44
+ * @param {HTMLFormElement} form
45
+ * @returns {Record<string, string>} The form's text fields by name.
46
+ */
47
+ function fieldsOf(form) {
48
+ /** @type {Record<string, string>} */
49
+ const fields = {};
50
+ for (const field of form.elements) {
51
+ if (!isInput(field) && field.tagName !== 'SELECT' && field.tagName !== 'TEXTAREA') continue;
52
+ const input = /** @type {HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement} */ (field);
53
+ if (!input.name || input.type === 'submit' || input.type === 'button' || input.type === 'checkbox') continue;
54
+ fields[input.name] = input.value;
55
+ }
56
+ return fields;
57
+ }
58
+
59
+ /**
60
+ * @param {unknown} error
61
+ * @returns {string} What to tell the customer: Shopify's own messages when it refused the input.
62
+ */
63
+ function messageOf(error) {
64
+ const userErrors = error instanceof TetherError ? (error.userErrors ?? []) : [];
65
+ const message = userErrors.map((userError) => userError.message).join(' ');
66
+ if (message) return message;
67
+ const text = error instanceof Error && error.message ? error.message : 'Something went wrong';
68
+ return text.replace(/^Customer accounts: /, '');
69
+ }
70
+
71
+ /**
72
+ * Runs one change for `el`; a second one while it is on its way (Enter, a double click) is ignored. The failure is
73
+ * shown in `el` (`tether-form-error`), the place the customer looks.
74
+ *
75
+ * @param {HTMLElement} el
76
+ * @param {() => Promise<void>} change
77
+ * @returns {Promise<'saved' | 'error' | 'busy'>}
78
+ */
79
+ async function act(el, change) {
80
+ if (el.getAttribute('aria-busy') === 'true') return 'busy';
81
+ el.classList.add('is-busy');
82
+ el.setAttribute('aria-busy', 'true');
83
+ el.setAttribute(attr('form-state'), 'pending');
84
+ const errorBoxes = qsa(el, sel('form-error'));
85
+ errorBoxes.forEach((box) => {
86
+ setText(box, '');
87
+ });
88
+ try {
89
+ await change();
90
+ el.setAttribute(attr('form-state'), 'saved');
91
+ return 'saved';
92
+ } catch (error) {
93
+ el.setAttribute(attr('form-state'), 'error');
94
+ errorBoxes.forEach((box) => {
95
+ setText(box, messageOf(error));
96
+ });
97
+ return 'error';
98
+ } finally {
99
+ el.classList.remove('is-busy');
100
+ el.removeAttribute('aria-busy');
101
+ }
102
+ }
103
+
104
+ /**
105
+ * @param {Element} button
106
+ * @returns {boolean} True when the button asks no `tether-confirm` question, or the visitor agreed.
107
+ */
108
+ const confirmed = (button) => {
109
+ const question = get(button, 'confirm');
110
+ return !question || window.confirm(question);
111
+ };
112
+
113
+ /** @param {HTMLFormElement} form */
114
+ const addressFormReset = (form) => {
115
+ form.reset();
116
+ delete form.dataset['tetherAddressId'];
117
+ };
118
+
119
+ /** @param {SubmitEvent} event */
120
+ function onSubmit(event) {
121
+ const form = event.target;
122
+ if (!(form instanceof window.HTMLFormElement)) return;
123
+ if (form.matches(sel('profile-form'))) {
124
+ event.preventDefault();
125
+ void act(form, async () => {
126
+ await updateProfile(fieldsOf(form));
127
+ await pagesContext().bindCustomer(document);
128
+ });
129
+ } else if (form.matches(sel('address-form'))) {
130
+ event.preventDefault();
131
+ const id = form.dataset['tetherAddressId'];
132
+ const box = form.elements.namedItem('isDefault');
133
+ const isDefault = box instanceof window.HTMLInputElement && box.checked;
134
+ void act(form, async () => {
135
+ await saveAddress(fieldsOf(form), { id, isDefault });
136
+ addressFormReset(form);
137
+ await renderAddresses(document);
138
+ });
139
+ }
140
+ }
141
+
142
+ /**
143
+ * Fills the address form with a shown address, to edit it.
144
+ *
145
+ * @param {string} id
146
+ */
147
+ function editAddress(id) {
148
+ const address = shownAddress(id);
149
+ const form = document.querySelector(sel('address-form'));
150
+ if (!address || !form || !isForm(form)) return;
151
+ form.dataset['tetherAddressId'] = address.id;
152
+ for (const field of form.elements) {
153
+ if (!isInput(field) && field.tagName !== 'SELECT' && field.tagName !== 'TEXTAREA') continue;
154
+ const input = /** @type {HTMLInputElement} */ (field);
155
+ if (input.name === 'isDefault') input.checked = address.isDefault;
156
+ else if (input.name && Object.hasOwn(address, input.name)) {
157
+ const value = Object.entries(address).find(([key]) => key === input.name)?.[1];
158
+ input.value = typeof value === 'string' ? value : '';
159
+ }
160
+ }
161
+ form.scrollIntoView({ block: 'nearest' });
162
+ }
163
+
164
+ /**
165
+ * @param {MouseEvent} event
166
+ * @param {Element} target
167
+ * @returns {boolean} True when the click was on an address button.
168
+ */
169
+ function onAddressClick(event, target) {
170
+ const row = target.closest('[data-tether-address]');
171
+ const id = row instanceof window.HTMLElement ? row.dataset['tetherAddress'] : undefined;
172
+ if (target.closest(sel('address-new'))) {
173
+ const form = document.querySelector(sel('address-form'));
174
+ if (form && isForm(form)) addressFormReset(form);
175
+ return true;
176
+ }
177
+ if (!(row instanceof window.HTMLElement) || !id) return false;
178
+ const deleteButton = target.closest(sel('address-delete'));
179
+ if (deleteButton) {
180
+ event.preventDefault();
181
+ if (!confirmed(deleteButton)) return true;
182
+ void act(row, async () => {
183
+ await deleteAddress(id);
184
+ await renderAddresses(document);
185
+ });
186
+ } else if (target.closest(sel('address-default'))) {
187
+ event.preventDefault();
188
+ void act(row, async () => {
189
+ await saveAddress(null, { id, isDefault: true });
190
+ await renderAddresses(document);
191
+ });
192
+ } else if (target.closest(sel('address-edit'))) {
193
+ event.preventDefault();
194
+ editAddress(id);
195
+ } else {
196
+ return false;
197
+ }
198
+ return true;
199
+ }
200
+
201
+ /** @param {HTMLElement} button A `tether-subscription-action` button. */
202
+ function onSubscriptionAction(button) {
203
+ const row = button.closest('[data-tether-subscription]');
204
+ const action = get(button, 'subscription-action');
205
+ const disabled = ('disabled' in button && button.disabled === true) || button.classList.contains('is-disabled');
206
+ if (!(row instanceof window.HTMLElement) || !isSubscriptionAction(action) || disabled || !confirmed(button)) return;
207
+ const id = row.dataset['tetherSubscription'] ?? '';
208
+ row.setAttribute(attr('subscription-state'), 'pending');
209
+ void act(row, async () => {
210
+ await subscriptionAction(id, action);
211
+ await renderSubscriptions(document);
212
+ }).then((outcome) => {
213
+ const state = outcome === 'error' ? 'error' : 'done';
214
+ // The re-render may have replaced the row: mark the one that shows this subscription now.
215
+ qsa(document, '[data-tether-subscription]')
216
+ .filter((candidate) => candidate === row || candidate.dataset['tetherSubscription'] === id)
217
+ .forEach((candidate) => {
218
+ candidate.setAttribute(attr('subscription-state'), state);
219
+ });
220
+ });
221
+ }
222
+
223
+ /**
224
+ * @param {HTMLElement} row A line row of the order page.
225
+ * @returns {ReturnLine}
226
+ */
227
+ function returnLineOf(row) {
228
+ const quantity = row.querySelector(sel('return-quantity'));
229
+ const reason = row.querySelector(sel('return-reason'));
230
+ const note = row.querySelector(sel('return-note'));
231
+ /** @param {Element | null} field */
232
+ const valueOf = (field) => (field && 'value' in field && typeof field.value === 'string' ? field.value : '');
233
+ /** @type {ReturnLine} */
234
+ const line = { lineItemId: row.dataset['tetherLine'] ?? '', quantity: Number.parseInt(valueOf(quantity) || '0', 10) };
235
+ const reasonId = valueOf(reason);
236
+ if (reasonId) line.reasonId = reasonId;
237
+ const noteText = valueOf(note);
238
+ if (noteText) line.note = noteText;
239
+ return line;
240
+ }
241
+
242
+ /** @param {HTMLElement} button A `tether-order-return` button. */
243
+ function onReturnRequest(button) {
244
+ const scope = button.closest(sel('order'));
245
+ if (!(scope instanceof window.HTMLElement)) return;
246
+ const orderId = scope.dataset['tetherOrderId'];
247
+ if (!orderId) return;
248
+ const lines = qsa(scope, '[data-tether-line]')
249
+ .map(returnLineOf)
250
+ .filter((line) => line.quantity > 0);
251
+ if (!lines.length || !confirmed(button)) return;
252
+ void act(scope, async () => {
253
+ await requestReturn(orderId, lines);
254
+ scope.dataset['tetherReturned'] = '1';
255
+ await renderOrder(scope);
256
+ });
257
+ }
258
+
259
+ /** @param {MouseEvent} event */
260
+ function onClick(event) {
261
+ const target = event.target;
262
+ if (!(target instanceof window.Element)) return;
263
+ if (onAddressClick(event, target)) return;
264
+ const subscriptionButton = target.closest(sel('subscription-action'));
265
+ if (subscriptionButton instanceof window.HTMLElement) {
266
+ event.preventDefault();
267
+ onSubscriptionAction(subscriptionButton);
268
+ return;
269
+ }
270
+ const returnButton = target.closest(sel('order-return'));
271
+ if (returnButton instanceof window.HTMLElement) {
272
+ event.preventDefault();
273
+ onReturnRequest(returnButton);
274
+ }
275
+ }
276
+
277
+ /** Listens on the document once, for every account page rendered into it. */
278
+ export function listenToPages() {
279
+ // Capture: before a router (Astro's ClientRouter) turns the submit into a navigation with the fields in the URL.
280
+ document.addEventListener('submit', onSubmit, true);
281
+ document.addEventListener('input', (event) => {
282
+ const target = event.target;
283
+ if (target instanceof window.HTMLElement && target.closest(sel('profile-form'))) {
284
+ target.dataset['tetherTouched'] = '1';
285
+ }
286
+ });
287
+ document.addEventListener('click', onClick);
288
+ }