Building Headless Shops With WooGraphQL: Chapter 3 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 third part of our tutorial series on creating a headless eCommerce application! This chapter is a step-by-step walkthrough of creating the shop listing pages using WooGraphQL + Next 13. The focus is on implementing a navigation system and a Shop page for your e-commerce platform.

Building Shop Listing Pages with WooGraphQL

We'll start by creating the shop page with little to no filtering functionality, then finish up by implementing the filtering functionality and updating each component respectively. Without further delay let's begin!

Part 1: Edit Root Layout

We will first modify our root layout to include top navigation.

1. Update /app/layout.tsx:

// app/layout.tsx
import {
  OrderEnum,
  TermObjectsConnectionOrderbyEnum,
  fetchCategories,
} from '@/graphql';

import { TopNav, NavItem } from '@/server/TopNav';
import { Toaster } from '@/ui/toaster';

import './globals.css';

export const metadata = {
  title: process.env.SITE_NAME,
  description: process.env.SITE_DESCRIPTION,
}

export default async function RootLayout({
  children,
}: {
  children: React.ReactNode
}) {
  const categories = await fetchCategories(
    5,
    1,
    {
    orderby: TermObjectsConnectionOrderbyEnum.COUNT,
    order: OrderEnum.DESC
    }
  ) || [];
  const menu: NavItem[] = [
    ...categories.map((category) => ({
        label: category.name as string,
        href: `/${category.slug}`,
    })),
  ];

  return (
    <html lang="en">
      <body>
        <TopNav menu={menu} />
        <main className="w-full">
          {children}
        </main>
        <Toaster />
      </body>
    </html>
  );
}

The new layout will make use of TopNav from the WooGraphQL server. This will populate the navigation menu with product categories fetched from the GraphQL endpoint. Additionally, we import a Toaster for user notifications and feedback.

2. Create server/TopNav/index.ts:

// server/TopNav/index.ts
export * from './TopNav';

This file is acting as a barrel file, allowing us to import everything from ./TopNav at once, simplifying imports elsewhere in our code. This is a common practice for isolating associated tests and local utility files. Expect this pattern to be used for all components in the /server. Going forward I won't be displaying the component barrel files with some exceptions.

3. Create server/TopNav/TopNav.tsx:

// server/TopNav/TopNav.tsx
import { NavLink } from '@/ui/NavLink';
import { UserNav } from '@/client/UserNav';

export interface NavItem {
    label: string;
    href: string;
    cta?: boolean;
}

export interface TopNavProps {
    menu: NavItem[];
}

export function TopNav({ menu }: TopNavProps) {
  return (
    <nav className="w-full bg-white min-h-24 py-4 px-4">
      <ul className="max-w-screen-lg m-auto w-full flex flex-row gap-x-4 justify-end items-center">
        <h1>{process.env.SITE_NAME}</h1>
        {menu.map((item, i) => (
          <li key={i} className="group">
            <NavLink href={item.href}>
              {item.label}
            </NavLink>
          </li>
        ))}
      </ul>
    </nav>
  );
}

This file defines our TopNav component, a full-width navigation bar that includes a list of categories (if any) and site branding. In our case in our layout.tsx we are providing the 5 most populated categories.

Part 2: Create Shop Page And Shop Page Server Components

Now, we'll create a Shop page where products can be listed.

1. Refactor app/page.tsx:

// app/page.tsx
import {
  fetchProducts,
  fetchCategories,
  fetchColors,
} from '@/graphql';
import { Shop } from '@/server/Shop';

export default async function ShopPage() {
  const products = await fetchProducts(20, 0);
  const categories = await fetchCategories(20, 0, { hideEmpty: true }) || [];
  const colors = await fetchColors(20, 0, { hideEmpty: true }) || [];

  if (!products) return (
    <h1>Page not found</h1>
  );

  return (
    <Shop
      products={products}
      categories={categories}
      colors={colors}
    />
  );
}

The new implementation fetches products, categories, and colors from our GraphQL endpoint and displays them using the Shop component. This component is responsible for rendering a full product list, categories, and color filters.

2. Create the Shop component /server/Shop/Shop.tsx:

