UNPKG

@redotech/redo-hydrogen

Version:

Utilities to enable and disable Redo coverage on Hydrogen stores

352 lines (308 loc) 10.3 kB
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, };