@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
|
@@ -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]);
|
|
@@ -3,10 +3,10 @@
|
|
|
3
3
|
* codes and the `[tether-cart-lines]` repeaters. It renders what it is given (a `CartView`) and holds no state, so
|
|
4
4
|
* every render shows the cart as the store has it at that moment.
|
|
5
5
|
*/
|
|
6
|
-
import { cfg, reportError } from '../config.
|
|
7
|
-
import { getProduct } from '../loader.
|
|
8
|
-
import { allPlans } from '../selling-plans.
|
|
9
|
-
import { isGreaterAmount } from '../amounts.
|
|
6
|
+
import { cfg, reportError } from '../config.ts';
|
|
7
|
+
import { getProduct } from '../loader.ts';
|
|
8
|
+
import { allPlans } from '../selling-plans.ts';
|
|
9
|
+
import { isGreaterAmount } from '../amounts.ts';
|
|
10
10
|
import {
|
|
11
11
|
attr,
|
|
12
12
|
sel,
|
|
@@ -21,40 +21,36 @@ import {
|
|
|
21
21
|
repeatKeyed,
|
|
22
22
|
productUrl,
|
|
23
23
|
notifyLayoutChanged,
|
|
24
|
-
} from '../dom.
|
|
25
|
-
import { isOpen, syncDrawerAria } from './drawer.
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
24
|
+
} from '../dom.ts';
|
|
25
|
+
import { isOpen, syncDrawerAria } from './drawer.ts';
|
|
26
|
+
import type { CartWarning } from '../config.ts';
|
|
27
|
+
import type { Product } from '../fragments.ts';
|
|
28
|
+
import type { ShopifyCart, ShopifyCartLine, PendingQuantity } from './store.ts';
|
|
29
29
|
|
|
30
|
-
/**
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
*/
|
|
30
|
+
/** What a render shows. */
|
|
31
|
+
interface CartView {
|
|
32
|
+
cart: ShopifyCart | null;
|
|
33
|
+
warnings: readonly CartWarning[];
|
|
34
|
+
/** Line id → the quantity quick clicks asked for. */
|
|
35
|
+
pending: ReadonlyMap<string, PendingQuantity>;
|
|
36
|
+
/** The last load or change failed (`tether-show="cart-error"`). */
|
|
37
|
+
failed: boolean;
|
|
38
|
+
}
|
|
37
39
|
|
|
38
40
|
/** Shopify's title of a variant of a product without options. */
|
|
39
41
|
const DEFAULT_TITLE = 'Default Title';
|
|
40
42
|
|
|
41
|
-
/**
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
* @returns {number} The quantity to show: the one asked for while an update is on its way.
|
|
45
|
-
*/
|
|
46
|
-
export const quantityOf = (line, pending) => pending.get(line.id)?.quantity ?? line.quantity;
|
|
43
|
+
/** @returns The quantity to show: the one asked for while an update is on its way. */
|
|
44
|
+
export const quantityOf = (line: ShopifyCartLine, pending: ReadonlyMap<string, PendingQuantity>): number =>
|
|
45
|
+
pending.get(line.id)?.quantity ?? line.quantity;
|
|
47
46
|
|
|
48
|
-
|
|
49
|
-
const numericId = (gid) => gid.split('/').pop() ?? gid;
|
|
47
|
+
const numericId = (gid: string) => gid.split('/').pop() ?? gid;
|
|
50
48
|
|
|
51
|
-
/**
|
|
52
|
-
|
|
53
|
-
* @returns {string} The product page, with the line's variant and plan selected.
|
|
54
|
-
*/
|
|
55
|
-
function lineUrl(line) {
|
|
49
|
+
/** @returns The product page, with the line's variant and plan selected. */
|
|
50
|
+
function lineUrl(line: ShopifyCartLine): string | null {
|
|
56
51
|
const variant = line.merchandise;
|
|
57
52
|
const base = productUrl(variant.product.handle, variant.product.id);
|
|
53
|
+
if (!base) return null;
|
|
58
54
|
const query = new URLSearchParams();
|
|
59
55
|
query.set('variant', numericId(variant.id));
|
|
60
56
|
const planId = line.sellingPlanAllocation?.sellingPlan.id;
|
|
@@ -62,13 +58,12 @@ function lineUrl(line) {
|
|
|
62
58
|
return `${base}${base.includes('?') ? '&' : '?'}${query.toString()}`;
|
|
63
59
|
}
|
|
64
60
|
|
|
65
|
-
|
|
61
|
+
type LineText = (line: ShopifyCartLine, view: CartView) => string | number;
|
|
66
62
|
|
|
67
|
-
|
|
68
|
-
const productTitle = (line) => line.merchandise.product.title;
|
|
63
|
+
const productTitle: LineText = (line) => line.merchandise.product.title;
|
|
69
64
|
|
|
70
|
-
/**
|
|
71
|
-
const LINE_TEXT = {
|
|
65
|
+
/** The text of each `tether-line` key. */
|
|
66
|
+
const LINE_TEXT: Record<string, LineText> = {
|
|
72
67
|
title: productTitle,
|
|
73
68
|
'product-title': productTitle,
|
|
74
69
|
'variant-title': (line) => (line.merchandise.title === DEFAULT_TITLE ? '' : line.merchandise.title),
|
|
@@ -95,12 +90,10 @@ const LINE_TEXT = {
|
|
|
95
90
|
const ATTRIBUTE_KEY = 'attribute:';
|
|
96
91
|
|
|
97
92
|
/**
|
|
98
|
-
* @param
|
|
99
|
-
* @
|
|
100
|
-
* @param {CartView} view
|
|
101
|
-
* @returns {string | number | undefined} The text for `key`, `undefined` for a key Tether doesn't know.
|
|
93
|
+
* @param key The `tether-line` value.
|
|
94
|
+
* @returns The text for `key`, `undefined` for a key Tether doesn't know.
|
|
102
95
|
*/
|
|
103
|
-
function lineValue(line, key, view) {
|
|
96
|
+
function lineValue(line: ShopifyCartLine, key: string, view: CartView): string | number | undefined {
|
|
104
97
|
if (Object.hasOwn(LINE_TEXT, key)) return LINE_TEXT[key]?.(line, view);
|
|
105
98
|
if (!key.startsWith(ATTRIBUTE_KEY)) return undefined;
|
|
106
99
|
const name = key.slice(ATTRIBUTE_KEY.length);
|
|
@@ -110,12 +103,8 @@ function lineValue(line, key, view) {
|
|
|
110
103
|
/**
|
|
111
104
|
* Fills a `<select tether-line-plan>` with the product's plans (and one-time purchase, when the product allows it and
|
|
112
105
|
* the select isn't `subscription-only`), keeping the options when they haven't changed.
|
|
113
|
-
*
|
|
114
|
-
* @param {HTMLSelectElement} select
|
|
115
|
-
* @param {ShopifyCartLine} line
|
|
116
|
-
* @param {Product} product
|
|
117
106
|
*/
|
|
118
|
-
function fillPlanSelect(select, line, product) {
|
|
107
|
+
function fillPlanSelect(select: HTMLSelectElement, line: ShopifyCartLine, product: Product) {
|
|
119
108
|
const plans = allPlans(product);
|
|
120
109
|
const allowOneTime = !product.requiresSellingPlan && get(select, 'line-plan') !== 'subscription-only';
|
|
121
110
|
const oneTime = { value: '', label: select.getAttribute(attr('one-time-label')) || 'One-time purchase' };
|
|
@@ -136,12 +125,7 @@ function fillPlanSelect(select, line, product) {
|
|
|
136
125
|
show(select, plans.length > 0);
|
|
137
126
|
}
|
|
138
127
|
|
|
139
|
-
|
|
140
|
-
* @param {HTMLElement} node
|
|
141
|
-
* @param {ShopifyCartLine} line
|
|
142
|
-
* @param {CartView} view
|
|
143
|
-
*/
|
|
144
|
-
function renderLineState(node, line, view) {
|
|
128
|
+
function renderLineState(node: HTMLElement, line: ShopifyCartLine, view: CartView) {
|
|
145
129
|
const isSubscription = Boolean(line.sellingPlanAllocation);
|
|
146
130
|
node.classList.toggle('is-subscription', isSubscription);
|
|
147
131
|
// Shopify's note about this line, such as less stock than asked.
|
|
@@ -154,25 +138,21 @@ function renderLineState(node, line, view) {
|
|
|
154
138
|
qsa(node, sel('line', 'warning')).forEach((el) => {
|
|
155
139
|
setTextTracked(el, warning?.message ?? '');
|
|
156
140
|
});
|
|
157
|
-
const conditions =
|
|
158
|
-
subscription
|
|
159
|
-
'one-time'
|
|
160
|
-
'on-sale'
|
|
161
|
-
'has-components'
|
|
162
|
-
warning
|
|
163
|
-
|
|
141
|
+
const conditions = new Map([
|
|
142
|
+
['subscription', isSubscription],
|
|
143
|
+
['one-time', !isSubscription],
|
|
144
|
+
['on-sale', isGreaterAmount(line.cost.compareAtAmountPerQuantity?.amount, line.cost.amountPerQuantity.amount)],
|
|
145
|
+
['has-components', (line.lineComponents ?? []).length > 0],
|
|
146
|
+
['warning', Boolean(warning)],
|
|
147
|
+
]);
|
|
164
148
|
qsa(node, sel('line-show')).forEach((el) => {
|
|
165
149
|
const key = get(el, 'line-show');
|
|
166
|
-
|
|
150
|
+
const visible = key === null ? undefined : conditions.get(key);
|
|
151
|
+
if (visible !== undefined) show(el, visible);
|
|
167
152
|
});
|
|
168
153
|
}
|
|
169
154
|
|
|
170
|
-
|
|
171
|
-
* @param {HTMLElement} node
|
|
172
|
-
* @param {ShopifyCartLine} line
|
|
173
|
-
* @param {CartView} view
|
|
174
|
-
*/
|
|
175
|
-
function renderLine(node, line, view) {
|
|
155
|
+
function renderLine(node: HTMLElement, line: ShopifyCartLine, view: CartView) {
|
|
176
156
|
const variant = line.merchandise;
|
|
177
157
|
node.setAttribute(attr('line-id'), line.id);
|
|
178
158
|
qsa(node, sel('line')).forEach((el) => {
|
|
@@ -182,7 +162,8 @@ function renderLine(node, line, view) {
|
|
|
182
162
|
return;
|
|
183
163
|
}
|
|
184
164
|
if (key === 'url') {
|
|
185
|
-
|
|
165
|
+
const url = lineUrl(line);
|
|
166
|
+
if (url) el.setAttribute('href', url);
|
|
186
167
|
return;
|
|
187
168
|
}
|
|
188
169
|
const value = lineValue(line, key, view);
|
|
@@ -193,34 +174,31 @@ function renderLine(node, line, view) {
|
|
|
193
174
|
getProduct(variant.product.handle)
|
|
194
175
|
.then((product) => {
|
|
195
176
|
// The selector matches only <select> elements.
|
|
196
|
-
fillPlanSelect(
|
|
177
|
+
fillPlanSelect(select as HTMLSelectElement, line, product);
|
|
197
178
|
})
|
|
198
|
-
.catch((
|
|
179
|
+
.catch((error: unknown) => {
|
|
199
180
|
reportError(error, 'line plan options');
|
|
200
181
|
});
|
|
201
182
|
});
|
|
202
183
|
}
|
|
203
184
|
|
|
204
|
-
|
|
205
|
-
* @param {ShopifyCart | null} cart
|
|
206
|
-
* @param {CartView} view
|
|
207
|
-
*/
|
|
208
|
-
function renderConditions(cart, view) {
|
|
185
|
+
function renderConditions(cart: ShopifyCart | null, view: CartView) {
|
|
209
186
|
const count = cart?.totalQuantity ?? 0;
|
|
210
187
|
const hasSubscription = (cart?.lines.nodes ?? []).some((line) => line.sellingPlanAllocation);
|
|
211
|
-
const conditions =
|
|
212
|
-
'cart-empty'
|
|
213
|
-
'cart-filled'
|
|
214
|
-
'cart-has-subscription'
|
|
215
|
-
'cart-no-subscription'
|
|
216
|
-
'cart-has-discount'
|
|
217
|
-
'cart-error'
|
|
218
|
-
'cart-warning'
|
|
219
|
-
|
|
188
|
+
const conditions = new Map([
|
|
189
|
+
['cart-empty', count === 0],
|
|
190
|
+
['cart-filled', count > 0],
|
|
191
|
+
['cart-has-subscription', hasSubscription],
|
|
192
|
+
['cart-no-subscription', !hasSubscription],
|
|
193
|
+
['cart-has-discount', (cart?.discountCodes ?? []).some((code) => code.applicable)],
|
|
194
|
+
['cart-error', view.failed],
|
|
195
|
+
['cart-warning', view.warnings.length > 0],
|
|
196
|
+
]);
|
|
220
197
|
qsa(document, sel('show')).forEach((el) => {
|
|
221
198
|
const key = get(el, 'show');
|
|
222
|
-
|
|
223
|
-
|
|
199
|
+
const visible = key === null ? undefined : conditions.get(key);
|
|
200
|
+
if (visible === undefined || el.closest(sel('product'))) return;
|
|
201
|
+
show(el, visible);
|
|
224
202
|
});
|
|
225
203
|
qsa(document, sel('checkout')).forEach((el) => {
|
|
226
204
|
el.classList.toggle('is-disabled', !count);
|
|
@@ -228,8 +206,7 @@ function renderConditions(cart, view) {
|
|
|
228
206
|
});
|
|
229
207
|
}
|
|
230
208
|
|
|
231
|
-
|
|
232
|
-
function renderFields(cart) {
|
|
209
|
+
function renderFields(cart: ShopifyCart | null) {
|
|
233
210
|
qsa(document, `textarea${sel('cart-note')},input${sel('cart-note')}`).forEach((el) => {
|
|
234
211
|
// The visitor may be typing in it.
|
|
235
212
|
if (document.activeElement !== el) setText(el, cart?.note ?? '');
|
|
@@ -244,11 +221,10 @@ function renderFields(cart) {
|
|
|
244
221
|
});
|
|
245
222
|
}
|
|
246
223
|
|
|
247
|
-
|
|
248
|
-
export function renderCart(view) {
|
|
224
|
+
export function renderCart(view: CartView): void {
|
|
249
225
|
const { cart } = view;
|
|
250
|
-
|
|
251
|
-
|
|
226
|
+
const money = (key: 'subtotalAmount' | 'totalAmount' | 'checkoutChargeAmount') =>
|
|
227
|
+
cart ? formatMoney(cart.cost[key]) : '';
|
|
252
228
|
|
|
253
229
|
syncDrawerAria(isOpen());
|
|
254
230
|
qsa(document, sel('cart-count')).forEach((el) => {
|
|
@@ -282,13 +258,9 @@ export function renderCart(view) {
|
|
|
282
258
|
if (cart) notifyLayoutChanged('cart');
|
|
283
259
|
}
|
|
284
260
|
|
|
285
|
-
/**
|
|
286
|
-
|
|
287
|
-
*
|
|
288
|
-
* @param {string} count
|
|
289
|
-
*/
|
|
290
|
-
export function showSavedCount(count) {
|
|
261
|
+
/** Shows the count a saved cart had on the last page in the cart counts the page left at 0, until it has loaded. */
|
|
262
|
+
export function showSavedCount(count: number): void {
|
|
291
263
|
qsa(document, sel('cart-count')).forEach((el) => {
|
|
292
|
-
if (/^\s*0?\s*$/.test(el.textContent)) el.textContent = count;
|
|
264
|
+
if (/^\s*0?\s*$/.test(el.textContent)) el.textContent = String(count);
|
|
293
265
|
});
|
|
294
266
|
}
|