// server/Shop/Shop.tsx
import { Product, ProductCategory, PaColor } from '@/graphql';
import { ShopCategories } from "@/client/ShopCategories";
import { PaColorPicker } from '@/client/PaColorPicker';
import { SearchBar } from '@/client/SearchBar';
import { ProductListing } from '@/client/ProductListing';
import { ShopSidebar } from '@/server/ShopSidebar';

export interface ShopProps {
    products: Product[];
    categories?: ProductCategory[];
    colors: PaColor[];
}

export function Shop(props: ShopProps) {
  const {
    products,
    categories,
    colors,
  } = props;

  return (
    <div className="w-full flex max-w-screen-lg mx-auto">
      <ShopSidebar>
        {categories && (
        <>
          <p className="font-serif text-lg font-bold mb-2">Categories</p>
          <ShopCategories categories={categories} />
        </>
        )}
        <p className="font-serif text-lg font-bold mb-2">Colors</p>
        <PaColorPicker colors={colors} />
      </ShopSidebar>
      <div className="w-full px-4 lg:w-3/4">
        <p className="font-serif text-lg font-bold mb-2">Search</p>
        <SearchBar />
        <p className="font-serif text-lg font-bold mb-2">Results</p>
        <ProductListing products={products} />
      </div>
    </div>
  );
}

The Shop component displays a sidebar with categories and color filters and a main area with a search bar and product listing. It makes use of several child components, which we'll create next

3. Create the ShopSidebar component:

// server/ShopSidebar/ShopSidebar.tsx
import { PropsWithChildren } from 'react';

import {
  Sheet,
  SheetContent,
  SheetTrigger,
  SheetFooter,
  SheetClose,
} from '@/ui/sheet';
import { Button } from '@/ui/button';

export function ShopSidebar({ children }: PropsWithChildren) {
  return (
    <>
      <Sheet>
        <SheetTrigger asChild className="lg:hidden w-16 fixed inset-x-0 mx-auto bottom-10 z-30">
          <Button type="button">
            Open
          </Button>
        </SheetTrigger>
        <SheetContent>
          {children}
          <SheetFooter>
            <SheetClose asChild className="mt-4">
              <Button className="w-full flex items-center gap-x-2" type="button">
                Close
              </Button>
            </SheetClose>
          </SheetFooter>
        </SheetContent>
      </Sheet>
      <div className="hidden lg:block w-1/4 px-4">
        {children}
      </div>
    </>
  );
}

The ShopSidebar component provides sheet-style side navigation for mobile views by utilizing another shadcn/ui component. It collapses into a button and expands when clicked, revealing its children (categories and color filters). On larger screens, the sidebar is always visible.

Part 3: Create Shop Page Client Components

1. Creating the barrel file for a client component /client/ShopCategories/index.ts:

// client/ShopCategories/index.tsx
'use client';
export * from './ShopCategories';

Similar to the server components barrel file with the exception of 'use client'; at the top of the file.

2. Create ShopCategories component:

// client/ShopCategories/ShopCategories.tsx
import Link from 'next/link';

import { cn } from '@/utils/ui';
import { ProductCategory } from '@/graphql';

import { Badge } from '@/ui/badge';
import { NavLink } from '@/ui/NavLink';

export interface ShopCategoriesProps {
  categories: ProductCategory[];
}

export function ShopCategories({ categories }: ShopCategoriesProps) {
  return (
    <ul className="mb-4 max-h-[40vh] lg:max-h-[25vh] overflow-y-scroll scrollbar-thin scrollbar-corner-rounded scrollbar-thumb-ring">
      {categories.map((category) => {
        return (
          <li className="group py-2" key={category.id}>
            <NavLink href="#">
              {category.name}
            </NavLink>
          </li>
        );
      })}
    </ul>
  );
}

The ShopCategories component takes the fetched product categories and lists them in a scrollable list. Each category is clickable, but the link is not yet functional.

3. Create PaColorPicker component:

// client/PaColorPicker/PaColorPicker.tsx
import { cn } from '@/utils/ui';
import { PaColor } from '@/graphql';

import { NavLink } from '@/ui/NavLink';

interface ColorSwatchProps {
  color: string;
  circle?: boolean;
  small?: boolean;
}

function ColorSwatch({ color, circle, small }: ColorSwatchProps) {
  return (
    <div
      className={cn(
          circle ? 'rounded-full' : 'rounded',
          small ? 'w-4 h-4' : 'w-6 h-6',
      )}
      style={{ backgroundColor: color }}
    />
  );
}

