@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/.claude/settings.local.json +34 -0
- package/.github/workflows/ci.yml +56 -0
- package/.github/workflows/e2e.yml +72 -0
- package/.github/workflows/publish.yml +36 -0
- package/.prettierrc +7 -0
- package/.vscode/settings.json +10 -0
- package/CHANGELOG.md +32 -24
- 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 +13 -3
- package/src/components/redo-checkout-buttons.tsx +58 -74
- 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 +69 -70
- package/src/svg.d.ts +2 -2
- package/src/types.ts +30 -23
- package/src/utils/cart.ts +137 -147
- 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,24 @@
|
|
|
1
1
|
import { FetcherWithComponents, useFetcher } from "@remix-run/react";
|
|
2
2
|
import { CartInfoToEnable } from "../types";
|
|
3
|
-
import { CartForm, CartReturn, OptimisticCart
|
|
4
|
-
import type { AppData } from
|
|
5
|
-
import React, { useCallback, useEffect, useRef } from
|
|
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 =
|
|
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 = (
|
|
14
|
-
|
|
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 = (
|
|
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
|
|
30
|
-
}
|
|
33
|
+
return "isOptimistic" in cart && (cart.isOptimistic ?? false);
|
|
34
|
+
};
|
|
31
35
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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)
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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 ===
|
|
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 (
|
|
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
|
-
|
|
99
|
-
|
|
100
|
-
|
|
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:
|
|
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(
|
|
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 ===
|
|
148
|
+
return cartLine.merchandise.product.vendor === "re:do";
|
|
156
149
|
});
|
|
157
150
|
|
|
158
|
-
if(redoProductsInCart.length !== 0) {
|
|
159
|
-
await removeLinesFromCart({
|
|
160
|
-
|
|
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
|
-
|
|
177
|
-
|
|
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
|
-
|
|
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:
|
|
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 (
|
|
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
|
-
|
|
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:
|
|
275
|
+
{ method: "POST", action: "/cart" },
|
|
284
276
|
);
|
|
285
277
|
}
|
|
286
278
|
};
|
|
287
279
|
|
|
288
|
-
type FetcherData<T> = NonNullable<T | unknown
|
|
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 ===
|
|
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
|
-
|
|
334
|
-
|
|
335
|
-
|
|
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
|
-
|
|
351
|
-
|
|
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 ===
|
|
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
|
-
|
|
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
|
+
}
|
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
|
}
|