@occluze/tether 0.6.7 → 0.7.0
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 +11 -11
- package/dist/esm/astro.js +2 -2
- package/dist/esm/chunks/account-UIPWJQ3Y.js +1 -0
- package/dist/esm/chunks/account-pages-6ROA6Y4V.js +1 -0
- package/dist/esm/chunks/analytics-2V632IRV.js +1 -0
- package/dist/esm/chunks/chunk-4GCXM764.js +1 -0
- package/dist/esm/chunks/chunk-56FCLJJD.js +1 -0
- package/dist/esm/chunks/chunk-5Z5O3767.js +1 -0
- package/dist/esm/chunks/chunk-6OZJCAGZ.js +1 -0
- package/dist/esm/chunks/chunk-A5SYCVQM.js +1 -0
- package/dist/esm/chunks/chunk-BA4LQJJQ.js +1 -0
- package/dist/esm/chunks/chunk-BFTZR4OB.js +4 -0
- package/dist/esm/chunks/chunk-C5JYQCWH.js +1 -0
- package/dist/esm/chunks/chunk-E5YO6274.js +1 -0
- package/dist/esm/chunks/chunk-MPDWM2NN.js +1 -0
- package/dist/esm/chunks/chunk-T44MUL4Y.js +1 -0
- package/dist/esm/chunks/chunk-YLUULATY.js +1 -0
- package/dist/esm/chunks/diagnose-3FEKRIEK.js +14 -0
- package/dist/esm/chunks/extras-6POFBNOP.js +1 -0
- package/dist/esm/chunks/markets-ui-FD6NI2OG.js +1 -0
- package/dist/esm/chunks/search-UJ73YBQ5.js +1 -0
- package/dist/esm/index.js +1 -1
- package/dist/esm/server.js +2 -1
- package/dist/presets/barba.esm.js +55 -39
- package/dist/tether.account-pages.js +781 -495
- package/dist/tether.account-pages.min.js +1 -1
- package/dist/tether.account.js +462 -381
- package/dist/tether.account.min.js +1 -1
- package/dist/tether.analytics.js +299 -194
- package/dist/tether.analytics.min.js +1 -1
- package/dist/tether.diagnose.js +354 -262
- package/dist/tether.diagnose.min.js +2 -2
- package/dist/tether.extras.js +82 -45
- package/dist/tether.extras.min.js +1 -1
- package/dist/tether.js +3653 -2116
- package/dist/tether.markets.js +238 -150
- package/dist/tether.markets.min.js +1 -1
- package/dist/tether.min.js +3 -3
- package/dist/tether.min.js.map +4 -4
- package/dist/tether.search.js +207 -136
- package/dist/tether.search.min.js +1 -1
- package/dist/types/server/src/shared/account-orders.d.ts +44 -0
- package/dist/types/server/src/shared/account-routes.d.ts +153 -0
- package/dist/types/server/src/shared/api-versions.d.ts +13 -0
- package/dist/types/server/src/shared/error-message.d.ts +2 -0
- package/dist/types/server/src/shared/json.d.ts +25 -0
- package/dist/types/server/src/shared/market-codes.d.ts +16 -0
- package/dist/types/src/account/addresses-view.d.ts +5 -0
- package/dist/types/src/account/answers.d.ts +40 -0
- package/dist/types/src/account/markup.d.ts +19 -0
- package/dist/types/src/account/operations.d.ts +41 -0
- package/dist/types/src/account/order-lines.d.ts +13 -0
- package/dist/types/src/account/order-list.d.ts +12 -0
- package/dist/types/src/account/order-page.d.ts +6 -0
- package/dist/types/src/account/page-answers.d.ts +182 -0
- package/dist/types/src/account/page-events.d.ts +2 -0
- package/dist/types/src/account/server-client.d.ts +60 -0
- package/dist/types/src/account/subscriptions-view.d.ts +2 -0
- package/dist/types/src/account-api.d.ts +18 -0
- package/dist/types/src/account-marks.d.ts +15 -0
- package/dist/types/src/account-pages.d.ts +8 -0
- package/dist/types/src/account.d.ts +64 -0
- package/dist/types/src/amounts.d.ts +12 -0
- package/dist/types/src/analytics-shopify.d.ts +41 -0
- package/dist/types/src/analytics.d.ts +2 -0
- package/dist/types/src/api-version.d.ts +28 -0
- package/dist/types/src/astro.d.ts +42 -0
- package/dist/types/src/cart/cart-shape.d.ts +43 -0
- package/dist/types/src/cart/checkout.d.ts +16 -0
- package/dist/types/src/cart/drawer.d.ts +18 -0
- package/dist/types/src/cart/mutations.d.ts +79 -0
- package/dist/types/src/cart/render.d.ts +17 -0
- package/dist/types/src/cart/store.d.ts +182 -0
- package/dist/types/src/cart.d.ts +106 -0
- package/dist/types/src/client.d.ts +100 -0
- package/dist/types/src/collection.d.ts +25 -0
- package/dist/types/src/config-sections.d.ts +40 -0
- package/dist/types/src/config.d.ts +185 -0
- package/dist/types/src/controls/cart-controls.d.ts +2 -0
- package/dist/types/src/controls/discount.d.ts +2 -0
- package/dist/types/src/controls/extras.d.ts +5 -0
- package/dist/types/src/controls/navigation.d.ts +12 -0
- package/dist/types/src/diagnose-markup.d.ts +19 -0
- package/dist/types/src/diagnose.d.ts +60 -0
- package/dist/types/src/dom.d.ts +75 -0
- package/dist/types/src/extras.d.ts +21 -0
- package/dist/types/src/fragments.d.ts +158 -0
- package/dist/types/src/globals.d.ts +15 -0
- package/dist/types/src/images.d.ts +20 -0
- package/dist/types/src/index.d.ts +167 -0
- package/dist/types/src/initial.d.ts +56 -0
- package/dist/types/src/lazy.d.ts +28 -0
- package/dist/types/src/loader.d.ts +84 -0
- package/dist/types/src/market-codes.d.ts +6 -0
- package/dist/types/src/markets-ui.d.ts +32 -0
- package/dist/types/src/markets.d.ts +72 -0
- package/dist/types/src/markup.d.ts +131 -0
- package/dist/types/src/mounting.d.ts +46 -0
- package/dist/types/src/page-market.d.ts +27 -0
- package/dist/types/src/presets/barba.d.ts +56 -0
- package/dist/types/src/product-events.d.ts +2 -0
- package/dist/types/src/product-render.d.ts +17 -0
- package/dist/types/src/product-shape.d.ts +88 -0
- package/dist/types/src/product-state.d.ts +110 -0
- package/dist/types/src/product.d.ts +66 -0
- package/dist/types/src/repeaters.d.ts +16 -0
- package/dist/types/src/search.d.ts +33 -0
- package/dist/types/src/selling-plans.d.ts +64 -0
- package/dist/types/src/server.d.ts +88 -0
- package/dist/types/src/shopify-values.d.ts +46 -0
- package/dist/types/src/status.d.ts +33 -0
- package/dist/types/src/storage.d.ts +12 -0
- package/dist/types/src/store-html.d.ts +39 -0
- package/dist/types/src/storefront/catalog.d.ts +38 -0
- package/dist/types/src/storefront/json-ld.d.ts +20 -0
- package/dist/types/src/variant-render.d.ts +14 -0
- package/package.json +42 -22
- package/src/account/addresses-view.ts +58 -0
- package/src/account/answers.ts +79 -0
- package/src/account/markup.ts +36 -0
- package/src/account/operations.ts +145 -0
- package/src/account/order-lines.ts +75 -0
- package/src/account/order-list.ts +107 -0
- package/src/account/order-page.ts +137 -0
- package/src/account/page-answers.ts +317 -0
- package/src/account/page-events.ts +253 -0
- package/src/account/server-client.ts +183 -0
- package/src/account/subscriptions-view.ts +115 -0
- package/src/account-api.ts +98 -0
- package/src/account-marks.ts +35 -0
- package/src/account-pages.ts +81 -0
- package/src/account.ts +284 -0
- package/src/amounts.ts +64 -0
- package/src/analytics-shopify.ts +246 -0
- package/src/analytics.ts +315 -0
- package/src/api-version.ts +79 -0
- package/src/astro.ts +272 -0
- package/src/cart/cart-shape.ts +25 -0
- package/src/cart/checkout.ts +101 -0
- package/src/cart/drawer.ts +125 -0
- package/src/cart/mutations.ts +237 -0
- package/src/cart/render.ts +266 -0
- package/src/cart/store.ts +279 -0
- package/src/cart.ts +345 -0
- package/src/client.ts +391 -0
- package/src/collection.ts +175 -0
- package/src/config-sections.ts +284 -0
- package/src/config.ts +480 -0
- package/src/controls/cart-controls.ts +123 -0
- package/src/controls/discount.ts +75 -0
- package/src/controls/extras.ts +54 -0
- package/src/controls/navigation.ts +77 -0
- package/src/diagnose-markup.ts +298 -0
- package/src/diagnose.ts +294 -0
- package/src/dom.ts +268 -0
- package/src/extras.ts +136 -0
- package/src/fragments.ts +261 -0
- package/src/globals.d.ts +15 -0
- package/src/images.ts +91 -0
- package/src/index.ts +415 -0
- package/src/initial.ts +101 -0
- package/src/lazy-iife.ts +197 -0
- package/src/lazy.ts +42 -0
- package/src/loader.ts +363 -0
- package/src/market-codes.ts +6 -0
- package/src/markets-ui.ts +227 -0
- package/src/markets.ts +246 -0
- package/src/markup.ts +127 -0
- package/src/mounting.ts +264 -0
- package/src/page-market.ts +110 -0
- package/src/part-integrity.ts +7 -0
- package/src/parts/account-pages.ts +7 -0
- package/src/parts/account.ts +7 -0
- package/src/parts/analytics.ts +7 -0
- package/src/parts/diagnose.ts +7 -0
- package/src/parts/extras.ts +7 -0
- package/src/parts/markets.ts +7 -0
- package/src/parts/search.ts +7 -0
- package/src/presets/barba.ts +194 -0
- package/src/product-events.ts +115 -0
- package/src/product-render.ts +218 -0
- package/src/product-shape.ts +49 -0
- package/src/product-state.ts +318 -0
- package/src/product.ts +308 -0
- package/src/repeaters.ts +161 -0
- package/src/search.ts +305 -0
- package/src/selling-plans.ts +145 -0
- package/src/server.ts +211 -0
- package/src/shopify-values.ts +91 -0
- package/src/status.ts +106 -0
- package/src/storage.ts +34 -0
- package/src/store-html.ts +359 -0
- package/src/storefront/catalog.ts +196 -0
- package/src/storefront/json-ld.ts +144 -0
- package/src/variant-render.ts +139 -0
- package/dist/esm/chunks/account-Q6E2TFEV.js +0 -1
- package/dist/esm/chunks/account-pages-UZXR4R5F.js +0 -1
- package/dist/esm/chunks/analytics-IJBKATUP.js +0 -1
- package/dist/esm/chunks/chunk-4QJKLPYR.js +0 -1
- package/dist/esm/chunks/chunk-64TUD3QR.js +0 -1
- package/dist/esm/chunks/chunk-AJLPU2JC.js +0 -4
- package/dist/esm/chunks/chunk-FIOZPPC5.js +0 -1
- package/dist/esm/chunks/chunk-K2YEQAIH.js +0 -1
- package/dist/esm/chunks/chunk-KIDH76B7.js +0 -1
- package/dist/esm/chunks/chunk-PAYT5ZD7.js +0 -1
- package/dist/esm/chunks/chunk-RREHGH4A.js +0 -1
- package/dist/esm/chunks/chunk-SSJT43CJ.js +0 -1
- package/dist/esm/chunks/chunk-VC46IEJQ.js +0 -1
- package/dist/esm/chunks/diagnose-3WVAWNTX.js +0 -14
- package/dist/esm/chunks/extras-7I7WJX35.js +0 -1
- package/dist/esm/chunks/markets-ui-545XLFTE.js +0 -1
- package/dist/esm/chunks/search-NILMGYXG.js +0 -1
- package/src/account-pages.js +0 -318
- package/src/account.js +0 -385
- package/src/analytics.js +0 -232
- package/src/api-version.js +0 -35
- package/src/astro.js +0 -101
- package/src/cart.js +0 -559
- package/src/client.js +0 -131
- package/src/collection.js +0 -114
- package/src/config.js +0 -85
- package/src/diagnose.js +0 -246
- package/src/dom.js +0 -288
- package/src/extras.js +0 -63
- package/src/fragments.js +0 -144
- package/src/index.js +0 -395
- package/src/initial.js +0 -75
- package/src/lazy-iife.js +0 -46
- package/src/lazy.js +0 -18
- package/src/loader.js +0 -198
- package/src/markets-ui.js +0 -90
- package/src/markets.js +0 -133
- package/src/mounting.js +0 -154
- package/src/parts/account-pages.js +0 -3
- package/src/parts/account.js +0 -3
- package/src/parts/analytics.js +0 -3
- package/src/parts/diagnose.js +0 -3
- package/src/parts/extras.js +0 -3
- package/src/parts/markets.js +0 -3
- package/src/parts/search.js +0 -3
- package/src/presets/barba.js +0 -100
- package/src/product.js +0 -570
- package/src/search.js +0 -153
- package/src/selling-plans.js +0 -94
- package/src/server.js +0 -252
- package/src/status.js +0 -66
- package/types/astro.d.ts +0 -17
- package/types/barba.d.ts +0 -16
- package/types/index.d.ts +0 -426
- package/types/server.d.ts +0 -110
|
@@ -0,0 +1,107 @@
|
|
|
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 { cfg, reportError } from '../config.ts';
|
|
6
|
+
import { isHttpsUrl } from '../shopify-values.ts';
|
|
7
|
+
import { formatDate, formatMoney, get, qsa, repeat, sel, setImage, setText } from '../dom.ts';
|
|
8
|
+
import { ownValue, showIn } from './markup.ts';
|
|
9
|
+
import { fillLines } from './order-lines.ts';
|
|
10
|
+
import type { AccountOrder } from '../../server/src/shared/account-orders.ts';
|
|
11
|
+
|
|
12
|
+
/** How many orders `orders()` answers, and a `tether-orders` list shows, without being told. */
|
|
13
|
+
export const DEFAULT_ORDER_COUNT = 10;
|
|
14
|
+
/** The most orders one request asks for: the Worker answers no more. */
|
|
15
|
+
export const MAX_ORDER_COUNT = 50;
|
|
16
|
+
|
|
17
|
+
/** @returns True for a number of orders one request can ask for: a whole number from 1 to `MAX_ORDER_COUNT`. */
|
|
18
|
+
export const isOrderCount = (value: unknown): value is number =>
|
|
19
|
+
typeof value === 'number' && Number.isInteger(value) && value >= 1 && value <= MAX_ORDER_COUNT;
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* @param limit The list's `tether-limit`.
|
|
23
|
+
* @returns How many orders the list shows: the limit, kept within what one request can ask for.
|
|
24
|
+
*/
|
|
25
|
+
function listLimit(limit: string | null): number {
|
|
26
|
+
const asked = Number.parseInt(limit || String(DEFAULT_ORDER_COUNT), 10) || DEFAULT_ORDER_COUNT;
|
|
27
|
+
return Math.min(Math.max(asked, 1), MAX_ORDER_COUNT);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* @param gid `gid://shopify/Order/123`, possibly with a query.
|
|
32
|
+
* @returns The numeric id at its end.
|
|
33
|
+
*/
|
|
34
|
+
const numericId = (gid: string): string => gid.replace(/\?.*$/, '').replace(/^.*\//, '');
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* The order's page: the site's own (`account.orderUrl`, e.g. `/account/order?id={id}`, every `{id}` the numeric
|
|
38
|
+
* order id), else Shopify's status page when it is https.
|
|
39
|
+
*/
|
|
40
|
+
function orderUrl(order: AccountOrder): string | null {
|
|
41
|
+
const template = cfg.account?.orderUrl;
|
|
42
|
+
if (template) return template.replaceAll('{id}', encodeURIComponent(numericId(order.id)));
|
|
43
|
+
const statusPage = order.statusPageUrl;
|
|
44
|
+
return statusPage !== null && isHttpsUrl(statusPage) ? statusPage : null;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** @returns The text of each `tether-order-bind` value. */
|
|
48
|
+
const orderTexts = (order: AccountOrder): Record<string, string | undefined> => ({
|
|
49
|
+
name: order.name ?? '',
|
|
50
|
+
date: formatDate(order.processedAt),
|
|
51
|
+
total: formatMoney(order.totalPrice),
|
|
52
|
+
status: order.financialStatus?.toLowerCase(),
|
|
53
|
+
fulfillment: order.fulfillmentStatus?.toLowerCase(),
|
|
54
|
+
items: order.lineItems.nodes.map((line) => `${String(line.quantity)}× ${line.title ?? ''}`).join(', '),
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
/** @param node A copy of the list's template. */
|
|
58
|
+
function fillOrder(node: HTMLElement, order: AccountOrder): void {
|
|
59
|
+
const lines = order.lineItems.nodes;
|
|
60
|
+
const url = orderUrl(order);
|
|
61
|
+
const texts = orderTexts(order);
|
|
62
|
+
qsa(node, sel('order-bind')).forEach((el) => {
|
|
63
|
+
const key = get(el, 'order-bind') ?? '';
|
|
64
|
+
if (key === 'url') {
|
|
65
|
+
if (url) el.setAttribute('href', url);
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
if (key === 'image') {
|
|
69
|
+
const image = lines.find((line) => line.image)?.image;
|
|
70
|
+
if (image) setImage(el, image.url, image.altText || lines[0]?.title || '');
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
const text = ownValue(texts, key);
|
|
74
|
+
if (text !== undefined) setText(el, text);
|
|
75
|
+
});
|
|
76
|
+
qsa(node, sel('order-lines')).forEach((box) => {
|
|
77
|
+
fillLines(box, lines, null);
|
|
78
|
+
});
|
|
79
|
+
if (get(node, 'order-bind') === 'url' && url) node.setAttribute('href', url);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Fills every `tether-orders` list in `root`. A failure is reported and leaves the lists empty, without claiming the
|
|
84
|
+
* customer has no orders.
|
|
85
|
+
*/
|
|
86
|
+
export async function renderOrderList(
|
|
87
|
+
root: ParentNode,
|
|
88
|
+
loadOrders: (first: number) => Promise<AccountOrder[]>,
|
|
89
|
+
): Promise<void> {
|
|
90
|
+
const lists = qsa(root, sel('orders'));
|
|
91
|
+
const first = lists[0];
|
|
92
|
+
if (!first) return;
|
|
93
|
+
showIn(root, 'show', { 'orders-loading': true, 'orders-empty': false });
|
|
94
|
+
const limit = listLimit(get(first, 'limit'));
|
|
95
|
+
/** `null` after a failure. */
|
|
96
|
+
let orders: AccountOrder[] | null;
|
|
97
|
+
try {
|
|
98
|
+
orders = await loadOrders(limit);
|
|
99
|
+
} catch (error) {
|
|
100
|
+
reportError(error, 'orders');
|
|
101
|
+
orders = null;
|
|
102
|
+
}
|
|
103
|
+
showIn(root, 'show', { 'orders-loading': false, 'orders-empty': orders?.length === 0 });
|
|
104
|
+
lists.forEach((list) => {
|
|
105
|
+
repeat(list, orders ?? [], fillOrder);
|
|
106
|
+
});
|
|
107
|
+
}
|
|
@@ -0,0 +1,137 @@
|
|
|
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.ts';
|
|
7
|
+
import { isHttpsUrl } from '../shopify-values.ts';
|
|
8
|
+
import { attr, formatDate, formatMoney, get, qsa, repeat, sel, setText, show } from '../dom.ts';
|
|
9
|
+
import { addressText, bindsIn, enumText, ownValue, showIn } from './markup.ts';
|
|
10
|
+
import { fillLines } from './order-lines.ts';
|
|
11
|
+
import { order } from './operations.ts';
|
|
12
|
+
import type { Fulfillment, OrderDetail } from './page-answers.ts';
|
|
13
|
+
|
|
14
|
+
type OrderState = 'loading' | 'ready' | 'not-found' | 'error';
|
|
15
|
+
|
|
16
|
+
/** @returns The order `tether-order` names: an id, or `url:<param>` for one in the page's URL. */
|
|
17
|
+
function orderKey(el: HTMLElement): string | null {
|
|
18
|
+
const raw = get(el, 'order') || '';
|
|
19
|
+
if (!raw.startsWith('url:')) return raw;
|
|
20
|
+
return new URL(window.location.href).searchParams.get(raw.slice('url:'.length) || 'id');
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Every state but `ready`, which shows with the order. */
|
|
24
|
+
export function showOrderState(el: HTMLElement, state: Exclude<OrderState, 'ready'>): void {
|
|
25
|
+
el.setAttribute(attr('order-state'), state);
|
|
26
|
+
showIn(el, 'order-show', {
|
|
27
|
+
loading: state === 'loading',
|
|
28
|
+
ready: false,
|
|
29
|
+
'not-found': state === 'not-found',
|
|
30
|
+
error: state === 'error',
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** @returns The text of each `tether-order-bind` value. */
|
|
35
|
+
const orderTexts = (detail: OrderDetail): Record<string, string> => ({
|
|
36
|
+
name: detail.name ?? '',
|
|
37
|
+
date: formatDate(detail.processedAt),
|
|
38
|
+
total: formatMoney(detail.totalPrice),
|
|
39
|
+
subtotal: formatMoney(detail.subtotal),
|
|
40
|
+
shipping: formatMoney(detail.totalShipping),
|
|
41
|
+
tax: formatMoney(detail.totalTax),
|
|
42
|
+
status: enumText(detail.financialStatus),
|
|
43
|
+
fulfillment: enumText(detail.fulfillmentStatus),
|
|
44
|
+
'shipping-address': addressText(detail.shippingAddress),
|
|
45
|
+
'billing-address': addressText(detail.billingAddress),
|
|
46
|
+
items: detail.lineItems.nodes.map((line) => `${String(line.quantity)}× ${line.title || line.name || ''}`).join(', '),
|
|
47
|
+
cancelled: formatDate(detail.cancelledAt),
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
/** One shipment row: its status and dates, and its tracking link, hidden without an https one. */
|
|
51
|
+
function fillFulfillment(row: HTMLElement, fulfillment: Fulfillment): void {
|
|
52
|
+
const tracking = fulfillment.trackingInformation[0];
|
|
53
|
+
const texts: Record<string, string> = {
|
|
54
|
+
status: enumText(fulfillment.latestShipmentStatus || fulfillment.status),
|
|
55
|
+
date: formatDate(fulfillment.createdAt),
|
|
56
|
+
eta: formatDate(fulfillment.estimatedDeliveryAt),
|
|
57
|
+
company: tracking?.company || '',
|
|
58
|
+
number: tracking?.number || '',
|
|
59
|
+
};
|
|
60
|
+
bindsIn(row, 'fulfillment-bind').forEach((el) => {
|
|
61
|
+
const key = get(el, 'fulfillment-bind') ?? '';
|
|
62
|
+
if (key === 'url') {
|
|
63
|
+
const url = tracking?.url ?? null;
|
|
64
|
+
const linked = url !== null && isHttpsUrl(url);
|
|
65
|
+
if (linked) el.setAttribute('href', url);
|
|
66
|
+
show(el, linked);
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
const text = ownValue(texts, key);
|
|
70
|
+
if (text !== undefined) setText(el, text);
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function fillOrder(el: HTMLElement, detail: OrderDetail): void {
|
|
75
|
+
const returnable = new Map(
|
|
76
|
+
(detail.returnInformation?.returnableLineItems.nodes ?? []).map((entry) => [
|
|
77
|
+
entry.lineItem?.id ?? '',
|
|
78
|
+
entry.quantity,
|
|
79
|
+
]),
|
|
80
|
+
);
|
|
81
|
+
const shipments = detail.fulfillments.nodes;
|
|
82
|
+
const texts = orderTexts(detail);
|
|
83
|
+
qsa(el, sel('order-bind')).forEach((bind) => {
|
|
84
|
+
const key = get(bind, 'order-bind') ?? '';
|
|
85
|
+
if (key === 'url') {
|
|
86
|
+
const statusPage = detail.statusPageUrl;
|
|
87
|
+
if (statusPage !== null && isHttpsUrl(statusPage)) bind.setAttribute('href', statusPage);
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
const text = ownValue(texts, key);
|
|
91
|
+
if (text !== undefined) setText(bind, text);
|
|
92
|
+
});
|
|
93
|
+
qsa(el, sel('order-lines')).forEach((box) => {
|
|
94
|
+
fillLines(box, detail.lineItems.nodes, returnable);
|
|
95
|
+
});
|
|
96
|
+
qsa(el, sel('order-fulfillments')).forEach((box) => {
|
|
97
|
+
repeat(box, shipments, fillFulfillment);
|
|
98
|
+
});
|
|
99
|
+
const returnRequested = el.dataset['tetherReturned'] === '1';
|
|
100
|
+
showIn(el, 'order-show', {
|
|
101
|
+
loading: false,
|
|
102
|
+
ready: true,
|
|
103
|
+
error: false,
|
|
104
|
+
'not-found': false,
|
|
105
|
+
cancelled: Boolean(detail.cancelledAt),
|
|
106
|
+
shipped: shipments.length > 0,
|
|
107
|
+
returnable: [...returnable.values()].some((quantity) => quantity > 0),
|
|
108
|
+
'has-returns': !returnRequested && detail.returns.nodes.length > 0,
|
|
109
|
+
'return-requested': returnRequested,
|
|
110
|
+
});
|
|
111
|
+
el.setAttribute(attr('order-state'), 'ready');
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Loads and renders the order `el` names. A failure is reported and shows the error state. */
|
|
115
|
+
export async function renderOrder(el: HTMLElement): Promise<void> {
|
|
116
|
+
const key = orderKey(el);
|
|
117
|
+
if (!key) {
|
|
118
|
+
el.setAttribute(attr('order-state'), 'not-found');
|
|
119
|
+
showIn(el, 'order-show', { 'not-found': true });
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
showOrderState(el, 'loading');
|
|
123
|
+
let detail: OrderDetail | null;
|
|
124
|
+
try {
|
|
125
|
+
detail = await order(key);
|
|
126
|
+
} catch (error) {
|
|
127
|
+
reportError(error, 'order');
|
|
128
|
+
showOrderState(el, 'error');
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
if (!detail) {
|
|
132
|
+
showOrderState(el, 'not-found');
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
el.dataset['tetherOrderId'] = detail.id;
|
|
136
|
+
fillOrder(el, detail);
|
|
137
|
+
}
|
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The account pages' answers as typed values: addresses, the order page, subscriptions and what a change returns. The
|
|
3
|
+
* rules are answers.ts's: every selected field is kept, a missing one reads as `null`, and the ids the pages act on
|
|
4
|
+
* are required.
|
|
5
|
+
*/
|
|
6
|
+
import {
|
|
7
|
+
countOrZero,
|
|
8
|
+
field,
|
|
9
|
+
isRecord,
|
|
10
|
+
listOf,
|
|
11
|
+
nodesOf,
|
|
12
|
+
numberOrNull,
|
|
13
|
+
textList,
|
|
14
|
+
textOrNull,
|
|
15
|
+
} from '../../server/src/shared/json.ts';
|
|
16
|
+
import { imageOrNull, moneyOrNull, toOrderLine } from '../../server/src/shared/account-orders.ts';
|
|
17
|
+
import type { AccountImage, AccountOrderLine } from '../../server/src/shared/account-orders.ts';
|
|
18
|
+
import type { Money } from '../shopify-values.ts';
|
|
19
|
+
import { requiredId, unexpected } from './answers.ts';
|
|
20
|
+
|
|
21
|
+
/** An address as the documents select it. */
|
|
22
|
+
export interface MailingAddress {
|
|
23
|
+
id: string | null;
|
|
24
|
+
firstName: string | null;
|
|
25
|
+
lastName: string | null;
|
|
26
|
+
company: string | null;
|
|
27
|
+
address1: string | null;
|
|
28
|
+
address2: string | null;
|
|
29
|
+
city: string | null;
|
|
30
|
+
province: string | null;
|
|
31
|
+
zoneCode: string | null;
|
|
32
|
+
zip: string | null;
|
|
33
|
+
country: string | null;
|
|
34
|
+
countryCode: string | null;
|
|
35
|
+
phoneNumber: string | null;
|
|
36
|
+
/** One line per entry. */
|
|
37
|
+
formatted: string[];
|
|
38
|
+
}
|
|
39
|
+
/** One of the customer's saved addresses. */
|
|
40
|
+
export type AccountAddress = MailingAddress & { id: string };
|
|
41
|
+
export type CustomerAddress = AccountAddress & { isDefault: boolean };
|
|
42
|
+
interface ReturnReason {
|
|
43
|
+
id: string;
|
|
44
|
+
name: string | null;
|
|
45
|
+
handle: string | null;
|
|
46
|
+
}
|
|
47
|
+
/** A line of the order page's document. */
|
|
48
|
+
export type OrderDetailLine = AccountOrderLine & {
|
|
49
|
+
id: string;
|
|
50
|
+
name: string | null;
|
|
51
|
+
sku: string | null;
|
|
52
|
+
price: Money | null;
|
|
53
|
+
totalPrice: Money | null;
|
|
54
|
+
suggestedReturnReasonDefinitions: { nodes: ReturnReason[] };
|
|
55
|
+
};
|
|
56
|
+
interface Tracking {
|
|
57
|
+
company: string | null;
|
|
58
|
+
number: string | null;
|
|
59
|
+
url: string | null;
|
|
60
|
+
}
|
|
61
|
+
export interface Fulfillment {
|
|
62
|
+
id: string | null;
|
|
63
|
+
status: string | null;
|
|
64
|
+
createdAt: string | null;
|
|
65
|
+
estimatedDeliveryAt: string | null;
|
|
66
|
+
latestShipmentStatus: string | null;
|
|
67
|
+
trackingInformation: Tracking[];
|
|
68
|
+
}
|
|
69
|
+
interface ReturnableLine {
|
|
70
|
+
quantity: number;
|
|
71
|
+
lineItem: { id: string } | null;
|
|
72
|
+
}
|
|
73
|
+
/** A return Shopify opened. */
|
|
74
|
+
export interface OpenedReturn {
|
|
75
|
+
id: string;
|
|
76
|
+
status: string | null;
|
|
77
|
+
}
|
|
78
|
+
/** The order page's document. */
|
|
79
|
+
export interface OrderDetail {
|
|
80
|
+
id: string;
|
|
81
|
+
name: string | null;
|
|
82
|
+
number: number | null;
|
|
83
|
+
processedAt: string | null;
|
|
84
|
+
cancelledAt: string | null;
|
|
85
|
+
financialStatus: string | null;
|
|
86
|
+
fulfillmentStatus: string | null;
|
|
87
|
+
statusPageUrl: string | null;
|
|
88
|
+
requiresShipping: boolean | null;
|
|
89
|
+
totalPrice: Money | null;
|
|
90
|
+
subtotal: Money | null;
|
|
91
|
+
totalShipping: Money | null;
|
|
92
|
+
totalTax: Money | null;
|
|
93
|
+
shippingAddress: MailingAddress | null;
|
|
94
|
+
billingAddress: MailingAddress | null;
|
|
95
|
+
lineItems: { nodes: OrderDetailLine[] };
|
|
96
|
+
fulfillments: { nodes: Fulfillment[] };
|
|
97
|
+
returnInformation: { returnableLineItems: { nodes: ReturnableLine[] } } | null;
|
|
98
|
+
returns: { nodes: OpenedReturn[] };
|
|
99
|
+
}
|
|
100
|
+
export interface SubscriptionLine {
|
|
101
|
+
id: string | null;
|
|
102
|
+
name: string | null;
|
|
103
|
+
title: string | null;
|
|
104
|
+
variantTitle: string | null;
|
|
105
|
+
quantity: number;
|
|
106
|
+
sku: string | null;
|
|
107
|
+
currentPrice: Money | null;
|
|
108
|
+
lineDiscountedPrice: Money | null;
|
|
109
|
+
image: AccountImage | null;
|
|
110
|
+
}
|
|
111
|
+
interface BillingCycle {
|
|
112
|
+
cycleIndex: number | null;
|
|
113
|
+
skipped: boolean;
|
|
114
|
+
billingAttemptExpectedDate: string | null;
|
|
115
|
+
}
|
|
116
|
+
/** The subscriptions document. */
|
|
117
|
+
export interface SubscriptionContract {
|
|
118
|
+
id: string;
|
|
119
|
+
/** `ACTIVE`, `PAUSED`, `CANCELLED`, `EXPIRED` or `FAILED`. */
|
|
120
|
+
status: string | null;
|
|
121
|
+
nextBillingDate: string | null;
|
|
122
|
+
currencyCode: string | null;
|
|
123
|
+
deliveryPrice: Money | null;
|
|
124
|
+
deliveryPolicy: { interval: string | null; intervalCount: { count: number } | null } | null;
|
|
125
|
+
lines: { nodes: SubscriptionLine[] };
|
|
126
|
+
upcomingBillingCycles: { nodes: BillingCycle[] };
|
|
127
|
+
}
|
|
128
|
+
export interface Profile {
|
|
129
|
+
firstName: string | null;
|
|
130
|
+
lastName: string | null;
|
|
131
|
+
}
|
|
132
|
+
/** What a subscription action changed: the contract (pause, resume, cancel) or its next billing cycle (skip, unskip). */
|
|
133
|
+
export type SubscriptionChange =
|
|
134
|
+
| { contract: { id: string | null; status: string | null } }
|
|
135
|
+
| { billingCycle: { cycleIndex: number | null; skipped: boolean } };
|
|
136
|
+
|
|
137
|
+
function toMailingAddress(value: unknown): MailingAddress {
|
|
138
|
+
const text = (name: string) => textOrNull(field(value, name));
|
|
139
|
+
return {
|
|
140
|
+
id: text('id'),
|
|
141
|
+
firstName: text('firstName'),
|
|
142
|
+
lastName: text('lastName'),
|
|
143
|
+
company: text('company'),
|
|
144
|
+
address1: text('address1'),
|
|
145
|
+
address2: text('address2'),
|
|
146
|
+
city: text('city'),
|
|
147
|
+
province: text('province'),
|
|
148
|
+
zoneCode: text('zoneCode'),
|
|
149
|
+
zip: text('zip'),
|
|
150
|
+
country: text('country'),
|
|
151
|
+
countryCode: text('countryCode'),
|
|
152
|
+
phoneNumber: text('phoneNumber'),
|
|
153
|
+
formatted: textList(field(value, 'formatted')),
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const mailingAddressOrNull = (value: unknown): MailingAddress | null =>
|
|
158
|
+
isRecord(value) ? toMailingAddress(value) : null;
|
|
159
|
+
|
|
160
|
+
/** @throws {TetherError} Without an id. */
|
|
161
|
+
export const toAccountAddress = (value: unknown): AccountAddress => ({
|
|
162
|
+
...toMailingAddress(value),
|
|
163
|
+
id: requiredId(field(value, 'id'), 'address id'),
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* @param value The addresses operation's list, each address with the `isDefault` it adds.
|
|
168
|
+
* @throws {TetherError} For an address without an id.
|
|
169
|
+
*/
|
|
170
|
+
export const toCustomerAddresses = (value: unknown): CustomerAddress[] =>
|
|
171
|
+
listOf(value, (address) => ({ ...toAccountAddress(address), isDefault: field(address, 'isDefault') === true }));
|
|
172
|
+
|
|
173
|
+
const toOrderDetailLine = (value: unknown): OrderDetailLine => ({
|
|
174
|
+
...toOrderLine(value),
|
|
175
|
+
id: requiredId(field(value, 'id'), 'line item id'),
|
|
176
|
+
name: textOrNull(field(value, 'name')),
|
|
177
|
+
sku: textOrNull(field(value, 'sku')),
|
|
178
|
+
price: moneyOrNull(field(value, 'price')),
|
|
179
|
+
totalPrice: moneyOrNull(field(value, 'totalPrice')),
|
|
180
|
+
suggestedReturnReasonDefinitions: nodesOf(field(value, 'suggestedReturnReasonDefinitions'), (reason) => ({
|
|
181
|
+
id: requiredId(field(reason, 'id'), 'return reason id'),
|
|
182
|
+
name: textOrNull(field(reason, 'name')),
|
|
183
|
+
handle: textOrNull(field(reason, 'handle')),
|
|
184
|
+
})),
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
const toFulfillment = (value: unknown): Fulfillment => ({
|
|
188
|
+
id: textOrNull(field(value, 'id')),
|
|
189
|
+
status: textOrNull(field(value, 'status')),
|
|
190
|
+
createdAt: textOrNull(field(value, 'createdAt')),
|
|
191
|
+
estimatedDeliveryAt: textOrNull(field(value, 'estimatedDeliveryAt')),
|
|
192
|
+
latestShipmentStatus: textOrNull(field(value, 'latestShipmentStatus')),
|
|
193
|
+
trackingInformation: listOf(field(value, 'trackingInformation'), (tracking) => ({
|
|
194
|
+
company: textOrNull(field(tracking, 'company')),
|
|
195
|
+
number: textOrNull(field(tracking, 'number')),
|
|
196
|
+
url: textOrNull(field(tracking, 'url')),
|
|
197
|
+
})),
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
function toReturnableLine(value: unknown): ReturnableLine {
|
|
201
|
+
const lineId = textOrNull(field(field(value, 'lineItem'), 'id'));
|
|
202
|
+
return { quantity: countOrZero(field(value, 'quantity')), lineItem: lineId === null ? null : { id: lineId } };
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function toReturnInformation(value: unknown): OrderDetail['returnInformation'] {
|
|
206
|
+
if (!isRecord(value)) return null;
|
|
207
|
+
return { returnableLineItems: nodesOf(value['returnableLineItems'], toReturnableLine) };
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* @param value The order operation's `order`.
|
|
212
|
+
* @returns `null` when the customer has no such order.
|
|
213
|
+
* @throws {TetherError} For an order, line, reason or return without an id.
|
|
214
|
+
*/
|
|
215
|
+
export function toOrderDetail(value: unknown): OrderDetail | null {
|
|
216
|
+
if (!isRecord(value)) return null;
|
|
217
|
+
const text = (name: string) => textOrNull(value[name]);
|
|
218
|
+
const money = (name: string) => moneyOrNull(value[name]);
|
|
219
|
+
const requiresShipping = value['requiresShipping'];
|
|
220
|
+
return {
|
|
221
|
+
id: requiredId(value['id'], 'order id'),
|
|
222
|
+
name: text('name'),
|
|
223
|
+
number: numberOrNull(value['number']),
|
|
224
|
+
processedAt: text('processedAt'),
|
|
225
|
+
cancelledAt: text('cancelledAt'),
|
|
226
|
+
financialStatus: text('financialStatus'),
|
|
227
|
+
fulfillmentStatus: text('fulfillmentStatus'),
|
|
228
|
+
statusPageUrl: text('statusPageUrl'),
|
|
229
|
+
requiresShipping: typeof requiresShipping === 'boolean' ? requiresShipping : null,
|
|
230
|
+
totalPrice: money('totalPrice'),
|
|
231
|
+
subtotal: money('subtotal'),
|
|
232
|
+
totalShipping: money('totalShipping'),
|
|
233
|
+
totalTax: money('totalTax'),
|
|
234
|
+
shippingAddress: mailingAddressOrNull(value['shippingAddress']),
|
|
235
|
+
billingAddress: mailingAddressOrNull(value['billingAddress']),
|
|
236
|
+
lineItems: nodesOf(value['lineItems'], toOrderDetailLine),
|
|
237
|
+
fulfillments: nodesOf(value['fulfillments'], toFulfillment),
|
|
238
|
+
returnInformation: toReturnInformation(value['returnInformation']),
|
|
239
|
+
returns: nodesOf(value['returns'], toReturn),
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
const toSubscriptionLine = (value: unknown): SubscriptionLine => ({
|
|
244
|
+
id: textOrNull(field(value, 'id')),
|
|
245
|
+
name: textOrNull(field(value, 'name')),
|
|
246
|
+
title: textOrNull(field(value, 'title')),
|
|
247
|
+
variantTitle: textOrNull(field(value, 'variantTitle')),
|
|
248
|
+
quantity: countOrZero(field(value, 'quantity')),
|
|
249
|
+
sku: textOrNull(field(value, 'sku')),
|
|
250
|
+
currentPrice: moneyOrNull(field(value, 'currentPrice')),
|
|
251
|
+
lineDiscountedPrice: moneyOrNull(field(value, 'lineDiscountedPrice')),
|
|
252
|
+
image: imageOrNull(field(value, 'image')),
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
const toBillingCycle = (value: unknown): BillingCycle => ({
|
|
256
|
+
cycleIndex: numberOrNull(field(value, 'cycleIndex')),
|
|
257
|
+
skipped: field(value, 'skipped') === true,
|
|
258
|
+
billingAttemptExpectedDate: textOrNull(field(value, 'billingAttemptExpectedDate')),
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
function toDeliveryPolicy(value: unknown): SubscriptionContract['deliveryPolicy'] {
|
|
262
|
+
if (!isRecord(value)) return null;
|
|
263
|
+
const intervalCount = value['intervalCount'];
|
|
264
|
+
return {
|
|
265
|
+
interval: textOrNull(value['interval']),
|
|
266
|
+
intervalCount: isRecord(intervalCount) ? { count: countOrZero(intervalCount['count']) } : null,
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/** @throws {TetherError} Without an id. */
|
|
271
|
+
const toSubscriptionContract = (value: unknown): SubscriptionContract => ({
|
|
272
|
+
id: requiredId(field(value, 'id'), 'subscription id'),
|
|
273
|
+
status: textOrNull(field(value, 'status')),
|
|
274
|
+
nextBillingDate: textOrNull(field(value, 'nextBillingDate')),
|
|
275
|
+
currencyCode: textOrNull(field(value, 'currencyCode')),
|
|
276
|
+
deliveryPrice: moneyOrNull(field(value, 'deliveryPrice')),
|
|
277
|
+
deliveryPolicy: toDeliveryPolicy(field(value, 'deliveryPolicy')),
|
|
278
|
+
lines: nodesOf(field(value, 'lines'), toSubscriptionLine),
|
|
279
|
+
upcomingBillingCycles: nodesOf(field(value, 'upcomingBillingCycles'), toBillingCycle),
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* @param value The subscriptions operation's list.
|
|
284
|
+
* @throws {TetherError} For a contract without an id.
|
|
285
|
+
*/
|
|
286
|
+
export const toSubscriptionContracts = (value: unknown): SubscriptionContract[] =>
|
|
287
|
+
listOf(value, toSubscriptionContract);
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* @param result The subscription operation's result.
|
|
291
|
+
* @throws {TetherError} With neither a contract nor a billing cycle.
|
|
292
|
+
*/
|
|
293
|
+
export function toSubscriptionChange(result: Record<string, unknown>): SubscriptionChange {
|
|
294
|
+
const { contract, billingCycle } = result;
|
|
295
|
+
if (isRecord(contract)) {
|
|
296
|
+
return { contract: { id: textOrNull(contract['id']), status: textOrNull(contract['status']) } };
|
|
297
|
+
}
|
|
298
|
+
if (isRecord(billingCycle)) {
|
|
299
|
+
const { cycleIndex, skipped } = toBillingCycle(billingCycle);
|
|
300
|
+
return { billingCycle: { cycleIndex, skipped } };
|
|
301
|
+
}
|
|
302
|
+
throw unexpected('changed subscription');
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/** @throws {TetherError} Without an id. */
|
|
306
|
+
export function toReturn(value: unknown): OpenedReturn {
|
|
307
|
+
return { id: requiredId(field(value, 'id'), 'return id'), status: textOrNull(field(value, 'status')) };
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/** @throws {TetherError} Without an id. */
|
|
311
|
+
export const toDeletedId = (value: unknown): string => requiredId(value, 'deleted address id');
|
|
312
|
+
|
|
313
|
+
/** @param value The profile operation's `customer`. */
|
|
314
|
+
export const toProfile = (value: unknown): Profile => ({
|
|
315
|
+
firstName: textOrNull(field(value, 'firstName')),
|
|
316
|
+
lastName: textOrNull(field(value, 'lastName')),
|
|
317
|
+
});
|