export interface PaColorPickerProps {
  colors: PaColor[];
}

export function PaColorPicker({ colors }: PaColorPickerProps) {
  return (
    <ul className="mb-4 max-h-[40vh] lg:max-h-[25vh] overflow-y-scroll scrollbar-thin scrollbar-corner-rounded scrollbar-thumb-ring">
      {colors.map((color) => {
        return (
          <li className="group py-4" key={color.id}>
            <NavLink href="#" className="px-0 flex gap-2">
              <ColorSwatch color={color.slug as string} />
              {color.name}
            </NavLink>
          </li>
        );
      })}
    </ul>
  );
}

The PaColorPicker component renders a list of color filters using the color data fetched from the GraphQL endpoint. Each color is represented by a ColorSwatch and the color's name. Similar to the categories, the color filters are clickable, but the links are not yet functional

4. Create ProductListing component:

// client/ProductListing/ProductListing.tsx
import { useEffect } from 'react';
import { useRouter } from 'next/navigation';
import Link from 'next/link';

import { cn } from '@/utils/ui';
import { Product, SimpleProduct } from '@/graphql';
import { useIsMobile } from '@/hooks/mobile';
import { Image } from '@/ui/Image';
import {
  Card,
  CardContent,
  CardFooter,
  CardHeader,
  CardTitle,
} from "@/ui/card";

export interface ProductListingProps {
  products: Product[];
}

const pageSize = 12;

export function ProductListing({ products }: ProductListingProps) {
  const { push } = useRouter();
  const isMobile = useIsMobile();
  const maxPages = 5;
  const pageCount = Math.floor((products).length / pageSize);
  const hasNext = page < pageCount;
  const hasPrev = page > 1;

  const displayProducts = products.slice((page - 1) * pageSize, page * pageSize);

  return (
    <>
      <p className="font-semibold mb-4">Showing {displayProducts.length} of {(filteredProducts || products).length} items</p>
      <div className="flex flex-wrap justify-center md:justify-start gap-4">
        {displayProducts.map((product) => {
          const sourceUrl = product.image?.sourceUrl;
          const altText = product.image?.altText || '';
          return (
            <Link href={`/product/${product.slug}`} key={product.id}>
              <Card className="w-44 md:w-36">
                <CardHeader className="p-4">
                  <CardTitle className="font-serif whitespace-nowrap">{product.name}</CardTitle>
                  {sourceUrl && (
                    <Image
                        width={176}
                        height={176}
                        className="w-full"
                        src={sourceUrl}
                        alt={altText}
                        ratio={1 / 1}
                        fill={false}
                    />
                  )}
                </CardHeader>
                <CardContent className="p-4">
                  <p className="text-sm truncate">{product.shortDescription}</p>
                </CardContent>
                <CardFooter className="p-4">
                  <p className="font-serif font-bold">{(product as SimpleProduct).price}</p>
                </CardFooter>
              </Card>
            </Link>
          );
        })}
      </div>
      <div className="flex justify-center my-4 gap-x-2 text-sms">
        <Link
            href="#"
            role="button"
            className={cn(
                hasPrev ? 'text-primary-foreground' : 'text-gray-400 opacity-50 pointer-events-none',
                'self-center rounded-md text-sm font-medium transition-colors',
                'focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring',
                'bg-primary shadow hover:bg-primary/90 h-9 px-4 py-2',
            )}
            aria-label="Previous page"
            shallow
        >
            Previous
        </Link>
        <div className="flex flex-wrap justify-center gap-2">
          {Array.from({ length: Math.min(pageCount, maxPages) }).map((_, index) => {
            const pageNumber = page > Math.floor(maxPages / 2)
            ? (page - Math.floor(maxPages / 2)) + index
            : index + 1;
            return (
              <Link
                key={index}
                href="#"
                role="button"
                className={cn(
                  page !== pageNumber ? 'text-primary-foreground' : 'text-gray-400 opacity-50 pointer-events-none',
                  'rounded-md text-sm font-medium transition-colors',
                  'focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring',
                  'bg-primary shadow hover:bg-primary/90 h-9 px-4 py-2',
                )}
                aria-label={`Page ${pageNumber}`}
                shallow
              >
                  {pageNumber}
              </Link>
            )
          })}
        </div>
        <Link
          href="#"
          role="button"
          className={cn(
            hasNext ? 'text-primary-foreground' : 'text-gray-400 opacity-50 pointer-events-none',
            'self-center rounded-md text-sm font-medium transition-colors',
            'focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring',
            'bg-primary shadow hover:bg-primary/90 h-9 px-4 py-2',
          )}
          aria-label="Next page"
          shallow
        >
          Next
        </Link>
      </div>
    </>
  );
}

