Testa su: questo tutorial copre un precedente rilascio WooGraphQL e non è più aggiornato. Una versione riscritta è in corso — ci collegheremo da qui una volta pubblicato.
Tabella dei contenuti
- Impostazione di WordPress per il tuo Headless eCommerce Applicazione
- Creare e configurare la tua App eCommerce per lo sviluppo
- Pagine delle inserzioni di Building Shop con WooGraphQL
- Creazione di Login utente e navigazione
- Creazione della pagina del prodotto e delle opzioni del carrello
Benvenuti nella terza parte della nostra serie di tutorial sulla creazione di un’applicazione eCommerce senza testa! Questo capitolo è un passo passo avanti nella creazione delle pagine di elenco del negozio utilizzando WooGraphQL + Next 13. L’obiettivo è quello di implementare un sistema di navigazione e una pagina Shop per la tua piattaforma di e-commerce.
Pagine delle inserzioni di Building Shop con WooGraphQL
Inizieremo creando la pagina del negozio con poca o nessuna funzionalità di filtraggio, quindi finire implementando la funzionalità di filtraggio e aggiornando ogni componente rispettivamente. Senza ulteriore ritardo cominciamo!
Parte 1: Modifica del layout della radice
Per prima cosa modificheremo il nostro layout radice per includere la navigazione superiore.
1. Aggiornamento /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>
);
}Il nuovo layout farà uso di TopNav dal server WooGraphQL. Questo poperà il menu di navigazione con le categorie di prodotti prese dal punto di vista GraphQL. Inoltre, importiamo un tostapane per le notifiche degli utenti e feedback.
2. Creare server/TopNav/index.ts:
// server/TopNav/index.ts
export * from './TopNav';
Questo file agisce come un file di barile, permettendoci di importare tutto da ./TopNav semplificare immediatamente le importazioni altrove nel nostro codice. Questa è una pratica comune per isolare i test associati e i file di utilità locale. Aspettatevi questo modello da utilizzare per tutti i componenti nel /server. Andando avanti non mostrerò i file del barile del componente con alcune eccezioni.
3. Creare 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>
);
}Questo file definisce il nostro TopNav componente, una barra di navigazione full-width che include un elenco di categorie (se presenti) e il branding del sito. Nel nostro caso layout.tsx stiamo fornendo le 5 categorie più popolate.
Parte 2: Creare Shop Page e Shop Page Componenti server
Ora, creeremo una pagina Shop dove i prodotti possono essere elencati.
1. Reattore 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}
/>
);
}La nuova applicazione fetches prodotti, categorie e colori dal nostro endpoint GraphQL e li visualizza utilizzando Shop componente. Questo componente è responsabile per la visualizzazione di un elenco completo dei prodotti, categorie e filtri a colori.
2. Creare il componente Shop /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>
);
}Il componente Shop visualizza una barra laterale con categorie e filtri a colori e un’area principale con una barra di ricerca e un elenco di prodotti. Fa uso di diversi componenti per bambini, che creeremo il prossimo
3. Creare il componente ShopSidebar:
// 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 componente fornisce la navigazione laterale in stile foglio per le viste mobili utilizzando un altro shadcn/ui componente. Si collassa in un pulsante e si espande quando cliccato, rivelando i suoi bambini (categorie e filtri di colore). Su schermi più grandi, la barra laterale è sempre visibile.
Parte 3: Crea componenti client della pagina del negozio
1. Creazione del file barilotto per un componente client /client/ShopCategories/index.ts:
// client/ShopCategories/index.tsx
'use client';
export * from './ShopCategories';
Simile al file del barile dei componenti del server con l’eccezione di 'use client'; nella parte superiore del file.
2. Creare ShopCategories componente:
// 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 componente prende le categorie di prodotti recuperati e li elenca in una lista pergamenabile. Ogni categoria è cliccabile, ma il link non è ancora funzionale.
3. Creare PaColorPicker componente:
// 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 componente rende un elenco di filtri a colori utilizzando i dati di colore recuperati dal punto di vista GraphQL. Ogni colore è rappresentato da un ColorSwatch e il nome del colore. Simile alle categorie, i filtri a colori sono cliccabili, ma i link non sono ancora funzionali
4. Creare ProductListing componente:
// 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 componente che hai creato elenca tutti i prodotti, permettendo agli utenti di visualizzare i dettagli del prodotto facendo clic su un prodotto specifico. The pageSize costante determina quanti prodotti verranno visualizzati per pagina, e la sezione impaginazione consente agli utenti di navigare in diverse pagine di prodotti.
La lista dei prodotti è affettata in base al numero di pagina corrente, che viene calcolato dividendo il numero totale dei prodotti per la dimensione della pagina. Ogni prodotto viene visualizzato su una carta con un’immagine, un nome e una breve descrizione. Fare clic sulla scheda naviga in una vista dettagliata del prodotto.
5. Creare SearchBar componente:
// 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 componente consente agli utenti di cercare prodotti. L’ingresso dell’utente viene gestito con lo stato locale e viene utilizzato un effetto di debounce per impedire che la funzione di ricerca esegua fino a 500 millisecondi passati dall’ultima modifica del loro ingresso. Questo può aiutare a prevenire richieste di ricerca non necessarie o prematuri.

