Building an Audiophile E-Commerce Website: A Comprehensive Next.js Tutorial
🚀 Live Demo
Check out the live version of this project: Audiophile E-Commerce on Vercel
📋 Table of Contents
🎯 Introduction
Welcome to this comprehensive tutorial on building a modern e-commerce website for Audiophile, a premium audio equipment retailer. This project demonstrates how to create a fully functional, responsive e-commerce site using Next.js, TypeScript, and modern web technologies.
What You'll Build
A responsive e-commerce website with product listings
Shopping cart functionality
Checkout process with form validation
Product detail pages with image galleries
Mobile-first responsive design
Modern UI with smooth animations
Features
🛒 Shopping cart with local storage and Convex backend
📱 Fully responsive design (mobile, tablet, desktop)
🎨 Modern UI with hover effects and animations
🔍 Product search and filtering
💳 Secure checkout process
📧 Order confirmation emails
🎯 Accessibility-focused design
🛠️ Tech Stack
Frontend: Next.js 14 with App Router
Language: TypeScript
Styling: Tailwind CSS
Animations: Framer Motion
Database: Convex
Email: Next.js API routes with email service
Deployment: Vercel
Icons: Custom SVG icons
📋 Prerequisites
Before starting, ensure you have:
Node.js 18+ installed
npm or yarn package manager
Git for version control
A Vercel account for deployment
Basic knowledge of React and TypeScript
🚀 Project Setup
1. Clone the Repository
git clone https://github.com/AgboolaAgbeniga/audiophile.git
cd audiophile
2. Install Dependencies
npm install
3. Environment Variables
Create a .env.local file in the root directory:
# Convex
NEXT_PUBLIC_CONVEX_URL=your_convex_url
# Email service (if implementing)
EMAIL_SERVICE_API_KEY=your_email_api_key
4. Set Up Convex Database
npx convex dev
5. Run the Development Server
npm run dev
Visit http://localhost:3000 to see your application.
📁 Project Structure
audiophile-ecommerce/
├── app/ # Next.js App Router
│ ├── api/ # API routes
│ ├── cart/ # Cart page
│ ├── checkout/ # Checkout page
│ ├── components/ # React components
│ │ ├── cart/ # Cart-related components
│ │ ├── checkout/ # Checkout components
│ │ ├── layout/ # Layout components
│ │ ├── products/ # Product components
│ │ └── ui/ # Reusable UI components
│ ├── contexts/ # React contexts
│ ├── data/ # Static data
│ ├── headphones/ # Category pages
│ ├── hooks/ # Custom hooks
│ ├── lib/ # Utility functions
│ ├── products/ # Product detail pages
│ └── speakers/ # Category pages
├── convex/ # Convex database
├── public/ # Static assets
└── styles/ # Global styles
🎨 Designing a Style Guide from Figma
Why Create a Style Guide?
A style guide ensures consistency across your application and speeds up development by providing reusable design tokens.
Step-by-Step Guide to Creating a Style Guide from Figma
1. Analyze the Figma Design
Open your Figma file: Audiophile Style Guide
Identify design tokens:
Colors (primary, secondary, neutral)
Typography (font families, sizes, weights)
Spacing (margins, paddings)
Border radius
Shadows and effects
2. Extract Color Palette
/* styles/globals.css */
:root {
/* Primary Colors */
--color-primary: #D87D4A;
--color-primary-hover: #FBAF85;
/* Neutral Colors */
--color-black: #000000;
--color-white: #FFFFFF;
--color-gray-100: #FAFAFA;
--color-gray-200: #F1F1F1;
/* Text Colors */
--color-text-primary: #000000;
--color-text-secondary: rgba(0, 0, 0, 0.5);
}
3. Define Typography Scale
/* Typography */
--font-family-primary: 'Manrope', sans-serif;
--font-size-heading-1: 56px;
--font-size-heading-2: 40px;
--font-size-heading-3: 32px;
--font-size-heading-4: 28px;
--font-size-heading-5: 24px;
--font-size-heading-6: 18px;
--font-weight-regular: 400;
--font-weight-medium: 500;
--font-weight-bold: 700;
4. Create Spacing Scale
/* Spacing */
--spacing-xs: 8px;
--spacing-sm: 16px;
--spacing-md: 24px;
--spacing-lg: 32px;
--spacing-xl: 48px;
--spacing-2xl: 64px;
5. Implement in Tailwind Config
// tailwind.config.js
module.exports = {
theme: {
extend: {
colors: {
primary: 'var(--color-primary)',
'primary-hover': 'var(--color-primary-hover)',
black: 'var(--color-black)',
white: 'var(--color-white)',
border: 'var(--color-gray-200)',
},
fontFamily: {
primary: ['Manrope', 'sans-serif'],
},
fontSize: {
'heading-1': ['56px', { lineHeight: '58px', letterSpacing: '2px' }],
'heading-2': ['40px', { lineHeight: '44px', letterSpacing: '1.5px' }],
// ... other sizes
},
spacing: {
'18': '72px',
'88': '352px',
},
},
},
}
6. Create Reusable Components
// components/ui/Button.tsx
interface ButtonProps {
variant?: 'primary' | 'secondary' | 'tertiary';
size?: 'sm' | 'md' | 'lg';
children: React.ReactNode;
// ... other props
}
const Button = ({ variant = 'primary', size = 'md', children, ...props }) => {
return (
<button
className={cn(
'font-bold uppercase tracking-wider transition-all duration-200',
// Variant styles
variant === 'primary' && 'bg-primary text-white hover:bg-primary-hover',
// Size styles
size === 'lg' && 'h-12 px-8 text-sm',
)}
{...props}
>
{children}
</button>
);
};
7. Document Your Style Guide
Create a style-guide page to showcase all components:
// app/style-guide/page.tsx
export default function StyleGuide() {
return (
<div className="container mx-auto px-6 py-16">
<h1 className="text-4xl font-bold mb-12">Style Guide</h1>
{/* Colors */}
<section className="mb-12">
<h2 className="text-2xl font-bold mb-6">Colors</h2>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<div className="text-center">
<div className="w-16 h-16 bg-primary mx-auto mb-2"></div>
<p className="text-sm">Primary</p>
<p className="text-xs text-gray-600">#D87D4A</p>
</div>
{/* More color swatches */}
</div>
</section>
{/* Typography */}
<section className="mb-12">
<h2 className="text-2xl font-bold mb-6">Typography</h2>
<div className="space-y-4">
<div>
<p className="text-heading-1">Heading 1 - 56px</p>
</div>
{/* More typography examples */}
</div>
</section>
{/* Components */}
<section className="mb-12">
<h2 className="text-2xl font-bold mb-6">Components</h2>
<div className="space-y-8">
<div>
<h3 className="text-lg font-semibold mb-4">Buttons</h3>
<div className="flex gap-4">
<Button variant="primary">Primary</Button>
<Button variant="secondary">Secondary</Button>
</div>
</div>
{/* More component examples */}
</div>
</section>
</div>
);
}
🧩 Building the UI Components
Creating Reusable Components
Button Component
// components/ui/Button.tsx
'use client';
import React from 'react';
import { motion } from 'framer-motion';
import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '../../lib/utils';
const buttonVariants = cva(
'inline-flex items-center justify-center font-bold uppercase tracking-[1px] transition-all duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:opacity-50 disabled:pointer-events-none cursor-pointer',
{
variants: {
variant: {
primary: 'bg-[#D87D4A] text-white h-12 px-8 hover:bg-[#FBAF85] hover:shadow-lg',
secondary: 'bg-white text-black h-12 px-8 border border-black hover:bg-black hover:text-white',
tertiary: 'bg-transparent text-white h-12 px-8 hover:text-primary',
black: 'bg-black text-white h-12 px-8 hover:bg-[#4C4C4C]',
shop: 'bg-transparent text-black/50 font-bold uppercase hover:text-[#D87D4A] group-hover:text-[#D87D4A] flex items-center gap-[13px]',
},
size: {
default: 'h-10 py-2 px-4',
sm: 'h-9 px-3 rounded-md',
lg: 'h-11 px-8 rounded-md',
},
},
defaultVariants: {
variant: 'primary',
size: 'default',
},
}
);
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
loadingText?: string;
successText?: string;
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, loadingText, successText, children, ...props }, ref) => {
const [isLoading, setIsLoading] = React.useState(false);
const [isSuccess, setIsSuccess] = React.useState(false);
const handleClick = async (e: React.MouseEvent<HTMLButtonElement>) => {
if (isLoading || isSuccess) return;
setIsLoading(true);
try {
await props.onClick?.(e);
if (successText) {
setIsSuccess(true);
setTimeout(() => {
setIsSuccess(false);
setIsLoading(false);
}, 1500);
} else {
setIsLoading(false);
}
} catch (error) {
setIsLoading(false);
}
};
const getButtonText = () => {
if (isLoading) return loadingText || 'Loading...';
if (isSuccess) return successText || 'Success!';
return children;
};
const getButtonIcon = () => {
if (isSuccess) {
return (
<svg className="w-5 h-5 ml-2" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
</svg>
);
}
return null;
};
return (
<motion.button
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
onClick={handleClick}
disabled={isLoading || isSuccess || props.disabled}
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
transition={{ duration: 0.1 }}
{...props}
>
{getButtonText()}
{getButtonIcon()}
</motion.button>
);
}
);
Button.displayName = 'Button';
export { Button, buttonVariants };
Input Component
// components/ui/Input.tsx
'use client';
import React from 'react';
import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '../../lib/utils';
const inputVariants = cva(
'w-full h-[56px] px-4 py-3 font-medium text-[14px] rounded-md focus:outline-none transition-colors duration-200',
{
variants: {
state: {
default: 'border border-border bg-white text-black placeholder:text-black/40 hover:border-primary-hover',
active: 'border border-primary bg-white text-black focus:ring-1 focus:ring-primary caret-primary',
error: 'border border-[#CD2C2C] bg-white text-black placeholder:text-black/40',
},
size: {
default: 'md:w-[309px]',
address: 'md:w-[634px]',
},
},
defaultVariants: {
state: 'default',
size: 'default',
},
}
);
export interface InputProps
extends Omit<React.InputHTMLAttributes<HTMLInputElement>, 'size'>,
VariantProps<typeof inputVariants> {
label?: string;
error?: string;
}
const Input = React.forwardRef<HTMLInputElement, InputProps>(
({ className, state, size, label, error, ...props }, ref) => {
const [isFocused, setIsFocused] = React.useState(false);
const handleFocus = () => setIsFocused(true);
const handleBlur = () => setIsFocused(false);
const currentState = error ? 'error' : isFocused ? 'active' : 'default';
return (
<div className="flex flex-col">
<div className="flex justify-between items-center mb-1">
{label && (
<label className={cn('text-[12px] font-bold', error ? 'text-[#CD2C2C] ' : 'text-black')}>
{label}
</label>
)}
{error && <span className="text-[#CD2C2C] text-[12px] font-medium leading-tight">{error}</span>}
</div>
<input
className={cn(inputVariants({ state: currentState, size, className }))}
ref={ref}
onFocus={handleFocus}
onBlur={handleBlur}
{...props}
/>
</div>
);
}
);
Input.displayName = 'Input';
export { Input, inputVariants };
📄 Implementing Pages and Routing
Home Page
// app/page.tsx
import Link from 'next/link';
import { Button } from './components/ui/Button';
import Category from './components/layout/Category';
import InfoSection from './components/layout/InfoSection';
import Header from './components/layout/Header';
export default function Home() {
return (
<div className="flex flex-col min-h-screen">
<Header />
<main>
{/* Hero Section */}
<section className="bg-[#141414] text-white">
<div className="container mx-auto px-6 py-16">
<div className="text-center">
<h1 className="text-4xl md:text-6xl font-bold mb-6">
Premium Audio Equipment
</h1>
<p className="text-lg mb-8 max-w-2xl mx-auto">
Experience music like never before with our curated selection of high-end audio products.
</p>
<Link href="/headphones">
<Button variant="primary" size="lg">
Shop Now
</Button>
</Link>
</div>
</div>
</section>
{/* Category Section */}
<Category />
{/* Info Section */}
<InfoSection />
</main>
</div>
);
}
Product Detail Page
// app/products/[slug]/page.tsx
'use client';
import React, { use, useState } from 'react';
import Link from 'next/link';
import { useQuery } from 'convex/react';
import { api } from '../../../convex/_generated/api';
import Category from '../../components/layout/Category';
import InfoSection from '../../components/layout/InfoSection';
import Footer from '../../components/layout/Footer';
import { Button } from '../../components/ui/Button';
import { Counter } from '../../components/ui/Counter';
import { useCart } from '../../contexts/CartContext';
interface ProductPageProps {
params: Promise<{
slug: string;
}>;
}
const ProductPage = ({ params }: ProductPageProps) => {
const { slug } = use(params);
const product = useQuery(api.queries.getProductBySlug, { slug });
const [quantity, setQuantity] = useState(1);
const { addItem } = useCart();
if (!product) {
return (
<div className="container mx-auto px-6 py-16 text-center">
<p className="text-red-500 text-lg mb-4">Product not found.</p>
<Link href="/" className="text-primary underline">
Go back to home
</Link>
</div>
);
}
return (
<div className="flex flex-col min-h-screen">
{/* Go Back Link */}
<div className="container mx-auto px-6 py-8 max-w-[1110px]">
<Link
href={`/${product.category}`}
className="text-black/50 hover:text-primary transition-colors duration-200"
>
Go Back
</Link>
</div>
{/* Product Details Section */}
<section className="container mx-auto py-8">
<div className="flex flex-col lg:flex-row items-center lg:items-start justify-between gap-10 max-w-[1110px] mx-auto">
{/* Product Image */}
<div className="w-full max-w-[540px] rounded-lg overflow-hidden">
<img
src={product.image}
alt={product.name}
loading="lazy"
className="w-full h-auto object-cover"
/>
</div>
{/* Product Info */}
<div className="flex flex-col justify-center w-full lg:max-w-[445px] text-center lg:text-left">
<h4 className="text-2xl md:text-4xl font-bold mb-4">{product.name}</h4>
<p className="text-base text-black/50 mb-6">{product.description}</p>
<h6 className="text-xl md:text-2xl font-bold mb-8">
${product.price.toLocaleString()}
</h6>
<div className="flex items-center justify-center lg:justify-start gap-4 mb-8">
<Counter
count={quantity}
onIncrement={() => setQuantity((prev) => Math.min(prev + 1, 99))}
onDecrement={() => setQuantity((prev) => Math.max(prev - 1, 1))}
/>
<Button
variant="primary"
size="lg"
successText="Added"
onClick={() => {
if (product) {
for (let i = 0; i < quantity; i++) {
addItem({
id: product._id,
name: product.name,
price: product.price,
image: product.image,
slug: product.slug,
});
}
}
}}
>
ADD TO CART
</Button>
</div>
</div>
</div>
</section>
{/* Features and In The Box Section */}
<section className="container mx-auto px-6 py-16">
<div className="max-w-[1110px] mx-auto grid grid-cols-1 md:grid-cols-[2fr_1fr] gap-12">
{/* Features */}
<div>
<h3 className="text-2xl font-bold mb-6">Features</h3>
<div className="space-y-4">
{product.features.map((feature, i) => (
<p key={i} className="text-gray-700 leading-relaxed">
{feature}
</p>
))}
</div>
</div>
{/* In The Box */}
<div>
<h3 className="text-2xl font-bold mb-6">In the Box</h3>
<ul className="space-y-2">
{product.includes.map((item, i) => (
<li key={i} className="text-gray-700 flex items-center">
<span className="font-bold text-primary mr-4">
{item.quantity}x
</span>
<span className="capitalize">{item.item}</span>
</li>
))}
</ul>
</div>
</div>
</section>
{/* Category Section */}
<Category />
{/* Info Section */}
<InfoSection />
{/* Footer */}
<div className="mt-16">
<Footer />
</div>
</div>
);
};
export default ProductPage;
🛒 Adding E-Commerce Functionality
Shopping Cart Context
// contexts/CartContext.tsx
'use client';
import React, { createContext, useContext, useEffect, useState } from 'react';
import { useMutation } from 'convex/react';
import { api } from '../../convex/_generated/api';
import { Id } from '../../convex/_generated/dataModel';
export interface CartItem {
id: string;
productId: Id<'products'>;
name: string;
price: number;
image: string;
quantity: number;
slug: string;
}
interface CartContextType {
items: CartItem[];
addItem: (product: Omit<CartItem, 'id' | 'quantity'> & { quantity?: number }) => void;
updateQuantity: (id: string, quantity: number) => void;
removeItem: (id: string) => void;
clearCart: () => void;
getTotalPrice: () => number;
getTotalItems: () => number;
}
const CART_STORAGE_KEY = 'audiophile_cart';
const CartContext = createContext<CartContextType | undefined>(undefined);
export const CartProvider: React.FC<{ children: React.ReactNode; userId?: Id<'users'> }> = ({
children,
userId
}) => {
const [items, setItems] = useState<CartItem[]>([]);
const clearUserCartMutation = useMutation(api.mutations.clearUserCart);
// Load cart from localStorage on mount
useEffect(() => {
const storedCart = localStorage.getItem(CART_STORAGE_KEY);
if (storedCart) {
try {
const parsedCart = JSON.parse(storedCart);
setItems(parsedCart);
} catch (error) {
console.error('Failed to parse cart from localStorage:', error);
}
}
}, []);
// Save cart to localStorage whenever items change
useEffect(() => {
localStorage.setItem(CART_STORAGE_KEY, JSON.stringify(items));
}, [items]);
const addItem = (product: Omit<CartItem, 'id' | 'quantity'> & { quantity?: number }) => {
const quantity = product.quantity || 1;
const existingItem = items.find(item => item.productId === product.productId);
if (existingItem) {
updateQuantity(existingItem.id, existingItem.quantity + quantity);
} else {
const newItem: CartItem = {
id: `${product.productId}_${Date.now()}`, // Generate unique ID for local cart
productId: product.productId,
name: product.name,
price: product.price,
image: product.image,
quantity,
slug: product.slug,
};
setItems(prev => [...prev, newItem]);
}
};
const updateQuantity = (id: string, quantity: number) => {
if (quantity <= 0) {
removeItem(id);
return;
}
setItems(prev =>
prev.map(item =>
item.id === id ? { ...item, quantity } : item
)
);
};
const removeItem = (id: string) => {
setItems(prev => prev.filter(item => item.id !== id));
};
const clearCart = () => {
setItems([]);
};
const getTotalPrice = () => {
return items.reduce((total, item) => total + (item.price * item.quantity), 0);
};
const getTotalItems = () => {
return items.reduce((total, item) => total + item.quantity, 0);
};
// Sync with Convex when user logs in
const syncCartWithConvex = async () => {
if (!userId || items.length === 0) return;
try {
// Clear existing cart items for this user in Convex
await clearUserCartMutation({ userId });
// Add current local cart items to Convex
for (const item of items) {
// You'd need to implement an addToCart mutation in Convex
// await addToCartMutation({ userId, productId: item.productId, quantity: item.quantity });
}
} catch (error) {
console.error('Failed to sync cart with Convex:', error);
}
};
useEffect(() => {
if (userId) {
syncCartWithConvex();
}
}, [userId]);
return (
<CartContext.Provider value={{
items,
addItem,
updateQuantity,
removeItem,
clearCart,
getTotalPrice,
getTotalItems,
}}>
{children}
</CartContext.Provider>
);
};
export const useCart = () => {
const context = useContext(CartContext);
if (context === undefined) {
throw new Error('useCart must be used within a CartProvider');
}
return context;
};
🗄️ Database Integration with Convex
Setting Up Convex
Install Convex CLI:
npm install -g convexInitialize Convex:
npx convex devCreate your schema:
// convex/schema.ts
import { defineSchema, defineTable } from 'convex/server';
import { v } from 'convex/values';
export default defineSchema({
products: defineTable({
name: v.string(),
slug: v.string(),
description: v.string(),
price: v.number(),
image: v.string(),
category: v.string(),
features: v.array(v.string()),
includes: v.array(v.object({
item: v.string(),
quantity: v.number(),
})),
gallery: v.optional(v.array(v.string())),
}),
orders: defineTable({
userId: v.optional(v.id('users')),
items: v.array(v.object({
productId: v.id('products'),
name: v.string(),
price: v.number(),
quantity: v.number(),
image: v.string(),
})),
customer: v.object({
name: v.string(),
email: v.string(),
phone: v.string(),
}),
shipping: v.object({
address: v.string(),
city: v.string(),
zip: v.string(),
country: v.string(),
}),
totals: v.object({
subtotal: v.number(),
shipping: v.number(),
tax: v.number(),
grandTotal: v.number(),
}),
status: v.string(),
}).index('by_user', ['userId']),
users: defineTable({
email: v.string(),
name: v.string(),
}).index('by_email', ['email']),
});
Creating Queries and Mutations
// convex/queries.ts
import { query } from './_generated/server';
import { v } from 'convex/values';
export const getProducts = query({
args: {},
handler: async (ctx) => {
return await ctx.db.query('products').collect();
},
});
export const getProductBySlug = query({
args: { slug: v.string() },
handler: async (ctx, args) => {
return await ctx.db
.query('products')
.withIndex('by_slug', (q) => q.eq('slug', args.slug))
.first();
},
});
export const getProductsByCategory = query({
args: { category: v.string() },
handler: async (ctx, args) => {
return await ctx.db
.query('products')
.withIndex('by_category', (q) => q.eq('category', args.category))
.collect();
},
});
🚀 Deployment to Vercel
Step 1: Connect Your Repository
Go to Vercel and sign in
Click "New Project"
Import your GitHub repository
Configure the project settings
Step 2: Environment Variables
Add your environment variables in Vercel:
NEXT_PUBLIC_CONVEX_URLAny email service API keys
Step 3: Deploy
Vercel will automatically deploy your application. The live URL will be something like https://your-project-name.vercel.app.
Step 4: Custom Domain (Optional)
You can add a custom domain in the Vercel dashboard under "Settings" > "Domains".
🎉 Conclusion
Congratulations! You've built a complete e-commerce website with modern features including:
Responsive design that works on all devices
Shopping cart with persistent storage
Secure checkout process
Product management with Convex
Email notifications
Modern animations and interactions
Next Steps
Add user authentication
Implement product reviews
Add search and filtering
Integrate payment processing
Add analytics tracking
Resources
This tutorial provides a solid foundation for building modern e-commerce applications. Feel free to customize and extend the functionality based on your specific requirements!


