Aufbau von Headless Shops mit WooGraphQL: Kapitel 3 von 5

banner

Heads up: Dieses Tutorial deckt eine frühere WooGraphQL-Version ab und ist nicht mehr auf dem neuesten Stand. Eine neu geschriebene Version ist in Arbeit – wir werden von hier aus darauf verlinken, sobald sie veröffentlicht wurde.

https://www.youtube.com/embed/nhlexP-KtSo

Inhaltsverzeichnis

  1. Einrichten von WordPress für Ihre Headless eCommerce-Anwendung
  2. Erstellen und Einrichten Ihrer eCommerce-App für die Entwicklung
  3. Erstellen von Shop Listing Pages mit WooGraphQL
  4. Erstellen von Benutzer-Login und Navigation
  5. Erstellen der Produktseite und Warenkorboptionen

Willkommen zum dritten Teil unserer Tutorial-Serie zum Erstellen einer Headless-E-Commerce-Anwendung! Dieses Kapitel ist eine Schritt-für-Schritt-Anleitung zum Erstellen der Shop-Listenseiten mit WooGraphQL + Next 13. Der Fokus liegt auf der Implementierung eines Navigationssystems und einer Shop-Seite für Ihre E-Commerce-Plattform.

Erstellen von Shop Listing Pages mit WooGraphQL

Wir beginnen mit der Erstellung der Shop-Seite mit wenig bis keiner Filterfunktionalität und enden dann mit der Implementierung der Filterfunktionalität und der Aktualisierung jeder Komponente. Ohne weitere Verzögerung beginnen wir!

Teil 1: Wurzel-Layout bearbeiten

Wir werden zuerst unser Root-Layout ändern, um die Top-Navigation aufzunehmen.

1. Aktualisierung /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>
  );
}

Das neue Layout wird von TopNav vom WooGraphQL-Server. Dadurch wird das Navigationsmenü mit Produktkategorien gefüllt, die vom GraphQL-Endpunkt abgerufen wurden. Darüber hinaus importieren wir einen Toaster für Benutzerbenachrichtigungen und Feedback.

2. Schaffen server/TopNav/index.ts:

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

Diese Datei fungiert als Barrel-Datei, so dass wir alles von importieren ./TopNav auf einmal, um Importe an anderer Stelle in unserem Code zu vereinfachen. Dies ist eine gängige Praxis zum Isolieren von zugehörigen Tests und lokalen Utility-Dateien. Erwarten Sie, dass dieses Muster für alle Komponenten in der /server. In Zukunft werde ich die Komponentenfassdateien mit einigen Ausnahmen nicht anzeigen.

3. Schaffen 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>
  );
}

Diese Datei definiert unsere TopNav Eine Komponente, eine Navigationsleiste mit voller Breite, die eine Liste von Kategorien (falls vorhanden) und das Branding von Websites enthält. In unserem Fall in unserem layout.tsx Wir bieten die 5 bevölkerungsreichsten Kategorien an.

Teil 2: Erstellen von Shop Page und Shop Page Server Komponenten

Jetzt erstellen wir eine Shop-Seite, auf der Produkte aufgelistet werden können.

1. Refaktor 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}
/>
  );
}

Die neue Implementierung holt Produkte, Kategorien und Farben aus unserem GraphQL-Endpunkt und zeigt sie mit dem Shop Bauteil. Diese Komponente ist für das Rendern einer vollständigen Produktliste, Kategorien und Farbfilter verantwortlich.

2. Erstellen der Shop-Komponente /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>
  );
}

Die Shop-Komponente zeigt eine Seitenleiste mit Kategorien und Farbfiltern und einen Hauptbereich mit einer Suchleiste und einer Produktliste an. Es verwendet mehrere Kinderkomponenten, die wir als nächstes erstellen werden

3. Erstellen Sie die ShopSidebar-Komponente:

// 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>
    </>
  );
}

Die ShopSidebar Komponente bietet Seitennavigation im Blattstil für mobile Ansichten durch Verwendung einer anderen shadcn/ui Bauteil. Es bricht in eine Schaltfläche zusammen und erweitert sich, wenn es angeklickt wird, und enthüllt seine Kinder (Kategorien und Farbfilter). Auf größeren Bildschirmen ist die Seitenleiste immer sichtbar.

Teil 3: Erstellen von Shop Page Client Komponenten

1. Erstellen der Barrel-Datei für eine Client-Komponente /client/ShopCategories/index.ts:

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