The ProductListing component you've created lists all the products, allowing users to view product details by clicking on a specific product. The pageSize constant determines how many products will be displayed per page, and the pagination section allows users to navigate to different pages of products.

The product list is sliced based on the current page number, which is calculated by dividing the total number of products by the page size. Each product is displayed on a card with an image, name, and short description. Clicking on the card navigates to a detailed product view.

5. Create SearchBar component:

// client/SearchBar/SearchBar.tsx
import { useState, useEffect } from 'react';
import { useRouter } from 'next/navigation';

import { Input } from "@/ui/input";

export function SearchBar() {
  const { push } = useRouter();
  const [searchInput, setSearchInput] = useState(search);
  const [debouncedSearchInput, setDebouncedSearchInput] = useState(searchInput);

  useEffect(() => {
    setSearchInput(search);
  }, [search]);

  useEffect(() => {
    const timeout = setTimeout(() => {
    setDebouncedSearchInput(searchInput);
    }, 500);
    return () => clearTimeout(timeout);
  }, [searchInput]);

  return (
    <Input
      className="mb-4"
      value={searchInput}
      onChange={(event) => setSearchInput(event.target.value)}
    />
  );
}

The SearchBar component allows users to search for products. The user's input is managed with local state, and a debounce effect is used to prevent the search function from running until 500 milliseconds have passed since the user last changed their input. This can help to prevent unnecessary or premature search requests.

If you run our application, you'll find that our shop page loads but none of the filters works. Mostly because our category, color, and page links are provided an actual URL to work with, nor are we doing anything with the SearchBar input. In Part 4 we will be building the ShopProvider component to do the job of filtering our products based upon URL and provide our filtering components with results and tools to build URLs for further interaction. Let's proceed.

Part 4: Adding Filter Functionality to Shop Page

1. Create ShopProvider component:

// client/ShopProvider/ShopProvider.tsx
import {
  useEffect,
  useContext,
  useReducer,
  createContext,
  PropsWithChildren,
  useCallback,
} from 'react';
import {
  usePathname,
  useSearchParams,
} from 'next/navigation';

import {
  Product,
  ProductCategory,
  PaColor,
  SimpleProduct,
  VariableProduct,
  ProductTypesEnum,
} from '@/graphql';

export type ProductWithPrice = SimpleProduct & { rawPrice: string }
  | VariableProduct & { rawPrice: string };

type URLParts = {
  search?: string;
  categories?: string[];
  colors?: string[];
  price?: [number, number|null];
  page?: number;
};
export interface ShopContext {
  currentUrl: string;
  buildUrl: (params: URLParts) => string;
  page: number;
  search: string;
  selectedCategories: string[];
  selectedColors: string[];
  priceRange: [number, number|null];
  setPriceRange: (priceRange: [number|null, number|null]) => void;
  globalMin: number;
  globalMax: number;
  products: Product[]|null;
  allProducts: Product[]|null;
}

export type ShopAction =
  | { type: 'UPDATE_STATE', payload: Partial<ShopContext> }

const initialState: ShopContext = {
  currentUrl: '',
  buildUrl: () => '',
  page: 1,
  search: '',
  selectedCategories: [],
  selectedColors: [],
  priceRange: [0, null],
  setPriceRange: () => {},
  globalMin: 0,
  globalMax: 100,
  products: null,
  allProducts: null,
};

const shopContext = createContext<ShopContext>(initialState);

export function useShopContext() {
  return useContext(shopContext);
}

const reducer = (state: ShopContext, action: ShopAction): ShopContext => {
  switch (action.type) {
    case 'UPDATE_STATE': 
      return { ...state, ...action.payload };
    default:
      return state;
  }
}

