@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,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sending the visitor to Shopify's checkout. The URL comes from Shopify at click time, because the cart's checkout URL
|
|
3
|
+
* changes with its market and customer; the account part resolves a logged-in customer's own (`setCheckoutResolver`).
|
|
4
|
+
*/
|
|
5
|
+
import { cfg, reportError } from '../config.ts';
|
|
6
|
+
import { TetherError } from '../client.ts';
|
|
7
|
+
import { isHttpsUrl } from '../shopify-values.ts';
|
|
8
|
+
import { emit } from '../dom.ts';
|
|
9
|
+
import { fetchCheckoutUrl } from './mutations.ts';
|
|
10
|
+
import { currentCart, savedCartId } from './store.ts';
|
|
11
|
+
import type { CheckoutTarget } from '../config.ts';
|
|
12
|
+
|
|
13
|
+
type CheckoutUrlAnswer = { kind: 'url'; url: string | null } | { kind: 'failed'; error: unknown };
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* The logged-in customer's checkout URL for the cart; `null` when nobody is logged in, so the cart's own checkout URL
|
|
17
|
+
* is used.
|
|
18
|
+
*/
|
|
19
|
+
type CheckoutResolver = () => Promise<string | null>;
|
|
20
|
+
|
|
21
|
+
let resolveAccountCheckout: CheckoutResolver | null = null;
|
|
22
|
+
|
|
23
|
+
/** Sets the account part's checkout URL, or clears it with `null`. A URL it answers opens checkout logged in. */
|
|
24
|
+
export function setCheckoutResolver(resolver: CheckoutResolver | null): void {
|
|
25
|
+
resolveAccountCheckout = resolver;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* @returns Shopify's answer as a value, so the request can run alongside the account's and its failure is read only
|
|
30
|
+
* when its URL is the one used.
|
|
31
|
+
*/
|
|
32
|
+
const askCheckoutUrl = (cartId: string): Promise<CheckoutUrlAnswer> =>
|
|
33
|
+
fetchCheckoutUrl(cartId).then(
|
|
34
|
+
(url) => ({ kind: 'url', url }),
|
|
35
|
+
(error: unknown) => ({ kind: 'failed', error }),
|
|
36
|
+
);
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* @returns The logged-in customer's checkout URL. When the account can't give one, the failure is reported and the
|
|
40
|
+
* cart's own checkout URL is used, which still checks out (without the login).
|
|
41
|
+
*/
|
|
42
|
+
async function accountCheckoutUrl(): Promise<string | null> {
|
|
43
|
+
if (!resolveAccountCheckout) return null;
|
|
44
|
+
try {
|
|
45
|
+
const url = await resolveAccountCheckout();
|
|
46
|
+
// Shopify's checkout shows the customer as logged in only with this parameter.
|
|
47
|
+
return url && `${url}${url.includes('?') ? '&' : '?'}logged_in=true`;
|
|
48
|
+
} catch (error) {
|
|
49
|
+
reportError(error, 'account checkout');
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** @throws {TetherError} `ERROR` without a checkout URL (the cart is empty or Shopify no longer has it). */
|
|
55
|
+
async function checkoutUrl(cartId: string): Promise<string> {
|
|
56
|
+
const cartAnswer = askCheckoutUrl(cartId);
|
|
57
|
+
const accountUrl = await accountCheckoutUrl();
|
|
58
|
+
if (accountUrl) return accountUrl;
|
|
59
|
+
const answer = await cartAnswer;
|
|
60
|
+
if (answer.kind === 'failed') throw answer.error;
|
|
61
|
+
if (!answer.url) throw new TetherError('No checkoutUrl (cart empty or expired)');
|
|
62
|
+
return answer.url;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* A new tab's `opener` gives the checkout page a handle on this one; a browser that doesn't let a script clear it
|
|
67
|
+
* leaves the tab as it opened.
|
|
68
|
+
*/
|
|
69
|
+
function detachOpenerIfAllowed(tab: Window) {
|
|
70
|
+
try {
|
|
71
|
+
tab.opener = null;
|
|
72
|
+
} catch {
|
|
73
|
+
// The browser keeps `opener` read-only: see the function's comment.
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Goes to Shopify's checkout. With `_blank` the tab opens inside the click (Safari blocks `window.open` after an
|
|
79
|
+
* await) and gets its URL once there is one.
|
|
80
|
+
*
|
|
81
|
+
* @returns The checkout URL, `null` with nothing to check out or after a reported failure.
|
|
82
|
+
*/
|
|
83
|
+
export async function checkout(target: CheckoutTarget = cfg.checkoutTarget): Promise<string | null> {
|
|
84
|
+
const cartId = savedCartId();
|
|
85
|
+
const cart = currentCart();
|
|
86
|
+
if (!cartId || (cart && !cart.totalQuantity)) return null;
|
|
87
|
+
const tab = target === '_blank' ? window.open('', '_blank') : null;
|
|
88
|
+
if (tab) detachOpenerIfAllowed(tab);
|
|
89
|
+
try {
|
|
90
|
+
const url = await checkoutUrl(cartId);
|
|
91
|
+
if (!isHttpsUrl(url)) throw new TetherError('The checkout URL is not https: not opened');
|
|
92
|
+
emit('checkout', { url, cart: currentCart() });
|
|
93
|
+
if (tab && !tab.closed) tab.location.href = url;
|
|
94
|
+
else window.location.href = url;
|
|
95
|
+
return url;
|
|
96
|
+
} catch (error) {
|
|
97
|
+
tab?.close();
|
|
98
|
+
reportError(error, 'checkout failed');
|
|
99
|
+
return null;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The cart drawer: every `[tether-cart-drawer]` on the page opens and closes together, as a modal dialog. Focus moves
|
|
3
|
+
* into the open cart, Tab stays inside it and focus returns on close (`cartFocus`). A page without a drawer (a cart
|
|
4
|
+
* built in React, Svelte or Vue) still has an open state, for `Tether.cart.subscribe`.
|
|
5
|
+
*/
|
|
6
|
+
import { cfg } from '../config.ts';
|
|
7
|
+
import { sel, get, qsa, emit } from '../dom.ts';
|
|
8
|
+
|
|
9
|
+
const FOCUSABLE =
|
|
10
|
+
'a[href],button:not([disabled]),input:not([disabled]),select:not([disabled]),textarea:not([disabled]),[tabindex]:not([tabindex="-1"])';
|
|
11
|
+
|
|
12
|
+
/** Long enough for the drawer's open transition to make its content visible: hidden elements can't take focus. */
|
|
13
|
+
const FOCUS_AFTER_OPEN_MS = 60;
|
|
14
|
+
|
|
15
|
+
/** The open state of a page without a drawer element. */
|
|
16
|
+
let openWithoutDrawer = false;
|
|
17
|
+
|
|
18
|
+
/** Where focus was before the cart opened. */
|
|
19
|
+
let returnFocus: HTMLElement | null = null;
|
|
20
|
+
|
|
21
|
+
const isFocusable = (el: Element | null): el is HTMLElement => el !== null && 'focus' in el;
|
|
22
|
+
|
|
23
|
+
/** @returns The visible focusable elements inside `drawer`, in document order. */
|
|
24
|
+
const focusables = (drawer: ParentNode): HTMLElement[] =>
|
|
25
|
+
qsa(drawer, FOCUSABLE).filter((el) => el.offsetParent !== null || el === document.activeElement);
|
|
26
|
+
|
|
27
|
+
/** Every cart drawer on the page. */
|
|
28
|
+
export const drawers = (): HTMLElement[] => qsa(document, sel('cart-drawer'));
|
|
29
|
+
|
|
30
|
+
const isOpenDrawer = (drawer: HTMLElement) => drawer.classList.contains(cfg.cartDrawerOpenClass);
|
|
31
|
+
|
|
32
|
+
/** True while a drawer, or the drawer-less cart, is open. */
|
|
33
|
+
export function isOpen(): boolean {
|
|
34
|
+
const all = drawers();
|
|
35
|
+
return all.length ? all.some(isOpenDrawer) : openWithoutDrawer;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* `aria-expanded` on the open buttons, and dialog semantics on the drawers unless their markup has its own. A closed
|
|
40
|
+
* drawer is hidden from screen readers and, with `cartFocus`, out of the Tab order (inert), like a closed dialog.
|
|
41
|
+
*/
|
|
42
|
+
export function syncDrawerAria(open: boolean): void {
|
|
43
|
+
qsa(document, sel('cart-open')).forEach((button) => {
|
|
44
|
+
button.setAttribute('aria-expanded', String(open));
|
|
45
|
+
});
|
|
46
|
+
drawers().forEach((drawer) => {
|
|
47
|
+
if (!drawer.hasAttribute('role')) drawer.setAttribute('role', 'dialog');
|
|
48
|
+
if (!drawer.hasAttribute('aria-label') && !drawer.hasAttribute('aria-labelledby')) {
|
|
49
|
+
drawer.setAttribute('aria-label', get(drawer, 'cart-label') || 'Cart');
|
|
50
|
+
}
|
|
51
|
+
drawer.setAttribute('aria-modal', String(open));
|
|
52
|
+
if (isOpenDrawer(drawer)) return;
|
|
53
|
+
drawer.setAttribute('aria-hidden', 'true');
|
|
54
|
+
if (cfg.cartFocus) drawer.inert = true;
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function focusInto(drawer: HTMLElement) {
|
|
59
|
+
const target = focusables(drawer)[0] ?? drawer;
|
|
60
|
+
if (target === drawer && !drawer.hasAttribute('tabindex')) drawer.tabIndex = -1;
|
|
61
|
+
target.focus({ preventScroll: true });
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Opens every drawer except `tether-cart-drawer="manual"` ones, which the site opens itself. */
|
|
65
|
+
export function openCart(): void {
|
|
66
|
+
if (isOpen()) return;
|
|
67
|
+
openWithoutDrawer = true;
|
|
68
|
+
const opened = drawers().filter((drawer) => get(drawer, 'cart-drawer') !== 'manual');
|
|
69
|
+
opened.forEach((drawer) => {
|
|
70
|
+
drawer.inert = false;
|
|
71
|
+
drawer.classList.add(cfg.cartDrawerOpenClass);
|
|
72
|
+
drawer.setAttribute('aria-hidden', 'false');
|
|
73
|
+
});
|
|
74
|
+
syncDrawerAria(true);
|
|
75
|
+
const [first] = opened;
|
|
76
|
+
if (cfg.cartFocus && first) {
|
|
77
|
+
returnFocus = isFocusable(document.activeElement) ? document.activeElement : null;
|
|
78
|
+
setTimeout(() => {
|
|
79
|
+
focusInto(first);
|
|
80
|
+
}, FOCUS_AFTER_OPEN_MS);
|
|
81
|
+
}
|
|
82
|
+
cfg.onCartOpen?.(opened);
|
|
83
|
+
emit('cart-toggled', { open: true });
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function closeCart(): void {
|
|
87
|
+
if (!isOpen()) return;
|
|
88
|
+
openWithoutDrawer = false;
|
|
89
|
+
const all = drawers();
|
|
90
|
+
const hadFocus = all.some((drawer) => drawer.contains(document.activeElement));
|
|
91
|
+
all.forEach((drawer) => {
|
|
92
|
+
drawer.classList.remove(cfg.cartDrawerOpenClass);
|
|
93
|
+
drawer.setAttribute('aria-hidden', 'true');
|
|
94
|
+
if (cfg.cartFocus) drawer.inert = true;
|
|
95
|
+
});
|
|
96
|
+
syncDrawerAria(false);
|
|
97
|
+
if (hadFocus && returnFocus?.isConnected) returnFocus.focus({ preventScroll: true });
|
|
98
|
+
returnFocus = null;
|
|
99
|
+
cfg.onCartClose?.(all);
|
|
100
|
+
emit('cart-toggled', { open: false });
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function toggleCart(): void {
|
|
104
|
+
if (isOpen()) closeCart();
|
|
105
|
+
else openCart();
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Keeps Tab inside the open drawer (`cartFocus`): from the last focusable element it wraps to the first, with Shift
|
|
110
|
+
* from the first to the last, and from outside the drawer it goes in. For a document `keydown` listener.
|
|
111
|
+
*/
|
|
112
|
+
export function trapFocus(event: KeyboardEvent): void {
|
|
113
|
+
if (event.key !== 'Tab' || !cfg.cartFocus || !isOpen()) return;
|
|
114
|
+
const drawer = drawers().find(isOpenDrawer);
|
|
115
|
+
if (!drawer) return;
|
|
116
|
+
const inside = focusables(drawer);
|
|
117
|
+
const first = inside[0];
|
|
118
|
+
const last = inside.at(-1);
|
|
119
|
+
if (!first || !last) return;
|
|
120
|
+
const active = document.activeElement;
|
|
121
|
+
const leaving = event.shiftKey ? active === first : active === last;
|
|
122
|
+
if (drawer.contains(active) && !leaving) return;
|
|
123
|
+
event.preventDefault();
|
|
124
|
+
(event.shiftKey ? last : first).focus();
|
|
125
|
+
}
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Storefront Cart API: loading the saved cart, creating one, and the mutations that change it. Shopify forgets a
|
|
3
|
+
* cart some days after its last change and once it has been checked out; a change that needs a cart then creates a
|
|
4
|
+
* new one with the same input, so the visitor's click still lands.
|
|
5
|
+
*/
|
|
6
|
+
import { cfg, log } from '../config.ts';
|
|
7
|
+
import { gql, orNull, shaped, unwrap, TetherError } from '../client.ts';
|
|
8
|
+
import { cartFragment } from '../fragments.ts';
|
|
9
|
+
import { GID } from '../selling-plans.ts';
|
|
10
|
+
import { toAttributes } from '../dom.ts';
|
|
11
|
+
import { CART } from './cart-shape.ts';
|
|
12
|
+
import { savedCartId, forgetSavedCart, setWarnings } from './store.ts';
|
|
13
|
+
import type { CartWarning } from '../config.ts';
|
|
14
|
+
import type { QueryText, UserError } from '../client.ts';
|
|
15
|
+
import type { AttributeValue } from '../dom.ts';
|
|
16
|
+
import type { ShopifyCart, Attribute } from './store.ts';
|
|
17
|
+
|
|
18
|
+
export type Attributes = Record<string, AttributeValue> | { key: string | number; value?: AttributeValue }[];
|
|
19
|
+
|
|
20
|
+
/** A line to add, in any of the shapes `Cart.add` takes. */
|
|
21
|
+
export interface LineSpec {
|
|
22
|
+
variantId?: string | number;
|
|
23
|
+
merchandiseId?: string | number;
|
|
24
|
+
id?: string | number;
|
|
25
|
+
quantity?: number | string;
|
|
26
|
+
sellingPlanId?: string | number | null;
|
|
27
|
+
sellingPlan?: string | number | null;
|
|
28
|
+
attributes?: Attributes | null;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Shopify's `CartLineInput`. */
|
|
32
|
+
export interface CartLineInput {
|
|
33
|
+
merchandiseId: string;
|
|
34
|
+
quantity: number;
|
|
35
|
+
sellingPlanId?: string;
|
|
36
|
+
attributes?: Attribute[];
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** What every cart mutation answers. Its lists are read with a fallback, so `isPayloadOf` checks the cart alone. */
|
|
40
|
+
interface CartPayload {
|
|
41
|
+
cart: ShopifyCart | null;
|
|
42
|
+
userErrors?: UserError[] | null;
|
|
43
|
+
warnings?: CartWarning[];
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Shopify answers a cart id it can't resolve, in a mutation, with a top-level GraphQL error whose only detail is
|
|
48
|
+
* this message (no `extensions.code`). A user error on the `cartId` field is recognised by its shape instead.
|
|
49
|
+
*/
|
|
50
|
+
const UNKNOWN_CART_MESSAGE = /does not exist|not found|invalid id/i;
|
|
51
|
+
|
|
52
|
+
/** @returns A check of a mutation's answer: a `null` payload (which `unwrap` refuses), or one with a cart or `null`. */
|
|
53
|
+
const isPayloadOf = (name: string) =>
|
|
54
|
+
shaped<Record<string, CartPayload | null>>({ [name]: orNull({ cart: orNull(CART) }) });
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* @returns True when `error` says Shopify doesn't have the cart, false for any other failure (the network, the token,
|
|
58
|
+
* a line it refused), after which the saved cart is still good.
|
|
59
|
+
*/
|
|
60
|
+
export function isCartGone(error: unknown): boolean {
|
|
61
|
+
if (!(error instanceof TetherError)) return false;
|
|
62
|
+
if (error.userErrors?.some((userError) => userError.field?.[0] === 'cartId')) return true;
|
|
63
|
+
return (error.errors ?? []).some((graphQLError) => UNKNOWN_CART_MESSAGE.test(graphQLError.message));
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* @param input A variant id, or a line.
|
|
68
|
+
* @param quantity For a variant id.
|
|
69
|
+
* @param options For a variant id.
|
|
70
|
+
* @throws {TetherError} `ERROR` without a variant id.
|
|
71
|
+
*/
|
|
72
|
+
export function toLineInput(
|
|
73
|
+
input: string | number | LineSpec | null | undefined,
|
|
74
|
+
quantity = 1,
|
|
75
|
+
options: Omit<LineSpec, 'variantId' | 'quantity'> = {},
|
|
76
|
+
): CartLineInput {
|
|
77
|
+
const spec: LineSpec =
|
|
78
|
+
typeof input === 'object' && input !== null
|
|
79
|
+
? input
|
|
80
|
+
: { ...options, quantity, ...(input === null || input === undefined ? {} : { variantId: input }) };
|
|
81
|
+
const merchandiseId = GID.variant(spec.merchandiseId || spec.variantId || spec.id);
|
|
82
|
+
if (!merchandiseId) throw new TetherError('add(): variantId is required');
|
|
83
|
+
const asked = parseInt(String(spec.quantity ?? 1), 10);
|
|
84
|
+
const line: CartLineInput = { merchandiseId, quantity: Math.max(1, asked || 1) };
|
|
85
|
+
const planId = GID.sellingPlan(spec.sellingPlanId || spec.sellingPlan);
|
|
86
|
+
if (planId) line.sellingPlanId = planId;
|
|
87
|
+
const attributes = toAttributes(spec.attributes);
|
|
88
|
+
if (attributes.length) line.attributes = attributes;
|
|
89
|
+
return line;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* @param name The mutation, which is also its payload's field.
|
|
94
|
+
* @param args The operation's variable definitions.
|
|
95
|
+
* @param inner The mutation field's arguments.
|
|
96
|
+
*/
|
|
97
|
+
function mutationText(name: string, args: string, inner: string): QueryText {
|
|
98
|
+
return (access) => `mutation Tether_${name}(${args}) {
|
|
99
|
+
${name}(${inner}) { cart { ...TetherCart } userErrors { field message } warnings { code message target } }
|
|
100
|
+
}
|
|
101
|
+
${cartFragment(access)}`;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* @returns The cart in Shopify's answer, after keeping its warnings.
|
|
106
|
+
* @throws {TetherError} For a failed request, or the mutation's user errors.
|
|
107
|
+
*/
|
|
108
|
+
async function send(
|
|
109
|
+
name: string,
|
|
110
|
+
args: string,
|
|
111
|
+
inner: string,
|
|
112
|
+
variables: Record<string, unknown>,
|
|
113
|
+
): Promise<ShopifyCart | null> {
|
|
114
|
+
const data = await gql(mutationText(name, args, inner), variables, isPayloadOf(name));
|
|
115
|
+
const payload = unwrap(data[name], name);
|
|
116
|
+
setWarnings(payload.warnings ?? []);
|
|
117
|
+
return payload.cart;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* A new cart in the visitor's market.
|
|
122
|
+
*
|
|
123
|
+
* @param input More of Shopify's `CartInput`, e.g. the first lines.
|
|
124
|
+
*/
|
|
125
|
+
function createCart(input: Record<string, unknown>): Promise<ShopifyCart | null> {
|
|
126
|
+
const withIdentity = cfg.country ? { buyerIdentity: { countryCode: cfg.country }, ...input } : input;
|
|
127
|
+
return send('cartCreate', '$input: CartInput!', 'input: $input', { input: withIdentity });
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** A mutation on the saved cart. */
|
|
131
|
+
interface CartMutation {
|
|
132
|
+
/** The mutation, e.g. `cartLinesAdd`. */
|
|
133
|
+
name: string;
|
|
134
|
+
/** Its variable definitions besides `$cartId`. */
|
|
135
|
+
args: string;
|
|
136
|
+
/** Its arguments besides `cartId`. */
|
|
137
|
+
inner: string;
|
|
138
|
+
variables: Record<string, unknown>;
|
|
139
|
+
/**
|
|
140
|
+
* The `CartInput` of a new cart that does what the mutation asks, when there is no cart (or Shopify no longer has
|
|
141
|
+
* it); `null` leaves the visitor without a cart instead.
|
|
142
|
+
*/
|
|
143
|
+
createInput: Record<string, unknown> | null;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Runs a mutation on the saved cart, or creates a cart from `createInput` when there is none to change.
|
|
148
|
+
*
|
|
149
|
+
* @returns The changed cart; `null` without a cart and without a `createInput`.
|
|
150
|
+
* @throws {TetherError} For a failed request or Shopify's user errors; also when the cart is gone and there is no
|
|
151
|
+
* `createInput` (the saved cart is forgotten first).
|
|
152
|
+
*/
|
|
153
|
+
export async function mutateCart({
|
|
154
|
+
name,
|
|
155
|
+
args,
|
|
156
|
+
inner,
|
|
157
|
+
variables,
|
|
158
|
+
createInput,
|
|
159
|
+
}: CartMutation): Promise<ShopifyCart | null> {
|
|
160
|
+
const cartId = savedCartId();
|
|
161
|
+
if (!cartId) return createInput ? createCart(createInput) : null;
|
|
162
|
+
try {
|
|
163
|
+
return await send(name, `$cartId: ID!, ${args}`, `cartId: $cartId, ${inner}`, { ...variables, cartId });
|
|
164
|
+
} catch (error) {
|
|
165
|
+
if (!isCartGone(error)) throw error;
|
|
166
|
+
forgetSavedCart();
|
|
167
|
+
if (!createInput) throw error;
|
|
168
|
+
log('cart expired, creating a new one');
|
|
169
|
+
return createCart(createInput);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** @returns The cart, `null` when Shopify doesn't have it (expired or checked out). */
|
|
174
|
+
export async function fetchCart(id: string): Promise<ShopifyCart | null> {
|
|
175
|
+
const data = await gql(
|
|
176
|
+
(access) => `query TetherCart($id: ID!) { cart(id: $id) { ...TetherCart } }\n${cartFragment(access)}`,
|
|
177
|
+
{ id },
|
|
178
|
+
shaped<{ cart: ShopifyCart | null }>({ cart: orNull(CART) }),
|
|
179
|
+
);
|
|
180
|
+
return data.cart;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** @returns The cart's current checkout URL, `null` when Shopify doesn't have the cart. */
|
|
184
|
+
export async function fetchCheckoutUrl(id: string): Promise<string | null> {
|
|
185
|
+
const data = await gql(
|
|
186
|
+
'query TetherCheckout($id: ID!) { cart(id: $id) { checkoutUrl } }',
|
|
187
|
+
{ id },
|
|
188
|
+
shaped<{ cart: { checkoutUrl: string } | null }>({ cart: orNull({ checkoutUrl: 'string' }) }),
|
|
189
|
+
);
|
|
190
|
+
return data.cart?.checkoutUrl ?? null;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** A change to one line, for `updateLine`. */
|
|
194
|
+
export interface LinePatch {
|
|
195
|
+
/** 0 or less removes the line. */
|
|
196
|
+
quantity?: number;
|
|
197
|
+
/** `null` switches the line back to one-time purchase. */
|
|
198
|
+
sellingPlanId?: string | number | null;
|
|
199
|
+
attributes?: Attributes;
|
|
200
|
+
merchandiseId?: string | number;
|
|
201
|
+
variantId?: string | number;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** @returns Shopify's `CartLineUpdateInput`. */
|
|
205
|
+
export function toLineUpdate(lineId: string, patch: number | LinePatch): Record<string, unknown> {
|
|
206
|
+
const change: LinePatch = typeof patch === 'number' ? { quantity: patch } : patch;
|
|
207
|
+
const line: Record<string, unknown> = { id: lineId };
|
|
208
|
+
if (change.quantity !== undefined) line['quantity'] = change.quantity;
|
|
209
|
+
if ('sellingPlanId' in change)
|
|
210
|
+
line['sellingPlanId'] = change.sellingPlanId ? GID.sellingPlan(change.sellingPlanId) : null;
|
|
211
|
+
if (change.attributes) line['attributes'] = toAttributes(change.attributes);
|
|
212
|
+
const variantId = change.merchandiseId || change.variantId;
|
|
213
|
+
if (variantId) line['merchandiseId'] = GID.variant(variantId);
|
|
214
|
+
return line;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
export const updateLines = (lines: Record<string, unknown>[]): Promise<ShopifyCart | null> =>
|
|
218
|
+
mutateCart({
|
|
219
|
+
name: 'cartLinesUpdate',
|
|
220
|
+
args: '$lines: [CartLineUpdateInput!]!',
|
|
221
|
+
inner: 'lines: $lines',
|
|
222
|
+
variables: { lines },
|
|
223
|
+
createInput: null,
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
export const removeLines = (lineIds: string[]): Promise<ShopifyCart | null> =>
|
|
227
|
+
mutateCart({
|
|
228
|
+
name: 'cartLinesRemove',
|
|
229
|
+
args: '$lineIds: [ID!]!',
|
|
230
|
+
inner: 'lineIds: $lineIds',
|
|
231
|
+
variables: { lineIds },
|
|
232
|
+
createInput: null,
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
/** @returns The cart once `lineId` has `target` (0 removes it). */
|
|
236
|
+
export const setLineQuantity = (lineId: string, target: number): Promise<ShopifyCart | null> =>
|
|
237
|
+
target > 0 ? updateLines([{ id: lineId, quantity: target }]) : removeLines([lineId]);
|