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
CartProviderandShopifyProvidercontext 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 Prompts 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 {
AttributeInput,
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 attributeInputs = getParsedJson(
formData.get('attributes'),
) as AttributeInput[];
const existingCartWithAttributes = (await cart.get()) as Cart;
const existingAttributes = existingCartWithAttributes?.attributes || [];
if (
Array.isArray(attributeInputs) &&
attributeInputs.every(
(a) => typeof a.key === 'string' && typeof a.value === 'string',
)
) {
// If empty array is passed, it means all attributes should be removed
if (!attributeInputs.length) {
result = await cart.updateAttributes([]);
} else {
const mergedMap = new Map(
existingAttributes.map((a) => [
a.key,
{key: a.key, value: a.value || ''},
]),
);
attributeInputs.forEach((a) => {
// If value is empty, it means the attribute should be removed
if (!a.value) {
mergedMap.delete(a.key);
return;
}
mergedMap.set(a.key, {key: a.key, value: a.value});
});
result = await cart.updateAttributes([...mergedMap.values()]);
}
} else {
result = {
cart: existingCartWithAttributes,
userErrors: [{message: 'Invalid `attributes` format.'}],
};
}
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 fileShopifyProvider.tsx
import {useMemo} from 'react';
import {
CartProvider as HydrogenCartProvider,
ShopifyProvider as HydrogenShopifyProvider,
} 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 ShopifyProvider({children}: {children: ReactNode}) {
const {ENV, isPreviewModeEnabled} = useRootLoaderData();
const locale = useLocale();
const previewModeCartFragment = useMemo(() => {
return CART_FRAGMENT.replace('CartApiQuery', 'CartFragment');
}, [CART_FRAGMENT]);
return (
<HydrogenShopifyProvider
storeDomain={ENV.PUBLIC_STORE_DOMAIN}
storefrontToken={ENV.PUBLIC_STOREFRONT_API_TOKEN}
storefrontApiVersion={DEFAULT_STOREFRONT_API_VERSION}
countryIsoCode={locale.country}
languageIsoCode={locale.language}
>
{/* When in customizer (preview mode), use Hydrogen's client side cart provider */}
{isPreviewModeEnabled ? (
<HydrogenCartProvider cartFragment={previewModeCartFragment}>
{children}
</HydrogenCartProvider>
) : (
children
)}
</HydrogenShopifyProvider>
);
}
ShopifyProvider.displayName = 'ShopifyProvider';
- 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 place the originalShopifyProviderandCartProviderwere placed:
<ShopifyProvider>
<CartProvider>*/ Other providers, code, etc /*</CartProvider>
</ShopifyProvider>
- Import
ShopifyProviderandCartProvideraccordingly
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 type {CartBuyerIdentityInput} from '@shopify/hydrogen/storefront-api-types';
import {useCartContext} from '~/contexts/CartProvider/useCartContext';
import type {CartActionData, CartWithActions} from '~/lib/types';
import {useRootLoaderData} from '../useRootLoaderData';
/**
* Module-level promise chain that serializes all cart mutations.
* This prevents concurrent API calls from returning stale cart state
* that overwrites the results of earlier mutations (e.g. rapidly
* removing multiple cart lines).
*/
let mutationQueue: Promise<CartActionData | null> = Promise.resolve(null);
export const useCart = (): CartWithActions => {
const {isPreviewModeEnabled} = useRootLoaderData();
const {publish, shop} = useAnalytics();
const {
state: {cart, status, error},
actions: {setCart, setError, setStatus},
} = useCartContext();
const getCartActionData = useCallback(
(formData: FormData): Promise<CartActionData | null> => {
const action = formData.get('action');
const mutation = async (): Promise<CartActionData | null> => {
let data = null as CartActionData | null;
setError(null);
setStatus(action === CartForm.ACTIONS.Create ? 'creating' : 'updating');
try {
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;
} catch (err) {
setError(err);
setStatus('idle');
return null;
}
};
mutationQueue = mutationQueue.then(mutation, mutation);
return mutationQueue;
},
[setCart, setError, setStatus],
);
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);
},
[getCartActionData],
);
const linesAdd: CartWithActions['linesAdd'] = useCallback(
async (lines) => {
const formData = new FormData();
formData.append('action', CartForm.ACTIONS.LinesAdd);
formData.append('lines', JSON.stringify(lines));
return getCartActionData(formData);
},
[cart, getCartActionData, 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));
return getCartActionData(formData);
},
[cart, getCartActionData, 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));
return getCartActionData(formData);
},
[cart, getCartActionData, 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);
},
[getCartActionData],
);
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);
},
[getCartActionData],
);
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);
},
[getCartActionData],
);
const noteUpdate: CartWithActions['noteUpdate'] = useCallback(
async (note) => {
const formData = new FormData();
formData.append('action', CartForm.ACTIONS.NoteUpdate);
formData.append('note', note);
return getCartActionData(formData);
},
[getCartActionData],
);
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;
};
- 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
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:
- Update the
cartproperty value passed toAnalytics.Providerto becart={isCartReady ? cartForAnalytics : null}if it isn't already, whereisCartReadycomes fromuseGlobalandcartForAnalyticslooks like:
const cartForAnalytics = useMemo(() => {
// SSR cart already has lines as a flat array; wrap for Analytics.Provider
return {
...cart,
id: cart?.id || 'uninitialized',
updatedAt: cart?.updatedAt || 'uninitialized',
lines: {nodes: Array.isArray(cart.lines) ? cart.lines : []},
} as unknown as CartReturn;
}, [cart]);
- In this case
cartcomes from the newuseCarthook - If
useCartForAnalytics.tsdoes exist underapp/hooks/cart, delete the file and remove its export fromindex.ts
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 `ShopifyProvider` 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 {
AttributeInput,
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 attributeInputs = getParsedJson(
formData.get('attributes'),
) as AttributeInput[];
const existingCartWithAttributes = (await cart.get()) as Cart;
const existingAttributes = existingCartWithAttributes?.attributes || [];
if (
Array.isArray(attributeInputs) &&
attributeInputs.every(
(a) => typeof a.key === 'string' && typeof a.value === 'string',
)
) {
// If empty array is passed, it means all attributes should be removed
if (!attributeInputs.length) {
result = await cart.updateAttributes([]);
} else {
const mergedMap = new Map(
existingAttributes.map((a) => [
a.key,
{key: a.key, value: a.value || ''},
]),
);
attributeInputs.forEach((a) => {
// If value is empty, it means the attribute should be removed
if (!a.value) {
mergedMap.delete(a.key);
return;
}
mergedMap.set(a.key, {key: a.key, value: a.value});
});
result = await cart.updateAttributes([...mergedMap.values()]);
}
} else {
result = {
cart: existingCartWithAttributes,
userErrors: [{message: 'Invalid `attributes` format.'}],
};
}
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 `ShopifyProvider.tsx`
/* Code starts below. Do not include this line */
import {useMemo} from 'react';
import {
CartProvider as HydrogenCartProvider,
ShopifyProvider as HydrogenShopifyProvider,
} 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 ShopifyProvider({children}: {children: ReactNode}) {
const {ENV, isPreviewModeEnabled} = useRootLoaderData();
const locale = useLocale();
const previewModeCartFragment = useMemo(() => {
return CART_FRAGMENT.replace('CartApiQuery', 'CartFragment');
}, [CART_FRAGMENT]);
return (
<HydrogenShopifyProvider
storeDomain={ENV.PUBLIC_STORE_DOMAIN}
storefrontToken={ENV.PUBLIC_STOREFRONT_API_TOKEN}
storefrontApiVersion={DEFAULT_STOREFRONT_API_VERSION}
countryIsoCode={locale.country}
languageIsoCode={locale.language}
>
{/* When in customizer (preview mode), use Hydrogen's client side cart provider */}
{isPreviewModeEnabled ? (
<HydrogenCartProvider cartFragment={previewModeCartFragment}>
{children}
</HydrogenCartProvider>
) : (
children
)}
</HydrogenShopifyProvider>
);
}
ShopifyProvider.displayName = 'ShopifyProvider';
/* 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 the original `ShopifyProvider` and `CartProvider` were placed:
/* Code starts below. Do not include this line */
<ShopifyProvider>
<CartProvider>*/ Other providers, code, etc /*</CartProvider>
</ShopifyProvider>
/* Code ends above. Do not include this line */
- Import `ShopifyProvider` 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 type {CartBuyerIdentityInput} from '@shopify/hydrogen/storefront-api-types';
import {useCartContext} from '~/contexts/CartProvider/useCartContext';
import type {CartActionData, CartWithActions} from '~/lib/types';
import {useRootLoaderData} from '../useRootLoaderData';
/**
* Module-level promise chain that serializes all cart mutations.
* This prevents concurrent API calls from returning stale cart state
* that overwrites the results of earlier mutations (e.g. rapidly
* removing multiple cart lines).
*/
let mutationQueue: Promise<CartActionData | null> = Promise.resolve(null);
export const useCart = (): CartWithActions => {
const {isPreviewModeEnabled} = useRootLoaderData();
const {publish, shop} = useAnalytics();
const {
state: {cart, status, error},
actions: {setCart, setError, setStatus},
} = useCartContext();
const getCartActionData = useCallback(
(formData: FormData): Promise<CartActionData | null> => {
const action = formData.get('action');
const mutation = async (): Promise<CartActionData | null> => {
let data = null as CartActionData | null;
setError(null);
setStatus(action === CartForm.ACTIONS.Create ? 'creating' : 'updating');
try {
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;
} catch (err) {
setError(err);
setStatus('idle');
return null;
}
};
mutationQueue = mutationQueue.then(mutation, mutation);
return mutationQueue;
},
[setCart, setError, setStatus],
);
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);
},
[getCartActionData],
);
const linesAdd: CartWithActions['linesAdd'] = useCallback(
async (lines) => {
const formData = new FormData();
formData.append('action', CartForm.ACTIONS.LinesAdd);
formData.append('lines', JSON.stringify(lines));
return getCartActionData(formData);
},
[cart, getCartActionData, 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));
return getCartActionData(formData);
},
[cart, getCartActionData, 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));
return getCartActionData(formData);
},
[cart, getCartActionData, 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);
},
[getCartActionData],
);
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);
},
[getCartActionData],
);
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);
},
[getCartActionData],
);
const noteUpdate: CartWithActions['noteUpdate'] = useCallback(
async (note) => {
const formData = new FormData();
formData.append('action', CartForm.ACTIONS.NoteUpdate);
formData.append('note', note);
return getCartActionData(formData);
},
[getCartActionData],
);
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;
};
/* 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
---
### 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:
- Update the `cart` property value passed to `Analytics.Provider` to be `cart={isCartReady ? cartForAnalytics : null}` if it isn't already, where `isCartReady` comes from `useGlobal` and `cartForAnalytics` looks like:
/* Code starts below. Do not include this line */
const cartForAnalytics = useMemo(() => {
// SSR cart already has lines as a flat array; wrap for Analytics.Provider
return {
...cart,
id: cart?.id || 'uninitialized',
updatedAt: cart?.updatedAt || 'uninitialized',
lines: {nodes: Array.isArray(cart.lines) ? cart.lines : []},
} as unknown as CartReturn;
}, [cart]);
/* Code ends above. Do not include this line */
- In this case `cart` comes from the new `useCart` hook
- Remove any unused imports
- If `useCartForAnalytics.ts` does exist under `app/hooks/cart`, delete the file and remove its export from `index.ts`
---
### 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 (you can confirm this by seeing if `react-router.config.ts` is in the root of the repo), then go back to `($locale).api.cart.tsx`, `($locale).cart.$lines.tsx`, and `($locale).discounts.$code.tsx` and follow the below prompt. Otherwise, do nothing:
- 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`