抬头: 此教程涵盖较早的 WooGraphQL 发布, 并且不再更新 。 重写版本正在展开,
目录
- 为您的无头电子商务应用程序设置文字通讯
- 创建和设置您的电子商务应用促进发展
- 使用 WooGraphQL 创建商店列表页面
- 创建用户登录和导航
- 创建产品页面和墨盒选项
欢迎收看我们关于创建无头电子商业应用程序的教程系列的第三部分! 本章是用WooGraphQL + Next 13 创建商店上市页面的一步步走过. 重点是为您的电子商务平台实施导航系统和商店网页。
使用 WooGraphQL 创建商店列表页面
我们首先创建很少或没有过滤功能的商店页面,然后分别通过实施过滤功能和更新每个组件来完成. 不再拖延,我们开始吧!
第一部分:编辑根版式
我们将首先修改我们的根布局,以包括顶端导航。
1. 联合国 更新 /app/layout.tsx数字 :
// 应用程序/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>
);
}新布局将利用 TopNav 来自 WooGraphQL 服务器。 这将在导航菜单中添加从 GraphQL 端点获取的产品类别 。 此外,我们进口了一台面包机,用于用户通知和反馈。
2. 联合国 创建 server/TopNav/index.ts数字 :
// 服务器/TopNav/index.ts
export * from './TopNav';
这个文件是作为桶文件的,允许我们从 ./TopNav 立即简化我们代码中的进口 这是隔离相关测试和本地效用文件的常见做法. 期望此模式用于当前 /server向前看 除了一些例外 我不会展示组件桶文件
3个 创建 server/TopNav/TopNav.tsx数字 :
// 服务器/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>
);
}这个文件定义了我们的 TopNav 组件,包括类别清单(如果有)和站点品牌的全宽导航条。 在我们的情况下 layout.tsx 我们正在提供5个人口最多的类别。
第2部分: 创建商店页面和商店页面服务器组件
现在,我们将创建一个商店页面,在那里可以列出产品.
1. 联合国 重构 app/page.tsx数字 :
// 应用/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}
/>
);
}新执行从我们的 GraphQL 端点获取产品、类别和颜色并使用 Shop 组件。 这个组件负责制作完整的产品清单,类别和色彩过滤.
2. 联合国 创建商店组件 /server/Shop/Shop.tsx数字 :
// 服务器/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>
);
}Shop组件显示一个有类别和颜色过滤器的侧边栏,以及一个有搜索栏和产品上市的主要区域. 它利用了几个孩子的成分,我们将创造下一个
3个 创建 ShopSidebar 组件 :
// 服务器/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>
</>
);
}这个 ShopSidebar 组件为移动视图提供工作表样式的侧导航,使用另一个 shadcn/ui 组件。 它会倒入一个按钮并被点击后会膨胀,揭示出它的孩子(类别和颜色过滤器). 在更大的屏幕上,侧边栏总是可见.
第3部分: 创建商店页面客户端组件
1. 联合国 为客户端组件创建桶文件 /client/ShopCategories/index.ts数字 :
// 客户端/ShopCategores/index.tsx
'use client';
export * from './ShopCategories';
类似于服务器组件桶文件,但 'use client'; 在文件的顶端。
2. 联合国 创建 ShopCategories 组件 :
// 客户端/ShopCategorys/ShopCategory.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>
);
}这个 ShopCategories 组件取取的产品类别并将其列在可滚动列表中。 每个分类可以点击,但链接还没有功能.
3个 创建 PaColorPicker 组件数字 :
// 客户端/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>
);
}
这个 PaColorPicker 组件会使用从 GraphQL 端点获取的颜色数据来编制颜色过滤器列表。 每种颜色都用一个 ColorSwatch 和颜色的名字。 与类别类似,颜色过滤器可以点击,但链接尚未发挥作用
4.四. 创建 ProductListing 组件 :
// 客户/生产/生产
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>
</>
);
}
这个 ProductListing 您创建的组件列出所有的产品, 允许用户点击特定产品查看产品细节 。 这个 pageSize 常数决定每个页面将显示多少个产品,而pagination部分允许用户导航到不同的页面产品.
产品清单以当前页码为分块,以产品总数除以页码大小来计算. 每个产品被显示在一张有图像,名称和简短描述的卡片上. 点击卡片导航到详细的产品视图 。
5 (韩语). 创建 SearchBar 组件 :
// 客户端/搜索栏/搜索栏
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)}
/>
);
}
这个 SearchBar 组件允许用户搜索产品。 用户的输入由本地状态管理,并使用去发效应来防止搜索功能运行,直到用户上次更改输入后500毫秒后通过. 这有助于防止不必要的或过早的搜索请求.

