0%
Plateforme E-commerce Next.js 15 - De A à Z

Plateforme E-commerce

Développez une boutique en ligne moderne avec Next.js 15

10-15 min

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 !

Ressources utiles

Commentaires

Les commentaires sont alimentés par GitHub Discussions

Connectez-vous avec GitHub pour participer à la discussion

Lien copié !