Ähnlich wie die Serverkomponenten Barrel-Datei mit Ausnahme von 'use client'; Ganz oben in der Datei.

2. Schaffen ShopCategories Bauteil:

// client/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>
  );
}

Die ShopCategories Component nimmt die abgeholten Produktkategorien und listet sie in einer scrollbaren Liste auf. Jede Kategorie ist anklickbar, aber der Link ist noch nicht funktionsfähig.

3. Schaffen PaColorPicker Komponente:

// 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>
  );
}

Die PaColorPicker Eine Komponente stellt eine Liste von Farbfiltern unter Verwendung der vom GraphQL-Endpunkt abgerufenen Farbdaten dar. Jede Farbe wird durch eine ColorSwatch und den Namen der Farbe. Ähnlich wie bei den Kategorien sind die Farbfilter anklickbar, aber die Links sind noch nicht funktionsfähig

4. Schaffen ProductListing Bauteil:

// 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>
    </>
  );
}

Die ProductListing Die von Ihnen erstellte Komponente listet alle Produkte auf, sodass Benutzer Produktdetails anzeigen können, indem sie auf ein bestimmtes Produkt klicken. Die pageSize Constant bestimmt, wie viele Produkte pro Seite angezeigt werden, und der Paginierungsbereich ermöglicht es Benutzern, zu verschiedenen Seiten von Produkten zu navigieren.

Die Produktliste wird basierend auf der aktuellen Seitenzahl geschnitten, die berechnet wird, indem die Gesamtzahl der Produkte durch die Seitengröße geteilt wird. Jedes Produkt wird auf einer Karte mit Bild, Name und kurzer Beschreibung angezeigt. Ein Klick auf die Karte navigiert zu einer detaillierten Produktansicht.

5. Schaffen SearchBar Bauteil:

// 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)}
/>
  );
}

Die SearchBar Eine Komponente ermöglicht es Benutzern, nach Produkten zu suchen. Die Eingabe des Benutzers wird mit dem lokalen Status verwaltet, und ein Debounce-Effekt wird verwendet, um zu verhindern, dass die Suchfunktion ausgeführt wird, bis 500 Millisekunden vergangen sind, seit der Benutzer zuletzt seine Eingabe geändert hat. Dies kann dazu beitragen, unnötige oder vorzeitige Suchanfragen zu verhindern.

Wenn Sie unsere Anwendung ausführen, werden Sie feststellen, dass unsere Shop-Seite geladen wird, aber keiner der Filter funktioniert. Vor allem, weil unsere Kategorie, Farbe und Seitenlinks eine tatsächliche URL zur Verfügung gestellt werden, mit der wir arbeiten können, noch tun wir etwas mit der SearchBar Input. In Teil 4 werden wir die ShopProvider Eine Komponente, um unsere Produkte basierend auf URL zu filtern und unseren Filterkomponenten Ergebnisse und Tools zur Erstellung von URLs für die weitere Interaktion zur Verfügung zu stellen. Fahren wir fort.

Teil 4: Hinzufügen von Filterfunktionalität zur Shop Page

1. Schaffen ShopProvider Bauteil:

// 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;
  }

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

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

  // Suche nach Name, Beschreibung und Kurzbeschreibung.
  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());
    });
  }

  // Berechnen Sie globale Min- und Max-Preise, bevor Sie nach Preisspannen filtern.
  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>
  );
}

