diff --git a/docs/resources/cart/LinesAdd.md b/docs/resources/cart/LinesAdd.md index 85ad4842ee..a5ed611b1e 100644 --- a/docs/resources/cart/LinesAdd.md +++ b/docs/resources/cart/LinesAdd.md @@ -1,17 +1,17 @@ # LinesAdd -The `LinesAdd` cart resource is a full-stack component that provides a set of utilities to facilitate adding line(s) to the cart. It also provides a set of hooks to help you handle optimistic and pending UI. +The `LinesAdd` cart resource is a full-stack component that provides a set of utilities to add line(s) to the cart. It also provides a set of hooks to help you handle optimistic and pending UI. ## `LinesAddForm` A Remix `fetcher.Form` that adds a set of line(s) to the cart. This form mutates the cart via the [cartLinesAdd](https://shopify.dev/api/storefront/2022-10/mutations/cartLinesAdd) mutation and provides `error`, `state` and `event` instrumentation for analytics. -| Prop | Type | Description | -| ------------ | ----------------------- | --------------------------------------------------- | --------------------------- | ------------------------------------------------------------------------------ | -| `lines` | `{quantity, variant}[]` | `lines items to add to the cart` | -| `onSuccess?` | `(event) => void` | `A callback that runs after every successful event` | -| `children` | `({ state: 'idle' | 'submitting' | 'loading'; error: string})` | `A render prop that provides the state and errors for the current submission.` | +| Prop | Type | Description | +| ------------ | ---------------------------------------------------------------- | ------------------------------------------------------------------------------ | +| `lines` | `{quantity, variant}[]` | `lines items to add to the cart` | +| `onSuccess?` | `(event) => void` | `A callback that runs after every successful event` | +| `children` | `({ state: 'idle' or 'submitting' or 'loading'; error: string})` | `A render prop that provides the state and errors for the current submission.` | Basic use: @@ -47,7 +47,6 @@ function AddToCartButton({selectedVariant, quantity}) { ]} onSuccess={(event) => { navigator.sendBeacon('/events', JSON.stringify(event)) - toggleNotification() }} > {(state, error) => ( @@ -83,15 +82,38 @@ const {linesAdd, linesAdding, linesAddingFetcher} = useLinesAdd(onSuccess); | :----------- | :---------------- | :-------------------------------------------------- | | `onSuccess?` | `(event) => void` | `A callback that runs after every successful event` | -Example use +Example use: reacting to add to cart event + +```jsx +// Toggle a cart drawer when adding to cart +function Layout() { + const {linesAdding} = useLinesAdd(); + const [drawer, setDrawer] = useState(false); + + useEffect(() => { + if (drawer || !linesAdding) return; + setDrawer(true); + }, [linesAdding, drawer, setDrawer]); + + return ( +
+
+ +
+ ); +} +``` + +Example use: programmatic add to cart ```jsx -// A hook that adds a free gift variant to the cart, if there are 3 items in the cart +// A hook that programmatically adds a free gift variant to the cart, +// if there are 3 or more items in the cart function useAddFreeGift({cart}) { const {linesAdd, linesAdding} = useLinesAdd(); const giftInCart = cart.lines... const freeGiftProductVariant = ... - const shouldAddGift = !linesAdding && !giftInCart && cart.lines.edges.length !== 3; + const shouldAddGift = !linesAdding && !giftInCart && cart.lines.edges.length >= 3; useEffect(() => { if (!shouldAddGift) return; diff --git a/docs/resources/cart/LinesRemove.md b/docs/resources/cart/LinesRemove.md new file mode 100644 index 0000000000..926a4b4127 --- /dev/null +++ b/docs/resources/cart/LinesRemove.md @@ -0,0 +1,171 @@ +# LinesRemove + +The `LinesRemove` cart resource is a full-stack component that provides a set of utilities to remove line(s) from the cart. It also provides a set of hooks to help you handle optimistic and pending UI. + +## `LinesRemoveForm` + +A Remix `fetcher.Form` that removes a set of line(s) from the cart. This form mutates the cart via the [cartLinesRemove](https://shopify.dev/api/storefront/2022-10/mutations/cartLinesRemove) mutation and provides +`error`, `state` and `event` instrumentation for analytics. + +| Prop | Type | Description | +| :----------- | :--------------------------------------------------------------- | :----------------------------------------------------------------------------- | +| `lineIds` | `['lineId..]` | `line ids to remove from the cart` | +| `onSuccess?` | `(event) => void` | `A callback that runs after every successful event` | +| `children` | `({ state: 'idle' or 'submitting' or 'loading'; error: string})` | `A render prop that provides the state and errors for the current submission.` | + +Basic use: + +```jsx +function RemoveFromCart({lindeIds}) { + return ( + + {(state, error) => } + + ); +} +``` + +Advanced use: + +```jsx +// Advanced example +function RemoveFromCart({lindeIds}) { + return ( + { + navigator.sendBeacon('/events', JSON.stringify(event)) + }} + > + {(state, error) => ( + + {error ?

