@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.
Files changed (259) hide show
  1. package/README.md +11 -11
  2. package/dist/esm/astro.js +2 -2
  3. package/dist/esm/chunks/account-UIPWJQ3Y.js +1 -0
  4. package/dist/esm/chunks/account-pages-6ROA6Y4V.js +1 -0
  5. package/dist/esm/chunks/analytics-2V632IRV.js +1 -0
  6. package/dist/esm/chunks/chunk-4GCXM764.js +1 -0
  7. package/dist/esm/chunks/chunk-56FCLJJD.js +1 -0
  8. package/dist/esm/chunks/chunk-5Z5O3767.js +1 -0
  9. package/dist/esm/chunks/chunk-6OZJCAGZ.js +1 -0
  10. package/dist/esm/chunks/chunk-A5SYCVQM.js +1 -0
  11. package/dist/esm/chunks/chunk-BA4LQJJQ.js +1 -0
  12. package/dist/esm/chunks/chunk-BFTZR4OB.js +4 -0
  13. package/dist/esm/chunks/chunk-C5JYQCWH.js +1 -0
  14. package/dist/esm/chunks/chunk-E5YO6274.js +1 -0
  15. package/dist/esm/chunks/chunk-MPDWM2NN.js +1 -0
  16. package/dist/esm/chunks/chunk-T44MUL4Y.js +1 -0
  17. package/dist/esm/chunks/chunk-YLUULATY.js +1 -0
  18. package/dist/esm/chunks/diagnose-3FEKRIEK.js +14 -0
  19. package/dist/esm/chunks/extras-6POFBNOP.js +1 -0
  20. package/dist/esm/chunks/markets-ui-FD6NI2OG.js +1 -0
  21. package/dist/esm/chunks/search-UJ73YBQ5.js +1 -0
  22. package/dist/esm/index.js +1 -1
  23. package/dist/esm/server.js +2 -2
  24. package/dist/presets/barba.esm.js +19 -23
  25. package/dist/tether.account-pages.js +320 -365
  26. package/dist/tether.account-pages.min.js +1 -1
  27. package/dist/tether.account.js +242 -491
  28. package/dist/tether.account.min.js +1 -1
  29. package/dist/tether.analytics.js +66 -88
  30. package/dist/tether.analytics.min.js +1 -1
  31. package/dist/tether.diagnose.js +36 -33
  32. package/dist/tether.diagnose.min.js +2 -2
  33. package/dist/tether.extras.js +28 -36
  34. package/dist/tether.extras.min.js +1 -1
  35. package/dist/tether.js +1270 -894
  36. package/dist/tether.markets.js +73 -90
  37. package/dist/tether.markets.min.js +1 -1
  38. package/dist/tether.min.js +0 -0
  39. package/dist/tether.min.js.map +4 -4
  40. package/dist/tether.search.js +44 -68
  41. package/dist/tether.search.min.js +1 -1
  42. package/dist/types/server/src/shared/account-orders.d.ts +44 -0
  43. package/dist/types/server/src/shared/account-routes.d.ts +153 -0
  44. package/dist/types/server/src/shared/api-versions.d.ts +13 -0
  45. package/dist/types/server/src/shared/error-message.d.ts +2 -0
  46. package/dist/types/server/src/shared/json.d.ts +25 -0
  47. package/dist/types/server/src/shared/market-codes.d.ts +16 -0
  48. package/dist/types/src/account/addresses-view.d.ts +5 -0
  49. package/dist/types/src/account/answers.d.ts +40 -0
  50. package/dist/types/src/account/markup.d.ts +19 -0
  51. package/dist/types/src/account/operations.d.ts +41 -0
  52. package/dist/types/src/account/order-lines.d.ts +13 -0
  53. package/dist/types/src/account/order-list.d.ts +12 -0
  54. package/dist/types/src/account/order-page.d.ts +6 -0
  55. package/dist/types/src/account/page-answers.d.ts +182 -0
  56. package/dist/types/src/account/page-events.d.ts +2 -0
  57. package/dist/types/src/account/server-client.d.ts +60 -0
  58. package/dist/types/src/account/subscriptions-view.d.ts +2 -0
  59. package/dist/types/src/account-api.d.ts +18 -0
  60. package/dist/types/src/account-marks.d.ts +15 -0
  61. package/dist/types/src/account-pages.d.ts +8 -0
  62. package/dist/types/src/account.d.ts +64 -0
  63. package/dist/types/src/amounts.d.ts +12 -0
  64. package/dist/types/src/analytics-shopify.d.ts +41 -0
  65. package/dist/types/src/analytics.d.ts +2 -0
  66. package/dist/types/src/api-version.d.ts +28 -0
  67. package/dist/types/src/astro.d.ts +42 -0
  68. package/dist/types/src/cart/cart-shape.d.ts +43 -0
  69. package/dist/types/src/cart/checkout.d.ts +16 -0
  70. package/dist/types/src/cart/drawer.d.ts +18 -0
  71. package/dist/types/src/cart/mutations.d.ts +79 -0
  72. package/dist/types/src/cart/render.d.ts +17 -0
  73. package/dist/types/src/cart/store.d.ts +182 -0
  74. package/dist/types/src/cart.d.ts +106 -0
  75. package/dist/types/src/client.d.ts +100 -0
  76. package/dist/types/src/collection.d.ts +25 -0
  77. package/dist/types/src/config-sections.d.ts +40 -0
  78. package/dist/types/src/config.d.ts +185 -0
  79. package/dist/types/src/controls/cart-controls.d.ts +2 -0
  80. package/dist/types/src/controls/discount.d.ts +2 -0
  81. package/dist/types/src/controls/extras.d.ts +5 -0
  82. package/dist/types/src/controls/navigation.d.ts +12 -0
  83. package/dist/types/src/diagnose-markup.d.ts +19 -0
  84. package/dist/types/src/diagnose.d.ts +60 -0
  85. package/dist/types/src/dom.d.ts +75 -0
  86. package/dist/types/src/extras.d.ts +21 -0
  87. package/dist/types/src/fragments.d.ts +158 -0
  88. package/dist/types/src/globals.d.ts +15 -0
  89. package/dist/types/src/images.d.ts +20 -0
  90. package/dist/types/src/index.d.ts +167 -0
  91. package/dist/types/src/initial.d.ts +56 -0
  92. package/dist/types/src/lazy.d.ts +28 -0
  93. package/dist/types/src/loader.d.ts +84 -0
  94. package/dist/types/src/market-codes.d.ts +6 -0
  95. package/dist/types/src/markets-ui.d.ts +32 -0
  96. package/dist/types/src/markets.d.ts +72 -0
  97. package/dist/types/src/markup.d.ts +131 -0
  98. package/dist/types/src/mounting.d.ts +46 -0
  99. package/dist/types/src/page-market.d.ts +27 -0
  100. package/dist/types/src/presets/barba.d.ts +56 -0
  101. package/dist/types/src/product-events.d.ts +2 -0
  102. package/dist/types/src/product-render.d.ts +17 -0
  103. package/dist/types/src/product-shape.d.ts +88 -0
  104. package/dist/types/src/product-state.d.ts +110 -0
  105. package/dist/types/src/product.d.ts +66 -0
  106. package/dist/types/src/repeaters.d.ts +16 -0
  107. package/dist/types/src/search.d.ts +33 -0
  108. package/dist/types/src/selling-plans.d.ts +64 -0
  109. package/dist/types/src/server.d.ts +88 -0
  110. package/dist/types/src/shopify-values.d.ts +46 -0
  111. package/dist/types/src/status.d.ts +33 -0
  112. package/dist/types/src/storage.d.ts +12 -0
  113. package/dist/types/src/store-html.d.ts +39 -0
  114. package/dist/types/src/storefront/catalog.d.ts +38 -0
  115. package/dist/types/src/storefront/json-ld.d.ts +20 -0
  116. package/dist/types/src/variant-render.d.ts +14 -0
  117. package/package.json +24 -23
  118. package/src/account/addresses-view.ts +58 -0
  119. package/src/account/answers.ts +79 -0
  120. package/src/account/markup.ts +36 -0
  121. package/src/account/operations.ts +145 -0
  122. package/src/account/{order-lines.js → order-lines.ts} +20 -41
  123. package/src/account/order-list.ts +107 -0
  124. package/src/account/order-page.ts +137 -0
  125. package/src/account/page-answers.ts +317 -0
  126. package/src/account/{page-events.js → page-events.ts} +55 -90
  127. package/src/account/server-client.ts +183 -0
  128. package/src/account/{subscriptions-view.js → subscriptions-view.ts} +25 -68
  129. package/src/account-api.ts +98 -0
  130. package/src/account-marks.ts +35 -0
  131. package/src/{account-pages.js → account-pages.ts} +29 -37
  132. package/src/account.ts +284 -0
  133. package/src/amounts.ts +64 -0
  134. package/src/{analytics-shopify.js → analytics-shopify.ts} +112 -107
  135. package/src/analytics.ts +315 -0
  136. package/src/{api-version.js → api-version.ts} +25 -31
  137. package/src/{astro.js → astro.ts} +110 -104
  138. package/src/cart/cart-shape.ts +25 -0
  139. package/src/cart/checkout.ts +101 -0
  140. package/src/cart/{drawer.js → drawer.ts} +18 -31
  141. package/src/cart/mutations.ts +237 -0
  142. package/src/cart/{render.js → render.ts} +68 -96
  143. package/src/cart/store.ts +279 -0
  144. package/src/{cart.js → cart.ts} +82 -128
  145. package/src/client.ts +391 -0
  146. package/src/{collection.js → collection.ts} +30 -38
  147. package/src/config-sections.ts +284 -0
  148. package/src/config.ts +480 -0
  149. package/src/controls/{cart-controls.js → cart-controls.ts} +22 -54
  150. package/src/controls/{discount.js → discount.ts} +13 -34
  151. package/src/controls/{extras.js → extras.ts} +15 -24
  152. package/src/controls/{navigation.js → navigation.ts} +24 -30
  153. package/src/{diagnose-markup.js → diagnose-markup.ts} +37 -72
  154. package/src/{diagnose.js → diagnose.ts} +95 -96
  155. package/src/dom.ts +268 -0
  156. package/src/{extras.js → extras.ts} +43 -52
  157. package/src/fragments.ts +261 -0
  158. package/src/globals.d.ts +15 -0
  159. package/src/{images.js → images.ts} +30 -43
  160. package/src/index.ts +415 -0
  161. package/src/initial.ts +101 -0
  162. package/src/lazy-iife.ts +197 -0
  163. package/src/lazy.ts +42 -0
  164. package/src/{loader.js → loader.ts} +149 -123
  165. package/src/market-codes.ts +6 -0
  166. package/src/{markets-ui.js → markets-ui.ts} +91 -68
  167. package/src/markets.ts +246 -0
  168. package/src/markup.ts +127 -0
  169. package/src/{mounting.js → mounting.ts} +88 -101
  170. package/src/{page-market.js → page-market.ts} +32 -38
  171. package/src/part-integrity.ts +7 -0
  172. package/src/parts/account-pages.ts +7 -0
  173. package/src/parts/account.ts +7 -0
  174. package/src/parts/analytics.ts +7 -0
  175. package/src/parts/diagnose.ts +7 -0
  176. package/src/parts/extras.ts +7 -0
  177. package/src/parts/markets.ts +7 -0
  178. package/src/parts/search.ts +7 -0
  179. package/src/presets/barba.ts +194 -0
  180. package/src/{product-events.js → product-events.ts} +33 -48
  181. package/src/{product-render.js → product-render.ts} +38 -77
  182. package/src/product-shape.ts +49 -0
  183. package/src/product-state.ts +318 -0
  184. package/src/{product.js → product.ts} +90 -126
  185. package/src/{repeaters.js → repeaters.ts} +40 -52
  186. package/src/{search.js → search.ts} +85 -103
  187. package/src/selling-plans.ts +145 -0
  188. package/src/server.ts +211 -0
  189. package/src/{shopify-values.js → shopify-values.ts} +24 -22
  190. package/src/{status.js → status.ts} +34 -54
  191. package/src/storage.ts +34 -0
  192. package/src/store-html.ts +359 -0
  193. package/src/storefront/catalog.ts +196 -0
  194. package/src/storefront/{json-ld.js → json-ld.ts} +45 -68
  195. package/src/{variant-render.js → variant-render.ts} +24 -50
  196. package/dist/esm/chunks/account-C3NP6HFB.js +0 -1
  197. package/dist/esm/chunks/account-pages-WLK374FO.js +0 -1
  198. package/dist/esm/chunks/analytics-G3SAWMNL.js +0 -1
  199. package/dist/esm/chunks/chunk-27PJJZGC.js +0 -1
  200. package/dist/esm/chunks/chunk-2FSXS55U.js +0 -1
  201. package/dist/esm/chunks/chunk-FCSWQDVV.js +0 -1
  202. package/dist/esm/chunks/chunk-FOMSMIAM.js +0 -1
  203. package/dist/esm/chunks/chunk-GZJTCLLW.js +0 -1
  204. package/dist/esm/chunks/chunk-HEQILZVR.js +0 -1
  205. package/dist/esm/chunks/chunk-JOOEXYF3.js +0 -4
  206. package/dist/esm/chunks/chunk-MPTQYLSM.js +0 -1
  207. package/dist/esm/chunks/chunk-NEHZ37TU.js +0 -3
  208. package/dist/esm/chunks/chunk-V74KGEJF.js +0 -1
  209. package/dist/esm/chunks/chunk-VT4CDUJJ.js +0 -1
  210. package/dist/esm/chunks/chunk-YJLWX2HK.js +0 -1
  211. package/dist/esm/chunks/diagnose-6VVGZNTY.js +0 -14
  212. package/dist/esm/chunks/extras-GJVRDFM3.js +0 -1
  213. package/dist/esm/chunks/markets-ui-UNB4PTA7.js +0 -1
  214. package/dist/esm/chunks/search-OU75RNQL.js +0 -1
  215. package/src/account/addresses-view.js +0 -92
  216. package/src/account/browser-session.js +0 -359
  217. package/src/account/markup.js +0 -48
  218. package/src/account/operations.js +0 -180
  219. package/src/account/order-list.js +0 -91
  220. package/src/account/order-page.js +0 -192
  221. package/src/account/server-client.js +0 -172
  222. package/src/account/shared.js +0 -111
  223. package/src/account/shopify-auth.js +0 -150
  224. package/src/account-api.js +0 -118
  225. package/src/account-links.js +0 -35
  226. package/src/account.js +0 -342
  227. package/src/amounts.js +0 -36
  228. package/src/analytics.js +0 -352
  229. package/src/cart/checkout.js +0 -97
  230. package/src/cart/mutations.js +0 -234
  231. package/src/cart/store.js +0 -326
  232. package/src/client.js +0 -267
  233. package/src/config.js +0 -349
  234. package/src/dom.js +0 -287
  235. package/src/fragments.js +0 -237
  236. package/src/index.js +0 -353
  237. package/src/initial.js +0 -87
  238. package/src/lazy-iife.js +0 -116
  239. package/src/lazy.js +0 -39
  240. package/src/market-codes.js +0 -35
  241. package/src/markets.js +0 -334
  242. package/src/markup.js +0 -64
  243. package/src/parts/account-pages.js +0 -6
  244. package/src/parts/account.js +0 -6
  245. package/src/parts/analytics.js +0 -6
  246. package/src/parts/diagnose.js +0 -6
  247. package/src/parts/extras.js +0 -6
  248. package/src/parts/markets.js +0 -6
  249. package/src/parts/search.js +0 -6
  250. package/src/presets/barba.js +0 -190
  251. package/src/product-state.js +0 -318
  252. package/src/selling-plans.js +0 -154
  253. package/src/server.js +0 -215
  254. package/src/store-html.js +0 -0
  255. package/src/storefront/catalog.js +0 -199
  256. package/types/astro.d.ts +0 -28
  257. package/types/barba.d.ts +0 -16
  258. package/types/index.d.ts +0 -577
  259. 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.js';
