@redotech/redo-hydrogen 1.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.
@@ -0,0 +1,157 @@
1
+ import { useFetcher } from "@remix-run/react";
2
+ import { CartReturn } from "@shopify/hydrogen";
3
+ import { createContext, ReactNode, useContext, useEffect, useState } from "react";
4
+ import { CartProductVariantFragment, CartAttributeKey, CartInfoToEnable, RedoContextValue, RedoCoverageClient } from "../types";
5
+ import { REDO_PUBLIC_API_HOSTNAME_LOCAL } from "../utils/security";
6
+ import { addProductToCartIfNeeded, removeProductFromCartIfNeeded, setCartRedoEnabledAttribute, useFetcherWithPromise } from "../utils/cart";
7
+
8
+ const DEFAULT_REDO_CONTEXT_VALUE: RedoContextValue = {
9
+ enabled: false,
10
+ loading: true,
11
+ }
12
+
13
+ const RedoContext = createContext(DEFAULT_REDO_CONTEXT_VALUE);
14
+
15
+ const RedoProvider = ({
16
+ cart,
17
+ storeId,
18
+ children
19
+ }: {
20
+ cart: CartReturn,
21
+ storeId: string,
22
+ children: ReactNode,
23
+ }): ReactNode => {
24
+ const [cartProduct, setCartProduct] = useState();
25
+ const [cartAttribute, setCartAttribute] = useState<CartAttributeKey>();
26
+ const [cartInfoToEnable, setCartInfoToEnable] = useState<CartInfoToEnable>();
27
+ const [loading, setLoading] = useState<boolean>(true);
28
+
29
+ useEffect(() => {
30
+ fetch(`http://${REDO_PUBLIC_API_HOSTNAME_LOCAL}/v2.2/stores/${storeId}/coverage-products`, {
31
+ method: 'POST',
32
+ headers: {
33
+ "Content-Type": "application/json"
34
+ },
35
+ body: JSON.stringify({
36
+ cart: {
37
+ lineItems: cart.lines.nodes.map((cartLine) => ({
38
+ id: cartLine.id,
39
+ originalPrice: {
40
+ amount: cartLine.merchandise?.price?.amount,
41
+ currency: cartLine.merchandise?.price?.currencyCode
42
+ },
43
+ priceTotal: {
44
+ amount: cartLine.cost?.totalAmount?.amount,
45
+ currency: cartLine.cost?.totalAmount?.currencyCode
46
+ },
47
+ product: {
48
+ id: cartLine.merchandise?.product?.id
49
+ },
50
+ variant: {
51
+ id: cartLine.merchandise?.id
52
+ },
53
+ quantity: cartLine.quantity,
54
+ })),
55
+ priceTotal: {
56
+ amount: cart.cost?.totalAmount?.amount,
57
+ currency: cart.cost?.totalAmount?.currencyCode
58
+ },
59
+ },
60
+ customer: {
61
+ id: cart.buyerIdentity?.customer?.id || '',
62
+ country: cart.buyerIdentity?.countryCode
63
+ }
64
+ })
65
+ })
66
+ .then(async (res) => {
67
+ let json = await res.json();
68
+
69
+ setLoading(false);
70
+
71
+ setCartInfoToEnable(json.cartInfoToEnable);
72
+ // setCartInfoToEnable(json.coverageProducts[0].cartInfoToEnable);
73
+ })
74
+ }, [cart]);
75
+
76
+ const contextVal: RedoContextValue = {
77
+ enabled: true,
78
+ loading,
79
+ storeId,
80
+ cartInfoToEnable,
81
+ cart,
82
+ };
83
+
84
+ return (
85
+ <RedoContext.Provider value={contextVal}>
86
+ {children}
87
+ </RedoContext.Provider>
88
+ );
89
+ };
90
+
91
+ const useRedoCoverageClient = (): RedoCoverageClient => {
92
+ const redoContext = useContext(RedoContext);
93
+ const fetcher = useFetcherWithPromise();
94
+
95
+ useEffect(() => {
96
+ if(redoContext.loading || !redoContext.cartInfoToEnable) {
97
+ return;
98
+ }
99
+ removeProductFromCartIfNeeded({
100
+ fetcher,
101
+ cart: redoContext.cart,
102
+ cartInfoToEnable: redoContext.cartInfoToEnable
103
+ });
104
+ }, [redoContext.loading])
105
+
106
+ return {
107
+ enable: async () => {
108
+ if(redoContext.loading || !redoContext.cartInfoToEnable) {
109
+ return false;
110
+ }
111
+ let addProductResult = await addProductToCartIfNeeded({
112
+ fetcher,
113
+ cart: redoContext.cart,
114
+ cartInfoToEnable: redoContext.cartInfoToEnable,
115
+ });
116
+ await setCartRedoEnabledAttribute({
117
+ fetcher,
118
+ cartInfoToEnable: redoContext.cartInfoToEnable,
119
+ enabled: true
120
+ });
121
+ return true;
122
+ },
123
+ disable: async () => {
124
+ if(!redoContext.cartInfoToEnable) {
125
+ return false;
126
+ }
127
+ await removeProductFromCartIfNeeded({
128
+ fetcher,
129
+ cart: redoContext.cart,
130
+ cartInfoToEnable: redoContext.cartInfoToEnable
131
+ });
132
+ await setCartRedoEnabledAttribute({
133
+ fetcher,
134
+ cartInfoToEnable: redoContext.cartInfoToEnable,
135
+ enabled: false
136
+ });
137
+ return true;
138
+ },
139
+ get enabled() {
140
+ return redoContext.enabled;
141
+ },
142
+ get price() {
143
+ return Number(redoContext.cartInfoToEnable?.selectedVariant.price.amount);
144
+ },
145
+ get cartProduct() {
146
+ return redoContext.cartInfoToEnable?.selectedVariant;
147
+ },
148
+ get cartAttribute() {
149
+ return redoContext.cartInfoToEnable?.cartAttribute
150
+ }
151
+ }
152
+ };
153
+
154
+ export {
155
+ RedoProvider,
156
+ useRedoCoverageClient
157
+ }
package/src/types.ts ADDED
@@ -0,0 +1,40 @@
1
+ import { CartReturn } from "@shopify/hydrogen";
2
+ import { ProductVariant } from "@shopify/hydrogen-react/storefront-api-types";
3
+
4
+ type CartProductVariantFragment = Omit<ProductVariant,
5
+ "components" | "metafields" | "quantityPriceBreaks" | "quantityRule" | "requiresComponents" | "requiresShipping" | "storeAvailability" | "taxable" | "weightUnit"
6
+ >;
7
+
8
+ type CartAttributeKey = string;
9
+
10
+ interface RedoCoverageClient {
11
+ enable(): Promise<boolean>;
12
+ disable(): Promise<boolean>;
13
+ get enabled(): boolean;
14
+ get price(): number;
15
+ get cartProduct(): CartProductVariantFragment | undefined
16
+ get cartAttribute(): CartAttributeKey | undefined
17
+ }
18
+
19
+ type CartInfoToEnable = {
20
+ productId: string,
21
+ variantId: string,
22
+ cartAttribute: CartAttributeKey,
23
+ selectedVariant: CartProductVariantFragment
24
+ }
25
+
26
+ type RedoContextValue = {
27
+ enabled: boolean,
28
+ loading: boolean,
29
+ storeId?: string,
30
+ cartInfoToEnable?: CartInfoToEnable,
31
+ cart?: CartReturn
32
+ };
33
+
34
+ export type {
35
+ CartAttributeKey,
36
+ CartInfoToEnable,
37
+ RedoContextValue,
38
+ RedoCoverageClient,
39
+ CartProductVariantFragment
40
+ }
@@ -0,0 +1,195 @@
1
+ import { FetcherWithComponents, useFetcher } from "@remix-run/react";
2
+ import { CartInfoToEnable } from "../types";
3
+ import { CartForm, CartReturn } from "@shopify/hydrogen";
4
+ import { CartLine } from "@shopify/hydrogen-react/storefront-api-types";
5
+ import type { AppData } from '@remix-run/react/dist/data';
6
+ import React from 'react'
7
+
8
+ const DEFAULT_REDO_ENABLED_CART_ATTRIBUTE = 'redo_opted_in_from_cart';
9
+
10
+ const addProductToCartIfNeeded = async ({
11
+ cart,
12
+ fetcher,
13
+ cartInfoToEnable
14
+ }: {
15
+ cart: CartReturn | undefined,
16
+ fetcher: FetcherWithComponents<unknown>,
17
+ cartInfoToEnable: CartInfoToEnable
18
+ }) => {
19
+ if(!cart) {
20
+ return await addProductToCart({ fetcher, cartInfoToEnable });
21
+ }
22
+
23
+ const redoProductsInCart = cart.lines.nodes.filter((cartLine) => {
24
+ return cartLine.merchandise.product.vendor === 're:do';
25
+ });
26
+ const correctRedoProductInCart = redoProductsInCart?.filter((cartLine) => {
27
+ return cartLine.merchandise.id === `gid://shopify/ProductVariant/${cartInfoToEnable.variantId}`;
28
+ });
29
+ if(redoProductsInCart.length === 0) {
30
+ return await addProductToCart({ fetcher, cartInfoToEnable });
31
+ } else if (redoProductsInCart.length === 1 && correctRedoProductInCart.length === 1 && correctRedoProductInCart[0].quantity === 1) {
32
+ // No action needed
33
+ return;
34
+ } else {
35
+ let isSuccess = true;
36
+
37
+ await removeLinesFromCart({ fetcher, lineIds: redoProductsInCart.map((cartLine) => cartLine.id) });
38
+ await addProductToCart({ fetcher, cartInfoToEnable });
39
+
40
+ return;
41
+ }
42
+ };
43
+
44
+ const removeLinesFromCart = async ({
45
+ fetcher,
46
+ lineIds
47
+ }: {
48
+ fetcher: FetcherWithComponents<unknown>,
49
+ lineIds: string[]
50
+ }) => {
51
+ const formInput = {
52
+ action: CartForm.ACTIONS.LinesRemove,
53
+ inputs: {
54
+ lineIds
55
+ }
56
+ }
57
+
58
+ await fetcher.submit(
59
+ {
60
+ [CartForm.INPUT_NAME]: JSON.stringify(formInput),
61
+ },
62
+ {method: 'POST', action: '/cart'},
63
+ );
64
+ };
65
+
66
+ const removeProductFromCartIfNeeded = async ({
67
+ cart,
68
+ fetcher,
69
+ cartInfoToEnable
70
+ }: {
71
+ cart: CartReturn | undefined,
72
+ fetcher: FetcherWithComponents<unknown>,
73
+ cartInfoToEnable: CartInfoToEnable
74
+ }) => {
75
+ if(!cart) {
76
+ console.error('No cart');
77
+ return;
78
+ }
79
+
80
+ const redoProductsInCart = cart.lines.nodes.filter((cartLine) => {
81
+ return cartLine.merchandise.product.vendor === 're:do';
82
+ });
83
+
84
+ if(redoProductsInCart.length !== 0) {
85
+ await removeLinesFromCart({ fetcher, lineIds: redoProductsInCart.map((cartLine) => cartLine.id) });
86
+ } else {
87
+ }
88
+ };
89
+
90
+ const addProductToCart = async ({
91
+ fetcher,
92
+ cartInfoToEnable
93
+ }: {
94
+ fetcher: FetcherWithComponents<unknown>,
95
+ cartInfoToEnable: CartInfoToEnable
96
+ }) => {
97
+ const redoProductLine = {
98
+ "merchandiseId": `gid://shopify/ProductVariant/${cartInfoToEnable.variantId}`,
99
+ "quantity": 1,
100
+ "selectedVariant": cartInfoToEnable.selectedVariant
101
+ };
102
+
103
+ const formInput = {
104
+ action: CartForm.ACTIONS.LinesAdd,
105
+ inputs: {
106
+ lines: [
107
+ redoProductLine
108
+ ]
109
+ }
110
+ }
111
+
112
+ await fetcher.submit(
113
+ {
114
+ [CartForm.INPUT_NAME]: JSON.stringify(formInput),
115
+ },
116
+ {method: 'POST', action: '/cart'},
117
+ );
118
+ };
119
+
120
+ const setCartRedoEnabledAttribute = async ({
121
+ fetcher,
122
+ cartInfoToEnable,
123
+ enabled
124
+ }: {
125
+ fetcher: FetcherWithComponents<unknown>,
126
+ cartInfoToEnable: CartInfoToEnable | null,
127
+ enabled: boolean
128
+ }) => {
129
+ const formInput = {
130
+ action: CartForm.ACTIONS.AttributesUpdateInput,
131
+ inputs: {
132
+ attributes: [
133
+ {
134
+ key: cartInfoToEnable?.cartAttribute || DEFAULT_REDO_ENABLED_CART_ATTRIBUTE,
135
+ value: enabled.toString()
136
+ }
137
+ ]
138
+ }
139
+ }
140
+
141
+ await fetcher.submit(
142
+ {
143
+ [CartForm.INPUT_NAME]: JSON.stringify(formInput),
144
+ },
145
+ {method: 'POST', action: '/cart'},
146
+ );
147
+ };
148
+
149
+ type FetcherData<T> = NonNullable<T | unknown> // FIXME: used to use SerializeFrom which is deprecated. Can this be better typed?
150
+ type ResolveFunction<T> = (value: FetcherData<T>) => void
151
+
152
+ function useFetcherWithPromise<TData = AppData>(opts?: Parameters<typeof useFetcher>[0]) {
153
+ const fetcher = useFetcher<TData>(opts)
154
+ const resolveRef = React.useRef<ResolveFunction<TData>>(null)
155
+ const promiseRef = React.useRef<Promise<FetcherData<TData>>>(null)
156
+
157
+ if (!promiseRef.current) {
158
+ promiseRef.current = new Promise<FetcherData<TData>>((resolve) => {
159
+ resolveRef.current = resolve
160
+ })
161
+ }
162
+
163
+ const resetResolver = React.useCallback(() => {
164
+ promiseRef.current = new Promise((resolve) => {
165
+ resolveRef.current = resolve
166
+ })
167
+ }, [promiseRef, resolveRef])
168
+
169
+ const submit = React.useCallback(
170
+ async (...args: Parameters<typeof fetcher.submit>) => {
171
+ fetcher.submit(...args);
172
+ return promiseRef.current
173
+ },
174
+ [fetcher, promiseRef]
175
+ )
176
+
177
+ React.useEffect(() => {
178
+ if (fetcher.state === 'idle') {
179
+ if (fetcher.data) {
180
+ resolveRef.current?.(fetcher.data)
181
+ }
182
+ resetResolver()
183
+ }
184
+ }, [fetcher, resetResolver])
185
+
186
+ return { ...fetcher, submit }
187
+ }
188
+
189
+ export {
190
+ DEFAULT_REDO_ENABLED_CART_ATTRIBUTE,
191
+ addProductToCartIfNeeded,
192
+ removeProductFromCartIfNeeded,
193
+ setCartRedoEnabledAttribute,
194
+ useFetcherWithPromise
195
+ };
@@ -0,0 +1,50 @@
1
+ import {
2
+ DependencyList,
3
+ useCallback,
4
+ useEffect,
5
+ useRef,
6
+ useState,
7
+ } from "react";
8
+
9
+ export interface Loader<T> {
10
+ (abort: AbortSignal): Promise<T>;
11
+ }
12
+
13
+ export interface LoadState<T> {
14
+ error?: any;
15
+ pending: boolean;
16
+ value?: T;
17
+ }
18
+
19
+ export function useLoad<T>(fn: Loader<T>, deps: DependencyList): LoadState<T> {
20
+ const [state, setState] = useState<LoadState<T>>({ pending: false });
21
+
22
+ useEffect(() => {
23
+ const abortController = new AbortController();
24
+ setState((state) => ({ ...state, pending: true }));
25
+ fn(abortController.signal).then(
26
+ (value) => setState({ pending: false, value }),
27
+ (error) => {
28
+ if (
29
+ !(
30
+ error.message.includes("Request aborted for RPC method") ||
31
+ error.code === "ERR_CANCELED" ||
32
+ error.message === "Another request is in flight"
33
+ )
34
+ ) {
35
+ setState({ pending: false, error });
36
+ }
37
+ },
38
+ );
39
+ return () => {
40
+ abortController.abort();
41
+ setState((state) => ({ ...state, pending: false }));
42
+ };
43
+ // The way useLoad() is designed, we have no choice but to trust that the user gave us the correct deps for fn().
44
+ // We could fix this by marking useLoad() as a custom hook, and then exhaustive-deps would enforce that for us.
45
+ // https://www.npmjs.com/package/eslint-plugin-react-hooks#advanced-configuration
46
+ // eslint-disable-next-line react-hooks/exhaustive-deps
47
+ }, deps);
48
+
49
+ return state;
50
+ }
@@ -0,0 +1,13 @@
1
+ const REDO_PUBLIC_API_HOSTNAME = 'api.getredo.com';
2
+ const REDO_PUBLIC_API_HOSTNAME_LOCAL = 'localhost:8001';
3
+
4
+ const REDO_REQUIRED_HOSTNAMES = [
5
+ REDO_PUBLIC_API_HOSTNAME,
6
+ REDO_PUBLIC_API_HOSTNAME_LOCAL
7
+ ];
8
+
9
+ export {
10
+ REDO_REQUIRED_HOSTNAMES,
11
+ REDO_PUBLIC_API_HOSTNAME,
12
+ REDO_PUBLIC_API_HOSTNAME_LOCAL,
13
+ };
package/tsconfig.json ADDED
@@ -0,0 +1,20 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "es5",
4
+ "lib": ["dom", "dom.iterable", "esnext"],
5
+ "allowJs": true,
6
+ "skipLibCheck": true,
7
+ "esModuleInterop": true,
8
+ "allowSyntheticDefaultImports": true,
9
+ "strict": true,
10
+ "forceConsistentCasingInFileNames": true,
11
+ "noFallthroughCasesInSwitch": true,
12
+ "module": "esnext",
13
+ "moduleResolution": "node",
14
+ "resolveJsonModule": true,
15
+ "isolatedModules": true,
16
+ "noEmit": true,
17
+ "jsx": "react-jsx"
18
+ },
19
+ "include": ["src", "src/index.ts"]
20
+ }