@redotech/redo-hydrogen 1.4.6 → 1.4.8

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/src/utils/cart.ts CHANGED
@@ -1,20 +1,24 @@
1
1
  import { FetcherWithComponents, useFetcher } from "@remix-run/react";
2
2
  import { CartInfoToEnable } from "../types";
3
- import { CartForm, CartReturn, OptimisticCart, OptimisticCartLine } from "@shopify/hydrogen";
4
- import type { AppData } from '@remix-run/react/dist/data';
5
- import React, { useCallback, useEffect, useRef } from 'react'
3
+ import { CartForm, CartReturn, OptimisticCart } from "@shopify/hydrogen";
4
+ import type { AppData } from "@remix-run/react/dist/data";
5
+ import React, { useCallback, useEffect, useRef } from "react";
6
6
  import { CartWithActionsDocs } from "@shopify/hydrogen-react/dist/types/cart-types";
7
7
  import { CartLine, ComponentizableCartLine } from "@shopify/hydrogen-react/storefront-api-types";
8
8
 
9
- const DEFAULT_REDO_ENABLED_CART_ATTRIBUTE = 'redo_opted_in_from_cart';
9
+ const DEFAULT_REDO_ENABLED_CART_ATTRIBUTE = "redo_opted_in_from_cart";
10
10
  const CONCIERGE_ATTRIBUTION_CART_ATTRIBUTE_KEY = "redo.conciergeAssisted";
11
11
  const CONCIERGE_CONVERSATION_IDS_STORAGE_KEY = "redoConciergeConversationIds";
12
12
 
13
- const isCartWithActionsDocs = (cart: CartReturn | CartWithActionsDocs| OptimisticCart): cart is CartWithActionsDocs => {
14
- return (Array.isArray(cart.lines) && 'linesAdd' in cart && typeof cart.linesAdd === 'function');
15
- }
13
+ const isCartWithActionsDocs = (
14
+ cart: CartReturn | CartWithActionsDocs | OptimisticCart,
15
+ ): cart is CartWithActionsDocs => {
16
+ return Array.isArray(cart.lines) && "linesAdd" in cart && typeof cart.linesAdd === "function";
17
+ };
16
18
 
17
- const getCartLines = (cart: CartReturn | CartWithActionsDocs | OptimisticCart): Array<CartLine | ComponentizableCartLine> => {
19
+ const getCartLines = (
20
+ cart: CartReturn | CartWithActionsDocs | OptimisticCart,
21
+ ): Array<CartLine | ComponentizableCartLine> => {
18
22
  if (isOptimisticCart(cart)) {
19
23
  return cart.lines.nodes;
20
24
  } else if (isCartWithActionsDocs(cart)) {
@@ -22,85 +26,75 @@ const getCartLines = (cart: CartReturn | CartWithActionsDocs | OptimisticCart):
22
26
  } else {
23
27
  return cart.lines.nodes ?? cart.lines.edges.map((edge) => edge.node);
24
28
  }
25
- }
29
+ };
26
30
 
27
31
  // https://shopify.dev/docs/api/hydrogen/2025-01/hooks/useoptimisticcart
28
32
  const isOptimisticCart = (cart: CartReturn | CartWithActionsDocs | OptimisticCart): cart is OptimisticCart => {
29
- return 'isOptimistic' in cart && (cart.isOptimistic ?? false);
30
- }
33
+ return "isOptimistic" in cart && (cart.isOptimistic ?? false);
34
+ };
31
35
 