7
- import { getProduct } from '../loader.js';
8
- import { allPlans } from '../selling-plans.js';
9
- import { isGreaterAmount } from '../amounts.js';
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.js';
25
- import { isOpen, syncDrawerAria } from './drawer.js';
26
- /** @import { CartWarning } from '../config.js' */
27
- /** @import { Product } from '../fragments.js' */
28
- /** @import { ShopifyCart, ShopifyCartLine, PendingQuantity } from './store.js' */
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
- * @typedef {object} CartView What a render shows.
32
- * @property {ShopifyCart | null} cart
33
- * @property {readonly CartWarning[]} warnings
34
- * @property {ReadonlyMap<string, PendingQuantity>} pending Line id → the quantity quick clicks asked for.
35
- * @property {boolean} failed The last load or change failed (`tether-show="cart-error"`).
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
- * @param {ShopifyCartLine} line
43
- * @param {ReadonlyMap<string, PendingQuantity>} pending
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
- /** @param {string} gid */
49
- const numericId = (gid) => gid.split('/').pop() ?? gid;
47
+ const numericId = (gid: string) => gid.split('/').pop() ?? gid;
50
48
 
51
- /**
52
- * @param {ShopifyCartLine} line
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
- /** @typedef {(line: ShopifyCartLine, view: CartView) => string | number} LineText */
61
+ type LineText = (line: ShopifyCartLine, view: CartView) => string | number;
66
62
 
