@redotech/redo-hydrogen
Version:
Utilities to enable and disable Redo coverage on Hydrogen stores
352 lines (308 loc) • 10.3 kB
text/typescript
import { FetcherWithComponents, useFetcher } from "react-router";
import { CartInfoToEnable } from "../types";
import { CartForm, CartReturn, OptimisticCart } from "@shopify/hydrogen";
import React, { useCallback, useEffect, useRef } from "react";
import { CartWithActionsDocs } from "@shopify/hydrogen-react/dist/types/cart-types";
import { CartLine, ComponentizableCartLine } from "@shopify/hydrogen-react/storefront-api-types";
const DEFAULT_REDO_ENABLED_CART_ATTRIBUTE = "redo_opted_in_from_cart";
const CONCIERGE_ATTRIBUTION_CART_ATTRIBUTE_KEY = "redo.conciergeAssisted";
const CONCIERGE_CONVERSATION_IDS_STORAGE_KEY = "redoConciergeConversationIds";
const isCartWithActionsDocs = (
cart: CartReturn | CartWithActionsDocs | OptimisticCart,
): cart is CartWithActionsDocs => {
return Array.isArray(cart.lines) && "linesAdd" in cart && typeof cart.linesAdd === "function";
};
const getCartLines = (
cart: CartReturn | CartWithActionsDocs | OptimisticCart,
): Array<CartLine | ComponentizableCartLine> => {
if (isOptimisticCart(cart)) {
return cart.lines.nodes;
} else if (isCartWithActionsDocs(cart)) {
return cart.lines;
} else {
return cart.lines.nodes ?? cart.lines.edges.map((edge) => edge.node);
}
};
// https://shopify.dev/docs/api/hydrogen/2025-01/hooks/useoptimisticcart
const isOptimisticCart = (cart: CartReturn | CartWithActionsDocs | OptimisticCart): cart is OptimisticCart => {
return "isOptimistic" in cart && (cart.isOptimistic ?? false);
};
const addProductToCartIfNeeded = async ({
cart,
fetcher,
waitCartIdle,
cartInfoToEnable,
}: {
cart: CartReturn | CartWithActionsDocs | OptimisticCart | undefined;
fetcher: FetcherWithComponents<unknown>;
waitCartIdle: WaitCartIdleCallback;
cartInfoToEnable: CartInfoToEnable;
}) => {
if (!cart) {
return await addProductToCart({ cart, fetcher, waitCartIdle, cartInfoToEnable });
}
const redoProductsInCart = getCartLines(cart).filter((cartLine) => {
return cartLine.merchandise.product.vendor === "re:do";
});
const correctRedoProductInCart = redoProductsInCart?.filter((cartLine) => {
return cartLine.merchandise.id === `gid://shopify/ProductVariant/${cartInfoToEnable.variantId}`;
});
if (redoProductsInCart.length === 0) {
return await addProductToCart({ cart, fetcher, waitCartIdle, cartInfoToEnable });
} else if (
redoProductsInCart.length === 1 &&
correctRedoProductInCart.length === 1 &&
correctRedoProductInCart[0].quantity === 1
) {
// No action needed
return;
} else {
await removeLinesFromCart({
cart,
fetcher,
waitCartIdle,
lineIds: redoProductsInCart.map((cartLine) => cartLine.id),
});
await addProductToCart({ cart, fetcher, waitCartIdle, cartInfoToEnable });
}
};
const removeLinesFromCart = async ({
cart,
fetcher,
waitCartIdle,
lineIds,
}: {
cart: CartReturn | CartWithActionsDocs | OptimisticCart | undefined;
fetcher: FetcherWithComponents<unknown>;
waitCartIdle: WaitCartIdleCallback;
lineIds: string[];
}) => {
const formInput = {
action: CartForm.ACTIONS.LinesRemove,
inputs: {
lineIds,
},
};
if (cart && isCartWithActionsDocs(cart)) {
cart.linesRemove(lineIds);
await waitCartIdle();
} else {
await fetcher.submit(
{
[CartForm.INPUT_NAME]: JSON.stringify(formInput),
},
{ method: "POST", action: "/cart" },
);
}
};
const removeProductFromCartIfNeeded = async ({
cart,
fetcher,
waitCartIdle,
}: {
cart: CartReturn | CartWithActionsDocs | OptimisticCart | undefined;
fetcher: FetcherWithComponents<unknown>;
waitCartIdle: WaitCartIdleCallback;
cartInfoToEnable: CartInfoToEnable;
}) => {
if (!cart) {
console.error("No cart");
return;
}
const redoProductsInCart = getCartLines(cart).filter((cartLine) => {
return cartLine.merchandise.product.vendor === "re:do";
});
if (redoProductsInCart.length !== 0) {
await removeLinesFromCart({
cart,
fetcher,
waitCartIdle,
lineIds: redoProductsInCart.map((cartLine) => cartLine.id),
});
}
};
const addProductToCart = async ({
waitCartIdle,
cart,
fetcher,
cartInfoToEnable,
}: {
waitCartIdle: WaitCartIdleCallback;
cart: CartReturn | CartWithActionsDocs | OptimisticCart | undefined;
fetcher: FetcherWithComponents<unknown>;
cartInfoToEnable: CartInfoToEnable;
}) => {
const redoProductLine = {
merchandiseId: `gid://shopify/ProductVariant/${cartInfoToEnable.variantId}`,
quantity: 1,
};
const formInput = {
action: CartForm.ACTIONS.LinesAdd,
inputs: {
lines: [redoProductLine],
},
};
if (cart && isCartWithActionsDocs(cart)) {
cart.linesAdd([redoProductLine]);
await waitCartIdle();
} else {
await fetcher.submit(
{
[CartForm.INPUT_NAME]: JSON.stringify(formInput),
},
{ method: "POST", action: "/cart" },
);
}
};
interface ConversationIdWithExpiry {
conversationId: string;
expiresAt: number;
}
function getConciergeConversationIdsFromStorage(): string[] | null {
try {
const stored = localStorage.getItem(CONCIERGE_CONVERSATION_IDS_STORAGE_KEY);
if (!stored) {
return null;
}
const conversationIdsWithExpiry: ConversationIdWithExpiry[] = JSON.parse(stored);
const now = Date.now();
const validConversationIds = conversationIdsWithExpiry
.filter((item) => item.expiresAt > now)
.map((item) => item.conversationId);
return validConversationIds.length > 0 ? validConversationIds : null;
} catch (_error) {
return null;
}
}
const setCartRedoEnabledAttribute = async ({
cart,
fetcher,
waitCartIdle,
cartInfoToEnable,
enabled,
}: {
cart: CartReturn | CartWithActionsDocs | OptimisticCart | undefined;
fetcher: FetcherWithComponents<unknown>;
waitCartIdle: WaitCartIdleCallback;
cartInfoToEnable: CartInfoToEnable | null;
enabled: boolean;
}) => {
const redoCartAttribute = {
key: cartInfoToEnable?.cartAttribute || DEFAULT_REDO_ENABLED_CART_ATTRIBUTE,
value: enabled.toString(),
};
const existingAttributes = cart?.attributes || [];
const existingAttributesMap = new Map(existingAttributes.map((attr) => [attr.key, attr.value]));
existingAttributesMap.set(redoCartAttribute.key, redoCartAttribute.value);
const conciergeConversationIds = getConciergeConversationIdsFromStorage();
if (conciergeConversationIds && conciergeConversationIds.length > 0) {
existingAttributesMap.set(
CONCIERGE_ATTRIBUTION_CART_ATTRIBUTE_KEY,
JSON.stringify({
conciergeConversationIds: conciergeConversationIds,
}),
);
}
const updatedAttributes = Array.from(existingAttributesMap.entries()).map(([key, value]) => ({
key,
value: value ?? "",
}));
const formInput = {
action: CartForm.ACTIONS.AttributesUpdateInput,
inputs: {
attributes: updatedAttributes,
},
};
if (cart && isCartWithActionsDocs(cart)) {
cart.cartAttributesUpdate(updatedAttributes);
await waitCartIdle();
} else {
await fetcher.submit(
{
[CartForm.INPUT_NAME]: JSON.stringify(formInput),
},
{ method: "POST", action: "/cart" },
);
}
};
type FetcherData<T> = NonNullable<T | unknown>; // FIXME: used to use SerializeFrom which is deprecated. Can this be better typed?
type ResolveFunction<T> = (value: FetcherData<T>) => void;
function useFetcherWithPromise<TData = unknown>(opts?: Parameters<typeof useFetcher>[0]) {
const fetcher = useFetcher<TData>(opts);
const resolveRef = React.useRef<ResolveFunction<TData> | null>(null);
const promiseRef = React.useRef<Promise<FetcherData<TData>> | null>(null);
if (!promiseRef.current) {
promiseRef.current = new Promise<FetcherData<TData>>((resolve) => {
resolveRef.current = resolve;
});
}
const resetResolver = React.useCallback(() => {
promiseRef.current = new Promise((resolve) => {
resolveRef.current = resolve;
});
}, [promiseRef, resolveRef]);
const submit = React.useCallback(
async (...args: Parameters<typeof fetcher.submit>): Promise<void> => {
fetcher.submit(...args);
await promiseRef.current;
},
[fetcher, promiseRef],
);
React.useEffect(() => {
if (fetcher.state === "idle") {
if (fetcher.data) {
resolveRef.current?.(fetcher.data);
}
resetResolver();
}
}, [fetcher, resetResolver]);
return { ...fetcher, submit };
}
type WaitCartIdleCallback = () => Promise<CartReturn | CartWithActionsDocs | OptimisticCart>;
// This function allows us to await a cart idle state without breaking React rules.
// It returns a function, which returns a promise, which will resolve once the cart value passed in reaches an idle state.
// Not intended for use with CartReturn, but will accept that value if passed in to avoid breaking rules of hooks
type CartUnion = CartReturn | CartWithActionsDocs | OptimisticCart;
const useWaitCartIdle = (cart: CartUnion | undefined) => {
const resolveRef = useRef<((value: CartUnion) => void) | null>(null);
const promiseRef = useRef<Promise<CartUnion>>(null!);
if (!promiseRef.current) {
promiseRef.current = new Promise<CartReturn | CartWithActionsDocs | OptimisticCart>((resolve) => {
resolveRef.current = resolve;
});
}
const resetResolver = useCallback(() => {
promiseRef.current = new Promise((resolve) => {
resolveRef.current = resolve;
});
}, [promiseRef, resolveRef]);
const waitCartIdle = useCallback(async () => {
return promiseRef.current;
}, [cart, promiseRef]);
useEffect(() => {
if (!cart) {
return;
}
if (!isCartWithActionsDocs(cart)) {
// Wrong type of cart. Just resolve.
resolveRef.current?.(cart);
resetResolver();
} else if (cart.status === "idle") {
resolveRef.current?.(cart);
resetResolver();
}
}, [cart, resetResolver]);
return waitCartIdle;
};
export type { WaitCartIdleCallback };
export {
DEFAULT_REDO_ENABLED_CART_ATTRIBUTE,
addProductToCartIfNeeded,
removeProductFromCartIfNeeded,
setCartRedoEnabledAttribute,
useFetcherWithPromise,
useWaitCartIdle,
isCartWithActionsDocs,
getCartLines,
isOptimisticCart,
};