32
- const isRedoInCart = ({
33
- cart
34
- }: {
35
- cart: CartReturn | CartWithActionsDocs | OptimisticCart
36
- }): boolean => {
37
- if(!cart) {
38
- return false;
36
+ // Build a stable key from non-Redo cart lines so effects can detect changes that
37
+ // affect coverage eligibility/price even when the cart object is mutated in place.
38
+ // Excludes Redo items so toggling coverage doesn't cause us to re-evaluate.
39
+ const getCartEligibilityPriceKey = (cart: CartReturn | CartWithActionsDocs | OptimisticCart | undefined): string => {
40
+ if (!cart) {
41
+ return "";
39
42
  }
40
43
 
41
- return getCartLines(cart).some((cartLine) => {
42
- return cartLine.merchandise.product.vendor === 're:do';
43
- });
44
- }
45
-
46
- const waitForConditionsMetOrTimeout = ({
47
- conditions,
48
- timeoutMs
49
- }: {
50
- conditions: (() => boolean)[];
51
- timeoutMs: number;
52
- }): Promise<boolean> => {
53
- return new Promise((resolve, reject) => {
54
- let start = Date.now();
55
- let interval = setInterval(() => {
56
- if((Date.now() - start) > timeoutMs) {
57
- clearInterval(interval);
58
- return resolve(false);
59
- }
60
-
61
- let conditionsMet = conditions.every((conditionCallback) => conditionCallback());
62
-
63
- if(conditionsMet) {
64
- clearInterval(interval);
65
- return resolve(true);
66
- }
67
- }, 100);
68
- })
69
- }
44
+ return getCartLines(cart)
45
+ .filter((cartLine) => cartLine.merchandise?.product?.vendor !== "re:do")
46
+ .map((cartLine) =>
47
+ [
48
+ cartLine.id,
49
+ cartLine.merchandise?.id,
50
+ cartLine.quantity,
51
+ cartLine.merchandise?.price?.amount,
52
+ cartLine.merchandise?.price?.currencyCode,
53
+ cartLine.cost?.totalAmount?.amount,
54
+ cartLine.cost?.totalAmount?.currencyCode,
55
+ ].join(":"),
56
+ )
57
+ .join("|");
58
+ };
70
59
 
71
60
  const addProductToCartIfNeeded = async ({
72
61
  cart,
73
62
  fetcher,
74
63
  waitCartIdle,
75
- cartInfoToEnable
64
+ cartInfoToEnable,
76
65
  }: {
77
- cart: CartReturn | CartWithActionsDocs | OptimisticCart | undefined,
78
- fetcher: FetcherWithComponents<unknown>,
66
+ cart: CartReturn | CartWithActionsDocs | OptimisticCart | undefined;
67
+ fetcher: FetcherWithComponents<unknown>;
79
68
  waitCartIdle: WaitCartIdleCallback;
80
- cartInfoToEnable: CartInfoToEnable
69
+ cartInfoToEnable: CartInfoToEnable;
81
70
  }) => {
82
- if(!cart) {
71
+ if (!cart) {
83
72
  return await addProductToCart({ cart, fetcher, waitCartIdle, cartInfoToEnable });
84
73
  }
85
74
 
86
75
  const redoProductsInCart = getCartLines(cart).filter((cartLine) => {
87
- return cartLine.merchandise.product.vendor === 're:do';
76
+ return cartLine.merchandise.product.vendor === "re:do";
88
77
  });
89
78
  const correctRedoProductInCart = redoProductsInCart?.filter((cartLine) => {
90
79
  return cartLine.merchandise.id === `gid://shopify/ProductVariant/${cartInfoToEnable.variantId}`;
91
80
  });
92
- if(redoProductsInCart.length === 0) {
81
+ if (redoProductsInCart.length === 0) {
93
82
  return await addProductToCart({ cart, fetcher, waitCartIdle, cartInfoToEnable });
94
- } else if (redoProductsInCart.length === 1 && correctRedoProductInCart.length === 1 && correctRedoProductInCart[0].quantity === 1) {
83
+ } else if (
84
+ redoProductsInCart.length === 1 &&
85
+ correctRedoProductInCart.length === 1 &&
86
+ correctRedoProductInCart[0].quantity === 1
87
+ ) {
95
88
  // No action needed
96
89
  return;
97
90
  } else {
98
- let isSuccess = true;
99
-
100
- await removeLinesFromCart({ cart, fetcher, waitCartIdle, lineIds: redoProductsInCart.map((cartLine) => cartLine.id) });
91
+ await removeLinesFromCart({
92
+ cart,
93
+ fetcher,
94
+ waitCartIdle,
95
+ lineIds: redoProductsInCart.map((cartLine) => cartLine.id),
96
+ });
101
97
  await addProductToCart({ cart, fetcher, waitCartIdle, cartInfoToEnable });
102
-
103
- return;
104
98
  }
105
99
  };
106
100
 
@@ -108,7 +102,7 @@ const removeLinesFromCart = async ({
108
102
  cart,
109
103
  fetcher,
110
104
  waitCartIdle,
111
- lineIds
105
+ lineIds,
112
106
  }: {
113
107
  cart: CartReturn | CartWithActionsDocs | OptimisticCart | undefined;
114
108
  fetcher: FetcherWithComponents<unknown>;
@@ -118,11 +112,11 @@ const removeLinesFromCart = async ({
118
112
  const formInput = {
119
113
  action: CartForm.ACTIONS.LinesRemove,
120
114
  inputs: {
121
- lineIds
122
- }
123
- }
115
+ lineIds,
116
+ },
117
+ };
124
118
 
125
- if(cart && isCartWithActionsDocs(cart)) {
119
+ if (cart && isCartWithActionsDocs(cart)) {
126
120
  cart.linesRemove(lineIds);
127
121
  await waitCartIdle();
128
122
  } else {
@@ -130,7 +124,7 @@ const removeLinesFromCart = async ({
130
124
  {
131
125
  [CartForm.INPUT_NAME]: JSON.stringify(formInput),
132
126
  },
133
- {method: 'POST', action: '/cart'},
127
+ { method: "POST", action: "/cart" },
134
128
  );
135
129
  }
136
130
  };
@@ -139,25 +133,28 @@ const removeProductFromCartIfNeeded = async ({
139
133
  cart,
140
134
  fetcher,
141
135
  waitCartIdle,
142
- cartInfoToEnable
143
136
  }: {
144
- cart: CartReturn | CartWithActionsDocs | OptimisticCart | undefined,
145
- fetcher: FetcherWithComponents<unknown>,
146
- waitCartIdle: WaitCartIdleCallback
147
- cartInfoToEnable: CartInfoToEnable
137
+ cart: CartReturn | CartWithActionsDocs | OptimisticCart | undefined;
138
+ fetcher: FetcherWithComponents<unknown>;
139
+ waitCartIdle: WaitCartIdleCallback;
140
+ cartInfoToEnable: CartInfoToEnable;
148
141
  }) => {
149
- if(!cart) {
150
- console.error('No cart');
142
+ if (!cart) {
143
+ console.error("No cart");
151
144
  return;
152
145
  }
153
146
 
154
147
  const redoProductsInCart = getCartLines(cart).filter((cartLine) => {
155
- return cartLine.merchandise.product.vendor === 're:do';
148
+ return cartLine.merchandise.product.vendor === "re:do";
156
149
  });
157
150
 
158
- if(redoProductsInCart.length !== 0) {
159
- await removeLinesFromCart({ cart, fetcher, waitCartIdle, lineIds: redoProductsInCart.map((cartLine) => cartLine.id) });
160
- } else {
151
+ if (redoProductsInCart.length !== 0) {
152
+ await removeLinesFromCart({
153
+ cart,
154
+ fetcher,
155
+ waitCartIdle,
156
+ lineIds: redoProductsInCart.map((cartLine) => cartLine.id),
157
+ });
161
158
  }
162
159
  };
163
160
 
@@ -168,25 +165,23 @@ const addProductToCart = async ({
168
165
  cartInfoToEnable,
169
166
  }: {
170
167
  waitCartIdle: WaitCartIdleCallback;
171
- cart: CartReturn | CartWithActionsDocs | OptimisticCart | undefined,
172
- fetcher: FetcherWithComponents<unknown>,
173
- cartInfoToEnable: CartInfoToEnable
168
+ cart: CartReturn | CartWithActionsDocs | OptimisticCart | undefined;
169
+ fetcher: FetcherWithComponents<unknown>;
170
+ cartInfoToEnable: CartInfoToEnable;
174
171
  }) => {
175
172
  const redoProductLine = {
176
- "merchandiseId": `gid://shopify/ProductVariant/${cartInfoToEnable.variantId}`,
177
- "quantity": 1,
173
+ merchandiseId: `gid://shopify/ProductVariant/${cartInfoToEnable.variantId}`,
174
+ quantity: 1,
178
175
  };
179
176
 
180
177
  const formInput = {
181
178
  action: CartForm.ACTIONS.LinesAdd,
182
179
  inputs: {
183
- lines: [
184
- redoProductLine
185
- ]
186
- }
187
- }
180
+ lines: [redoProductLine],
181
+ },
182
+ };
188
183
 
189
- if(cart && isCartWithActionsDocs(cart)) {
184
+ if (cart && isCartWithActionsDocs(cart)) {
190
185
  cart.linesAdd([redoProductLine]);
191
186
  await waitCartIdle();
192
187
  } else {
@@ -194,7 +189,7 @@ const addProductToCart = async ({
194
189
  {
195
190
  [CartForm.INPUT_NAME]: JSON.stringify(formInput),
196
191
  },
197
- {method: 'POST', action: '/cart'},
192
+ { method: "POST", action: "/cart" },
198
193
  );
199
194
  }
200
195
  };
@@ -211,8 +206,7 @@ function getConciergeConversationIdsFromStorage(): string[] | null {
211
206
  return null;
212
207
  }
213
208
 
214
- const conversationIdsWithExpiry: ConversationIdWithExpiry[] =
215
- JSON.parse(stored);
209
+ const conversationIdsWithExpiry: ConversationIdWithExpiry[] = JSON.parse(stored);
216
210
  const now = Date.now();
217
211
 
218
212
  const validConversationIds = conversationIdsWithExpiry
@@ -220,7 +214,7 @@ function getConciergeConversationIdsFromStorage(): string[] | null {
220
214
  .map((item) => item.conversationId);
221
215
 
222
216
  return validConversationIds.length > 0 ? validConversationIds : null;
223
- } catch (error) {
217
+ } catch (_error) {
224
218
  return null;
225
219
  }
226
220
  }
@@ -230,7 +224,7 @@ const setCartRedoEnabledAttribute = async ({
230
224
  fetcher,
231
225
  waitCartIdle,
232
226
  cartInfoToEnable,
233
- enabled
227
+ enabled,
234
228
  }: {
235
229
  cart: CartReturn | CartWithActionsDocs | OptimisticCart | undefined;
236
230
  fetcher: FetcherWithComponents<unknown>;
@@ -240,15 +234,13 @@ const setCartRedoEnabledAttribute = async ({
240
234
  }) => {
241
235
  const redoCartAttribute = {
242
236
  key: cartInfoToEnable?.cartAttribute || DEFAULT_REDO_ENABLED_CART_ATTRIBUTE,
243
- value: enabled.toString()
237
+ value: enabled.toString(),
244
238
  };
245
239
 
246
240
  const existingAttributes = cart?.attributes || [];
247
-
248
- const existingAttributesMap = new Map(
249
- existingAttributes.map(attr => [attr.key, attr.value])
250
- );
251
-
241
+
242
+ const existingAttributesMap = new Map(existingAttributes.map((attr) => [attr.key, attr.value]));
243
+
252
244
  existingAttributesMap.set(redoCartAttribute.key, redoCartAttribute.value);
253
245
  const conciergeConversationIds = getConciergeConversationIdsFromStorage();
254
246
  if (conciergeConversationIds && conciergeConversationIds.length > 0) {
@@ -256,23 +248,23 @@ const setCartRedoEnabledAttribute = async ({
256
248
  CONCIERGE_ATTRIBUTION_CART_ATTRIBUTE_KEY,
257
249
  JSON.stringify({
258
250
  conciergeConversationIds: conciergeConversationIds,
259
- })
251
+ }),
260
252
  );
261
253
  }
262
254
 
263
255
  const updatedAttributes = Array.from(existingAttributesMap.entries()).map(([key, value]) => ({
264
256
  key,
265
- value: value ?? ""
257
+ value: value ?? "",
266
258
  }));
267
259
 
268
260
  const formInput = {
269
261
  action: CartForm.ACTIONS.AttributesUpdateInput,
270
262
  inputs: {
271
- attributes: updatedAttributes
272
- }
273
- }
263
+ attributes: updatedAttributes,
264
+ },
265
+ };
274
266
 
275
- if(cart && isCartWithActionsDocs(cart)) {
267
+ if (cart && isCartWithActionsDocs(cart)) {
276
268
  cart.cartAttributesUpdate(updatedAttributes);
277
269
  await waitCartIdle();
278
270
  } else {
@@ -280,49 +272,49 @@ const setCartRedoEnabledAttribute = async ({
280
272
  {
281
273
  [CartForm.INPUT_NAME]: JSON.stringify(formInput),
282
274
  },
283
- {method: 'POST', action: '/cart'},
275
+ { method: "POST", action: "/cart" },
284
276
  );
285
277
  }
286
278
  };
287
279
 
288
- type FetcherData<T> = NonNullable<T | unknown> // FIXME: used to use SerializeFrom which is deprecated. Can this be better typed?
289
- type ResolveFunction<T> = (value: FetcherData<T>) => void
280
+ type FetcherData<T> = NonNullable<T | unknown>; // FIXME: used to use SerializeFrom which is deprecated. Can this be better typed?
281
+ type ResolveFunction<T> = (value: FetcherData<T>) => void;
290
282
 
291
283
  function useFetcherWithPromise<TData = AppData>(opts?: Parameters<typeof useFetcher>[0]) {
292
- const fetcher = useFetcher<TData>(opts)
293
- const resolveRef = React.useRef<ResolveFunction<TData>>(null)
294
- const promiseRef = React.useRef<Promise<FetcherData<TData>>>(null)
284
+ const fetcher = useFetcher<TData>(opts);
285
+ const resolveRef = React.useRef<ResolveFunction<TData>>(null);
286
+ const promiseRef = React.useRef<Promise<FetcherData<TData>>>(null);
295
287
 
296
288
  if (!promiseRef.current) {
297
289
  promiseRef.current = new Promise<FetcherData<TData>>((resolve) => {
298
- resolveRef.current = resolve
299
- })
290
+ resolveRef.current = resolve;
291
+ });
300
292
  }
301
293
 
302
294
  const resetResolver = React.useCallback(() => {
303
295
  promiseRef.current = new Promise((resolve) => {
304
- resolveRef.current = resolve
305
- })
306
- }, [promiseRef, resolveRef])
296
+ resolveRef.current = resolve;
297
+ });
298
+ }, [promiseRef, resolveRef]);
307
299
 
308
300
  const submit = React.useCallback(
309
301
  async (...args: Parameters<typeof fetcher.submit>) => {
310
302
  fetcher.submit(...args);
311
- return promiseRef.current
303
+ return promiseRef.current;
312
304
  },
313
- [fetcher, promiseRef]
314
- )
305
+ [fetcher, promiseRef],
306
+ );
315
307
 
316
308
  React.useEffect(() => {
317
- if (fetcher.state === 'idle') {
309
+ if (fetcher.state === "idle") {
318
310
  if (fetcher.data) {
319
- resolveRef.current?.(fetcher.data)
311
+ resolveRef.current?.(fetcher.data);
320
312
  }
321
- resetResolver()
313
+ resetResolver();
322
314
  }
323
- }, [fetcher, resetResolver])
315
+ }, [fetcher, resetResolver]);
324
316
 
325
- return { ...fetcher, submit }
317
+ return { ...fetcher, submit };
326
318
  }