function filterProducts(products: Product[], state: ShopContext) {
  let filteredProducts = products;
  if (Object.is(state, initialState)) {
    return filteredProducts;
  }

  // Search by category.
  if (state.selectedCategories.length) {
    filteredProducts = filteredProducts.filter((product) => {
      return product.productCategories?.nodes?.some((category: ProductCategory) => {
        return state.selectedCategories.includes(category.slug as string);
      });
    });
  }

  // Search by color.
  if (state.selectedColors.length) {
    filteredProducts = filteredProducts.filter((product) => {
      return product.allPaColor?.nodes?.some((color: PaColor) => {
        return state.selectedColors.includes(color.slug as string);
      });
    });
  }

  // Search by name, description, and short description.
  if (state.search) {
    filteredProducts = filteredProducts.filter((product) => {
      return product.name?.toLowerCase().includes(state.search.toLowerCase())
        || product.description?.toLowerCase().includes(state.search.toLowerCase())
        || product.shortDescription?.toLowerCase().includes(state.search.toLowerCase());
    });
  }

  // Calculate global min and max prices before filtering by price range.
  const prices = filteredProducts.map((product) => {
    const stringPrice = (product as ProductWithPrice).rawPrice;
    if (stringPrice && product.type === ProductTypesEnum.VARIABLE) {
      let rawPrices = stringPrice.split(',').map((price) => Number(price));
      rawPrices.sort();
      return rawPrices;
    }
    if (stringPrice && product.type === ProductTypesEnum.SIMPLE) {
      return Number(stringPrice);
    }
    return 0;
  });

  prices.sort((priceA, priceB) => {
    if (Array.isArray(priceA) && Array.isArray(priceB)) {
      return priceA[0] - priceB[0];
    }
    if (Array.isArray(priceA)) {
      return priceA[0] - (priceB as number);
    }
    if (Array.isArray(priceB)) {
      return (priceA as number) - priceB[0];
    }
    return (priceA as number) - (priceB as number);
  });

  const firstPrice = prices.length && prices[0];
  const lastPrice = prices.length && prices.slice(-1)[0];
  const globalMin = (Array.isArray(firstPrice) ? firstPrice[0] : firstPrice) || 0;
  const globalMax = Array.isArray(lastPrice) ? lastPrice[0] : lastPrice;

  if (state.priceRange[0] || state.priceRange[1]) {
    filteredProducts = filteredProducts.filter((product) => {
      const price = (product as ProductWithPrice).rawPrice;
      if (price && product.type === ProductTypesEnum.VARIABLE) {
        const prices = price.split(',');
        const min = prices[0];
        const max = prices.slice(-1)[0];
        return (min && Number(min) >= state.priceRange[0])
          || (state.priceRange[1] && max && Number(max) <= state.priceRange[1]);
      } else if (price && product.type === ProductTypesEnum.SIMPLE) {
        return (price && Number(price) >= state.priceRange[0])
          && (state.priceRange[1] && price && Number(price) <= state.priceRange[1]);
      }
      return false;
    });
  }

  return {
    allProducts: products,
    products: filteredProducts,
    globalMin,
    globalMax
  };
}

const { Provider } = shopContext;


export interface ShopProviderProps {
  allProducts: Product[];
}

