@occluze/tether 0.6.8 → 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 -2
- package/dist/presets/barba.esm.js +19 -23
- package/dist/tether.account-pages.js +320 -365
- package/dist/tether.account-pages.min.js +1 -1
- package/dist/tether.account.js +242 -491
- package/dist/tether.account.min.js +1 -1
- package/dist/tether.analytics.js +66 -88
- package/dist/tether.analytics.min.js +1 -1
- package/dist/tether.diagnose.js +36 -33
- package/dist/tether.diagnose.min.js +2 -2
- package/dist/tether.extras.js +28 -36
- package/dist/tether.extras.min.js +1 -1
- package/dist/tether.js +1270 -894
- package/dist/tether.markets.js +73 -90
- 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 +44 -68
- 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 +24 -23
- 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.js → order-lines.ts} +20 -41
- 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.js → page-events.ts} +55 -90
- package/src/account/server-client.ts +183 -0
- package/src/account/{subscriptions-view.js → subscriptions-view.ts} +25 -68
- package/src/account-api.ts +98 -0
- package/src/account-marks.ts +35 -0
- package/src/{account-pages.js → account-pages.ts} +29 -37
- package/src/account.ts +284 -0
- package/src/amounts.ts +64 -0
- package/src/{analytics-shopify.js → analytics-shopify.ts} +112 -107
- package/src/analytics.ts +315 -0
- package/src/{api-version.js → api-version.ts} +25 -31
- package/src/{astro.js → astro.ts} +110 -104
- package/src/cart/cart-shape.ts +25 -0
- package/src/cart/checkout.ts +101 -0
- package/src/cart/{drawer.js → drawer.ts} +18 -31
- package/src/cart/mutations.ts +237 -0
- package/src/cart/{render.js → render.ts} +68 -96
- package/src/cart/store.ts +279 -0
- package/src/{cart.js → cart.ts} +82 -128
- package/src/client.ts +391 -0
- package/src/{collection.js → collection.ts} +30 -38
- package/src/config-sections.ts +284 -0
- package/src/config.ts +480 -0
- package/src/controls/{cart-controls.js → cart-controls.ts} +22 -54
- package/src/controls/{discount.js → discount.ts} +13 -34
- package/src/controls/{extras.js → extras.ts} +15 -24
- package/src/controls/{navigation.js → navigation.ts} +24 -30
- package/src/{diagnose-markup.js → diagnose-markup.ts} +37 -72
- package/src/{diagnose.js → diagnose.ts} +95 -96
- package/src/dom.ts +268 -0
- package/src/{extras.js → extras.ts} +43 -52
- package/src/fragments.ts +261 -0
- package/src/globals.d.ts +15 -0
- package/src/{images.js → images.ts} +30 -43
- 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.js → loader.ts} +149 -123
- package/src/market-codes.ts +6 -0
- package/src/{markets-ui.js → markets-ui.ts} +91 -68
- package/src/markets.ts +246 -0
- package/src/markup.ts +127 -0
- package/src/{mounting.js → mounting.ts} +88 -101
- package/src/{page-market.js → page-market.ts} +32 -38
- 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.js → product-events.ts} +33 -48
- package/src/{product-render.js → product-render.ts} +38 -77
- package/src/product-shape.ts +49 -0
- package/src/product-state.ts +318 -0
- package/src/{product.js → product.ts} +90 -126
- package/src/{repeaters.js → repeaters.ts} +40 -52
- package/src/{search.js → search.ts} +85 -103
- package/src/selling-plans.ts +145 -0
- package/src/server.ts +211 -0
- package/src/{shopify-values.js → shopify-values.ts} +24 -22
- package/src/{status.js → status.ts} +34 -54
- 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.js → json-ld.ts} +45 -68
- package/src/{variant-render.js → variant-render.ts} +24 -50
- package/dist/esm/chunks/account-C3NP6HFB.js +0 -1
- package/dist/esm/chunks/account-pages-WLK374FO.js +0 -1
- package/dist/esm/chunks/analytics-G3SAWMNL.js +0 -1
- package/dist/esm/chunks/chunk-27PJJZGC.js +0 -1
- package/dist/esm/chunks/chunk-2FSXS55U.js +0 -1
- package/dist/esm/chunks/chunk-FCSWQDVV.js +0 -1
- package/dist/esm/chunks/chunk-FOMSMIAM.js +0 -1
- package/dist/esm/chunks/chunk-GZJTCLLW.js +0 -1
- package/dist/esm/chunks/chunk-HEQILZVR.js +0 -1
- package/dist/esm/chunks/chunk-JOOEXYF3.js +0 -4
- package/dist/esm/chunks/chunk-MPTQYLSM.js +0 -1
- package/dist/esm/chunks/chunk-NEHZ37TU.js +0 -3
- package/dist/esm/chunks/chunk-V74KGEJF.js +0 -1
- package/dist/esm/chunks/chunk-VT4CDUJJ.js +0 -1
- package/dist/esm/chunks/chunk-YJLWX2HK.js +0 -1
- package/dist/esm/chunks/diagnose-6VVGZNTY.js +0 -14
- package/dist/esm/chunks/extras-GJVRDFM3.js +0 -1
- package/dist/esm/chunks/markets-ui-UNB4PTA7.js +0 -1
- package/dist/esm/chunks/search-OU75RNQL.js +0 -1
- package/src/account/addresses-view.js +0 -92
- package/src/account/browser-session.js +0 -359
- package/src/account/markup.js +0 -48
- package/src/account/operations.js +0 -180
- package/src/account/order-list.js +0 -91
- package/src/account/order-page.js +0 -192
- package/src/account/server-client.js +0 -172
- package/src/account/shared.js +0 -111
- package/src/account/shopify-auth.js +0 -150
- package/src/account-api.js +0 -118
- package/src/account-links.js +0 -35
- package/src/account.js +0 -342
- package/src/amounts.js +0 -36
- package/src/analytics.js +0 -352
- package/src/cart/checkout.js +0 -97
- package/src/cart/mutations.js +0 -234
- package/src/cart/store.js +0 -326
- package/src/client.js +0 -267
- package/src/config.js +0 -349
- package/src/dom.js +0 -287
- package/src/fragments.js +0 -237
- package/src/index.js +0 -353
- package/src/initial.js +0 -87
- package/src/lazy-iife.js +0 -116
- package/src/lazy.js +0 -39
- package/src/market-codes.js +0 -35
- package/src/markets.js +0 -334
- package/src/markup.js +0 -64
- package/src/parts/account-pages.js +0 -6
- package/src/parts/account.js +0 -6
- package/src/parts/analytics.js +0 -6
- package/src/parts/diagnose.js +0 -6
- package/src/parts/extras.js +0 -6
- package/src/parts/markets.js +0 -6
- package/src/parts/search.js +0 -6
- package/src/presets/barba.js +0 -190
- package/src/product-state.js +0 -318
- package/src/selling-plans.js +0 -154
- package/src/server.js +0 -215
- package/src/store-html.js +0 -0
- package/src/storefront/catalog.js +0 -199
- package/types/astro.d.ts +0 -28
- package/types/barba.d.ts +0 -16
- package/types/index.d.ts +0 -577
- package/types/server.d.ts +0 -182
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* actions and return requests. Each change marks its form or row busy (`aria-busy`, `is-busy`), then sets
|
|
4
4
|
* `tether-form-state` to `saved` or `error` with Shopify's message in `tether-form-error`.
|
|
5
5
|
*/
|
|
6
|
-
import { attr, get, qsa, sel, setText } from '../dom.
|
|
6
|
+
import { attr, get, qsa, sel, setText } from '../dom.ts';
|
|
7
7
|
import {
|
|
8
8
|
saveAddress,
|
|
9
9
|
deleteAddress,
|
|
@@ -11,56 +11,39 @@ import {
|
|
|
11
11
|
requestReturn,
|
|
12
12
|
updateProfile,
|
|
13
13
|
pagesContext,
|
|
14
|
-
} from './operations.
|
|
15
|
-
import {
|
|
16
|
-
import {
|
|
17
|
-
import {
|
|
18
|
-
import {
|
|
19
|
-
|
|
20
|
-
|
|
14
|
+
} from './operations.ts';
|
|
15
|
+
import type { ReturnLine } from './operations.ts';
|
|
16
|
+
import { renderAddresses, shownAddress } from './addresses-view.ts';
|
|
17
|
+
import { renderSubscriptions } from './subscriptions-view.ts';
|
|
18
|
+
import { renderOrder } from './order-page.ts';
|
|
19
|
+
import { TetherError } from '../client.ts';
|
|
20
|
+
import type { SubscriptionAction } from '../../server/src/shared/account-routes.ts';
|
|
21
21
|
|
|
22
|
-
|
|
23
|
-
const SUBSCRIPTION_ACTIONS = ['pause', 'resume', 'skip', 'unskip', 'cancel'];
|
|
22
|
+
const SUBSCRIPTION_ACTIONS: readonly SubscriptionAction[] = ['pause', 'resume', 'skip', 'unskip', 'cancel'];
|
|
24
23
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
* @returns {value is SubscriptionAction}
|
|
28
|
-
*/
|
|
29
|
-
const isSubscriptionAction = (value) => SUBSCRIPTION_ACTIONS.some((action) => action === value);
|
|
24
|
+
const isSubscriptionAction = (value: string | null): value is SubscriptionAction =>
|
|
25
|
+
SUBSCRIPTION_ACTIONS.some((action) => action === value);
|
|
30
26
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
const isInput = (el) => el.tagName === 'INPUT';
|
|
27
|
+
const isField = (el: Element): el is HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement =>
|
|
28
|
+
el instanceof window.HTMLInputElement ||
|
|
29
|
+
el instanceof window.HTMLSelectElement ||
|
|
30
|
+
el instanceof window.HTMLTextAreaElement;
|
|
36
31
|
|
|
37
|
-
|
|
38
|
-
* @param {Element} el
|
|
39
|
-
* @returns {el is HTMLFormElement}
|
|
40
|
-
*/
|
|
41
|
-
const isForm = (el) => el.tagName === 'FORM';
|
|
32
|
+
const isForm = (el: Element): el is HTMLFormElement => el instanceof window.HTMLFormElement;
|
|
42
33
|
|
|
43
|
-
/**
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
*/
|
|
47
|
-
function fieldsOf(form) {
|
|
48
|
-
/** @type {Record<string, string>} */
|
|
49
|
-
const fields = {};
|
|
34
|
+
/** @returns The form's text fields by name. */
|
|
35
|
+
function fieldsOf(form: HTMLFormElement): Record<string, string> {
|
|
36
|
+
const fields: Record<string, string> = {};
|
|
50
37
|
for (const field of form.elements) {
|
|
51
|
-
if (!
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
fields[input.name] = input.value;
|
|
38
|
+
if (!isField(field)) continue;
|
|
39
|
+
if (!field.name || field.type === 'submit' || field.type === 'button' || field.type === 'checkbox') continue;
|
|
40
|
+
fields[field.name] = field.value;
|
|
55
41
|
}
|
|
56
42
|
return fields;
|
|
57
43
|
}
|
|
58
44
|
|
|
59
|
-
/**
|
|
60
|
-
|
|
61
|
-
* @returns {string} What to tell the customer: Shopify's own messages when it refused the input.
|
|
62
|
-
*/
|
|
63
|
-
function messageOf(error) {
|
|
45
|
+
/** @returns What to tell the customer: Shopify's own messages when it refused the input. */
|
|
46
|
+
function customerMessage(error: unknown): string {
|
|
64
47
|
const userErrors = error instanceof TetherError ? (error.userErrors ?? []) : [];
|
|
65
48
|
const message = userErrors.map((userError) => userError.message).join(' ');
|
|
66
49
|
if (message) return message;
|
|
@@ -71,12 +54,8 @@ function messageOf(error) {
|
|
|
71
54
|
/**
|
|
72
55
|
* Runs one change for `el`; a second one while it is on its way (Enter, a double click) is ignored. The failure is
|
|
73
56
|
* 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
57
|
*/
|
|
79
|
-
async function act(el, change) {
|
|
58
|
+
async function act(el: HTMLElement, change: () => Promise<void>): Promise<'saved' | 'error' | 'busy'> {
|
|
80
59
|
if (el.getAttribute('aria-busy') === 'true') return 'busy';
|
|
81
60
|
el.classList.add('is-busy');
|
|
82
61
|
el.setAttribute('aria-busy', 'true');
|
|
@@ -92,7 +71,7 @@ async function act(el, change) {
|
|
|
92
71
|
} catch (error) {
|
|
93
72
|
el.setAttribute(attr('form-state'), 'error');
|
|
94
73
|
errorBoxes.forEach((box) => {
|
|
95
|
-
setText(box,
|
|
74
|
+
setText(box, customerMessage(error));
|
|
96
75
|
});
|
|
97
76
|
return 'error';
|
|
98
77
|
} finally {
|
|
@@ -101,23 +80,18 @@ async function act(el, change) {
|
|
|
101
80
|
}
|
|
102
81
|
}
|
|
103
82
|
|
|
104
|
-
/**
|
|
105
|
-
|
|
106
|
-
* @returns {boolean} True when the button asks no `tether-confirm` question, or the visitor agreed.
|
|
107
|
-
*/
|
|
108
|
-
const confirmed = (button) => {
|
|
83
|
+
/** @returns True when the button asks no `tether-confirm` question, or the visitor agreed. */
|
|
84
|
+
const confirmed = (button: Element): boolean => {
|
|
109
85
|
const question = get(button, 'confirm');
|
|
110
86
|
return !question || window.confirm(question);
|
|
111
87
|
};
|
|
112
88
|
|
|
113
|
-
|
|
114
|
-
const addressFormReset = (form) => {
|
|
89
|
+
const addressFormReset = (form: HTMLFormElement): void => {
|
|
115
90
|
form.reset();
|
|
116
91
|
delete form.dataset['tetherAddressId'];
|
|
117
92
|
};
|
|
118
93
|
|
|
119
|
-
|
|
120
|
-
function onSubmit(event) {
|
|
94
|
+
function onSubmit(event: SubmitEvent): void {
|
|
121
95
|
const form = event.target;
|
|
122
96
|
if (!(form instanceof window.HTMLFormElement)) return;
|
|
123
97
|
if (form.matches(sel('profile-form'))) {
|
|
@@ -139,34 +113,27 @@ function onSubmit(event) {
|
|
|
139
113
|
}
|
|
140
114
|
}
|
|
141
115
|
|
|
142
|
-
/**
|
|
143
|
-
|
|
144
|
-
*
|
|
145
|
-
* @param {string} id
|
|
146
|
-
*/
|
|
147
|
-
function editAddress(id) {
|
|
116
|
+
/** Fills the address form with a shown address, to edit it. */
|
|
117
|
+
function editAddress(id: string): void {
|
|
148
118
|
const address = shownAddress(id);
|
|
149
119
|
const form = document.querySelector(sel('address-form'));
|
|
150
120
|
if (!address || !form || !isForm(form)) return;
|
|
151
121
|
form.dataset['tetherAddressId'] = address.id;
|
|
122
|
+
const valuesByName: Readonly<Record<string, unknown>> = { ...address };
|
|
152
123
|
for (const field of form.elements) {
|
|
153
|
-
if (!
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
else if (
|
|
157
|
-
const value =
|
|
158
|
-
|
|
124
|
+
if (!isField(field)) continue;
|
|
125
|
+
if (field.name === 'isDefault') {
|
|
126
|
+
if (field instanceof window.HTMLInputElement) field.checked = address.isDefault;
|
|
127
|
+
} else if (field.name && Object.hasOwn(valuesByName, field.name)) {
|
|
128
|
+
const value = valuesByName[field.name];
|
|
129
|
+
field.value = typeof value === 'string' ? value : '';
|
|
159
130
|
}
|
|
160
131
|
}
|
|
161
132
|
form.scrollIntoView({ block: 'nearest' });
|
|
162
133
|
}
|
|
163
134
|
|
|
164
|
-
/**
|
|
165
|
-
|
|
166
|
-
* @param {Element} target
|
|
167
|
-
* @returns {boolean} True when the click was on an address button.
|
|
168
|
-
*/
|
|
169
|
-
function onAddressClick(event, target) {
|
|
135
|
+
/** @returns True when the click was on an address button. */
|
|
136
|
+
function onAddressClick(event: MouseEvent, target: Element): boolean {
|
|
170
137
|
const row = target.closest('[data-tether-address]');
|
|
171
138
|
const id = row instanceof window.HTMLElement ? row.dataset['tetherAddress'] : undefined;
|
|
172
139
|
if (target.closest(sel('address-new'))) {
|
|
@@ -198,8 +165,8 @@ function onAddressClick(event, target) {
|
|
|
198
165
|
return true;
|
|
199
166
|
}
|
|
200
167
|
|
|
201
|
-
/** @param
|
|
202
|
-
function onSubscriptionAction(button) {
|
|
168
|
+
/** @param button A `tether-subscription-action` button. */
|
|
169
|
+
function onSubscriptionAction(button: HTMLElement): void {
|
|
203
170
|
const row = button.closest('[data-tether-subscription]');
|
|
204
171
|
const action = get(button, 'subscription-action');
|
|
205
172
|
const disabled = ('disabled' in button && button.disabled === true) || button.classList.contains('is-disabled');
|
|
@@ -220,18 +187,17 @@ function onSubscriptionAction(button) {
|
|
|
220
187
|
});
|
|
221
188
|
}
|
|
222
189
|
|
|
223
|
-
/**
|
|
224
|
-
|
|
225
|
-
* @returns {ReturnLine}
|
|
226
|
-
*/
|
|
227
|
-
function returnLineOf(row) {
|
|
190
|
+
/** @param row A line row of the order page. */
|
|
191
|
+
function returnLineOf(row: HTMLElement): ReturnLine {
|
|
228
192
|
const quantity = row.querySelector(sel('return-quantity'));
|
|
229
193
|
const reason = row.querySelector(sel('return-reason'));
|
|
230
194
|
const note = row.querySelector(sel('return-note'));
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
195
|
+
const valueOf = (field: Element | null) =>
|
|
196
|
+
field && 'value' in field && typeof field.value === 'string' ? field.value : '';
|
|
197
|
+
const line: ReturnLine = {
|
|
198
|
+
lineItemId: row.dataset['tetherLine'] ?? '',
|
|
199
|
+
quantity: Number.parseInt(valueOf(quantity) || '0', 10),
|
|
200
|
+
};
|
|
235
201
|
const reasonId = valueOf(reason);
|
|
236
202
|
if (reasonId) line.reasonId = reasonId;
|
|
237
203
|
const noteText = valueOf(note);
|
|
@@ -239,8 +205,8 @@ function returnLineOf(row) {
|
|
|
239
205
|
return line;
|
|
240
206
|
}
|
|
241
207
|
|
|
242
|
-
/** @param
|
|
243
|
-
function onReturnRequest(button) {
|
|
208
|
+
/** @param button A `tether-order-return` button. */
|
|
209
|
+
function onReturnRequest(button: HTMLElement): void {
|
|
244
210
|
const scope = button.closest(sel('order'));
|
|
245
211
|
if (!(scope instanceof window.HTMLElement)) return;
|
|
246
212
|
const orderId = scope.dataset['tetherOrderId'];
|
|
@@ -256,8 +222,7 @@ function onReturnRequest(button) {
|
|
|
256
222
|
});
|
|
257
223
|
}
|
|
258
224
|
|
|
259
|
-
|
|
260
|
-
function onClick(event) {
|
|
225
|
+
function onClick(event: MouseEvent): void {
|
|
261
226
|
const target = event.target;
|
|
262
227
|
if (!(target instanceof window.Element)) return;
|
|
263
228
|
if (onAddressClick(event, target)) return;
|
|
@@ -275,7 +240,7 @@ function onClick(event) {
|
|
|
275
240
|
}
|
|
276
241
|
|
|
277
242
|
/** Listens on the document once, for every account page rendered into it. */
|
|
278
|
-
export function listenToPages() {
|
|
243
|
+
export function listenToPages(): void {
|
|
279
244
|
// Capture: before a router (Astro's ClientRouter) turns the submit into a navigation with the fields in the URL.
|
|
280
245
|
document.addEventListener('submit', onSubmit, true);
|
|
281
246
|
document.addEventListener('input', (event) => {
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The browser's side of the Worker's account routes (`account.server`). The Worker holds the Shopify tokens, and
|
|
3
|
+
* this browser only a session cookie its scripts can't read; what it keeps itself is the "may be logged in" hint,
|
|
4
|
+
* which holds no secret. Storage throws in private mode and where the visitor blocks site data: the hint is then
|
|
5
|
+
* not kept, and only pages with account markup load the part.
|
|
6
|
+
*/
|
|
7
|
+
import { cfg } from '../config.ts';
|
|
8
|
+
import { TetherError } from '../client.ts';
|
|
9
|
+
import { Cart } from '../cart.ts';
|
|
10
|
+
import { emit } from '../dom.ts';
|
|
11
|
+
import { isRecord, textOrNull } from '../../server/src/shared/json.ts';
|
|
12
|
+
import { messageOf } from '../../server/src/shared/error-message.ts';
|
|
13
|
+
import { hintKey } from '../account-marks.ts';
|
|
14
|
+
import { removeStored, setStored } from '../storage.ts';
|
|
15
|
+
import { toAccountCustomer, toUserErrors } from './answers.ts';
|
|
16
|
+
import type { AccountCustomer } from './answers.ts';
|
|
17
|
+
|
|
18
|
+
/** The Worker's answer, or a 401: the session cookie is missing, has expired, or Shopify ended the login. */
|
|
19
|
+
export type ServerAnswer = { kind: 'answer'; data: Record<string, unknown> } | { kind: 'logged-out' };
|
|
20
|
+
type ServerLogin = { loggedIn: true; customer: AccountCustomer } | { loggedIn: false };
|
|
21
|
+
export interface ServerRequest {
|
|
22
|
+
method?: 'GET' | 'POST';
|
|
23
|
+
body?: Record<string, unknown>;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** The Worker's `/me` answer, until a change of the customer's data. */
|
|
27
|
+
let known: ServerLogin | null = null;
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* @returns The Worker's account routes, without a trailing slash. `account.server` is a full URL or a path on this
|
|
31
|
+
* site.
|
|
32
|
+
* @throws {TetherError} `NOT_CONFIGURED` without `account.server`.
|
|
33
|
+
*/
|
|
34
|
+
function serverBase(): string {
|
|
35
|
+
const server = cfg.account?.server;
|
|
36
|
+
if (!server) {
|
|
37
|
+
throw new TetherError('Customer accounts need the Worker: set account.server', { code: 'NOT_CONFIGURED' });
|
|
38
|
+
}
|
|
39
|
+
return new URL(server, window.location.href).href.replace(/\/$/, '');
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Keeps the hint that makes the core load the part on the next page; without storage it is not kept (see the file's
|
|
44
|
+
* comment).
|
|
45
|
+
*/
|
|
46
|
+
function setLoginHint(loggedIn: boolean): void {
|
|
47
|
+
if (loggedIn) setStored(hintKey(), '1');
|
|
48
|
+
else removeStored(hintKey());
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** @throws {TetherError} `NETWORK` when the request fails before an answer. */
|
|
52
|
+
async function fetchFromWorker(url: string, init: RequestInit): Promise<Response> {
|
|
53
|
+
try {
|
|
54
|
+
return await fetch(url, init);
|
|
55
|
+
} catch (error) {
|
|
56
|
+
throw new TetherError(`Network error: ${messageOf(error)}`, {
|
|
57
|
+
code: 'NETWORK',
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** An error page that isn't JSON reads as an empty answer: the response's status then says what went wrong. */
|
|
63
|
+
async function readJsonObjectOrEmpty(response: Response): Promise<Record<string, unknown>> {
|
|
64
|
+
let json: unknown;
|
|
65
|
+
try {
|
|
66
|
+
json = await response.json();
|
|
67
|
+
} catch {
|
|
68
|
+
return {};
|
|
69
|
+
}
|
|
70
|
+
return isRecord(json) ? json : {};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* One request to the Worker, with the session cookie. A POST carries the header the Worker requires against
|
|
75
|
+
* cross-site forms.
|
|
76
|
+
*
|
|
77
|
+
* @throws {TetherError} `NOT_CONFIGURED` without `account.server`, `INVALID` for refused input, `HTTP_<status>` for
|
|
78
|
+
* any other failure but a 401, with the Worker's `userErrors`; `NETWORK` when the Worker can't be reached.
|
|
79
|
+
*/
|
|
80
|
+
export async function serverFetch(path: string, { method = 'GET', body }: ServerRequest = {}): Promise<ServerAnswer> {
|
|
81
|
+
const init: RequestInit = { method, credentials: 'include' };
|
|
82
|
+
if (method === 'POST') init.headers = { 'Content-Type': 'application/json', 'X-Tether-Account': '1' };
|
|
83
|
+
if (body) init.body = JSON.stringify(body);
|
|
84
|
+
const response = await fetchFromWorker(`${serverBase()}${path}`, init);
|
|
85
|
+
if (response.status === 401) return { kind: 'logged-out' };
|
|
86
|
+
const data = await readJsonObjectOrEmpty(response);
|
|
87
|
+
if (!response.ok) {
|
|
88
|
+
const reason = textOrNull(data['error']) ?? String(response.status);
|
|
89
|
+
const userErrors = toUserErrors(data['userErrors']);
|
|
90
|
+
throw new TetherError(`Customer accounts: ${reason}`, {
|
|
91
|
+
status: response.status,
|
|
92
|
+
...(userErrors.length > 0 && { userErrors }),
|
|
93
|
+
// The Worker marks refused input (a 400, or a 404 for a contract that isn't the customer's) with `code`.
|
|
94
|
+
...(data['code'] === 'INVALID' && { code: 'INVALID' }),
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
return { kind: 'answer', data };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Who is logged in, asked once and then kept; the "may be logged in" hint follows the answer.
|
|
102
|
+
*
|
|
103
|
+
* @throws {TetherError} When the Worker can't be reached or fails: that says nothing about the login.
|
|
104
|
+
*/
|
|
105
|
+
export async function serverLogin(): Promise<ServerLogin> {
|
|
106
|
+
if (!known) {
|
|
107
|
+
const answer = await serverFetch('/me');
|
|
108
|
+
known =
|
|
109
|
+
answer.kind === 'answer' && answer.data['loggedIn'] === true
|
|
110
|
+
? { loggedIn: true, customer: toAccountCustomer(answer.data['customer']) }
|
|
111
|
+
: { loggedIn: false };
|
|
112
|
+
}
|
|
113
|
+
setLoginHint(known.loggedIn);
|
|
114
|
+
return known;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** @returns True when the Worker last said a customer is logged in. */
|
|
118
|
+
export const isServerLoggedIn = (): boolean => known?.loggedIn === true;
|
|
119
|
+
|
|
120
|
+
/** Asks the Worker again on the next `serverLogin()`, after a change of the customer's data. */
|
|
121
|
+
export function forgetServerLogin(): void {
|
|
122
|
+
known = null;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Goes to the Worker's (or Shopify's) pages, through `account.navigate` when the site replaces the redirect. */
|
|
126
|
+
function navigate(url: string): void {
|
|
127
|
+
const replacement = cfg.account?.navigate;
|
|
128
|
+
if (replacement) replacement(url);
|
|
129
|
+
else window.location.assign(url);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* @param locale The caller's choice.
|
|
134
|
+
* @returns The language of Shopify's login page: the caller's, `account.locale`, else the site's; `''` for Shopify's
|
|
135
|
+
* default.
|
|
136
|
+
*/
|
|
137
|
+
const loginLanguage = (locale: string | undefined): string =>
|
|
138
|
+
locale || cfg.account?.locale || (cfg.locale || '').split('-')[0] || '';
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* To the Worker's login, which comes back to `returnTo` with `?account=<outcome>`. The cart is linked later, at
|
|
142
|
+
* checkout, through the Worker's protected `POST /cart`.
|
|
143
|
+
*
|
|
144
|
+
* @throws {TetherError} `NOT_CONFIGURED` without `account.server`.
|
|
145
|
+
*/
|
|
146
|
+
export function serverLoginRedirect({ returnTo, locale }: { returnTo: string; locale?: string | undefined }): void {
|
|
147
|
+
const url = new URL(`${serverBase()}/login`);
|
|
148
|
+
url.searchParams.set('returnTo', returnTo);
|
|
149
|
+
const language = loginLanguage(locale);
|
|
150
|
+
if (language) url.searchParams.set('locale', language);
|
|
151
|
+
if (cfg.country) url.searchParams.set('country', cfg.country.toUpperCase());
|
|
152
|
+
navigate(url.toString());
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* The logged-in customer's checkout, for `Cart.setCheckoutResolver`: the Worker links the cart to the customer and
|
|
157
|
+
* answers with its checkout URL.
|
|
158
|
+
*
|
|
159
|
+
* @returns `null` when nobody is logged in or the Worker linked no cart, so the cart's own checkout URL is used.
|
|
160
|
+
* @throws {TetherError} When the Worker fails; the cart reports it and checks out without the login.
|
|
161
|
+
*/
|
|
162
|
+
export async function serverCheckoutUrl(): Promise<string | null> {
|
|
163
|
+
const cartId = Cart.id();
|
|
164
|
+
if (!(await serverLogin()).loggedIn || !cartId) return null;
|
|
165
|
+
const answer = await serverFetch('/cart', { method: 'POST', body: { cartId } });
|
|
166
|
+
if (answer.kind === 'logged-out') return null;
|
|
167
|
+
return textOrNull(answer.data['checkoutUrl']);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Ends the session at the Worker, which also unlinks the cart, then goes on to Shopify's logout when the Worker says
|
|
172
|
+
* where. Sends `tether:account-logout`.
|
|
173
|
+
*/
|
|
174
|
+
export async function serverLogout(returnTo: string): Promise<'redirected' | 'stayed'> {
|
|
175
|
+
const answer = await serverFetch('/logout', { method: 'POST', body: { returnTo, cartId: Cart.id() } });
|
|
176
|
+
known = null;
|
|
177
|
+
setLoginHint(false);
|
|
178
|
+
emit('account-logout', {});
|
|
179
|
+
const logoutUrl = answer.kind === 'answer' ? textOrNull(answer.data['logoutUrl']) : null;
|
|
180
|
+
if (!logoutUrl) return 'stayed';
|
|
181
|
+
navigate(logoutUrl);
|
|
182
|
+
return 'redirected';
|
|
183
|
+
}
|
|
@@ -3,39 +3,14 @@
|
|
|
3
3
|
* `subscriptions-empty` and `subscriptions-error`. Each row's `tether-subscription-show` conditions and action buttons
|
|
4
4
|
* follow the contract's status and its next billing cycle.
|
|
5
5
|
*/
|
|
6
|
-
import { reportError } from '../config.
|
|
7
|
-
import { attr, formatMoney, get, qsa, repeat, sel, setImage, setText } from '../dom.
|
|
8
|
-
import { bindsIn, enumText, ownValue, showIn } from './markup.
|
|
9
|
-
import { subscriptions } from './operations.
|
|
10
|
-
import {
|
|
11
|
-
/** @import { Money } from '../shopify-values.js' */
|
|
6
|
+
import { reportError } from '../config.ts';
|
|
7
|
+
import { attr, formatDate, formatMoney, get, qsa, repeat, sel, setImage, setText } from '../dom.ts';
|
|
8
|
+
import { bindsIn, enumText, ownValue, showIn } from './markup.ts';
|
|
9
|
+
import { subscriptions } from './operations.ts';
|
|
10
|
+
import type { SubscriptionContract, SubscriptionLine } from './page-answers.ts';
|
|
12
11
|
|
|
13
|
-
/**
|
|
14
|
-
|
|
15
|
-
* @property {string} title
|
|
16
|
-
* @property {string | null} variantTitle
|
|
17
|
-
* @property {number} quantity
|
|
18
|
-
* @property {Money} currentPrice
|
|
19
|
-
* @property {Money | null} lineDiscountedPrice
|
|
20
|
-
* @property {{ url: string, altText: string | null } | null} image
|
|
21
|
-
*/
|
|
22
|
-
/**
|
|
23
|
-
* @typedef {object} SubscriptionContract The subscriptions document in account-ops.js.
|
|
24
|
-
* @property {string} id
|
|
25
|
-
* @property {'ACTIVE' | 'PAUSED' | 'CANCELLED' | 'EXPIRED' | 'FAILED'} status
|
|
26
|
-
* @property {string | null} nextBillingDate
|
|
27
|
-
* @property {string} currencyCode
|
|
28
|
-
* @property {Money | null} deliveryPrice
|
|
29
|
-
* @property {{ interval: string | null, intervalCount: { count: number } | null } | null} deliveryPolicy
|
|
30
|
-
* @property {{ nodes: SubscriptionLine[] }} lines
|
|
31
|
-
* @property {{ nodes: { cycleIndex: number, skipped: boolean }[] }} upcomingBillingCycles
|
|
32
|
-
*/
|
|
33
|
-
|
|
34
|
-
/**
|
|
35
|
-
* @param {SubscriptionContract} contract
|
|
36
|
-
* @returns {Record<string, boolean>} The row's `tether-subscription-show` conditions, `can-<action>` among them.
|
|
37
|
-
*/
|
|
38
|
-
function conditionsOf(contract) {
|
|
12
|
+
/** @returns The row's `tether-subscription-show` conditions, `can-<action>` among them. */
|
|
13
|
+
function conditionsOf(contract: SubscriptionContract): Record<string, boolean> {
|
|
39
14
|
const next = contract.upcomingBillingCycles.nodes[0];
|
|
40
15
|
const status = contract.status;
|
|
41
16
|
const active = status === 'ACTIVE';
|
|
@@ -54,42 +29,33 @@ function conditionsOf(contract) {
|
|
|
54
29
|
};
|
|
55
30
|
}
|
|
56
31
|
|
|
57
|
-
/**
|
|
58
|
-
|
|
59
|
-
* @returns {string} `month`, `3 months`; `''` without a delivery policy.
|
|
60
|
-
*/
|
|
61
|
-
function intervalText(contract) {
|
|
32
|
+
/** @returns `month`, `3 months`; `''` without a delivery policy. */
|
|
33
|
+
function intervalText(contract: SubscriptionContract): string {
|
|
62
34
|
const unit = enumText(contract.deliveryPolicy?.interval);
|
|
63
35
|
if (!unit) return '';
|
|
64
36
|
const count = contract.deliveryPolicy?.intervalCount?.count ?? 1;
|
|
65
|
-
return count <= 1 ? unit : `${count} ${unit}s`;
|
|
37
|
+
return count <= 1 ? unit : `${String(count)} ${unit}s`;
|
|
66
38
|
}
|
|
67
39
|
|
|
68
|
-
/**
|
|
69
|
-
|
|
70
|
-
* @returns {Record<string, string>} The text of each `tether-subscription-bind` value.
|
|
71
|
-
*/
|
|
72
|
-
function contractTexts(contract) {
|
|
40
|
+
/** @returns The text of each `tether-subscription-bind` value. */
|
|
41
|
+
function contractTexts(contract: SubscriptionContract): Record<string, string> {
|
|
73
42
|
const lines = contract.lines.nodes;
|
|
74
43
|
const total = lines.reduce((sum, line) => sum + Number.parseFloat(line.lineDiscountedPrice?.amount ?? '0'), 0);
|
|
75
44
|
return {
|
|
76
45
|
status: enumText(contract.status),
|
|
77
|
-
next:
|
|
46
|
+
next: formatDate(contract.nextBillingDate),
|
|
78
47
|
interval: intervalText(contract),
|
|
79
|
-
products: lines.map((line) => `${line.quantity}× ${line.title}`).join(', '),
|
|
80
|
-
total:
|
|
48
|
+
products: lines.map((line) => `${String(line.quantity)}× ${line.title ?? ''}`).join(', '),
|
|
49
|
+
total: formatMoney(
|
|
50
|
+
lines.length && contract.currencyCode ? { amount: String(total), currencyCode: contract.currencyCode } : null,
|
|
51
|
+
),
|
|
81
52
|
delivery: formatMoney(contract.deliveryPrice),
|
|
82
53
|
};
|
|
83
54
|
}
|
|
84
55
|
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
*/
|
|
89
|
-
function fillLine(row, line) {
|
|
90
|
-
/** @type {Record<string, string>} */
|
|
91
|
-
const texts = {
|
|
92
|
-
title: line.title,
|
|
56
|
+
function fillLine(row: HTMLElement, line: SubscriptionLine): void {
|
|
57
|
+
const texts: Record<string, string> = {
|
|
58
|
+
title: line.title ?? '',
|
|
93
59
|
'variant-title': line.variantTitle || '',
|
|
94
60
|
quantity: String(line.quantity),
|
|
95
61
|
price: formatMoney(line.currentPrice),
|
|
@@ -97,7 +63,7 @@ function fillLine(row, line) {
|
|
|
97
63
|
bindsIn(row, 'subscription-line').forEach((el) => {
|
|
98
64
|
const key = get(el, 'subscription-line') ?? '';
|
|
99
65
|
if (key === 'image') {
|
|
100
|
-
if (line.image) setImage(el, line.image.url, line.image.altText || line.title);
|
|
66
|
+
if (line.image) setImage(el, line.image.url, line.image.altText || line.title || '');
|
|
101
67
|
return;
|
|
102
68
|
}
|
|
103
69
|
const text = ownValue(texts, key);
|
|
@@ -105,11 +71,7 @@ function fillLine(row, line) {
|
|
|
105
71
|
});
|
|
106
72
|
}
|
|
107
73
|
|
|
108
|
-
|
|
109
|
-
* @param {HTMLElement} row
|
|
110
|
-
* @param {SubscriptionContract} contract
|
|
111
|
-
*/
|
|
112
|
-
function fillContract(row, contract) {
|
|
74
|
+
function fillContract(row: HTMLElement, contract: SubscriptionContract): void {
|
|
113
75
|
row.dataset['tetherSubscription'] = contract.id;
|
|
114
76
|
row.setAttribute(attr('subscription-status'), enumText(contract.status));
|
|
115
77
|
const texts = contractTexts(contract);
|
|
@@ -129,17 +91,12 @@ function fillContract(row, contract) {
|
|
|
129
91
|
});
|
|
130
92
|
}
|
|
131
93
|
|
|
132
|
-
/**
|
|
133
|
-
|
|
134
|
-
*
|
|
135
|
-
* @param {ParentNode} root
|
|
136
|
-
*/
|
|
137
|
-
export async function renderSubscriptions(root) {
|
|
94
|
+
/** Loads and renders the subscriptions. A failure is reported and shows `subscriptions-error`. */
|
|
95
|
+
export async function renderSubscriptions(root: ParentNode): Promise<void> {
|
|
138
96
|
const boxes = qsa(root, sel('subscriptions'));
|
|
139
97
|
if (!boxes.length) return;
|
|
140
98
|
showIn(root, 'show', { 'subscriptions-loading': true, 'subscriptions-empty': false });
|
|
141
|
-
|
|
142
|
-
let list;
|
|
99
|
+
let list: SubscriptionContract[];
|
|
143
100
|
try {
|
|
144
101
|
list = await subscriptions();
|
|
145
102
|
} catch (error) {
|