327
319
 
328
320
  type WaitCartIdleCallback = () => Promise<CartReturn | CartWithActionsDocs | OptimisticCart>;
@@ -330,49 +322,46 @@ type WaitCartIdleCallback = () => Promise<CartReturn | CartWithActionsDocs | Opt
330
322
  // This function allows us to await a cart idle state without breaking React rules.
331
323
  // It returns a function, which returns a promise, which will resolve once the cart value passed in reaches an idle state.
332
324
  // Not intended for use with CartReturn, but will accept that value if passed in to avoid breaking rules of hooks
333
- const useWaitCartIdle = (cart: CartReturn | CartWithActionsDocs | OptimisticCart | undefined) => {
334
- const resolveRef = useRef<any>(null)
335
- const promiseRef = useRef<any>(null)
325
+ type CartUnion = CartReturn | CartWithActionsDocs | OptimisticCart;
326
+
327
+ const useWaitCartIdle = (cart: CartUnion | undefined) => {
328
+ const resolveRef = useRef<((value: CartUnion) => void) | null>(null);
329
+ const promiseRef = useRef<Promise<CartUnion>>(null!);
336
330
 
337
331
  if (!promiseRef.current) {
338
332
  promiseRef.current = new Promise<CartReturn | CartWithActionsDocs | OptimisticCart>((resolve) => {
339
- resolveRef.current = resolve
340
- })
333
+ resolveRef.current = resolve;
334
+ });
341
335
  }
