SSR Cart Migration
This guide outlines the steps to migrate a Pack Hydrogen storefront using client-side cart with server-side cart. This will include:
- New
($locale).api.cart.tsxroute - New
CartProviderandPreviewModeCartProvidercontext providers - New
useCarthook (replaceduseCartfrom@shopify/hydrogen-react) - Update to
useAddToCartanduseCartLinehooks - Update to Graphql fragments
- Update cart analytics logic
- Update to
($locale).cart.$lines.tsxand($locale).discounts.$code.tsx - New cart typescript types
- Necessary tweak to
CartUpsell.tsx(if applicable)
Things to Consider
At the bottom under LLM Prompt is a single prompt that can be passed into an LLM for automating each task. These prompts have been tested internally using the IDE Cursor or the Claude Code extension for VS Code
Adjust the chat settings accordingly to allow agent to write files without asking for permission each time:
If using Cursor:
- Turn on
Agentmode - Turn on
Auto-Run Mode
If using Claude Code extension for VS Code:
- Type in
/into chat to open menu, then click onGeneral config... - Enable
Claude Code: Allow Dangerously Skip Permissions
Note: These LLM prompts are tested with Pack storefronts that:
- Are built off Pack's Blueprint template
- Have not deviated significantly from the Blueprint template
In case of more custom builds, review each prompt and edit as needed to work within the codebase. Or follow the manual steps and ensure changes are made to the appopriate files and blocks of code.
Manual Setup
Cart route
- Add
($locale).api.cart.tsxtoapp/routes
import {CartForm} from '@shopify/hydrogen';
import type {
ActionFunctionArgs,
LoaderFunctionArgs,
} from '@shopify/remix-oxygen';
import type {CartQueryDataReturn} from '@shopify/hydrogen';
import type {Cart} from '@shopify/hydrogen/storefront-api-types';
import {isLocalPath} from '~/lib/utils';
const getParsedJson = (value: FormDataEntryValue | null) => {
if (!value || typeof value !== 'string') return value;
try {
return JSON.parse(value);
} catch (error) {
return value;
}
};
export async function action({request, context}: ActionFunctionArgs) {
const {cart} = context;
const formData = await request.formData();
const action = formData.get('action');
let status = 200;
let result: CartQueryDataReturn;
if (!action)
return Response.json({errors: ['Missing `action` in body']}, {status: 400});
switch (action) {
case CartForm.ACTIONS.Create:
result = await cart.create(getParsedJson(formData.get('cart')));
break;
case CartForm.ACTIONS.LinesAdd:
result = await cart.addLines(getParsedJson(formData.get('lines')));
break;
case CartForm.ACTIONS.LinesUpdate:
result = await cart.updateLines(getParsedJson(formData.get('lines')));
break;
case CartForm.ACTIONS.LinesRemove:
result = await cart.removeLines(getParsedJson(formData.get('lineIds')));
break;
case CartForm.ACTIONS.DiscountCodesUpdate:
const formDiscountCodes = getParsedJson(formData.get('discountCodes'));
const discountCodes = (
Array.isArray(formDiscountCodes) ? formDiscountCodes : []
) as string[];
// Combine discount codes already applied on cart
const existingCartWithDiscountCodes = (await cart.get()) as Cart;
if (existingCartWithDiscountCodes) {
discountCodes.push(
...(existingCartWithDiscountCodes.discountCodes?.map(
({code}) => code,
) || []),
);
}
result = await cart.updateDiscountCodes(discountCodes);
break;
case CartForm.ACTIONS.BuyerIdentityUpdate:
result = await cart.updateBuyerIdentity(
getParsedJson(formData.get('buyerIdentity')),
);
break;
case CartForm.ACTIONS.AttributesUpdateInput:
const formAttributes = getParsedJson(formData.get('attributes'));
const attributes = Array.isArray(formAttributes) ? formAttributes : [];
// Combine cart attributes in cart
const existingCartWithAttributes = (await cart.get()) as Cart;
if (existingCartWithAttributes) {
attributes.push(...(existingCartWithAttributes.attributes || []));
}
result = await cart.updateAttributes(attributes);
break;
case CartForm.ACTIONS.NoteUpdate:
result = await cart.updateNote(formData.get('note') as string);
break;
default:
return Response.json(
{errors: [`${action} cart action is not defined`]},
{status: 400},
);
}
/**
* The Cart ID may change after each mutation. We need to update it each time in the session.
*/
let headers = new Headers();
if (result.cart?.id) headers = cart.setCartId(result.cart.id);
const redirectTo = formData.get('redirectTo') ?? null;
if (typeof redirectTo === 'string' && isLocalPath(redirectTo)) {
status = 303;
headers.set('Location', redirectTo);
}
const {cart: cartResult, warnings, userErrors} = result;
return Response.json(
{
cart: cartResult,
userErrors,
warnings,
},
{status, headers},
);
}
export async function loader({context}: LoaderFunctionArgs) {
return Response.json({cart: await context.cart.get()});
}
- Add
isLocalPathtoapp/lib/utils/document.utils.ts:
/**
* Validates that a url is local
* @param url
* @returns `true` if local `false`if external domain
*/
export function isLocalPath(url: string) {
try {
// We don't want to redirect cross domain,
// doing so could create fishing vulnerability
// If `new URL()` succeeds, it's a fully qualified
// url which is cross domain. If it fails, it's just
// a path, which will be the current domain.
new URL(url);
} catch (e) {
return true;
}
return false;
}
Providers
- In
root.tsxloader:
- Destructure out
cartfromcontext - Return
cart.get()ascartfrom the loader's return, e.g.return {analytics, cart: cart.get(), ... }
- In the
app/contextsfolder, create folderCartProvider - Add following files:
CartProvider.tsx
import {useEffect, useMemo, useReducer} from 'react';
import type {ReactNode} from 'react';
import type {Cart} from '@shopify/hydrogen/storefront-api-types';
import type {Action, CartState, Dispatch} from '~/lib/types';
import {useRootLoaderData} from '~/hooks';
import {Context} from './useCartContext';
const cartState = {
cart: null,
status: 'uninitialized',
error: null,
};
const reducer = (state: CartState, action: Action) => {
switch (action.type) {
case 'SET_CART':
return {
...state,
cart: action.payload,
};
case 'SET_STATUS':
return {
...state,
status: action.payload,
};
case 'SET_ERROR':
return {
...state,
error: action.payload,
};
default:
throw new Error(`Invalid Context action of type: ${action.type}`);
}
};
const actions = (dispatch: Dispatch) => ({
setCart: (cart: Cart) => dispatch({type: 'SET_CART', payload: cart}),
setStatus: (status: CartState['status']) =>
dispatch({type: 'SET_STATUS', payload: status}),
setError: (error: unknown) => dispatch({type: 'SET_ERROR', payload: error}),
});
export function CartProvider({children}: {children: ReactNode}) {
const {cart} = useRootLoaderData();
const [state, dispatch] = useReducer(reducer, cartState);
const value = useMemo(() => ({state, actions: actions(dispatch)}), [state]);
/*
* Fetch cart on initial load and set in context
*/
useEffect(() => {
if (value.state.status !== 'uninitialized') return;
const setCart = async () => {
value.actions.setError(null);
const cartData = await (cart as Promise<Cart>);
value.actions.setCart(cartData);
if (cartData) value.actions.setStatus('idle');
};
setCart();
}, [cart, value.state.status === 'uninitialized']);
return <Context.Provider value={value}>{children}</Context.Provider>;
}
useCartContext.ts
import {createContext, useContext} from 'react';
import type {CartContext} from '~/lib/types';
export const Context = createContext({
state: {},
actions: {},
} as CartContext);
export const useCartContext = () => useContext(Context);
- In the
app/contextsfolder, add the filePreviewModeCartProvider.tsx
import {useMemo} from 'react';
import {CartProvider, ShopifyProvider} from '@shopify/hydrogen-react';
import type {ReactNode} from 'react';
import {CART_FRAGMENT} from '~/data/graphql/storefront/cart';
import {useLocale, useRootLoaderData} from '~/hooks';
import {DEFAULT_STOREFRONT_API_VERSION} from '~/lib/constants';
export function PreviewModeCartProvider({children}: {children: ReactNode}) {
const {ENV, isPreviewModeEnabled} = useRootLoaderData();
const locale = useLocale();
const clientSideCartFragment = useMemo(() => {
return CART_FRAGMENT.replace('CartApiQuery', 'CartFragment');
}, [CART_FRAGMENT]);
if (isPreviewModeEnabled) {
return (
<ShopifyProvider
storeDomain={`https://${ENV.PUBLIC_STORE_DOMAIN}`}
storefrontToken={ENV.PUBLIC_STOREFRONT_API_TOKEN}
storefrontApiVersion={DEFAULT_STOREFRONT_API_VERSION}
countryIsoCode={locale.country}
languageIsoCode={locale.language}
>
<CartProvider cartFragment={clientSideCartFragment}>
{children}
</CartProvider>
</ShopifyProvider>
);
}
return children;
}
PreviewModeCartProvider.displayName = 'PreviewModeCartProvider';
- Find wherever
<<ShopifyProvider><CartProvider> */ ... /* </CartProvider></<ShopifyProvider>is rendered, usuallyDocument.tsx
- Remove those two providers
- Add the two new cart providers as the outer most providers in
ContextsProvider.tsxif it exists, otherwise add them to the same placeShopifyProviderand originalCartProviderwere placed:
<PreviewModeCartProvider>
<CartProvider>*/ Other providers, code, etc /*</CartProvider>
</PreviewModeCartProvider>
- Import
PreviewModeCartProviderandCartProvideraccordingly
useCart hook
- Add
useCart.tstoapp/hooks/cart
import {useCallback} from 'react';
import {CartForm, flattenConnection, useAnalytics} from '@shopify/hydrogen';
import {useCart as useClientSideCart} from '@shopify/hydrogen-react';
import equal from 'fast-deep-equal';
import type {
Cart,
CartBuyerIdentityInput,
CartLine,
CartLineInput,
} from '@shopify/hydrogen/storefront-api-types';
import {useCartContext} from '~/contexts/CartProvider/useCartContext';
import {AnalyticsEvent} from '~/components/Analytics/constants';
import type {CartActionData, CartWithActions} from '~/lib/types';
import {useRootLoaderData} from '../useRootLoaderData';
export const useCart = (): CartWithActions => {
const {isPreviewModeEnabled} = useRootLoaderData();
const {publish, shop} = useAnalytics();
const {
state: {cart, status, error},
actions: {setCart, setError, setStatus},
} = useCartContext();
const getCartActionData = useCallback(
async (formData: FormData): Promise<CartActionData | null> => {
const action = formData.get('action');
let data = null as CartActionData | null;
setError(null);
setStatus(action === CartForm.ACTIONS.Create ? 'creating' : 'updating');
const response = await fetch('/api/cart', {
method: 'POST',
body: formData,
});
data = await response.json();
if (data?.userErrors?.length) {
setError(data?.userErrors);
}
if (data?.cart) {
setCart(data.cart);
}
setStatus('idle');
return data;
},
[],
);
const cartCreate: CartWithActions['cartCreate'] = useCallback(
async (cartInput) => {
const formData = new FormData();
formData.append('action', CartForm.ACTIONS.Create);
formData.append('cart', JSON.stringify(cartInput));
return getCartActionData(formData);
},
[],
);
const linesAdd: CartWithActions['linesAdd'] = useCallback(
async (lines) => {
const formData = new FormData();
formData.append('action', CartForm.ACTIONS.LinesAdd);
formData.append('lines', JSON.stringify(lines));
const data = await getCartActionData(formData);
if (data?.cart) {
lines.forEach((lineInput) => {
const prevLine =
flattenConnection(cart?.lines).find((prevCartLine) => {
return compareInputWithLine(lineInput, prevCartLine as CartLine);
}) || null;
let currentLine =
flattenConnection(data.cart?.lines).find((currentCartLine) => {
return compareInputWithLine(
lineInput,
currentCartLine as CartLine,
);
}) || null;
// If current line cannot be matched through comparing attributes, fallback to ignoring attributes and just find matching merchandise id for sake of analytics event
if (!currentLine) {
currentLine =
flattenConnection(data.cart?.lines).find((currentCartLine) => {
return (
currentCartLine.merchandise.id === lineInput.merchandiseId &&
lineInput.sellingPlanId ===
currentCartLine.sellingPlanAllocation?.sellingPlan?.id
);
}) || null;
}
publish(AnalyticsEvent.PRODUCT_ADD_TO_CART, {
cart: data.cart,
prevCart: cart,
currentLine,
prevLine,
shop,
});
});
}
return data;
},
[cart, publish, shop],
);
const linesUpdate: CartWithActions['linesUpdate'] = useCallback(
async (lines) => {
const formData = new FormData();
formData.append('action', CartForm.ACTIONS.LinesUpdate);
formData.append('lines', JSON.stringify(lines));
const data = await getCartActionData(formData);
if (data?.cart) {
lines.forEach((lineInput) => {
const prevLine =
flattenConnection(cart?.lines).find((prevCartLine) => {
return prevCartLine.id === lineInput.id;
}) || null;
const currentLine =
flattenConnection(data.cart?.lines).find((currentCartLine) => {
return currentCartLine.id === lineInput.id;
}) || null;
if ((currentLine?.quantity || 0) > (prevLine?.quantity || 0)) {
publish(AnalyticsEvent.PRODUCT_ADD_TO_CART, {
cart: data.cart,
prevCart: cart,
currentLine,
prevLine,
shop,
});
} else if ((currentLine?.quantity || 0) < (prevLine?.quantity || 0)) {
publish(AnalyticsEvent.PRODUCT_REMOVED_FROM_CART, {
cart: data.cart,
prevCart: cart,
currentLine,
prevLine,
shop,
});
}
});
}
return data;
},
[cart, publish, shop],
);
const linesRemove: CartWithActions['linesRemove'] = useCallback(
async (lineIds) => {
const formData = new FormData();
formData.append('action', CartForm.ACTIONS.LinesRemove);
formData.append('lineIds', JSON.stringify(lineIds));
const data = await getCartActionData(formData);
if (data?.cart) {
lineIds.forEach((lineId) => {
const prevLine =
flattenConnection(cart?.lines).find((prevCartLine) => {
return prevCartLine.id === lineId;
}) || null;
const currentLine =
flattenConnection(data.cart?.lines).find((currentCartLine) => {
return currentCartLine.id === lineId;
}) || null;
publish(AnalyticsEvent.PRODUCT_REMOVED_FROM_CART, {
cart: data.cart,
prevCart: cart,
currentLine,
prevLine,
shop,
});
});
}
return data;
},
[cart, publish, shop],
);
const discountCodesUpdate: CartWithActions['discountCodesUpdate'] =
useCallback(async (discountCodes) => {
const formData = new FormData();
formData.append('action', CartForm.ACTIONS.DiscountCodesUpdate);
formData.append('discountCodes', JSON.stringify(discountCodes));
return getCartActionData(formData);
}, []);
const cartAttributesUpdate: CartWithActions['cartAttributesUpdate'] =
useCallback(async (attributes) => {
const formData = new FormData();
formData.append('action', CartForm.ACTIONS.AttributesUpdateInput);
formData.append('attributes', JSON.stringify(attributes));
return getCartActionData(formData);
}, []);
const buyerIdentityUpdate: CartWithActions['buyerIdentityUpdate'] =
useCallback(
async (
buyerIdentity: CartBuyerIdentityInput,
): Promise<CartActionData | null> => {
const formData = new FormData();
formData.append('action', CartForm.ACTIONS.BuyerIdentityUpdate);
formData.append('buyerIdentity', JSON.stringify(buyerIdentity));
return getCartActionData(formData);
},
[],
);
const noteUpdate: CartWithActions['noteUpdate'] = useCallback(
async (note) => {
const formData = new FormData();
formData.append('action', CartForm.ACTIONS.NoteUpdate);
formData.append('note', note);
return getCartActionData(formData);
},
[],
);
return (
isPreviewModeEnabled
? // eslint-disable-next-line react-hooks/rules-of-hooks
useClientSideCart()
: {
...cart,
...(cart?.lines ? {lines: flattenConnection(cart.lines)} : null),
buyerIdentityUpdate,
cartAttributesUpdate,
cartCreate,
discountCodesUpdate,
error,
linesAdd,
linesRemove,
linesUpdate,
noteUpdate,
status,
}
) as CartWithActions;
};
const compareInputWithLine = (input: CartLineInput, line: CartLine) => {
const inputAttrLength = input.attributes ? input.attributes.length : 0;
const lineAttrLength = line.attributes ? line.attributes.length : 0;
if (inputAttrLength !== lineAttrLength) return false;
if (inputAttrLength || lineAttrLength) {
const inputAttributesByKey = input.attributes?.reduce(
(acc: Record<string, string>, attr) => {
acc[attr.key] = attr.value;
return acc;
},
{},
);
const lineAttributesByKey = line.attributes?.reduce(
(acc: Record<string, string>, attr) => {
acc[attr.key] = attr.value;
return acc;
},
{},
);
if (!equal(inputAttributesByKey, lineAttributesByKey)) return false;
}
if (input.sellingPlanId !== line.sellingPlanAllocation?.sellingPlan?.id)
return false;
return line.merchandise.id === input.merchandiseId;
};
- Some legacy stores will instead use
PackEventNameimported fromapp/components/PackAnalytics/constants.tsinstead ofAnalyticsEvent. If this is the case, change the import toimport {PackEventName} from '~/components/PackAnalytics/constants';and update all instances ofAnalyticsEventwithPackEventName - Export
useCartfrom the/cartindex.tsfile
useAddToCart hook
- Update
useAddToCart.tsxto reflect:
import {useCallback, useEffect, useState} from 'react';
import type {
AttributeInput,
ProductVariant,
SellingPlan,
} from '@shopify/hydrogen/storefront-api-types';
import {useCart, useMenu, useRootLoaderData, useSettings} from '~/hooks';
/**
* Add to cart hook
* @param addToCartTextOverride - Add to cart button text override
* @param attributes - Array of attributes
* @param quantity - Quantity
* @param selectedVariant - Selected variant
* @param sellingPlanId - Selling plan id
* @returns Add to cart hook return
* @example
* ```tsx
* const {buttonText, cartIsUpdating, handleAddToCart, isAdded, isAdding, isSoldOut, subtext} = useAddToCart({
* addToCartText: 'Add to cart!',
* attributes: [{name: 'Color', value: 'Red'}],
* quantity: 1,
* selectedVariant: product.variants[0],
* });
* ```
*/
interface UseAddToCartProps {
addToCartText?: string;
attributes?: AttributeInput[];
quantity?: number;
selectedVariant?: ProductVariant | null;
sellingPlanId?: SellingPlan['id'];
}
interface UseAddToCartReturn {
buttonText: string;
cartIsUpdating: boolean;
failed: boolean;
handleAddToCart: () => void;
handleNotifyMe: (component: React.ReactNode) => void;
isAdded: boolean;
isAdding: boolean;
isNotifyMe: boolean;
isSoldOut: boolean;
subtext: string;
}
export function useAddToCart({
addToCartText: addToCartTextOverride = '',
attributes,
quantity = 1,
selectedVariant = null,
sellingPlanId,
}: UseAddToCartProps): UseAddToCartReturn {
const {error, linesAdd, status} = useCart();
const {isPreviewModeEnabled} = useRootLoaderData();
const {product: productSettings} = useSettings();
const {openCart, openModal} = useMenu();
const [isAdding, setIsAdding] = useState(false);
const [isAdded, setIsAdded] = useState(false);
const [failed, setFailed] = useState(false);
const enabledNotifyMe = productSettings?.backInStock?.enabled ?? true;
const variantIsSoldOut = selectedVariant && !selectedVariant.availableForSale;
const variantIsPreorder = !!selectedVariant?.currentlyNotInStock;
let buttonText = '';
if (failed) {
buttonText = 'Failed To Add';
} else if (variantIsPreorder) {
buttonText = productSettings?.addToCart?.preorderText || 'Preorder';
} else if (variantIsSoldOut) {
buttonText = enabledNotifyMe
? productSettings?.backInStock?.notifyMeText || 'Notify Me'
: productSettings?.addToCart?.soldOutText || 'Sold Out';
} else {
buttonText =
addToCartTextOverride ||
productSettings?.addToCart?.addToCartText ||
'Add To Cart';
}
const cartIsUpdating = status === 'creating' || status === 'updating';
const handleAddToCart = useCallback(async () => {
if (!selectedVariant?.id || isAdding || cartIsUpdating) return;
setIsAdding(true);
setFailed(false);
const data = await linesAdd([
{
attributes,
merchandiseId: selectedVariant.id,
quantity,
sellingPlanId,
},
]);
if (data) {
if (data.userErrors?.length) {
setIsAdding(false);
setFailed(true);
setTimeout(() => setFailed(false), 3000);
} else {
setIsAdding(false);
setIsAdded(true);
openCart();
setTimeout(() => setIsAdded(false), 1000);
}
}
}, [
attributes,
isAdding,
linesAdd,
quantity,
selectedVariant?.id,
sellingPlanId,
status,
]);
const handleNotifyMe = useCallback(
(component: React.ReactNode) => {
if (!selectedVariant?.id) return;
openModal(component);
},
[selectedVariant?.id],
);
useEffect(() => {
if (!isPreviewModeEnabled) return;
if (isAdding && status === 'idle') {
setIsAdding(false);
setIsAdded(true);
openCart();
setTimeout(() => setIsAdded(false), 1000);
}
}, [status, isAdding, isPreviewModeEnabled]);
useEffect(() => {
if (!error) return;
if (Array.isArray(error)) {
error.forEach((e) => {
console.error('useCart:error', e.message);
});
} else {
console.error('useCart:error', error);
}
}, [error]);
return {
buttonText,
cartIsUpdating, // cart is updating
failed,
handleAddToCart,
handleNotifyMe,
isAdded, // line is added (true for only a second)
isAdding, // line is adding
isNotifyMe: !!variantIsSoldOut && enabledNotifyMe,
isSoldOut: !!variantIsSoldOut,
subtext: productSettings?.addToCart?.subtext,
};
}
- Ensure that no third party or supplemental logic was removed uninintentionally. If so incorporate back in accordingly
useCartLine Hook
- Update
useCartLine.tsto be:
import {useCallback, useEffect, useMemo, useRef, useState} from 'react';
import debounce from 'lodash/debounce';
import type {CartLine} from '@shopify/hydrogen/storefront-api-types';
import {useCart} from '~/hooks';
export const useCartLine = ({line}: {line: CartLine}) => {
const {id, quantity} = {...line};
const {linesRemove, linesUpdate, status} = useCart();
const [optimisticQuantity, setOptimisticQuantity] = useState(quantity);
/*
* Optimistic quantity debounce logic
*/
const handleDebouncedDecrement = useCallback(async () => {
if (optimisticQuantity > 0) {
const data = await linesUpdate([{id, quantity: optimisticQuantity}]);
if (data && !data.cart) setOptimisticQuantity(quantity);
} else {
const data = await linesRemove([id]);
if (data && !data.cart) setOptimisticQuantity(quantity);
}
}, [id, linesRemove, linesUpdate, optimisticQuantity, quantity, status]);
const handleDebouncedIncrement = useCallback(async () => {
const data = await linesUpdate([{id, quantity: optimisticQuantity}]);
if (data && !data.cart) setOptimisticQuantity(quantity);
}, [id, linesUpdate, optimisticQuantity, quantity, status]);
const debouncedDecrementRef = useRef(handleDebouncedDecrement);
const debouncedIncrementRef = useRef(handleDebouncedIncrement);
useEffect(() => {
debouncedDecrementRef.current = handleDebouncedDecrement;
debouncedIncrementRef.current = handleDebouncedIncrement;
}, [handleDebouncedDecrement, handleDebouncedIncrement]);
const doDecrementCallbackWithDebounce = useMemo(() => {
const callback = () => debouncedDecrementRef.current();
return debounce(callback, 200);
}, []);
const doIncrementCallbackWithDebounce = useMemo(() => {
const callback = () => debouncedIncrementRef.current();
return debounce(callback, 200);
}, []);
/*
* Cart line handlers
*/
const handleDecrement = useCallback(() => {
if (optimisticQuantity > 0) {
doDecrementCallbackWithDebounce();
setOptimisticQuantity(optimisticQuantity - 1);
} else {
setOptimisticQuantity(0);
linesRemove([id]);
}
}, [doDecrementCallbackWithDebounce, id, linesRemove, optimisticQuantity]);
const handleIncrement = useCallback(() => {
doIncrementCallbackWithDebounce();
setOptimisticQuantity(optimisticQuantity + 1);
}, [doIncrementCallbackWithDebounce, optimisticQuantity]);
const handleRemove = useCallback(async () => {
setOptimisticQuantity(0);
const data = await linesRemove([id]);
if (data && !data.cart) setOptimisticQuantity(quantity);
}, [id, linesRemove, quantity, status]);
return {
handleDecrement,
handleIncrement,
handleRemove,
optimisticQuantity,
};
};
- Update
CartLine.tsxor whereveruseCartLine()is used accordingly:
- Destructure
optimisticQuantityfromuseCart()and pass as thequantityattribute to<QuantitySelector />; removequantitydestructure fromlineif it's no longer used anywhere else - Remove
isUpdatingLinefromuseCart()destructured object and removeisUpdatingattribute from<QuantitySelector /> <QuantitySelector />should look something like:
<QuantitySelector
handleDecrement={handleDecrement}
handleIncrement={handleIncrement}
productTitle={merchandise.product.title}
quantity={optimisticQuantity}
/>
- Wrap the entire
CartLinereturn with aoptimisticQuantity > 0conditional, e.g.
return optimisticQuantity > 0 ? <div> {/* Cart line */} </div> : null;
Graphql Update
- Find where the Graphql fragment
CART_FRAGMENTis defined (likelyapp/data/graphql/storefront/cart) - Replace
fragment CartFragment on Cartwithfragment CartApiQuery on Cart - In
server.ts, update or add the following arguments tocreateCartHandler:
setCartId: cartSetIdDefault({domain: getCookieDomain(request.url)}),
cartQueryFragment: CART_FRAGMENT,
cartMutateFragment: CART_FRAGMENT.replace(
'CartApiQuery',
'CartApiMutation',
).replace('$numCartLines', '250'),
- First if
getCookieDomainis exported fromapp/lib/server-utils/app.server.ts, import it from there. Otherwise ifgetCookieDomainis not already exported from~/lib/utilsadd it todocument.utils.ts:
export const getCookieDomain = (url: string) => {
try {
const {hostname} = new URL(url);
const domainParts = hostname.split('.');
return `.${
domainParts?.length > 2 ? domainParts.slice(-2).join('.') : hostname
}`;
} catch (error) {
console.error(`getCookieDomain:error:`, error);
return '';
}
};
/**
* Validates that a url is local
* @param url
* @returns `true` if local `false`if external domain
*/
export function isLocalPath(url: string) {
try {
// We don't want to redirect cross domain,
// doing so could create fishing vulnerability
// If `new URL()` succeeds, it's a fully qualified
// url which is cross domain. If it fails, it's just
// a path, which will be the current domain.
new URL(url);
} catch (e) {
return true;
}
return false;
}
Update useCart Imports
- Find every file where
useCartis imported from@shopify/hydrogen-react, then remove the import - Import instead
useCartfrom the~/hooksfolder, i.e.import {useCart} from '~/hooks';. Ensure the import is combined with any existing imports from~/hooks
Analytics
- In either the
app/contextsproviderAnalyticsProvider.tsx(if it exists), or otherwiseLayout.tsx, i.e. wherever<Analytics.Provider>from@shopify/hydrogenis rendered:
- Destructure
cartfromuseRootLoaderData() - Replace the existing
cartattribute in<Analytics.Provider>withcart={cart as Promise<Cart>} - Add
import type {Cart} from '@shopify/hydrogen/storefront-api-types'; - If
const cartForAnalytics = useCartForAnalytics();is defined, remove that; orcartForAnalyticsis defined locally with auseMemoremove that, as well as any mention ofcartfromuseCartandisCartReadyfromuseGlobal - Remove unused imports
- If
useCartForAnalytics.tsdoes exist underapp/hooks/cart, delete the file and remove its export fromindex.ts
- In either
app/components/Analytics/constants.tsorapp/components/PackAnalytics/constants.ts(legacy), update the const either namedAnalyticsEventorPackEventName(legacy) accordingly:
export const AnalyticsEvent = { // legacy is called `PackEventName`
...HydrogenAnalyticsEvent, // legacy spreads ...AnalyticsEvent
PRODUCT_ADD_TO_CART: 'custom_product_added_to_cart',
PRODUCT_REMOVED_FROM_CART: 'custom_product_removed_from_cart',
...
} as Omit<
typeof HydrogenAnalyticsEvent,
'PRODUCT_ADD_TO_CART' | 'PRODUCT_REMOVED_FROM_CART'
> & {
PRODUCT_ADD_TO_CART: 'custom_product_added_to_cart';
PRODUCT_REMOVED_FROM_CART: 'custom_product_removed_from_cart';
...
};
Additional Cart Routes
In app/routes add or update the following routes accordingly:
($locale).cart.$lines.tsx
import {redirect, type LoaderFunctionArgs} from '@shopify/remix-oxygen';
/**
* Automatically creates a new cart based on the URL and redirects straight to checkout.
* Expected URL structure:
* ```ts
* /cart/<variant_id>:<quantity>
*
* ```
* More than one `<variant_id>:<quantity>` separated by a comma, can be supplied in the URL, for
* carts with more than one product variant.
*
* @param `?discount` an optional discount code to apply to the cart
* @example
* Example path creating a cart with two product variants, different quantities, and a discount code:
* ```ts
* /cart/41007289663544:1,41007289696312:2?discount=HYDROBOARD
*
* ```
* @preserve
*/
export async function loader({request, context, params}: LoaderFunctionArgs) {
const {cart} = context;
const {lines} = params;
const linesMap = lines?.split(',').map((line) => {
const lineDetails = line.split(':');
const variantId = lineDetails[0];
const quantity = parseInt(lineDetails[1], 10);
return {
merchandiseId: `gid://shopify/ProductVariant/${variantId}`,
quantity,
};
});
const url = new URL(request.url);
const searchParams = new URLSearchParams(url.search);
const discount = searchParams.get('discount');
const discountArray = discount ? [discount] : [];
//! create a cart
const result = await cart.create({
lines: linesMap,
discountCodes: discountArray,
});
const cartResult = result.cart;
if (result.errors?.length || !cartResult) {
throw new Response('Link may be expired. Try checking the URL.', {
status: 410,
});
}
// Update cart id in cookie
const headers = cart.setCartId(cartResult.id);
//! redirect to checkout
if (cartResult.checkoutUrl) {
return redirect(cartResult.checkoutUrl, {headers});
} else {
throw new Error('No checkout URL found');
}
}
export default function Component() {
return null;
}
($locale).discounts.$code.tsx
import {redirect, type LoaderFunctionArgs} from '@shopify/remix-oxygen';
/**
* Automatically applies a discount found on the url
* If a cart exists it's updated with the discount, otherwise a cart is created with the discount already applied
* @param ?redirect an optional path to return to otherwise return to the home page
* @example
* Example path applying a discount and redirecting
* ```ts
* /discount/FREESHIPPING?redirect=/products
*
* ```
* @preserve
*/
export async function loader({request, context, params}: LoaderFunctionArgs) {
const {cart} = context;
// N.B. This route will probably be removed in the future.
const session = context.session as any;
const {code} = params;
const url = new URL(request.url);
const searchParams = new URLSearchParams(url.search);
let redirectParam =
searchParams.get('redirect') || searchParams.get('return_to') || '/';
if (redirectParam.includes('//')) {
// Avoid redirecting to external URLs to prevent phishing attacks
redirectParam = '/';
}
searchParams.delete('redirect');
searchParams.delete('return_to');
const redirectUrl = `${redirectParam}?${searchParams}`;
if (!code) {
return redirect(redirectUrl);
}
const result = await cart.updateDiscountCodes([code]);
const headers = cart.setCartId(result.cart.id);
// Using set-cookie on a 303 redirect will not work if the domain origin have port number (:3000)
// If there is no cart id and a new cart id is created in the progress, it will not be set in the cookie
// on localhost:3000
return redirect(redirectUrl, {
status: 303,
headers,
});
}
Cart Typescript
- In
app/lib/typesin eithercontext.types.tsorglobal.types.ts, add the following cart context provider types:
export type CartStatus =
| 'uninitialized'
| 'creating'
| 'fetching'
| 'updating'
| 'idle';
export type CartError = unknown;
export interface CartState {
cart: Cart | null;
status: CartStatus;
error: CartError;
}
export interface CartActions {
setCart: (cart: Cart) => void;
setStatus: (status: CartState['status']) => void;
setError: (error: unknown) => void;
}
export interface CartContext {
state: CartState;
actions: CartActions;
}
- Create a new file in in
app/lib/typescalledcart.types.ts
import type {
Cart,
CartBuyerIdentityInput,
CartInput,
CartLine,
CartLineInput,
CartLineUpdateInput,
AttributeInput,
UserError,
CartWarning,
} from '@shopify/hydrogen/storefront-api-types';
import type {CartError, CartStatus} from './context.types';
export interface CartActionData {
cart: Cart | null;
userErrors: UserError[] | null;
warnings: CartWarning[] | null;
}
export type CartWithActions = Omit<Cart, 'lines'> & {
lines: CartLine[];
cartCreate: (cartInput: CartInput) => Promise<CartActionData | null>;
linesAdd: (lines: CartLineInput[]) => Promise<CartActionData | null>;
linesUpdate: (lines: CartLineUpdateInput[]) => Promise<CartActionData | null>;
linesRemove: (lineIds: string[]) => Promise<CartActionData | null>;
discountCodesUpdate: (
discountCodes: string[],
) => Promise<CartActionData | null>;
cartAttributesUpdate: (
attributes: AttributeInput[],
) => Promise<CartActionData | null>;
buyerIdentityUpdate: (
buyerIdentity: CartBuyerIdentityInput,
) => Promise<CartActionData | null>;
noteUpdate: (note: string) => Promise<CartActionData | null>;
status: CartStatus;
error: CartError;
};
- Ensure
cart.types.tsgets exported in the/typesindex.tsfile - If
FreeShippingMeter.tsxexists, remove the type defintion foruseCart()if it exists, i.e. change= useCart() as CartWithActions & {discountAllocations: Cart['discountAllocations'];}; to just= useCart() - In
CartTotals.tsx
- Remove the same type defintion for
useCart()as above - Remove the type
Cart['discountAllocations']fromparsedDiscountAllocations - Remove any unused imports
Misc
- If
CartUpsell.tsxoriginated from the Blueprint template exists (there'll be auseEffectthat has a conditional forif (status === 'idle')and hasfullProductsDepas a dependency), do:
- Remove
cartLinesorlinesfrom thatuseEffectdependencies, and instead destructure outupdatedAtfromuseCart()and add that to theuseEffectdependencies - This step is important to prevent an infinite loop caused by this migration with this particular component
useCartUpdateAttributes.tsxin theapp/hooks/cartfolder can be removed, and its export removed from theindex.tsfile
- Wherever the function
updateCartAttributesis used fromuseCartUpdateAttributes(), replace it withcartAttributesUpdatedestructured fromuseCart() - Remove old import
LLM Prompts
Combined prompt
This prompt outlines the steps to migrate a Pack Hydrogen storefront using client-side cart with server-side cart. This will include:
- New `($locale).api.cart.tsx` route
- New `CartProvider` and `PreviewModeCartProvider` context providers
- New `useCart` hook
- Update to `useAddToCart` and `useCartLine` hooks
- Update to Graphql fragments
- Updates all `useCart` imports from `@shopify/hydrogen-react` with from `~/hooks`
- Update cart analytics logic
- Update to `($locale).cart.$lines.tsx` and `($locale).discounts.$code.tsx`
- New cart typescript types
- Necessary tweak to `CartUpsell.tsx` (if applicable)
After reading all these steps, separate them into their applicable groups and create checklists, then execute all the tasks.
### Cart route
1. Add `($locale).api.cart.tsx` to `app/routes`
/* Code starts below. Do not include this line */
import {CartForm} from '@shopify/hydrogen';
import type {
ActionFunctionArgs,
LoaderFunctionArgs,
} from '@shopify/remix-oxygen';
import type {CartQueryDataReturn} from '@shopify/hydrogen';
import type {Cart} from '@shopify/hydrogen/storefront-api-types';
import {isLocalPath} from '~/lib/utils';
const getParsedJson = (value: FormDataEntryValue | null) => {
if (!value || typeof value !== 'string') return value;
try {
return JSON.parse(value);
} catch (error) {
return value;
}
};
export async function action({request, context}: ActionFunctionArgs) {
const {cart} = context;
const formData = await request.formData();
const action = formData.get('action');
let status = 200;
let result: CartQueryDataReturn;
if (!action)
return Response.json({errors: ['Missing `action` in body']}, {status: 400});
switch (action) {
case CartForm.ACTIONS.Create:
result = await cart.create(getParsedJson(formData.get('cart')));
break;
case CartForm.ACTIONS.LinesAdd:
result = await cart.addLines(getParsedJson(formData.get('lines')));
break;
case CartForm.ACTIONS.LinesUpdate:
result = await cart.updateLines(getParsedJson(formData.get('lines')));
break;
case CartForm.ACTIONS.LinesRemove:
result = await cart.removeLines(getParsedJson(formData.get('lineIds')));
break;
case CartForm.ACTIONS.DiscountCodesUpdate:
const formDiscountCodes = getParsedJson(formData.get('discountCodes'));
const discountCodes = (
Array.isArray(formDiscountCodes) ? formDiscountCodes : []
) as string[];
// Combine discount codes already applied on cart
const existingCartWithDiscountCodes = (await cart.get()) as Cart;
if (existingCartWithDiscountCodes) {
discountCodes.push(
...(existingCartWithDiscountCodes.discountCodes?.map(
({code}) => code,
) || []),
);
}
result = await cart.updateDiscountCodes(discountCodes);
break;
case CartForm.ACTIONS.BuyerIdentityUpdate:
result = await cart.updateBuyerIdentity(
getParsedJson(formData.get('buyerIdentity')),
);
break;
case CartForm.ACTIONS.AttributesUpdateInput:
const formAttributes = getParsedJson(formData.get('attributes'));
const attributes = Array.isArray(formAttributes) ? formAttributes : [];
// Combine cart attributes in cart
const existingCartWithAttributes = (await cart.get()) as Cart;
if (existingCartWithAttributes) {
attributes.push(...(existingCartWithAttributes.attributes || []));
}
result = await cart.updateAttributes(attributes);
break;
case CartForm.ACTIONS.NoteUpdate:
result = await cart.updateNote(formData.get('note') as string);
break;
default:
return Response.json(
{errors: [`${action} cart action is not defined`]},
{status: 400},
);
}
/**
* The Cart ID may change after each mutation. We need to update it each time in the session.
*/
let headers = new Headers();
if (result.cart?.id) headers = cart.setCartId(result.cart.id);
const redirectTo = formData.get('redirectTo') ?? null;
if (typeof redirectTo === 'string' && isLocalPath(redirectTo)) {
status = 303;
headers.set('Location', redirectTo);
}
const {cart: cartResult, warnings, userErrors} = result;
return Response.json(
{
cart: cartResult,
userErrors,
warnings,
},
{status, headers},
);
}
export async function loader({context}: LoaderFunctionArgs) {
return Response.json({cart: await context.cart.get()});
}
/* Code ends above. Do not include this line */
2. Add `isLocalPath` to `app/lib/utils/document.utils.ts`:
/* Code starts below. Do not include this line */
/**
* Validates that a url is local
* @param url
* @returns `true` if local `false`if external domain
*/
export function isLocalPath(url: string) {
try {
// We don't want to redirect cross domain,
// doing so could create fishing vulnerability
// If `new URL()` succeeds, it's a fully qualified
// url which is cross domain. If it fails, it's just
// a path, which will be the current domain.
new URL(url);
} catch (e) {
return true;
}
return false;
}
/* Code ends above. Do not include this line */
### Providers
1. In `root.tsx` loader:
- Destructure out `cart` from `context`
- Return `cart.get()` as `cart` from the loader's return, e.g. `return {analytics, cart: cart.get(), ... }`
2. In the `app/contexts` folder, create folder `CartProvider`
3. Add following files:
`CartProvider.tsx`
/* Code starts below. Do not include this line */
import {useEffect, useMemo, useReducer} from 'react';
import type {ReactNode} from 'react';
import type {Cart} from '@shopify/hydrogen/storefront-api-types';
import type {Action, CartState, Dispatch} from '~/lib/types';
import {useRootLoaderData} from '~/hooks';
import {Context} from './useCartContext';
const cartState = {
cart: null,
status: 'uninitialized',
error: null,
};
const reducer = (state: CartState, action: Action) => {
switch (action.type) {
case 'SET_CART':
return {
...state,
cart: action.payload,
};
case 'SET_STATUS':
return {
...state,
status: action.payload,
};
case 'SET_ERROR':
return {
...state,
error: action.payload,
};
default:
throw new Error(`Invalid Context action of type: ${action.type}`);
}
};
const actions = (dispatch: Dispatch) => ({
setCart: (cart: Cart) => dispatch({type: 'SET_CART', payload: cart}),
setStatus: (status: CartState['status']) =>
dispatch({type: 'SET_STATUS', payload: status}),
setError: (error: unknown) => dispatch({type: 'SET_ERROR', payload: error}),
});
export function CartProvider({children}: {children: ReactNode}) {
const {cart} = useRootLoaderData();
const [state, dispatch] = useReducer(reducer, cartState);
const value = useMemo(() => ({state, actions: actions(dispatch)}), [state]);
/*
* Fetch cart on initial load and set in context
*/
useEffect(() => {
if (value.state.status !== 'uninitialized') return;
const setCart = async () => {
value.actions.setError(null);
const cartData = await (cart as Promise<Cart>);
value.actions.setCart(cartData);
if (cartData) value.actions.setStatus('idle');
};
setCart();
}, [cart, value.state.status === 'uninitialized']);
return <Context.Provider value={value}>{children}</Context.Provider>;
}
/* Code ends above. Do not include this line */
`useCartContext.ts`
/* Code starts below. Do not include this line */
import {createContext, useContext} from 'react';
import type {CartContext} from '~/lib/types';
export const Context = createContext({
state: {},
actions: {},
} as CartContext);
export const useCartContext = () => useContext(Context);
/* Code ends above. Do not include this line */
4. In the `app/contexts` folder, add the file `PreviewModeCartProvider.tsx`
/* Code starts below. Do not include this line */
import {useMemo} from 'react';
import {CartProvider, ShopifyProvider} from '@shopify/hydrogen-react';
import type {ReactNode} from 'react';
import {CART_FRAGMENT} from '~/data/graphql/storefront/cart';
import {useLocale, useRootLoaderData} from '~/hooks';
import {DEFAULT_STOREFRONT_API_VERSION} from '~/lib/constants';
export function PreviewModeCartProvider({children}: {children: ReactNode}) {
const {ENV, isPreviewModeEnabled} = useRootLoaderData();
const locale = useLocale();
const clientSideCartFragment = useMemo(() => {
return CART_FRAGMENT.replace('CartApiQuery', 'CartFragment');
}, [CART_FRAGMENT]);
if (isPreviewModeEnabled) {
return (
<ShopifyProvider
storeDomain={`https://${ENV.PUBLIC_STORE_DOMAIN}`}
storefrontToken={ENV.PUBLIC_STOREFRONT_API_TOKEN}
storefrontApiVersion={DEFAULT_STOREFRONT_API_VERSION}
countryIsoCode={locale.country}
languageIsoCode={locale.language}
>
<CartProvider cartFragment={clientSideCartFragment}>
{children}
</CartProvider>
</ShopifyProvider>
);
}
return children;
}
PreviewModeCartProvider.displayName = 'PreviewModeCartProvider';
/* Code ends above. Do not include this line */
5. Find wherever `<<ShopifyProvider><CartProvider> */ ... /* </CartProvider></<ShopifyProvider>` is rendered, usually `Document.tsx`
- Remove those two providers
- Add the two new cart providers as the outer most providers in `ContextsProvider.tsx` if it exists, otherwise add them to the same place `ShopifyProvider` and original `CartProvider` were placed:
/* Code starts below. Do not include this line */
<PreviewModeCartProvider>
<CartProvider>*/ Other providers, code, etc /*</CartProvider>
</PreviewModeCartProvider>
/* Code ends above. Do not include this line */
- Import `PreviewModeCartProvider` and `CartProvider` accordingly
---
### useCart hook
1. Add `useCart.ts` to `app/hooks/cart`
/* Code starts below. Do not include this line */
import {useCallback} from 'react';
import {CartForm, flattenConnection, useAnalytics} from '@shopify/hydrogen';
import {useCart as useClientSideCart} from '@shopify/hydrogen-react';
import equal from 'fast-deep-equal';
import type {
Cart,
CartBuyerIdentityInput,
CartLine,
CartLineInput,
} from '@shopify/hydrogen/storefront-api-types';
import {useCartContext} from '~/contexts/CartProvider/useCartContext';
import {AnalyticsEvent} from '~/components/Analytics/constants';
import type {CartActionData, CartWithActions} from '~/lib/types';
import {useRootLoaderData} from '../useRootLoaderData';
export const useCart = (): CartWithActions => {
const {isPreviewModeEnabled} = useRootLoaderData();
const {publish, shop} = useAnalytics();
const {
state: {cart, status, error},
actions: {setCart, setError, setStatus},
} = useCartContext();
const getCartActionData = useCallback(
async (formData: FormData): Promise<CartActionData | null> => {
const action = formData.get('action');
let data = null as CartActionData | null;
setError(null);
setStatus(action === CartForm.ACTIONS.Create ? 'creating' : 'updating');
const response = await fetch('/api/cart', {
method: 'POST',
body: formData,
});
data = await response.json();
if (data?.userErrors?.length) {
setError(data?.userErrors);
}
if (data?.cart) {
setCart(data.cart);
}
setStatus('idle');
return data;
},
[],
);
const cartCreate: CartWithActions['cartCreate'] = useCallback(
async (cartInput) => {
const formData = new FormData();
formData.append('action', CartForm.ACTIONS.Create);
formData.append('cart', JSON.stringify(cartInput));
return getCartActionData(formData);
},
[],
);
const linesAdd: CartWithActions['linesAdd'] = useCallback(
async (lines) => {
const formData = new FormData();
formData.append('action', CartForm.ACTIONS.LinesAdd);
formData.append('lines', JSON.stringify(lines));
const data = await getCartActionData(formData);
if (data?.cart) {
lines.forEach((lineInput) => {
const prevLine =
flattenConnection(cart?.lines).find((prevCartLine) => {
return compareInputWithLine(lineInput, prevCartLine as CartLine);
}) || null;
let currentLine =
flattenConnection(data.cart?.lines).find((currentCartLine) => {
return compareInputWithLine(
lineInput,
currentCartLine as CartLine,
);
}) || null;
// If current line cannot be matched through comparing attributes, fallback to ignoring attributes and just find matching merchandise id for sake of analytics event
if (!currentLine) {
currentLine =
flattenConnection(data.cart?.lines).find((currentCartLine) => {
return (
currentCartLine.merchandise.id === lineInput.merchandiseId &&
lineInput.sellingPlanId ===
currentCartLine.sellingPlanAllocation?.sellingPlan?.id
);
}) || null;
}
publish(AnalyticsEvent.PRODUCT_ADD_TO_CART, {
cart: data.cart,
prevCart: cart,
currentLine,
prevLine,
shop,
});
});
}
return data;
},
[cart, publish, shop],
);
const linesUpdate: CartWithActions['linesUpdate'] = useCallback(
async (lines) => {
const formData = new FormData();
formData.append('action', CartForm.ACTIONS.LinesUpdate);
formData.append('lines', JSON.stringify(lines));
const data = await getCartActionData(formData);
if (data?.cart) {
lines.forEach((lineInput) => {
const prevLine =
flattenConnection(cart?.lines).find((prevCartLine) => {
return prevCartLine.id === lineInput.id;
}) || null;
const currentLine =
flattenConnection(data.cart?.lines).find((currentCartLine) => {
return currentCartLine.id === lineInput.id;
}) || null;
if ((currentLine?.quantity || 0) > (prevLine?.quantity || 0)) {
publish(AnalyticsEvent.PRODUCT_ADD_TO_CART, {
cart: data.cart,
prevCart: cart,
currentLine,
prevLine,
shop,
});
} else if ((currentLine?.quantity || 0) < (prevLine?.quantity || 0)) {
publish(AnalyticsEvent.PRODUCT_REMOVED_FROM_CART, {
cart: data.cart,
prevCart: cart,
currentLine,
prevLine,
shop,
});
}
});
}
return data;
},
[cart, publish, shop],
);
const linesRemove: CartWithActions['linesRemove'] = useCallback(
async (lineIds) => {
const formData = new FormData();
formData.append('action', CartForm.ACTIONS.LinesRemove);
formData.append('lineIds', JSON.stringify(lineIds));
const data = await getCartActionData(formData);
if (data?.cart) {
lineIds.forEach((lineId) => {
const prevLine =
flattenConnection(cart?.lines).find((prevCartLine) => {
return prevCartLine.id === lineId;
}) || null;
const currentLine =
flattenConnection(data.cart?.lines).find((currentCartLine) => {
return currentCartLine.id === lineId;
}) || null;
publish(AnalyticsEvent.PRODUCT_REMOVED_FROM_CART, {
cart: data.cart,
prevCart: cart,
currentLine,
prevLine,
shop,
});
});
}
return data;
},
[cart, publish, shop],
);
const discountCodesUpdate: CartWithActions['discountCodesUpdate'] =
useCallback(async (discountCodes) => {
const formData = new FormData();
formData.append('action', CartForm.ACTIONS.DiscountCodesUpdate);
formData.append('discountCodes', JSON.stringify(discountCodes));
return getCartActionData(formData);
}, []);
const cartAttributesUpdate: CartWithActions['cartAttributesUpdate'] =
useCallback(async (attributes) => {
const formData = new FormData();
formData.append('action', CartForm.ACTIONS.AttributesUpdateInput);
formData.append('attributes', JSON.stringify(attributes));
return getCartActionData(formData);
}, []);
const buyerIdentityUpdate: CartWithActions['buyerIdentityUpdate'] =
useCallback(
async (
buyerIdentity: CartBuyerIdentityInput,
): Promise<CartActionData | null> => {
const formData = new FormData();
formData.append('action', CartForm.ACTIONS.BuyerIdentityUpdate);
formData.append('buyerIdentity', JSON.stringify(buyerIdentity));
return getCartActionData(formData);
},
[],
);
const noteUpdate: CartWithActions['noteUpdate'] = useCallback(
async (note) => {
const formData = new FormData();
formData.append('action', CartForm.ACTIONS.NoteUpdate);
formData.append('note', note);
return getCartActionData(formData);
},
[],
);
return (
isPreviewModeEnabled
? // eslint-disable-next-line react-hooks/rules-of-hooks
useClientSideCart()
: {
...cart,
...(cart?.lines ? {lines: flattenConnection(cart.lines)} : null),
buyerIdentityUpdate,
cartAttributesUpdate,
cartCreate,
discountCodesUpdate,
error,
linesAdd,
linesRemove,
linesUpdate,
noteUpdate,
status,
}
) as CartWithActions;
};
const compareInputWithLine = (input: CartLineInput, line: CartLine) => {
const inputAttrLength = input.attributes ? input.attributes.length : 0;
const lineAttrLength = line.attributes ? line.attributes.length : 0;
if (inputAttrLength !== lineAttrLength) return false;
if (inputAttrLength || lineAttrLength) {
const inputAttributesByKey = input.attributes?.reduce(
(acc: Record<string, string>, attr) => {
acc[attr.key] = attr.value;
return acc;
},
{},
);
const lineAttributesByKey = line.attributes?.reduce(
(acc: Record<string, string>, attr) => {
acc[attr.key] = attr.value;
return acc;
},
{},
);
if (!equal(inputAttributesByKey, lineAttributesByKey)) return false;
}
if (input.sellingPlanId !== line.sellingPlanAllocation?.sellingPlan?.id)
return false;
return line.merchandise.id === input.merchandiseId;
};
/* Code ends above. Do not include this line */
2. Some legacy stores will instead use `PackEventName` imported from `app/components/PackAnalytics/constants.ts` instead of `AnalyticsEvent`. If this is the case, change the import to `import {PackEventName} from '~/components/PackAnalytics/constants';` and update all instances of `AnalyticsEvent` with `PackEventName`
3. Export `useCart` from the `/cart` `index.ts` file
---
### useAddToCart hook
1. Update `useAddToCart.tsx` to reflect:
/* Code starts below. Do not include this line */
import {useCallback, useEffect, useState} from 'react';
import type {
AttributeInput,
ProductVariant,
SellingPlan,
} from '@shopify/hydrogen/storefront-api-types';
import {useCart, useMenu, useRootLoaderData, useSettings} from '~/hooks';
/**
* Add to cart hook
* @param addToCartTextOverride - Add to cart button text override
* @param attributes - Array of attributes
* @param quantity - Quantity
* @param selectedVariant - Selected variant
* @param sellingPlanId - Selling plan id
* @returns Add to cart hook return
* @example
* `` `tsx
* const {buttonText, cartIsUpdating, handleAddToCart, isAdded, isAdding, isSoldOut, subtext} = useAddToCart({
* addToCartText: 'Add to cart!',
* attributes: [{name: 'Color', value: 'Red'}],
* quantity: 1,
* selectedVariant: product.variants[0],
* });
* `` `
*/
interface UseAddToCartProps {
addToCartText?: string;
attributes?: AttributeInput[];
quantity?: number;
selectedVariant?: ProductVariant | null;
sellingPlanId?: SellingPlan['id'];
}
interface UseAddToCartReturn {
buttonText: string;
cartIsUpdating: boolean;
failed: boolean;
handleAddToCart: () => void;
handleNotifyMe: (component: React.ReactNode) => void;
isAdded: boolean;
isAdding: boolean;
isNotifyMe: boolean;
isSoldOut: boolean;
subtext: string;
}
export function useAddToCart({
addToCartText: addToCartTextOverride = '',
attributes,
quantity = 1,
selectedVariant = null,
sellingPlanId,
}: UseAddToCartProps): UseAddToCartReturn {
const {error, linesAdd, status} = useCart();
const {isPreviewModeEnabled} = useRootLoaderData();
const {product: productSettings} = useSettings();
const {openCart, openModal} = useMenu();
const [isAdding, setIsAdding] = useState(false);
const [isAdded, setIsAdded] = useState(false);
const [failed, setFailed] = useState(false);
const enabledNotifyMe = productSettings?.backInStock?.enabled ?? true;
const variantIsSoldOut = selectedVariant && !selectedVariant.availableForSale;
const variantIsPreorder = !!selectedVariant?.currentlyNotInStock;
let buttonText = '';
if (failed) {
buttonText = 'Failed To Add';
} else if (variantIsPreorder) {
buttonText = productSettings?.addToCart?.preorderText || 'Preorder';
} else if (variantIsSoldOut) {
buttonText = enabledNotifyMe
? productSettings?.backInStock?.notifyMeText || 'Notify Me'
: productSettings?.addToCart?.soldOutText || 'Sold Out';
} else {
buttonText =
addToCartTextOverride ||
productSettings?.addToCart?.addToCartText ||
'Add To Cart';
}
const cartIsUpdating = status === 'creating' || status === 'updating';
const handleAddToCart = useCallback(async () => {
if (!selectedVariant?.id || isAdding || cartIsUpdating) return;
setIsAdding(true);
setFailed(false);
const data = await linesAdd([
{
attributes,
merchandiseId: selectedVariant.id,
quantity,
sellingPlanId,
},
]);
if (data) {
if (data.userErrors?.length) {
setIsAdding(false);
setFailed(true);
setTimeout(() => setFailed(false), 3000);
} else {
setIsAdding(false);
setIsAdded(true);
openCart();
setTimeout(() => setIsAdded(false), 1000);
}
}
}, [
attributes,
isAdding,
linesAdd,
quantity,
selectedVariant?.id,
sellingPlanId,
status,
]);
const handleNotifyMe = useCallback(
(component: React.ReactNode) => {
if (!selectedVariant?.id) return;
openModal(component);
},
[selectedVariant?.id],
);
useEffect(() => {
if (!isPreviewModeEnabled) return;
if (isAdding && status === 'idle') {
setIsAdding(false);
setIsAdded(true);
openCart();
setTimeout(() => setIsAdded(false), 1000);
}
}, [status, isAdding, isPreviewModeEnabled]);
useEffect(() => {
if (!error) return;
if (Array.isArray(error)) {
error.forEach((e) => {
console.error('useCart:error', e.message);
});
} else {
console.error('useCart:error', error);
}
}, [error]);
return {
buttonText,
cartIsUpdating, // cart is updating
failed,
handleAddToCart,
handleNotifyMe,
isAdded, // line is added (true for only a second)
isAdding, // line is adding
isNotifyMe: !!variantIsSoldOut && enabledNotifyMe,
isSoldOut: !!variantIsSoldOut,
subtext: productSettings?.addToCart?.subtext,
};
}
/* Code ends above. Do not include this line */
2. Ensure that no third party or supplemental logic was removed uninintentionally. If so incorporate back in accordingly
---
### useCartLine Hook
1. Update `useCartLine.ts` to be:
/* Code starts below. Do not include this line */
import {useCallback, useEffect, useMemo, useRef, useState} from 'react';
import debounce from 'lodash/debounce';
import type {CartLine} from '@shopify/hydrogen/storefront-api-types';
import {useCart} from '~/hooks';
export const useCartLine = ({line}: {line: CartLine}) => {
const {id, quantity} = {...line};
const {linesRemove, linesUpdate, status} = useCart();
const [optimisticQuantity, setOptimisticQuantity] = useState(quantity);
/*
* Optimistic quantity debounce logic
*/
const handleDebouncedDecrement = useCallback(async () => {
if (optimisticQuantity > 0) {
const data = await linesUpdate([{id, quantity: optimisticQuantity}]);
if (data && !data.cart) setOptimisticQuantity(quantity);
} else {
const data = await linesRemove([id]);
if (data && !data.cart) setOptimisticQuantity(quantity);
}
}, [id, linesRemove, linesUpdate, optimisticQuantity, quantity, status]);
const handleDebouncedIncrement = useCallback(async () => {
const data = await linesUpdate([{id, quantity: optimisticQuantity}]);
if (data && !data.cart) setOptimisticQuantity(quantity);
}, [id, linesUpdate, optimisticQuantity, quantity, status]);
const debouncedDecrementRef = useRef(handleDebouncedDecrement);
const debouncedIncrementRef = useRef(handleDebouncedIncrement);
useEffect(() => {
debouncedDecrementRef.current = handleDebouncedDecrement;
debouncedIncrementRef.current = handleDebouncedIncrement;
}, [handleDebouncedDecrement, handleDebouncedIncrement]);
const doDecrementCallbackWithDebounce = useMemo(() => {
const callback = () => debouncedDecrementRef.current();
return debounce(callback, 200);
}, []);
const doIncrementCallbackWithDebounce = useMemo(() => {
const callback = () => debouncedIncrementRef.current();
return debounce(callback, 200);
}, []);
/*
* Cart line handlers
*/
const handleDecrement = useCallback(() => {
if (optimisticQuantity > 0) {
doDecrementCallbackWithDebounce();
setOptimisticQuantity(optimisticQuantity - 1);
} else {
setOptimisticQuantity(0);
linesRemove([id]);
}
}, [doDecrementCallbackWithDebounce, id, linesRemove, optimisticQuantity]);
const handleIncrement = useCallback(() => {
doIncrementCallbackWithDebounce();
setOptimisticQuantity(optimisticQuantity + 1);
}, [doIncrementCallbackWithDebounce, optimisticQuantity]);
const handleRemove = useCallback(async () => {
setOptimisticQuantity(0);
const data = await linesRemove([id]);
if (data && !data.cart) setOptimisticQuantity(quantity);
}, [id, linesRemove, quantity, status]);
return {
handleDecrement,
handleIncrement,
handleRemove,
optimisticQuantity,
};
};
/* Code ends above. Do not include this line */
2. Update `CartLine.tsx` or wherever `useCartLine()` is used accordingly:
- Destructure `optimisticQuantity` from `useCart()` and pass as the `quantity` attribute to `<QuantitySelector />`; remove `quantity` destructure from `line` if it's no longer used anywhere else
- Remove `isUpdatingLine` from `useCart()` destructured object and remove `isUpdating` attribute from `<QuantitySelector />`
- `<QuantitySelector />` should look something like:
/* Code starts below. Do not include this line */
<QuantitySelector
handleDecrement={handleDecrement}
handleIncrement={handleIncrement}
productTitle={merchandise.product.title}
quantity={optimisticQuantity}
/>
/* Code ends above. Do not include this line */
- Wrap the entire `CartLine` return with a `optimisticQuantity > 0` conditional, e.g.
/* Code starts below. Do not include this line */
return optimisticQuantity > 0 ? <div> {/* Cart line */} </div> : null;
/* Code ends above. Do not include this line */
---
### Graphql Update
1. Find where the Graphql fragment `CART_FRAGMENT` is defined (likely `app/data/graphql/storefront/cart`)
2. Replace `fragment CartFragment on Cart` with `fragment CartApiQuery on Cart`
3. In `server.ts`, update or add the following arguments to `createCartHandler`:
/* Code starts below. Do not include this line */
setCartId: cartSetIdDefault({domain: getCookieDomain(request.url)}),
cartQueryFragment: CART_FRAGMENT,
cartMutateFragment: CART_FRAGMENT.replace(
'CartApiQuery',
'CartApiMutation',
).replace('$numCartLines', '250'),
/* Code ends above. Do not include this line */
4. First if `getCookieDomain` is exported from `app/lib/server-utils/app.server.ts`, import it from there. Otherwise if `getCookieDomain` is not already exported from `~/lib/utils` add it to `document.utils.ts`:
/* Code starts below. Do not include this line */
export const getCookieDomain = (url: string) => {
try {
const {hostname} = new URL(url);
const domainParts = hostname.split('.');
return `.${
domainParts?.length > 2 ? domainParts.slice(-2).join('.') : hostname
}`;
} catch (error) {
console.error(`getCookieDomain:error:`, error);
return '';
}
};
/**
* Validates that a url is local
* @param url
* @returns `true` if local `false`if external domain
*/
export function isLocalPath(url: string) {
try {
// We don't want to redirect cross domain,
// doing so could create fishing vulnerability
// If `new URL()` succeeds, it's a fully qualified
// url which is cross domain. If it fails, it's just
// a path, which will be the current domain.
new URL(url);
} catch (e) {
return true;
}
return false;
}
/* Code ends above. Do not include this line */
---
### Update useCart Imports
1. Find every file where `useCart` is imported from `@shopify/hydrogen-react`, then remove the import
2. Import instead `useCart` from the `~/hooks` folder, i.e. `import {useCart} from '~/hooks';`. Ensure the import is combined with any existing imports from `~/hooks`
---
### Analytics
1. In either the `app/contexts` provider `AnalyticsProvider.tsx` (if it exists), or otherwise `Layout.tsx`, i.e. wherever `<Analytics.Provider>` from `@shopify/hydrogen` is rendered:
- Destructure `cart` from `useRootLoaderData()`
- Replace the existing `cart` attribute in `<Analytics.Provider>` with `cart={cart as Promise<Cart>}`
- Add `import type {Cart} from '@shopify/hydrogen/storefront-api-types';`
- If `const cartForAnalytics = useCartForAnalytics();` is defined, remove that; or `cartForAnalytics` is defined locally with a `useMemo` remove that, as well as any mention of `cart` from `useCart` and `isCartReady` from `useGlobal`
- Remove unused imports
- If `useCartForAnalytics.ts` does exist under `app/hooks/cart`, delete the file and remove its export from `index.ts`
2. In either `app/components/Analytics/constants.ts` or `app/components/PackAnalytics/constants.ts` (legacy), update the const either named `AnalyticsEvent` or `PackEventName` (legacy) accordingly:
/* Code starts below. Do not include this line */
export const AnalyticsEvent = { // legacy is called `PackEventName`
...HydrogenAnalyticsEvent, // legacy spreads ...AnalyticsEvent
PRODUCT_ADD_TO_CART: 'custom_product_added_to_cart',
PRODUCT_REMOVED_FROM_CART: 'custom_product_removed_from_cart',
...
} as Omit<
typeof HydrogenAnalyticsEvent,
'PRODUCT_ADD_TO_CART' | 'PRODUCT_REMOVED_FROM_CART'
> & {
PRODUCT_ADD_TO_CART: 'custom_product_added_to_cart';
PRODUCT_REMOVED_FROM_CART: 'custom_product_removed_from_cart';
...
};
/* Code ends above. Do not include this line */
---
### Additional Cart Routes
In `app/routes` add or update the following routes accordingly:
`($locale).cart.$lines.tsx`
/* Code starts below. Do not include this line */
import {redirect, type LoaderFunctionArgs} from '@shopify/remix-oxygen';
/**
* Automatically creates a new cart based on the URL and redirects straight to checkout.
* Expected URL structure:
* /* Code ends above. Do not include this line */ts
* /cart/<variant_id>:<quantity>
*
* /* Code ends above. Do not include this line */
* More than one `<variant_id>:<quantity>` separated by a comma, can be supplied in the URL, for
* carts with more than one product variant.
*
* @param `?discount` an optional discount code to apply to the cart
* @example
* Example path creating a cart with two product variants, different quantities, and a discount code:
* /* Code ends above. Do not include this line */ts
* /cart/41007289663544:1,41007289696312:2?discount=HYDROBOARD
*
* /* Code ends above. Do not include this line */
* @preserve
*/
export async function loader({request, context, params}: LoaderFunctionArgs) {
const {cart} = context;
const {lines} = params;
const linesMap = lines?.split(',').map((line) => {
const lineDetails = line.split(':');
const variantId = lineDetails[0];
const quantity = parseInt(lineDetails[1], 10);
return {
merchandiseId: `gid://shopify/ProductVariant/${variantId}`,
quantity,
};
});
const url = new URL(request.url);
const searchParams = new URLSearchParams(url.search);
const discount = searchParams.get('discount');
const discountArray = discount ? [discount] : [];
//! create a cart
const result = await cart.create({
lines: linesMap,
discountCodes: discountArray,
});
const cartResult = result.cart;
if (result.errors?.length || !cartResult) {
throw new Response('Link may be expired. Try checking the URL.', {
status: 410,
});
}
// Update cart id in cookie
const headers = cart.setCartId(cartResult.id);
//! redirect to checkout
if (cartResult.checkoutUrl) {
return redirect(cartResult.checkoutUrl, {headers});
} else {
throw new Error('No checkout URL found');
}
}
export default function Component() {
return null;
}
/* Code ends above. Do not include this line */
`($locale).discounts.$code.tsx`
/* Code starts below. Do not include this line */
import {redirect, type LoaderFunctionArgs} from '@shopify/remix-oxygen';
/**
* Automatically applies a discount found on the url
* If a cart exists it's updated with the discount, otherwise a cart is created with the discount already applied
* @param ?redirect an optional path to return to otherwise return to the home page
* @example
* Example path applying a discount and redirecting
* /* Code ends above. Do not include this line */ts
* /discount/FREESHIPPING?redirect=/products
*
* /* Code ends above. Do not include this line */
* @preserve
*/
export async function loader({request, context, params}: LoaderFunctionArgs) {
const {cart} = context;
// N.B. This route will probably be removed in the future.
const session = context.session as any;
const {code} = params;
const url = new URL(request.url);
const searchParams = new URLSearchParams(url.search);
let redirectParam =
searchParams.get('redirect') || searchParams.get('return_to') || '/';
if (redirectParam.includes('//')) {
// Avoid redirecting to external URLs to prevent phishing attacks
redirectParam = '/';
}
searchParams.delete('redirect');
searchParams.delete('return_to');
const redirectUrl = `${redirectParam}?${searchParams}`;
if (!code) {
return redirect(redirectUrl);
}
const result = await cart.updateDiscountCodes([code]);
const headers = cart.setCartId(result.cart.id);
// Using set-cookie on a 303 redirect will not work if the domain origin have port number (:3000)
// If there is no cart id and a new cart id is created in the progress, it will not be set in the cookie
// on localhost:3000
return redirect(redirectUrl, {
status: 303,
headers,
});
}
/* Code ends above. Do not include this line */
---
### Cart Typescript
1. In `app/lib/types` in either `context.types.ts` or `global.types.ts`, add the following cart context provider types:
/* Code starts below. Do not include this line */
export type CartStatus =
| 'uninitialized'
| 'creating'
| 'fetching'
| 'updating'
| 'idle';
export type CartError = unknown;
export interface CartState {
cart: Cart | null;
status: CartStatus;
error: CartError;
}
export interface CartActions {
setCart: (cart: Cart) => void;
setStatus: (status: CartState['status']) => void;
setError: (error: unknown) => void;
}
export interface CartContext {
state: CartState;
actions: CartActions;
}
/* Code ends above. Do not include this line */
3. Create a new file in in `app/lib/types` called `cart.types.ts`
/* Code starts below. Do not include this line */
import type {
Cart,
CartBuyerIdentityInput,
CartInput,
CartLine,
CartLineInput,
CartLineUpdateInput,
AttributeInput,
UserError,
CartWarning,
} from '@shopify/hydrogen/storefront-api-types';
import type {CartError, CartStatus} from './context.types';
export interface CartActionData {
cart: Cart | null;
userErrors: UserError[] | null;
warnings: CartWarning[] | null;
}
export type CartWithActions = Omit<Cart, 'lines'> & {
lines: CartLine[];
cartCreate: (cartInput: CartInput) => Promise<CartActionData | null>;
linesAdd: (lines: CartLineInput[]) => Promise<CartActionData | null>;
linesUpdate: (lines: CartLineUpdateInput[]) => Promise<CartActionData | null>;
linesRemove: (lineIds: string[]) => Promise<CartActionData | null>;
discountCodesUpdate: (
discountCodes: string[],
) => Promise<CartActionData | null>;
cartAttributesUpdate: (
attributes: AttributeInput[],
) => Promise<CartActionData | null>;
buyerIdentityUpdate: (
buyerIdentity: CartBuyerIdentityInput,
) => Promise<CartActionData | null>;
noteUpdate: (note: string) => Promise<CartActionData | null>;
status: CartStatus;
error: CartError;
};
/* Code ends above. Do not include this line */
4. Ensure `cart.types.ts` gets exported in the `/types` `index.ts` file
5. If `FreeShippingMeter.tsx` exists, remove the type defintion for `useCart()` if it exists, i.e. change `= useCart() as CartWithActions & {discountAllocations: Cart['discountAllocations'];`}; to just `= useCart()`
6. In `CartTotals.tsx`
- Remove the same type defintion for `useCart()` as above
- Remove the type `Cart['discountAllocations']` from `parsedDiscountAllocations`
- Remove any unused imports
---
### Misc
1. If `CartUpsell.tsx` originated from the Blueprint template exists (there'll be a `useEffect` that has a conditional for `if (status === 'idle')` and has `fullProductsDep` as a dependency), do:
- Remove `cartLines` or `lines` from that `useEffect` dependencies, and instead destructure out `updatedAt` from `useCart()` and add that to the `useEffect` dependencies
- This step is important to prevent an infinite loop caused by this migration with this particular component
2. `useCartUpdateAttributes.tsx` in the `app/hooks/cart` folder can be removed, and its export removed from the `index.ts` file
- Wherever the function `updateCartAttributes` is used from `useCartUpdateAttributes()`, replace it with `cartAttributesUpdate` destructured from `useCart()`
- Remove old import
3. If this migration was done after the React Router 7 migration, then go back to `($locale).api.cart.tsx`, `($locale).cart.$lines.tsx`, and `($locale).discounts.$code.tsx` and follow this prompt:
- Import `Route` like this, where right of the last `/` is the name of the file itself. For example, if the file name is `($locale).products.$handle.tsx` then the import for `Route` would look like `import type {Route} from './+types/($locale).products.$handle’;`
- Make sure the route import comes after the `'~/*'` imports with a line in between (i.e. follows eslint import order rule. Same block as sibling imports, i.e. `'./*'`)
- Update `ActionFunctionArgs` to `Route.ActionArgs`; and `LoaderFunctionArgs` to `Route.LoaderArgs`