export function ShopProvider({ allProducts, children }: PropsWithChildren<ShopProviderProps>) {
  const [state, dispatch] = useReducer(reducer, initialState);
  const pathname = usePathname();
  const searchParams = useSearchParams();
  const currentParams = searchParams.toString();
  

  useEffect(() => {
    dispatch({ type: 'UPDATE_STATE', payload: {
      search: searchParams.get('search') || '',
      selectedCategories: searchParams.get('categories')?.trim().split('|') || [],
      selectedColors: searchParams.get('colors')?.trim().split('|') || [],
      priceRange: searchParams.get('price')
        ?.trim()
        .split('-')
        .map((p) => Number(p) || 0)
        .reverse() as [number, number|null] || [0, null],
      page: Number(searchParams.get('page')) || 1,
    }});
  }, [currentParams]);

  const buildUrl = useCallback((params: URLParts) => {
    const urlParts = {
      search: searchParams.get('search'),
      categories: searchParams.get('categories')?.trim().split('|') || [],
      colors: searchParams.get('colors')?.trim().split('|') || [],
      price: searchParams.get('price')
        ?.trim()
        .split('-')
        .map((p) => Number(p) || 0)
        .reverse() as [number, number|null],
      page: Number(searchParams.get('page')),
      ...params,
    };

    const url = new URL(`${process.env.FRONTEND_URL}${pathname}`);

    if (urlParts.search) {
      url.searchParams.set('search', urlParts.search);
    }
    if (urlParts.categories.length) {
      url.searchParams.set('categories', urlParts.categories.join('|'));
    }
    if (urlParts.colors.length) {
      url.searchParams.set('colors', urlParts.colors.join('|'));
    }
    const price = urlParts.price;
    if (price && ((0 !== price[0] && state.globalMin !== price[0]) 
      || price[1] && state.globalMax !== price[1])) {
      url.searchParams.set('price', price.filter(p => !!p).join('-'));
    }
    if (urlParts.page && urlParts.page !== 1) {
      url.searchParams.set('page', urlParts.page.toString());
    }

    return `${url.pathname}${url.search}`;
  }, [searchParams, pathname]);

  const store = {
    ...state,
    currentUrl: `${pathname}${searchParams.toString()}`,
    buildUrl,
    ...filterProducts(allProducts, state),
  };

  return (
    <Provider value={store}>
      {children}
    </Provider>
  );
}

The ShopProvider component manages state and provides logic for filtering products based on search parameters such as search term, product category, color, and price range. Here's an explanation of the core parts:

  • Types & Initial State: The ShopContext type defines the shape of the state managed by the ShopProvider component. initialState defines the initial values of that state.
  • Reducer: The reducer function takes the current state and an action, and returns the new state. For now, we only have one action type: 'UPDATE_STATE', which simply merges the current state with the payload of the action.
  • Context: We use React's Context API to create a context and a provider component. The context allows us to share state and some helper functions across the component tree without having to manually pass props down through intermediate nodes.
  • Filter Products: This function applies the filters (search term, category, color, and price range) to the list of products. It also calculates the global minimum and maximum prices for the current product list.
  • useEffect Hook: This hook synchronizes the state with the URL query parameters. Whenever the query parameters change, it dispatches a 'UPDATE_STATE' action with the new state derived from the query parameters.
  • buildUrl Function: This function builds a new URL for the current filters. It takes an object of URL parts, merges it with the current search parameters, and builds a new URL from it. This URL can be used to navigate to the current filters' state.
  • Provider: The Provider component wraps its children, providing them with access to the context. It passes the current state and helper functions to the context.

2. Add ShopProvider to the Shop Page:

// app/page.tsx
...

export default async function ShopPage() {
  ...

  return (
    <ShopProvider allProducts={products}>
      <Shop
        products={products}
        categories={categories}
        colors={colors}
      />
    </ShopProvider>
  );
}

With this the ShopProvider has been added to the Shop Page. All products are passed into this provider to ensure that the entire shop has access to the state.

3. Update filter components to utilize ShopProvider:

// client/ShopCategories/ShopCategories.tsx
import Link from 'next/link';
...

import { useShopContext } from '@/client/ShopProvider';
import { Badge } from '@/ui/badge';
...

export function ShopCategories({ categories }: ShopCategoriesProps) {
  const { selectedCategories, buildUrl } = useShopContext();
  return (
    <>
      <div className="flex gap-2 flex-wrap mb-4">
        {selectedCategories.map((slug) => {
          const category = categories.find((c) => c.slug === slug);
          if (!category) {
            return null;
          }

          const href = buildUrl({
            categories: selectedCategories.filter((s) => s !== slug),
            page: 1,
          });
          return (
            <Link
              key={category.id}
              href={href}
              shallow
              prefetch={false}
            >
              <Badge
                variant="outline"
                className={cn(
                  'hover:bg-red-500 hover:text-white cursor-pointer',
                  'transition-colors duration-250 ease-in-out'
                )}
              >
                {category.name}
              </Badge>
            </Link>
          );
        })}
      </div>
      <ul className="mb-4 max-h-[40vh] lg:max-h-[25vh] overflow-y-scroll scrollbar-thin scrollbar-corner-rounded scrollbar-thumb-ring">
        {categories.map((category) => {
          if (selectedCategories.includes(category.slug as string)) {
            return null;
          }
          const href = buildUrl({
            categories: [...selectedCategories, category.slug as string],
            page: 1,
          });
          return (
            <li className="group py-2" key={category.id}>
              <NavLink href={href} prefetch={false} shallow>
                {category.name}
              </NavLink>
            </li>
          );
        })}
      </ul>
    </>
  );
}
// client/PaColorPicker/PaColorPicker.tsx
import Link from 'next/link';
...