67
- /** @type {LineText} */
68
- const productTitle = (line) => line.merchandise.product.title;
63
+ const productTitle: LineText = (line) => line.merchandise.product.title;
69
64
 
70
- /** @type {Record<string, LineText>} The text of each `tether-line` key. */
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 {ShopifyCartLine} line
99
- * @param {string} key The `tether-line` value.
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: isSubscription,
159
- 'one-time': !isSubscription,
160
- 'on-sale': isGreaterAmount(line.cost.compareAtAmountPerQuantity?.amount, line.cost.amountPerQuantity.amount),
161
- 'has-components': (line.lineComponents ?? []).length > 0,
162
- warning: Boolean(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
- if (key !== null && Object.hasOwn(conditions, key)) show(el, conditions[/** @type {keyof conditions} */ (key)]);
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
- el.setAttribute('href', lineUrl(line));
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(/** @type {HTMLSelectElement} */ (select), line, product);
177
+ fillPlanSelect(select as HTMLSelectElement, line, product);
197
178
  })
198
- .catch((/** @type {unknown} */ error) => {
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': count === 0,
213
- 'cart-filled': count > 0,
214
- 'cart-has-subscription': hasSubscription,
215
- 'cart-no-subscription': !hasSubscription,
216
- 'cart-has-discount': (cart?.discountCodes ?? []).some((code) => code.applicable),
217
- 'cart-error': view.failed,
218
- 'cart-warning': view.warnings.length > 0,
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
- if (key === null || !Object.hasOwn(conditions, key) || el.closest(sel('product'))) return;
223
- show(el, conditions[/** @type {keyof conditions} */ (key)]);
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
- /** @param {ShopifyCart | null} cart */
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
- /** @param {CartView} view */
248
- export function renderCart(view) {
224
+ export function renderCart(view: CartView): void {
249
225
  const { cart } = view;
250
- /** @param {'subtotalAmount' | 'totalAmount' | 'checkoutChargeAmount'} key */
251
- const money = (key) => (cart ? formatMoney(cart.cost[key]) : '');
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
- * Shows the count a saved cart had on the last page in the cart counts the page left at 0, until it has loaded.
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
  }