Table of Contents
- Setting Up WordPress for Your Headless eCommerce Application
- Creating and Setting Up Your eCommerce App for development
- Building Shop Listing Pages With WooGraphQL
- Creating User Login and Navigation
- Creating the Product page and Cart Options
Welcome to the final chapter of our in-depth tutorial series, where we will focus on implementing the Single Product and Cart Options for your eCommerce store. You've made it a long way, and now it's time to put the finishing touches to your application, creating a powerful and flexible shopping experience for your customers.
Creating the Product page and Cart Options
One of the vital aspects of any eCommerce store is the ability to display individual product details. This allows users to gain a comprehensive understanding of the products they're interested in, increasing their confidence to make a purchase. We will walk you through how to create and design a detailed Single Product page that showcases your product's unique features and attributes.
Next, we'll move on to enhancing the functionality of your store with Cart Options. These options are the key to a smooth and flexible shopping process, providing users with the power to manage their orders effectively. Whether it's a simple product with no variations or a more complex one with multiple variants, we'll demonstrate how to handle both scenarios.
We will specifically look into creating a robust and intuitive cart system that works in harmony with the SessionProvider
developed in our previous tutorial. Furthermore, we will introduce a new hook and a route page, tools that will allow you to provide top-notch, seamless shopping experiences.
It's important to note that this tutorial builds on the concepts and codes introduced in previous sections, so make sure you've been through all the previous tutorials and are comfortable with the content.
By the end of this tutorial, you'll have a fully functional, feature-rich eCommerce store ready to charm your customers. So, without further ado, let's wrap up our series and dive into the creation of a comprehensive Single Product page and sophisticated Cart Options.
Part 1: Create Single Product Page
1. Create /app/product/[slug]/page.tsx
:
// app/product/[slug]/page.tsx
import { fetchProductBy, ProductIdTypeEnum } from '@/graphql';
import { ShopProduct } from '@/server/ShopProduct';
export interface ProductPageProps {
params: {
slug: string
}
}
export default async function ProductPage({ params }: ProductPageProps) {
const { slug } = params;
const product = await fetchProductBy(slug, ProductIdTypeEnum.SLUG);
if (!slug || !product) return (
<h1>Page not found</h1>
);
return (
<ShopProduct product={product} />
);
}
2. Create /server/ShopProduct
:
// server/ShopProduct/ShopProduct.tsx
import { Product, SimpleProduct, VariationAttribute } from '@/graphql';
import { ProductImage } from '@/client/ProductImage';
import {
Tabs,
TabsContent,
TabsList,
TabsTrigger,
} from '@/ui/tabs';
export interface ShopProductProps {
product: Product;
}
export function ShopProduct(props: ShopProductProps) {
const {
product,
} = props;
const attributes: Record<string, string[]> = (product as SimpleProduct).defaultAttributes?.nodes?.reduce(
(attributesList, attribute) => {
const {
value,
label
} = attribute as VariationAttribute;
const currentAttributes = attributesList[label as string] || [];
return {
...attributesList,
[label as string]: [...currentAttributes, value as string],
};
},
{} as Record<string, string[]>
) || {};
return (
<div className="w-full flex flex-wrap gap-4 max-w-screen-lg mx-auto mb-36 lg:mb-0">
<ProductImage product={product} />
<div className="basis-full md:basis-1/2 pt-4 px-4 flex flex-col">
<h1 className="font-serif text-2xl font-bold mb-2">{product.name}</h1>
<p className="text-lg font-bold mb-2">{product.shortDescription}</p>
<CartOptions product={product} />
</div>
<Tabs defaultValue="description" className="w-full px-4">
<TabsList className="w-full md:w-auto">
<TabsTrigger value="description">Description</TabsTrigger>
<TabsTrigger value="attributes">Attributes</TabsTrigger>
<TabsTrigger value="reviews">Reviews</TabsTrigger>
</TabsList>
<TabsContent value="description">
<div dangerouslySetInnerHTML={{ __html: product.description as string }} />
</TabsContent>
<TabsContent value="attributes">
<ul>
{Object.entries(attributes).map(([label, values]) => (
<li key={label}>
<p><span className="font-serif font-medium">{label}:</span> {values.join(', ')}</p>
</li>
))}
</ul>
</TabsContent>
<TabsContent value="reviews">
<p>To Be Continued...</p>
</TabsContent>
</Tabs>
</div>
);
}
3. Create /client/ProductImage
:
// client/ProductImage/ProductImage.tsx
import { Product } from '@/graphql';
import { Image } from '@/ui/Image';
export interface ProductImageProps {
product: Product;
}
export function ProductImage({ product }: ProductImageProps) {
const sourceUrl = product?.image?.sourceUrl;
const altText = product?.image?.altText || '';
if (!sourceUrl) {
return null;
}
return (
<div className="basis-full md:basis-auto grow">
<Image
className="md:rounded-br"
src={sourceUrl}
alt={altText}
ratio={3 / 4}
/>
</div>
);
}
And that's it. We have a single product page with an image and description sections. Keen eyes have noticed the lack of a price, cart options and incomplete reviews sections.
Reviews are out-of-scope of this tutorials. Consider it homework. As for the price and cart options, we will working on these in the next part. Just like the previous two tutorials we'll have to implement a new context provider. This one will manage the viewing state of the product we are viewing. This will come in handy when working with variable products. Let's get to it then.
Part 2: Create ProductProvider
1. Create /client/ProductProvider
:
// client/ProductProvider/ProductProvider.tsx
import {
useContext,
useReducer,
useEffect,
createContext,
PropsWithChildren
} from 'react';
import get from 'lodash/get';
import {
Product,
ProductTypesEnum,
ProductVariation,
SimpleProduct,
VariableProduct,
} from '@/graphql';
export interface ProductContext {
data: Product|null;
isVariableProduct: boolean;
hasSelectedVariation: boolean;
selectedVariation: ProductVariation | null;
get: (field: keyof ProductVariation | keyof SimpleProduct | keyof VariableProduct) => unknown;
selectVariation: (variation?: ProductVariation) => void;
}
const initialState: ProductContext = {
data: null,
isVariableProduct: false,
hasSelectedVariation: false,
selectedVariation: null,
get: () => null,
selectVariation: () => null,
};
const productContext = createContext<ProductContext>(initialState);
export function useProductContext() {
return useContext(productContext);
}
type ProductAction = {
type: 'SET_PRODUCT';
payload: Product;
} | {
type: 'SET_VARIATION';
payload: ProductVariation|null;
} | {
type: 'SET_IS_VARIABLE_PRODUCT';
payload: boolean;
} | {
type: 'SET_HAS_SELECTED_VARIATION';
payload: boolean;
};
const null_variation = {
id: '',
databaseId: 0,
} as ProductVariation;
function reducer(state: ProductContext, action: ProductAction) {
switch(action.type) {
case 'SET_PRODUCT':
return {
...state,
data: action.payload,
};
case 'SET_VARIATION':
return {
...state,
selectedVariation: action.payload,
};
case 'SET_IS_VARIABLE_PRODUCT':
return {
...state,
isVariableProduct: action.payload,
};
case 'SET_HAS_SELECTED_VARIATION':
return {
...state,
hasSelectedVariation: action.payload,
};
default:
return state;
}
}
const { Provider } = productContext;
export interface ProductProviderProps {
product: Product;
}
export function ProductProvider({ product, children}: PropsWithChildren<ProductProviderProps>) {
const [state, dispatch] = useReducer(reducer, initialState);
useEffect(() => {
dispatch({
type: 'SET_PRODUCT',
payload: product,
});
if (product.type === ProductTypesEnum.VARIABLE) {
dispatch({
type: 'SET_IS_VARIABLE_PRODUCT',
payload: true,
});
dispatch({
type: 'SET_HAS_SELECTED_VARIATION',
payload: false,
});
dispatch({
type: 'SET_VARIATION',
payload: null_variation,
});
}
}, [product]);
const store = {
...state,
get: (field: keyof ProductVariation | keyof SimpleProduct | keyof VariableProduct) => {
if (!state.data) {
return null;
}
if (state.selectedVariation) {
return get(state.selectedVariation, field as keyof ProductVariation)
|| get(state.data as VariableProduct, field as keyof VariableProduct);
}
return get(state.data as SimpleProduct, field as keyof SimpleProduct);
},
selectVariation: (variation?: ProductVariation) => {
if (!variation) {
dispatch({
type: 'SET_VARIATION',
payload: null_variation,
});
dispatch({
type: 'SET_HAS_SELECTED_VARIATION',
payload: false,
});
return;
}
dispatch({
type: 'SET_VARIATION',
payload: variation,
});
dispatch({
type: 'SET_HAS_SELECTED_VARIATION',
payload: true,
});
},
};
return (
<Provider value={store}>
{children}
</Provider>
)
}
ProductProvider
has one primary job. To isolate a product variation and return the get
callback for it.
get
takes a field name a returns the field value on the selected variation. If the product is not a variable product or no variation is selected, get
pulls the field value from the main product object. Now let's set the SessionProvider
on the single page component.
2. Update /app/product/[slug]/page.tsx
:
// app/product/[slug]/page.tsx
...
import { ShopProduct } from '/server/ShopProduct';
...
export default async function ProductPage({ params }: ProductPageProps) {
...
return (
<ProductProvider product={product}>
<ShopProduct product={product} />
</ProductProvider>
);
}
We are almost ready to implement the cart options, but we need to make a few changes to some things we created in the last tutorials. The SessionProvider
component and session.ts
utility file need to be updated to handle cart actions. Let's start with the session.ts
file.
Part 3: Update SessionProvider
1. Update /utils/session.ts
:
// utils/session.ts
...
type FetchCartResponse = {
cart: Cart;
sessionToken: string;
}
export type CartAction = {
mutation: 'add';
productId: number;
quantity: number;
variationId?: number;
variation?: {
attributeName: string;
attributeValue: string;
}[]
extraData?: string;
} | {
mutation: 'update';
items: { key: string, quantity: number }[];
} | {
mutation: 'remove';
keys: string[];
all?: boolean;
}
export async function updateCart(input: CartAction): Promise<Cart|string> {
const sessionToken = await getSessionToken();
const authToken = await getAuthToken();
let json: FetchCartResponse;
try {
json = await apiCall<FetchCartResponse>(
'/api/cart',
{
method: 'POST',
body: JSON.stringify({
sessionToken,
authToken,
input,
}),
},
);
} catch (error) {
return (error as GraphQLError)?.message || error as string;
}
const { cart } = json;
saveSessionToken(json.sessionToken);
return cart;
}
A minor change but you might have noticed that new updateCart
utility function, besides taking the new CartAction
type as a input, it also sends a POST request to /api/cart
, which does not exist. Let's remedy that.
2. Create /app/api/cart/route.ts
:
import { NextResponse } from 'next/server';
import { print } from 'graphql';
import {
AddToCartMutation,
AddToCartDocument,
UpdateCartItemQuantitiesMutation,
UpdateCartItemQuantitiesDocument,
RemoveItemsFromCartMutation,
RemoveItemsFromCartDocument,
Cart,
getClient,
} from '@/graphql';
import { CartAction } from '@/utils/session';
type RequestBody = {
sessionToken: string;
authToken?: string;
input: CartAction;
}
type GraphQLRequestHeaders = {
Authorization?: string;
'woocommerce-session': string;
}
export async function POST(request: Request) {
try {
const body = await request.json() as RequestBody;
if (!body.sessionToken) {
return NextResponse.json({ errors: { message: 'Session not started' } }, { status: 500 });
}
const headers: GraphQLRequestHeaders = { 'woocommerce-session': `Session ${body.sessionToken}` };
if (body.authToken) {
headers.Authorization = `Bearer ${body.authToken}`;
}
const graphQLClient = getClient();
graphQLClient.setHeaders(headers);
if (!body.input) {
return NextResponse.json({ errors: { message: 'No input provided' } }, { status: 500 });
}
const { mutation, ...input } = body.input;
if (!mutation) {
return NextResponse.json({ errors: { message: 'No mutation provided' } }, { status: 500 });
}
let cart: Cart;
let sessionToken: string|null = null;
let results;
switch (mutation) {
case 'add':
results = await graphQLClient.rawRequest<AddToCartMutation>(
print(AddToCartDocument),
{ ...input },
);
cart = results.data?.addToCart?.cart as Cart;
sessionToken = results.headers.get('woocommerce-session');
break;
case 'update':
results = await graphQLClient.rawRequest<UpdateCartItemQuantitiesMutation>(
print(UpdateCartItemQuantitiesDocument),
{ ...input },
);
cart = results.data?.updateItemQuantities?.cart as Cart;
sessionToken = results.headers.get('woocommerce-session') || body.sessionToken;
break;
case 'remove':
results = await graphQLClient.rawRequest<RemoveItemsFromCartMutation>(
print(RemoveItemsFromCartDocument),
{ ...input },
);
cart = results.data?.removeItemsFromCart?.cart as Cart;
sessionToken = results.headers.get('woocommerce-session') || body.sessionToken;
break;
default:
return NextResponse.json({ errors: { message: 'Invalid mutation provided' } }, { status: 500 });
}
if (!cart || !sessionToken) {
const message = 'No cart or session token returned from WooCommerce';
return NextResponse.json({ errors: { message } }, { status: 500 });
}
return NextResponse.json({ cart, sessionToken });
} catch (err) {
console.log(err);
return NextResponse.json({ errors: { message: 'Sorry, something went wrong' } }, { status: 500 });
}
}
The /api/cart
takes a POST request expected a sessionToken
and an input
object in the shape of our CartAction
. If an authToken
is provided, set on the request with the sessionToken
. When given the proper input the POST
callback attempts to execute the action specific by the input
object and return the resulting cart
object for the session. With this we're ready to update the SessionProvider
.
3. Update /client/SessionProvider
:
// client/SessionProvider/SessionProvider.tsx
...
import {
...
CartItem,
MetaData,
VariationAttribute,
} from '@/graphql';
import {
...
updateCart as updateCartApiCall,
CartAction,
} from '@/utils/session';
...
export interface SessionContext {
...
updateCart: (action: CartAction) => Promise<boolean>;
findInCart: (
productId: number,
variationId?: number,
variation?: {
attributeName: string;
attributeValue: string;
}[],
extraData?: string,
) => CartItem|undefined;
}
const initialContext: SessionContext = {
...
updateCart: (action: CartAction) => new Promise((resolve) => { resolve(false); }),
findInCart: (
productId: number,
variationId?: number,
variation?: {
attributeName: string;
attributeValue: string;
}[],
extraData?: string,
) => undefined,
};
...
/**
* Checks if product matches the provided cart/registry item.
*
* @param {number} productId Item product ID.
* @param {number} variationId Item variation ID.
* @param {string} extraData Item metadata JSON string.
* @returns
*/
const cartItemSearch = (
productId: number,
variationId?: number,
variationData?: {
attributeName: string;
attributeValue: string;
}[],
extraData?: string,
skipMeta = false,
) => ({
product,
variation,
extraData:
existingExtraData = [],
}: CartItem) => {
if (product?.node?.databaseId && productId !== product.node.databaseId) {
return false;
}
if (
variation?.node?.databaseId
&& variationId !== variation.node.databaseId
) {
return false;
}
if (variationData?.length && !variation?.attributes?.length) {
return false;
}
if (variationData?.length && variation?.attributes?.length) {
const variationAttributes = variation.attributes as VariationAttribute[];
const found = variationData
.filter(({ attributeName, attributeValue }) => !!variationAttributes?.find(
({ label, value }) => (label as string).toLowerCase() === attributeName && value === attributeValue,
))
.length;
if (!found) return false;
}
if (skipMeta) {
return true;
}
if (existingExtraData?.length && !extraData) {
return false;
}
if (!!extraData && typeof extraData === 'string') {
const decodeMeta = JSON.parse(extraData);
let found = false;
Object.entries(decodeMeta).forEach(([targetKey]) => {
found = !!(existingExtraData as MetaData[])?.find(
({ key, value }) => key === targetKey && value === `${decodeMeta[targetKey]}`,
);
});
if (!found) {
return false;
}
}
return true;
};
...
export function SessionProvider({ children }: PropsWithChildren) {
...
// Process cart action response.
const setCart = (cart: Cart|string) => {
if (typeof cart === 'string') {
toast({
title: 'Cart Action Error',
description: 'Cart mutation failed.',
variant: 'destructive'
});
dispatch({
type: 'UPDATE_STATE',
payload: { fetching: false } as SessionContext,
});
return false;
}
dispatch({
type: 'UPDATE_STATE',
payload: { cart, fetching: false } as SessionContext,
});
return true;
};
const updateCart = (action: CartAction) => {
dispatch({
type: 'UPDATE_STATE',
payload: { fetching: true } as SessionContext,
});
return updateCartApiCall(action)
.then(setCart);
};
const findInCart = (
productId: number,
variationId?: number,
variation?: {
attributeName: string;
attributeValue: string;
}[],
extraData?: string) => {
const items = state?.cart?.contents?.nodes as CartItem[];
if (!items) {
return undefined;
}
return items.find(cartItemSearch(productId, variationId, variation, extraData, true)) || undefined;
};
useEffect(() => {
if (state.fetching) {
return;
}
if (!state.customer || !state.cart) {
fetchSession();
}
}, []);
const store: SessionContext = {
...
updateCart,
findInCart,
};
return (
<Provider value={store}>{children}</Provider>
);
}
export const useSession = () => useContext(sessionContext);
With this change context consumers have access to the updateCart
and findInCart
callback.
updateCart
: Executes a cart action based upon providedCartAction
object.findInCart
: Utilizes thecartItemSearch
function to check if a provided item is in the cart. If found, the correspondingCartItem
is returned.
Part 4: Create CartOptions
In this part we will implement the CartOptions
, SimpleCartOptions
, and VariableCartOptions
components.
1. Create /server/CartOptions
:
// server/CartOptions/CartOptions.tsx
import { PropsWithChildren } from 'react';
import {
Product,
ProductTypesEnum,
SimpleProduct,
} from '@/graphql';
import { cn } from '@/utils/ui';
import { SimpleCartOptions } from '@/client/SimpleCartOptions';
import { VariableCartOptions } from '@/client/VariableCartOptions';
function Container({ className, children }: PropsWithChildren<{ className?: string }>) {
return (
<div
className={cn(
className && className,
'fixed inset-x-0 mx-auto p-4',
'lg:relative lg:inset-auto lg:mx-0 lg:p-0',
'bg-white bottom-0 z-30 w-screen',
'lg:bg-inherit lg:bottom-auto lg:z-auto lg:w-auto',
)}
>
{children}
</div>
);
}
export interface CartOptionsProps {
product: Product;
className?: string;
}
export function CartOptions(props: CartOptionsProps) {
const { product, className } = props;
const { type } = product as unknown as SimpleProduct;
let Component: (props: CartOptionsProps) => JSX.Element|null = () => null;
if (type === ProductTypesEnum.SIMPLE) {
Component = SimpleCartOptions;
} else if (type === ProductTypesEnum.VARIABLE) {
Component = VariableCartOptions;
}
return (
<Container className={className}>
<Component product={product} />
</Container>
);
}
CartOptions
is a simple container for most part, sticky to the bottom on mobile devices. Before moving on the SimpleCartOptions
and VariableCartOptions
, let create a hook that will make the product's cart state a breeze.
2. Create /hooks/useCartMutations.ts
:
// hooks/useCartMutations.ts
import { useEffect, useMemo, useState } from 'react';
import { useSession } from '@woographql/client/SessionProvider';
export interface CartMutationInput {
mutation?: 'add'|'update'|'remove';
quantity?: number;
all?: boolean;
variation?: {
attributeName: string;
attributeValue: string;
}[]
}
const useCartMutations = (
productId: number,
variationId?: number,
variation?: {
attributeName: string;
attributeValue: string;
}[],
extraData?: string,
) => {
const {
cart,
updateCart,
findInCart,
fetching,
} = useSession();
const [quantityFound, setQuantityInCart] = useState(
findInCart(productId, variationId, variation, extraData)?.quantity as number || 0,
);
const itemKey = useMemo(
() => findInCart(productId, variationId, variation, extraData)?.key,
[findInCart, productId, variationId, variation, extraData],
);
useEffect(() => {
setQuantityInCart(
findInCart(productId, variationId, variation, extraData)?.quantity || 0,
);
}, [findInCart, productId, variationId, variation, extraData, cart?.contents?.nodes]);
async function mutate<T extends CartMutationInput>(values: T) {
const {
quantity = 1,
all = false,
mutation = 'update',
} = values;
if (!cart) {
return;
}
if (!productId) {
throw new Error('No item provided.');
// TODO: Send error to Sentry.IO.
}
switch (mutation) {
case 'remove': {
if (!quantityFound) {
throw new Error('Provided item not in cart');
}
if (!itemKey) {
throw new Error('Failed to find item in cart.');
}
updateCart({
mutation: 'remove',
keys: [itemKey],
all,
});
break;
}
case 'update':
if (!quantityFound) {
throw new Error('Failed to find item in cart.');
}
if (!itemKey) {
throw new Error('Failed to find item in cart.');
}
updateCart({
mutation: 'update',
items: [{ key: itemKey, quantity }]
});
break;
default:
updateCart({
mutation: 'add',
quantity,
productId,
variationId,
variation,
extraData,
});
break;
}
}
const store = {
fetching,
quantityFound,
mutate
};
return store;
};
export default useCartMutations;
While provided with at least a productId
the useCartMutations
hook with check the cart
nearest SessionProvider
for a cart item that matches that productId
. Note that the search is extremely exact to add support for all types of product and item meta. Meaning if the item in question is a variation it must be provided with the corresponding variationId
and any unset attribute values in the variation
object. Now let's put this hook to use in the SimpleCartOptions
component.
3. Create /client/SimpleCartOptions
:
// client/SimpleCartOptions/SimpleCartOptions.tsx
import {
FormEvent,
useState,
useEffect,
} from 'react';
import { cn } from '@/utils/ui';
import { Product, StockStatusEnum } from '@/graphql';
import type { ProductWithPrice } from '@/client/ShopProvider';
import useCartMutations from '@/hooks/useCartMutations';
import { Input } from '@/ui/input';
import { Button } from '@/ui/button';
import { LoadingSpinner } from '@/ui/LoadingSpinner';
import { useToast } from '@/ui/use-toast';
interface CartOptionsProps {
product: Product;
className?: string;
}
export function SimpleCartOptions(props: CartOptionsProps) {
const { toast } = useToast();
const [quantity, setQuantity] = useState(1);
const [executing, setExecuting] = useState<'add'|'update'|'remove'|null>(null);
const { product } = props;
const {
rawPrice,
databaseId,
soldIndividually,
stockStatus,
stockQuantity,
manageStock,
} = product as ProductWithPrice;
const { fetching, mutate, quantityFound } = useCartMutations(databaseId);
const outOfStock = stockStatus === StockStatusEnum.OUT_OF_STOCK;
const mutation = quantityFound ? 'update' : 'add';
let submitButtonText = quantityFound ? 'Update' : 'Add To Basket';
if (outOfStock) {
submitButtonText = 'Out of Stock';
}
const maxQuantity = manageStock ? stockQuantity as number : undefined;
const onAddOrUpdate = async (event: FormEvent) => {
event.preventDefault();
setExecuting(mutation);
await mutate({ mutation, quantity });
if (mutation === 'add') {
toast({
title: 'Added to cart',
description: `${quantity} × ${product.name}`,
});
} else {
toast({
title: 'Updated cart',
description: `${quantity} × ${product.name}`,
});
}
};
const onRemove = async() => {
setExecuting('remove');
await mutate({ mutation: 'remove' });
toast({
title: 'Removed from cart',
description: `${quantity} × ${product.name}`,
});
};
useEffect(() => {
if (!fetching) {
setExecuting(null);
}
}, [fetching]);
useEffect(() => {
if (quantityFound) {
setQuantity(quantityFound);
}
}, [quantityFound]);
return (
<form
onSubmit={onAddOrUpdate}
className="flex flex-wrap gap-x-2 gap-y-4 items-center"
>
{(!soldIndividually || outOfStock) && (
<Input
className="basis-1/2 shrink"
type="number"
min={1}
max={maxQuantity}
value={quantity}
disabled={fetching}
onChange={(event) => setQuantity(Number(event.target.value))}
/>
)}
<p className="basis-auto grow text-center font-serif text-lg">
{outOfStock && 'Out Of Stock'}
{(!soldIndividually || outOfStock) && `× $${rawPrice} = `}
{!outOfStock && (<strong>{`$${Number(rawPrice) * quantity}`}</strong>)}
</p>
<div className="basis-full md:basis-auto flex gap-x-2">
<Button
type="submit"
className={cn(
"basis-full md:basis-auto inline-flex gap-2"
)}
disabled={fetching || outOfStock}
>
{submitButtonText}
{fetching && executing !== 'remove' && <LoadingSpinner noText />}
</Button>
{!!quantityFound && (
<Button
type="button"
className="basis-full md:basis-auto inline-flex gap-2 bg-red-500"
onClick={onRemove}
disabled={fetching}
>
Remove
{fetching && executing === 'remove' && <LoadingSpinner noText />}
</Button>
)}
</div>
</form>
);
}
Here where calling useCartMutations
with just a productId
because were just dealing with simple product here. We also get a few values directly off the product
object so we can properly render the cart options form according to the availability of the item. Quick things to note:
soldIndividually
: A boolean flag signifying that item is to be sold in quantity of one, so there is no reason to render the quantity field iftrue
.stockStatus
: An enumeration hold the stock status of the product. Possible values areOUT_OF_STOCK
,ON_BACKORDER
, andIN_STOCK
manageStock
: A boolean flag signifying that there is a limit supply of this product.stockQuantity
: A numeric value that represents the number of product left in supply ifmanageStock
istrue
, otherwise it can be ignored.
If you though the last component was complicated, I would like to apologize in advance. But if it helps ease your mind, the next component is the final component in the applications. So congratulations on making it this far and thank you for sticking with me.
Now with any more delay the VariableCartOptions
.
4. Create /client/VariableCartOptions
:
// client/VariableCartOptions/VariableCartOptions.tsx
import {
FormEvent,
useState,
useEffect,
CSSProperties,
} from 'react';
import { cn } from '@/utils/ui';
import {
Product,
VariableProduct,
StockStatusEnum,
GlobalProductAttribute,
TermNode,
VariationAttribute,
ProductVariation,
} from '@/graphql';
import useCartMutations from '@/hooks/useCartMutations';
import { useProductContext } from '@/client/ProductProvider';
import { Input } from '@/ui/input';
import { Button } from '@/ui/button';
import { LoadingSpinner } from '@/ui/LoadingSpinner';
import { useToast } from '@/ui/use-toast';
import { RadioGroup, RadioGroupItem } from '@/ui/radio-group';
import { Label } from '@/ui/label';
function ucfirst(str: string) {
return str.charAt(0).toUpperCase() + str.slice(1);
}
type SelectedProductAttributes = { [key: string]: string };
interface CartOptionsProps {
product: Product;
className?: string;
}
export function VariableCartOptions({ product }: CartOptionsProps) {
const { toast } = useToast();
const [quantity, setQuantity] = useState(1);
const [executing, setExecuting] = useState<'add'|'update'|'remove'|null>(null);
const { get, selectVariation, hasSelectedVariation, selectedVariation } = useProductContext();
const rawPrice = get('rawPrice' as keyof Product) as string;
const soldIndividually = get('soldIndividually') as boolean;
const stockStatus = get('stockStatus') as StockStatusEnum;
const stockQuantity = get('stockQuantity') as number;
const manageStock = get('manageStock') as boolean;
const attributes = product.attributes?.nodes || [];
const variations = ((product as VariableProduct).variations?.nodes || []) as ProductVariation[];
const defaultAttributes = (product as VariableProduct).defaultAttributes?.nodes || [];
const [selectedAttributes, selectAttributes] = useState<SelectedProductAttributes>(
(defaultAttributes || []).reduce(
(results, attribute) => {
const { value, label } = attribute as VariationAttribute;
return {
...results,
[label as string]: value as string,
};
},
{},
),
);
useEffect(() => {
const variation = variations && variations.find(
({ attributes: variationAttributes }) => (
variationAttributes?.nodes as VariationAttribute[] || []
)?.every(
({ value, label }) => {
const index = ucfirst((label as string).replace(/_|-/g, ' '));
return !value || selectedAttributes[index] === value;
}
),
);
selectVariation(variation);
}, [selectedAttributes, product]);
const productId = product.databaseId;
const variationId = hasSelectedVariation ? get('databaseId') as number : undefined;
// Add any attributes not on the variation.
const variationAttributes = selectedVariation?.attributes?.nodes || [];
const variation = Object.entries((selectedAttributes))
.filter(([attributeName, attributeValue]) => {
return !!variationAttributes.find((variationAttribute) => {
const { value, label } = variationAttribute as VariationAttribute;
return !value && label === attributeName;
});
})
.map(([attributeName, attributeValue]) => ({ attributeName: attributeName.toLowerCase(), attributeValue }));
const { fetching, mutate, quantityFound } = useCartMutations(productId, variationId, variation);
const outOfStock = stockStatus === StockStatusEnum.OUT_OF_STOCK;
const mutation = quantityFound ? 'update' : 'add';
let submitButtonText = quantityFound ? 'Update' : 'Add To Basket';
if (outOfStock) {
submitButtonText = 'Out of Stock';
}
const maxQuantity = manageStock ? stockQuantity as number : undefined;
const onAddOrUpdate = async (event: FormEvent) => {
event.preventDefault();
setExecuting(mutation);
await mutate({ mutation, quantity });
if (mutation === 'add') {
toast({
title: 'Added to cart',
description: `${quantity} × ${product.name}`,
});
} else {
toast({
title: 'Updated cart',
description: `${quantity} × ${product.name}`,
});
}
};
const onRemove = async() => {
setExecuting('remove');
await mutate({ mutation: 'remove' });
toast({
title: 'Removed from cart',
description: `${quantity} × ${product.name}`,
});
};
useEffect(() => {
if (!fetching) {
setExecuting(null);
}
}, [fetching]);
useEffect(() => {
if (quantityFound) {
setQuantity(quantityFound);
} else {
setQuantity(1);
}
}, [quantityFound]);
return (
<form
onSubmit={onAddOrUpdate}
className="flex flex-wrap gap-x-2 gap-y-4 items-center"
>
<div className="w-full">
{(attributes || []).map((attribute) => {
const {
id,
name,
label,
options,
variation: isVariationAttribute,
terms,
} = attribute as GlobalProductAttribute;
if (!isVariationAttribute) {
return null;
}
return (
<div key={id} className="w-full flex gap-x-4">
<p className="text-lg font-serif font-medium">{label || name}</p>
<RadioGroup
className="flex gap-2"
name={name as string}
onValueChange={(value) => {
selectAttributes({
...selectedAttributes,
[name as string]: value,
});
}}
>
{(terms?.nodes || options)?.map((option) => {
let value: string;
let buttonLabel: string;
let style: CSSProperties|undefined;
let id;
if (typeof option !== 'object') {
id = `${name}-${option}`;
value = option as string;
buttonLabel = option
.replace('-', ' ')
.replace(/^\w/, (c) => c.toUpperCase());
} else {
const { id: globalId, name: termName, slug } = option as TermNode;
id = globalId;
value = termName as string;
buttonLabel = termName as string;
if (name?.toLowerCase() === 'color') {
style = {
backgroundColor: slug as string,
}
}
}
return (
<div key={id} className="flex items-center space-x-2">
<RadioGroupItem
id={id}
className="w-6 h-6 text-lg"
value={value}
checked={selectedAttributes[name as string] === value}
style={style}
/>
<Label htmlFor={id}>{buttonLabel}</Label>
</div>
);
})}
</RadioGroup>
</div>
);
})}
</div>
{(hasSelectedVariation) ? (
<>
{(!soldIndividually || outOfStock) && (
<Input
className="basis-1/2 shrink"
type="number"
min={1}
max={maxQuantity}
value={quantity}
disabled={fetching}
onChange={(event) => setQuantity(Number(event.target.value))}
/>
)}
<p className="basis-auto grow text-center font-serif text-lg">
{outOfStock && 'Out Of Stock'}
{(!soldIndividually || outOfStock) && `× $${rawPrice} = `}
{!outOfStock && (<strong>{`$${Number(rawPrice) * quantity}`}</strong>)}
</p>
<div className="basis-full md:basis-auto flex gap-x-2">
<Button
type="submit"
className={cn(
"basis-full md:basis-auto inline-flex gap-2"
)}
disabled={fetching || outOfStock}
>
{submitButtonText}
{fetching && executing !== 'remove' && <LoadingSpinner noText />}
</Button>
{!!quantityFound && (
<Button
type="button"
className="basis-full md:basis-auto inline-flex gap-2 bg-red-500"
onClick={onRemove}
disabled={fetching}
>
Remove
{fetching && executing === 'remove' && <LoadingSpinner noText />}
</Button>
)}
</div>
</>
) : (
<p className="basis-full md:basis-auto text-center font-serif text-lg">
This product is not available at this time. Sorry, for the inconvenience.
</p>
)}
</form>
);
}
Now, let's break down this beast of a component. Note that this doing much of the same stuff as the SimpleCartOptions
and with some minor tweaking this component could be made to support both variable and simple products. However, it would make it even more hard to read so I left them separate for this tutorial.
So first you probably notice where getting much of the same values we got from the product
object in SimpleCartOptions
.
const rawPrice = get('rawPrice' as keyof Product) as string;
const soldIndividually = get('soldIndividually') as boolean;
const stockStatus = get('stockStatus') as StockStatusEnum;
const stockQuantity = get('stockQuantity') as number;
const manageStock = get('manageStock') as boolean;
However we are using the get
callback from ProductProvider
this way the value comes from the selected variation if one is set.
Next thing to highlight is the allocation of mounting selectedAttributes
which is sourced from the product
object's defaultAttributes
. If one isn't set, the attribute radio buttons are left unset on mount.
const defaultAttributes = (product as VariableProduct).defaultAttributes?.nodes || [];
const [selectedAttributes, selectAttributes] = useState<SelectedProductAttributes>(
(defaultAttributes || []).reduce(
(results, attribute) => {
const { value, label } = attribute as VariationAttribute;
return {
...results,
[label as string]: value as string,
};
},
{},
),
);
Simple enough, next thing to look at is the search for a matching variation when the selectedAttributes
have been updated.
useEffect(() => {
const variation = variations && variations.find(
({ attributes: variationAttributes }) => (
variationAttributes?.nodes as VariationAttribute[] || []
)?.every(
({ value, label }) => {
const index = ucfirst((label as string).replace(/_|-/g, ' '));
return !value || selectedAttributes[index] === value;
}
),
);
selectVariation(variation);
}, [selectedAttributes, product]);
As you can see the useEffect
is triggered every time the main product
object or selectedAttributes
is updated. What it does is compare the selectedAttributes
to the attributes
of every variation available to see if it find a match. If the variation
is missing a value for a specific attribute it can be assumed that any attribute is acceptable, that attribute just has to be specified before the item can be added to the cart. And on that note let's get to last thing unique to the component.
const productId = product.databaseId;
const variationId = hasSelectedVariation ? get('databaseId') as number : undefined;
// Add any attributes not on the variation.
const variationAttributes = selectedVariation?.attributes?.nodes || [];
const variation = Object.entries((selectedAttributes))
.filter(([attributeName, attributeValue]) => {
return !!variationAttributes.find((variationAttribute) => {
const { value, label } = variationAttribute as VariationAttribute;
return !value && label === attributeName;
});
})
.map(([attributeName, attributeValue]) => ({ attributeName: attributeName.toLowerCase(), attributeValue }));
const { fetching, mutate, quantityFound } = useCartMutations(productId, variationId, variation);
Nothing out of ordinary here except the variation
constant. It would like the find
callback in the useEffect
from the last snippet but in reverse, it search selectedAttributes
object and looks for any attributes that unspecified on the selectedVariation
and maps them to the shape of the useCartMutation
's variation
parameter. This parameter is later passed to our findInCart
option to identify if the variation in the cart.
With this you notice that when you add a variation to the cart the Update
and Remove
buttons appear and when you change the selected attributes using the radio button the Update
and Remove
disappear because the newly selected variation is not it the cart. This is the exact behavior we want.
Now we could put pin in this and call it done but let's do one last thing to make the Product page pop out, we're gonna update the ProductImage
component to utilize the get
from the SessionProvider
to change the display image to the image of the selectedVariation
.
5. Update /client/ProductImage
:
// client/ProductImage/ProductImage.tsx
...
import { useProductContext } from '@/client/ProductProvider';
...
export function ProductImage({ product }: ProductImageProps) {
const { get } = useProductContext();
const sourceUrl = get('image.sourceUrl' as keyof Product) as string || product?.image?.sourceUrl;
const altText = get('image.altText' as keyof Product) as string || product?.image?.altText || '';
...
}
That's it. When you change the ProductProvider
changes the selectedVariation
the displayed image will change if the variation has an image set.
Conclusion
And there you have it, you've successfully reached the end of our comprehensive tutorial series. Over the course of these tutorials, you have constructed a powerful and user-friendly eCommerce store from the ground up, utilizing Next.js, WooCommerce, GraphQL, and other valuable technologies.
The Journey So Far
And there you have it, you've successfully reached the end of our comprehensive tutorial series. Over the course of these tutorials, you have constructed a powerful and user-friendly eCommerce store from the ground up, utilizing Next.js, WooCommerce, GraphQL, and other valuable technologies.
- Starting with setting up the foundational structure of your store, you've learned how to fetch and display product lists dynamically, developed an efficient filtering system that refines products based on categories, colors, and other key attributes. You've created a robust pagination system to handle an extensive list of products effectively.
- Furthermore, you've learned how to handle end-user sessions, enabling a seamless transition from our Next.js application to the WooCommerce store hosted on our WordPress installation. This functionality is a game-changer, allowing us to utilize native WooCommerce pages, saving us from creating complex pages on our end and enhancing the user experience.
- Today, we've added the final touch by creating a Single Product page to display detailed information about each product and implemented Cart Options for simple and variable products. These features have been harmonized with the
SessionProvider
, delivering a smooth shopping experience to your users.
Remember, all the concepts, methods, and codes introduced in these tutorials serve as a solid starting point. Depending on the specific needs of your eCommerce store, you can always expand upon what you have learned here.
Congratulations on your achievement! You have mastered the essentials of building a versatile eCommerce application using a modern tech stack. Continue to learn, experiment, and enhance your application. The world of eCommerce is always evolving, and so should your store. We look forward to seeing the incredible work you'll produce in the future.