Die ShopProvider Component verwaltet den Zustand und bietet Logik zum Filtern von Produkten basierend auf Suchparametern wie Suchbegriff, Produktkategorie, Farbe und Preisspanne. Hier ist eine Erklärung der Kernteile:

  • Typen & Initial State: Der ShopContext Typ definiert die Form des Staates, der von der ShopProvider Bauteil. initialState definiert die Anfangswerte dieses Zustands.
  • ReduziererDie Reducer-Funktion nimmt den aktuellen Zustand und eine Aktion und gibt den neuen Zustand zurück. Im Moment haben wir nur einen Aktionstyp: ‘UPDATE STATE’, der einfach den aktuellen Zustand mit der Nutzlast der Aktion zusammenführt.
  • KontextWir verwenden die Context API von React, um einen Kontext und eine Provider-Komponente zu erstellen. Der Kontext ermöglicht es uns, Status- und einige Helferfunktionen über den Komponentenbaum zu teilen, ohne dass Requisiten manuell durch Zwischenknoten weitergegeben werden müssen.
  • Filterprodukte: Diese Funktion wendet die Filter (Suchbegriff, Kategorie, Farbe und Preisklasse) auf die Liste der Produkte an. Es berechnet auch die globalen Mindest- und Höchstpreise für die aktuelle Produktliste.
  • useEffect Hook: Dieser Haken synchronisiert den Zustand mit den URL-Abfrageparametern. Jedes Mal, wenn sich die Abfrageparameter ändern, wird eine Aktion ‘UPDATE STATE’ mit dem neuen Zustand gesendet, der aus den Abfrageparametern abgeleitet wurde.
  • buildUrl Funktion: Diese Funktion erstellt eine neue URL für die aktuellen Filter. Es nimmt ein Objekt von URL-Teilen, fügt es mit den aktuellen Suchparametern zusammen und erstellt daraus eine neue URL. Diese URL kann verwendet werden, um zum Status der aktuellen Filter zu navigieren.
  • Anbieter: Der Provider Component wickelt seine Kinder ein und bietet ihnen Zugang zum Kontext. Es übergibt den aktuellen Zustand und Helferfunktionen an den Kontext.

2. Addition ShopProvider auf der Shop Page:

// app/page.tsx
...

export default async function ShopPage() {
  ...

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

Mit diesem ShopProvider wurde der Shop-Seite hinzugefügt. Alle Produkte werden an diesen Anbieter weitergegeben, um sicherzustellen, dass der gesamte Shop Zugang zum Staat hat.

3. Aktualisieren von Filterkomponenten zur Verwendung ShopProvider:

// client/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, und SearchBar aktualisiert wurden, um die ShopProviderDies bedeutet, dass diese Komponenten nun auf den Shop-Kontext zugreifen können, sodass sie Produkte anhand ausgewählter Kategorien, Farben und Suchanfragen filtern können.

Hinweis: die Funktionen buildUrl und useShopContext werden über diese Komponenten hinweg verwendet. Die buildUrl Die Funktion erstellt eine neue URL basierend auf Änderungen an den Filtern (z. B. ausgewählte Kategorien, Farben und Suchbegriffe). useShopContext ist ein benutzerdefinierter Hook, der einen einfachen Zugriff auf den von ShopProvider.

Zum Beispiel in der ShopCategories Komponente, können Sie sehen, dass es verwendet die selectedCategories und buildUrl aus dem useShopContext Haken. Wenn eine Kategorie ausgewählt wird, aktualisiert sie die URL und damit die Anzeige von Produkten gemäß den ausgewählten Kategorien:

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

Ebenso ist die PaColorPicker, ProductListing, und SearchBar Komponenten nutzen auch die buildUrl und useShopContext Haken zum Anpassen der angezeigten Produkte basierend auf vom Benutzer ausgewählten Filtern.

Mit der Einbeziehung dieser Änderungen verfügt Ihr Shop nun über ein zentralisiertes Zustandsmanagementsystem, das eine effiziente Manipulation und den Abruf des Zustands während Ihrer gesamten Anwendung ermöglicht. Dies führt zu einem robusteren und flexibleren E-Commerce-Shop, der besser geeignet ist, die Bedürfnisse seiner Benutzer zu erfüllen.

Teil 5: Hinzufügen der Kategorieseiten.

Der letzte Teil wird schnell sein. Um die Seite für die /category/* Links in der TopNav definiert /app/[category]/page.tsx wie folgt:

// app/[Kategorie]/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>
  );
}

Es sollte ziemlich offensichtlich sein, was vor sich geht, also ohne zu sehr ins Detail zu gehen, funktioniert diese Seite identisch mit unserer Shop-Seite, außer dass keine Kategorien für unsere bereitgestellt werden Shop Komponente für die Filterung und nur Produkte, die mit unserer ausgewählten Kategorie verbunden sind, werden abgefragt.

Schlussfolgerung

Das ist es für dieses Tutorial! Sie haben nun erfolgreich eine funktionale Shop-Seite mit einer voll funktionsfähigen oberen Navigation, Produktlisten und Filtern erstellt. Es gibt jedoch noch viel zu tun, im nächsten Tutorial werden wir die Benutzernavigation und die Anmeldeseite implementieren. Bleibt dran!

Weiter zum nächsten Kapitel



Leave a Reply

Your email address will not be published. Required fields are marked *