Building Headless Shops With WooGraphQL: Chapter 5 of 5

Geoff Taylor
By Geoff Taylor
10 months ago
banner

Table of Contents

  1. Setting Up WordPress for Your Headless eCommerce Application
  2. Creating and Setting Up Your eCommerce App for development
  3. Building Shop Listing Pages With WooGraphQL
  4. Creating User Login and Navigation
  5. 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 provided CartAction object.
  • findInCart: Utilizes the cartItemSearch function to check if a provided item is in the cart. If found, the corresponding CartItem 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 if true.
  • stockStatus: An enumeration hold the stock status of the product. Possible values are OUT_OF_STOCK, ON_BACKORDER, and IN_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 if manageStock is true, 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.