import { useShopContext } from '@/client/ShopProvider';
...

import { Badge } from '@/ui/badge';
...

export function PaColorPicker({ colors }: PaColorPickerProps) {
  const { buildUrl, selectedColors, allProducts } = useShopContext();

  const displayedColors = allProducts
    ? colors.filter((color) => {
      const productsWithColor = allProducts.filter((product) => {
        if (!product.allPaColor || product.allPaColor.nodes.length === 0) {
          return false;
        }

        return product.allPaColor.nodes.some((node: PaColor) => {
          return node.slug === color.slug;
        });
      });

      return productsWithColor.length > 0;
    })
    : colors;

  return (
    <>
      <div className="flex gap-2 flex-wrap mb-4">
        {selectedColors.map((slug) => {
          const color = displayedColors.find((c) => c.slug === slug);
          if (!color) {
              return null;
          }

          const href = buildUrl({
            colors: selectedColors.filter((s) => s !== slug),
          });
          return (
            <Link
              key={color.id}
              href={href}
              shallow
              prefetch={false}
            >
              <Badge
                variant="outline"
                className={cn(
                  'hover:bg-red-500 hover:text-white cursor-pointer',
                  'transition-colors duration-250 ease-in-out',
                  'flex gap-1 border-0'
                )}
              >
                <ColorSwatch color={color.slug as string} circle small />
                {color.name}
              </Badge>
            </Link>
          );
        })}
      </div>
      <ul className="mb-4 max-h-[40vh] lg:max-h-[25vh] overflow-y-scroll scrollbar-thin scrollbar-corner-rounded scrollbar-thumb-ring">
        {displayedColors.map((color) => {
          if (selectedColors.includes(color.slug as string)) {
            return null;
          }

          const href = buildUrl({
            colors: [...selectedColors, color.slug as string],
          });
          return (
            <li className="group py-4" key={color.id}>
              <NavLink href={href} className="px-0 flex gap-2">
                <ColorSwatch color={color.slug as string} />
                {color.name}
              </NavLink>
            </li>
          );
        })}
      </ul>
    </>
  );
}
/ client/ProductListing/ProductListing.tsx
...

import { useShopContext } from "@/client/ShopProvider";
...

export function ProductListing({ products }: ProductListingProps) {
  ...
  const { products: filteredProducts, buildUrl, page } = useShopContext();
  const pageCount = Math.floor((filteredProducts || products).length / pageSize);
  const hasNext = page < pageCount;
  const hasPrev = page > 1;

  const displayProducts = filteredProducts?.slice((page - 1) * pageSize, page * pageSize)
  || products.slice((page - 1) * pageSize, page * pageSize);

  useEffect(() => {
    if (page > pageCount) {
      const url = buildUrl({ page: pageCount });
      push(url, { shallow: true });
    }
  }, [pageCount]);

  return (
    <>
      <p className="font-semibold mb-4">Showing {displayProducts.length} of {(filteredProducts || products).length} items</p>
      ...
      <div className="flex justify-center my-4 gap-x-2 text-sms">
        <Link
          href={buildUrl({ page: Math.max(page - 1, 1) })}
          role="button"
          className={cn(
              hasPrev ? 'text-primary-foreground' : 'text-gray-400 opacity-50 pointer-events-none',
              'self-center rounded-md text-sm font-medium transition-colors',
              'focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring',
              'bg-primary shadow hover:bg-primary/90 h-9 px-4 py-2',
          )}
          aria-label="Previous page"
          shallow
        >
          Previous
        </Link>
        <div className="flex flex-wrap justify-center gap-2">
          {Array.from({ length: Math.min(pageCount, maxPages) }).map((_, index) => {
            const pageNumber = page > Math.floor(maxPages / 2)
              ? (page - Math.floor(maxPages / 2)) + index
              : index + 1;
            return (
              <Link
                key={index}
                href={buildUrl({ page: pageNumber })}
                role="button"
                className={cn(
                  page !== pageNumber ? 'text-primary-foreground' : 'text-gray-400 opacity-50 pointer-events-none',
                  'rounded-md text-sm font-medium transition-colors',
                  'focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring',
                  'bg-primary shadow hover:bg-primary/90 h-9 px-4 py-2',
                )}
                aria-label={`Page ${pageNumber}`}
                shallow
              >
                {pageNumber}
              </Link>
            )
          })}
        </div>
        <Link
          href={buildUrl({ page: Math.min(page + 1, pageCount) })}
          role="button"
          className={cn(
            hasNext ? 'text-primary-foreground' : 'text-gray-400 opacity-50 pointer-events-none',
            'self-center rounded-md text-sm font-medium transition-colors',
            'focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring',
            'bg-primary shadow hover:bg-primary/90 h-9 px-4 py-2',
          )}
          aria-label="Next page"
          shallow
        >
          Next
        </Link>
      </div>
    </>
  );
}
// client/SearchBar/SearchBar.tsx
...

