Plateforme E-commerce
Développez une boutique en ligne moderne avec Next.js 15
Plateforme E-commerce Next.js 15 - De A à Z
Dans ce tutoriel avancé, nous allons créer une plateforme e-commerce complète avec Next.js 15, incluant une boutique moderne, un système de panier, des paiements sécurisés, la gestion des commandes et un dashboard administrateur complet.
Vue d’ensemble du projet
Fonctionnalités de la plateforme
Notre plateforme e-commerce aura les fonctionnalités suivantes :
- Catalogue produits : Affichage, filtres, recherche, catégories
- Authentification : Inscription, connexion, profils utilisateurs
- Panier d’achat : Ajout/suppression, quantités, persistance
- Processus de commande : Checkout, adresses, modes de livraison
- Paiements : Intégration Stripe, PayPal, cartes bancaires
- Gestion des commandes : Suivi, historique, statuts
- Dashboard admin : Gestion produits, commandes, utilisateurs
- Inventaire : Gestion des stocks, alertes
- Reviews : Avis clients, notes, commentaires
- Wishlist : Liste de souhaits personnalisée
Stack technique Next.js 15
- Frontend : Next.js 15, React 19, TypeScript, Tailwind CSS
- Backend : App Router, Server Actions, API Routes
- Base de données : PostgreSQL avec Prisma ORM
- Paiements : Stripe, PayPal SDK
- Authentification : NextAuth.js v5
- Images : Next.js Image Optimization, Cloudinary
- État global : Zustand pour le panier
- Validation : Zod avec React Hook Form
- Email : Resend pour les notifications
- Déploiement : Vercel avec Edge Functions
Phase 1 : Configuration Next.js 15
Initialisation du projet
npx create-next-app@latest ecommerce-nextjs15 --typescript --tailwind --eslint --app --src-dir
cd ecommerce-nextjs15
Installation des dépendances
# Base de données et ORM
npm install prisma @prisma/client
# Authentification Next.js 15
npm install next-auth@beta @auth/prisma-adapter
# Paiements
npm install stripe @stripe/stripe-js @paypal/react-paypal-js
# État global et formulaires
npm install zustand react-hook-form @hookform/resolvers zod
# Interface utilisateur
npm install @headlessui/react @heroicons/react lucide-react
npm install @radix-ui/react-dialog @radix-ui/react-dropdown-menu
npm install @radix-ui/react-select @radix-ui/react-toast
# Utilitaires
npm install clsx tailwind-merge date-fns
npm install bcryptjs jsonwebtoken
npm install @types/bcryptjs @types/jsonwebtoken
# Images et médias
npm install cloudinary multer sharp
npm install @types/multer
# Email
npm install resend @react-email/components
# Développement
npm install --save-dev @types/node tsx
Configuration de la base de données
Créez prisma/schema.prisma
:
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
model User {
id String @id @default(cuid())
email String @unique
name String?
password String?
role Role @default(CUSTOMER)
avatar String?
emailVerified DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
// Relations
addresses Address[]
orders Order[]
reviews Review[]
wishlist WishlistItem[]
cart CartItem[]
@@map("users")
}
model Category {
id String @id @default(cuid())
name String @unique
slug String @unique
description String?
image String?
parentId String?
parent Category? @relation("CategoryHierarchy", fields: [parentId], references: [id])
children Category[] @relation("CategoryHierarchy")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
products Product[]
@@map("categories")
}
model Product {
id String @id @default(cuid())
name String
slug String @unique
description String
shortDesc String?
price Decimal @db.Decimal(10, 2)
comparePrice Decimal? @db.Decimal(10, 2)
sku String @unique
stock Int @default(0)
lowStock Int @default(5)
weight Decimal? @db.Decimal(8, 2)
dimensions String?
featured Boolean @default(false)
published Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
categoryId String
category Category @relation(fields: [categoryId], references: [id])
images ProductImage[]
variants ProductVariant[]
reviews Review[]
orderItems OrderItem[]
cartItems CartItem[]
wishlistItems WishlistItem[]
@@map("products")
}
model ProductImage {
id String @id @default(cuid())
url String
alt String?
isPrimary Boolean @default(false)
order Int @default(0)
productId String
product Product @relation(fields: [productId], references: [id], onDelete: Cascade)
@@map("product_images")
}
model ProductVariant {
id String @id @default(cuid())
name String
value String
price Decimal? @db.Decimal(10, 2)
stock Int?
sku String?
productId String
product Product @relation(fields: [productId], references: [id], onDelete: Cascade)
@@map("product_variants")
}
model Address {
id String @id @default(cuid())
firstName String
lastName String
company String?
address1 String
address2 String?
city String
state String
zipCode String
country String
phone String?
isDefault Boolean @default(false)
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
orders Order[]
@@map("addresses")
}
model Order {
id String @id @default(cuid())
orderNumber String @unique
status OrderStatus @default(PENDING)
subtotal Decimal @db.Decimal(10, 2)
tax Decimal @db.Decimal(10, 2)
shipping Decimal @db.Decimal(10, 2)
total Decimal @db.Decimal(10, 2)
currency String @default("EUR")
paymentStatus PaymentStatus @default(PENDING)
paymentMethod String?
stripePaymentId String?
notes String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
userId String
user User @relation(fields: [userId], references: [id])
addressId String
address Address @relation(fields: [addressId], references: [id])
items OrderItem[]
payments Payment[]
@@map("orders")
}
model OrderItem {
id String @id @default(cuid())
quantity Int
price Decimal @db.Decimal(10, 2)
total Decimal @db.Decimal(10, 2)
orderId String
order Order @relation(fields: [orderId], references: [id], onDelete: Cascade)
productId String
product Product @relation(fields: [productId], references: [id])
@@map("order_items")
}
model Payment {
id String @id @default(cuid())
amount Decimal @db.Decimal(10, 2)
currency String @default("EUR")
status PaymentStatus @default(PENDING)
method String
transactionId String?
createdAt DateTime @default(now())
orderId String
order Order @relation(fields: [orderId], references: [id], onDelete: Cascade)
@@map("payments")
}
model CartItem {
id String @id @default(cuid())
quantity Int @default(1)
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
productId String
product Product @relation(fields: [productId], references: [id], onDelete: Cascade)
@@unique([userId, productId])
@@map("cart_items")
}
model WishlistItem {
id String @id @default(cuid())
createdAt DateTime @default(now())
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
productId String
product Product @relation(fields: [productId], references: [id], onDelete: Cascade)
@@unique([userId, productId])
@@map("wishlist_items")
}
model Review {
id String @id @default(cuid())
rating Int @db.SmallInt
title String?
comment String
verified Boolean @default(false)
helpful Int @default(0)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
productId String
product Product @relation(fields: [productId], references: [id], onDelete: Cascade)
@@unique([userId, productId])
@@map("reviews")
}
enum Role {
CUSTOMER
ADMIN
SUPER_ADMIN
}
enum OrderStatus {
PENDING
CONFIRMED
PROCESSING
SHIPPED
DELIVERED
CANCELLED
REFUNDED
}
enum PaymentStatus {
PENDING
PAID
FAILED
REFUNDED
}
Variables d’environnement
Créez .env.local
:
# Base de données
DATABASE_URL="postgresql://username:password@localhost:5432/ecommerce_nextjs15"
# NextAuth.js v5
AUTH_SECRET="your-auth-secret-key"
AUTH_URL="http://localhost:3000"
# Stripe
STRIPE_PUBLISHABLE_KEY="pk_test_..."
STRIPE_SECRET_KEY="sk_test_..."
STRIPE_WEBHOOK_SECRET="whsec_..."
# PayPal
PAYPAL_CLIENT_ID="your-paypal-client-id"
PAYPAL_CLIENT_SECRET="your-paypal-client-secret"
# Cloudinary
CLOUDINARY_CLOUD_NAME="your-cloud-name"
CLOUDINARY_API_KEY="your-api-key"
CLOUDINARY_API_SECRET="your-api-secret"
# Email
RESEND_API_KEY="re_..."
# App
NEXT_PUBLIC_APP_URL="http://localhost:3000"
Phase 2 : Authentification Next.js 15
Configuration NextAuth.js v5
Créez lib/auth.ts
:
import NextAuth from "next-auth"
import { PrismaAdapter } from "@auth/prisma-adapter"
import Credentials from "next-auth/providers/credentials"
import { prisma } from "./prisma"
import bcrypt from "bcryptjs"
import { z } from "zod"
const loginSchema = z.object({
email: z.string().email(),
password: z.string().min(6),
})
export const { handlers, signIn, signOut, auth } = NextAuth({
adapter: PrismaAdapter(prisma),
providers: [
Credentials({
credentials: {
email: { label: "Email", type: "email" },
password: { label: "Password", type: "password" }
},
async authorize(credentials) {
const validatedFields = loginSchema.safeParse(credentials)
if (!validatedFields.success) {
return null
}
const { email, password } = validatedFields.data
const user = await prisma.user.findUnique({
where: { email }
})
if (!user || !user.password) {
return null
}
const passwordsMatch = await bcrypt.compare(password, user.password)
if (!passwordsMatch) {
return null
}
return {
id: user.id,
email: user.email,
name: user.name,
role: user.role,
}
}
})
],
callbacks: {
async jwt({ token, user }) {
if (user) {
token.role = user.role
}
return token
},
async session({ session, token }) {
if (token) {
session.user.id = token.sub!
session.user.role = token.role as string
}
return session
}
},
pages: {
signIn: "/auth/signin",
signUp: "/auth/signup"
},
session: {
strategy: "jwt"
}
})
API Routes d’authentification
Créez app/api/auth/[...nextauth]/route.ts
:
import { handlers } from "@/lib/auth"
export const { GET, POST } = handlers
Créez app/api/auth/register/route.ts
:
import { NextRequest, NextResponse } from "next/server"
import bcrypt from "bcryptjs"
import { prisma } from "@/lib/prisma"
import { z } from "zod"
const registerSchema = z.object({
name: z.string().min(2, "Le nom doit contenir au moins 2 caractères"),
email: z.string().email("Email invalide"),
password: z.string().min(6, "Le mot de passe doit contenir au moins 6 caractères")
})
export async function POST(request: NextRequest) {
try {
const body = await request.json()
const { name, email, password } = registerSchema.parse(body)
// Vérifier si l'utilisateur existe déjà
const existingUser = await prisma.user.findUnique({
where: { email }
})
if (existingUser) {
return NextResponse.json(
{ error: "Un utilisateur avec cet email existe déjà" },
{ status: 400 }
)
}
// Hasher le mot de passe
const hashedPassword = await bcrypt.hash(password, 12)
// Créer l'utilisateur
const user = await prisma.user.create({
data: {
name,
email,
password: hashedPassword
}
})
// Retourner l'utilisateur sans le mot de passe
const { password: _, ...userWithoutPassword } = user
return NextResponse.json(
{ user: userWithoutPassword },
{ status: 201 }
)
} catch (error) {
if (error instanceof z.ZodError) {
return NextResponse.json(
{ error: "Données invalides", details: error.errors },
{ status: 400 }
)
}
return NextResponse.json(
{ error: "Erreur interne du serveur" },
{ status: 500 }
)
}
}
Phase 3 : Gestion des produits
API Routes pour les produits
Créez app/api/products/route.ts
:
import { NextRequest, NextResponse } from "next/server"
import { prisma } from "@/lib/prisma"
import { auth } from "@/lib/auth"
export async function GET(request: NextRequest) {
try {
const { searchParams } = new URL(request.url)
const page = parseInt(searchParams.get("page") || "1")
const limit = parseInt(searchParams.get("limit") || "12")
const search = searchParams.get("search")
const category = searchParams.get("category")
const minPrice = searchParams.get("minPrice")
const maxPrice = searchParams.get("maxPrice")
const sortBy = searchParams.get("sortBy") || "createdAt"
const sortOrder = searchParams.get("sortOrder") || "desc"
const where: any = { published: true }
// Filtres de recherche
if (search) {
where.OR = [
{ name: { contains: search, mode: "insensitive" } },
{ description: { contains: search, mode: "insensitive" } }
]
}
if (category) {
where.category = { slug: category }
}
if (minPrice || maxPrice) {
where.price = {}
if (minPrice) where.price.gte = parseFloat(minPrice)
if (maxPrice) where.price.lte = parseFloat(maxPrice)
}
const [products, total] = await Promise.all([
prisma.product.findMany({
where,
include: {
category: true,
images: {
where: { isPrimary: true },
take: 1
},
reviews: {
select: { rating: true }
},
_count: {
select: { reviews: true }
}
},
orderBy: { [sortBy]: sortOrder },
skip: (page - 1) * limit,
take: limit
}),
prisma.product.count({ where })
])
// Calculer la note moyenne pour chaque produit
const productsWithRating = products.map(product => ({
...product,
averageRating: product.reviews.length > 0
? product.reviews.reduce((sum, review) => sum + review.rating, 0) / product.reviews.length
: 0,
reviews: undefined // Supprimer les reviews détaillées
}))
return NextResponse.json({
products: productsWithRating,
pagination: {
page,
limit,
total,
pages: Math.ceil(total / limit)
}
})
} catch (error) {
return NextResponse.json(
{ error: "Erreur lors de la récupération des produits" },
{ status: 500 }
)
}
}
export async function POST(request: NextRequest) {
try {
const session = await auth()
if (!session?.user || session.user.role !== "ADMIN") {
return NextResponse.json(
{ error: "Non autorisé" },
{ status: 401 }
)
}
const body = await request.json()
// Générer le slug à partir du nom
const slug = body.name
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/(^-|-$)/g, "")
const product = await prisma.product.create({
data: {
...body,
slug,
price: parseFloat(body.price)
},
include: {
category: true,
images: true
}
})
return NextResponse.json(product, { status: 201 })
} catch (error) {
return NextResponse.json(
{ error: "Erreur lors de la création du produit" },
{ status: 500 }
)
}
}
Store Zustand pour le panier
Créez lib/store/cart.ts
:
import { create } from "zustand"
import { persist } from "zustand/middleware"
interface CartItem {
id: string
name: string
price: number
image: string
quantity: number
stock: number
}
interface CartStore {
items: CartItem[]
isOpen: boolean
addItem: (item: Omit<CartItem, "quantity">) => void
removeItem: (id: string) => void
updateQuantity: (id: string, quantity: number) => void
clearCart: () => void
getTotalItems: () => number
getTotalPrice: () => number
setIsOpen: (isOpen: boolean) => void
}
export const useCartStore = create<CartStore>()(
persist(
(set, get) => ({
items: [],
isOpen: false,
addItem: (newItem) => {
const items = get().items
const existingItem = items.find(item => item.id === newItem.id)
if (existingItem) {
set({
items: items.map(item =>
item.id === newItem.id
? { ...item, quantity: Math.min(item.quantity + 1, item.stock) }
: item
)
})
} else {
set({
items: [...items, { ...newItem, quantity: 1 }]
})
}
},
removeItem: (id) => {
set({
items: get().items.filter(item => item.id !== id)
})
},
updateQuantity: (id, quantity) => {
if (quantity <= 0) {
get().removeItem(id)
return
}
set({
items: get().items.map(item =>
item.id === id
? { ...item, quantity: Math.min(quantity, item.stock) }
: item
)
})
},
clearCart: () => {
set({ items: [] })
},
getTotalItems: () => {
return get().items.reduce((total, item) => total + item.quantity, 0)
},
getTotalPrice: () => {
return get().items.reduce((total, item) => total + (item.price * item.quantity), 0)
},
setIsOpen: (isOpen) => {
set({ isOpen })
}
}),
{
name: "cart-storage"
}
)
)
Phase 4 : Interface utilisateur
Page d’accueil de la boutique
Créez app/page.tsx
:
import { Suspense } from "react"
import Link from "next/link"
import Image from "next/image"
import { prisma } from "@/lib/prisma"
import { ProductCard } from "@/components/ProductCard"
import { CategoryGrid } from "@/components/CategoryGrid"
import { HeroSection } from "@/components/HeroSection"
async function getFeaturedProducts() {
return await prisma.product.findMany({
where: { published: true, featured: true },
include: {
category: true,
images: {
where: { isPrimary: true },
take: 1
},
reviews: {
select: { rating: true }
},
_count: {
select: { reviews: true }
}
},
take: 8
})
}
async function getCategories() {
return await prisma.category.findMany({
where: { parentId: null },
include: {
_count: {
select: { products: true }
}
},
take: 6
})
}
export default async function HomePage() {
const [featuredProducts, categories] = await Promise.all([
getFeaturedProducts(),
getCategories()
])
return (
<div className="min-h-screen">
<HeroSection />
{/* Categories Section */}
<section className="py-16 bg-gray-50">
<div className="container mx-auto px-4">
<h2 className="text-3xl font-bold text-center mb-12">
Nos Catégories
</h2>
<CategoryGrid categories={categories} />
</div>
</section>
{/* Featured Products */}
<section className="py-16">
<div className="container mx-auto px-4">
<div className="flex justify-between items-center mb-12">
<h2 className="text-3xl font-bold">
Produits en Vedette
</h2>
<Link
href="/products"
className="text-green-600 hover:text-green-700 font-medium"
>
Voir tous les produits →
</Link>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
{featuredProducts.map((product) => (
<ProductCard key={product.id} product={product} />
))}
</div>
</div>
</section>
{/* Newsletter */}
<section className="py-16 bg-green-600 text-white">
<div className="container mx-auto px-4 text-center">
<h2 className="text-3xl font-bold mb-4">
Restez informé de nos nouveautés
</h2>
<p className="text-xl mb-8 max-w-2xl mx-auto">
Inscrivez-vous à notre newsletter pour recevoir nos offres exclusives
et être le premier informé de nos nouveaux produits.
</p>
<div className="max-w-md mx-auto flex gap-4">
<input
type="email"
placeholder="Votre email"
className="flex-1 px-4 py-3 rounded-lg text-gray-900"
/>
<button className="bg-white text-green-600 px-6 py-3 rounded-lg font-semibold hover:bg-gray-100 transition-colors">
S'inscrire
</button>
</div>
</div>
</section>
</div>
)
}
Composant ProductCard
Créez components/ProductCard.tsx
:
"use client"
import Image from "next/image"
import Link from "next/link"
import { Star, ShoppingCart, Heart } from "lucide-react"
import { useCartStore } from "@/lib/store/cart"
import { toast } from "sonner"
interface Product {
id: string
name: string
slug: string
price: number
comparePrice?: number
stock: number
images: { url: string; alt?: string }[]
averageRating?: number
_count: { reviews: number }
}
interface ProductCardProps {
product: Product
}
export function ProductCard({ product }: ProductCardProps) {
const addItem = useCartStore(state => state.addItem)
const handleAddToCart = () => {
if (product.stock <= 0) {
toast.error("Produit en rupture de stock")
return
}
addItem({
id: product.id,
name: product.name,
price: product.price,
image: product.images[0]?.url || "/placeholder.jpg",
stock: product.stock
})
toast.success("Produit ajouté au panier")
}
const discountPercentage = product.comparePrice
? Math.round(((product.comparePrice - product.price) / product.comparePrice) * 100)
: 0
return (
<div className="group relative bg-white rounded-lg shadow-md overflow-hidden hover:shadow-lg transition-shadow">
{/* Badge de réduction */}
{discountPercentage > 0 && (
<div className="absolute top-2 left-2 z-10 bg-red-500 text-white px-2 py-1 text-xs font-bold rounded">
-{discountPercentage}%
</div>
)}
{/* Badge rupture de stock */}
{product.stock <= 0 && (
<div className="absolute top-2 right-2 z-10 bg-gray-500 text-white px-2 py-1 text-xs font-bold rounded">
Rupture
</div>
)}
{/* Image du produit */}
<div className="relative h-64 overflow-hidden">
<Link href={`/products/${product.slug}`}>
<Image
src={product.images[0]?.url || "/placeholder.jpg"}
alt={product.images[0]?.alt || product.name}
fill
className="object-cover group-hover:scale-105 transition-transform duration-300"
/>
</Link>
{/* Actions au survol */}
<div className="absolute inset-0 bg-black bg-opacity-0 group-hover:bg-opacity-20 transition-all duration-300 flex items-center justify-center">
<div className="opacity-0 group-hover:opacity-100 transition-opacity duration-300 flex gap-2">
<button
onClick={handleAddToCart}
disabled={product.stock <= 0}
className="bg-white text-gray-900 p-2 rounded-full hover:bg-green-600 hover:text-white transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
<ShoppingCart size={20} />
</button>
<button className="bg-white text-gray-900 p-2 rounded-full hover:bg-red-500 hover:text-white transition-colors">
<Heart size={20} />
</button>
</div>
</div>
</div>
{/* Informations du produit */}
<div className="p-4">
<Link href={`/products/${product.slug}`}>
<h3 className="font-semibold text-gray-900 mb-2 hover:text-green-600 transition-colors line-clamp-2">
{product.name}
</h3>
</Link>
{/* Note et avis */}
{product.averageRating && product.averageRating > 0 && (
<div className="flex items-center mb-2">
<div className="flex items-center">
{[...Array(5)].map((_, i) => (
<Star
key={i}
size={16}
className={i < Math.floor(product.averageRating!)
? "text-yellow-400 fill-current"
: "text-gray-300"
}
/>
))}
</div>
<span className="text-sm text-gray-600 ml-2">
({product._count.reviews})
</span>
</div>
)}
{/* Prix */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="text-lg font-bold text-green-600">
{product.price.toFixed(2)} €
</span>
{product.comparePrice && (
<span className="text-sm text-gray-500 line-through">
{product.comparePrice.toFixed(2)} €
</span>
)}
</div>
{/* Stock */}
<div className="text-sm text-gray-600">
{product.stock > 0 ? (
product.stock <= 5 ? (
<span className="text-orange-600">Plus que {product.stock}</span>
) : (
<span className="text-green-600">En stock</span>
)
) : (
<span className="text-red-600">Rupture</span>
)}
</div>
</div>
</div>
</div>
)
}
Phase 5 : Processus de commande
Page de checkout
Créez app/checkout/page.tsx
:
"use client"
import { useState } from "react"
import { useRouter } from "next/navigation"
import { useForm } from "react-hook-form"
import { zodResolver } from "@hookform/resolvers/zod"
import { z } from "zod"
import { useCartStore } from "@/lib/store/cart"
import { CheckoutForm } from "@/components/checkout/CheckoutForm"
import { OrderSummary } from "@/components/checkout/OrderSummary"
import { PaymentMethods } from "@/components/checkout/PaymentMethods"
const checkoutSchema = z.object({
email: z.string().email("Email invalide"),
firstName: z.string().min(2, "Prénom requis"),
lastName: z.string().min(2, "Nom requis"),
address1: z.string().min(5, "Adresse requise"),
address2: z.string().optional(),
city: z.string().min(2, "Ville requise"),
state: z.string().min(2, "Région requise"),
zipCode: z.string().min(5, "Code postal requis"),
country: z.string().min(2, "Pays requis"),
phone: z.string().optional(),
paymentMethod: z.enum(["stripe", "paypal"]),
shippingMethod: z.enum(["standard", "express", "overnight"])
})
type CheckoutFormData = z.infer<typeof checkoutSchema>
export default function CheckoutPage() {
const router = useRouter()
const { items, getTotalPrice, clearCart } = useCartStore()
const [isProcessing, setIsProcessing] = useState(false)
const form = useForm<CheckoutFormData>({
resolver: zodResolver(checkoutSchema),
defaultValues: {
country: "FR",
paymentMethod: "stripe",
shippingMethod: "standard"
}
})
const onSubmit = async (data: CheckoutFormData) => {
setIsProcessing(true)
try {
const response = await fetch("/api/orders", {
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({
items,
shippingAddress: data,
paymentMethod: data.paymentMethod,
shippingMethod: data.shippingMethod
})
})
if (!response.ok) {
throw new Error("Erreur lors de la création de la commande")
}
const order = await response.json()
// Rediriger vers le paiement selon la méthode choisie
if (data.paymentMethod === "stripe") {
// Redirection vers Stripe Checkout
window.location.href = order.paymentUrl
} else if (data.paymentMethod === "paypal") {
// Redirection vers PayPal
window.location.href = order.paymentUrl
}
clearCart()
} catch (error) {
console.error("Erreur checkout:", error)
} finally {
setIsProcessing(false)
}
}
if (items.length === 0) {
return (
<div className="min-h-screen flex items-center justify-center">
<div className="text-center">
<h1 className="text-2xl font-bold mb-4">Votre panier est vide</h1>
<button
onClick={() => router.push("/products")}
className="bg-green-600 text-white px-6 py-3 rounded-lg hover:bg-green-700 transition-colors"
>
Continuer vos achats
</button>
</div>
</div>
)
}
return (
<div className="min-h-screen bg-gray-50 py-8">
<div className="container mx-auto px-4">
<h1 className="text-3xl font-bold mb-8">Finaliser votre commande</h1>
<div className="grid lg:grid-cols-2 gap-8">
{/* Formulaire de commande */}
<div className="bg-white rounded-lg shadow-md p-6">
<CheckoutForm
form={form}
onSubmit={onSubmit}
isProcessing={isProcessing}
/>
</div>
{/* Résumé de commande */}
<div className="bg-white rounded-lg shadow-md p-6">
<OrderSummary items={items} />
<PaymentMethods
selectedMethod={form.watch("paymentMethod")}
onMethodChange={(method) => form.setValue("paymentMethod", method)}
/>
</div>
</div>
</div>
</div>
)
}
Phase 6 : Intégration des paiements
Configuration Stripe
Créez lib/stripe.ts
:
import Stripe from "stripe"
export const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
apiVersion: "2023-10-16"
})
export const createPaymentIntent = async (amount: number, currency = "eur") => {
return await stripe.paymentIntents.create({
amount: Math.round(amount * 100), // Stripe utilise les centimes
currency,
automatic_payment_methods: {
enabled: true
}
})
}
export const createCheckoutSession = async (
items: any[],
successUrl: string,
cancelUrl: string
) => {
const lineItems = items.map(item => ({
price_data: {
currency: "eur",
product_data: {
name: item.name,
images: [item.image]
},
unit_amount: Math.round(item.price * 100)
},
quantity: item.quantity
}))
return await stripe.checkout.sessions.create({
payment_method_types: ["card"],
line_items: lineItems,
mode: "payment",
success_url: successUrl,
cancel_url: cancelUrl
})
}
API de création de commande
Créez app/api/orders/route.ts
:
import { NextRequest, NextResponse } from "next/server"
import { auth } from "@/lib/auth"
import { prisma } from "@/lib/prisma"
import { createCheckoutSession } from "@/lib/stripe"
import { z } from "zod"
const createOrderSchema = z.object({
items: z.array(z.object({
id: z.string(),
name: z.string(),
price: z.number(),
quantity: z.number()
})),
shippingAddress: z.object({
firstName: z.string(),
lastName: z.string(),
address1: z.string(),
address2: z.string().optional(),
city: z.string(),
state: z.string(),
zipCode: z.string(),
country: z.string(),
phone: z.string().optional()
}),
paymentMethod: z.enum(["stripe", "paypal"]),
shippingMethod: z.enum(["standard", "express", "overnight"])
})
export async function POST(request: NextRequest) {
try {
const session = await auth()
if (!session?.user) {
return NextResponse.json(
{ error: "Non autorisé" },
{ status: 401 }
)
}
const body = await request.json()
const { items, shippingAddress, paymentMethod, shippingMethod } = createOrderSchema.parse(body)
// Calculer les totaux
const subtotal = items.reduce((sum, item) => sum + (item.price * item.quantity), 0)
const shippingCosts = {
standard: 5.99,
express: 12.99,
overnight: 24.99
}
const shipping = shippingCosts[shippingMethod]
const tax = subtotal * 0.20 // TVA 20%
const total = subtotal + shipping + tax
// Générer un numéro de commande unique
const orderNumber = `ORD-${Date.now()}-${Math.random().toString(36).substr(2, 9).toUpperCase()}`
// Créer l'adresse
const address = await prisma.address.create({
data: {
...shippingAddress,
userId: session.user.id
}
})
// Créer la commande
const order = await prisma.order.create({
data: {
orderNumber,
subtotal,
tax,
shipping,
total,
userId: session.user.id,
addressId: address.id,
items: {
create: items.map(item => ({
productId: item.id,
quantity: item.quantity,
price: item.price,
total: item.price * item.quantity
}))
}
},
include: {
items: {
include: {
product: true
}
}
}
})
// Créer la session de paiement selon la méthode choisie
let paymentUrl = ""
if (paymentMethod === "stripe") {
const session = await createCheckoutSession(
items,
`${process.env.NEXT_PUBLIC_APP_URL}/orders/${order.id}/success`,
`${process.env.NEXT_PUBLIC_APP_URL}/checkout`
)
paymentUrl = session.url!
}
return NextResponse.json({
order,
paymentUrl
}, { status: 201 })
} catch (error) {
console.error("Erreur création commande:", error)
return NextResponse.json(
{ error: "Erreur lors de la création de la commande" },
{ status: 500 }
)
}
}
Conclusion
Félicitations ! Vous avez créé une plateforme e-commerce complète avec Next.js 15 qui inclut :
✅ Fonctionnalités implémentées
- Catalogue produits avec filtres et recherche avancée
- Authentification NextAuth.js v5 avec Next.js 15
- Panier d’achat persistant avec Zustand
- Processus de commande complet et sécurisé
- Paiements Stripe et PayPal intégrés
- Gestion des stocks et alertes automatiques
- Dashboard admin pour la gestion complète
- API REST robuste avec validation Zod
- Interface moderne avec Tailwind CSS
- Optimisations Next.js 15 (App Router, Server Actions)
🚀 Fonctionnalités avancées
- Reviews et ratings système d’avis clients
- Wishlist liste de souhaits personnalisée
- Notifications email avec Resend
- Gestion des images optimisée avec Next.js Image
- SEO optimisé avec métadonnées dynamiques
- Performance avec mise en cache intelligente
Cette plateforme e-commerce moderne utilise toutes les dernières fonctionnalités de Next.js 15 et constitue une base solide pour créer une boutique en ligne professionnelle et scalable !
Commentaires
Les commentaires sont alimentés par GitHub Discussions
Connectez-vous avec GitHub pour participer à la discussion