@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,253 @@
|
|
|
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.ts';
|
|
7
|
+
import {
|
|
8
|
+
saveAddress,
|
|
9
|
+
deleteAddress,
|
|
10
|
+
subscriptionAction,
|
|
11
|
+
requestReturn,
|
|
12
|
+
updateProfile,
|
|
13
|
+
pagesContext,
|
|
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
|
+
|
|
22
|
+
const SUBSCRIPTION_ACTIONS: readonly SubscriptionAction[] = ['pause', 'resume', 'skip', 'unskip', 'cancel'];
|
|
23
|
+
|
|
24
|
+
const isSubscriptionAction = (value: string | null): value is SubscriptionAction =>
|
|
25
|
+
SUBSCRIPTION_ACTIONS.some((action) => action === value);
|
|
26
|
+
|
|
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;
|
|
31
|
+
|
|
32
|
+
const isForm = (el: Element): el is HTMLFormElement => el instanceof window.HTMLFormElement;
|
|
33
|
+
|
|
34
|
+
/** @returns The form's text fields by name. */
|
|
35
|
+
function fieldsOf(form: HTMLFormElement): Record<string, string> {
|
|
36
|
+
const fields: Record<string, string> = {};
|
|
37
|
+
for (const field of form.elements) {
|
|
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;
|
|
41
|
+
}
|
|
42
|
+
return fields;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** @returns What to tell the customer: Shopify's own messages when it refused the input. */
|
|
46
|
+
function customerMessage(error: unknown): string {
|
|
47
|
+
const userErrors = error instanceof TetherError ? (error.userErrors ?? []) : [];
|
|
48
|
+
const message = userErrors.map((userError) => userError.message).join(' ');
|
|
49
|
+
if (message) return message;
|
|
50
|
+
const text = error instanceof Error && error.message ? error.message : 'Something went wrong';
|
|
51
|
+
return text.replace(/^Customer accounts: /, '');
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Runs one change for `el`; a second one while it is on its way (Enter, a double click) is ignored. The failure is
|
|
56
|
+
* shown in `el` (`tether-form-error`), the place the customer looks.
|
|
57
|
+
*/
|
|
58
|
+
async function act(el: HTMLElement, change: () => Promise<void>): Promise<'saved' | 'error' | 'busy'> {
|
|
59
|
+
if (el.getAttribute('aria-busy') === 'true') return 'busy';
|
|
60
|
+
el.classList.add('is-busy');
|
|
61
|
+
el.setAttribute('aria-busy', 'true');
|
|
62
|
+
el.setAttribute(attr('form-state'), 'pending');
|
|
63
|
+
const errorBoxes = qsa(el, sel('form-error'));
|
|
64
|
+
errorBoxes.forEach((box) => {
|
|
65
|
+
setText(box, '');
|
|
66
|
+
});
|
|
67
|
+
try {
|
|
68
|
+
await change();
|
|
69
|
+
el.setAttribute(attr('form-state'), 'saved');
|
|
70
|
+
return 'saved';
|
|
71
|
+
} catch (error) {
|
|
72
|
+
el.setAttribute(attr('form-state'), 'error');
|
|
73
|
+
errorBoxes.forEach((box) => {
|
|
74
|
+
setText(box, customerMessage(error));
|
|
75
|
+
});
|
|
76
|
+
return 'error';
|
|
77
|
+
} finally {
|
|
78
|
+
el.classList.remove('is-busy');
|
|
79
|
+
el.removeAttribute('aria-busy');
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** @returns True when the button asks no `tether-confirm` question, or the visitor agreed. */
|
|
84
|
+
const confirmed = (button: Element): boolean => {
|
|
85
|
+
const question = get(button, 'confirm');
|
|
86
|
+
return !question || window.confirm(question);
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
const addressFormReset = (form: HTMLFormElement): void => {
|
|
90
|
+
form.reset();
|
|
91
|
+
delete form.dataset['tetherAddressId'];
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
function onSubmit(event: SubmitEvent): void {
|
|
95
|
+
const form = event.target;
|
|
96
|
+
if (!(form instanceof window.HTMLFormElement)) return;
|
|
97
|
+
if (form.matches(sel('profile-form'))) {
|
|
98
|
+
event.preventDefault();
|
|
99
|
+
void act(form, async () => {
|
|
100
|
+
await updateProfile(fieldsOf(form));
|
|
101
|
+
await pagesContext().bindCustomer(document);
|
|
102
|
+
});
|
|
103
|
+
} else if (form.matches(sel('address-form'))) {
|
|
104
|
+
event.preventDefault();
|
|
105
|
+
const id = form.dataset['tetherAddressId'];
|
|
106
|
+
const box = form.elements.namedItem('isDefault');
|
|
107
|
+
const isDefault = box instanceof window.HTMLInputElement && box.checked;
|
|
108
|
+
void act(form, async () => {
|
|
109
|
+
await saveAddress(fieldsOf(form), { id, isDefault });
|
|
110
|
+
addressFormReset(form);
|
|
111
|
+
await renderAddresses(document);
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Fills the address form with a shown address, to edit it. */
|
|
117
|
+
function editAddress(id: string): void {
|
|
118
|
+
const address = shownAddress(id);
|
|
119
|
+
const form = document.querySelector(sel('address-form'));
|
|
120
|
+
if (!address || !form || !isForm(form)) return;
|
|
121
|
+
form.dataset['tetherAddressId'] = address.id;
|
|
122
|
+
const valuesByName: Readonly<Record<string, unknown>> = { ...address };
|
|
123
|
+
for (const field of form.elements) {
|
|
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 : '';
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
form.scrollIntoView({ block: 'nearest' });
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** @returns True when the click was on an address button. */
|
|
136
|
+
function onAddressClick(event: MouseEvent, target: Element): boolean {
|
|
137
|
+
const row = target.closest('[data-tether-address]');
|
|
138
|
+
const id = row instanceof window.HTMLElement ? row.dataset['tetherAddress'] : undefined;
|
|
139
|
+
if (target.closest(sel('address-new'))) {
|
|
140
|
+
const form = document.querySelector(sel('address-form'));
|
|
141
|
+
if (form && isForm(form)) addressFormReset(form);
|
|
142
|
+
return true;
|
|
143
|
+
}
|
|
144
|
+
if (!(row instanceof window.HTMLElement) || !id) return false;
|
|
145
|
+
const deleteButton = target.closest(sel('address-delete'));
|
|
146
|
+
if (deleteButton) {
|
|
147
|
+
event.preventDefault();
|
|
148
|
+
if (!confirmed(deleteButton)) return true;
|
|
149
|
+
void act(row, async () => {
|
|
150
|
+
await deleteAddress(id);
|
|
151
|
+
await renderAddresses(document);
|
|
152
|
+
});
|
|
153
|
+
} else if (target.closest(sel('address-default'))) {
|
|
154
|
+
event.preventDefault();
|
|
155
|
+
void act(row, async () => {
|
|
156
|
+
await saveAddress(null, { id, isDefault: true });
|
|
157
|
+
await renderAddresses(document);
|
|
158
|
+
});
|
|
159
|
+
} else if (target.closest(sel('address-edit'))) {
|
|
160
|
+
event.preventDefault();
|
|
161
|
+
editAddress(id);
|
|
162
|
+
} else {
|
|
163
|
+
return false;
|
|
164
|
+
}
|
|
165
|
+
return true;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** @param button A `tether-subscription-action` button. */
|
|
169
|
+
function onSubscriptionAction(button: HTMLElement): void {
|
|
170
|
+
const row = button.closest('[data-tether-subscription]');
|
|
171
|
+
const action = get(button, 'subscription-action');
|
|
172
|
+
const disabled = ('disabled' in button && button.disabled === true) || button.classList.contains('is-disabled');
|
|
173
|
+
if (!(row instanceof window.HTMLElement) || !isSubscriptionAction(action) || disabled || !confirmed(button)) return;
|
|
174
|
+
const id = row.dataset['tetherSubscription'] ?? '';
|
|
175
|
+
row.setAttribute(attr('subscription-state'), 'pending');
|
|
176
|
+
void act(row, async () => {
|
|
177
|
+
await subscriptionAction(id, action);
|
|
178
|
+
await renderSubscriptions(document);
|
|
179
|
+
}).then((outcome) => {
|
|
180
|
+
const state = outcome === 'error' ? 'error' : 'done';
|
|
181
|
+
// The re-render may have replaced the row: mark the one that shows this subscription now.
|
|
182
|
+
qsa(document, '[data-tether-subscription]')
|
|
183
|
+
.filter((candidate) => candidate === row || candidate.dataset['tetherSubscription'] === id)
|
|
184
|
+
.forEach((candidate) => {
|
|
185
|
+
candidate.setAttribute(attr('subscription-state'), state);
|
|
186
|
+
});
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** @param row A line row of the order page. */
|
|
191
|
+
function returnLineOf(row: HTMLElement): ReturnLine {
|
|
192
|
+
const quantity = row.querySelector(sel('return-quantity'));
|
|
193
|
+
const reason = row.querySelector(sel('return-reason'));
|
|
194
|
+
const note = row.querySelector(sel('return-note'));
|
|
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
|
+
};
|
|
201
|
+
const reasonId = valueOf(reason);
|
|
202
|
+
if (reasonId) line.reasonId = reasonId;
|
|
203
|
+
const noteText = valueOf(note);
|
|
204
|
+
if (noteText) line.note = noteText;
|
|
205
|
+
return line;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/** @param button A `tether-order-return` button. */
|
|
209
|
+
function onReturnRequest(button: HTMLElement): void {
|
|
210
|
+
const scope = button.closest(sel('order'));
|
|
211
|
+
if (!(scope instanceof window.HTMLElement)) return;
|
|
212
|
+
const orderId = scope.dataset['tetherOrderId'];
|
|
213
|
+
if (!orderId) return;
|
|
214
|
+
const lines = qsa(scope, '[data-tether-line]')
|
|
215
|
+
.map(returnLineOf)
|
|
216
|
+
.filter((line) => line.quantity > 0);
|
|
217
|
+
if (!lines.length || !confirmed(button)) return;
|
|
218
|
+
void act(scope, async () => {
|
|
219
|
+
await requestReturn(orderId, lines);
|
|
220
|
+
scope.dataset['tetherReturned'] = '1';
|
|
221
|
+
await renderOrder(scope);
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function onClick(event: MouseEvent): void {
|
|
226
|
+
const target = event.target;
|
|
227
|
+
if (!(target instanceof window.Element)) return;
|
|
228
|
+
if (onAddressClick(event, target)) return;
|
|
229
|
+
const subscriptionButton = target.closest(sel('subscription-action'));
|
|
230
|
+
if (subscriptionButton instanceof window.HTMLElement) {
|
|
231
|
+
event.preventDefault();
|
|
232
|
+
onSubscriptionAction(subscriptionButton);
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
235
|
+
const returnButton = target.closest(sel('order-return'));
|
|
236
|
+
if (returnButton instanceof window.HTMLElement) {
|
|
237
|
+
event.preventDefault();
|
|
238
|
+
onReturnRequest(returnButton);
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/** Listens on the document once, for every account page rendered into it. */
|
|
243
|
+
export function listenToPages(): void {
|
|
244
|
+
// Capture: before a router (Astro's ClientRouter) turns the submit into a navigation with the fields in the URL.
|
|
245
|
+
document.addEventListener('submit', onSubmit, true);
|
|
246
|
+
document.addEventListener('input', (event) => {
|
|
247
|
+
const target = event.target;
|
|
248
|
+
if (target instanceof window.HTMLElement && target.closest(sel('profile-form'))) {
|
|
249
|
+
target.dataset['tetherTouched'] = '1';
|
|
250
|
+
}
|
|
251
|
+
});
|
|
252
|
+
document.addEventListener('click', onClick);
|
|
253
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The customer's subscriptions (`tether-subscriptions`), with `tether-show="subscriptions-loading"`,
|
|
3
|
+
* `subscriptions-empty` and `subscriptions-error`. Each row's `tether-subscription-show` conditions and action buttons
|
|
4
|
+
* follow the contract's status and its next billing cycle.
|
|
5
|
+
*/
|
|
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';
|
|
11
|
+
|
|
12
|
+
/** @returns The row's `tether-subscription-show` conditions, `can-<action>` among them. */
|
|
13
|
+
function conditionsOf(contract: SubscriptionContract): Record<string, boolean> {
|
|
14
|
+
const next = contract.upcomingBillingCycles.nodes[0];
|
|
15
|
+
const status = contract.status;
|
|
16
|
+
const active = status === 'ACTIVE';
|
|
17
|
+
return {
|
|
18
|
+
active,
|
|
19
|
+
paused: status === 'PAUSED',
|
|
20
|
+
cancelled: status === 'CANCELLED',
|
|
21
|
+
expired: status === 'EXPIRED',
|
|
22
|
+
failed: status === 'FAILED',
|
|
23
|
+
skipped: Boolean(next?.skipped),
|
|
24
|
+
'can-pause': active,
|
|
25
|
+
'can-resume': status === 'PAUSED' || status === 'FAILED',
|
|
26
|
+
'can-skip': active && next !== undefined && !next.skipped,
|
|
27
|
+
'can-unskip': active && Boolean(next?.skipped),
|
|
28
|
+
'can-cancel': active || status === 'PAUSED' || status === 'FAILED',
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** @returns `month`, `3 months`; `''` without a delivery policy. */
|
|
33
|
+
function intervalText(contract: SubscriptionContract): string {
|
|
34
|
+
const unit = enumText(contract.deliveryPolicy?.interval);
|
|
35
|
+
if (!unit) return '';
|
|
36
|
+
const count = contract.deliveryPolicy?.intervalCount?.count ?? 1;
|
|
37
|
+
return count <= 1 ? unit : `${String(count)} ${unit}s`;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** @returns The text of each `tether-subscription-bind` value. */
|
|
41
|
+
function contractTexts(contract: SubscriptionContract): Record<string, string> {
|
|
42
|
+
const lines = contract.lines.nodes;
|
|
43
|
+
const total = lines.reduce((sum, line) => sum + Number.parseFloat(line.lineDiscountedPrice?.amount ?? '0'), 0);
|
|
44
|
+
return {
|
|
45
|
+
status: enumText(contract.status),
|
|
46
|
+
next: formatDate(contract.nextBillingDate),
|
|
47
|
+
interval: intervalText(contract),
|
|
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
|
+
),
|
|
52
|
+
delivery: formatMoney(contract.deliveryPrice),
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function fillLine(row: HTMLElement, line: SubscriptionLine): void {
|
|
57
|
+
const texts: Record<string, string> = {
|
|
58
|
+
title: line.title ?? '',
|
|
59
|
+
'variant-title': line.variantTitle || '',
|
|
60
|
+
quantity: String(line.quantity),
|
|
61
|
+
price: formatMoney(line.currentPrice),
|
|
62
|
+
};
|
|
63
|
+
bindsIn(row, 'subscription-line').forEach((el) => {
|
|
64
|
+
const key = get(el, 'subscription-line') ?? '';
|
|
65
|
+
if (key === 'image') {
|
|
66
|
+
if (line.image) setImage(el, line.image.url, line.image.altText || line.title || '');
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
const text = ownValue(texts, key);
|
|
70
|
+
if (text !== undefined) setText(el, text);
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function fillContract(row: HTMLElement, contract: SubscriptionContract): void {
|
|
75
|
+
row.dataset['tetherSubscription'] = contract.id;
|
|
76
|
+
row.setAttribute(attr('subscription-status'), enumText(contract.status));
|
|
77
|
+
const texts = contractTexts(contract);
|
|
78
|
+
qsa(row, sel('subscription-bind')).forEach((el) => {
|
|
79
|
+
const text = ownValue(texts, get(el, 'subscription-bind') ?? '');
|
|
80
|
+
if (text !== undefined) setText(el, text);
|
|
81
|
+
});
|
|
82
|
+
qsa(row, sel('subscription-lines')).forEach((box) => {
|
|
83
|
+
repeat(box, contract.lines.nodes, fillLine);
|
|
84
|
+
});
|
|
85
|
+
const conditions = conditionsOf(contract);
|
|
86
|
+
showIn(row, 'subscription-show', conditions);
|
|
87
|
+
qsa(row, sel('subscription-action')).forEach((button) => {
|
|
88
|
+
const allowed = ownValue(conditions, `can-${get(button, 'subscription-action') ?? ''}`) === true;
|
|
89
|
+
if ('disabled' in button) button.disabled = !allowed;
|
|
90
|
+
button.classList.toggle('is-disabled', !allowed);
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Loads and renders the subscriptions. A failure is reported and shows `subscriptions-error`. */
|
|
95
|
+
export async function renderSubscriptions(root: ParentNode): Promise<void> {
|
|
96
|
+
const boxes = qsa(root, sel('subscriptions'));
|
|
97
|
+
if (!boxes.length) return;
|
|
98
|
+
showIn(root, 'show', { 'subscriptions-loading': true, 'subscriptions-empty': false });
|
|
99
|
+
let list: SubscriptionContract[];
|
|
100
|
+
try {
|
|
101
|
+
list = await subscriptions();
|
|
102
|
+
} catch (error) {
|
|
103
|
+
reportError(error, 'subscriptions');
|
|
104
|
+
showIn(root, 'show', { 'subscriptions-loading': false, 'subscriptions-error': true });
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
showIn(root, 'show', {
|
|
108
|
+
'subscriptions-loading': false,
|
|
109
|
+
'subscriptions-empty': list.length === 0,
|
|
110
|
+
'subscriptions-error': false,
|
|
111
|
+
});
|
|
112
|
+
boxes.forEach((box) => {
|
|
113
|
+
repeat(box, list, fillContract);
|
|
114
|
+
});
|
|
115
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The core's side of customer accounts, which are an optional part: whether a page needs the part, and
|
|
3
|
+
* `Tether.account`, whose functions load it on first call. Every function returns a promise, the part's own sync ones
|
|
4
|
+
* (`isLoggedIn`) too, because the part may still have to load.
|
|
5
|
+
*/
|
|
6
|
+
import { cfg, reportError } from './config.ts';
|
|
7
|
+
import { attr } from './dom.ts';
|
|
8
|
+
import { loadPart } from './lazy.ts';
|
|
9
|
+
import { within } from './mounting.ts';
|
|
10
|
+
import { hintKey, isLoginOutcome } from './account-marks.ts';
|
|
11
|
+
import { getStored } from './storage.ts';
|
|
12
|
+
import type { Parts } from './lazy.ts';
|
|
13
|
+
|
|
14
|
+
type AccountPart = Parts['account'];
|
|
15
|
+
type AccountResult<Name extends keyof AccountPart> = Awaited<ReturnType<AccountPart[Name]>>;
|
|
16
|
+
type AccountFunction<Name extends keyof AccountPart> = (
|
|
17
|
+
...args: Parameters<AccountPart[Name]>
|
|
18
|
+
) => Promise<AccountResult<Name>>;
|
|
19
|
+
type AccountApi = { [Name in keyof AccountPart]: AccountFunction<Name> };
|
|
20
|
+
|
|
21
|
+
/** The attributes of the account markup: a page with one of them loads the part. */
|
|
22
|
+
const ACCOUNT_ATTRIBUTES = [
|
|
23
|
+
'account-login',
|
|
24
|
+
'account-bind',
|
|
25
|
+
'account-link',
|
|
26
|
+
'orders',
|
|
27
|
+
'order',
|
|
28
|
+
'addresses',
|
|
29
|
+
'subscriptions',
|
|
30
|
+
'profile-form',
|
|
31
|
+
'address-form',
|
|
32
|
+
];
|
|
33
|
+
|
|
34
|
+
/** @returns A selector for every element the account part fills in or shows. */
|
|
35
|
+
const accountSelector = (): string =>
|
|
36
|
+
[...ACCOUNT_ATTRIBUTES.map((name) => `[${attr(name)}]`), `[${attr('show')}^="logged-"]`].join(',');
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Whether this browser keeps the "may be logged in" hint, so checkout on a page without account markup still knows
|
|
40
|
+
* the customer.
|
|
41
|
+
*
|
|
42
|
+
* @returns False where the browser blocks storage, which then holds no hint.
|
|
43
|
+
*/
|
|
44
|
+
function hasLoginHint(): boolean {
|
|
45
|
+
return Boolean(getStored(hintKey()));
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Loads the account part and mounts it on `root`, when accounts are configured and the page needs them: account
|
|
50
|
+
* markup in `root`, a login coming back from the Worker (`?account=`), or the "may be logged in" hint.
|
|
51
|
+
*/
|
|
52
|
+
export function mountAccountIfUsed(root: ParentNode): void {
|
|
53
|
+
if (!cfg.account) return;
|
|
54
|
+
const needed =
|
|
55
|
+
within(root, accountSelector()).length > 0 ||
|
|
56
|
+
isLoginOutcome(new URLSearchParams(window.location.search).get('account')) ||
|
|
57
|
+
hasLoginHint();
|
|
58
|
+
if (!needed) return;
|
|
59
|
+
loadPart('account')
|
|
60
|
+
.then((part) => part.mountAccount(root))
|
|
61
|
+
.catch((error: unknown) => {
|
|
62
|
+
reportError(error, 'account');
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** @returns `name` of the account part, called once the part has loaded. */
|
|
67
|
+
function fromPart<Name extends keyof AccountPart>(name: Name): AccountFunction<Name> {
|
|
68
|
+
return (...args) =>
|
|
69
|
+
loadPart('account').then((part) => {
|
|
70
|
+
// `Name` picks one function of the part, and `args` are that function's parameters.
|
|
71
|
+
const call = part[name] as (
|
|
72
|
+
...callArgs: Parameters<AccountPart[Name]>
|
|
73
|
+
) => AccountResult<Name> | PromiseLike<AccountResult<Name>>;
|
|
74
|
+
return call(...args);
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* `Tether.account`: the account part's functions, each loading the part on first call. A plain object, so `await
|
|
80
|
+
* Tether.account` and `Object.keys(Tether.account)` behave as for any object.
|
|
81
|
+
*/
|
|
82
|
+
export const accountApi: AccountApi = {
|
|
83
|
+
login: fromPart('login'),
|
|
84
|
+
logout: fromPart('logout'),
|
|
85
|
+
isLoggedIn: fromPart('isLoggedIn'),
|
|
86
|
+
customer: fromPart('customer'),
|
|
87
|
+
orders: fromPart('orders'),
|
|
88
|
+
order: fromPart('order'),
|
|
89
|
+
updateProfile: fromPart('updateProfile'),
|
|
90
|
+
addresses: fromPart('addresses'),
|
|
91
|
+
saveAddress: fromPart('saveAddress'),
|
|
92
|
+
deleteAddress: fromPart('deleteAddress'),
|
|
93
|
+
subscriptions: fromPart('subscriptions'),
|
|
94
|
+
subscriptionAction: fromPart('subscriptionAction'),
|
|
95
|
+
requestReturn: fromPart('requestReturn'),
|
|
96
|
+
render: fromPart('render'),
|
|
97
|
+
mountAccount: fromPart('mountAccount'),
|
|
98
|
+
};
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What tells a page that customer accounts are in use in this browser: the "may be logged in" hint the account part
|
|
3
|
+
* keeps in `localStorage`, and the Worker's `?account=` mark after a login. The core reads them to decide whether a
|
|
4
|
+
* page loads the part, and the part writes the hint, so both take them from here.
|
|
5
|
+
*/
|
|
6
|
+
import { cfg } from './config.ts';
|
|
7
|
+
import { removeStored } from './storage.ts';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* What the Worker's login redirect says in `?account=`: `in` after a login, `expired` when the login took too long,
|
|
11
|
+
* `error` when it failed.
|
|
12
|
+
*/
|
|
13
|
+
type LoginOutcome = 'in' | 'expired' | 'error';
|
|
14
|
+
|
|
15
|
+
/** The "may be logged in" hint's key: it makes the core load the part on the next page, and holds no secret. */
|
|
16
|
+
export const hintKey = (): string => `${cfg.prefix}:account-hint:${cfg.domain}`;
|
|
17
|
+
|
|
18
|
+
/** Removes a key from both storage areas, each on its own: a blocked one doesn't keep the key in the other. */
|
|
19
|
+
function removeFromBothAreas(key: string): void {
|
|
20
|
+
removeStored(key, 'sessionStorage');
|
|
21
|
+
removeStored(key, 'localStorage');
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Removes the customer tokens and pending login that Tether 0.6's browser-mode accounts kept in this browser. The
|
|
26
|
+
* session held a refresh token with the customer's full scope, so it must not stay behind once accounts run through
|
|
27
|
+
* the Worker. TODO: remove in 0.8.0, when no visitor's browser still holds a 0.6 session.
|
|
28
|
+
*/
|
|
29
|
+
export function forgetBrowserModeTokens(): void {
|
|
30
|
+
removeFromBothAreas(`${cfg.prefix}:account:${cfg.domain}`);
|
|
31
|
+
removeFromBothAreas(`${cfg.prefix}:account-login:${cfg.domain}`);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export const isLoginOutcome = (value: string | null): value is LoginOutcome =>
|
|
35
|
+
value === 'in' || value === 'expired' || value === 'error';
|