@redotech/redo-hydrogen 1.2.1 → 1.3.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/CHANGELOG.md +9 -0
- 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 +5 -5
- package/package.json +2 -1
- package/rollup.config.js +2 -0
- package/src/components/redo-checkout-buttons.tsx +90 -44
- package/src/providers/redo-coverage-client.tsx +13 -7
- package/src/svg.d.ts +4 -0
- package/src/types.ts +3 -3
- package/src/utils/cart.ts +119 -24
- package/src/utils/circle-spinner.svg +24 -0
- package/src/utils/timeout.ts +12 -0
- package/tsconfig.json +1 -1
package/dist/types.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { CartReturn } from '@shopify/hydrogen';
|
|
1
|
+
import { CartReturn, OptimisticCart } from '@shopify/hydrogen';
|
|
2
2
|
import { ReactNode, DependencyList } from 'react';
|
|
3
3
|
import { CartWithActionsDocs } from '@shopify/hydrogen-react/dist/types/cart-types';
|
|
4
4
|
import { ProductVariant } from '@shopify/hydrogen-react/storefront-api-types';
|
|
@@ -14,7 +14,7 @@ interface RedoCoverageClient {
|
|
|
14
14
|
get eligible(): boolean;
|
|
15
15
|
get price(): number | undefined;
|
|
16
16
|
get storeId(): string | undefined;
|
|
17
|
-
get cart(): CartReturn | CartWithActionsDocs | undefined;
|
|
17
|
+
get cart(): CartReturn | CartWithActionsDocs | OptimisticCart | undefined;
|
|
18
18
|
get cartProduct(): CartProductVariantFragment | undefined;
|
|
19
19
|
get cartAttribute(): CartAttributeKey | undefined;
|
|
20
20
|
get errors(): RedoError[] | undefined;
|
|
@@ -30,7 +30,7 @@ type RedoContextValue = {
|
|
|
30
30
|
loading: boolean;
|
|
31
31
|
storeId?: string;
|
|
32
32
|
cartInfoToEnable?: CartInfoToEnable;
|
|
33
|
-
cart?: CartReturn | CartWithActionsDocs;
|
|
33
|
+
cart?: CartReturn | CartWithActionsDocs | OptimisticCart;
|
|
34
34
|
errors?: RedoError[];
|
|
35
35
|
};
|
|
36
36
|
declare enum RedoErrorType {
|
|
@@ -45,14 +45,14 @@ type RedoError = {
|
|
|
45
45
|
};
|
|
46
46
|
|
|
47
47
|
declare const RedoProvider: ({ cart, storeId, children }: {
|
|
48
|
-
cart: CartReturn | CartWithActionsDocs;
|
|
48
|
+
cart: CartReturn | CartWithActionsDocs | OptimisticCart;
|
|
49
49
|
storeId: string;
|
|
50
50
|
children: ReactNode;
|
|
51
51
|
}) => ReactNode;
|
|
52
52
|
declare const useRedoCoverageClient: () => RedoCoverageClient;
|
|
53
53
|
|
|
54
54
|
declare const RedoCheckoutButtons: (props: {
|
|
55
|
-
cart: CartReturn | CartWithActionsDocs;
|
|
55
|
+
cart: CartReturn | CartWithActionsDocs | OptimisticCart;
|
|
56
56
|
children?: ReactNode;
|
|
57
57
|
onClick?: (enabled: boolean) => void;
|
|
58
58
|
}) => react_jsx_runtime.JSX.Element;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@redotech/redo-hydrogen",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.3.0",
|
|
4
4
|
"description": "Utilities to enable and disable Redo coverage on Hydrogen stores",
|
|
5
5
|
"main": "dist/cjs/index.js",
|
|
6
6
|
"module": "dist/esm/index.js",
|
|
@@ -26,6 +26,7 @@
|
|
|
26
26
|
"@rollup/plugin-node-resolve": "^16.0.0",
|
|
27
27
|
"@rollup/plugin-terser": "^0.4.4",
|
|
28
28
|
"@rollup/plugin-typescript": "^12.1.2",
|
|
29
|
+
"@svgr/rollup": "^8.1.0",
|
|
29
30
|
"@types/react": "^19.0.8",
|
|
30
31
|
"nodemon": "^3.1.9",
|
|
31
32
|
"react": "^18.3.1",
|
package/rollup.config.js
CHANGED
|
@@ -4,6 +4,7 @@ import typescript from "@rollup/plugin-typescript";
|
|
|
4
4
|
import dts from "rollup-plugin-dts";
|
|
5
5
|
import terser from "@rollup/plugin-terser";
|
|
6
6
|
import peerDepsExternal from "rollup-plugin-peer-deps-external";
|
|
7
|
+
import svgr from '@svgr/rollup';
|
|
7
8
|
|
|
8
9
|
const packageJson = require("./package.json");
|
|
9
10
|
|
|
@@ -30,6 +31,7 @@ export default [
|
|
|
30
31
|
commonjs(),
|
|
31
32
|
typescript({ tsconfig: "./tsconfig.json" }),
|
|
32
33
|
terser(),
|
|
34
|
+
svgr(),
|
|
33
35
|
],
|
|
34
36
|
external: ["react", "react-dom"],
|
|
35
37
|
},
|
|
@@ -1,9 +1,5 @@
|
|
|
1
1
|
import React, { MouseEvent, ReactNode, useEffect, useState } from "react";
|
|
2
|
-
import {
|
|
3
|
-
CartForm,
|
|
4
|
-
CartActionInput,
|
|
5
|
-
CartReturn,
|
|
6
|
-
} from "@shopify/hydrogen";
|
|
2
|
+
import { CartForm, CartActionInput, CartReturn, OptimisticCart } from "@shopify/hydrogen";
|
|
7
3
|
import { useRedoCoverageClient } from "../providers/redo-coverage-client";
|
|
8
4
|
import { CartInfoToEnable, RedoCoverageClient } from "../types";
|
|
9
5
|
import { REDO_PUBLIC_API_HOSTNAME } from "../utils/security";
|
|
@@ -11,6 +7,9 @@ import { CurrencyCode } from "@shopify/hydrogen-react/storefront-api-types";
|
|
|
11
7
|
import { CartWithActionsDocs } from "@shopify/hydrogen-react/dist/types/cart-types";
|
|
12
8
|
import { getCartLines, isCartWithActionsDocs } from "../utils/cart";
|
|
13
9
|
|
|
10
|
+
import CircleSpinner from "../utils/circle-spinner.svg";
|
|
11
|
+
import { executeWithTimeout } from "../utils/timeout";
|
|
12
|
+
|
|
14
13
|
type CheckoutButtonUIResponse = {
|
|
15
14
|
html: string;
|
|
16
15
|
css: string;
|
|
@@ -22,7 +21,7 @@ const getButtonsToShow = ({
|
|
|
22
21
|
storeId
|
|
23
22
|
}: {
|
|
24
23
|
redoCoverageClient: RedoCoverageClient,
|
|
25
|
-
cart: CartReturn | CartWithActionsDocs,
|
|
24
|
+
cart: CartReturn | CartWithActionsDocs | OptimisticCart,
|
|
26
25
|
storeId: string;
|
|
27
26
|
}): Promise<CheckoutButtonUIResponse | null> => {
|
|
28
27
|
return new Promise<CheckoutButtonUIResponse | null>((resolve, reject) => {
|
|
@@ -47,7 +46,7 @@ const getButtonsToShow = ({
|
|
|
47
46
|
ui: json
|
|
48
47
|
});
|
|
49
48
|
|
|
50
|
-
if(!ui) {
|
|
49
|
+
if (!ui) {
|
|
51
50
|
return reject(null);
|
|
52
51
|
}
|
|
53
52
|
|
|
@@ -62,15 +61,15 @@ const applyButtonVariables = ({
|
|
|
62
61
|
ui
|
|
63
62
|
}: {
|
|
64
63
|
redoCoverageClient: RedoCoverageClient,
|
|
65
|
-
cart: CartReturn | CartWithActionsDocs,
|
|
64
|
+
cart: CartReturn | CartWithActionsDocs | OptimisticCart,
|
|
66
65
|
ui: CheckoutButtonUIResponse
|
|
67
66
|
}): CheckoutButtonUIResponse | null => {
|
|
68
|
-
if(!redoCoverageClient.eligible || !redoCoverageClient.price || !cart?.cost) {
|
|
67
|
+
if (!redoCoverageClient.eligible || !redoCoverageClient.price || !cart?.cost) {
|
|
69
68
|
return null;
|
|
70
69
|
}
|
|
71
70
|
|
|
72
|
-
let currencyCode: CurrencyCode = cart.cost.
|
|
73
|
-
if(currencyCode === 'XXX') {
|
|
71
|
+
let currencyCode: CurrencyCode = cart.cost.subtotalAmount.currencyCode;
|
|
72
|
+
if (currencyCode === 'XXX') {
|
|
74
73
|
currencyCode = 'USD';
|
|
75
74
|
}
|
|
76
75
|
|
|
@@ -78,9 +77,9 @@ const applyButtonVariables = ({
|
|
|
78
77
|
const combinedPrice = new Intl.NumberFormat('en-US', {
|
|
79
78
|
style: 'currency',
|
|
80
79
|
currency: currencyCode
|
|
81
|
-
}).format(Number(cart.cost.
|
|
80
|
+
}).format(Number(cart.cost.subtotalAmount.amount) + (cartContainsRedo ? 0 : redoCoverageClient.price));
|
|
82
81
|
|
|
83
|
-
if(!combinedPrice || !combinedPrice.length || combinedPrice.includes('NaN')) {
|
|
82
|
+
if (!combinedPrice || !combinedPrice.length || combinedPrice.includes('NaN')) {
|
|
84
83
|
return null;
|
|
85
84
|
}
|
|
86
85
|
|
|
@@ -103,7 +102,7 @@ const findAncestor = (
|
|
|
103
102
|
};
|
|
104
103
|
|
|
105
104
|
const RedoCheckoutButtons = (props: {
|
|
106
|
-
cart: CartReturn | CartWithActionsDocs;
|
|
105
|
+
cart: CartReturn | CartWithActionsDocs | OptimisticCart;
|
|
107
106
|
children?: ReactNode;
|
|
108
107
|
onClick?: (enabled: boolean) => void;
|
|
109
108
|
}) => {
|
|
@@ -112,23 +111,54 @@ const RedoCheckoutButtons = (props: {
|
|
|
112
111
|
let checkoutUrl = redoCoverageClient.cart?.checkoutUrl || '/checkout';
|
|
113
112
|
let [redoProductToAdd, setRedoProductToAdd] =
|
|
114
113
|
useState<CartInfoToEnable | null>(null);
|
|
115
|
-
let [checkoutButtonsUI, setCheckoutButtonsUI] =
|
|
116
|
-
null
|
|
117
|
-
|
|
114
|
+
let [checkoutButtonsUI, setCheckoutButtonsUI] =
|
|
115
|
+
useState<CheckoutButtonUIResponse | null>(null);
|
|
116
|
+
|
|
117
|
+
const [buttonPending, setButtonPending] = useState(false);
|
|
118
118
|
|
|
119
119
|
useEffect(() => {
|
|
120
120
|
(async () => {
|
|
121
|
-
if(!redoCoverageClient.eligible || !cart || !redoCoverageClient.storeId) {
|
|
121
|
+
if (!redoCoverageClient.eligible || !cart || !redoCoverageClient.storeId) {
|
|
122
122
|
return;
|
|
123
123
|
}
|
|
124
124
|
|
|
125
125
|
const buttons = await getButtonsToShow({ redoCoverageClient, cart, storeId: redoCoverageClient.storeId });
|
|
126
|
-
if(buttons) {
|
|
126
|
+
if (buttons) {
|
|
127
127
|
setCheckoutButtonsUI(buttons);
|
|
128
128
|
}
|
|
129
129
|
})();
|
|
130
130
|
}, [cart, redoCoverageClient.eligible, redoCoverageClient.price, redoCoverageClient.storeId]);
|
|
131
131
|
|
|
132
|
+
/** To avoid the inevitable spammers trying to checkout faster by clicking over and over, between the time the promise resolves and the new tab opens (or errors) */
|
|
133
|
+
const DELAY_TO_ALLOW_CLICKING_AGAIN = 2000;
|
|
134
|
+
const TIMEOUT_FOR_CHECKOUTS = 8000;
|
|
135
|
+
|
|
136
|
+
const handleCoverageCheckoutClick = async (isCoverage: boolean) => {
|
|
137
|
+
if (!redoCoverageClient || !redoCoverageClient.enable || !redoCoverageClient.disable) {
|
|
138
|
+
console.error('Required redoCoverageClient methods not available');
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
setButtonPending(true);
|
|
143
|
+
try {
|
|
144
|
+
const functionToCall = isCoverage ? redoCoverageClient.enable : redoCoverageClient.disable;
|
|
145
|
+
const result = await executeWithTimeout(
|
|
146
|
+
functionToCall(),
|
|
147
|
+
TIMEOUT_FOR_CHECKOUTS
|
|
148
|
+
);
|
|
149
|
+
|
|
150
|
+
if (props.onClick) {
|
|
151
|
+
await props.onClick(result);
|
|
152
|
+
}
|
|
153
|
+
} catch (e) {
|
|
154
|
+
console.error(e);
|
|
155
|
+
} finally {
|
|
156
|
+
setTimeout(() => {
|
|
157
|
+
setButtonPending(false);
|
|
158
|
+
}, DELAY_TO_ALLOW_CLICKING_AGAIN);
|
|
159
|
+
}
|
|
160
|
+
};
|
|
161
|
+
|
|
132
162
|
const wrapperClickHandler = async (e: MouseEvent) => {
|
|
133
163
|
let clickedElement = e.target as HTMLElement;
|
|
134
164
|
|
|
@@ -136,26 +166,21 @@ const RedoCheckoutButtons = (props: {
|
|
|
136
166
|
return;
|
|
137
167
|
}
|
|
138
168
|
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
(
|
|
154
|
-
)
|
|
155
|
-
) {
|
|
156
|
-
await redoCoverageClient.disable();
|
|
157
|
-
if (props.onClick) {
|
|
158
|
-
await props.onClick(false);
|
|
169
|
+
const isCoverageButton = findAncestor(
|
|
170
|
+
clickedElement,
|
|
171
|
+
(el) => el.dataset?.target == "coverage-button"
|
|
172
|
+
);
|
|
173
|
+
|
|
174
|
+
const isNonCoverageButton = findAncestor(
|
|
175
|
+
clickedElement,
|
|
176
|
+
(el) => el.dataset?.target == "non-coverage-button",
|
|
177
|
+
);
|
|
178
|
+
|
|
179
|
+
if (isCoverageButton || isNonCoverageButton) {
|
|
180
|
+
try {
|
|
181
|
+
await handleCoverageCheckoutClick(isCoverageButton ? true : false);
|
|
182
|
+
} catch (error) {
|
|
183
|
+
console.error('Failed to update coverage state:', error);
|
|
159
184
|
}
|
|
160
185
|
window.location.href = checkoutUrl;
|
|
161
186
|
}
|
|
@@ -164,11 +189,32 @@ const RedoCheckoutButtons = (props: {
|
|
|
164
189
|
return (
|
|
165
190
|
<div>
|
|
166
191
|
{checkoutButtonsUI ? (
|
|
167
|
-
<div onClick={wrapperClickHandler}>
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
192
|
+
<div onClick={wrapperClickHandler} style={{ position: "relative" }}>
|
|
193
|
+
{checkoutButtonsUI.css ? <style>{checkoutButtonsUI.css}</style> : ''}
|
|
194
|
+
<div
|
|
195
|
+
dangerouslySetInnerHTML={{ __html: checkoutButtonsUI.html }}
|
|
196
|
+
style={{
|
|
197
|
+
opacity: (buttonPending) ? 0.25 : 1,
|
|
198
|
+
transition: 'opacity 0.2s ease-in-out'
|
|
199
|
+
}}
|
|
200
|
+
/>
|
|
201
|
+
{(buttonPending) && (
|
|
202
|
+
<div
|
|
203
|
+
style={{
|
|
204
|
+
position: "absolute",
|
|
205
|
+
top: 0,
|
|
206
|
+
left: 0,
|
|
207
|
+
width: "100%",
|
|
208
|
+
height: "100%",
|
|
209
|
+
display: "flex",
|
|
210
|
+
justifyContent: "center",
|
|
211
|
+
alignItems: "center",
|
|
212
|
+
zIndex: 1,
|
|
213
|
+
}}
|
|
214
|
+
>
|
|
215
|
+
<CircleSpinner />
|
|
216
|
+
</div>
|
|
217
|
+
)}
|
|
172
218
|
</div>
|
|
173
219
|
) : (
|
|
174
220
|
props.children
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import { useFetcher } from "@remix-run/react";
|
|
2
|
-
import { CartReturn } from "@shopify/hydrogen";
|
|
3
|
-
import { createContext, ReactNode, useContext, useEffect, useState } from "react";
|
|
2
|
+
import { CartReturn, OptimisticCart } from "@shopify/hydrogen";
|
|
3
|
+
import { createContext, ReactNode, useCallback, useContext, useEffect, useRef, useState } from "react";
|
|
4
4
|
import { CartProductVariantFragment, CartAttributeKey, CartInfoToEnable, RedoContextValue, RedoCoverageClient, RedoError, RedoErrorType } from "../types";
|
|
5
5
|
import { REDO_PUBLIC_API_HOSTNAME } from "../utils/security";
|
|
6
|
-
import { addProductToCartIfNeeded, removeProductFromCartIfNeeded, setCartRedoEnabledAttribute, useFetcherWithPromise, isCartWithActionsDocs, getCartLines } from "../utils/cart";
|
|
6
|
+
import { addProductToCartIfNeeded, removeProductFromCartIfNeeded, setCartRedoEnabledAttribute, useFetcherWithPromise, isCartWithActionsDocs, getCartLines, useWaitCartIdle, isOptimisticCart } from "../utils/cart";
|
|
7
7
|
import { CartWithActionsDocs } from "@shopify/hydrogen-react/dist/types/cart-types";
|
|
8
8
|
|
|
9
9
|
const DEFAULT_REDO_CONTEXT_VALUE: RedoContextValue = {
|
|
@@ -18,7 +18,7 @@ const RedoProvider = ({
|
|
|
18
18
|
storeId,
|
|
19
19
|
children
|
|
20
20
|
}: {
|
|
21
|
-
cart: CartReturn | CartWithActionsDocs,
|
|
21
|
+
cart: CartReturn | CartWithActionsDocs | OptimisticCart,
|
|
22
22
|
storeId: string,
|
|
23
23
|
children: ReactNode,
|
|
24
24
|
}): ReactNode => {
|
|
@@ -37,7 +37,7 @@ const RedoProvider = ({
|
|
|
37
37
|
}
|
|
38
38
|
|
|
39
39
|
useEffect(() => {
|
|
40
|
-
if(!cart || !storeId) {
|
|
40
|
+
if(!cart || !storeId || isOptimisticCart(cart)) {
|
|
41
41
|
return;
|
|
42
42
|
}
|
|
43
43
|
|
|
@@ -141,17 +141,19 @@ const RedoProvider = ({
|
|
|
141
141
|
const useRedoCoverageClient = (): RedoCoverageClient => {
|
|
142
142
|
const redoContext = useContext(RedoContext);
|
|
143
143
|
const fetcher = useFetcherWithPromise();
|
|
144
|
+
const waitCartIdle = useWaitCartIdle(redoContext.cart);
|
|
144
145
|
|
|
145
146
|
useEffect(() => {
|
|
146
147
|
if(redoContext.loading || !redoContext.cartInfoToEnable) {
|
|
147
148
|
return;
|
|
148
149
|
}
|
|
149
150
|
removeProductFromCartIfNeeded({
|
|
150
|
-
fetcher,
|
|
151
151
|
cart: redoContext.cart,
|
|
152
|
+
fetcher,
|
|
153
|
+
waitCartIdle,
|
|
152
154
|
cartInfoToEnable: redoContext.cartInfoToEnable
|
|
153
155
|
});
|
|
154
|
-
}, [redoContext.loading])
|
|
156
|
+
}, [redoContext.loading]);
|
|
155
157
|
|
|
156
158
|
return {
|
|
157
159
|
enable: async () => {
|
|
@@ -160,12 +162,14 @@ const useRedoCoverageClient = (): RedoCoverageClient => {
|
|
|
160
162
|
}
|
|
161
163
|
let addProductResult = await addProductToCartIfNeeded({
|
|
162
164
|
fetcher,
|
|
165
|
+
waitCartIdle,
|
|
163
166
|
cart: redoContext.cart,
|
|
164
167
|
cartInfoToEnable: redoContext.cartInfoToEnable,
|
|
165
168
|
});
|
|
166
169
|
await setCartRedoEnabledAttribute({
|
|
167
170
|
cart: redoContext.cart,
|
|
168
171
|
fetcher,
|
|
172
|
+
waitCartIdle,
|
|
169
173
|
cartInfoToEnable: redoContext.cartInfoToEnable,
|
|
170
174
|
enabled: true
|
|
171
175
|
});
|
|
@@ -177,12 +181,14 @@ const useRedoCoverageClient = (): RedoCoverageClient => {
|
|
|
177
181
|
}
|
|
178
182
|
await removeProductFromCartIfNeeded({
|
|
179
183
|
fetcher,
|
|
184
|
+
waitCartIdle,
|
|
180
185
|
cart: redoContext.cart,
|
|
181
186
|
cartInfoToEnable: redoContext.cartInfoToEnable
|
|
182
187
|
});
|
|
183
188
|
await setCartRedoEnabledAttribute({
|
|
184
189
|
cart: redoContext.cart,
|
|
185
190
|
fetcher,
|
|
191
|
+
waitCartIdle,
|
|
186
192
|
cartInfoToEnable: redoContext.cartInfoToEnable,
|
|
187
193
|
enabled: false
|
|
188
194
|
});
|
package/src/svg.d.ts
ADDED
package/src/types.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { CartReturn } from "@shopify/hydrogen";
|
|
1
|
+
import { CartReturn, OptimisticCart } from "@shopify/hydrogen";
|
|
2
2
|
import { CartWithActionsDocs } from "@shopify/hydrogen-react/dist/types/cart-types";
|
|
3
3
|
import { ProductVariant } from "@shopify/hydrogen-react/storefront-api-types";
|
|
4
4
|
|
|
@@ -16,7 +16,7 @@ interface RedoCoverageClient {
|
|
|
16
16
|
get eligible(): boolean;
|
|
17
17
|
get price(): number | undefined;
|
|
18
18
|
get storeId(): string | undefined;
|
|
19
|
-
get cart(): CartReturn | CartWithActionsDocs | undefined;
|
|
19
|
+
get cart(): CartReturn | CartWithActionsDocs | OptimisticCart | undefined;
|
|
20
20
|
get cartProduct(): CartProductVariantFragment | undefined;
|
|
21
21
|
get cartAttribute(): CartAttributeKey | undefined;
|
|
22
22
|
get errors(): RedoError[] | undefined;
|
|
@@ -34,7 +34,7 @@ type RedoContextValue = {
|
|
|
34
34
|
loading: boolean,
|
|
35
35
|
storeId?: string,
|
|
36
36
|
cartInfoToEnable?: CartInfoToEnable,
|
|
37
|
-
cart?: CartReturn | CartWithActionsDocs,
|
|
37
|
+
cart?: CartReturn | CartWithActionsDocs | OptimisticCart,
|
|
38
38
|
errors?: RedoError[],
|
|
39
39
|
};
|
|
40
40
|
|
package/src/utils/cart.ts
CHANGED
|
@@ -1,47 +1,84 @@
|
|
|
1
1
|
import { FetcherWithComponents, useFetcher } from "@remix-run/react";
|
|
2
2
|
import { CartInfoToEnable } from "../types";
|
|
3
|
-
import { CartForm, CartReturn } from "@shopify/hydrogen";
|
|
3
|
+
import { CartForm, CartReturn, OptimisticCart, OptimisticCartLine } from "@shopify/hydrogen";
|
|
4
4
|
import type { AppData } from '@remix-run/react/dist/data';
|
|
5
|
-
import React from 'react'
|
|
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
9
|
const DEFAULT_REDO_ENABLED_CART_ATTRIBUTE = 'redo_opted_in_from_cart';
|
|
10
10
|
|
|
11
|
-
const isCartWithActionsDocs = (cart: CartReturn | CartWithActionsDocs): cart is CartWithActionsDocs => {
|
|
11
|
+
const isCartWithActionsDocs = (cart: CartReturn | CartWithActionsDocs| OptimisticCart): cart is CartWithActionsDocs => {
|
|
12
12
|
return (Array.isArray(cart.lines) && 'linesAdd' in cart && typeof cart.linesAdd === 'function');
|
|
13
13
|
}
|
|
14
14
|
|
|
15
|
-
const getCartLines = (cart: CartReturn | CartWithActionsDocs): Array<CartLine | ComponentizableCartLine> => {
|
|
16
|
-
if(
|
|
15
|
+
const getCartLines = (cart: CartReturn | CartWithActionsDocs | OptimisticCart): Array<CartLine | ComponentizableCartLine> => {
|
|
16
|
+
if (isOptimisticCart(cart)) {
|
|
17
|
+
return cart.lines.nodes;
|
|
18
|
+
} else if (isCartWithActionsDocs(cart)) {
|
|
17
19
|
return cart.lines;
|
|
18
20
|
} else {
|
|
19
21
|
return cart.lines.nodes ?? cart.lines.edges.map((edge) => edge.node);
|
|
20
22
|
}
|
|
21
23
|
}
|
|
22
24
|
|
|
23
|
-
|
|
25
|
+
// https://shopify.dev/docs/api/hydrogen/2025-01/hooks/useoptimisticcart
|
|
26
|
+
const isOptimisticCart = (cart: CartReturn | CartWithActionsDocs | OptimisticCart): cart is OptimisticCart => {
|
|
27
|
+
return 'isOptimistic' in cart && (cart.isOptimistic ?? false);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const isRedoInCart = ({
|
|
31
|
+
cart
|
|
32
|
+
}: {
|
|
33
|
+
cart: CartReturn | CartWithActionsDocs | OptimisticCart
|
|
34
|
+
}): boolean => {
|
|
35
|
+
if(!cart) {
|
|
36
|
+
return false;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
return getCartLines(cart).some((cartLine) => {
|
|
40
|
+
return cartLine.merchandise.product.vendor === 're:do';
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const waitForConditionsMetOrTimeout = ({
|
|
45
|
+
conditions,
|
|
46
|
+
timeoutMs
|
|
47
|
+
}: {
|
|
48
|
+
conditions: (() => boolean)[];
|
|
49
|
+
timeoutMs: number;
|
|
50
|
+
}): Promise<boolean> => {
|
|
24
51
|
return new Promise((resolve, reject) => {
|
|
52
|
+
let start = Date.now();
|
|
25
53
|
let interval = setInterval(() => {
|
|
26
|
-
if(
|
|
54
|
+
if((Date.now() - start) > timeoutMs) {
|
|
55
|
+
clearInterval(interval);
|
|
56
|
+
return resolve(false);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
let conditionsMet = conditions.every((conditionCallback) => conditionCallback());
|
|
60
|
+
|
|
61
|
+
if(conditionsMet) {
|
|
27
62
|
clearInterval(interval);
|
|
28
|
-
return resolve();
|
|
63
|
+
return resolve(true);
|
|
29
64
|
}
|
|
30
65
|
}, 100);
|
|
31
|
-
})
|
|
66
|
+
})
|
|
32
67
|
}
|
|
33
68
|
|
|
34
69
|
const addProductToCartIfNeeded = async ({
|
|
35
70
|
cart,
|
|
36
71
|
fetcher,
|
|
72
|
+
waitCartIdle,
|
|
37
73
|
cartInfoToEnable
|
|
38
74
|
}: {
|
|
39
|
-
cart: CartReturn | CartWithActionsDocs | undefined,
|
|
75
|
+
cart: CartReturn | CartWithActionsDocs | OptimisticCart | undefined,
|
|
40
76
|
fetcher: FetcherWithComponents<unknown>,
|
|
77
|
+
waitCartIdle: WaitCartIdleCallback;
|
|
41
78
|
cartInfoToEnable: CartInfoToEnable
|
|
42
79
|
}) => {
|
|
43
80
|
if(!cart) {
|
|
44
|
-
return await addProductToCart({ cart, fetcher, cartInfoToEnable });
|
|
81
|
+
return await addProductToCart({ cart, fetcher, waitCartIdle, cartInfoToEnable });
|
|
45
82
|
}
|
|
46
83
|
|
|
47
84
|
const redoProductsInCart = getCartLines(cart).filter((cartLine) => {
|
|
@@ -51,15 +88,15 @@ const addProductToCartIfNeeded = async ({
|
|
|
51
88
|
return cartLine.merchandise.id === `gid://shopify/ProductVariant/${cartInfoToEnable.variantId}`;
|
|
52
89
|
});
|
|
53
90
|
if(redoProductsInCart.length === 0) {
|
|
54
|
-
return await addProductToCart({ cart, fetcher, cartInfoToEnable });
|
|
91
|
+
return await addProductToCart({ cart, fetcher, waitCartIdle, cartInfoToEnable });
|
|
55
92
|
} else if (redoProductsInCart.length === 1 && correctRedoProductInCart.length === 1 && correctRedoProductInCart[0].quantity === 1) {
|
|
56
93
|
// No action needed
|
|
57
94
|
return;
|
|
58
95
|
} else {
|
|
59
96
|
let isSuccess = true;
|
|
60
97
|
|
|
61
|
-
await removeLinesFromCart({ cart, fetcher, lineIds: redoProductsInCart.map((cartLine) => cartLine.id) });
|
|
62
|
-
await addProductToCart({ cart, fetcher, cartInfoToEnable });
|
|
98
|
+
await removeLinesFromCart({ cart, fetcher, waitCartIdle, lineIds: redoProductsInCart.map((cartLine) => cartLine.id) });
|
|
99
|
+
await addProductToCart({ cart, fetcher, waitCartIdle, cartInfoToEnable });
|
|
63
100
|
|
|
64
101
|
return;
|
|
65
102
|
}
|
|
@@ -68,10 +105,12 @@ const addProductToCartIfNeeded = async ({
|
|
|
68
105
|
const removeLinesFromCart = async ({
|
|
69
106
|
cart,
|
|
70
107
|
fetcher,
|
|
108
|
+
waitCartIdle,
|
|
71
109
|
lineIds
|
|
72
110
|
}: {
|
|
73
|
-
cart: CartReturn | CartWithActionsDocs | undefined;
|
|
111
|
+
cart: CartReturn | CartWithActionsDocs | OptimisticCart | undefined;
|
|
74
112
|
fetcher: FetcherWithComponents<unknown>;
|
|
113
|
+
waitCartIdle: WaitCartIdleCallback;
|
|
75
114
|
lineIds: string[];
|
|
76
115
|
}) => {
|
|
77
116
|
const formInput = {
|
|
@@ -83,7 +122,7 @@ const removeLinesFromCart = async ({
|
|
|
83
122
|
|
|
84
123
|
if(cart && isCartWithActionsDocs(cart)) {
|
|
85
124
|
cart.linesRemove(lineIds);
|
|
86
|
-
await
|
|
125
|
+
await waitCartIdle();
|
|
87
126
|
} else {
|
|
88
127
|
await fetcher.submit(
|
|
89
128
|
{
|
|
@@ -97,10 +136,12 @@ const removeLinesFromCart = async ({
|
|
|
97
136
|
const removeProductFromCartIfNeeded = async ({
|
|
98
137
|
cart,
|
|
99
138
|
fetcher,
|
|
139
|
+
waitCartIdle,
|
|
100
140
|
cartInfoToEnable
|
|
101
141
|
}: {
|
|
102
|
-
cart: CartReturn | CartWithActionsDocs | undefined,
|
|
142
|
+
cart: CartReturn | CartWithActionsDocs | OptimisticCart | undefined,
|
|
103
143
|
fetcher: FetcherWithComponents<unknown>,
|
|
144
|
+
waitCartIdle: WaitCartIdleCallback
|
|
104
145
|
cartInfoToEnable: CartInfoToEnable
|
|
105
146
|
}) => {
|
|
106
147
|
if(!cart) {
|
|
@@ -113,24 +154,25 @@ const removeProductFromCartIfNeeded = async ({
|
|
|
113
154
|
});
|
|
114
155
|
|
|
115
156
|
if(redoProductsInCart.length !== 0) {
|
|
116
|
-
await removeLinesFromCart({ cart, fetcher, lineIds: redoProductsInCart.map((cartLine) => cartLine.id) });
|
|
157
|
+
await removeLinesFromCart({ cart, fetcher, waitCartIdle, lineIds: redoProductsInCart.map((cartLine) => cartLine.id) });
|
|
117
158
|
} else {
|
|
118
159
|
}
|
|
119
160
|
};
|
|
120
161
|
|
|
121
162
|
const addProductToCart = async ({
|
|
163
|
+
waitCartIdle,
|
|
122
164
|
cart,
|
|
123
165
|
fetcher,
|
|
124
166
|
cartInfoToEnable,
|
|
125
167
|
}: {
|
|
126
|
-
|
|
168
|
+
waitCartIdle: WaitCartIdleCallback;
|
|
169
|
+
cart: CartReturn | CartWithActionsDocs | OptimisticCart | undefined,
|
|
127
170
|
fetcher: FetcherWithComponents<unknown>,
|
|
128
171
|
cartInfoToEnable: CartInfoToEnable
|
|
129
172
|
}) => {
|
|
130
173
|
const redoProductLine = {
|
|
131
174
|
"merchandiseId": `gid://shopify/ProductVariant/${cartInfoToEnable.variantId}`,
|
|
132
175
|
"quantity": 1,
|
|
133
|
-
"selectedVariant": cartInfoToEnable.selectedVariant
|
|
134
176
|
};
|
|
135
177
|
|
|
136
178
|
const formInput = {
|
|
@@ -144,7 +186,7 @@ const addProductToCart = async ({
|
|
|
144
186
|
|
|
145
187
|
if(cart && isCartWithActionsDocs(cart)) {
|
|
146
188
|
cart.linesAdd([redoProductLine]);
|
|
147
|
-
await
|
|
189
|
+
await waitCartIdle();
|
|
148
190
|
} else {
|
|
149
191
|
await fetcher.submit(
|
|
150
192
|
{
|
|
@@ -158,11 +200,13 @@ const addProductToCart = async ({
|
|
|
158
200
|
const setCartRedoEnabledAttribute = async ({
|
|
159
201
|
cart,
|
|
160
202
|
fetcher,
|
|
203
|
+
waitCartIdle,
|
|
161
204
|
cartInfoToEnable,
|
|
162
205
|
enabled
|
|
163
206
|
}: {
|
|
164
|
-
cart: CartReturn | CartWithActionsDocs | undefined;
|
|
207
|
+
cart: CartReturn | CartWithActionsDocs | OptimisticCart | undefined;
|
|
165
208
|
fetcher: FetcherWithComponents<unknown>;
|
|
209
|
+
waitCartIdle: WaitCartIdleCallback;
|
|
166
210
|
cartInfoToEnable: CartInfoToEnable | null;
|
|
167
211
|
enabled: boolean;
|
|
168
212
|
}) => {
|
|
@@ -182,7 +226,7 @@ const setCartRedoEnabledAttribute = async ({
|
|
|
182
226
|
|
|
183
227
|
if(cart && isCartWithActionsDocs(cart)) {
|
|
184
228
|
cart.cartAttributesUpdate([redoCartAttribute]);
|
|
185
|
-
await
|
|
229
|
+
await waitCartIdle();
|
|
186
230
|
} else {
|
|
187
231
|
await fetcher.submit(
|
|
188
232
|
{
|
|
@@ -233,12 +277,63 @@ function useFetcherWithPromise<TData = AppData>(opts?: Parameters<typeof useFetc
|
|
|
233
277
|
return { ...fetcher, submit }
|
|
234
278
|
}
|
|
235
279
|
|
|
280
|
+
type WaitCartIdleCallback = () => Promise<CartReturn | CartWithActionsDocs | OptimisticCart>;
|
|
281
|
+
|
|
282
|
+
// This function allows us to await a cart idle state without breaking React rules.
|
|
283
|
+
// It returns a function, which returns a promise, which will resolve once the cart value passed in reaches an idle state.
|
|
284
|
+
// Not intended for use with CartReturn, but will accept that value if passed in to avoid breaking rules of hooks
|
|
285
|
+
const useWaitCartIdle = (cart: CartReturn | CartWithActionsDocs | OptimisticCart | undefined) => {
|
|
286
|
+
const resolveRef = useRef<any>(null)
|
|
287
|
+
const promiseRef = useRef<any>(null)
|
|
288
|
+
|
|
289
|
+
if (!promiseRef.current) {
|
|
290
|
+
promiseRef.current = new Promise<CartReturn | CartWithActionsDocs | OptimisticCart>((resolve) => {
|
|
291
|
+
resolveRef.current = resolve
|
|
292
|
+
})
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
const resetResolver = useCallback(() => {
|
|
296
|
+
promiseRef.current = new Promise((resolve) => {
|
|
297
|
+
resolveRef.current = resolve
|
|
298
|
+
})
|
|
299
|
+
}, [promiseRef, resolveRef]);
|
|
300
|
+
|
|
301
|
+
const waitCartIdle = useCallback(
|
|
302
|
+
async () => {
|
|
303
|
+
return promiseRef.current
|
|
304
|
+
},
|
|
305
|
+
[cart, promiseRef]
|
|
306
|
+
)
|
|
307
|
+
|
|
308
|
+
useEffect(() => {
|
|
309
|
+
if(!cart) {
|
|
310
|
+
return;
|
|
311
|
+
}
|
|
312
|
+
if(!isCartWithActionsDocs(cart)) {
|
|
313
|
+
// Wrong type of cart. Just resolve.
|
|
314
|
+
resolveRef.current?.(cart);
|
|
315
|
+
resetResolver();
|
|
316
|
+
} else if(cart.status === 'idle') {
|
|
317
|
+
resolveRef.current?.(cart)
|
|
318
|
+
resetResolver();
|
|
319
|
+
}
|
|
320
|
+
}, [cart, resetResolver]);
|
|
321
|
+
|
|
322
|
+
return waitCartIdle;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
export type {
|
|
326
|
+
WaitCartIdleCallback
|
|
327
|
+
}
|
|
328
|
+
|
|
236
329
|
export {
|
|
237
330
|
DEFAULT_REDO_ENABLED_CART_ATTRIBUTE,
|
|
238
331
|
addProductToCartIfNeeded,
|
|
239
332
|
removeProductFromCartIfNeeded,
|
|
240
333
|
setCartRedoEnabledAttribute,
|
|
241
334
|
useFetcherWithPromise,
|
|
335
|
+
useWaitCartIdle,
|
|
242
336
|
isCartWithActionsDocs,
|
|
243
|
-
getCartLines
|
|
337
|
+
getCartLines,
|
|
338
|
+
isOptimisticCart
|
|
244
339
|
};
|