342
336
 
343
337
  const resetResolver = useCallback(() => {
344
338
  promiseRef.current = new Promise((resolve) => {
345
- resolveRef.current = resolve
346
- })
339
+ resolveRef.current = resolve;
340
+ });
347
341
  }, [promiseRef, resolveRef]);
348
342
 
349
- const waitCartIdle = useCallback(
350
- async () => {
351
- return promiseRef.current
352
- },
353
- [cart, promiseRef]
354
- )
343
+ const waitCartIdle = useCallback(async () => {
344
+ return promiseRef.current;
345
+ }, [cart, promiseRef]);
355
346
 
356
347
  useEffect(() => {
357
- if(!cart) {
348
+ if (!cart) {
358
349
  return;
359
350
  }
360
- if(!isCartWithActionsDocs(cart)) {
351
+ if (!isCartWithActionsDocs(cart)) {
361
352
  // Wrong type of cart. Just resolve.
362
353
  resolveRef.current?.(cart);
363
354
  resetResolver();
364
- } else if(cart.status === 'idle') {
365
- resolveRef.current?.(cart)
355
+ } else if (cart.status === "idle") {
356
+ resolveRef.current?.(cart);
366
357
  resetResolver();
367
358
  }
368
359
  }, [cart, resetResolver]);
369
360
 
370
361
  return waitCartIdle;
371
- }
362
+ };
372
363
 
