Build a production-ready product catalog with advanced features! Implement URL-based filtering for shareable links, real-time search, multi-select filters, price ranges, sorting options, pagination, and a shopping cart with persistent state. This project demonstrates complex data filtering, state management, and building real e-commerce functionality!
Project Overview
Features We'll Build
- Product Listing: Grid view with pagination
- Advanced Filtering: Category, price range, ratings, brand
- Search: Real-time product search
- Sorting: Price, name, rating, newest
- Product Details: Individual product pages
- Shopping Cart: Add, remove, update quantities
- Cart Persistence: Save cart to localStorage
- URL State: Shareable filtered URLs
- Responsive Design: Mobile-friendly layout
- Loading States: Suspense and skeleton loaders
Technologies Used
- Next.js 15 with App Router
- TypeScript for type safety
- Zustand for cart state management
- Prisma or JSON for product data
- Tailwind CSS for styling
- searchParams for URL-based filters
Data Structure
Product Type Definition
export interface Product {
id: string;
name: string;
description: string;
price: number;
originalPrice?: number; // For showing discounts
category: string;
brand: string;
image: string;
images: string[]; // Multiple product images
rating: number; // 0-5
reviewCount: number;
inStock: boolean;
stockCount: number;
tags: string[];
specifications: Record<string, string>;
createdAt: string;
}
export interface FilterParams {
search?: string;
category?: string;
brand?: string[];
minPrice?: number;
maxPrice?: number;
minRating?: number;
inStock?: boolean;
sortBy?: 'price-asc' | 'price-desc' | 'name-asc' | 'name-desc' | 'rating' | 'newest';
page?: number;
perPage?: number;
}
export interface PaginatedProducts {
products: Product[];
total: number;
page: number;
perPage: number;
totalPages: number;
}
// ✅ Complete product structure
// ✅ Flexible filtering options
// ✅ Pagination support
// ✅ Type-safe throughoutMock Product Data
[
{
"id": "1",
"name": "Wireless Headphones",
"description": "Premium noise-cancelling wireless headphones with 30-hour battery life.",
"price": 299.99,
"originalPrice": 399.99,
"category": "Electronics",
"brand": "AudioTech",
"image": "/images/headphones-1.jpg",
"images": [
"/images/headphones-1.jpg",
"/images/headphones-2.jpg",
"/images/headphones-3.jpg"
],
"rating": 4.5,
"reviewCount": 128,
"inStock": true,
"stockCount": 45,
"tags": ["wireless", "noise-cancelling", "bluetooth"],
"specifications": {
"Battery Life": "30 hours",
"Connectivity": "Bluetooth 5.0",
"Weight": "250g",
"Color": "Black"
},
"createdAt": "2024-01-15T10:00:00Z"
},
{
"id": "2",
"name": "Smart Watch Pro",
"description": "Advanced fitness tracking with heart rate monitor and GPS.",
"price": 449.99,
"category": "Electronics",
"brand": "TechWear",
"image": "/images/watch-1.jpg",
"images": ["/images/watch-1.jpg", "/images/watch-2.jpg"],
"rating": 4.7,
"reviewCount": 89,
"inStock": true,
"stockCount": 23,
"tags": ["smartwatch", "fitness", "gps"],
"specifications": {
"Display": "1.4 inch AMOLED",
"Battery": "7 days",
"Water Resistant": "5ATM",
"Sensors": "Heart Rate, GPS, SpO2"
},
"createdAt": "2024-01-20T14:30:00Z"
}
]
// ✅ Rich product data
// ✅ Multiple images
// ✅ Specifications
// ✅ Stock trackingE-commerce Project Structure
Complete file organization for product catalog
Select a file or folder to see details
Product Utilities
Product Filtering and Search
import productsData from '@/data/products.json';
import { Product, FilterParams, PaginatedProducts } from './types';
const products = productsData as Product[];
export function getProducts(filters: FilterParams = {}): PaginatedProducts {
let filtered = [...products];
// Search filter
if (filters.search) {
const query = filters.search.toLowerCase();
filtered = filtered.filter(
(p) =>
p.name.toLowerCase().includes(query) ||
p.description.toLowerCase().includes(query) ||
p.tags.some((tag) => tag.toLowerCase().includes(query))
);
}
// Category filter
if (filters.category) {
filtered = filtered.filter((p) => p.category === filters.category);
}
// Brand filter (multi-select)
if (filters.brand && filters.brand.length > 0) {
filtered = filtered.filter((p) => filters.brand!.includes(p.brand));
}
// Price range filter
if (filters.minPrice !== undefined) {
filtered = filtered.filter((p) => p.price >= filters.minPrice!);
}
if (filters.maxPrice !== undefined) {
filtered = filtered.filter((p) => p.price <= filters.maxPrice!);
}
// Rating filter
if (filters.minRating !== undefined) {
filtered = filtered.filter((p) => p.rating >= filters.minRating!);
}
// Stock filter
if (filters.inStock) {
filtered = filtered.filter((p) => p.inStock && p.stockCount > 0);
}
// Sorting
const sortBy = filters.sortBy || 'newest';
switch (sortBy) {
case 'price-asc':
filtered.sort((a, b) => a.price - b.price);
break;
case 'price-desc':
filtered.sort((a, b) => b.price - a.price);
break;
case 'name-asc':
filtered.sort((a, b) => a.name.localeCompare(b.name));
break;
case 'name-desc':
filtered.sort((a, b) => b.name.localeCompare(a.name));
break;
case 'rating':
filtered.sort((a, b) => b.rating - a.rating);
break;
case 'newest':
filtered.sort(
(a, b) =>
new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
);
break;
}
// Pagination
const page = filters.page || 1;
const perPage = filters.perPage || 12;
const total = filtered.length;
const totalPages = Math.ceil(total / perPage);
const start = (page - 1) * perPage;
const end = start + perPage;
const paginatedProducts = filtered.slice(start, end);
return {
products: paginatedProducts,
total,
page,
perPage,
totalPages,
};
}
export function getProductById(id: string): Product | undefined {
return products.find((p) => p.id === id);
}
export function getCategories(): string[] {
const categories = new Set(products.map((p) => p.category));
return Array.from(categories).sort();
}
export function getBrands(): string[] {
const brands = new Set(products.map((p) => p.brand));
return Array.from(brands).sort();
}
export function getPriceRange(): { min: number; max: number } {
const prices = products.map((p) => p.price);
return {
min: Math.floor(Math.min(...prices)),
max: Math.ceil(Math.max(...prices)),
};
}
// ✅ Comprehensive filtering
// ✅ Multi-criteria search
// ✅ Flexible sorting
// ✅ Pagination built-in
// ✅ Helper functions for filtersProduct Listing Page
Products Page with Filters
import { Suspense } from 'react';
import { getProducts, getCategories, getBrands, getPriceRange } from '@/lib/products';
import { ProductGrid } from '@/components/ProductGrid';
import { ProductFilters } from '@/components/ProductFilters';
import { ProductSort } from '@/components/ProductSort';
import { Pagination } from '@/components/Pagination';
import { FilterParams } from '@/lib/types';
interface PageProps {
searchParams: {
search?: string;
category?: string;
brand?: string | string[];
minPrice?: string;
maxPrice?: string;
minRating?: string;
inStock?: string;
sortBy?: string;
page?: string;
};
}
export default function ProductsPage({ searchParams }: PageProps) {
// Parse filters from searchParams
const filters: FilterParams = {
search: searchParams.search,
category: searchParams.category,
brand: Array.isArray(searchParams.brand)
? searchParams.brand
: searchParams.brand
? [searchParams.brand]
: undefined,
minPrice: searchParams.minPrice ? parseFloat(searchParams.minPrice) : undefined,
maxPrice: searchParams.maxPrice ? parseFloat(searchParams.maxPrice) : undefined,
minRating: searchParams.minRating ? parseFloat(searchParams.minRating) : undefined,
inStock: searchParams.inStock === 'true',
sortBy: searchParams.sortBy as any,
page: searchParams.page ? parseInt(searchParams.page, 10) : 1,
};
// Get filtered products
const result = getProducts(filters);
const categories = getCategories();
const brands = getBrands();
const priceRange = getPriceRange();
return (
<div className="container mx-auto px-4 py-8">
<div className="mb-8">
<h1 className="text-3xl font-bold mb-2">Products</h1>
<p className="text-gray-600">
{result.total} product{result.total !== 1 ? 's' : ''} found
</p>
</div>
<div className="grid grid-cols-1 lg:grid-cols-4 gap-8">
{/* Filters Sidebar */}
<aside className="lg:col-span-1">
<ProductFilters
categories={categories}
brands={brands}
priceRange={priceRange}
currentFilters={filters}
/>
</aside>
{/* Products */}
<main className="lg:col-span-3">
{/* Sort and View Options */}
<div className="mb-6 flex items-center justify-between">
<p className="text-sm text-gray-600">
Showing {(result.page - 1) * result.perPage + 1}-
{Math.min(result.page * result.perPage, result.total)} of {result.total}
</p>
<ProductSort currentSort={filters.sortBy} />
</div>
{/* Products Grid */}
<Suspense fallback={<ProductGridSkeleton />}>
<ProductGrid products={result.products} />
</Suspense>
{/* Pagination */}
{result.totalPages > 1 && (
<div className="mt-8">
<Pagination
currentPage={result.page}
totalPages={result.totalPages}
/>
</div>
)}
</main>
</div>
</div>
);
}
function ProductGridSkeleton() {
return (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
{Array.from({ length: 6 }).map((_, i) => (
<div key={i} className="animate-pulse">
<div className="bg-gray-200 aspect-square rounded-lg mb-4" />
<div className="h-4 bg-gray-200 rounded mb-2" />
<div className="h-4 bg-gray-200 rounded w-2/3" />
</div>
))}
</div>
);
}
// ✅ URL-based filtering
// ✅ Server-side rendering
// ✅ Suspense boundaries
// ✅ Pagination
// ✅ Loading statesProduct Filters Component
'use client';
import { useRouter, useSearchParams } from 'next/navigation';
import { FilterParams } from '@/lib/types';
interface ProductFiltersProps {
categories: string[];
brands: string[];
priceRange: { min: number; max: number };
currentFilters: FilterParams;
}
export function ProductFilters({
categories,
brands,
priceRange,
currentFilters,
}: ProductFiltersProps) {
const router = useRouter();
const searchParams = useSearchParams();
const updateFilters = (updates: Partial<FilterParams>) => {
const params = new URLSearchParams(searchParams);
// Update or remove parameters
Object.entries(updates).forEach(([key, value]) => {
if (value === undefined || value === null || value === '') {
params.delete(key);
} else if (Array.isArray(value)) {
params.delete(key);
value.forEach((v) => params.append(key, v.toString()));
} else {
params.set(key, value.toString());
}
});
// Reset to page 1 when filters change
if (Object.keys(updates).some((key) => key !== 'page')) {
params.set('page', '1');
}
router.push(`/products?${params.toString()}`);
};
const clearFilters = () => {
router.push('/products');
};
return (
<div className="space-y-6">
{/* Clear Filters */}
<div className="flex items-center justify-between">
<h3 className="font-semibold">Filters</h3>
<button
onClick={clearFilters}
className="text-sm text-blue-600 hover:underline"
>
Clear all
</button>
</div>
{/* Search */}
<div>
<label className="block text-sm font-medium mb-2">Search</label>
<input
type="text"
placeholder="Search products..."
defaultValue={currentFilters.search}
onChange={(e) => updateFilters({ search: e.target.value })}
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
/>
</div>
{/* Category */}
<div>
<label className="block text-sm font-medium mb-2">Category</label>
<select
value={currentFilters.category || ''}
onChange={(e) => updateFilters({ category: e.target.value || undefined })}
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
>
<option value="">All Categories</option>
{categories.map((cat) => (
<option key={cat} value={cat}>
{cat}
</option>
))}
</select>
</div>
{/* Brand (Multi-select) */}
<div>
<label className="block text-sm font-medium mb-2">Brand</label>
<div className="space-y-2 max-h-48 overflow-y-auto">
{brands.map((brand) => (
<label key={brand} className="flex items-center">
<input
type="checkbox"
checked={currentFilters.brand?.includes(brand)}
onChange={(e) => {
const current = currentFilters.brand || [];
const updated = e.target.checked
? [...current, brand]
: current.filter((b) => b !== brand);
updateFilters({ brand: updated.length > 0 ? updated : undefined });
}}
className="mr-2"
/>
<span className="text-sm">{brand}</span>
</label>
))}
</div>
</div>
{/* Price Range */}
<div>
<label className="block text-sm font-medium mb-2">Price Range</label>
<div className="space-y-2">
<input
type="number"
placeholder="Min"
value={currentFilters.minPrice || ''}
onChange={(e) =>
updateFilters({ minPrice: e.target.value ? parseFloat(e.target.value) : undefined })
}
className="w-full px-3 py-2 border rounded-lg"
min={priceRange.min}
max={priceRange.max}
/>
<input
type="number"
placeholder="Max"
value={currentFilters.maxPrice || ''}
onChange={(e) =>
updateFilters({ maxPrice: e.target.value ? parseFloat(e.target.value) : undefined })
}
className="w-full px-3 py-2 border rounded-lg"
min={priceRange.min}
max={priceRange.max}
/>
</div>
</div>
{/* Rating */}
<div>
<label className="block text-sm font-medium mb-2">Minimum Rating</label>
<select
value={currentFilters.minRating || ''}
onChange={(e) =>
updateFilters({ minRating: e.target.value ? parseFloat(e.target.value) : undefined })
}
className="w-full px-3 py-2 border rounded-lg"
>
<option value="">Any</option>
<option value="4">4+ Stars</option>
<option value="3">3+ Stars</option>
<option value="2">2+ Stars</option>
</select>
</div>
{/* In Stock */}
<div>
<label className="flex items-center">
<input
type="checkbox"
checked={currentFilters.inStock || false}
onChange={(e) => updateFilters({ inStock: e.target.checked || undefined })}
className="mr-2"
/>
<span className="text-sm">In Stock Only</span>
</label>
</div>
</div>
);
}
// ✅ URL-based state
// ✅ Multi-select filters
// ✅ Price range inputs
// ✅ Clear all filters
// ✅ Resets page on filter changeShopping Cart Implementation
Cart Store with Zustand
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
import { Product } from './types';
export interface CartItem {
product: Product;
quantity: number;
}
interface CartStore {
items: CartItem[];
addItem: (product: Product, quantity?: number) => void;
removeItem: (productId: string) => void;
updateQuantity: (productId: string, quantity: number) => void;
clearCart: () => void;
getTotalItems: () => number;
getTotalPrice: () => number;
}
export const useCartStore = create<CartStore>()(
persist(
(set, get) => ({
items: [],
addItem: (product, quantity = 1) => {
set((state) => {
const existingItem = state.items.find(
(item) => item.product.id === product.id
);
if (existingItem) {
// Update quantity if item exists
return {
items: state.items.map((item) =>
item.product.id === product.id
? { ...item, quantity: item.quantity + quantity }
: item
),
};
}
// Add new item
return {
items: [...state.items, { product, quantity }],
};
});
},
removeItem: (productId) => {
set((state) => ({
items: state.items.filter((item) => item.product.id !== productId),
}));
},
updateQuantity: (productId, quantity) => {
if (quantity <= 0) {
get().removeItem(productId);
return;
}
set((state) => ({
items: state.items.map((item) =>
item.product.id === productId ? { ...item, quantity } : item
),
}));
},
clearCart: () => {
set({ items: [] });
},
getTotalItems: () => {
return get().items.reduce((total, item) => total + item.quantity, 0);
},
getTotalPrice: () => {
return get().items.reduce(
(total, item) => total + item.product.price * item.quantity,
0
);
},
}),
{
name: 'shopping-cart', // localStorage key
}
)
);
// ✅ Zustand for state management
// ✅ Persist to localStorage
// ✅ Add, remove, update operations
// ✅ Total calculations
// ✅ Type-safe throughoutCart Button Component
'use client';
import Link from 'next/link';
import { useCartStore } from '@/lib/cart-store';
export function CartButton() {
const totalItems = useCartStore((state) => state.getTotalItems());
return (
<Link
href="/cart"
className="relative inline-flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
>
<svg
className="w-5 h-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M3 3h2l.4 2M7 13h10l4-8H5.4M7 13L5.4 5M7 13l-2.293 2.293c-.63.63-.184 1.707.707 1.707H17m0 0a2 2 0 100 4 2 2 0 000-4zm-8 2a2 2 0 11-4 0 2 2 0 014 0z"
/>
</svg>
<span>Cart</span>
{/* Badge */}
{totalItems > 0 && (
<span className="absolute -top-2 -right-2 flex items-center justify-center w-6 h-6 text-xs font-bold bg-red-500 text-white rounded-full">
{totalItems}
</span>
)}
</Link>
);
}
// ✅ Shows item count
// ✅ Badge for visual feedback
// ✅ Links to cart page
// ✅ Real-time updatesAdd to Cart Button
'use client';
import { useState } from 'react';
import { useCartStore } from '@/lib/cart-store';
import { Product } from '@/lib/types';
interface AddToCartButtonProps {
product: Product;
}
export function AddToCartButton({ product }: AddToCartButtonProps) {
const [quantity, setQuantity] = useState(1);
const [added, setAdded] = useState(false);
const addItem = useCartStore((state) => state.addItem);
const handleAddToCart = () => {
addItem(product, quantity);
setAdded(true);
// Reset feedback after 2 seconds
setTimeout(() => setAdded(false), 2000);
};
return (
<div className="space-y-4">
{/* Quantity Selector */}
<div className="flex items-center gap-4">
<label className="text-sm font-medium">Quantity:</label>
<div className="flex items-center border rounded-lg">
<button
onClick={() => setQuantity(Math.max(1, quantity - 1))}
className="px-3 py-2 hover:bg-gray-100"
disabled={quantity <= 1}
>
-
</button>
<span className="px-4 py-2 border-x">{quantity}</span>
<button
onClick={() => setQuantity(Math.min(product.stockCount, quantity + 1))}
className="px-3 py-2 hover:bg-gray-100"
disabled={quantity >= product.stockCount}
>
+
</button>
</div>
</div>
{/* Add to Cart Button */}
<button
onClick={handleAddToCart}
disabled={!product.inStock}
className={`w-full px-6 py-3 rounded-lg font-semibold transition-colors ${
added
? 'bg-green-600 text-white'
: product.inStock
? 'bg-blue-600 text-white hover:bg-blue-700'
: 'bg-gray-300 text-gray-500 cursor-not-allowed'
}`}
>
{added ? '✓ Added to Cart' : product.inStock ? 'Add to Cart' : 'Out of Stock'}
</button>
</div>
);
}
// ✅ Quantity selector
// ✅ Stock validation
// ✅ Visual feedback
// ✅ Disabled when out of stockShopping Cart Page
Cart Page Implementation
'use client';
import { useCartStore } from '@/lib/cart-store';
import Image from 'next/image';
import Link from 'next/link';
export default function CartPage() {
const { items, removeItem, updateQuantity, getTotalPrice } = useCartStore();
const totalPrice = getTotalPrice();
if (items.length === 0) {
return (
<div className="container mx-auto px-4 py-16 text-center">
<h1 className="text-3xl font-bold mb-4">Your Cart is Empty</h1>
<p className="text-gray-600 mb-8">
Add some products to get started!
</p>
<Link
href="/products"
className="inline-block px-6 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
>
Browse Products
</Link>
</div>
);
}
return (
<div className="container mx-auto px-4 py-8">
<h1 className="text-3xl font-bold mb-8">Shopping Cart</h1>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
{/* Cart Items */}
<div className="lg:col-span-2 space-y-4">
{items.map((item) => (
<div
key={item.product.id}
className="flex gap-4 p-4 bg-white border rounded-lg"
>
{/* Product Image */}
<div className="relative w-24 h-24 flex-shrink-0">
<Image
src={item.product.image}
alt={item.product.name}
fill
className="object-cover rounded"
/>
</div>
{/* Product Info */}
<div className="flex-1">
<Link
href={`/products/${item.product.id}`}
className="font-semibold hover:text-blue-600"
>
{item.product.name}
</Link>
<p className="text-sm text-gray-600 mt-1">
${item.product.price.toFixed(2)}
</p>
{/* Quantity Controls */}
<div className="flex items-center gap-2 mt-3">
<button
onClick={() => updateQuantity(item.product.id, item.quantity - 1)}
className="px-2 py-1 border rounded hover:bg-gray-100"
>
-
</button>
<span className="px-3">{item.quantity}</span>
<button
onClick={() => updateQuantity(item.product.id, item.quantity + 1)}
className="px-2 py-1 border rounded hover:bg-gray-100"
disabled={item.quantity >= item.product.stockCount}
>
+
</button>
<button
onClick={() => removeItem(item.product.id)}
className="ml-auto text-sm text-red-600 hover:underline"
>
Remove
</button>
</div>
</div>
{/* Item Total */}
<div className="text-right">
<p className="font-semibold">
${(item.product.price * item.quantity).toFixed(2)}
</p>
</div>
</div>
))}
</div>
{/* Order Summary */}
<div className="lg:col-span-1">
<div className="bg-gray-50 p-6 rounded-lg sticky top-8">
<h2 className="text-xl font-bold mb-4">Order Summary</h2>
<div className="space-y-2 mb-4">
<div className="flex justify-between">
<span>Subtotal</span>
<span>${totalPrice.toFixed(2)}</span>
</div>
<div className="flex justify-between">
<span>Shipping</span>
<span>FREE</span>
</div>
<div className="flex justify-between">
<span>Tax</span>
<span>${(totalPrice * 0.1).toFixed(2)}</span>
</div>
<div className="border-t pt-2 flex justify-between font-bold text-lg">
<span>Total</span>
<span>${(totalPrice * 1.1).toFixed(2)}</span>
</div>
</div>
<button className="w-full px-6 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 font-semibold mb-2">
Proceed to Checkout
</button>
<Link
href="/products"
className="block text-center text-blue-600 hover:underline text-sm"
>
Continue Shopping
</Link>
</div>
</div>
</div>
</div>
);
}
// ✅ Empty cart state
// ✅ Quantity controls
// ✅ Remove items
// ✅ Order summary
// ✅ Sticky sidebar
// ✅ Responsive layoutProduct Detail Page
Individual Product Page
import { notFound } from 'next/navigation';
import Image from 'next/image';
import { getProductById, getProducts } from '@/lib/products';
import { AddToCartButton } from '@/components/AddToCartButton';
import { RelatedProducts } from '@/components/RelatedProducts';
import { Metadata } from 'next';
interface PageProps {
params: { id: string };
}
// Generate static params
export async function generateStaticParams() {
const { products } = getProducts({ perPage: 1000 });
return products.map((product) => ({ id: product.id }));
}
// Generate metadata
export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
const product = getProductById(params.id);
if (!product) {
return {};
}
return {
title: `${product.name} | Shop`,
description: product.description,
openGraph: {
title: product.name,
description: product.description,
images: [product.image],
},
};
}
export default function ProductDetailPage({ params }: PageProps) {
const product = getProductById(params.id);
if (!product) {
notFound();
}
const discount = product.originalPrice
? Math.round(((product.originalPrice - product.price) / product.originalPrice) * 100)
: 0;
return (
<div className="container mx-auto px-4 py-8">
<div className="grid grid-cols-1 lg:grid-cols-2 gap-12">
{/* Product Images */}
<div>
<div className="relative aspect-square mb-4 rounded-lg overflow-hidden">
<Image
src={product.image}
alt={product.name}
fill
className="object-cover"
priority
/>
{discount > 0 && (
<div className="absolute top-4 right-4 px-3 py-1 bg-red-500 text-white font-bold rounded">
-{discount}%
</div>
)}
</div>
{/* Thumbnail Images */}
{product.images.length > 1 && (
<div className="grid grid-cols-4 gap-2">
{product.images.map((img, i) => (
<div key={i} className="relative aspect-square rounded overflow-hidden">
<Image src={img} alt={`${product.name} ${i + 1}`} fill className="object-cover" />
</div>
))}
</div>
)}
</div>
{/* Product Info */}
<div>
<h1 className="text-3xl font-bold mb-2">{product.name}</h1>
{/* Rating */}
<div className="flex items-center gap-2 mb-4">
<div className="flex items-center">
{Array.from({ length: 5 }).map((_, i) => (
<span key={i} className={i < Math.floor(product.rating) ? 'text-yellow-400' : 'text-gray-300'}>
★
</span>
))}
</div>
<span className="text-sm text-gray-600">
{product.rating} ({product.reviewCount} reviews)
</span>
</div>
{/* Price */}
<div className="mb-6">
<div className="flex items-center gap-3">
<span className="text-3xl font-bold">${product.price.toFixed(2)}</span>
{product.originalPrice && (
<span className="text-xl text-gray-400 line-through">
${product.originalPrice.toFixed(2)}
</span>
)}
</div>
</div>
{/* Description */}
<p className="text-gray-600 mb-6">{product.description}</p>
{/* Stock Status */}
<div className="mb-6">
{product.inStock ? (
<p className="text-green-600 font-semibold">
In Stock ({product.stockCount} available)
</p>
) : (
<p className="text-red-600 font-semibold">Out of Stock</p>
)}
</div>
{/* Add to Cart */}
<AddToCartButton product={product} />
{/* Specifications */}
<div className="mt-8">
<h2 className="text-xl font-bold mb-4">Specifications</h2>
<dl className="space-y-2">
{Object.entries(product.specifications).map(([key, value]) => (
<div key={key} className="flex">
<dt className="w-1/3 text-gray-600">{key}:</dt>
<dd className="w-2/3 font-semibold">{value}</dd>
</div>
))}
</dl>
</div>
{/* Tags */}
<div className="mt-6">
<div className="flex flex-wrap gap-2">
{product.tags.map((tag) => (
<span key={tag} className="px-3 py-1 bg-gray-100 text-gray-700 rounded-full text-sm">
{tag}
</span>
))}
</div>
</div>
</div>
</div>
{/* Related Products */}
<div className="mt-16">
<RelatedProducts currentProduct={product} />
</div>
</div>
);
}
// ✅ Static generation
// ✅ Image gallery
// ✅ Price with discount
// ✅ Stock status
// ✅ Specifications
// ✅ Add to cart
// ✅ Related products
// ✅ SEO optimizedKey Takeaways
- URL-based filtering - searchParams for shareable filters
- Multi-criteria search - search, category, brand, price, rating
- Zustand state management - cart with localStorage persistence
- Static generation - pre-generate product pages
- Responsive design - mobile-friendly layouts
- Image optimization - Next.js Image component
- Pagination - handle large product catalogs
- Type safety - TypeScript throughout
What's Next?
You've built a complete e-commerce catalog! Next, we'll build Project 3: Dashboard with Authentication—a full authentication system with protected routes, user management, role-based access control, and an admin dashboard. You'll learn complete authentication flows and building secure applications!
💡 Project Enhancement Ideas
Extend your catalog: Add product reviews and ratings, implement wishlist functionality, add product comparison, create advanced search with autocomplete, implement real checkout with Stripe, add order tracking, create vendor/admin panels, or integrate with inventory management systems!