@redotech/redo-hydrogen 1.4.6 → 2.0.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/.github/workflows/ci.yml +58 -0
- package/.github/workflows/e2e.yml +72 -0
- package/.github/workflows/publish.yml +68 -0
- package/.prettierrc +7 -0
- package/.vscode/settings.json +10 -0
- package/CHANGELOG.md +36 -24
- package/README.md +37 -2
- package/dist/cjs/index.js +2 -2
- package/dist/cjs/index.js.map +1 -1
- package/dist/esm/index.js +2 -2
- package/dist/esm/index.js.map +1 -1
- package/dist/types.d.ts +24 -4
- package/eslint.config.mjs +22 -0
- package/package.json +25 -12
- package/src/components/redo-checkout-buttons.tsx +51 -72
- package/src/components/redo-info-modal.tsx +486 -345
- package/src/hooks/use-purple-dot-preorder.ts +39 -0
- package/src/index.ts +14 -4
- package/src/providers/redo-coverage-client.tsx +67 -69
- package/src/svg.d.ts +2 -2
- package/src/types.ts +30 -23
- package/src/utils/cart.ts +117 -153
- package/src/utils/purple-dot.ts +86 -0
- package/src/utils/react-utils.ts +9 -14
- package/src/utils/security.ts +3 -8
- package/src/utils/timeout.ts +1 -6
package/src/utils/cart.ts
CHANGED
|
@@ -1,20 +1,23 @@
|
|
|
1
|
-
import { FetcherWithComponents, useFetcher } from "
|
|
1
|
+
import { FetcherWithComponents, useFetcher } from "react-router";
|
|
2
2
|
import { CartInfoToEnable } from "../types";
|
|
3
|
-
import { CartForm, CartReturn, OptimisticCart
|
|
4
|
-
import
|
|
5
|
-
import React, { useCallback, useEffect, useRef } from 'react'
|
|
3
|
+
import { CartForm, CartReturn, OptimisticCart } from "@shopify/hydrogen";
|
|
4
|
+
import React, { useCallback, useEffect, useRef } from "react";
|
|
6
5
|
import { CartWithActionsDocs } from "@shopify/hydrogen-react/dist/types/cart-types";
|
|
7
6
|
import { CartLine, ComponentizableCartLine } from "@shopify/hydrogen-react/storefront-api-types";
|
|
8
7
|
|
|
9
|
-
const DEFAULT_REDO_ENABLED_CART_ATTRIBUTE =
|
|
8
|
+
const DEFAULT_REDO_ENABLED_CART_ATTRIBUTE = "redo_opted_in_from_cart";
|
|
10
9
|
const CONCIERGE_ATTRIBUTION_CART_ATTRIBUTE_KEY = "redo.conciergeAssisted";
|
|
11
10
|
const CONCIERGE_CONVERSATION_IDS_STORAGE_KEY = "redoConciergeConversationIds";
|
|
12
11
|
|
|
13
|
-
const isCartWithActionsDocs = (
|
|
14
|
-
|
|
15
|
-
|
|
12
|
+
const isCartWithActionsDocs = (
|
|
13
|
+
cart: CartReturn | CartWithActionsDocs | OptimisticCart,
|
|
14
|
+
): cart is CartWithActionsDocs => {
|
|
15
|
+
return Array.isArray(cart.lines) && "linesAdd" in cart && typeof cart.linesAdd === "function";
|
|
16
|
+
};
|
|
16
17
|
|
|
17
|
-
const getCartLines = (
|
|
18
|
+
const getCartLines = (
|
|
19
|
+
cart: CartReturn | CartWithActionsDocs | OptimisticCart,
|
|
20
|
+
): Array<CartLine | ComponentizableCartLine> => {
|
|
18
21
|
if (isOptimisticCart(cart)) {
|
|
19
22
|
return cart.lines.nodes;
|
|
20
23
|
} else if (isCartWithActionsDocs(cart)) {
|
|
@@ -22,85 +25,51 @@ const getCartLines = (cart: CartReturn | CartWithActionsDocs | OptimisticCart):
|
|
|
22
25
|
} else {
|
|
23
26
|
return cart.lines.nodes ?? cart.lines.edges.map((edge) => edge.node);
|
|
24
27
|
}
|
|
25
|
-
}
|
|
28
|
+
};
|
|
26
29
|
|
|
27
30
|
// https://shopify.dev/docs/api/hydrogen/2025-01/hooks/useoptimisticcart
|
|
28
31
|
const isOptimisticCart = (cart: CartReturn | CartWithActionsDocs | OptimisticCart): cart is OptimisticCart => {
|
|
29
|
-
return
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
const isRedoInCart = ({
|
|
33
|
-
cart
|
|
34
|
-
}: {
|
|
35
|
-
cart: CartReturn | CartWithActionsDocs | OptimisticCart
|
|
36
|
-
}): boolean => {
|
|
37
|
-
if(!cart) {
|
|
38
|
-
return false;
|
|
39
|
-
}
|
|
40
|
-
|
|
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
|
-
}
|
|
32
|
+
return "isOptimistic" in cart && (cart.isOptimistic ?? false);
|
|
33
|
+
};
|
|
70
34
|
|
|
71
35
|
const addProductToCartIfNeeded = async ({
|
|
72
36
|
cart,
|
|
73
37
|
fetcher,
|
|
74
38
|
waitCartIdle,
|
|
75
|
-
cartInfoToEnable
|
|
39
|
+
cartInfoToEnable,
|
|
76
40
|
}: {
|
|
77
|
-
cart: CartReturn | CartWithActionsDocs | OptimisticCart | undefined
|
|
78
|
-
fetcher: FetcherWithComponents<unknown
|
|
41
|
+
cart: CartReturn | CartWithActionsDocs | OptimisticCart | undefined;
|
|
42
|
+
fetcher: FetcherWithComponents<unknown>;
|
|
79
43
|
waitCartIdle: WaitCartIdleCallback;
|
|
80
|
-
cartInfoToEnable: CartInfoToEnable
|
|
44
|
+
cartInfoToEnable: CartInfoToEnable;
|
|
81
45
|
}) => {
|
|
82
|
-
if(!cart) {
|
|
46
|
+
if (!cart) {
|
|
83
47
|
return await addProductToCart({ cart, fetcher, waitCartIdle, cartInfoToEnable });
|
|
84
48
|
}
|
|
85
49
|
|
|
86
50
|
const redoProductsInCart = getCartLines(cart).filter((cartLine) => {
|
|
87
|
-
return cartLine.merchandise.product.vendor ===
|
|
51
|
+
return cartLine.merchandise.product.vendor === "re:do";
|
|
88
52
|
});
|
|
89
53
|
const correctRedoProductInCart = redoProductsInCart?.filter((cartLine) => {
|
|
90
54
|
return cartLine.merchandise.id === `gid://shopify/ProductVariant/${cartInfoToEnable.variantId}`;
|
|
91
55
|
});
|
|
92
|
-
if(redoProductsInCart.length === 0) {
|
|
56
|
+
if (redoProductsInCart.length === 0) {
|
|
93
57
|
return await addProductToCart({ cart, fetcher, waitCartIdle, cartInfoToEnable });
|
|
94
|
-
} else if (
|
|
58
|
+
} else if (
|
|
59
|
+
redoProductsInCart.length === 1 &&
|
|
60
|
+
correctRedoProductInCart.length === 1 &&
|
|
61
|
+
correctRedoProductInCart[0].quantity === 1
|
|
62
|
+
) {
|
|
95
63
|
// No action needed
|
|
96
64
|
return;
|
|
97
65
|
} else {
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
66
|
+
await removeLinesFromCart({
|
|
67
|
+
cart,
|
|
68
|
+
fetcher,
|
|
69
|
+
waitCartIdle,
|
|
70
|
+
lineIds: redoProductsInCart.map((cartLine) => cartLine.id),
|
|
71
|
+
});
|
|
101
72
|
await addProductToCart({ cart, fetcher, waitCartIdle, cartInfoToEnable });
|
|
102
|
-
|
|
103
|
-
return;
|
|
104
73
|
}
|
|
105
74
|
};
|
|
106
75
|
|
|
@@ -108,7 +77,7 @@ const removeLinesFromCart = async ({
|
|
|
108
77
|
cart,
|
|
109
78
|
fetcher,
|
|
110
79
|
waitCartIdle,
|
|
111
|
-
lineIds
|
|
80
|
+
lineIds,
|
|
112
81
|
}: {
|
|
113
82
|
cart: CartReturn | CartWithActionsDocs | OptimisticCart | undefined;
|
|
114
83
|
fetcher: FetcherWithComponents<unknown>;
|
|
@@ -118,11 +87,11 @@ const removeLinesFromCart = async ({
|
|
|
118
87
|
const formInput = {
|
|
119
88
|
action: CartForm.ACTIONS.LinesRemove,
|
|
120
89
|
inputs: {
|
|
121
|
-
lineIds
|
|
122
|
-
}
|
|
123
|
-
}
|
|
90
|
+
lineIds,
|
|
91
|
+
},
|
|
92
|
+
};
|
|
124
93
|
|
|
125
|
-
if(cart && isCartWithActionsDocs(cart)) {
|
|
94
|
+
if (cart && isCartWithActionsDocs(cart)) {
|
|
126
95
|
cart.linesRemove(lineIds);
|
|
127
96
|
await waitCartIdle();
|
|
128
97
|
} else {
|
|
@@ -130,7 +99,7 @@ const removeLinesFromCart = async ({
|
|
|
130
99
|
{
|
|
131
100
|
[CartForm.INPUT_NAME]: JSON.stringify(formInput),
|
|
132
101
|
},
|
|
133
|
-
{method:
|
|
102
|
+
{ method: "POST", action: "/cart" },
|
|
134
103
|
);
|
|
135
104
|
}
|
|
136
105
|
};
|
|
@@ -139,25 +108,28 @@ const removeProductFromCartIfNeeded = async ({
|
|
|
139
108
|
cart,
|
|
140
109
|
fetcher,
|
|
141
110
|
waitCartIdle,
|
|
142
|
-
cartInfoToEnable
|
|
143
111
|
}: {
|
|
144
|
-
cart: CartReturn | CartWithActionsDocs | OptimisticCart | undefined
|
|
145
|
-
fetcher: FetcherWithComponents<unknown
|
|
146
|
-
waitCartIdle: WaitCartIdleCallback
|
|
147
|
-
cartInfoToEnable: CartInfoToEnable
|
|
112
|
+
cart: CartReturn | CartWithActionsDocs | OptimisticCart | undefined;
|
|
113
|
+
fetcher: FetcherWithComponents<unknown>;
|
|
114
|
+
waitCartIdle: WaitCartIdleCallback;
|
|
115
|
+
cartInfoToEnable: CartInfoToEnable;
|
|
148
116
|
}) => {
|
|
149
|
-
if(!cart) {
|
|
150
|
-
console.error(
|
|
117
|
+
if (!cart) {
|
|
118
|
+
console.error("No cart");
|
|
151
119
|
return;
|
|
152
120
|
}
|
|
153
121
|
|
|
154
122
|
const redoProductsInCart = getCartLines(cart).filter((cartLine) => {
|
|
155
|
-
return cartLine.merchandise.product.vendor ===
|
|
123
|
+
return cartLine.merchandise.product.vendor === "re:do";
|
|
156
124
|
});
|
|
157
125
|
|
|
158
|
-
if(redoProductsInCart.length !== 0) {
|
|
159
|
-
await removeLinesFromCart({
|
|
160
|
-
|
|
126
|
+
if (redoProductsInCart.length !== 0) {
|
|
127
|
+
await removeLinesFromCart({
|
|
128
|
+
cart,
|
|
129
|
+
fetcher,
|
|
130
|
+
waitCartIdle,
|
|
131
|
+
lineIds: redoProductsInCart.map((cartLine) => cartLine.id),
|
|
132
|
+
});
|
|
161
133
|
}
|
|
162
134
|
};
|
|
163
135
|
|
|
@@ -168,25 +140,23 @@ const addProductToCart = async ({
|
|
|
168
140
|
cartInfoToEnable,
|
|
169
141
|
}: {
|
|
170
142
|
waitCartIdle: WaitCartIdleCallback;
|
|
171
|
-
cart: CartReturn | CartWithActionsDocs | OptimisticCart | undefined
|
|
172
|
-
fetcher: FetcherWithComponents<unknown
|
|
173
|
-
cartInfoToEnable: CartInfoToEnable
|
|
143
|
+
cart: CartReturn | CartWithActionsDocs | OptimisticCart | undefined;
|
|
144
|
+
fetcher: FetcherWithComponents<unknown>;
|
|
145
|
+
cartInfoToEnable: CartInfoToEnable;
|
|
174
146
|
}) => {
|
|
175
147
|
const redoProductLine = {
|
|
176
|
-
|
|
177
|
-
|
|
148
|
+
merchandiseId: `gid://shopify/ProductVariant/${cartInfoToEnable.variantId}`,
|
|
149
|
+
quantity: 1,
|
|
178
150
|
};
|
|
179
151
|
|
|
180
152
|
const formInput = {
|
|
181
153
|
action: CartForm.ACTIONS.LinesAdd,
|
|
182
154
|
inputs: {
|
|
183
|
-
lines: [
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
}
|
|
187
|
-
}
|
|
155
|
+
lines: [redoProductLine],
|
|
156
|
+
},
|
|
157
|
+
};
|
|
188
158
|
|
|
189
|
-
if(cart && isCartWithActionsDocs(cart)) {
|
|
159
|
+
if (cart && isCartWithActionsDocs(cart)) {
|
|
190
160
|
cart.linesAdd([redoProductLine]);
|
|
191
161
|
await waitCartIdle();
|
|
192
162
|
} else {
|
|
@@ -194,7 +164,7 @@ const addProductToCart = async ({
|
|
|
194
164
|
{
|
|
195
165
|
[CartForm.INPUT_NAME]: JSON.stringify(formInput),
|
|
196
166
|
},
|
|
197
|
-
{method:
|
|
167
|
+
{ method: "POST", action: "/cart" },
|
|
198
168
|
);
|
|
199
169
|
}
|
|
200
170
|
};
|
|
@@ -211,8 +181,7 @@ function getConciergeConversationIdsFromStorage(): string[] | null {
|
|
|
211
181
|
return null;
|
|
212
182
|
}
|
|
213
183
|
|
|
214
|
-
const conversationIdsWithExpiry: ConversationIdWithExpiry[] =
|
|
215
|
-
JSON.parse(stored);
|
|
184
|
+
const conversationIdsWithExpiry: ConversationIdWithExpiry[] = JSON.parse(stored);
|
|
216
185
|
const now = Date.now();
|
|
217
186
|
|
|
218
187
|
const validConversationIds = conversationIdsWithExpiry
|
|
@@ -220,7 +189,7 @@ function getConciergeConversationIdsFromStorage(): string[] | null {
|
|
|
220
189
|
.map((item) => item.conversationId);
|
|
221
190
|
|
|
222
191
|
return validConversationIds.length > 0 ? validConversationIds : null;
|
|
223
|
-
} catch (
|
|
192
|
+
} catch (_error) {
|
|
224
193
|
return null;
|
|
225
194
|
}
|
|
226
195
|
}
|
|
@@ -230,7 +199,7 @@ const setCartRedoEnabledAttribute = async ({
|
|
|
230
199
|
fetcher,
|
|
231
200
|
waitCartIdle,
|
|
232
201
|
cartInfoToEnable,
|
|
233
|
-
enabled
|
|
202
|
+
enabled,
|
|
234
203
|
}: {
|
|
235
204
|
cart: CartReturn | CartWithActionsDocs | OptimisticCart | undefined;
|
|
236
205
|
fetcher: FetcherWithComponents<unknown>;
|
|
@@ -240,15 +209,13 @@ const setCartRedoEnabledAttribute = async ({
|
|
|
240
209
|
}) => {
|
|
241
210
|
const redoCartAttribute = {
|
|
242
211
|
key: cartInfoToEnable?.cartAttribute || DEFAULT_REDO_ENABLED_CART_ATTRIBUTE,
|
|
243
|
-
value: enabled.toString()
|
|
212
|
+
value: enabled.toString(),
|
|
244
213
|
};
|
|
245
214
|
|
|
246
215
|
const existingAttributes = cart?.attributes || [];
|
|
247
|
-
|
|
248
|
-
const existingAttributesMap = new Map(
|
|
249
|
-
|
|
250
|
-
);
|
|
251
|
-
|
|
216
|
+
|
|
217
|
+
const existingAttributesMap = new Map(existingAttributes.map((attr) => [attr.key, attr.value]));
|
|
218
|
+
|
|
252
219
|
existingAttributesMap.set(redoCartAttribute.key, redoCartAttribute.value);
|
|
253
220
|
const conciergeConversationIds = getConciergeConversationIdsFromStorage();
|
|
254
221
|
if (conciergeConversationIds && conciergeConversationIds.length > 0) {
|
|
@@ -256,23 +223,23 @@ const setCartRedoEnabledAttribute = async ({
|
|
|
256
223
|
CONCIERGE_ATTRIBUTION_CART_ATTRIBUTE_KEY,
|
|
257
224
|
JSON.stringify({
|
|
258
225
|
conciergeConversationIds: conciergeConversationIds,
|
|
259
|
-
})
|
|
226
|
+
}),
|
|
260
227
|
);
|
|
261
228
|
}
|
|
262
229
|
|
|
263
230
|
const updatedAttributes = Array.from(existingAttributesMap.entries()).map(([key, value]) => ({
|
|
264
231
|
key,
|
|
265
|
-
value: value ?? ""
|
|
232
|
+
value: value ?? "",
|
|
266
233
|
}));
|
|
267
234
|
|
|
268
235
|
const formInput = {
|
|
269
236
|
action: CartForm.ACTIONS.AttributesUpdateInput,
|
|
270
237
|
inputs: {
|
|
271
|
-
attributes: updatedAttributes
|
|
272
|
-
}
|
|
273
|
-
}
|
|
238
|
+
attributes: updatedAttributes,
|
|
239
|
+
},
|
|
240
|
+
};
|
|
274
241
|
|
|
275
|
-
if(cart && isCartWithActionsDocs(cart)) {
|
|
242
|
+
if (cart && isCartWithActionsDocs(cart)) {
|
|
276
243
|
cart.cartAttributesUpdate(updatedAttributes);
|
|
277
244
|
await waitCartIdle();
|
|
278
245
|
} else {
|
|
@@ -280,49 +247,49 @@ const setCartRedoEnabledAttribute = async ({
|
|
|
280
247
|
{
|
|
281
248
|
[CartForm.INPUT_NAME]: JSON.stringify(formInput),
|
|
282
249
|
},
|
|
283
|
-
{method:
|
|
250
|
+
{ method: "POST", action: "/cart" },
|
|
284
251
|
);
|
|
285
252
|
}
|
|
286
253
|
};
|
|
287
254
|
|
|
288
|
-
type FetcherData<T> = NonNullable<T | unknown
|
|
289
|
-
type ResolveFunction<T> = (value: FetcherData<T>) => void
|
|
255
|
+
type FetcherData<T> = NonNullable<T | unknown>; // FIXME: used to use SerializeFrom which is deprecated. Can this be better typed?
|
|
256
|
+
type ResolveFunction<T> = (value: FetcherData<T>) => void;
|
|
290
257
|
|
|
291
|
-
function useFetcherWithPromise<TData =
|
|
292
|
-
const fetcher = useFetcher<TData>(opts)
|
|
293
|
-
const resolveRef = React.useRef<ResolveFunction<TData
|
|
294
|
-
const promiseRef = React.useRef<Promise<FetcherData<TData
|
|
258
|
+
function useFetcherWithPromise<TData = unknown>(opts?: Parameters<typeof useFetcher>[0]) {
|
|
259
|
+
const fetcher = useFetcher<TData>(opts);
|
|
260
|
+
const resolveRef = React.useRef<ResolveFunction<TData> | null>(null);
|
|
261
|
+
const promiseRef = React.useRef<Promise<FetcherData<TData>> | null>(null);
|
|
295
262
|
|
|
296
263
|
if (!promiseRef.current) {
|
|
297
264
|
promiseRef.current = new Promise<FetcherData<TData>>((resolve) => {
|
|
298
|
-
resolveRef.current = resolve
|
|
299
|
-
})
|
|
265
|
+
resolveRef.current = resolve;
|
|
266
|
+
});
|
|
300
267
|
}
|
|
301
268
|
|
|
302
269
|
const resetResolver = React.useCallback(() => {
|
|
303
270
|
promiseRef.current = new Promise((resolve) => {
|
|
304
|
-
resolveRef.current = resolve
|
|
305
|
-
})
|
|
306
|
-
}, [promiseRef, resolveRef])
|
|
271
|
+
resolveRef.current = resolve;
|
|
272
|
+
});
|
|
273
|
+
}, [promiseRef, resolveRef]);
|
|
307
274
|
|
|
308
275
|
const submit = React.useCallback(
|
|
309
|
-
async (...args: Parameters<typeof fetcher.submit>) => {
|
|
276
|
+
async (...args: Parameters<typeof fetcher.submit>): Promise<void> => {
|
|
310
277
|
fetcher.submit(...args);
|
|
311
|
-
|
|
278
|
+
await promiseRef.current;
|
|
312
279
|
},
|
|
313
|
-
[fetcher, promiseRef]
|
|
314
|
-
)
|
|
280
|
+
[fetcher, promiseRef],
|
|
281
|
+
);
|
|
315
282
|
|
|
316
283
|
React.useEffect(() => {
|
|
317
|
-
if (fetcher.state ===
|
|
284
|
+
if (fetcher.state === "idle") {
|
|
318
285
|
if (fetcher.data) {
|
|
319
|
-
resolveRef.current?.(fetcher.data)
|
|
286
|
+
resolveRef.current?.(fetcher.data);
|
|
320
287
|
}
|
|
321
|
-
resetResolver()
|
|
288
|
+
resetResolver();
|
|
322
289
|
}
|
|
323
|
-
}, [fetcher, resetResolver])
|
|
290
|
+
}, [fetcher, resetResolver]);
|
|
324
291
|
|
|
325
|
-
return { ...fetcher, submit }
|
|
292
|
+
return { ...fetcher, submit };
|
|
326
293
|
}
|
|
327
294
|
|
|
328
295
|
type WaitCartIdleCallback = () => Promise<CartReturn | CartWithActionsDocs | OptimisticCart>;
|
|
@@ -330,49 +297,46 @@ type WaitCartIdleCallback = () => Promise<CartReturn | CartWithActionsDocs | Opt
|
|
|
330
297
|
// This function allows us to await a cart idle state without breaking React rules.
|
|
331
298
|
// It returns a function, which returns a promise, which will resolve once the cart value passed in reaches an idle state.
|
|
332
299
|
// Not intended for use with CartReturn, but will accept that value if passed in to avoid breaking rules of hooks
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
300
|
+
type CartUnion = CartReturn | CartWithActionsDocs | OptimisticCart;
|
|
301
|
+
|
|
302
|
+
const useWaitCartIdle = (cart: CartUnion | undefined) => {
|
|
303
|
+
const resolveRef = useRef<((value: CartUnion) => void) | null>(null);
|
|
304
|
+
const promiseRef = useRef<Promise<CartUnion>>(null!);
|
|
336
305
|
|
|
337
306
|
if (!promiseRef.current) {
|
|
338
307
|
promiseRef.current = new Promise<CartReturn | CartWithActionsDocs | OptimisticCart>((resolve) => {
|
|
339
|
-
resolveRef.current = resolve
|
|
340
|
-
})
|
|
308
|
+
resolveRef.current = resolve;
|
|
309
|
+
});
|
|
341
310
|
}
|
|
342
311
|
|
|
343
312
|
const resetResolver = useCallback(() => {
|
|
344
313
|
promiseRef.current = new Promise((resolve) => {
|
|
345
|
-
resolveRef.current = resolve
|
|
346
|
-
})
|
|
314
|
+
resolveRef.current = resolve;
|
|
315
|
+
});
|
|
347
316
|
}, [promiseRef, resolveRef]);
|
|
348
317
|
|
|
349
|
-
const waitCartIdle = useCallback(
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
},
|
|
353
|
-
[cart, promiseRef]
|
|
354
|
-
)
|
|
318
|
+
const waitCartIdle = useCallback(async () => {
|
|
319
|
+
return promiseRef.current;
|
|
320
|
+
}, [cart, promiseRef]);
|
|
355
321
|
|
|
356
322
|
useEffect(() => {
|
|
357
|
-
if(!cart) {
|
|
323
|
+
if (!cart) {
|
|
358
324
|
return;
|
|
359
325
|
}
|
|
360
|
-
if(!isCartWithActionsDocs(cart)) {
|
|
326
|
+
if (!isCartWithActionsDocs(cart)) {
|
|
361
327
|
// Wrong type of cart. Just resolve.
|
|
362
328
|
resolveRef.current?.(cart);
|
|
363
329
|
resetResolver();
|
|
364
|
-
} else if(cart.status ===
|
|
365
|
-
resolveRef.current?.(cart)
|
|
330
|
+
} else if (cart.status === "idle") {
|
|
331
|
+
resolveRef.current?.(cart);
|
|
366
332
|
resetResolver();
|
|
367
333
|
}
|
|
368
334
|
}, [cart, resetResolver]);
|
|
369
335
|
|
|
370
336
|
return waitCartIdle;
|
|
371
|
-
}
|
|
337
|
+
};
|
|
372
338
|
|
|
373
|
-
export type {
|
|
374
|
-
WaitCartIdleCallback
|
|
375
|
-
}
|
|
339
|
+
export type { WaitCartIdleCallback };
|
|
376
340
|
|
|
377
341
|
export {
|
|
378
342
|
DEFAULT_REDO_ENABLED_CART_ATTRIBUTE,
|
|
@@ -383,5 +347,5 @@ export {
|
|
|
383
347
|
useWaitCartIdle,
|
|
384
348
|
isCartWithActionsDocs,
|
|
385
349
|
getCartLines,
|
|
386
|
-
isOptimisticCart
|
|
387
|
-
};
|
|
350
|
+
isOptimisticCart,
|
|
351
|
+
};
|
|
@@ -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
|
+
}
|
package/src/utils/react-utils.ts
CHANGED
|
@@ -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?:
|
|
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
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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
|
+
}
|
package/src/utils/security.ts
CHANGED
|
@@ -1,10 +1,5 @@
|
|
|
1
|
-
const REDO_PUBLIC_API_HOSTNAME =
|
|
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 };
|
package/src/utils/timeout.ts
CHANGED
|
@@ -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
|
}
|