373
- export type {
374
- WaitCartIdleCallback
375
- }
364
+ export type { WaitCartIdleCallback };
376
365
 
377
366
  export {
378
367
  DEFAULT_REDO_ENABLED_CART_ATTRIBUTE,
@@ -383,5 +372,6 @@ export {
383
372
  useWaitCartIdle,
384
373
  isCartWithActionsDocs,
385
374
  getCartLines,
386
- isOptimisticCart
387
- };
375
+ getCartEligibilityPriceKey,
376
+ isOptimisticCart,
377
+ };
@@ -0,0 +1,86 @@
1
+ const DISABLED_ATTR = "data-redo-preorder-disabled";
2
+ const ORIGINAL_DISABLED_ATTR = "data-redo-original-disabled";
3
+
4
+ /**
5
+ * Finds add-to-cart buttons on the page
6
+ */
7
+ function findAddToCartButtons(): HTMLButtonElement[] {
8
+ const buttons = document.querySelectorAll<HTMLButtonElement>(
9
+ 'button[name="add"], button[type="submit"], .product-form__submit',
10
+ );
11
+
12
+ return Array.from(buttons).filter((button) => {
13
+ const text = button.textContent?.toLowerCase().trim() ?? "";
14
+ return text.includes("add to cart") || text.includes("add to bag");
15
+ });
16
+ }
17
+
18
+ /**
19
+ * Checks for Purple Dot preorder element and updates button states accordingly.
20
+ * Disables add-to-cart buttons when purple-dot-learn-more element is present.
21
+ * Re-enables them when the element is removed.
22
+ */
23
+ export function updatePurpleDotButtons(): void {
24
+ const hasPurpleDot = document.querySelector("purple-dot-learn-more") !== null;
25
+ const addToCartButtons = findAddToCartButtons();
26
+
27
+ for (const button of addToCartButtons) {
28
+ if (hasPurpleDot) {
29
+ // Disable the button
30
+ if (!button.hasAttribute(DISABLED_ATTR)) {
31
+ // Preserve original disabled state
32
+ if (!button.hasAttribute(ORIGINAL_DISABLED_ATTR)) {
33
+ button.setAttribute(ORIGINAL_DISABLED_ATTR, String(button.disabled));
34
+ }
35
+
36
+ button.setAttribute(DISABLED_ATTR, "true");
37
+ button.disabled = true;
38
+ button.style.opacity = "0.5";
39
+ button.style.cursor = "not-allowed";
40
+ button.style.pointerEvents = "none";
41
+
42
+ // Add message if not already present
43
+ if (!button.parentElement?.querySelector(".redo-preorder-msg")) {
44
+ const msg = document.createElement("div");
45
+ msg.className = "redo-preorder-msg";
46
+ msg.textContent = "Preorder items cannot be added during exchanges";
47
+ msg.style.cssText = "color: #d63031; font-size: 12px; margin-top: 8px; font-weight: 500;";
48
+ button.parentElement?.insertBefore(msg, button.nextSibling);
49
+ }
50
+ }
51
+ } else {
52
+ // Re-enable the button if we disabled it
53
+ if (button.hasAttribute(DISABLED_ATTR)) {
54
+ const wasDisabled = button.getAttribute(ORIGINAL_DISABLED_ATTR) === "true";
55
+ button.removeAttribute(DISABLED_ATTR);
56
+ button.removeAttribute(ORIGINAL_DISABLED_ATTR);
57
+ button.disabled = wasDisabled;
58
+ button.style.opacity = "";
59
+ button.style.cursor = "";
60
+ button.style.pointerEvents = "";
61
+ button.parentElement?.querySelector(".redo-preorder-msg")?.remove();
62
+ }
63
+ }
64
+ }
65
+ }
66
+
67
+ /**
68
+ * Cleans up all disabled buttons (used on unmount).
69
+ * Restores original disabled state.
70
+ */
71
+ export function cleanupPurpleDotButtons(): void {
72
+ const disabledButtons = Array.from(document.querySelectorAll<HTMLButtonElement>(`button[${DISABLED_ATTR}]`));
73
+
74
+ for (const button of disabledButtons) {
75
+ const wasDisabled = button.getAttribute(ORIGINAL_DISABLED_ATTR) === "true";
76
+ button.removeAttribute(DISABLED_ATTR);
77
+ button.removeAttribute(ORIGINAL_DISABLED_ATTR);
78
+ button.disabled = wasDisabled;
79
+ button.style.opacity = "";
80
+ button.style.cursor = "";
81
+ button.style.pointerEvents = "";
82
+ }
83
+
84
+ // Remove all messages
85
+ document.querySelectorAll(".redo-preorder-msg").forEach((msg) => msg.remove());
86
+ }
@@ -1,17 +1,11 @@
1
- import {
2
- DependencyList,
3
- useCallback,
4
- useEffect,
5
- useRef,
6
- useState,
7
- } from "react";
1
+ import { DependencyList, useEffect, useState } from "react";
8
2
 
