UNPKG

@redotech/redo-hydrogen

Version:

Utilities to enable and disable Redo coverage on Hydrogen stores

339 lines (299 loc) 9.55 kB
import { FetcherWithComponents, useFetcher } from "@remix-run/react"; import { CartInfoToEnable } from "../types"; import { CartForm, CartReturn, OptimisticCart, OptimisticCartLine } from "@shopify/hydrogen"; import type { AppData } from '@remix-run/react/dist/data'; 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 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 isRedoInCart = ({ cart }: { cart: CartReturn | CartWithActionsDocs | OptimisticCart }): boolean => { if(!cart) { return false; } return getCartLines(cart).some((cartLine) => { return cartLine.merchandise.product.vendor === 're:do'; }); } const waitForConditionsMetOrTimeout = ({ conditions, timeoutMs }: { conditions: (() => boolean)[]; timeoutMs: number; }): Promise<boolean> => { return new Promise((resolve, reject) => { let start = Date.now(); let interval = setInterval(() => { if((Date.now() - start) > timeoutMs) { clearInterval(interval); return resolve(false); } let conditionsMet = conditions.every((conditionCallback) => conditionCallback()); if(conditionsMet) { clearInterval(interval); return resolve(true); } }, 100); }) } 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 { let isSuccess = true; await removeLinesFromCart({ cart, fetcher, waitCartIdle, lineIds: redoProductsInCart.map((cartLine) => cartLine.id) }); await addProductToCart({ cart, fetcher, waitCartIdle, cartInfoToEnable }); return; } }; 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, cartInfoToEnable }: { 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) }); } else { } }; 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'}, ); } }; 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 formInput = { action: CartForm.ACTIONS.AttributesUpdateInput, inputs: { attributes: [ redoCartAttribute ] } } if(cart && isCartWithActionsDocs(cart)) { cart.cartAttributesUpdate([redoCartAttribute]); 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 = AppData>(opts?: Parameters<typeof useFetcher>[0]) { const fetcher = useFetcher<TData>(opts) const resolveRef = React.useRef<ResolveFunction<TData>>(null) const promiseRef = React.useRef<Promise<FetcherData<TData>>>(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>) => { fetcher.submit(...args); return 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 const useWaitCartIdle = (cart: CartReturn | CartWithActionsDocs | OptimisticCart | undefined) => { const resolveRef = useRef<any>(null) const promiseRef = useRef<any>(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 };