如果你运行我们的应用程序, 你会发现我们的商店页面载荷 但过滤器没有工作。 主要因为我们的类别、颜色和页面链接都得到了一个实际的URL,可以与之合作,我们也没有做任何事情。 SearchBar 输入。 在第四部分,我们将建立 ShopProvider 组件来完成基于URL过滤我们的产品的工作,并为我们的过滤组件提供结果和工具来构建URL供进一步互动. 继续吧
第4部分:将过滤器功能添加到商店页面
1. 联合国 创建 ShopProvider 组件 :
// 客户端/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;
}
// 按类别搜索.
if (state.selectedCategories.length) {
filteredProducts = filteredProducts.filter((product) => {
return product.productCategories?.nodes?.some((category: ProductCategory) => {
return state.selectedCategories.includes(category.slug as string);
});
});
}
// 以色相相相寻.
if (state.selectedColors.length) {
filteredProducts = filteredProducts.filter((product) => {
return product.allPaColor?.nodes?.some((color: PaColor) => {
return state.selectedColors.includes(color.slug as string);
});
});
}
// 按姓名、描述和简短描述搜索。
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());
});
}
// 在按价格范围过滤前计算全球分和最大价格。
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>
);
}
这个 ShopProvider 组件管理状态,并提供基于搜索词、产品类别、颜色和价格范围等搜索参数过滤产品的逻辑。 以下是核心部分的解释:
- 类型初始状态( I)编号:
ShopContext类型定义由ShopProvider组件。initialState定义该状态的初始值。 - 缩小器: 减少函数取取当前状态和一个动作,并返回新状态. 目前我们只有一个动作类型:”UPDATE STATE”,它简单地将当前状态与动作的有效载荷相融合.
- A. 背景情况:我们使用React的上下文API来创建上下文和提供者组件. 上下文允许我们跨越组件树分享状态和一些辅助功能,而无需手动通过中间节点传递道具.
- 过滤产品:此函数将过滤器(搜索名词,类别,颜色和价格范围)应用于产品列表. 它还计算出目前产品清单的全球最低和最高价格。
- 使用效果钩: 此勾钩与 URL 查询参数同步状态 。 当查询参数发生变化时,它会发出一个“ UPDATE STATE” 动作,其中包含从查询参数中衍生出的新状态。
- 构建Url 函数: 此函数为当前过滤器建立新的 URL 。 它需要一个URL部件的对象,将其与当前搜索参数合并,并从中构建出一个新的URL. 此 URL 可用于导航当前过滤器状态 。
- 供应商编号:
Provider其组成部分包罗儿童,为他们提供了解背景的机会。 它将当前状态和助手功能传递到上下文.
2. 联合国 添加 ShopProvider 转到商店页面 :
// 应用/page.tsx
...
export default async function ShopPage() {
...
return (
<ShopProvider allProducts={products}>
<Shop
products={products}
categories={categories}
colors={colors}
/>
</ShopProvider>
);
}有了这个 ShopProvider 已添加到商店页面。 所有产品都传入这个供应商,以确保整个商店能够进入国家。
3个 更新要使用的过滤组件 ShopProvider数字 :
// 客户端/ShopCategorys/ShopCategory.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>
</>
);
}// 客户端/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>
</>
);
}// 客户端/搜索栏/搜索栏
...
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,以及 SearchBar 已更新,以便使用 ShopProvider。这意味着这些组件现在可以访问商店上下文,允许它们根据选定的类别、颜色和搜索查询过滤产品。
注:职能 buildUrl 和 useShopContext 用于这些组件。 这个 buildUrl 函数根据过滤器的修改(例如选定的类别、颜色和搜索术语)构建一个新的URL。 useShopContext 是一个自定义的钩子,可以方便地访问由 ShopProvider。 。 。 。
举例来说, ShopCategories 组件,您可以看到它使用 selectedCategories 和 buildUrl 从 useShopContext 钩. 每次选择一个类别,它都会更新URL,从而按照所选类别显示产品:
// 客户端/ShopCategorys/ShopCategory.tsx
const { selectedCategories, buildUrl } = useShopContext();
同样, PaColorPicker, (中文(简体) ). ProductListing,以及 SearchBar 组件还使用 buildUrl 和 useShopContext 根据用户选择的过滤器调整所显示产品的钩.
随着这些变化的加入,你的商店现在吹嘘着一个集中的州管理系统,它允许在你的应用程序中有效地操纵和检索州。 这使得eCommerce商店更强大更灵活,更能满足用户的需求.

第5部分:增加类别页。
最后一部分会很快的 执行页 /category/* 链接 TopNav 定义 /app/[category]/page.tsx 内容如下:
// app/[类别]/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>
);
}事情应该很明显 所以不要太详细 这页的作品与我们的商店网页完全相同 Shop 用于过滤的组件和仅与我们选定的类别相连接的产品会被查询。
结论
就是这个教程! 现在您已成功创建了功能性商店页面,该页面具有完全功能的顶端导航,产品上市和过滤功能. 不过仍有许多工作要做,在下个教程中,我们将执行用户导航和登录页面. 继续听!

Leave a Reply