import { useShopContext } from '@/client/ShopProvider';
...

export function SearchBar() {
  ...
  const { currentUrl, buildUrl, search } = useShopContext();
  const [searchInput, setSearchInput] = useState(search);
  const [debouncedSearchInput, setDebouncedSearchInput] = useState(searchInput);
  ...

  useEffect(() => {
    const url = buildUrl({
    search: debouncedSearchInput,
    page: 1,
    });
    if (url !== currentUrl) {
      push(url, { shallow: true });
    }
  }, [debouncedSearchInput]);

  return (
    <Input
      className="mb-4"
      value={searchInput}
      onChange={(event) => setSearchInput(event.target.value)}
    />
  );
}

ShopCategories, PaColorPicker, ProductListing, and SearchBar have been updated to utilize the ShopProvider. This means these components can now access the shop context, allowing them to filter products based on selected categories, colors, and search queries.

Note: the functions buildUrl and useShopContext are used across these components. The buildUrl function constructs a new URL based on changes to the filters (e.g., selected categories, colors, and search terms). useShopContext is a custom hook that provides easy access to the context managed by ShopProvider.

For instance, in the ShopCategories component, you can see that it uses the selectedCategories and buildUrl from the useShopContext hook. Whenever a category is selected, it updates the URL and hence the display of products as per the selected categories:

// client/ShopCategories/ShopCategories.tsx
const { selectedCategories, buildUrl } = useShopContext();

Similarly, the PaColorPicker, ProductListing, and SearchBar components also use the buildUrl and useShopContext hook to adjust the displayed products based on user-selected filters.

With the inclusion of these changes, your shop now boasts a centralized state management system that allows for efficient manipulation and retrieval of state throughout your application. This results in a more robust and flexible eCommerce store that is better suited to meet the needs of its users.

Part 5: Adding the Category pages.

This last part will be quick. To implement the page for the /category/* links in the TopNav define /app/[category]/page.tsx as follows:

// app/[category]/page.tsx
import {
  fetchProducts,
  fetchColors,
} from '@/graphql';

import { Shop } from '@/server/Shop';
import { ShopProvider } from '@/client/ShopProvider';
export interface CategoryPageProps { 
  params: {
    category: string
  }
}

export default async function CategoryPage({ params }: CategoryPageProps) {
  const { category } = params;

  if (!category) return (
    <h1>Page not found</h1>
  );

  const products = await fetchProducts(1, 0, { category: category });
  const colors = await fetchColors(1) || [];

  if (!products || products.length === 0) return (
    <h1>{`The ${category} category does not exist. Please check URL and try again.`}</h1>
  );

  return (
    <ShopProvider allProducts={products}>
      <Shop
        products={products}
        colors={colors}
      />
    </ShopProvider>
  );
}

It should be pretty obvious as to what's going on, so without going into too much detail this page works identically to our Shop Page except no categories are provided to our Shop component for filtering and only products connected to our selected category are queried.

Conclusion

That's it for this tutorial! You've now successfully created a functional Shop page with a fully functional top navigation, product listings, and filters. There's still a lot to do though, in the next tutorial we'll be implementing User Navigation and the Login Page. Stay tuned!

Continue to next chapter