{error}

} + )} +
+ ) +} +``` + +## `useLinesRemove` + +This hook provides a programmatic way to remove line(s) from the cart; + +Hook signature + +```jsx +function onSuccess(event) { + console.log('lines removed'); +} + +const {linesRemove, linesRemoving, linesRemoveFetcher} = + useLinesRemove(onSuccess); +``` + +| Action | Description | +| :------------------- | :--------------------------------------------------------------------------------------------------------- | +| `linesRemove` | A utility that submits the lines remove mutation via fetcher.submit() | +| `linesRemoving` | The lines being submitted. If the fetcher is idle it will be null. Useful for handling optimistic updates. | +| `linesRemoveFetcher` | The Remix `fetcher` handling the current form submission | + +| Prop | Type | Description | +| :----------- | :---------------- | :-------------------------------------------------- | +| `onSuccess?` | `(event) => void` | `A callback that runs after every successful event` | + +Example use + +```jsx +// A hook that removes a free gift variant from the cart, if there are less than 3 items in the cart +function useRemoveFreeGift({cart}) { + const {linesRemove, linesRemoving} = useLinesRemove(); + const freeGiftLineId = cart.lines.filter; + const shouldRemoveGift = + !linesRemoving && freeGiftLineId && cart.lines.edges.length < 3; + + useEffect(() => { + if (!shouldRemoveGift) return; + linesRemove({ + lineIds: [freeGiftLineId], + }); + }, [shouldRemoveGift, freeGiftLineId]); +} +``` + +## `useOptimisticLineRemove` + +A utility hook to easily implement optimistic UI when a specific line is being removed. + +Hook signature + +```jsx +const {optimisticLineRemove, linesRemoving} = useOptimisticLineRemove(lines); +``` + +| Action | Description | +| :--------------------- | :--------------------------------------------------------------------------------------------------------- | +| `optimisticLineRemove` | A boolean indicating if the line is being removed | +| `linesRemoving` | The lines being submitted. If the fetcher is idle it will be null. Useful for handling optimistic updates. | + +Example use + +```jsx +function CartLineItem({line}) { + const {optimisticLineRemove} = useOptimisticLineRemove(line); + const {id: lineId, merchandise} = line; + + return ( +
  • +

    {{merchandise.product.title}}

    + {merchandise.title} + ... +
  • + ); +} +``` + +## `useOptimisticLinesRemove` + +A utility hook to easily implement optimistic UI when a any cart line is being removed. + +Hook signature + +```jsx +const {optimisticLastLineRemove, linesRemoving} = + useOptimisticLinesRemove(lines); +``` + +| Action | Description | +| :------------------------- | :--------------------------------------------------------------------------------------------------------- | +| `optimisticLastLineRemove` | A boolean indicating that the last line in cart is being removed | +| `linesRemoving` | The lines being submitted. If the fetcher is idle it will be null. Useful for handling optimistic updates. | + +Example use + +```jsx +function Cart({cart}) { + const {lines} = cart; + const {optimisticLastLineRemove} = useOptimisticLinesRemove(lines); + + // optimistically show an empty cart if removing the last line + const cartEmpty = lines.length === 0 || optimisticLastLineRemove; + + return ( +
    + {cartEmpty + ? + : + } +
    + ); +} +``` diff --git a/templates/demo-store/app/components/CartDetails.tsx b/templates/demo-store/app/components/CartDetails.tsx index 20c291b8ec..4b37d08019 100644 --- a/templates/demo-store/app/components/CartDetails.tsx +++ b/templates/demo-store/app/components/CartDetails.tsx @@ -1,11 +1,11 @@ -import {useMemo, useRef} from 'react'; +import clsx from 'clsx'; +import {useRef} from 'react'; import {useScroll} from 'react-use'; import {flattenConnection, Money} from '@shopify/hydrogen-react'; import { type FetcherWithComponents, useFetcher, useLocation, - useFetchers, } from '@remix-run/react'; import { Button, @@ -24,6 +24,11 @@ import type { ProductConnection, } from '@shopify/hydrogen-react/storefront-api-types'; import {useOptimisticLinesAdd} from '~/routes/__resources/cart/LinesAdd'; +import { + LinesRemoveForm, + useOptimisticLineRemove, + useOptimisticLinesRemove, +} from '~/routes/__resources/cart/LinesRemove'; enum Action { SetQuantity = 'set-quantity', @@ -46,14 +51,11 @@ export function CartDetails({ const {y} = useScroll(scrollRef); const lineItemFetcher = useFetcher(); const {optimisticLinesAdd} = useOptimisticLinesAdd(lines); - const optimisticallyDeletingLastLine = - lines.length === 1 && - lineItemFetcher.submission && - lineItemFetcher.submission.formData.get('intent') === Action.RemoveLineItem; + const {optimisticLastLineRemove} = useOptimisticLinesRemove(lines); const cartIsEmpty = Boolean( (lines.length === 0 && !optimisticLinesAdd.length) || - optimisticallyDeletingLastLine, + optimisticLastLineRemove, ); if (cartIsEmpty) { @@ -164,10 +166,8 @@ function CartLineItem({ optimistic?: boolean; }) { const {id: lineId, quantity, merchandise} = line; - - const location = useLocation(); + const {optimisticLineRemove} = useOptimisticLineRemove(line); let optimisticQuantity = quantity; - let optimisticallyDeleting = false; if ( fetcher.submission && @@ -180,16 +180,14 @@ function CartLineItem({ ); break; } - - case Action.RemoveLineItem: { - optimisticallyDeleting = true; - break; - } } } - return optimisticallyDeleting ? null : ( -
  • + return ( +
  • {merchandise.image && (
    - - - - - - + @@ -262,6 +240,25 @@ function CartLineItem({ ); } +function CartLineRemove({lineIds}: {lineIds: CartLine['id'][]}) { + return ( + + {({state}) => ( + + )} + + ); +} + function CartLineQuantityAdjust({ lineId, quantity, diff --git a/templates/demo-store/app/routes/__resources/cart/LinesAdd.tsx b/templates/demo-store/app/routes/__resources/cart/LinesAdd.tsx index 74bc021a92..0ea226e67c 100644 --- a/templates/demo-store/app/routes/__resources/cart/LinesAdd.tsx +++ b/templates/demo-store/app/routes/__resources/cart/LinesAdd.tsx @@ -391,9 +391,6 @@ async function getCartLines({ return cart; } -/* - Create a cart with line(s) mutation -*/ /** * Create a cart with line(s) mutation * @param input CartInput https://shopify.dev/api/storefront/2022-01/input-objects/CartInput @@ -466,11 +463,12 @@ async function linesAddMutation({ return cartLinesAdd; } -/* - Component ---------------------------------------------------------------- - Add to cart form that adds a set of line(s) to the cart - @see: https://shopify.dev/api/storefront/2022-10/mutations/cartLinesAdd -*/ +/** + * Add to cart form that adds a set of line(s) to the cart + * @param lines an array of line(s) to add. {quantity, variant}[] + * @param children render submit button + * @param onSuccess? callback that runs after each form submission + */ const LinesAddForm = forwardRef( ({children, lines = [], onSuccess}, ref) => { const formId = useId(); diff --git a/templates/demo-store/app/routes/__resources/cart/LinesRemove.tsx b/templates/demo-store/app/routes/__resources/cart/LinesRemove.tsx new file mode 100644 index 0000000000..68e08d4fa3 --- /dev/null +++ b/templates/demo-store/app/routes/__resources/cart/LinesRemove.tsx @@ -0,0 +1,400 @@ +import React, {forwardRef, useCallback, useEffect, useId} from 'react'; +import {useFetcher, useLocation, Params, useFetchers} from '@remix-run/react'; +import {useIsHydrated} from '~/hooks/useIsHydrated'; +import type {PartialDeep} from 'type-fest'; +import type { + Cart, + CartLine, + CartLineConnection, + UserError, +} from '@shopify/hydrogen-react/storefront-api-types'; +import { + type ActionArgs, + type HydrogenContext, + redirect, + json, +} from '@shopify/hydrogen-remix'; +import invariant from 'tiny-invariant'; +import {getLocalizationFromLang} from '~/lib/utils'; +import {getSession} from '~/lib/session.server'; +import {getCartLines} from './LinesAdd'; + +interface LinesRemoveProps { + lineIds: CartLine['id'][]; + children: ({ + state, + error, + }: { + state: 'idle' | 'submitting' | 'loading'; + error: string; + }) => React.ReactNode; + onSuccess?: (event: LinesRemoveEvent) => void; +} + +interface LinesRemove { + linesRemoved: CartLine[]; + linesNotRemoved: CartLine[]; +} + +interface LinesRemoveEventPayload extends LinesRemove { + lineIds: CartLine['id'][]; +} + +interface LinesRemoveEvent { + type: 'lines_remove' | 'lines_remove_error'; + id: string; + payload: LinesRemoveEventPayload; +} + +interface DiffLinesProps { + prevLines: CartLineConnection; + currentLines: CartLineConnection; + removingLineIds: CartLine['id'][]; +} + +const ACTION_PATH = '/cart/LinesRemove'; + +/** + * action that handles the line(s) remove mutation + */ +async function action({request, context, params}: ActionArgs) { + const [session, formData] = await Promise.all([ + getSession(request, context), + request.formData(), + ]); + + const cartId = await session.get('cartId'); + invariant(cartId, 'Missing cartId'); + + invariant(formData.get('lineIds'), 'Missing lineIds'); + const lineIds = formData.get('lineIds') + ? (JSON.parse(String(formData.get('lineIds'))) as CartLine['id'][]) + : ([] as CartLine['id'][]); + + // we need to query the prevCart so we can validate + // what was really added or not for analytics + const prevCart = await getCartLines({cartId, params, context}); + + const {cart, errors} = await linesRemoveMutation({ + cartId, + lineIds, + params, + context, + }); + + if (errors.length) { + const errorMessage = errors?.map(({message}) => message).join('/n') || ''; + return json({error: errorMessage}); + } + + const redirectTo = JSON.parse(String(formData.get('redirectTo'))); + if (redirectTo) { + return redirect(redirectTo); + } + + const {event, error} = instrumentEvent({ + removingLineIds: lineIds, + prevLines: prevCart.lines, + currentLines: cart.lines, + }); + + return json({event, error}); +} + +/** + * Helper function to instrument lines_remove | lines_remove_error events + * @param removingLineIds the line ids being removed + * @param prevLines the line(s) available before removing + * @param currentLines the line(s) still available after removing + * @returns {event, error} + */ +function instrumentEvent({ + removingLineIds, + prevLines, + currentLines, +}: DiffLinesProps) { + // determine what line(s) were actually removed or not + const {linesRemoved, linesNotRemoved} = diffLines({ + removingLineIds, + prevLines, + currentLines, + }); + + const event: LinesRemoveEvent = { + type: 'lines_remove', + id: crypto.randomUUID(), + payload: { + lineIds: removingLineIds, + linesRemoved, + linesNotRemoved: [], + }, + }; + + let error = null; + if (linesNotRemoved?.length) { + event.type = 'lines_remove_error'; + event.payload.linesNotRemoved = linesNotRemoved; + + error = linesNotRemoved.length + ? `Failed to remove line ids ${linesNotRemoved + .map(({id}: CartLine) => id) + .join(',')}` + : null; + } + + return {event, error}; +} + +/** + * Diff lines to determine which lines were actually removed and which one were not + * @todo: remove when this is provided by the mutation + * @param removingLineIds the line ids being removed + * @param prevLines the line(s) available before removing + * @param currentLines the line(s) still available after removing + * @returns + */ +function diffLines({removingLineIds, prevLines, currentLines}: DiffLinesProps) { + return prevLines?.edges?.reduce( + (_result, {node: _prevLine}) => { + const lineStillExists = currentLines.edges.find( + ({node: line}) => line.id === _prevLine.id, + ); + if (lineStillExists) { + if (removingLineIds.includes(lineStillExists?.node?.id)) { + _result.linesNotRemoved = [..._result.linesNotRemoved, _prevLine]; + } + } else { + _result.linesRemoved = [..._result.linesRemoved, _prevLine]; + } + return _result; + }, + {linesRemoved: [], linesNotRemoved: []} as LinesRemove, + ) as LinesRemove; +} + +/* + Mutation ----------------------------------------------------------------------------------------- +*/ +const REMOVE_LINE_ITEMS_MUTATION = `#graphql + mutation ($cartId: ID!, $lineIds: [ID!]!, $language: LanguageCode) + @inContext(country: $country, language: $language) { + cartLinesRemove(cartId: $cartId, lineIds: $lineIds) { + cart { + id + totalQuantity + lines(first: 100) { + edges { + node { + id + quantity + merchandise { + ...on ProductVariant { + id + } + } + } + } + } + } + errors: userErrors { + message + field + code + } + } + } +`; + +/** + * Create a cart with line(s) mutation + * @param cartId the current cart id + * @param lineIds [ID!]! an array of cart line ids to remove + * @see https://shopify.dev/api/storefront/2022-07/mutations/cartlinesremove + * @returns mutated cart + */ +async function linesRemoveMutation({ + cartId, + lineIds, + params, + context, +}: { + cartId: string; + lineIds: Cart['id'][]; + params: Params; + context: HydrogenContext; +}) { + const {storefront} = context; + invariant(storefront, 'missing storefront client in lines remove mutation'); + + const {country, language} = getLocalizationFromLang(params.lang); + + const {cartLinesRemove} = await storefront.mutate<{ + cartLinesRemove: {cart: Cart; errors: UserError[]}; + }>(REMOVE_LINE_ITEMS_MUTATION, { + variables: { + cartId, + lineIds, + country, + language, + }, + }); + + invariant(cartLinesRemove, 'No data returned from remove lines mutation'); + return cartLinesRemove; +} + +/** + * Form that remove line(s) from cart + * @param lineIds [ID!]! an array of cart line ids to remove + * @param children render submit button + * @param onSuccess? callback that runs after each form submission + * @see: https://shopify.dev/api/storefront/2022-10/mutations/cartLinesRemove + */ +const LinesRemoveForm = forwardRef( + ( + {lineIds, children, onSuccess}: LinesRemoveProps, + ref: React.Ref, + ) => { + const formId = useId(); + const isHydrated = useIsHydrated(); + const fetcher = useFetcher(); + const location = useLocation(); + const currentUrl = `${location.pathname}${location.search}`; + + const event = fetcher.data?.event; + const error = fetcher.data?.error; + + useEffect(() => { + if (!event) return; + onSuccess?.(event); + }, [event, onSuccess]); + + return ( + + + {/* used to trigger a redirect back to the same url when JS is disabled */} + {isHydrated ? null : ( + + )} + {children({state: fetcher.state, error})} + + ); + }, +); + +/** + * A hook version of LinesRemoveForm to remove cart line(s) programmatically + * @param onSuccess callback function that executes on success + * @returns { linesRemove, linesRemoveFetcher, linesRemoving, } + */ +function useLinesRemove( + onSuccess: (event: LinesRemoveEvent) => void = () => {}, +) { + const fetcher = useFetcher(); + const fetchers = useFetchers(); + const linesRemoveFetcher = fetchers.find( + (fetcher) => fetcher?.submission?.action === ACTION_PATH, + ); + + let linesRemoving; + + // set linesRemoving + if (linesRemoveFetcher?.submission) { + const deletingLineIdsStr = + linesRemoveFetcher?.submission?.formData?.get('lineIds'); + if (deletingLineIdsStr && typeof deletingLineIdsStr === 'string') { + try { + linesRemoving = JSON.parse(deletingLineIdsStr); + } catch (_) { + // noop + } + } + } + + const linesRemove = useCallback( + ({lineIds}: {lineIds: CartLine['id'][]}) => { + const form = new FormData(); + form.set('lineIds', JSON.stringify(lineIds)); + fetcher.submit(form, { + method: 'post', + action: ACTION_PATH, + replace: false, + }); + }, + [fetcher], + ); + + useEffect(() => { + if (!fetcher?.data?.event) return; + onSuccess?.(fetcher.data.event); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [fetcher?.data?.event]); + + return { + linesRemove, + linesRemoveFetcher, + linesRemoving, + }; +} + +/** + * A utility hook to implement optimistic lines removal + * @param lines CartLine[] + * @returns {optimisticLastLineRemove, linesRemoving} + */ +function useOptimisticLinesRemove( + lines?: PartialDeep[] | CartLine[], +) { + const fetchers = useFetchers(); + const linesRemoveFetcher = fetchers.find( + (fetcher) => fetcher?.submission?.action === ACTION_PATH, + ); + + let linesRemoving: CartLine['id'][] = []; + let optimisticLastLineRemove = false; + + if (!linesRemoveFetcher?.submission) { + return {optimisticLastLineRemove, linesRemoving}; + } + + const linesRemoveStr = + linesRemoveFetcher?.submission?.formData?.get('lineIds'); + if (linesRemoveStr && typeof linesRemoveStr === 'string') { + try { + linesRemoving = JSON.parse(linesRemoveStr); + } catch (_) { + // noop + } + } + + if (lines?.length && linesRemoving?.length) { + optimisticLastLineRemove = linesRemoving.length === lines.length; + } + + return {optimisticLastLineRemove, linesRemoving}; +} + +/** + * A utility hook to implement optimistic single line removal + * @param line? optional CartLine + * @returns {optimisticLineRemove, linesRemoving} + */ +function useOptimisticLineRemove(line?: CartLine) { + const {linesRemoving} = useOptimisticLinesRemove(); + + const optimisticLineRemove = + line && linesRemoving?.length + ? Boolean(linesRemoving.includes(line.id)) + : false; + + return {optimisticLineRemove, linesRemoving}; +} + +export { + action, + LinesRemoveForm, + linesRemoveMutation, + useLinesRemove, + useOptimisticLineRemove, + useOptimisticLinesRemove, +}; diff --git a/templates/demo-store/app/routes/cart.tsx b/templates/demo-store/app/routes/cart.tsx index 4761f9c156..a5a5c9018e 100644 --- a/templates/demo-store/app/routes/cart.tsx +++ b/templates/demo-store/app/routes/cart.tsx @@ -50,22 +50,6 @@ export const action: ActionFunction = async ({request, context, params}) => { return json({cart}); } - case 'remove-line-item': { - /** - * We're re-using the same mutation as setting a quantity of 0, - * but theoretically we could use the `cartLinesRemove` mutation. - */ - const lineId = formData.get('lineId'); - invariant(lineId, 'Missing lineId'); - invariant(cartId, 'Missing cartId'); - await updateLineItem(context, { - cartId, - lineItem: {id: lineId, quantity: 0}, - params, - }); - return json({cart}); - } - default: { throw new Error(`Cart intent ${intent} not supported`); }