9
3
  export interface Loader<T> {
10
4
  (abort: AbortSignal): Promise<T>;
11
5
  }
12
6
 
13
7
  export interface LoadState<T> {
14
- error?: any;
8
+ error?: unknown;
15
9
  pending: boolean;
16
10
  value?: T;
17
11
  }
@@ -24,12 +18,14 @@ export function useLoad<T>(fn: Loader<T>, deps: DependencyList): LoadState<T> {
24
18
  setState((state) => ({ ...state, pending: true }));
25
19
  fn(abortController.signal).then(
26
20
  (value) => setState({ pending: false, value }),
27
- (error) => {
21
+ (error: unknown) => {
22
+ const message = error instanceof Error ? error.message : "";
23
+ const code = (error as Record<string, unknown>)?.code;
28
24
  if (
29
25
  !(
30
- error.message.includes("Request aborted for RPC method") ||
31
- error.code === "ERR_CANCELED" ||
32
- error.message === "Another request is in flight"
26
+ message.includes("Request aborted for RPC method") ||
27
+ code === "ERR_CANCELED" ||
28
+ message === "Another request is in flight"
33
29
  )
34
30
  ) {
35
31
  setState({ pending: false, error });
@@ -43,8 +39,7 @@ export function useLoad<T>(fn: Loader<T>, deps: DependencyList): LoadState<T> {
43
39
  // The way useLoad() is designed, we have no choice but to trust that the user gave us the correct deps for fn().
44
40
  // We could fix this by marking useLoad() as a custom hook, and then exhaustive-deps would enforce that for us.
45
41
  // https://www.npmjs.com/package/eslint-plugin-react-hooks#advanced-configuration
46
- // eslint-disable-next-line react-hooks/exhaustive-deps
47
42
  }, deps);
48
43
 
49
44
  return state;
50
- }
45
+ }
@@ -1,10 +1,5 @@
1
- const REDO_PUBLIC_API_HOSTNAME = 'api.getredo.com';
1
+ const REDO_PUBLIC_API_HOSTNAME = "api.getredo.com";
2
2
 
3
- const REDO_REQUIRED_HOSTNAMES = [
4
- REDO_PUBLIC_API_HOSTNAME
5
- ];
3
+ const REDO_REQUIRED_HOSTNAMES = [REDO_PUBLIC_API_HOSTNAME];
6
4
 
7
- export {
8
- REDO_REQUIRED_HOSTNAMES,
9
- REDO_PUBLIC_API_HOSTNAME
10
- };
5
+ export { REDO_REQUIRED_HOSTNAMES, REDO_PUBLIC_API_HOSTNAME };
@@ -3,10 +3,5 @@ export async function executeWithTimeout<T, E extends Error>(
3
3
  timeoutMs: number,
4
4
  error: E = new Error("timeout") as E,
5
5
  ): Promise<T> {
6
- return Promise.race([
7
- promise,
8
- new Promise<never>((_, reject) =>
9
- setTimeout(() => reject(error), timeoutMs),
10
- ),
11
- ]);
6
+ return Promise.race([promise, new Promise<never>((_, reject) => setTimeout(() => reject(error), timeoutMs))]);
12
7
  }