Se si esegue la nostra applicazione, troverete che la nostra pagina del negozio carica ma nessuno dei filtri funziona. Soprattutto perché la nostra categoria, il colore e i link di pagina sono forniti un URL effettivo con cui lavorare, né stiamo facendo nulla con il SearchBar ingresso. Nella Parte 4 costruiremo ShopProvider componente per fare il lavoro di filtrare i nostri prodotti in base all’URL e fornire ai nostri componenti filtranti risultati e strumenti per costruire URL per ulteriori interazioni. Procediamo.
Parte 4: Aggiungere la funzionalità del filtro alla pagina del negozio
1. Creare ShopProvider componente:
// 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;
}
// Ricerca per categoria.
if (state.selectedCategories.length) {
filteredProducts = filteredProducts.filter((product) => {
return product.productCategories?.nodes?.some((category: ProductCategory) => {
return state.selectedCategories.includes(category.slug as string);
});
});
}
// Ricerca per colore.
if (state.selectedColors.length) {
filteredProducts = filteredProducts.filter((product) => {
return product.allPaColor?.nodes?.some((color: PaColor) => {
return state.selectedColors.includes(color.slug as string);
});
});
}
// Ricerca per nome, descrizione e breve descrizione.
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());
});
}
// Calcola i prezzi minimi e massimi globali prima di filtrare per fascia di prezzo.
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 componente gestisce lo stato e fornisce la logica per il filtraggio dei prodotti in base ai parametri di ricerca come il termine di ricerca, la categoria di prodotto, il colore e la fascia di prezzo. Ecco una spiegazione delle parti principali:
- Tipi & Stato iniziale:
ShopContexttipo definisce la forma dello stato gestito dalShopProvidercomponente.initialStatedefinisce i valori iniziali di tale stato. - Riduzione: La funzione di riduttore prende lo stato attuale e un’azione, e restituisce il nuovo stato. Per ora, abbiamo solo un tipo di azione: ‘UPDATE STATE’, che semplicemente fonde lo stato attuale con il carico utile dell’azione.
- Contesto: Usiamo l’API Context di React per creare un contesto e un componente del fornitore. Il contesto ci permette di condividere le funzioni di stato e di alcuni helper attraverso l’albero dei componenti senza dover passare manualmente i prop giù attraverso nodi intermedi.
- Prodotti di filtro: Questa funzione applica i filtri (termine di ricerca, categoria, colore e fascia di prezzo) all’elenco dei prodotti. Calcola anche i prezzi minimi e massimi globali per l’elenco attuale dei prodotti.
- usoEffect gancio: Questo gancio sincronizza lo stato con i parametri di query dell’URL. Ogni volta che i parametri di query cambiano, invia un’azione ‘UPDATE STATE’ con il nuovo stato derivato dai parametri di query.
- funzione buildUrl: Questa funzione costruisce un nuovo URL per i filtri attuali. Ci vuole un oggetto di parti URL, si fonde con i parametri di ricerca attuali, e costruisce un nuovo URL da esso. Questo URL può essere utilizzato per navigare allo stato dei filtri attuali.
- Fornitore:
Providercomponente avvolge i suoi figli, fornendo loro l’accesso al contesto. Passa le funzioni di stato e helper attuali al contesto.
2. Aggiungi ShopProvider alla pagina del negozio:
// app/page.tsx
...
export default async function ShopPage() {
...
return (
<ShopProvider allProducts={products}>
<Shop
products={products}
categories={categories}
colors={colors}
/>
</ShopProvider>
);
}Con questo ShopProvider è stato aggiunto alla pagina negozio. Tutti i prodotti sono passati in questo fornitore per garantire che l’intero negozio ha accesso allo stato.
3. Aggiornare i componenti del filtro da utilizzare 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♪ ProductListinge SearchBar sono stati aggiornati per utilizzare ShopProviderQuesto significa che questi componenti possono ora accedere al contesto del negozio, permettendo loro di filtrare i prodotti in base a categorie selezionate, colori e query di ricerca.
Nota: le funzioni buildUrl e useShopContext vengono utilizzati attraverso questi componenti. The buildUrl la funzione costruisce un nuovo URL basato sulle modifiche ai filtri (ad esempio, categorie selezionate, colori e termini di ricerca). useShopContext è un gancio personalizzato che fornisce un facile accesso al contesto gestito da ShopProvider.
Per esempio, nel ShopCategories componente, si può vedere che utilizza il selectedCategories e buildUrl dal useShopContext Uncino. Quando viene selezionata una categoria, aggiorna l’URL e quindi la visualizzazione dei prodotti secondo le categorie selezionate:
// client/ShopCategories/ShopCategories.tsx
const { selectedCategories, buildUrl } = useShopContext();
Analogamente, il PaColorPicker♪ ProductListinge SearchBar i componenti utilizzano anche buildUrl e useShopContext gancio per regolare i prodotti visualizzati in base ai filtri selezionati dall’utente.
Con l’inclusione di questi cambiamenti, il vostro negozio ora vanta un sistema centralizzato di gestione dello stato che consente una manipolazione efficiente e il recupero di stato durante la vostra applicazione. Questo si traduce in un negozio di eCommerce più robusto e flessibile che è più adatto per soddisfare le esigenze dei suoi utenti.

Parte 5: Aggiungere le pagine della Categoria.
Quest’ultima parte sarà veloce. Per implementare la pagina per /category/* link in TopNav Definizione /app/[category]/page.tsx come segue:
// app/[categoria]/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>
);
}Dovrebbe essere abbastanza ovvio per quello che sta succedendo, quindi senza entrare troppo in dettaglio questa pagina funziona in modo identico alla nostra pagina di negozio, tranne che non ci sono categorie fornite alla nostra Shop componente per il filtraggio e solo i prodotti collegati alla nostra categoria selezionata vengono interrogati.
Conclusioni
Questo è tutto per questo tutorial! Ora hai creato con successo una pagina di Shop funzionale con una navigazione superiore completamente funzionale, elenchi di prodotti e filtri. C’è ancora molto da fare, però, nel prossimo tutorial sarà implementare la navigazione utente e la pagina di accesso. Restate in sintonia!

Leave a Reply