@occluze/tether 0.6.6 → 0.6.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -3
- package/dist/esm/astro.js +2 -2
- package/dist/esm/chunks/account-C3NP6HFB.js +1 -0
- package/dist/esm/chunks/account-pages-WLK374FO.js +1 -0
- package/dist/esm/chunks/analytics-G3SAWMNL.js +1 -0
- package/dist/esm/chunks/chunk-27PJJZGC.js +1 -0
- package/dist/esm/chunks/chunk-2FSXS55U.js +1 -0
- package/dist/esm/chunks/chunk-FCSWQDVV.js +1 -0
- package/dist/esm/chunks/chunk-FOMSMIAM.js +1 -0
- package/dist/esm/chunks/chunk-GZJTCLLW.js +1 -0
- package/dist/esm/chunks/chunk-HEQILZVR.js +1 -0
- package/dist/esm/chunks/chunk-JOOEXYF3.js +4 -0
- package/dist/esm/chunks/chunk-MPTQYLSM.js +1 -0
- package/dist/esm/chunks/chunk-NEHZ37TU.js +3 -0
- package/dist/esm/chunks/chunk-V74KGEJF.js +1 -0
- package/dist/esm/chunks/chunk-VT4CDUJJ.js +1 -0
- package/dist/esm/chunks/chunk-YJLWX2HK.js +1 -0
- package/dist/esm/chunks/diagnose-6VVGZNTY.js +14 -0
- package/dist/esm/chunks/extras-GJVRDFM3.js +1 -0
- package/dist/esm/chunks/markets-ui-UNB4PTA7.js +1 -0
- package/dist/esm/chunks/search-OU75RNQL.js +1 -0
- package/dist/esm/index.js +1 -1
- package/dist/esm/server.js +2 -1
- package/dist/presets/barba.esm.js +56 -36
- package/dist/tether.account-pages.js +776 -432
- package/dist/tether.account-pages.min.js +1 -1
- package/dist/tether.account.js +655 -321
- package/dist/tether.account.min.js +1 -1
- package/dist/tether.analytics.js +318 -191
- package/dist/tether.analytics.min.js +1 -1
- package/dist/tether.diagnose.js +334 -243
- package/dist/tether.diagnose.min.js +2 -2
- package/dist/tether.extras.js +78 -33
- package/dist/tether.extras.min.js +1 -1
- package/dist/tether.js +3171 -1998
- package/dist/tether.markets.js +231 -126
- package/dist/tether.markets.min.js +1 -1
- package/dist/tether.min.js +0 -0
- package/dist/tether.min.js.map +4 -4
- package/dist/tether.search.js +221 -126
- package/dist/tether.search.min.js +1 -1
- package/package.json +23 -4
- package/src/account/addresses-view.js +92 -0
- package/src/account/browser-session.js +359 -0
- package/src/account/markup.js +48 -0
- package/src/account/operations.js +180 -0
- package/src/account/order-lines.js +96 -0
- package/src/account/order-list.js +91 -0
- package/src/account/order-page.js +192 -0
- package/src/account/page-events.js +288 -0
- package/src/account/server-client.js +172 -0
- package/src/account/shared.js +111 -0
- package/src/account/shopify-auth.js +150 -0
- package/src/account/subscriptions-view.js +158 -0
- package/src/account-api.js +118 -0
- package/src/account-links.js +35 -0
- package/src/account-pages.js +68 -284
- package/src/account.js +281 -317
- package/src/amounts.js +36 -0
- package/src/analytics-shopify.js +241 -0
- package/src/analytics.js +304 -184
- package/src/api-version.js +75 -25
- package/src/astro.js +233 -68
- package/src/cart/checkout.js +97 -0
- package/src/cart/drawer.js +138 -0
- package/src/cart/mutations.js +234 -0
- package/src/cart/render.js +294 -0
- package/src/cart/store.js +326 -0
- package/src/cart.js +322 -481
- package/src/client.js +214 -78
- package/src/collection.js +126 -57
- package/src/config.js +323 -59
- package/src/controls/cart-controls.js +155 -0
- package/src/controls/discount.js +96 -0
- package/src/controls/extras.js +63 -0
- package/src/controls/navigation.js +83 -0
- package/src/diagnose-markup.js +333 -0
- package/src/diagnose.js +251 -202
- package/src/dom.js +219 -220
- package/src/extras.js +119 -37
- package/src/fragments.js +115 -22
- package/src/images.js +104 -0
- package/src/index.js +245 -285
- package/src/initial.js +78 -66
- package/src/lazy-iife.js +92 -22
- package/src/lazy.js +25 -4
- package/src/loader.js +263 -124
- package/src/market-codes.js +35 -0
- package/src/markets-ui.js +169 -55
- package/src/markets.js +286 -85
- package/src/markup.js +64 -0
- package/src/mounting.js +214 -91
- package/src/page-market.js +116 -0
- package/src/parts/account-pages.js +4 -1
- package/src/parts/account.js +4 -1
- package/src/parts/analytics.js +4 -1
- package/src/parts/diagnose.js +4 -1
- package/src/parts/extras.js +4 -1
- package/src/parts/markets.js +4 -1
- package/src/parts/search.js +4 -1
- package/src/presets/barba.js +120 -30
- package/src/product-events.js +130 -0
- package/src/product-render.js +257 -0
- package/src/product-state.js +318 -0
- package/src/product.js +233 -459
- package/src/repeaters.js +173 -0
- package/src/search.js +277 -107
- package/src/selling-plans.js +107 -47
- package/src/server.js +168 -205
- package/src/shopify-values.js +89 -0
- package/src/status.js +99 -39
- package/src/store-html.js +0 -0
- package/src/storefront/catalog.js +199 -0
- package/src/storefront/json-ld.js +167 -0
- package/src/variant-render.js +165 -0
- package/types/astro.d.ts +13 -2
- package/types/index.d.ts +178 -27
- package/types/server.d.ts +89 -17
- package/dist/esm/chunks/account-QUBHQG3C.js +0 -1
- package/dist/esm/chunks/account-pages-VWDHF6WA.js +0 -1
- package/dist/esm/chunks/analytics-SLATWPLC.js +0 -1
- package/dist/esm/chunks/chunk-64TUD3QR.js +0 -1
- package/dist/esm/chunks/chunk-AJLPU2JC.js +0 -4
- package/dist/esm/chunks/chunk-FIOZPPC5.js +0 -1
- package/dist/esm/chunks/chunk-K2YEQAIH.js +0 -1
- package/dist/esm/chunks/chunk-L2TRRKYV.js +0 -1
- package/dist/esm/chunks/chunk-RREHGH4A.js +0 -1
- package/dist/esm/chunks/chunk-VC46IEJQ.js +0 -1
- package/dist/esm/chunks/chunk-VCPI7MJ2.js +0 -1
- package/dist/esm/chunks/chunk-VPVJPNB4.js +0 -1
- package/dist/esm/chunks/chunk-ZIUIWEON.js +0 -1
- package/dist/esm/chunks/diagnose-VXQBSD3A.js +0 -14
- package/dist/esm/chunks/extras-R4X34VQL.js +0 -1
- package/dist/esm/chunks/markets-ui-F3R5T5EL.js +0 -1
- package/dist/esm/chunks/search-NILMGYXG.js +0 -1
|
@@ -0,0 +1,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
|
+
}
|