move to gitea

This commit is contained in:
Mamadou Sall
2025-08-24 22:41:31 +02:00
commit 032b1d9452
122 changed files with 28723 additions and 0 deletions

41
.gitignore vendored Normal file
View File

@@ -0,0 +1,41 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files (can opt-in for committing if needed)
.env*
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts

36
README.md Normal file
View File

@@ -0,0 +1,36 @@
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
## Getting Started
First, run the development server:
```bash
npm run dev
# or
yarn dev
# or
pnpm dev
# or
bun dev
```
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
## Learn More
To learn more about Next.js, take a look at the following resources:
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
## Deploy on Vercel
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.

21
components.json Normal file
View File

@@ -0,0 +1,21 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "",
"css": "src/app/globals.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"iconLibrary": "lucide"
}

16
eslint.config.mjs Normal file
View File

@@ -0,0 +1,16 @@
import { dirname } from "path";
import { fileURLToPath } from "url";
import { FlatCompat } from "@eslint/eslintrc";
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const compat = new FlatCompat({
baseDirectory: __dirname,
});
const eslintConfig = [
...compat.extends("next/core-web-vitals", "next/typescript"),
];
export default eslintConfig;

35
next.config.js Normal file
View File

@@ -0,0 +1,35 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
images: {
remotePatterns: [
{
protocol: 'http',
hostname: 'wordpress-hwk484kwgs40k8cggw0ks0c4.45.159.222.88.sslip.io',
port: '',
pathname: '/wp-content/uploads/**',
},
{
protocol: 'https',
hostname: 'wordpress-hwk484kwgs40k8cggw0ks0c4.45.159.222.88.sslip.io',
port: '',
pathname: '/wp-content/uploads/**',
},
// Autres domaines si nécessaire
{
protocol: 'https',
hostname: 'via.placeholder.com',
}
],
},
// Votre config CORS si vous l'avez ajoutée
async rewrites() {
return [
{
source: '/wp-api/:path*',
destination: 'http://wordpress-hwk484kwgs40k8cggw0ks0c4.45.159.222.88.sslip.io/wp-json/:path*',
},
];
},
};
module.exports = nextConfig;

7969
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

52
package.json Normal file
View File

@@ -0,0 +1,52 @@
{
"name": "melhfa-ecommerce",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev --turbo",
"build": "next build",
"start": "next start",
"lint": "next lint"
},
"dependencies": {
"@hookform/resolvers": "^3.3.2",
"@radix-ui/react-accordion": "^1.1.2",
"@radix-ui/react-checkbox": "^1.0.4",
"@radix-ui/react-dialog": "^1.0.5",
"@radix-ui/react-dropdown-menu": "^2.1.15",
"@radix-ui/react-label": "^2.1.7",
"@radix-ui/react-select": "^2.0.0",
"@radix-ui/react-separator": "^1.1.7",
"@radix-ui/react-slider": "^1.1.2",
"@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-tabs": "^1.1.12",
"@woocommerce/woocommerce-rest-api": "^1.0.1",
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.0",
"embla-carousel-react": "^8.0.0",
"lucide-react": "^0.451.0",
"next": "^15.3.5",
"next-themes": "^0.2.1",
"react": "19.0.0-rc-fb9a90fa48-20240614",
"react-dom": "19.0.0-rc-fb9a90fa48-20240614",
"react-hook-form": "^7.47.0",
"tailwind-merge": "^2.5.2",
"zod": "^3.22.4"
},
"devDependencies": {
"@types/node": "^20",
"@types/react": "npm:types-react@rc",
"@types/react-dom": "npm:types-react-dom@rc",
"autoprefixer": "10.4.16",
"eslint": "^8",
"eslint-config-next": "^15.3.5",
"postcss": "8.4.31",
"tailwindcss": "3.3.6",
"tailwindcss-animate": "1.0.7",
"typescript": "^5"
},
"overrides": {
"@types/react": "npm:types-react@rc",
"@types/react-dom": "npm:types-react-dom@rc"
}
}

6
postcss.config.js Normal file
View File

@@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

1
public/file.svg Normal file
View File

@@ -0,0 +1 @@
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 391 B

1
public/globe.svg Normal file
View File

@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

1
public/next.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

1
public/vercel.svg Normal file
View File

@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>

After

Width:  |  Height:  |  Size: 128 B

1
public/window.svg Normal file
View File

@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>

After

Width:  |  Height:  |  Size: 385 B

291
src/app/account/page.tsx Normal file
View File

@@ -0,0 +1,291 @@
// src/app/account/page.tsx
'use client';
import { useEffect, useState } from 'react';
import { useAuth } from '@/hooks/useAuth';
import { AuthService } from '@/lib/auth';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Separator } from '@/components/ui/separator';
import {
User,
ShoppingBag,
MapPin,
CreditCard,
Settings,
LogOut,
Package
} from 'lucide-react';
import { useRequireAuth } from '@/hooks/useAuth';
import { MelhfaFullScreenLoader, MelhfaLoader } from '@/components/ui/MelhfaLoader';
export default function AccountPage() {
const { user, logout, updateUser, isLoading } = useAuth();
const { isAuthenticated } = useRequireAuth();
const [orders, setOrders] = useState<any[]>([]);
const [loadingOrders, setLoadingOrders] = useState(true);
useEffect(() => {
if (user) {
loadUserOrders();
}
}, [user]);
const loadUserOrders = async () => {
if (!user) return;
try {
setLoadingOrders(true);
const userOrders = await AuthService.getUserOrders(user.id);
setOrders(userOrders);
} catch (error) {
console.error('Erreur lors du chargement des commandes:', error);
} finally {
setLoadingOrders(false);
}
};
if (isLoading || !isAuthenticated) {
return <MelhfaFullScreenLoader text="Connexion en cours..." color="purple" />;
}
return (
<div className="min-h-screen bg-gray-50 py-8">
<div className="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8">
{/* Header */}
<div className="bg-white rounded-lg shadow-sm p-6 mb-8">
<div className="flex items-center justify-between">
<div className="flex items-center space-x-4">
<div className="h-16 w-16 bg-gradient-to-r from-blue-500 to-purple-600 rounded-full flex items-center justify-center">
<User className="h-8 w-8 text-white" />
</div>
<div>
<h1 className="text-2xl font-bold text-gray-900">
Bonjour, {user?.firstName || user?.displayName} !
</h1>
<p className="text-gray-600">{user?.email}</p>
</div>
</div>
<Button variant="outline" onClick={logout} className="flex items-center space-x-2">
<LogOut className="h-4 w-4" />
<span>Déconnexion</span>
</Button>
</div>
</div>
{/* Contenu principal */}
<Tabs defaultValue="profile" className="space-y-6">
<TabsList className="grid w-full grid-cols-4">
<TabsTrigger value="profile" className="flex items-center space-x-2">
<User className="h-4 w-4" />
<span>Profil</span>
</TabsTrigger>
<TabsTrigger value="orders" className="flex items-center space-x-2">
<Package className="h-4 w-4" />
<span>Commandes</span>
</TabsTrigger>
<TabsTrigger value="addresses" className="flex items-center space-x-2">
<MapPin className="h-4 w-4" />
<span>Adresses</span>
</TabsTrigger>
<TabsTrigger value="settings" className="flex items-center space-x-2">
<Settings className="h-4 w-4" />
<span>Paramètres</span>
</TabsTrigger>
</TabsList>
{/* Onglet Profil */}
<TabsContent value="profile" className="space-y-6">
<Card>
<CardHeader>
<CardTitle>Informations personnelles</CardTitle>
<CardDescription>
Gérez vos informations de profil
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="firstName">Prénom</Label>
<Input
id="firstName"
defaultValue={user?.firstName || ''}
/>
</div>
<div className="space-y-2">
<Label htmlFor="lastName">Nom</Label>
<Input
id="lastName"
defaultValue={user?.lastName || ''}
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="email">Email</Label>
<Input
id="email"
type="email"
defaultValue={user?.email || ''}
/>
</div>
<Button>Mettre à jour le profil</Button>
</CardContent>
</Card>
</TabsContent>
{/* Onglet Commandes */}
<TabsContent value="orders" className="space-y-6">
<Card>
<CardHeader>
<CardTitle>Mes commandes</CardTitle>
<CardDescription>
Historique de vos commandes
</CardDescription>
</CardHeader>
<CardContent>
{loadingOrders ? (
<div className="flex justify-center py-8">
<MelhfaLoader size="lg" text="Chargement des commandes..." color="blue" />
</div>
) : orders.length > 0 ? (
<div className="space-y-4">
{orders.map((order) => (
<div key={order.id} className="border rounded-lg p-4">
<div className="flex justify-between items-start">
<div>
<h3 className="font-semibold">Commande #{order.number}</h3>
<p className="text-sm text-gray-600">
{new Date(order.date_created).toLocaleDateString('fr-FR')}
</p>
<p className="text-sm">
Status: <span className="font-medium">{order.status}</span>
</p>
</div>
<div className="text-right">
<p className="font-semibold">{order.total} {order.currency}</p>
<Button variant="outline" size="sm" className="mt-2">
Voir détails
</Button>
</div>
</div>
</div>
))}
</div>
) : (
<div className="text-center py-8">
<ShoppingBag className="h-12 w-12 text-gray-400 mx-auto mb-4" />
<p className="text-gray-600">Aucune commande pour le moment</p>
<Button className="mt-4">
Découvrir nos produits
</Button>
</div>
)}
</CardContent>
</Card>
</TabsContent>
{/* Onglet Adresses */}
<TabsContent value="addresses" className="space-y-6">
<div className="grid md:grid-cols-2 gap-6">
<Card>
<CardHeader>
<CardTitle>Adresse de facturation</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="billing_address">Adresse</Label>
<Input
id="billing_address"
defaultValue={user?.billing?.address_1 || ''}
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="billing_city">Ville</Label>
<Input
id="billing_city"
defaultValue={user?.billing?.city || ''}
/>
</div>
<div className="space-y-2">
<Label htmlFor="billing_postcode">Code postal</Label>
<Input
id="billing_postcode"
defaultValue={user?.billing?.postcode || ''}
/>
</div>
</div>
<Button>Mettre à jour</Button>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Adresse de livraison</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="shipping_address">Adresse</Label>
<Input
id="shipping_address"
defaultValue={user?.shipping?.address_1 || ''}
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="shipping_city">Ville</Label>
<Input
id="shipping_city"
defaultValue={user?.shipping?.city || ''}
/>
</div>
<div className="space-y-2">
<Label htmlFor="shipping_postcode">Code postal</Label>
<Input
id="shipping_postcode"
defaultValue={user?.shipping?.postcode || ''}
/>
</div>
</div>
<Button>Mettre à jour</Button>
</CardContent>
</Card>
</div>
</TabsContent>
{/* Onglet Paramètres */}
<TabsContent value="settings" className="space-y-6">
<Card>
<CardHeader>
<CardTitle>Sécurité du compte</CardTitle>
<CardDescription>
Gérez la sécurité de votre compte
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<Button variant="outline">
Changer le mot de passe
</Button>
<Separator />
<div className="pt-4">
<h3 className="font-semibold text-red-600 mb-2">Zone de danger</h3>
<p className="text-sm text-gray-600 mb-4">
La suppression de votre compte est irréversible.
</p>
<Button variant="destructive">
Supprimer le compte
</Button>
</div>
</CardContent>
</Card>
</TabsContent>
</Tabs>
</div>
</div>
);
}

View File

@@ -0,0 +1,126 @@
// src/app/api/orders/create/route.ts
import { NextRequest, NextResponse } from 'next/server';
import WooCommerceRestApi from "@woocommerce/woocommerce-rest-api";
const api = new WooCommerceRestApi({
url: process.env.NEXT_PUBLIC_WC_API_URL || "",
consumerKey: process.env.NEXT_PUBLIC_WC_CONSUMER_KEY || "",
consumerSecret: process.env.NEXT_PUBLIC_WC_CONSUMER_SECRET || "",
version: "wc/v3",
queryStringAuth: true,
});
export async function POST(request: NextRequest) {
try {
const orderData = await request.json();
console.log('Données de commande reçues:', orderData);
// Préparer les données pour WooCommerce
const wooOrderData = {
// Informations client
billing: {
first_name: orderData.customerInfo.firstName,
last_name: orderData.customerInfo.lastName,
email: orderData.customerInfo.email,
phone: orderData.customerInfo.phone,
address_1: orderData.customerInfo.address,
city: orderData.customerInfo.city,
postcode: orderData.customerInfo.postalCode,
country: orderData.customerInfo.country || 'MR',
},
shipping: {
first_name: orderData.customerInfo.firstName,
last_name: orderData.customerInfo.lastName,
address_1: orderData.customerInfo.address,
city: orderData.customerInfo.city,
postcode: orderData.customerInfo.postalCode,
country: orderData.customerInfo.country || 'MR',
},
// Produits
line_items: orderData.items.map((item: any) => ({
product_id: item.id,
quantity: item.quantity,
name: item.name,
price: parseFloat(item.price),
total: item.total.toString()
})),
// Méthode de paiement
payment_method: orderData.paymentMethod === 'cash' ? 'cod' : orderData.paymentMethod,
payment_method_title: orderData.paymentMethod === 'cash' ? 'Paiement à la livraison' : 'Carte bancaire',
// Frais de livraison
shipping_lines: orderData.shipping > 0 ? [{
method_id: 'flat_rate',
method_title: 'Livraison standard',
total: orderData.shipping.toString()
}] : [],
// Statut
status: 'processing',
// Notes
customer_note: orderData.customerInfo.notes || '',
// Métadonnées
meta_data: [
{
key: '_created_via',
value: 'melhfa_frontend'
},
{
key: '_order_source',
value: 'nextjs_app'
}
]
};
console.log('Données formatées pour WooCommerce:', wooOrderData);
// Créer la commande dans WooCommerce
const response = await api.post('orders', wooOrderData);
if (response.data) {
console.log('✅ Commande créée dans WooCommerce:', response.data.id);
return NextResponse.json({
success: true,
order: {
id: response.data.id,
number: response.data.number,
status: response.data.status,
total: response.data.total,
date_created: response.data.date_created,
payment_method: response.data.payment_method_title,
billing: response.data.billing,
shipping: response.data.shipping,
}
});
} else {
throw new Error('Pas de données de commande retournées');
}
} catch (error: any) {
console.error('❌ Erreur création commande:', error);
let errorMessage = 'Erreur lors de la création de la commande';
if (error.response?.data?.message) {
errorMessage = error.response.data.message;
} else if (error.message) {
errorMessage = error.message;
}
return NextResponse.json(
{
success: false,
message: errorMessage,
details: error.response?.data || error.message
},
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,31 @@
// src/app/auth/login/page.tsx
import { Suspense } from 'react';
import LoginForm from '@/components/auth/LoginForm';
import { MelhfaLoader } from '@/components/ui/MelhfaLoader';
export default function LoginPage() {
return (
<div className="min-h-screen bg-gray-50 flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8">
<div className="w-full max-w-md">
<div className="text-center mb-8">
<h1 className="text-3xl font-bold text-gray-900 mb-2">
Bienvenue sur Melhfa Store
</h1>
<p className="text-gray-600">
Connectez-vous pour accéder à votre espace personnel
</p>
</div>
<Suspense fallback={
<div className="flex justify-center">
<MelhfaLoader size="lg" text="Chargement..." color="blue" />
</div>
}>
<LoginForm />
</Suspense>
</div>
</div>
);
}

View File

@@ -0,0 +1,30 @@
// src/app/auth/register/page.tsx
import { Suspense } from 'react';
import { RegisterForm } from '@/components/auth/RegisterForm';
import { MelhfaLoader } from '@/components/ui/MelhfaLoader';
export default function RegisterPage() {
return (
<div className="min-h-screen bg-gray-50 flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8">
<div className="w-full max-w-md">
<div className="text-center mb-8">
<h1 className="text-3xl font-bold text-gray-900 mb-2">
Rejoignez Melhfa Store
</h1>
<p className="text-gray-600">
Créez votre compte pour découvrir nos créations mauritaniennes
</p>
</div>
<Suspense fallback={
<div className="flex justify-center">
<MelhfaLoader size="lg" text="Chargement..." color="purple" />
</div>
}>
<RegisterForm />
</Suspense>
</div>
</div>
);
}

235
src/app/boutique/page.tsx Normal file
View File

@@ -0,0 +1,235 @@
// src/app/boutique/page.tsx
import { Suspense } from 'react';
import { Metadata } from 'next';
import { WooCommerceService } from '@/lib/woocommerce';
import ProductGrid from '@/components/product/ProductGrid';
import ProductFilters from '@/components/product/ProductFilters';
import { Button } from '@/components/ui/button';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { LayoutGrid, List, SlidersHorizontal } from 'lucide-react';
export const metadata: Metadata = {
title: 'Boutique - Collection complète de Melhfa',
description: 'Découvrez notre collection complète de melhfa mauritaniennes. Voiles traditionnels et modernes, accessoires premium et créations artisanales.',
openGraph: {
title: 'Boutique MELHFA - Collection complète',
description: 'Découvrez notre collection complète de melhfa mauritaniennes.',
images: ['/images/boutique-og.jpg'],
},
};
interface BoutiquePageProps {
searchParams: {
page?: string;
category?: string;
filter?: string;
sort?: string;
search?: string;
};
}
export default async function BoutiquePage({
searchParams
}: BoutiquePageProps): Promise<JSX.Element> {
const page = Number(searchParams.page) || 1;
const category = searchParams.category;
const filter = searchParams.filter;
const sort = searchParams.sort;
const search = searchParams.search;
// Construire les paramètres pour l'API WooCommerce
const apiParams: any = {
page,
per_page: 20,
};
if (category) apiParams.category = category;
if (search) apiParams.search = search;
if (filter === 'sale') apiParams.on_sale = true;
if (filter === 'featured') apiParams.featured = true;
if (filter === 'new') {
apiParams.orderby = 'date';
apiParams.order = 'desc';
}
switch (sort) {
case 'price-asc':
apiParams.orderby = 'price';
apiParams.order = 'asc';
break;
case 'price-desc':
apiParams.orderby = 'price';
apiParams.order = 'desc';
break;
case 'name-asc':
apiParams.orderby = 'title';
apiParams.order = 'asc';
break;
case 'name-desc':
apiParams.orderby = 'title';
apiParams.order = 'desc';
break;
default:
apiParams.orderby = 'date';
apiParams.order = 'desc';
}
// Récupérer les produits et catégories
const [productsResponse, categoriesResponse] = await Promise.all([
WooCommerceService.getProducts(apiParams),
WooCommerceService.getCategories(),
]);
const products = productsResponse.data || [];
const categories = categoriesResponse.data || [];
return (
<div className="min-h-screen bg-white">
{/* Header de la page */}
<div className="bg-gray-50 py-16 mt-16">
<div className="max-w-[1400px] mx-auto px-6">
<div className="text-center space-y-4">
<h1 className="text-4xl md:text-5xl font-light tracking-wide text-black">
{filter === 'sale' && 'Promotions'}
{filter === 'featured' && 'Produits Vedettes'}
{filter === 'new' && 'Nouvelles Arrivées'}
{category && `Catégorie: ${category}`}
{search && `Résultats pour: "${search}"`}
{!filter && !category && !search && 'Boutique'}
</h1>
<p className="text-gray-600 max-w-2xl mx-auto">
{filter === 'sale' && 'Profitez de nos offres exceptionnelles sur une sélection de melhfa premium'}
{filter === 'featured' && 'Découvrez nos créations d\'exception, sélectionnées par nos artisans'}
{filter === 'new' && 'Les dernières créations de nos ateliers mauritaniens'}
{!filter && !category && !search && 'Découvrez notre collection complète de melhfa mauritaniennes, alliant tradition et modernité'}
</p>
</div>
</div>
</div>
<div className="max-w-[1400px] mx-auto px-6 py-12">
<div className="flex flex-col lg:flex-row gap-8">
{/* Sidebar Filters */}
<aside className="lg:w-64 flex-shrink-0">
<div className="sticky top-24">
<Suspense fallback={<FiltersSkeleton />}>
<ProductFilters
categories={categories}
currentCategory={category}
currentFilter={filter}
currentSort={sort}
/>
</Suspense>
</div>
</aside>
{/* Main Content */}
<main className="flex-1">
{/* Toolbar */}
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4 mb-8 pb-6 border-b border-gray-200">
<div className="flex items-center gap-4">
<span className="text-sm text-gray-600">
{products.length} produit{products.length > 1 ? 's' : ''} trouvé{products.length > 1 ? 's' : ''}
</span>
</div>
<div className="flex items-center gap-4">
{/* View Toggle */}
<Tabs defaultValue="grid" className="hidden sm:block">
<TabsList className="grid w-fit grid-cols-2">
<TabsTrigger value="grid" className="px-3">
<LayoutGrid className="w-4 h-4" />
</TabsTrigger>
<TabsTrigger value="list" className="px-3">
<List className="w-4 h-4" />
</TabsTrigger>
</TabsList>
</Tabs>
{/* Mobile Filters */}
<Button
variant="outline"
size="sm"
className="lg:hidden"
>
<SlidersHorizontal className="w-4 h-4 mr-2" />
Filtres
</Button>
</div>
</div>
{/* Products Grid */}
<Suspense fallback={<ProductGridSkeleton />}>
<ProductGrid
products={products}
currentPage={page}
hasMore={products.length === 20} // Supposer qu'il y a plus si on a 20 produits
/>
</Suspense>
{/* Pagination */}
{products.length === 20 && (
<div className="flex justify-center mt-12">
<Button
variant="outline"
className="px-8"
asChild
>
<a href={`/boutique?${new URLSearchParams({
...searchParams,
page: (page + 1).toString()
}).toString()}`}>
Charger plus de produits
</a>
</Button>
</div>
)}
</main>
</div>
</div>
</div>
);
}
// Composants de skeleton pour le chargement
function FiltersSkeleton(): JSX.Element {
return (
<div className="space-y-6">
<div className="space-y-3">
<div className="h-4 bg-gray-200 rounded w-20" />
<div className="space-y-2">
{[...Array(5)].map((_, i) => (
<div key={i} className="h-3 bg-gray-200 rounded w-full" />
))}
</div>
</div>
<div className="space-y-3">
<div className="h-4 bg-gray-200 rounded w-16" />
<div className="space-y-2">
{[...Array(4)].map((_, i) => (
<div key={i} className="h-3 bg-gray-200 rounded w-full" />
))}
</div>
</div>
</div>
);
}
function ProductGridSkeleton(): JSX.Element {
return (
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-6">
{[...Array(12)].map((_, i) => (
<div key={i} className="animate-pulse">
<div className="aspect-[3/4] bg-gray-200 rounded-lg mb-4" />
<div className="space-y-2">
<div className="h-4 bg-gray-200 rounded w-3/4" />
<div className="h-4 bg-gray-200 rounded w-1/2" />
<div className="h-8 bg-gray-200 rounded w-full" />
</div>
</div>
))}
</div>
);
}

421
src/app/checkout/page.tsx Normal file
View File

@@ -0,0 +1,421 @@
// src/app/checkout/page.tsx
'use client';
import { useState, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { useAuth } from '@/hooks/useAuth';
import { useCartActions } from '@/hooks/useCartSync';import { useHydration } from '@/hooks/useHydration';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Separator } from '@/components/ui/separator';
import { useCart } from '@/contexts/CartContext';
import {
CreditCard,
Lock,
ArrowLeft,
MapPin,
Phone,
Mail,
User,
} from 'lucide-react';
import Link from 'next/link';
import Image from 'next/image';
import { MelhfaLoader } from '@/components/ui/MelhfaLoader';
export default function CheckoutPage() {
const router = useRouter();
const { user, isAuthenticated } = useAuth();
const { cart, clearCart } = useCart();
const isHydrated = useHydration(); // Attendre l'hydratation
const [isLoading, setIsLoading] = useState(false);
const [formData, setFormData] = useState({
firstName: user?.firstName || '',
lastName: user?.lastName || '',
email: user?.email || '',
phone: '',
address: '',
city: '',
postalCode: '',
paymentMethod: 'cash'
});
// Redirection si panier vide - SEULEMENT après hydratation
useEffect(() => {
if (isHydrated && cart.items.length === 0) {
console.log('Panier vide après hydratation, redirection...');
router.push('/panier');
}
}, [cart.items.length, router, isHydrated]);
// Afficher un loader pendant l'hydratation
if (!isHydrated) {
return (
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
<MelhfaLoader size="lg" text="Chargement du checkout..." color="purple" />
</div>
);
}
// Vérifier si le panier est vide après hydratation
if (cart.items.length === 0) {
return (
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
<div className="text-center">
<h2 className="text-2xl font-bold mb-4">Panier vide</h2>
<p className="mb-4">Votre panier est vide. Ajoutez des produits pour continuer.</p>
<Button asChild>
<Link href="/boutique">Voir nos produits</Link>
</Button>
</div>
</div>
);
}
const subtotal = cart.total || 0;
const shipping = subtotal >= 50000 ? 0 : 5000; // Livraison gratuite > 50k MRU
const total = subtotal + shipping;
const handleSubmit = async () => {
setIsLoading(true);
try {
console.log('🛒 Création de la commande...');
// Vérifier que les champs obligatoires sont remplis
if (!formData.firstName || !formData.lastName || !formData.email || !formData.phone || !formData.address || !formData.city) {
alert('Veuillez remplir tous les champs obligatoires');
setIsLoading(false);
return;
}
// Préparer les données de commande
const orderData = {
customerInfo: {
firstName: formData.firstName,
lastName: formData.lastName,
email: formData.email,
phone: formData.phone,
address: formData.address,
city: formData.city,
postalCode: formData.postalCode,
country: 'MR',
notes: '' // On peut ajouter un champ notes plus tard
},
items: cart.items.map(item => ({
id: item.id,
name: item.name,
price: item.price,
quantity: item.quantity,
total: item.total
})),
paymentMethod: formData.paymentMethod,
subtotal: subtotal,
shipping: shipping,
total: total
};
console.log('📦 Données de commande:', orderData);
// Appeler l'API pour créer la commande
const response = await fetch('/api/orders/create', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(orderData)
});
const result = await response.json();
console.log('📋 Réponse API:', result);
if (result.success) {
console.log('✅ Commande créée avec succès!', result.order);
// Sauvegarder les détails de la commande pour la page de succès
if (typeof window !== 'undefined') {
localStorage.setItem('lastOrder', JSON.stringify(result.order));
}
// Vider le panier
clearCart();
// Rediriger vers la page de succès
router.push('/checkout/success');
} else {
console.error('❌ Erreur:', result.message);
alert(`Erreur lors de la création de la commande: ${result.message}`);
}
} catch (error) {
console.error('💥 Erreur complète:', error);
alert('Erreur de connexion. Veuillez réessayer.');
} finally {
setIsLoading(false);
}
};
const handleInputChange = (field: string, value: string) => {
setFormData(prev => ({
...prev,
[field]: value
}));
};
const formatPrice = (price: number) => {
return new Intl.NumberFormat('fr-MR', {
style: 'currency',
currency: 'MRU',
minimumFractionDigits: 0,
}).format(price);
};
return (
<div className="min-h-screen bg-gray-50 py-8">
<div className="max-w-6xl mx-auto px-4">
{/* Header */}
<div className="mb-8">
<Button variant="ghost" asChild className="mb-4">
<Link href="/panier">
<ArrowLeft className="w-4 h-4 mr-2" />
Retour au panier
</Link>
</Button>
<h1 className="text-3xl font-bold">Finaliser la commande</h1>
<p className="text-gray-600">
{cart.items.length} article{cart.items.length > 1 ? 's' : ''} dans votre panier
</p>
</div>
<div className="grid lg:grid-cols-2 gap-8">
{/* Formulaire */}
<div className="space-y-6">
{/* Informations personnelles */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<User className="w-5 h-5" />
Informations personnelles
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div>
<Label htmlFor="firstName">Prénom</Label>
<Input
id="firstName"
value={formData.firstName}
onChange={(e) => handleInputChange('firstName', e.target.value)}
placeholder="Votre prénom"
/>
</div>
<div>
<Label htmlFor="lastName">Nom</Label>
<Input
id="lastName"
value={formData.lastName}
onChange={(e) => handleInputChange('lastName', e.target.value)}
placeholder="Votre nom"
/>
</div>
</div>
<div>
<Label htmlFor="email">Email</Label>
<Input
id="email"
type="email"
value={formData.email}
onChange={(e) => handleInputChange('email', e.target.value)}
placeholder="votre@email.com"
/>
</div>
<div>
<Label htmlFor="phone">Téléphone</Label>
<Input
id="phone"
value={formData.phone}
onChange={(e) => handleInputChange('phone', e.target.value)}
placeholder="+222 XX XX XX XX"
/>
</div>
</CardContent>
</Card>
{/* Adresse de livraison */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<MapPin className="w-5 h-5" />
Adresse de livraison
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div>
<Label htmlFor="address">Adresse</Label>
<Input
id="address"
value={formData.address}
onChange={(e) => handleInputChange('address', e.target.value)}
placeholder="Votre adresse complète"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<Label htmlFor="city">Ville</Label>
<Input
id="city"
value={formData.city}
onChange={(e) => handleInputChange('city', e.target.value)}
placeholder="Nouakchott"
/>
</div>
<div>
<Label htmlFor="postalCode">Code postal</Label>
<Input
id="postalCode"
value={formData.postalCode}
onChange={(e) => handleInputChange('postalCode', e.target.value)}
placeholder="Code postal"
/>
</div>
</div>
</CardContent>
</Card>
{/* Méthode de paiement */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<CreditCard className="w-5 h-5" />
Méthode de paiement
</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-3">
<div className="flex items-center space-x-2 p-4 border rounded-lg">
<input
type="radio"
name="paymentMethod"
value="cash"
checked={formData.paymentMethod === 'cash'}
onChange={(e) => handleInputChange('paymentMethod', e.target.value)}
/>
<div>
<div className="font-medium">Paiement à la livraison</div>
<div className="text-sm text-gray-600">Espèces uniquement</div>
</div>
</div>
<div className="flex items-center space-x-2 p-4 border rounded-lg opacity-50">
<input
type="radio"
name="paymentMethod"
value="card"
disabled
/>
<div>
<div className="font-medium">Carte bancaire</div>
<div className="text-sm text-gray-600">Bientôt disponible</div>
</div>
</div>
</div>
</CardContent>
</Card>
</div>
{/* Résumé de commande */}
<div className="space-y-6">
<Card>
<CardHeader>
<CardTitle>Résumé de la commande</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{/* Produits */}
<div className="space-y-3">
{cart.items.map((item) => (
<div key={item.id} className="flex items-center space-x-3">
<div className="relative w-16 h-16 rounded-lg overflow-hidden bg-gray-100">
{item.images?.[0] && (
<Image
src={item.images[0].src}
alt={item.name}
fill
className="object-cover"
/>
)}
</div>
<div className="flex-1">
<h3 className="font-medium text-sm">{item.name}</h3>
<p className="text-sm text-gray-600">Quantité: {item.quantity}</p>
</div>
<div className="text-sm font-medium">
{formatPrice(item.total)}
</div>
</div>
))}
</div>
<Separator />
{/* Totaux */}
<div className="space-y-2">
<div className="flex justify-between">
<span>Sous-total</span>
<span>{formatPrice(subtotal)}</span>
</div>
<div className="flex justify-between">
<span>Livraison</span>
<span>{shipping === 0 ? 'Gratuite' : formatPrice(shipping)}</span>
</div>
<Separator />
<div className="flex justify-between text-lg font-medium">
<span>Total</span>
<span>{formatPrice(total)}</span>
</div>
</div>
{/* Bouton de commande */}
<Button
onClick={handleSubmit}
disabled={isLoading}
className="w-full"
size="lg"
>
{isLoading ? (
<div className="flex items-center gap-2">
<div className="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin" />
Traitement...
</div>
) : (
<>
<Lock className="w-4 h-4 mr-2" />
Confirmer la commande
</>
)}
</Button>
{/* Sécurité */}
<div className="text-center text-sm text-gray-600 flex items-center justify-center gap-1">
<Lock className="w-3 h-3" />
Paiement 100% sécurisé
</div>
</CardContent>
</Card>
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,209 @@
// src/app/checkout/success/page.tsx
'use client';
import { useEffect, useState } from 'react';
import Link from 'next/link';
import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
import {
CheckCircle,
Package,
Truck,
Home,
ShoppingBag
} from 'lucide-react';
interface OrderDetails {
id: number;
number: string;
status: string;
total: string;
date_created: string;
payment_method: string;
billing: {
first_name: string;
last_name: string;
email: string;
};
}
export default function CheckoutSuccessPage() {
const [orderDetails, setOrderDetails] = useState<OrderDetails | null>(null);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
// Récupérer les détails de la commande depuis localStorage
try {
const savedOrder = localStorage.getItem('lastOrder');
if (savedOrder) {
const order = JSON.parse(savedOrder);
setOrderDetails(order);
// Nettoyer localStorage après récupération
localStorage.removeItem('lastOrder');
} else {
// Pas de commande trouvée, générer des données par défaut
setOrderDetails({
id: 0,
number: `MELHFA-${Date.now().toString().slice(-6)}`,
status: 'processing',
total: '0 MRU',
date_created: new Date().toISOString(),
payment_method: 'Paiement à la livraison',
billing: {
first_name: '',
last_name: '',
email: ''
}
});
}
} catch (error) {
console.error('Erreur lors de la récupération des détails de commande:', error);
} finally {
setIsLoading(false);
}
}, []);
const formatDate = (dateString: string) => {
return new Date(dateString).toLocaleDateString('fr-FR', {
year: 'numeric',
month: 'long',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
});
};
const formatPrice = (price: string | number) => {
const numPrice = typeof price === 'string' ? parseFloat(price) : price;
return new Intl.NumberFormat('fr-MR', {
style: 'currency',
currency: 'MRU',
minimumFractionDigits: 0,
}).format(numPrice);
};
if (isLoading) {
return (
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
<div className="text-center space-y-4">
<div className="w-16 h-16 border-4 border-black border-t-transparent rounded-full animate-spin mx-auto" />
<p className="text-gray-600">Chargement des détails de commande...</p>
</div>
</div>
);
}
if (!orderDetails) {
return (
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
<div className="text-center">
<h2 className="text-2xl font-bold mb-4">Aucune commande trouvée</h2>
<Button asChild>
<Link href="/boutique">Retour à la boutique</Link>
</Button>
</div>
</div>
);
}
return (
<div className="min-h-screen bg-gray-50 py-16">
<div className="max-w-2xl mx-auto px-4 text-center">
{/* Icône de succès */}
<div className="w-24 h-24 bg-green-100 rounded-full flex items-center justify-center mx-auto mb-8">
<CheckCircle className="w-12 h-12 text-green-600" />
</div>
{/* Message principal */}
<h1 className="text-4xl font-bold text-gray-900 mb-4">
Commande confirmée !
</h1>
<p className="text-xl text-gray-600 mb-2">
Merci {orderDetails.billing.first_name} pour votre achat
</p>
<p className="text-gray-600 mb-8">
Votre commande de melhfa authentiques a é enregistrée
</p>
{/* Détails de la commande */}
<Card className="mb-8">
<CardContent className="p-6">
<div className="space-y-4">
<div className="flex items-center justify-center gap-2 text-lg">
<Package className="w-5 h-5" />
<span>
Commande n° <strong>
{orderDetails.number || `#${orderDetails.id}`}
</strong>
</span>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 text-sm">
<div>
<strong>Total:</strong> {formatPrice(orderDetails.total)}
</div>
<div>
<strong>Paiement:</strong> {orderDetails.payment_method}
</div>
<div>
<strong>Date:</strong> {formatDate(orderDetails.date_created)}
</div>
<div>
<strong>Statut:</strong> En traitement
</div>
</div>
<div className="text-gray-600 pt-4 border-t">
<div className="flex items-center justify-center gap-2 mb-2">
<Truck className="w-4 h-4" />
<span>Livraison estimée : 2-3 jours ouvrables</span>
</div>
<p className="text-sm">
Vous recevrez un SMS de confirmation avec les détails de livraison
</p>
</div>
</div>
</CardContent>
</Card>
{/* Actions */}
<div className="space-y-4">
<Button asChild size="lg" className="w-full sm:w-auto">
<Link href="/">
<Home className="w-4 h-4 mr-2" />
Retour à l'accueil
</Link>
</Button>
<div>
<Button variant="outline" asChild className="w-full sm:w-auto">
<Link href="/boutique">
<ShoppingBag className="w-4 h-4 mr-2" />
Continuer mes achats
</Link>
</Button>
</div>
</div>
{/* Informations supplémentaires */}
<div className="mt-12 p-6 bg-blue-50 rounded-lg">
<h3 className="font-semibold text-blue-900 mb-2">
Prochaines étapes
</h3>
<ul className="text-sm text-blue-800 space-y-1">
<li> Votre commande est maintenant dans WooCommerce</li>
<li> Vous recevrez un SMS de confirmation</li>
<li> Notre équipe préparera votre commande</li>
<li> Livraison sous 2-3 jours ouvrables</li>
<li> Paiement à la réception</li>
</ul>
</div>
</div>
</div>
);
}

BIN
src/app/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

211
src/app/favoris/page.tsx Normal file
View File

@@ -0,0 +1,211 @@
// src/app/favoris/page.tsx
'use client';
import { useWishlist } from '@/contexts/WishlistContext';
import { useCart } from '@/contexts/CartContext';
import { formatPrice } from '@/lib/woocommerce';
import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import {
Heart,
ShoppingBag,
X,
ArrowRight,
Star,
Sparkles
} from 'lucide-react';
import Image from 'next/image';
import Link from 'next/link';
import { cn } from '@/lib/utils';
export default function FavorisPage() {
const { wishlist, removeFromWishlist, clearWishlist } = useWishlist();
const { addToCart, isInCart } = useCart();
console.log('💖 FavorisPage render - wishlist:', wishlist);
const handleAddToCart = (item: any) => {
addToCart({
id: item.id,
name: item.name,
price: item.price,
image: item.image,
});
};
const handleRemoveFromWishlist = (id: number) => {
removeFromWishlist(id);
};
if (!wishlist || wishlist.items.length === 0) {
return (
<div className="min-h-screen bg-white pt-20">
<div className="max-w-[1400px] mx-auto px-6 py-16">
<div className="text-center space-y-8">
<div className="w-32 h-32 mx-auto bg-gray-100 rounded-full flex items-center justify-center">
<Heart className="w-16 h-16 text-gray-400" />
</div>
<div className="space-y-4">
<h1 className="text-3xl font-light tracking-wide">Aucun favori pour le moment</h1>
<p className="text-gray-600 max-w-md mx-auto">
Découvrez notre collection et ajoutez vos melhfa préférées à vos favoris.
</p>
<div className="pt-6">
<Link href="/boutique">
<Button className="bg-black text-white hover:bg-gray-800 px-8 py-3">
Découvrir la boutique
<ArrowRight className="w-4 h-4 ml-2" />
</Button>
</Link>
</div>
</div>
</div>
</div>
</div>
);
}
return (
<div className="min-h-screen bg-white pt-20">
<div className="max-w-[1400px] mx-auto px-6 py-16">
{/* Header */}
<div className="flex items-center justify-between mb-12">
<div className="space-y-2">
<h1 className="text-3xl font-light tracking-wide flex items-center gap-3">
<Heart className="w-8 h-8 text-red-500 fill-current" />
Mes Favoris
</h1>
<p className="text-gray-600">
{wishlist.itemCount} produit{wishlist.itemCount > 1 ? 's' : ''} en favori{wishlist.itemCount > 1 ? 's' : ''}
</p>
</div>
{wishlist.itemCount > 0 && (
<Button
variant="outline"
onClick={clearWishlist}
className="text-red-600 border-red-200 hover:bg-red-50"
>
Vider tous les favoris
</Button>
)}
</div>
{/* Grille des favoris */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-8">
{wishlist.items.map((item) => (
<Card key={item.id} className="group hover:shadow-lg transition-all duration-300">
<CardContent className="p-0">
{/* Image Container */}
<div className="relative aspect-[3/4] overflow-hidden rounded-t-lg">
<Link href={`/produit/${item.slug}`}>
<Image
src={item.image}
alt={item.name}
fill
className="object-cover group-hover:scale-105 transition-transform duration-500"
/>
</Link>
{/* Bouton supprimer */}
<Button
variant="ghost"
size="sm"
onClick={() => handleRemoveFromWishlist(item.id)}
className="absolute top-3 right-3 w-9 h-9 p-0 rounded-full bg-white/90 hover:bg-white text-red-500 hover:text-red-600 shadow-sm"
>
<X className="w-4 h-4" />
</Button>
{/* Badge promo si applicable */}
{item.sale_price && item.regular_price && (
<Badge className="absolute top-3 left-3 bg-red-500 text-white">
-{Math.round(((parseFloat(item.regular_price) - parseFloat(item.sale_price)) / parseFloat(item.regular_price)) * 100)}%
</Badge>
)}
</div>
{/* Informations produit */}
<div className="p-4 space-y-3">
<div className="space-y-1">
<Link
href={`/produit/${item.slug}`}
className="hover:text-black transition-colors"
>
<h3 className="font-medium text-gray-900 line-clamp-2 group-hover:text-black">
{item.name}
</h3>
</Link>
<div className="flex items-center gap-2">
{item.sale_price ? (
<>
<span className="text-lg font-semibold text-red-600">
{formatPrice(parseFloat(item.sale_price))}
</span>
<span className="text-sm text-gray-500 line-through">
{formatPrice(parseFloat(item.regular_price || item.price))}
</span>
</>
) : (
<span className="text-lg font-semibold text-gray-900">
{formatPrice(parseFloat(item.price))}
</span>
)}
</div>
</div>
{/* Date d'ajout */}
<p className="text-xs text-gray-500">
Ajouté le {new Date(item.dateAdded).toLocaleDateString('fr-FR')}
</p>
{/* Actions */}
<div className="flex gap-2 pt-2">
<Button
onClick={() => handleAddToCart(item)}
className={cn(
"flex-1 transition-all duration-300",
isInCart(item.id)
? "bg-green-600 hover:bg-green-700 text-white"
: "bg-black hover:bg-gray-800 text-white"
)}
>
<ShoppingBag className="w-4 h-4 mr-2" />
{isInCart(item.id) ? 'Dans le panier' : 'Ajouter'}
</Button>
<Button
variant="outline"
size="sm"
onClick={() => handleRemoveFromWishlist(item.id)}
className="px-3 text-red-500 border-red-200 hover:bg-red-50"
>
<Heart className="w-4 h-4 fill-current" />
</Button>
</div>
</div>
</CardContent>
</Card>
))}
</div>
{/* Suggestions */}
<div className="mt-16 text-center space-y-4">
<div className="flex items-center justify-center gap-2">
<Sparkles className="w-5 h-5 text-yellow-500" />
<p className="text-gray-600">Continuez à découvrir nos collections</p>
</div>
<Link href="/boutique">
<Button variant="outline" className="px-8">
Voir plus de produits
<ArrowRight className="w-4 h-4 ml-2" />
</Button>
</Link>
</div>
</div>
</div>
);
}

583
src/app/globals.css Normal file
View File

@@ -0,0 +1,583 @@
/* ============================================== */
/* GLOBALS.CSS FINAL CORRIGÉ - MELHFA E-COMMERCE */
/* Compatible Tailwind v3 + Next.js 14 - SANS ERREURS */
/* ============================================== */
@tailwind base;
@tailwind components;
@tailwind utilities;
/* ============================================== */
/* VARIABLES CSS CUSTOM */
/* ============================================== */
@layer base {
:root {
/* Couleurs de base */
--background: 0 0% 100%;
--foreground: 222.2 84% 4.9%;
/* Composants UI */
--card: 0 0% 100%;
--card-foreground: 222.2 84% 4.9%;
--popover: 0 0% 100%;
--popover-foreground: 222.2 84% 4.9%;
/* Couleurs primaires */
--primary: 222.2 84% 4.9%;
--primary-foreground: 210 40% 98%;
--secondary: 210 40% 96%;
--secondary-foreground: 222.2 84% 4.9%;
/* États et interactions */
--muted: 210 40% 96%;
--muted-foreground: 215.4 16.3% 46.9%;
--accent: 210 40% 96%;
--accent-foreground: 222.2 84% 4.9%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 210 40% 98%;
/* Bordures et inputs */
--border: 214.3 31.8% 91.4%;
--input: 214.3 31.8% 91.4%;
--ring: 222.2 84% 4.9%;
/* Border radius global */
--radius: 0.5rem;
}
/* Mode sombre */
.dark {
--background: 222.2 84% 4.9%;
--foreground: 210 40% 98%;
--card: 222.2 84% 4.9%;
--card-foreground: 210 40% 98%;
--popover: 222.2 84% 4.9%;
--popover-foreground: 210 40% 98%;
--primary: 210 40% 98%;
--primary-foreground: 222.2 84% 4.9%;
--secondary: 217.2 32.6% 17.5%;
--secondary-foreground: 210 40% 98%;
--muted: 217.2 32.6% 17.5%;
--muted-foreground: 215 20.2% 65.1%;
--accent: 217.2 32.6% 17.5%;
--accent-foreground: 210 40% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 210 40% 98%;
--border: 217.2 32.6% 17.5%;
--input: 217.2 32.6% 17.5%;
--ring: 212.7 26.8% 83.9%;
}
}
/* ============================================== */
/* STYLES DE BASE - SANS CLASSES PROBLÉMATIQUES */
/* ============================================== */
@layer base {
html {
scroll-behavior: smooth;
font-family: system-ui, sans-serif;
}
body {
margin: 0;
background: hsl(var(--background));
color: hsl(var(--foreground));
font-feature-settings: "rlig" 1, "calt" 1;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
line-height: 1.6;
}
/* Suppression des bordures par défaut */
*,
*::before,
*::after {
box-sizing: border-box;
border-width: 0;
border-style: solid;
border-color: hsl(var(--border));
}
/* Scrollbar personnalisée */
::-webkit-scrollbar {
width: 6px;
height: 6px;
}
::-webkit-scrollbar-track {
background: #f1f1f1;
}
::-webkit-scrollbar-thumb {
background: #888;
border-radius: 3px;
}
::-webkit-scrollbar-thumb:hover {
background: #555;
}
/* Sélection de texte */
::selection {
background: hsl(var(--primary));
color: hsl(var(--primary-foreground));
}
}
/* ============================================== */
/* COMPOSANTS PERSONNALISÉS */
/* ============================================== */
@layer components {
/* Effets de hover pour cartes */
.card-hover {
@apply transition-all duration-300 hover:shadow-lg hover:-translate-y-1;
}
/* Effet zoom pour images */
.image-zoom {
@apply transition-transform duration-700 hover:scale-105;
}
/* Container parallax */
.parallax {
transform: translateZ(0);
will-change: transform;
}
/* Grid responsive pour produits */
.product-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 1.5rem;
}
@media (max-width: 640px) {
.product-grid {
grid-template-columns: repeat(2, 1fr);
gap: 1rem;
}
}
/* Styles de focus Ring personnalisés */
.focus-ring {
@apply focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2;
}
/* Estados de status/badges */
.status-success {
@apply bg-green-100 text-green-800 border border-green-200 px-3 py-1 rounded-full text-sm font-medium;
}
.status-warning {
@apply bg-yellow-100 text-yellow-800 border border-yellow-200 px-3 py-1 rounded-full text-sm font-medium;
}
.status-error {
@apply bg-red-100 text-red-800 border border-red-200 px-3 py-1 rounded-full text-sm font-medium;
}
.status-info {
@apply bg-blue-100 text-blue-800 border border-blue-200 px-3 py-1 rounded-full text-sm font-medium;
}
/* Cartes de produits MELHFA */
.melhfa-card {
@apply bg-white rounded-lg shadow-sm border border-gray-200 overflow-hidden transition-all duration-300 hover:shadow-md hover:-translate-y-1;
}
/* Boutons personnalisés */
.btn-primary {
@apply bg-black text-white px-6 py-3 rounded-lg font-medium transition-all duration-200 hover:bg-gray-800 focus:ring-2 focus:ring-black focus:ring-offset-2;
}
.btn-secondary {
@apply bg-gray-100 text-gray-900 px-6 py-3 rounded-lg font-medium transition-all duration-200 hover:bg-gray-200 focus:ring-2 focus:ring-gray-500 focus:ring-offset-2;
}
}
/* ============================================== */
/* UTILITAIRES PERSONNALISÉS */
/* ============================================== */
@layer utilities {
/* Espacement de sections */
.section-padding {
@apply py-16 md:py-20 lg:py-24;
}
.container-padding {
@apply px-6 lg:px-8;
}
/* Typographie */
.heading-xl {
@apply text-4xl md:text-5xl lg:text-6xl font-light tracking-tight leading-none;
}
.heading-lg {
@apply text-3xl md:text-4xl font-light tracking-wide leading-tight;
}
.heading-md {
@apply text-2xl md:text-3xl font-light tracking-wide;
}
.body-lg {
@apply text-lg md:text-xl text-gray-600 leading-relaxed;
}
.body-sm {
@apply text-sm text-gray-600 leading-relaxed;
}
/* Équilibrage du texte */
.text-balance {
text-wrap: balance;
}
/* Utilitaires de layout */
.center-absolute {
@apply absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2;
}
.center-flex {
@apply flex items-center justify-center;
}
/* Ratios d'aspect personnalisés */
.aspect-product {
aspect-ratio: 3 / 4;
}
.aspect-hero {
aspect-ratio: 16 / 9;
}
/* Safe area pour mobile */
.safe-top {
padding-top: env(safe-area-inset-top);
}
.safe-bottom {
padding-bottom: env(safe-area-inset-bottom);
}
/* Masquer scrollbar */
.hide-scrollbar {
-ms-overflow-style: none;
scrollbar-width: none;
}
.hide-scrollbar::-webkit-scrollbar {
display: none;
}
/* Conteneur max-width pour MELHFA */
.container-melhfa {
@apply max-w-[1400px] mx-auto px-6;
}
}
/* ============================================== */
/* ANIMATIONS PERSONNALISÉES */
/* ============================================== */
/* Keyframes */
@keyframes fade-in-up {
from {
opacity: 0;
transform: translateY(30px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes slide-in {
from {
transform: translateX(-100%);
}
to {
transform: translateX(0);
}
}
@keyframes scale-in {
from {
opacity: 0;
transform: scale(0.9);
}
to {
opacity: 1;
transform: scale(1);
}
}
@keyframes bounce-subtle {
0%,
20%,
53%,
80%,
100% {
transform: translate3d(0, 0, 0);
}
40%,
43% {
transform: translate3d(0, -8px, 0);
}
70% {
transform: translate3d(0, -4px, 0);
}
90% {
transform: translate3d(0, -2px, 0);
}
}
/* Classes d'animation */
.animate-fade-in-up {
animation: fade-in-up 0.8s ease-out;
}
.animate-slide-in {
animation: slide-in 0.5s ease-out;
}
.animate-scale-in {
animation: scale-in 0.3s ease-out;
}
.animate-bounce-subtle {
animation: bounce-subtle 1s ease-in-out;
}
/* ============================================== */
/* STYLES RESPONSIFS */
/* ============================================== */
@media (max-width: 640px) {
.heading-xl {
@apply text-3xl;
}
.heading-lg {
@apply text-2xl;
}
.section-padding {
@apply py-12;
}
}
/* ============================================== */
/* ACCESSIBILITÉ */
/* ============================================== */
@media (prefers-contrast: high) {
.melhfa-card {
@apply border-2 border-gray-800;
}
}
@media (prefers-reduced-motion: reduce) {
.animate-fade-in-up,
.animate-slide-in,
.animate-scale-in,
.animate-bounce-subtle,
.card-hover,
.image-zoom {
animation: none;
transition: none;
}
* {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
}
/* ============================================== */
/* STYLES D'IMPRESSION */
/* ============================================== */
@media print {
.no-print {
display: none !important;
}
body {
background: white !important;
color: black !important;
}
.melhfa-card {
@apply border border-gray-400 shadow-none;
}
}
/* Ajoutez ces animations dans votre fichier src/app/globals.css */
/* Animations personnalisées pour le loader Melhfa Mauritanien */
@keyframes melhfa-float {
0%,
100% {
transform: translateY(0) rotate(0deg);
}
25% {
transform: translateY(-8px) rotate(1deg);
}
50% {
transform: translateY(-4px) rotate(0deg);
}
75% {
transform: translateY(-10px) rotate(-1deg);
}
}
@keyframes melhfa-fold {
0%,
100% {
transform: translateY(0) scaleY(1);
}
50% {
transform: translateY(-3px) scaleY(0.98);
}
}
@keyframes melhfa-border {
0%,
100% {
stroke-dasharray: 0, 100;
}
50% {
stroke-dasharray: 50, 50;
}
}
@keyframes melhfa-patterns {
0%,
100% {
opacity: 0.6;
transform: scale(1);
}
50% {
opacity: 0.9;
transform: scale(1.05);
}
}
@keyframes melhfa-shine {
0%,
100% {
opacity: 0.05;
transform: translateX(0);
}
50% {
opacity: 0.2;
transform: translateX(10px);
}
}
@keyframes melhfa-fringe {
0%,
100% {
transform: translateY(0) rotate(0deg);
}
25% {
transform: translateY(2px) rotate(1deg);
}
75% {
transform: translateY(-1px) rotate(-0.5deg);
}
}
/* Classes d'animation pour le melhfa */
.animate-melhfa-float {
animation: melhfa-float 4s ease-in-out infinite;
}
.animate-melhfa-fold {
animation: melhfa-fold 3s ease-in-out infinite 0.5s;
}
.animate-melhfa-border {
animation: melhfa-border 2s ease-in-out infinite;
}
.animate-melhfa-patterns {
animation: melhfa-patterns 3s ease-in-out infinite 1s;
}
.animate-melhfa-shine {
animation: melhfa-shine 4s ease-in-out infinite 1.5s;
}
.animate-melhfa-fringe {
animation: melhfa-fringe 2s ease-in-out infinite 0.8s;
}
/* Effet de flottement doux pour les éléments UI */
@keyframes float {
0%,
100% {
transform: translateY(0px);
}
50% {
transform: translateY(-10px);
}
}
.animate-float {
animation: float 6s ease-in-out infinite;
}
/* Animation de brillance pour les accents dorés */
@keyframes shimmer {
0% {
background-position: -200px 0;
}
100% {
background-position: calc(200px + 100%) 0;
}
}
.animate-shimmer {
background: linear-gradient(90deg,
transparent,
rgba(255, 215, 0, 0.4),
transparent);
background-size: 200px 100%;
animation: shimmer 2s infinite;
}

41
src/app/layout.tsx Normal file
View File

@@ -0,0 +1,41 @@
// src/app/layout.tsx
import type { Metadata } from 'next';
import { Inter } from 'next/font/google';
import './globals.css';
import { AuthProvider } from '@/hooks/useAuth';
import { Navbar } from '@/components/layout/Navbar';
import { Footer } from '@/components/layout/Footer';
import { SiteLoader } from '@/components/layout/SiteLoader';
import { CartProvider } from '@/contexts/CartContext';
const inter = Inter({ subsets: ['latin'] });
export const metadata: Metadata = {
title: 'MELHFA - Voiles Mauritaniennes Authentiques',
description: 'Découvrez notre collection exclusive de melhfa mauritaniennes traditionnelles. Qualité premium, artisanat authentique.',
};
// Ajouter cet import en haut
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="fr">
<body className={inter.className}>
<SiteLoader minLoadingTime={2500} showWelcomeText={true}>
<AuthProvider>
<CartProvider> {/* 🆕 Nouveau */}
<div className="min-h-screen flex flex-col">
<Navbar />
<main className="flex-grow">
{children}
</main>
<Footer />
</div>
</CartProvider> {/* 🆕 Nouveau */}
</AuthProvider>
</SiteLoader>
</body>
</html>
);
}

214
src/app/page.tsx Normal file
View File

@@ -0,0 +1,214 @@
// src/app/page.tsx
import { Suspense } from 'react';
import Link from 'next/link';
import Image from 'next/image';
import { WooCommerceService } from '@/lib/woocommerce';
import ProductCard from '@/components/product/ProductCard';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { ArrowRight, Star, Truck, Shield, Heart } from 'lucide-react';
// Composants pour les sections de la page d'accueil
import HeroSection from '@/components/sections/HeroSection';
import FeaturedProductsSection from '@/components/sections/FeaturedProductsSection';
import PromoSection from '@/components/sections/PromoSection';
import NewsletterSection from '@/components/sections/NewsletterSection';
export default async function HomePage(): Promise<JSX.Element> {
// Récupérer les données des produits
const [featuredProducts, saleProducts, newArrivals] = await Promise.all([
WooCommerceService.getFeaturedProducts(6),
WooCommerceService.getSaleProducts(4),
WooCommerceService.getNewArrivals(8),
]);
return (
<main className="min-h-screen bg-white">
{/* Hero Section */}
<HeroSection />
{/* Promo Banner */}
<PromoSection />
{/* Featured Products */}
<section className="py-20 bg-gray-50">
<div className="max-w-[1400px] mx-auto px-6">
<div className="text-center mb-16">
<h2 className="text-4xl font-light mb-4 tracking-wide">
Produits Vedettes
</h2>
<p className="text-gray-600 max-w-2xl mx-auto">
Découvrez notre sélection de melhfa d&apos;exception, alliant tradition mauritanienne et élégance contemporaine
</p>
</div>
{/* Grid de produits vedettes avec design asymétrique */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-12">
{/* Produit principal - grande taille */}
<div className="lg:row-span-2">
<div className="relative h-[600px] lg:h-[824px] overflow-hidden rounded-lg group cursor-pointer">
<Image
src="/images/melhfa-featured-1.jpg"
alt="Collection Sahara"
fill
className="object-cover transition-transform duration-700 group-hover:scale-105"
/>
<div className="absolute top-6 right-6">
<Badge className="bg-red-500 text-white">-25%</Badge>
</div>
<div className="absolute inset-0 bg-gradient-to-t from-black/70 via-transparent to-transparent" />
<div className="absolute bottom-0 left-0 right-0 p-8 text-white transform translate-y-full group-hover:translate-y-0 transition-transform duration-300">
<h3 className="text-2xl font-light mb-3 tracking-wide">Collection Sahara</h3>
<p className="text-sm opacity-90 mb-4">
Melhfa artisanale aux motifs berbères traditionnels, tissée à la main dans nos ateliers de Nouakchott.
</p>
<div className="flex items-center justify-between">
<div className="text-xl font-medium">
42.000 MRU{" "}
<span className="text-sm line-through opacity-70">56.000 MRU</span>
</div>
<Button className="bg-white text-black hover:bg-gray-100">
Découvrir
</Button>
</div>
</div>
</div>
</div>
{/* Produits secondaires */}
<div className="space-y-6">
<div className="relative h-[400px] overflow-hidden rounded-lg group cursor-pointer">
<Image
src="/images/melhfa-featured-2.jpg"
alt="Melhfa Élégance"
fill
className="object-cover transition-transform duration-700 group-hover:scale-105"
/>
<div className="absolute inset-0 bg-gradient-to-t from-black/70 via-transparent to-transparent" />
<div className="absolute bottom-0 left-0 right-0 p-6 text-white transform translate-y-full group-hover:translate-y-0 transition-transform duration-300">
<h3 className="text-xl font-light mb-2 tracking-wide">Melhfa Élégance</h3>
<p className="text-sm opacity-90 mb-3">Design moderne pour femme active</p>
<div className="text-lg font-medium">32.000 MRU</div>
</div>
</div>
<div className="relative h-[400px] overflow-hidden rounded-lg group cursor-pointer">
<Image
src="/images/melhfa-featured-3.jpg"
alt="Melhfa Océan"
fill
className="object-cover transition-transform duration-700 group-hover:scale-105"
/>
<div className="absolute top-6 right-6">
<Badge className="bg-red-500 text-white">-15%</Badge>
</div>
<div className="absolute inset-0 bg-gradient-to-t from-black/70 via-transparent to-transparent" />
<div className="absolute bottom-0 left-0 right-0 p-6 text-white transform translate-y-full group-hover:translate-y-0 transition-transform duration-300">
<h3 className="text-xl font-light mb-2 tracking-wide">Melhfa Océan</h3>
<p className="text-sm opacity-90 mb-3">Nuances bleues inspirées de l&apos;Atlantique</p>
<div className="text-lg font-medium">
29.000 MRU{" "}
<span className="text-sm line-through opacity-70">34.000 MRU</span>
</div>
</div>
</div>
</div>
</div>
</div>
</section>
{/* New Arrivals Products */}
<section className="py-20">
<div className="max-w-[1400px] mx-auto px-6">
<div className="flex items-center justify-between mb-16">
<div>
<h2 className="text-4xl font-light mb-4 tracking-wide">Nouvelle Collection</h2>
<p className="text-gray-600">Les dernières créations de nos artisans</p>
</div>
<Button variant="outline" className="hidden md:flex" asChild>
<Link href="/boutique">
Voir tout
<ArrowRight className="w-4 h-4 ml-2" />
</Link>
</Button>
</div>
<Suspense fallback={<ProductGridSkeleton />}>
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-6">
{newArrivals.success && newArrivals.data.map((product) => (
<ProductCard
key={product.id}
product={product}
className="animate-fade-in-up"
/>
))}
</div>
</Suspense>
<div className="text-center mt-12 md:hidden">
<Button variant="outline" asChild>
<Link href="/boutique">
Voir tout
<ArrowRight className="w-4 h-4 ml-2" />
</Link>
</Button>
</div>
</div>
</section>
{/* Features Section */}
<section className="py-20 bg-gray-50">
<div className="max-w-[1400px] mx-auto px-6">
<div className="grid grid-cols-1 md:grid-cols-3 gap-8">
<div className="text-center group">
<div className="w-16 h-16 mx-auto mb-4 bg-black rounded-full flex items-center justify-center group-hover:scale-110 transition-transform duration-300">
<Truck className="w-8 h-8 text-white" />
</div>
<h3 className="text-lg font-medium mb-2">Livraison gratuite</h3>
<p className="text-sm text-gray-600">À partir de 50.000 MRU dans tout Nouakchott</p>
</div>
<div className="text-center group">
<div className="w-16 h-16 mx-auto mb-4 bg-black rounded-full flex items-center justify-center group-hover:scale-110 transition-transform duration-300">
<Shield className="w-8 h-8 text-white" />
</div>
<h3 className="text-lg font-medium mb-2">Garantie qualité</h3>
<p className="text-sm text-gray-600">Retour gratuit sous 30 jours</p>
</div>
<div className="text-center group">
<div className="w-16 h-16 mx-auto mb-4 bg-black rounded-full flex items-center justify-center group-hover:scale-110 transition-transform duration-300">
<Heart className="w-8 h-8 text-white" />
</div>
<h3 className="text-lg font-medium mb-2">Fait main</h3>
<p className="text-sm text-gray-600">Chaque melhfa est unique et artisanale</p>
</div>
</div>
</div>
</section>
{/* Newsletter Section */}
<NewsletterSection />
</main>
);
}
// Composant de skeleton pour le chargement
function ProductGridSkeleton(): JSX.Element {
return (
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-6">
{[...Array(8)].map((_, i) => (
<div key={i} className="animate-pulse">
<div className="aspect-[3/4] bg-gray-200 rounded-lg mb-4" />
<div className="space-y-2">
<div className="h-4 bg-gray-200 rounded w-3/4" />
<div className="h-4 bg-gray-200 rounded w-1/2" />
</div>
</div>
))}
</div>
);
}

228
src/app/panier/page.tsx Normal file
View File

@@ -0,0 +1,228 @@
// src/app/panier/page.tsx
'use client';
import { useCart } from '@/contexts/CartContext'; // Import depuis le Context
import { formatPrice } from '@/lib/woocommerce';
import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
import { Separator } from '@/components/ui/separator';
import { Input } from '@/components/ui/input';
import {
ShoppingBag,
Trash2,
Plus,
Minus,
ArrowRight,
Truck,
Shield,
Tag
} from 'lucide-react';
import Image from 'next/image';
import Link from 'next/link';
import { useState } from 'react';
export default function CartPage() {
const { cart, updateQuantity, removeFromCart, clearCart } = useCart(); // Utilise le Context
const [promoCode, setPromoCode] = useState('');
const [isPromoApplied, setIsPromoApplied] = useState(false);
const [promoDiscount, setPromoDiscount] = useState(0);
const subtotal = cart?.total || 0;
const shipping = subtotal >= 50000 ? 0 : 5000;
const discount = isPromoApplied ? promoDiscount : 0;
const total = subtotal + shipping - discount;
console.log('🛒 CartPage render - cart:', cart);
const handleQuantityChange = (productId: number, newQuantity: number) => {
console.log('🔢 CartPage - Changement quantité:', { productId, newQuantity });
if (newQuantity <= 0) {
removeFromCart(productId);
} else {
updateQuantity(productId, newQuantity);
}
};
const applyPromoCode = () => {
const promoCodes: { [key: string]: number } = {
'WELCOME10': 0.1,
'SUMMER20': 0.2,
'FIRST5': 0.05,
};
const discountPercent = promoCodes[promoCode.toUpperCase()];
if (discountPercent) {
setIsPromoApplied(true);
setPromoDiscount(subtotal * discountPercent);
}
};
const removePromoCode = () => {
setIsPromoApplied(false);
setPromoDiscount(0);
setPromoCode('');
};
if (!cart || cart.items.length === 0) {
return (
<div className="min-h-screen bg-white pt-20">
<div className="max-w-[1400px] mx-auto px-6 py-16">
<div className="text-center space-y-8">
<div className="w-32 h-32 mx-auto bg-gray-100 rounded-full flex items-center justify-center">
<ShoppingBag className="w-16 h-16 text-gray-400" />
</div>
<div className="space-y-4">
<h1 className="text-3xl font-light tracking-wide">Votre panier est vide</h1>
<p className="text-gray-600 max-w-md mx-auto">
Découvrez notre collection de melhfa exceptionnelles.
</p>
</div>
<div className="flex flex-col sm:flex-row gap-4 justify-center">
<Button asChild size="lg" className="bg-black text-white hover:bg-gray-800">
<Link href="/boutique">
Découvrir la boutique
<ArrowRight className="w-5 h-5 ml-2" />
</Link>
</Button>
</div>
</div>
</div>
</div>
);
}
return (
<div className="min-h-screen bg-white pt-20">
<div className="max-w-[1400px] mx-auto px-6 py-8">
{/* Header */}
<div className="mb-8">
<h1 className="text-3xl md:text-4xl font-light tracking-wide text-black mb-2">
Panier ({cart.itemCount} article{cart.itemCount > 1 ? 's' : ''})
</h1>
<p className="text-gray-600">Vérifiez vos articles avant de procéder au paiement</p>
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
{/* Articles */}
<div className="lg:col-span-2 space-y-6">
<div className="flex justify-between items-center">
<span className="text-sm text-gray-600">
{cart.items.length} produit{cart.items.length > 1 ? 's' : ''}
</span>
<Button
variant="ghost"
size="sm"
onClick={() => {
console.log('🧹 CartPage - Vider le panier');
clearCart();
}}
className="text-red-600 hover:text-red-700"
>
<Trash2 className="w-4 h-4 mr-2" />
Vider le panier
</Button>
</div>
{/* Liste des articles */}
<div className="space-y-4">
{cart.items.map((item) => (
<Card key={item.id}>
<CardContent className="p-6">
<div className="flex gap-4">
<div className="relative w-24 h-24 flex-shrink-0">
<Image
src={item.image}
alt={item.name}
fill
className="object-cover rounded"
sizes="96px"
/>
</div>
<div className="flex-1">
<h3 className="font-medium text-black mb-2">{item.name}</h3>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() => handleQuantityChange(item.id, item.quantity - 1)}
>
<Minus className="w-3 h-3" />
</Button>
<span className="w-8 text-center">{item.quantity}</span>
<Button
variant="outline"
size="sm"
onClick={() => handleQuantityChange(item.id, item.quantity + 1)}
>
<Plus className="w-3 h-3" />
</Button>
</div>
<div className="text-right">
<div className="font-medium">{formatPrice(item.total)}</div>
<Button
variant="ghost"
size="sm"
onClick={() => {
console.log('🗑️ CartPage - Suppression item:', item.id);
removeFromCart(item.id);
}}
className="text-red-600 hover:text-red-700"
>
Supprimer
</Button>
</div>
</div>
</div>
</div>
</CardContent>
</Card>
))}
</div>
</div>
{/* Résumé */}
<div className="space-y-6">
<Card>
<CardContent className="p-6 space-y-4">
<h3 className="text-lg font-medium">Résumé de la commande</h3>
<div className="space-y-2">
<div className="flex justify-between">
<span>Sous-total</span>
<span>{formatPrice(subtotal)}</span>
</div>
<div className="flex justify-between">
<span>Livraison</span>
<span>{shipping === 0 ? 'Gratuite' : formatPrice(shipping)}</span>
</div>
{discount > 0 && (
<div className="flex justify-between text-green-600">
<span>Réduction</span>
<span>-{formatPrice(discount)}</span>
</div>
)}
</div>
<Separator />
<div className="flex justify-between text-lg font-medium">
<span>Total</span>
<span>{formatPrice(total)}</span>
</div>
<Button asChild className="w-full bg-black text-white hover:bg-gray-800">
<Link href="/checkout">
Passer commande
<ArrowRight className="w-4 h-4 ml-2" />
</Link>
</Button>
</CardContent>
</Card>
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,150 @@
// src/components/auth/LoginForm.tsx
'use client';
import { useState } from 'react';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import * as z from 'zod';
import { useAuth } from '@/hooks/useAuth';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { Eye, EyeOff, Mail, Lock } from 'lucide-react';
import Link from 'next/link';
import { useRouter, useSearchParams } from 'next/navigation';
import { MelhfaInlineLoader } from '@/components/ui/MelhfaLoader';
const loginSchema = z.object({
username: z.string().min(1, 'Le nom d\'utilisateur est requis'),
password: z.string().min(6, 'Le mot de passe doit contenir au moins 6 caractères'),
});
type LoginFormData = z.infer<typeof loginSchema>;
export default function LoginForm() {
const { login, isLoading } = useAuth();
const router = useRouter();
const searchParams = useSearchParams();
const [showPassword, setShowPassword] = useState(false);
const [error, setError] = useState<string | null>(null);
const redirectTo = searchParams.get('redirect') || '/account';
const {
register,
handleSubmit,
formState: { errors, isSubmitting },
} = useForm<LoginFormData>({
resolver: zodResolver(loginSchema),
});
const onSubmit = async (data: LoginFormData) => {
setError(null);
try {
const response = await login(data);
if (response.success) {
router.push(redirectTo);
} else {
setError(response.message || 'Erreur de connexion');
}
} catch (err) {
setError('Une erreur inattendue s\'est produite');
}
};
return (
<Card className="w-full max-w-md mx-auto">
<CardHeader className="space-y-1">
<CardTitle className="text-2xl font-bold text-center">Connexion</CardTitle>
<CardDescription className="text-center">
Connectez-vous à votre compte pour accéder à votre espace personnel
</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
{error && (
<Alert variant="destructive">
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
<div className="space-y-2">
<Label htmlFor="username">Nom d'utilisateur ou Email</Label>
<div className="relative">
<Mail className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 h-4 w-4" />
<Input
id="username"
type="text"
placeholder="Votre nom d'utilisateur ou email"
className="pl-10"
{...register('username')}
/>
</div>
{errors.username && (
<p className="text-sm text-red-600">{errors.username.message}</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="password">Mot de passe</Label>
<div className="relative">
<Lock className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 h-4 w-4" />
<Input
id="password"
type={showPassword ? 'text' : 'password'}
placeholder="Votre mot de passe"
className="pl-10 pr-10"
{...register('password')}
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-3 top-1/2 transform -translate-y-1/2 text-gray-400 hover:text-gray-600"
>
{showPassword ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
</button>
</div>
{errors.password && (
<p className="text-sm text-red-600">{errors.password.message}</p>
)}
</div>
<Button
type="submit"
className="w-full"
disabled={isSubmitting || isLoading}
>
{(isSubmitting || isLoading) ? (
<MelhfaInlineLoader text="Connexion..." size="sm" color="blue" />
) : (
'Se connecter'
)}
</Button>
<div className="text-center space-y-2">
<Link
href="/auth/forgot-password"
className="text-sm text-blue-600 hover:text-blue-800 underline"
>
Mot de passe oublié ?
</Link>
<div className="text-sm text-gray-600">
Pas encore de compte ?{' '}
<Link
href={`/auth/register?redirect=${encodeURIComponent(redirectTo)}`}
className="text-blue-600 hover:text-blue-800 underline font-medium"
>
Créer un compte
</Link>
</div>
</div>
</form>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,233 @@
// src/components/auth/RegisterForm.tsx
'use client';
import { useState } from 'react';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import * as z from 'zod';
import { useAuth } from '@/hooks/useAuth';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { Eye, EyeOff, Mail, Lock, User } from 'lucide-react';
import Link from 'next/link';
import { useRouter, useSearchParams } from 'next/navigation';
import { MelhfaInlineLoader } from '@/components/ui/MelhfaLoader';
const registerSchema = z.object({
username: z.string()
.min(3, 'Le nom d\'utilisateur doit contenir au moins 3 caractères')
.regex(/^[a-zA-Z0-9_]+$/, 'Le nom d\'utilisateur ne peut contenir que des lettres, chiffres et underscore'),
email: z.string().email('Adresse email invalide'),
password: z.string()
.min(8, 'Le mot de passe doit contenir au moins 8 caractères')
.regex(/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/, 'Le mot de passe doit contenir au moins une minuscule, une majuscule et un chiffre'),
confirmPassword: z.string(),
firstName: z.string().optional(),
lastName: z.string().optional(),
}).refine((data) => data.password === data.confirmPassword, {
message: "Les mots de passe ne correspondent pas",
path: ["confirmPassword"],
});
type RegisterFormData = z.infer<typeof registerSchema>;
export function RegisterForm() {
const { register: registerUser, isLoading } = useAuth();
const router = useRouter();
const searchParams = useSearchParams();
const [showPassword, setShowPassword] = useState(false);
const [showConfirmPassword, setShowConfirmPassword] = useState(false);
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState<string | null>(null);
const redirectTo = searchParams.get('redirect') || '/account';
const {
register,
handleSubmit,
formState: { errors, isSubmitting },
} = useForm<RegisterFormData>({
resolver: zodResolver(registerSchema),
});
const onSubmit = async (data: RegisterFormData) => {
setError(null);
setSuccess(null);
try {
const response = await registerUser({
username: data.username,
email: data.email,
password: data.password,
firstName: data.firstName,
lastName: data.lastName,
});
if (response.success) {
setSuccess('Compte créé avec succès ! Redirection...');
setTimeout(() => {
router.push(redirectTo);
}, 2000);
} else {
setError(response.message || 'Erreur lors de la création du compte');
}
} catch (err) {
setError('Une erreur inattendue s\'est produite');
}
};
return (
<Card className="w-full max-w-md mx-auto">
<CardHeader className="space-y-1">
<CardTitle className="text-2xl font-bold text-center">Créer un compte</CardTitle>
<CardDescription className="text-center">
Rejoignez-nous pour accéder à toutes nos fonctionnalités
</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
{error && (
<Alert variant="destructive">
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
{success && (
<Alert className="border-green-200 bg-green-50 text-green-800">
<AlertDescription>{success}</AlertDescription>
</Alert>
)}
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="firstName">Prénom</Label>
<Input
id="firstName"
type="text"
placeholder="Votre prénom"
{...register('firstName')}
/>
</div>
<div className="space-y-2">
<Label htmlFor="lastName">Nom</Label>
<Input
id="lastName"
type="text"
placeholder="Votre nom"
{...register('lastName')}
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="username">Nom d'utilisateur</Label>
<div className="relative">
<User className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 h-4 w-4" />
<Input
id="username"
type="text"
placeholder="Choisissez un nom d'utilisateur"
className="pl-10"
{...register('username')}
/>
</div>
{errors.username && (
<p className="text-sm text-red-600">{errors.username.message}</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="email">Email</Label>
<div className="relative">
<Mail className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 h-4 w-4" />
<Input
id="email"
type="email"
placeholder="votre@email.com"
className="pl-10"
{...register('email')}
/>
</div>
{errors.email && (
<p className="text-sm text-red-600">{errors.email.message}</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="password">Mot de passe</Label>
<div className="relative">
<Lock className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 h-4 w-4" />
<Input
id="password"
type={showPassword ? 'text' : 'password'}
placeholder="Créez un mot de passe sécurisé"
className="pl-10 pr-10"
{...register('password')}
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-3 top-1/2 transform -translate-y-1/2 text-gray-400 hover:text-gray-600"
>
{showPassword ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
</button>
</div>
{errors.password && (
<p className="text-sm text-red-600">{errors.password.message}</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="confirmPassword">Confirmer le mot de passe</Label>
<div className="relative">
<Lock className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 h-4 w-4" />
<Input
id="confirmPassword"
type={showConfirmPassword ? 'text' : 'password'}
placeholder="Confirmez votre mot de passe"
className="pl-10 pr-10"
{...register('confirmPassword')}
/>
<button
type="button"
onClick={() => setShowConfirmPassword(!showConfirmPassword)}
className="absolute right-3 top-1/2 transform -translate-y-1/2 text-gray-400 hover:text-gray-600"
>
{showConfirmPassword ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
</button>
</div>
{errors.confirmPassword && (
<p className="text-sm text-red-600">{errors.confirmPassword.message}</p>
)}
</div>
<Button
type="submit"
className="w-full"
disabled={isSubmitting || isLoading}
>
{(isSubmitting || isLoading) ? (
<MelhfaInlineLoader text="Création du compte..." size="sm" color="purple" />
) : (
'Créer mon compte'
)}
</Button>
<div className="text-center text-sm text-gray-600">
Déjà un compte ?{' '}
<Link
href={`/auth/login?redirect=${encodeURIComponent(redirectTo)}`}
className="text-blue-600 hover:text-blue-800 underline font-medium"
>
Se connecter
</Link>
</div>
</form>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,145 @@
// src/components/cart/CartNotification.tsx
'use client';
import { useEffect, useState } from 'react';
import { formatPrice } from '@/lib/woocommerce';
import { Button } from '@/components/ui/button';
import { X, CheckCircle, ShoppingBag } from 'lucide-react';
import Image from 'next/image';
import Link from 'next/link';
import { cn } from '@/lib/utils';
interface CartNotificationProps {
isVisible: boolean;
onClose: () => void;
item?: {
name: string;
price: string;
image: string;
quantity: number;
};
}
export function CartNotification({ isVisible, onClose, item }: CartNotificationProps) {
const [isAnimating, setIsAnimating] = useState(false);
useEffect(() => {
if (isVisible) {
setIsAnimating(true);
// Auto-fermeture après 4 secondes
const timer = setTimeout(() => {
onClose();
}, 4000);
return () => clearTimeout(timer);
} else {
setIsAnimating(false);
}
}, [isVisible, onClose]);
if (!isVisible || !item) return null;
return (
<div className="fixed top-20 right-4 z-[70] max-w-sm">
<div
className={cn(
"bg-white border border-gray-200 rounded-lg shadow-lg p-4 transition-all duration-300",
isAnimating
? "transform translate-x-0 opacity-100"
: "transform translate-x-full opacity-0"
)}
>
{/* Header */}
<div className="flex items-center justify-between mb-3">
<div className="flex items-center space-x-2">
<CheckCircle className="w-5 h-5 text-green-500" />
<span className="font-medium text-sm">Ajouté au panier</span>
</div>
<button
onClick={onClose}
className="p-1 hover:bg-gray-100 rounded"
>
<X className="w-4 h-4 text-gray-400" />
</button>
</div>
{/* Product */}
<div className="flex gap-3 mb-4">
<div className="relative w-16 h-16 flex-shrink-0">
<Image
src={item.image}
alt={item.name}
fill
className="object-cover rounded"
sizes="64px"
/>
</div>
<div className="flex-1 min-w-0">
<h4 className="text-sm font-medium text-gray-900 truncate mb-1">
{item.name}
</h4>
<p className="text-sm text-gray-500">
Quantité: {item.quantity}
</p>
<p className="text-sm font-medium">
{formatPrice(parseFloat(item.price) * item.quantity)}
</p>
</div>
</div>
{/* Actions */}
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={onClose}
className="flex-1"
>
Continuer
</Button>
<Link href="/panier" className="flex-1">
<Button size="sm" className="w-full bg-black text-white hover:bg-gray-800">
<ShoppingBag className="w-4 h-4 mr-2" />
Voir panier
</Button>
</Link>
</div>
</div>
</div>
);
}
// Hook pour gérer les notifications
export function useCartNotification() {
const [notification, setNotification] = useState<{
isVisible: boolean;
item?: {
name: string;
price: string;
image: string;
quantity: number;
};
}>({ isVisible: false });
const showNotification = (item: {
name: string;
price: string;
image: string;
quantity: number;
}) => {
console.log('📢 Affichage notification pour:', item.name);
setNotification({ isVisible: true, item });
};
const hideNotification = () => {
console.log('❌ Fermeture notification');
setNotification({ isVisible: false });
};
return {
notification,
showNotification,
hideNotification
};
}

View File

@@ -0,0 +1,204 @@
// src/components/cart/CartPreview.tsx
'use client';
import { useCart } from '@/contexts/CartContext'; // Import depuis le Context - CORRIGÉ
import { Button } from '@/components/ui/button';
import { ShoppingBag, ArrowRight, X, Plus, Minus } from 'lucide-react';
import Image from 'next/image';
import Link from 'next/link';
import { formatPrice } from '@/lib/woocommerce';
interface CartPreviewProps {
isOpen: boolean;
onClose: () => void;
onMouseEnter?: () => void;
onMouseLeave?: () => void;
}
export function CartPreview({ isOpen, onClose, onMouseEnter, onMouseLeave }: CartPreviewProps) {
const { cart, removeFromCart, updateQuantity } = useCart();
const handleQuantityChange = (itemId: number, newQuantity: number) => {
if (newQuantity <= 0) {
removeFromCart(itemId);
} else {
updateQuantity(itemId, newQuantity);
}
};
if (!isOpen) return null;
if (!cart || cart.items.length === 0) {
return (
<div
className="absolute top-full right-0 mt-2 w-80 bg-white border border-gray-200 shadow-lg rounded-lg z-[60]"
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
>
<div className="p-6 text-center">
<ShoppingBag className="w-12 h-12 text-gray-300 mx-auto mb-4" />
<h3 className="text-lg font-light mb-2">Votre panier est vide</h3>
<p className="text-sm text-gray-500 mb-4">
Ajoutez des produits pour commencer vos achats
</p>
<Link href="/boutique" onClick={onClose}>
<Button className="w-full bg-black text-white hover:bg-gray-800">
Découvrir la boutique
</Button>
</Link>
</div>
</div>
);
}
const subtotal = cart.total || 0;
const shipping = subtotal >= 50000 ? 0 : 5000;
const total = subtotal + shipping;
console.log('🔍 CartPreview render - cart:', cart, 'itemCount:', cart.itemCount);
return (
<div
className="absolute top-full right-0 mt-2 w-96 bg-white border border-gray-200 shadow-xl rounded-lg z-[60]"
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
>
{/* Header */}
<div className="flex items-center justify-between p-4 border-b border-gray-100">
<h3 className="font-light text-lg">
Panier ({cart.itemCount} article{cart.itemCount > 1 ? 's' : ''})
</h3>
<button
onClick={onClose}
className="p-1 hover:bg-gray-100 rounded"
>
<X className="w-4 h-4" />
</button>
</div>
{/* Items avec contrôles */}
<div className="max-h-80 overflow-y-auto">
{cart.items.slice(0, 4).map((item) => (
<div key={item.id} className="p-4 border-b border-gray-50 hover:bg-gray-25">
<div className="flex gap-3 mb-3">
{/* Image */}
<div className="relative w-16 h-16 flex-shrink-0">
<Image
src={item.image}
alt={item.name}
fill
className="object-cover rounded"
sizes="64px"
/>
</div>
{/* Détails */}
<div className="flex-1 min-w-0">
<h4 className="text-sm font-light text-black truncate mb-1">
{item.name}
</h4>
<div className="text-sm font-medium">
{formatPrice(item.total)}
</div>
</div>
{/* Bouton supprimer */}
<button
onClick={() => {
console.log('🗑️ CartPreview - Suppression item:', item.id);
removeFromCart(item.id);
}}
className="p-1 hover:bg-gray-100 rounded text-red-500 hover:text-red-700"
>
<X className="w-3 h-3" />
</button>
</div>
{/* Contrôles de quantité */}
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() => handleQuantityChange(item.id, item.quantity - 1)}
className="h-7 w-7 p-0"
>
<Minus className="w-3 h-3" />
</Button>
<span className="px-2 py-1 bg-gray-100 rounded text-xs font-medium min-w-[2rem] text-center">
{item.quantity}
</span>
<Button
variant="outline"
size="sm"
onClick={() => handleQuantityChange(item.id, item.quantity + 1)}
className="h-7 w-7 p-0"
>
<Plus className="w-3 h-3" />
</Button>
</div>
</div>
))}
{/* Plus d'articles... */}
{cart.items.length > 4 && (
<div className="p-3 text-center text-sm text-gray-500">
+{cart.items.length - 4} autre{cart.items.length - 4 > 1 ? 's' : ''} article{cart.items.length - 4 > 1 ? 's' : ''}
</div>
)}
</div>
{/* Totaux */}
<div className="p-4 border-t border-gray-100 space-y-2">
<div className="flex justify-between text-sm">
<span>Sous-total</span>
<span>{formatPrice(subtotal)}</span>
</div>
{shipping > 0 && (
<div className="flex justify-between text-sm">
<span>Livraison</span>
<span>{formatPrice(shipping)}</span>
</div>
)}
{shipping === 0 && subtotal > 0 && (
<div className="flex justify-between text-sm text-green-600">
<span>Livraison</span>
<span>Gratuite</span>
</div>
)}
<div className="border-t border-gray-100 pt-2">
<div className="flex justify-between font-medium">
<span>Total</span>
<span>{formatPrice(total)}</span>
</div>
</div>
{shipping > 0 && (
<div className="text-xs text-gray-500 text-center">
Livraison gratuite à partir de {formatPrice(50000)}
</div>
)}
</div>
{/* Actions */}
<div className="p-4 space-y-2">
<Link href="/panier" onClick={onClose}>
<Button variant="outline" className="w-full">
Voir le panier complet
</Button>
</Link>
<Link href="/checkout" onClick={onClose}>
<Button className="w-full bg-black text-white hover:bg-gray-800">
Commander maintenant
<ArrowRight className="w-4 h-4 ml-2" />
</Button>
</Link>
</div>
</div>
);
}

View File

@@ -0,0 +1,75 @@
// src/components/cart/CartSyncProvider.tsx
'use client';
import { createContext, useContext, useEffect, useState, ReactNode } from 'react';
import { useCartActions } from '@/hooks/useCartSync';
interface CartSyncContextType {
lastUpdate: number;
forceSync: () => void;
}
const CartSyncContext = createContext<CartSyncContextType | undefined>(undefined);
interface CartSyncProviderProps {
children: ReactNode;
}
export function CartSyncProvider({ children }: CartSyncProviderProps) {
const [lastUpdate, setLastUpdate] = useState(Date.now());
const { cart } = useCart();
// Forcer une synchronisation
const forceSync = () => {
setLastUpdate(Date.now());
// Déclencher un événement global
window.dispatchEvent(new CustomEvent('forceCartSync', {
detail: { timestamp: Date.now() }
}));
};
// Écouter les changements du panier
useEffect(() => {
const handleCartChange = () => {
setLastUpdate(Date.now());
};
// Écouter les événements personnalisés
window.addEventListener('cartUpdated', handleCartChange);
window.addEventListener('forceCartSync', handleCartChange);
// Écouter les changements de localStorage (synchronisation entre onglets)
const handleStorageChange = (e: StorageEvent) => {
if (e.key === 'melhfa_cart') {
handleCartChange();
}
};
window.addEventListener('storage', handleStorageChange);
return () => {
window.removeEventListener('cartUpdated', handleCartChange);
window.removeEventListener('forceCartSync', handleCartChange);
window.removeEventListener('storage', handleStorageChange);
};
}, []);
// Déclencher une synchronisation quand le panier change
useEffect(() => {
forceSync();
}, [cart.itemCount, cart.total]);
return (
<CartSyncContext.Provider value={{ lastUpdate, forceSync }}>
{children}
</CartSyncContext.Provider>
);
}
export function useCartSyncContext() {
const context = useContext(CartSyncContext);
if (context === undefined) {
throw new Error('useCartSyncContext must be used within a CartSyncProvider');
}
return context;
}

View File

@@ -0,0 +1,55 @@
// src/components/layout/Footer.tsx
import Link from 'next/link';
export function Footer() {
return (
<footer className="bg-gray-900 text-white py-12">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="grid grid-cols-1 md:grid-cols-4 gap-8">
<div>
<h3 className="text-lg font-semibold mb-4">Melhfa Store</h3>
<p className="text-gray-400 text-sm">
Votre destination pour les melhfa mauritaniennes authentiques et de qualité premium.
</p>
</div>
<div>
<h4 className="font-semibold mb-4">Navigation</h4>
<ul className="space-y-2 text-sm">
<li><Link href="/" className="text-gray-400 hover:text-white">Accueil</Link></li>
<li><Link href="/boutique" className="text-gray-400 hover:text-white">Boutique</Link></li>
<li><Link href="/collections" className="text-gray-400 hover:text-white">Collections</Link></li>
<li><Link href="/about" className="text-gray-400 hover:text-white">À propos</Link></li>
</ul>
</div>
<div>
<h4 className="font-semibold mb-4">Compte</h4>
<ul className="space-y-2 text-sm">
<li><Link href="/auth/login" className="text-gray-400 hover:text-white">Connexion</Link></li>
<li><Link href="/auth/register" className="text-gray-400 hover:text-white">Inscription</Link></li>
<li><Link href="/account" className="text-gray-400 hover:text-white">Mon compte</Link></li>
<li><Link href="/panier" className="text-gray-400 hover:text-white">Panier</Link></li>
</ul>
</div>
<div>
<h4 className="font-semibold mb-4">Contact</h4>
<ul className="space-y-2 text-sm text-gray-400">
<li>Email: contact@melhfastore.com</li>
<li>Tél: +222 XX XX XX XX</li>
<li>Nouakchott, Mauritanie</li>
</ul>
</div>
</div>
<div className="border-t border-gray-800 mt-8 pt-8 text-center text-sm text-gray-400">
<p>&copy; 2025 Melhfa Store. Tous droits réservés.</p>
</div>
</div>
</footer>
);
}

View File

@@ -0,0 +1,213 @@
// src/components/layout/Header.tsx
'use client';
import { useState, useEffect } from 'react';
import Link from 'next/link';
import { useCartActions } from '@/hooks/useCartSync';import { ShoppingBag, Menu, X, Search, User } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import {
Sheet,
SheetContent,
SheetTrigger,
} from '@/components/ui/sheet';
interface NavigationItem {
name: string;
href: string;
}
const navigation: NavigationItem[] = [
{ name: 'Nouvelles Arrivées', href: '/boutique?filter=new' },
{ name: 'Collections', href: '/collections' },
{ name: 'Promotions', href: '/boutique?filter=sale' },
{ name: 'Accessoires', href: '/accessoires' },
];
export default function Header(): JSX.Element {
const { getItemCount } = useCart();
const [isScrolled, setIsScrolled] = useState(false);
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
const itemCount = getItemCount();
useEffect(() => {
const handleScroll = (): void => {
setIsScrolled(window.scrollY > 100);
};
window.addEventListener('scroll', handleScroll);
return () => window.removeEventListener('scroll', handleScroll);
}, []);
return (
<header
className={`fixed top-0 w-full z-50 transition-all duration-300 ${isScrolled
? 'bg-white/98 backdrop-blur-xl shadow-sm border-b border-black/5'
: 'bg-white/95 backdrop-blur-xl border-b border-black/5'
}`}
>
<div className="max-w-[1400px] mx-auto px-6">
<div className="flex items-center justify-between py-3">
{/* Logo */}
<Link
href="/"
className="text-2xl font-semibold tracking-[3px] text-black hover:opacity-70 transition-opacity"
>
MELHFA
</Link>
{/* Navigation Desktop */}
<nav className="hidden lg:flex items-center space-x-10">
{navigation.map((item) => (
<Link
key={item.name}
href={item.href}
className="text-sm font-normal text-black hover:opacity-60 transition-opacity tracking-wide uppercase"
>
{item.name}
</Link>
))}
</nav>
{/* Actions Desktop */}
<div className="hidden lg:flex items-center space-x-6">
<Button
variant="ghost"
size="sm"
className="text-sm font-normal tracking-wide uppercase text-black hover:bg-transparent hover:opacity-60"
>
<Search className="w-4 h-4 mr-2" />
Recherche
</Button>
<Button
variant="ghost"
size="sm"
className="text-sm font-normal tracking-wide uppercase text-black hover:bg-transparent hover:opacity-60"
>
<User className="w-4 h-4 mr-2" />
Connexion
</Button>
<Button
variant="ghost"
size="sm"
className="text-sm font-normal tracking-wide uppercase text-black hover:bg-transparent hover:opacity-60"
>
Aide
</Button>
<Link href="/panier" className="relative">
<Button
variant="ghost"
size="sm"
className="p-2 hover:bg-transparent hover:opacity-60"
>
<ShoppingBag className="w-5 h-5" />
{itemCount > 0 && (
<Badge
variant="destructive"
className="absolute -top-2 -right-2 w-5 h-5 rounded-full text-xs flex items-center justify-center p-0"
>
{itemCount}
</Badge>
)}
</Button>
</Link>
</div>
{/* Menu Mobile */}
<div className="lg:hidden flex items-center space-x-4">
<Link href="/panier" className="relative">
<Button
variant="ghost"
size="sm"
className="p-2 hover:bg-transparent hover:opacity-60"
>
<ShoppingBag className="w-5 h-5" />
{itemCount > 0 && (
<Badge
variant="destructive"
className="absolute -top-2 -right-2 w-5 h-5 rounded-full text-xs flex items-center justify-center p-0"
>
{itemCount}
</Badge>
)}
</Button>
</Link>
<Sheet open={isMobileMenuOpen} onOpenChange={setIsMobileMenuOpen}>
<SheetTrigger asChild>
<Button
variant="ghost"
size="sm"
className="p-2 hover:bg-transparent hover:opacity-60"
>
<Menu className="w-5 h-5" />
</Button>
</SheetTrigger>
<SheetContent side="right" className="w-80 p-0">
<div className="flex flex-col h-full">
{/* Header du menu mobile */}
<div className="flex items-center justify-between p-6 border-b border-gray-200">
<span className="text-lg font-semibold tracking-wide">Menu</span>
<Button
variant="ghost"
size="sm"
onClick={() => setIsMobileMenuOpen(false)}
className="p-2"
>
<X className="w-5 h-5" />
</Button>
</div>
{/* Navigation mobile */}
<nav className="flex-1 p-6">
<div className="space-y-6">
{navigation.map((item) => (
<Link
key={item.name}
href={item.href}
onClick={() => setIsMobileMenuOpen(false)}
className="block text-base font-normal text-black hover:opacity-60 transition-opacity tracking-wide uppercase"
>
{item.name}
</Link>
))}
</div>
<div className="mt-8 pt-8 border-t border-gray-200 space-y-4">
<Button
variant="ghost"
className="w-full justify-start text-sm font-normal tracking-wide uppercase text-black hover:bg-transparent hover:opacity-60"
>
<Search className="w-4 h-4 mr-3" />
Recherche
</Button>
<Button
variant="ghost"
className="w-full justify-start text-sm font-normal tracking-wide uppercase text-black hover:bg-transparent hover:opacity-60"
>
<User className="w-4 h-4 mr-3" />
Connexion
</Button>
<Button
variant="ghost"
className="w-full justify-start text-sm font-normal tracking-wide uppercase text-black hover:bg-transparent hover:opacity-60"
>
Aide
</Button>
</div>
</nav>
</div>
</SheetContent>
</Sheet>
</div>
</div>
</div>
</header>
);
}

View File

@@ -0,0 +1,429 @@
// src/components/layout/Navbar.tsx
'use client';
import { useState, useEffect } from 'react';
import Link from 'next/link';
import { useAuth } from '@/hooks/useAuth';
import { useCart } from '@/contexts/CartContext'; // Import depuis le Context
import { useHydration } from '@/hooks/useHydration';
import { Button } from '@/components/ui/button';
import {
Menu,
X,
User,
ShoppingBag,
Search,
LogOut,
Settings,
Package,
Heart
} from 'lucide-react';
import { Badge } from '@/components/ui/badge';
import { CartPreview } from '@/components/cart/CartPreview';
interface NavigationItem {
name: string;
href: string;
children?: NavigationItem[];
}
const navigation: NavigationItem[] = [
{
name: 'FEMMES',
href: '/femmes',
children: [
{ name: 'NOUVELLES ARRIVÉES', href: '/femmes/nouvelles-arrivees' },
{ name: 'VOILES TRADITIONNELLES', href: '/femmes/voiles-traditionnelles' },
{ name: 'COLLECTION MODERNE', href: '/femmes/collection-moderne' },
{ name: 'OCCASIONS SPÉCIALES', href: '/femmes/occasions-speciales' },
]
},
{
name: 'HOMMES',
href: '/hommes',
children: [
{ name: 'NOUVEAUTÉS', href: '/hommes/nouveautes' },
{ name: 'BOUBOUS', href: '/hommes/boubous' },
{ name: 'ACCESSOIRES', href: '/hommes/accessoires' },
]
},
{
name: 'ENFANTS',
href: '/enfants',
children: [
{ name: 'FILLES', href: '/enfants/filles' },
{ name: 'GARÇONS', href: '/enfants/garcons' },
{ name: 'NOUVEAUTÉS ENFANTS', href: '/enfants/nouveautes' },
]
},
{
name: 'MAISON',
href: '/maison',
children: [
{ name: 'DÉCORATION', href: '/maison/decoration' },
{ name: 'TEXTILES', href: '/maison/textiles' },
{ name: 'ARTISANAT', href: '/maison/artisanat' },
]
},
{
name: 'COLLECTIONS',
href: '/collections',
children: [
{ name: 'NOUVELLE COLLECTION', href: '/collections/nouvelle' },
{ name: 'HÉRITAGE MAURITANIEN', href: '/collections/heritage' },
{ name: 'ÉDITION LIMITÉE', href: '/collections/edition-limitee' },
]
}
];
const secondaryLinks = [
{ name: 'MEILLEURES VENTES', href: '/meilleures-ventes' },
{ name: 'PRIX SPÉCIAUX', href: '/promotions' },
{ name: 'ARTISANS', href: '/artisans' },
{ name: 'À PROPOS', href: '/a-propos' }
];
export function Navbar() {
const { user, isAuthenticated, logout, isLoading } = useAuth();
const { cart, getItemCount } = useCart(); // Utilise le Context maintenant
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
const [isScrolled, setIsScrolled] = useState(false);
const [isCartPreviewOpen, setIsCartPreviewOpen] = useState(false);
const isHydrated = useHydration();
// Le compteur vient directement du Context partagé
const cartCount = cart?.itemCount || 0;
console.log('🏠 Navbar Context - cartCount:', cartCount, 'cart:', cart);
// Délai pour fermer l'aperçu panier
const [closeTimeout, setCloseTimeout] = useState<NodeJS.Timeout | null>(null);
const handleMouseEnterCart = () => {
if (closeTimeout) {
clearTimeout(closeTimeout);
setCloseTimeout(null);
}
setIsCartPreviewOpen(true);
};
const handleMouseLeaveCart = () => {
const timeout = setTimeout(() => {
setIsCartPreviewOpen(false);
}, 200); // Délai de 200ms pour permettre le passage vers l'aperçu
setCloseTimeout(timeout);
};
useEffect(() => {
const handleScroll = (): void => {
setIsScrolled(window.scrollY > 20);
};
window.addEventListener('scroll', handleScroll);
return () => window.removeEventListener('scroll', handleScroll);
}, []);
// Nettoyer le timeout au démontage
useEffect(() => {
return () => {
if (closeTimeout) {
clearTimeout(closeTimeout);
}
};
}, [closeTimeout]);
// Bloquer le scroll quand le menu est ouvert
useEffect(() => {
if (isMobileMenuOpen) {
document.body.style.overflow = 'hidden';
} else {
document.body.style.overflow = 'unset';
}
return () => {
document.body.style.overflow = 'unset';
};
}, [isMobileMenuOpen]);
return (
<>
<nav
className={`fixed top-0 w-full z-50 transition-all duration-300 ${
isScrolled
? 'bg-white/95 backdrop-blur-md shadow-sm'
: 'bg-white/90 backdrop-blur-sm'
}`}
>
<div className="max-w-[1400px] mx-auto px-4 md:px-6">
<div className="flex items-center justify-between h-16">
{/* Menu Burger - À gauche comme Zara */}
<Button
variant="ghost"
size="sm"
onClick={() => setIsMobileMenuOpen(true)}
className="p-2 hover:bg-transparent hover:opacity-60"
>
<Menu className="w-5 h-5" />
</Button>
{/* Logo Central */}
<Link
href="/"
className="absolute left-1/2 transform -translate-x-1/2 text-2xl md:text-3xl font-light tracking-[4px] text-black hover:opacity-70 transition-opacity"
>
MELHFA
</Link>
{/* Actions à droite */}
<div className="flex items-center space-x-2 md:space-x-4">
<Button
variant="ghost"
size="sm"
className="p-2 hover:bg-transparent hover:opacity-60"
>
<Search className="w-5 h-5" />
</Button>
{/* Compte utilisateur */}
{isHydrated && !isLoading && (
<>
{isAuthenticated && user ? (
<Link href="/account">
<Button
variant="ghost"
size="sm"
className="p-2 hover:bg-transparent hover:opacity-60"
>
<User className="w-5 h-5" />
</Button>
</Link>
) : (
<Link href="/connexion">
<Button
variant="ghost"
size="sm"
className="p-2 hover:bg-transparent hover:opacity-60"
>
<User className="w-5 h-5" />
</Button>
</Link>
)}
</>
)}
{/* Panier avec hover amélioré */}
<div
className="relative"
onMouseEnter={handleMouseEnterCart}
onMouseLeave={handleMouseLeaveCart}
>
<Link href="/panier">
<Button
variant="ghost"
size="sm"
className="p-2 hover:bg-transparent hover:opacity-60 relative"
>
<ShoppingBag className="w-5 h-5" />
{cartCount > 0 && (
<Badge
variant="default"
className="absolute -top-1 -right-1 h-5 w-5 flex items-center justify-center text-xs bg-black text-white rounded-full"
>
{cartCount}
</Badge>
)}
</Button>
</Link>
<CartPreview
isOpen={isCartPreviewOpen}
onClose={() => setIsCartPreviewOpen(false)}
onMouseEnter={handleMouseEnterCart}
onMouseLeave={handleMouseLeaveCart}
/>
</div>
</div>
</div>
</div>
</nav>
{/* Menu Overlay - Style Zara */}
{isMobileMenuOpen && (
<div className="fixed inset-0 z-[100] bg-white">
{/* Header du menu - Position exacte comme la navbar */}
<div className="max-w-[1400px] mx-auto px-4 md:px-6">
<div className="flex items-center justify-between h-16">
{/* Bouton X à la même position que le burger */}
<Button
variant="ghost"
size="sm"
onClick={() => setIsMobileMenuOpen(false)}
className="p-2 hover:bg-transparent hover:opacity-60"
>
<X className="w-5 h-5" />
</Button>
{/* Logo Central - même position */}
<Link
href="/"
onClick={() => setIsMobileMenuOpen(false)}
className="absolute left-1/2 transform -translate-x-1/2 text-2xl md:text-3xl font-light tracking-[4px] text-black"
>
MELHFA
</Link>
{/* Spacer pour équilibrer */}
<div className="w-8" />
</div>
</div>
{/* Ligne de séparation */}
<div className="border-b border-gray-100"></div>
{/* Contenu du menu */}
<div className="flex h-[calc(100vh-64px)]">
{/* Navigation principale - Gauche */}
<div className="flex-1 p-6 md:p-8 overflow-y-auto">
<nav className="space-y-8">
{/* Catégories principales */}
<div className="space-y-6">
{navigation.map((category) => (
<div key={category.name} className="space-y-3">
<Link
href={category.href}
onClick={() => setIsMobileMenuOpen(false)}
className="block text-lg md:text-xl font-light tracking-wide text-black hover:opacity-60 transition-opacity"
>
{category.name}
</Link>
{/* Sous-catégories */}
{category.children && (
<div className="space-y-2 ml-4">
{category.children.map((subItem) => (
<Link
key={subItem.name}
href={subItem.href}
onClick={() => setIsMobileMenuOpen(false)}
className="block text-sm font-light tracking-wide text-gray-600 hover:text-black transition-colors uppercase"
>
{subItem.name}
</Link>
))}
</div>
)}
</div>
))}
</div>
{/* Séparateur */}
<div className="border-t border-gray-200 pt-6">
<div className="space-y-4">
{secondaryLinks.map((link) => (
<Link
key={link.name}
href={link.href}
onClick={() => setIsMobileMenuOpen(false)}
className="block text-sm font-light tracking-wide text-black hover:opacity-60 transition-opacity uppercase"
>
{link.name}
</Link>
))}
</div>
</div>
{/* Actions utilisateur */}
<div className="border-t border-gray-200 pt-6 space-y-4">
{isHydrated && !isLoading && (
<>
{isAuthenticated && user ? (
<>
<Link
href="/account"
onClick={() => setIsMobileMenuOpen(false)}
className="flex items-center space-x-3 text-sm font-light tracking-wide text-black hover:opacity-60 transition-opacity uppercase"
>
<User className="w-4 h-4" />
<span>MON COMPTE</span>
</Link>
<Link
href="/account?tab=orders"
onClick={() => setIsMobileMenuOpen(false)}
className="flex items-center space-x-3 text-sm font-light tracking-wide text-black hover:opacity-60 transition-opacity uppercase"
>
<Package className="w-4 h-4" />
<span>MES COMMANDES</span>
</Link>
<Link
href="/wishlist"
onClick={() => setIsMobileMenuOpen(false)}
className="flex items-center space-x-3 text-sm font-light tracking-wide text-black hover:opacity-60 transition-opacity uppercase"
>
<Heart className="w-4 h-4" />
<span>LISTE DE SOUHAITS</span>
</Link>
<button
onClick={() => {
logout();
setIsMobileMenuOpen(false);
}}
className="flex items-center space-x-3 text-sm font-light tracking-wide text-black hover:opacity-60 transition-opacity uppercase"
>
<LogOut className="w-4 h-4" />
<span>SE DÉCONNECTER</span>
</button>
</>
) : (
<Link
href="/connexion"
onClick={() => setIsMobileMenuOpen(false)}
className="flex items-center space-x-3 text-sm font-light tracking-wide text-black hover:opacity-60 transition-opacity uppercase"
>
<User className="w-4 h-4" />
<span>SE CONNECTER</span>
</Link>
)}
</>
)}
<Link
href="/aide"
onClick={() => setIsMobileMenuOpen(false)}
className="block text-sm font-light tracking-wide text-black hover:opacity-60 transition-opacity uppercase"
>
AIDE
</Link>
</div>
</nav>
</div>
{/* Image ou contenu à droite - optionnel comme Zara */}
<div className="hidden lg:block w-1/2 bg-gray-50">
<div className="h-full flex items-center justify-center p-8">
<div className="text-center space-y-4">
<h3 className="text-2xl font-light tracking-wide text-black">
NOUVELLE COLLECTION
</h3>
<p className="text-sm text-gray-600 max-w-xs">
Découvrez notre dernière collection de voiles mauritaniennes traditionnelles et modernes.
</p>
<Link
href="/boutique"
onClick={() => setIsMobileMenuOpen(false)}
className="inline-block border border-black px-6 py-2 text-sm font-light tracking-wide hover:bg-black hover:text-white transition-colors"
>
DÉCOUVRIR
</Link>
</div>
</div>
</div>
</div>
</div>
)}
</>
);
}

View File

@@ -0,0 +1,95 @@
// src/components/layout/SiteLoader.tsx
'use client';
import { useState, useEffect } from 'react';
import { MelhfaLoader } from '@/components/ui/MelhfaLoader';
interface SiteLoaderProps {
children: React.ReactNode;
minLoadingTime?: number; // durée minimale en ms
}
export function SiteLoader({
children,
minLoadingTime = 2000
}: SiteLoaderProps) {
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
const timer = setTimeout(() => {
setIsLoading(false);
}, minLoadingTime);
return () => clearTimeout(timer);
}, [minLoadingTime]);
if (isLoading) {
return (
<div className="fixed inset-0 bg-white flex items-center justify-center z-50">
<div className="flex flex-col items-center space-y-8">
{/* Logo simple */}
<h1 className="text-4xl md:text-5xl font-bold text-gray-800 tracking-wider">
MELHFA
</h1>
{/* Loader voile uniquement */}
<MelhfaLoader size="xl" color="purple" />
</div>
</div>
);
}
return <>{children}</>;
}
// Version encore plus minimaliste - juste le voile
export function MinimalLoader({ children }: { children: React.ReactNode }) {
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
const timer = setTimeout(() => {
setIsLoading(false);
}, 1500);
return () => clearTimeout(timer);
}, []);
if (isLoading) {
return (
<div className="fixed inset-0 bg-white flex items-center justify-center z-50">
<MelhfaLoader size="xl" color="purple" />
</div>
);
}
return <>{children}</>;
}
// Version avec juste le logo et le voile, arrière-plan épuré
export function CleanLoader({ children }: { children: React.ReactNode }) {
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
const timer = setTimeout(() => {
setIsLoading(false);
}, 2000);
return () => clearTimeout(timer);
}, []);
if (isLoading) {
return (
<div className="fixed inset-0 bg-gradient-to-br from-gray-50 to-white flex items-center justify-center z-50">
<div className="text-center space-y-6">
<div className="text-3xl font-light text-gray-700 tracking-[0.3em]">
MELHFA
</div>
<MelhfaLoader size="lg" color="purple" />
</div>
</div>
);
}
return <>{children}</>;
}

View File

@@ -0,0 +1,276 @@
// src/components/product/ProductCard.tsx
'use client';
import { useState } from 'react';
import Link from 'next/link';
import Image from 'next/image';
import { WooCommerceProduct } from '@/types/woocommerce';
import { useCart } from '@/contexts/CartContext';
import { formatPrice, isOnSale, getDiscountPercentage, getColorHex } from '@/lib/woocommerce';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Eye, ShoppingBag, Heart, Plus, Minus, Trash2 } from 'lucide-react';
import { cn } from '@/lib/utils';
interface ProductCardProps {
product: WooCommerceProduct;
className?: string;
showQuickView?: boolean;
}
interface ColorOption {
name: string;
hex: string;
}
export default function ProductCard({
product,
className = '',
showQuickView = true
}: ProductCardProps): JSX.Element {
const { addToCart, isInCart, getItemQuantity, updateQuantity, removeFromCart } = useCart();
const [isHovered, setIsHovered] = useState(false);
const [selectedColor, setSelectedColor] = useState<string | null>(null);
const [isAddingToCart, setIsAddingToCart] = useState(false);
const primaryImage = product.images?.[0]?.src || '/placeholder-product.jpg';
const secondaryImage = product.images?.[1]?.src || primaryImage;
const discountPercentage = getDiscountPercentage(product);
const productInCart = isInCart(product.id);
const currentQuantity = getItemQuantity(product.id);
// Extraire les couleurs des attributs du produit
const colorAttribute = product.attributes?.find(attr =>
attr.name.toLowerCase().includes('couleur') ||
attr.name.toLowerCase().includes('color')
);
const colorOptions: ColorOption[] = colorAttribute?.options.map(color => ({
name: color,
hex: getColorHex(color),
})) || [];
const handleAddToCart = async (): Promise<void> => {
if (isAddingToCart) return;
setIsAddingToCart(true);
console.log('🛒 ProductCard - Tentative d\'ajout:', product.name);
try {
addToCart({
id: product.id,
name: product.name,
price: product.sale_price || product.regular_price,
image: primaryImage,
});
console.log('✅ ProductCard - Produit ajouté avec succès');
await new Promise(resolve => setTimeout(resolve, 300));
} catch (error) {
console.error('❌ ProductCard - Erreur ajout:', error);
} finally {
setIsAddingToCart(false);
}
};
const handleQuantityChange = (newQuantity: number) => {
if (newQuantity <= 0) {
removeFromCart(product.id);
} else {
updateQuantity(product.id, newQuantity);
}
};
const handleRemoveFromCart = () => {
removeFromCart(product.id);
};
return (
<div
className={cn(
"group relative bg-white transition-all duration-300 hover:shadow-lg",
className
)}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
>
{/* Image Container */}
<div className="relative aspect-[3/4] overflow-hidden bg-gray-100">
<Link href={`/produit/${product.slug}`}>
<Image
src={isHovered && secondaryImage !== primaryImage ? secondaryImage : primaryImage}
alt={product.name}
fill
className="object-cover transition-all duration-500 group-hover:scale-105"
sizes="(max-width: 768px) 50vw, (max-width: 1200px) 33vw, 25vw"
/>
</Link>
{/* Badges */}
<div className="absolute top-4 left-4 flex flex-col gap-2">
{isOnSale(product) && (
<Badge className="bg-red-500 text-white text-xs px-2 py-1">
-{discountPercentage}%
</Badge>
)}
{product.featured && (
<Badge className="bg-black text-white text-xs px-2 py-1">
Nouvelle Collection
</Badge>
)}
</div>
{/* Quick Actions Overlay */}
{showQuickView && (
<div
className={cn(
"absolute inset-0 bg-black/10 flex items-center justify-center transition-opacity duration-300",
isHovered ? "opacity-100" : "opacity-0"
)}
>
<div className="flex gap-2">
<Button
variant="secondary"
size="sm"
className="bg-white hover:bg-gray-100 shadow-md"
asChild
>
<Link href={`/produit/${product.slug}`}>
<Eye className="w-4 h-4 mr-2" />
Aperçu rapide
</Link>
</Button>
</div>
</div>
)}
{/* Wishlist Button */}
<Button
variant="ghost"
size="sm"
className={cn(
"absolute top-4 right-4 w-8 h-8 p-0 bg-white/80 hover:bg-white transition-all duration-300",
isHovered ? "opacity-100 scale-100" : "opacity-0 scale-75"
)}
>
<Heart className="w-4 h-4" />
</Button>
</div>
{/* Product Info */}
<div className="p-4 space-y-3">
<div>
<Link
href={`/produit/${product.slug}`}
className="group"
>
<h3 className="text-sm font-medium text-black group-hover:opacity-70 transition-opacity tracking-wide uppercase">
{product.name}
</h3>
</Link>
{/* Prix */}
<div className="flex items-center gap-2 mt-1">
{isOnSale(product) ? (
<>
<span className="text-base font-medium text-black">
{formatPrice(product.sale_price)}
</span>
<span className="text-sm text-gray-500 line-through">
{formatPrice(product.regular_price)}
</span>
</>
) : (
<span className="text-base font-medium text-black">
{formatPrice(product.price)}
</span>
)}
</div>
</div>
{/* Couleurs disponibles */}
{colorOptions.length > 0 && (
<div className="flex items-center gap-2">
{colorOptions.slice(0, 4).map((color, index) => (
<button
key={index}
onClick={() => setSelectedColor(color.name)}
className={cn(
"w-4 h-4 rounded-full border-2 transition-all duration-200 hover:scale-110",
selectedColor === color.name
? "border-black shadow-md"
: "border-gray-200"
)}
style={{ backgroundColor: color.hex }}
title={color.name}
/>
))}
{colorOptions.length > 4 && (
<span className="text-xs text-gray-500 ml-1">
+{colorOptions.length - 4}
</span>
)}
</div>
)}
{/* Cart Actions */}
{!productInCart ? (
/* Bouton d'ajout normal */
<Button
onClick={handleAddToCart}
disabled={isAddingToCart || !product.purchasable}
className="w-full bg-black text-white hover:bg-gray-800 text-xs uppercase tracking-wide transition-all duration-300"
>
{isAddingToCart ? "Ajout..." : "Ajouter au panier"}
</Button>
) : (
/* Contrôles de quantité quand le produit est dans le panier */
<div className="space-y-2">
{/* Indicateur dans le panier */}
<div className="flex items-center justify-center gap-2 p-2 bg-green-50 border border-green-200 rounded text-green-800 text-xs">
<ShoppingBag className="w-3 h-3" />
<span>Dans le panier ({currentQuantity})</span>
</div>
{/* Contrôles de quantité */}
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() => handleQuantityChange(currentQuantity - 1)}
className="flex-1 h-8"
>
<Minus className="w-3 h-3" />
</Button>
<span className="px-3 py-1 bg-gray-100 rounded text-sm font-medium min-w-[2rem] text-center">
{currentQuantity}
</span>
<Button
variant="outline"
size="sm"
onClick={() => handleQuantityChange(currentQuantity + 1)}
className="flex-1 h-8"
>
<Plus className="w-3 h-3" />
</Button>
<Button
variant="outline"
size="sm"
onClick={handleRemoveFromCart}
className="h-8 px-2 text-red-600 hover:text-red-700 hover:bg-red-50"
title="Supprimer du panier"
>
<Trash2 className="w-3 h-3" />
</Button>
</div>
</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,354 @@
// src/components/product/ProductFilters.tsx
'use client';
import { useState } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import { Label } from '@/components/ui/label';
import { Slider } from '@/components/ui/slider';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue
} from '@/components/ui/select';
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from '@/components/ui/accordion';
import { X, Filter } from 'lucide-react';
import { cn } from '@/lib/utils';
interface Category {
id: number;
name: string;
slug: string;
count: number;
}
interface ProductFiltersProps {
categories: Category[];
currentCategory?: string;
currentFilter?: string;
currentSort?: string;
}
interface ColorOption {
name: string;
value: string;
hex: string;
}
interface SizeOption {
name: string;
value: string;
}
const colorOptions: ColorOption[] = [
{ name: 'Noir', value: 'noir', hex: '#000000' },
{ name: 'Blanc', value: 'blanc', hex: '#FFFFFF' },
{ name: 'Rouge', value: 'rouge', hex: '#DC2626' },
{ name: 'Bleu', value: 'bleu', hex: '#2563EB' },
{ name: 'Vert', value: 'vert', hex: '#059669' },
{ name: 'Jaune', value: 'jaune', hex: '#D97706' },
{ name: 'Violet', value: 'violet', hex: '#7C3AED' },
{ name: 'Rose', value: 'rose', hex: '#EC4899' },
];
const sizeOptions: SizeOption[] = [
{ name: 'Standard', value: 'standard' },
{ name: 'Grande taille', value: 'grande' },
{ name: 'Petite taille', value: 'petite' },
{ name: 'Sur mesure', value: 'sur-mesure' },
];
const sortOptions = [
{ label: 'Plus récent', value: 'date-desc' },
{ label: 'Prix croissant', value: 'price-asc' },
{ label: 'Prix décroissant', value: 'price-desc' },
{ label: 'Nom A-Z', value: 'name-asc' },
{ label: 'Nom Z-A', value: 'name-desc' },
];
export default function ProductFilters({
categories,
currentCategory,
currentFilter,
currentSort = 'date-desc'
}: ProductFiltersProps): JSX.Element {
const router = useRouter();
const searchParams = useSearchParams();
const [priceRange, setPriceRange] = useState<number[]>([0, 100000]);
const [selectedColors, setSelectedColors] = useState<string[]>([]);
const [selectedSizes, setSelectedSizes] = useState<string[]>([]);
const [selectedCategories, setSelectedCategories] = useState<string[]>(
currentCategory ? [currentCategory] : []
);
const updateFilters = (newParams: Record<string, string | string[] | null>): void => {
const params = new URLSearchParams(searchParams.toString());
Object.entries(newParams).forEach(([key, value]) => {
if (value === null || value === '' || (Array.isArray(value) && value.length === 0)) {
params.delete(key);
} else if (Array.isArray(value)) {
params.delete(key);
value.forEach(v => params.append(key, v));
} else {
params.set(key, value);
}
});
// Reset page to 1 when filters change
params.set('page', '1');
router.push(`/boutique?${params.toString()}`);
};
const handleSortChange = (value: string): void => {
updateFilters({ sort: value });
};
const handleCategoryChange = (categorySlug: string, checked: boolean): void => {
const newCategories = checked
? [...selectedCategories, categorySlug]
: selectedCategories.filter(c => c !== categorySlug);
setSelectedCategories(newCategories);
updateFilters({ category: newCategories.length > 0 ? newCategories : null });
};
const handleColorChange = (colorValue: string, checked: boolean): void => {
const newColors = checked
? [...selectedColors, colorValue]
: selectedColors.filter(c => c !== colorValue);
setSelectedColors(newColors);
updateFilters({ color: newColors.length > 0 ? newColors : null });
};
const handleSizeChange = (sizeValue: string, checked: boolean): void => {
const newSizes = checked
? [...selectedSizes, sizeValue]
: selectedSizes.filter(s => s !== sizeValue);
setSelectedSizes(newSizes);
updateFilters({ size: newSizes.length > 0 ? newSizes : null });
};
const clearAllFilters = (): void => {
setPriceRange([0, 100000]);
setSelectedColors([]);
setSelectedSizes([]);
setSelectedCategories([]);
router.push('/boutique');
};
const hasActiveFilters =
selectedCategories.length > 0 ||
selectedColors.length > 0 ||
selectedSizes.length > 0 ||
priceRange[0] > 0 ||
priceRange[1] < 100000;
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Filter className="w-4 h-4" />
<h2 className="font-medium text-sm uppercase tracking-wide">Filtres</h2>
</div>
{hasActiveFilters && (
<Button
variant="ghost"
size="sm"
onClick={clearAllFilters}
className="text-xs text-gray-500 hover:text-black p-0 h-auto"
>
<X className="w-3 h-3 mr-1" />
Effacer
</Button>
)}
</div>
{/* Sort */}
<div className="space-y-3">
<Label className="text-xs font-medium uppercase tracking-wide">
Trier par
</Label>
<Select value={currentSort} onValueChange={handleSortChange}>
<SelectTrigger className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
{sortOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Filters Accordion */}
<Accordion type="multiple" defaultValue={['categories', 'colors', 'sizes', 'price']}>
{/* Categories */}
{categories.length > 0 && (
<AccordionItem value="categories">
<AccordionTrigger className="text-xs font-medium uppercase tracking-wide py-3">
Catégories
</AccordionTrigger>
<AccordionContent className="space-y-3 pt-1">
{categories.map((category) => (
<div key={category.id} className="flex items-center space-x-2">
<Checkbox
id={`category-${category.id}`}
checked={selectedCategories.includes(category.slug)}
onCheckedChange={(checked) =>
handleCategoryChange(category.slug, checked as boolean)
}
/>
<Label
htmlFor={`category-${category.id}`}
className="text-sm cursor-pointer flex-1 flex justify-between"
>
<span>{category.name}</span>
<span className="text-gray-400">({category.count})</span>
</Label>
</div>
))}
</AccordionContent>
</AccordionItem>
)}
{/* Colors */}
<AccordionItem value="colors">
<AccordionTrigger className="text-xs font-medium uppercase tracking-wide py-3">
Couleurs
</AccordionTrigger>
<AccordionContent className="space-y-3 pt-1">
<div className="grid grid-cols-4 gap-3">
{colorOptions.map((color) => (
<div key={color.value} className="flex flex-col items-center space-y-1">
<button
onClick={() => handleColorChange(color.value, !selectedColors.includes(color.value))}
className={cn(
"w-8 h-8 rounded-full border-2 transition-all duration-200",
selectedColors.includes(color.value)
? "border-black scale-110 shadow-md"
: "border-gray-200 hover:scale-105",
color.hex === '#FFFFFF' && "border-gray-300"
)}
style={{ backgroundColor: color.hex }}
title={color.name}
/>
<span className="text-xs text-gray-600">{color.name}</span>
</div>
))}
</div>
</AccordionContent>
</AccordionItem>
{/* Sizes */}
<AccordionItem value="sizes">
<AccordionTrigger className="text-xs font-medium uppercase tracking-wide py-3">
Tailles
</AccordionTrigger>
<AccordionContent className="space-y-3 pt-1">
{sizeOptions.map((size) => (
<div key={size.value} className="flex items-center space-x-2">
<Checkbox
id={`size-${size.value}`}
checked={selectedSizes.includes(size.value)}
onCheckedChange={(checked) =>
handleSizeChange(size.value, checked as boolean)
}
/>
<Label
htmlFor={`size-${size.value}`}
className="text-sm cursor-pointer"
>
{size.name}
</Label>
</div>
))}
</AccordionContent>
</AccordionItem>
{/* Price Range */}
<AccordionItem value="price">
<AccordionTrigger className="text-xs font-medium uppercase tracking-wide py-3">
Prix (MRU)
</AccordionTrigger>
<AccordionContent className="space-y-4 pt-1">
<Slider
value={priceRange}
onValueChange={setPriceRange}
max={100000}
min={0}
step={1000}
className="w-full"
/>
<div className="flex justify-between text-sm text-gray-600">
<span>{priceRange[0].toLocaleString()} MRU</span>
<span>{priceRange[1].toLocaleString()} MRU</span>
</div>
<Button
variant="outline"
size="sm"
className="w-full"
onClick={() => updateFilters({
price_min: priceRange[0].toString(),
price_max: priceRange[1].toString()
})}
>
Appliquer
</Button>
</AccordionContent>
</AccordionItem>
</Accordion>
{/* Quick Filters */}
<div className="pt-4 border-t border-gray-200 space-y-3">
<Label className="text-xs font-medium uppercase tracking-wide">
Filtres rapides
</Label>
<div className="space-y-2">
<Button
variant={currentFilter === 'sale' ? 'default' : 'outline'}
size="sm"
className="w-full justify-start text-xs"
onClick={() => updateFilters({ filter: currentFilter === 'sale' ? null : 'sale' })}
>
En promotion
</Button>
<Button
variant={currentFilter === 'featured' ? 'default' : 'outline'}
size="sm"
className="w-full justify-start text-xs"
onClick={() => updateFilters({ filter: currentFilter === 'featured' ? null : 'featured' })}
>
Produits vedettes
</Button>
<Button
variant={currentFilter === 'new' ? 'default' : 'outline'}
size="sm"
className="w-full justify-start text-xs"
onClick={() => updateFilters({ filter: currentFilter === 'new' ? null : 'new' })}
>
Nouvelles arrivées
</Button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,94 @@
// src/components/product/ProductGrid.tsx
'use client';
import { WooCommerceProduct } from '@/types/woocommerce';
import ProductCard from './ProductCard';
import { cn } from '@/lib/utils';
interface ProductGridProps {
products: WooCommerceProduct[];
currentPage?: number;
hasMore?: boolean;
className?: string;
viewMode?: 'grid' | 'list';
}
export default function ProductGrid({
products,
currentPage = 1,
hasMore = false,
className = '',
viewMode = 'grid'
}: ProductGridProps): JSX.Element {
if (products.length === 0) {
return (
<div className="text-center py-16">
<div className="max-w-md mx-auto space-y-4">
<div className="w-24 h-24 bg-gray-100 rounded-full mx-auto flex items-center justify-center">
<svg
className="w-12 h-12 text-gray-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.5}
d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4"
/>
</svg>
</div>
<h3 className="text-lg font-medium text-gray-900">
Aucun produit trouvé
</h3>
<p className="text-gray-600">
Aucun produit ne correspond à vos critères de recherche.
Essayez de modifier vos filtres.
</p>
</div>
</div>
);
}
return (
<div className={cn('space-y-8', className)}>
{/* Grid de produits */}
<div
className={cn(
'grid gap-6',
viewMode === 'grid'
? 'grid-cols-2 md:grid-cols-3 lg:grid-cols-4'
: 'grid-cols-1 md:grid-cols-2 gap-8'
)}
>
{products.map((product, index) => (
<div
key={product.id}
className="animate-fade-in-up"
style={{
animationDelay: `${index * 100}ms`,
animationFillMode: 'both'
}}
>
<ProductCard
product={product}
className={cn(
viewMode === 'list' && 'flex-row h-48'
)}
/>
</div>
))}
</div>
{/* Informations de pagination */}
{currentPage > 1 && (
<div className="text-center text-sm text-gray-600">
Page {currentPage} {products.length} produits affichés
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,215 @@
// src/components/product/ProductImageGallery.tsx
'use client';
import { useState } from 'react';
import Image from 'next/image';
import { WooCommerceImage } from '@/types/woocommerce';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { ChevronLeft, ChevronRight, Expand, Heart } from 'lucide-react';
import { cn } from '@/lib/utils';
import {
Dialog,
DialogContent,
DialogTrigger,
} from '@/components/ui/dialog';
interface ProductImageGalleryProps {
images: WooCommerceImage[];
productName: string;
isOnSale?: boolean;
discountPercentage?: number;
isFeatured?: boolean;
}
export default function ProductImageGallery({
images,
productName,
isOnSale = false,
discountPercentage = 0,
isFeatured = false,
}: ProductImageGalleryProps): JSX.Element {
const [currentImageIndex, setCurrentImageIndex] = useState(0);
const [isWishlisted, setIsWishlisted] = useState(false);
const hasMultipleImages = images.length > 1;
const currentImage = images[currentImageIndex] || images[0];
const goToPrevious = (): void => {
setCurrentImageIndex((prev) =>
prev === 0 ? images.length - 1 : prev - 1
);
};
const goToNext = (): void => {
setCurrentImageIndex((prev) =>
prev === images.length - 1 ? 0 : prev + 1
);
};
const goToImage = (index: number): void => {
setCurrentImageIndex(index);
};
const toggleWishlist = (): void => {
setIsWishlisted(!isWishlisted);
};
if (!currentImage) {
return (
<div className="aspect-[3/4] bg-gray-200 rounded-lg flex items-center justify-center">
<span className="text-gray-400">Aucune image disponible</span>
</div>
);
}
return (
<div className="space-y-4">
{/* Main Image */}
<div className="relative group">
<div className="aspect-[3/4] relative overflow-hidden rounded-lg bg-gray-100">
<Image
src={currentImage.src}
alt={currentImage.alt || productName}
fill
className="object-cover transition-transform duration-700 group-hover:scale-105"
sizes="(max-width: 768px) 100vw, 50vw"
priority
/>
{/* Badges */}
<div className="absolute top-4 left-4 flex flex-col gap-2 z-10">
{isOnSale && discountPercentage > 0 && (
<Badge className="bg-red-500 text-white">
-{discountPercentage}%
</Badge>
)}
{isFeatured && (
<Badge className="bg-black text-white">
Nouveauté
</Badge>
)}
</div>
{/* Wishlist Button */}
<Button
variant="ghost"
size="sm"
onClick={toggleWishlist}
className={cn(
"absolute top-4 right-4 w-10 h-10 p-0 rounded-full z-10 transition-all duration-300",
"bg-white/80 hover:bg-white backdrop-blur-sm",
isWishlisted && "bg-red-50 text-red-500 hover:bg-red-100"
)}
>
<Heart
className={cn(
"w-5 h-5 transition-all duration-300",
isWishlisted && "fill-current"
)}
/>
</Button>
{/* Navigation Arrows */}
{hasMultipleImages && (
<>
<Button
variant="ghost"
size="sm"
onClick={goToPrevious}
className="absolute left-4 top-1/2 -translate-y-1/2 w-10 h-10 p-0 rounded-full bg-white/80 hover:bg-white backdrop-blur-sm opacity-0 group-hover:opacity-100 transition-opacity duration-300 z-10"
>
<ChevronLeft className="w-5 h-5" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={goToNext}
className="absolute right-4 top-1/2 -translate-y-1/2 w-10 h-10 p-0 rounded-full bg-white/80 hover:bg-white backdrop-blur-sm opacity-0 group-hover:opacity-100 transition-opacity duration-300 z-10"
>
<ChevronRight className="w-5 h-5" />
</Button>
</>
)}
{/* Expand Button */}
<Dialog>
<DialogTrigger asChild>
<Button
variant="ghost"
size="sm"
className="absolute bottom-4 right-4 w-10 h-10 p-0 rounded-full bg-white/80 hover:bg-white backdrop-blur-sm opacity-0 group-hover:opacity-100 transition-opacity duration-300 z-10"
>
<Expand className="w-5 h-5" />
</Button>
</DialogTrigger>
<DialogContent className="max-w-4xl w-full h-full max-h-[90vh] p-0">
<div className="relative w-full h-full">
<Image
src={currentImage.src}
alt={currentImage.alt || productName}
fill
className="object-contain"
sizes="90vw"
/>
</div>
</DialogContent>
</Dialog>
{/* Image Counter */}
{hasMultipleImages && (
<div className="absolute bottom-4 left-4 bg-black/60 text-white px-3 py-1 rounded-full text-sm backdrop-blur-sm">
{currentImageIndex + 1} / {images.length}
</div>
)}
</div>
</div>
{/* Thumbnail Navigation */}
{hasMultipleImages && (
<div className="flex gap-3 overflow-x-auto pb-2">
{images.map((image, index) => (
<button
key={image.id}
onClick={() => goToImage(index)}
className={cn(
"relative flex-shrink-0 w-20 h-20 rounded-lg overflow-hidden border-2 transition-all duration-300",
currentImageIndex === index
? "border-black shadow-md"
: "border-gray-200 hover:border-gray-400"
)}
>
<Image
src={image.src}
alt={image.alt || `${productName} - Image ${index + 1}`}
fill
className="object-cover"
sizes="80px"
/>
</button>
))}
</div>
)}
{/* Mobile Dots Indicator */}
{hasMultipleImages && (
<div className="flex justify-center gap-2 md:hidden">
{images.map((_, index) => (
<button
key={index}
onClick={() => goToImage(index)}
className={cn(
"w-2 h-2 rounded-full transition-all duration-300",
currentImageIndex === index
? "bg-black"
: "bg-gray-300"
)}
/>
))}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,385 @@
// src/components/product/ProductInfo.tsx
'use client';
import { useState } from 'react';
import { WooCommerceProduct } from '@/types/woocommerce';
import { useCartActions } from '@/hooks/useCartSync';import { formatPrice, isOnSale, getDiscountPercentage } from '@/lib/woocommerce';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue
} from '@/components/ui/select';
import { Separator } from '@/components/ui/separator';
import {
ShoppingBag,
Heart,
Share2,
Truck,
Shield,
RotateCcw,
Star,
Plus,
Minus
} from 'lucide-react';
import { cn } from '@/lib/utils';
interface ProductInfoProps {
product: WooCommerceProduct;
}
interface ColorOption {
name: string;
value: string;
hex: string;
}
interface SizeOption {
name: string;
value: string;
available: boolean;
}
export default function ProductInfo({ product }: ProductInfoProps): JSX.Element {
const { addToCart, isInCart, getItemQuantity } = useCart();
const [selectedColor, setSelectedColor] = useState<string>('');
const [selectedSize, setSelectedSize] = useState<string>('');
const [quantity, setQuantity] = useState(1);
const [isAddingToCart, setIsAddingToCart] = useState(false);
const [isWishlisted, setIsWishlisted] = useState(false);
const discountPercentage = getDiscountPercentage(product);
const productInCart = isInCart(product.id);
const cartQuantity = getItemQuantity(product.id);
// Extraire les couleurs des attributs du produit
const colorAttribute = product.attributes?.find(attr =>
attr.name.toLowerCase().includes('couleur') ||
attr.name.toLowerCase().includes('color')
);
const sizeAttribute = product.attributes?.find(attr =>
attr.name.toLowerCase().includes('taille') ||
attr.name.toLowerCase().includes('size')
);
const colorOptions: ColorOption[] = colorAttribute?.options.map(color => ({
name: color,
value: color.toLowerCase(),
hex: getColorHex(color),
})) || [];
const sizeOptions: SizeOption[] = sizeAttribute?.options.map(size => ({
name: size,
value: size.toLowerCase(),
available: true, // Vous pouvez ajouter la logique de disponibilité ici
})) || [];
const handleAddToCart = async (): Promise<void> => {
if (isAddingToCart) return;
setIsAddingToCart(true);
try {
addToCart({
id: product.id,
name: product.name,
price: product.sale_price || product.regular_price,
image: product.images[0]?.src || '/placeholder-product.jpg',
}, quantity);
// Simulation d'une petite attente pour l'UX
await new Promise(resolve => setTimeout(resolve, 500));
} finally {
setIsAddingToCart(false);
}
};
const increaseQuantity = (): void => {
setQuantity(prev => prev + 1);
};
const decreaseQuantity = (): void => {
setQuantity(prev => Math.max(1, prev - 1));
};
const toggleWishlist = (): void => {
setIsWishlisted(!isWishlisted);
};
const shareProduct = (): void => {
if (navigator.share) {
navigator.share({
title: product.name,
text: product.short_description,
url: window.location.href,
});
} else {
// Fallback: copier l'URL dans le presse-papiers
navigator.clipboard.writeText(window.location.href);
}
};
return (
<div className="space-y-8">
{/* Product Header */}
<div className="space-y-4">
{/* Categories */}
{product.categories.length > 0 && (
<div className="flex flex-wrap gap-2">
{product.categories.map((category) => (
<Badge key={category.id} variant="outline" className="text-xs">
{category.name}
</Badge>
))}
</div>
)}
{/* Title */}
<h1 className="text-3xl md:text-4xl font-light tracking-wide text-black">
{product.name}
</h1>
{/* Ratings */}
{product.rating_count > 0 && (
<div className="flex items-center gap-2">
<div className="flex items-center">
{[...Array(5)].map((_, i) => (
<Star
key={i}
className={cn(
"w-4 h-4",
i < Math.floor(parseFloat(product.average_rating))
? "fill-yellow-400 text-yellow-400"
: "text-gray-300"
)}
/>
))}
</div>
<span className="text-sm text-gray-600">
{product.average_rating} ({product.rating_count} avis)
</span>
</div>
)}
{/* Price */}
<div className="flex items-center gap-4">
{isOnSale(product) ? (
<>
<span className="text-3xl font-medium text-black">
{formatPrice(product.sale_price)}
</span>
<span className="text-xl text-gray-500 line-through">
{formatPrice(product.regular_price)}
</span>
{discountPercentage > 0 && (
<Badge className="bg-red-500 text-white">
-{discountPercentage}%
</Badge>
)}
</>
) : (
<span className="text-3xl font-medium text-black">
{formatPrice(product.price)}
</span>
)}
</div>
{/* Short Description */}
{product.short_description && (
<div
className="text-gray-600 leading-relaxed"
dangerouslySetInnerHTML={{ __html: product.short_description }}
/>
)}
</div>
<Separator />
{/* Product Options */}
<div className="space-y-6">
{/* Colors */}
{colorOptions.length > 0 && (
<div className="space-y-3">
<Label className="text-sm font-medium">
Couleur: {selectedColor && <span className="font-normal">{selectedColor}</span>}
</Label>
<div className="flex flex-wrap gap-3">
{colorOptions.map((color) => (
<button
key={color.value}
onClick={() => setSelectedColor(color.name)}
className={cn(
"w-8 h-8 rounded-full border-2 transition-all duration-200 hover:scale-110",
selectedColor === color.name
? "border-black shadow-md scale-110"
: "border-gray-200"
)}
style={{ backgroundColor: color.hex }}
title={color.name}
/>
))}
</div>
</div>
)}
{/* Sizes */}
{sizeOptions.length > 0 && (
<div className="space-y-3">
<Label className="text-sm font-medium">Taille</Label>
<Select value={selectedSize} onValueChange={setSelectedSize}>
<SelectTrigger>
<SelectValue placeholder="Choisir une taille" />
</SelectTrigger>
<SelectContent>
{sizeOptions.map((size) => (
<SelectItem
key={size.value}
value={size.value}
disabled={!size.available}
>
{size.name} {!size.available && '(Rupture de stock)'}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
{/* Quantity */}
<div className="space-y-3">
<Label className="text-sm font-medium">Quantité</Label>
<div className="flex items-center gap-4">
<div className="flex items-center border border-gray-300 rounded-lg">
<Button
variant="ghost"
size="sm"
onClick={decreaseQuantity}
disabled={quantity <= 1}
className="px-3 h-10 hover:bg-gray-100"
>
<Minus className="w-4 h-4" />
</Button>
<span className="px-4 py-2 min-w-[3rem] text-center">{quantity}</span>
<Button
variant="ghost"
size="sm"
onClick={increaseQuantity}
className="px-3 h-10 hover:bg-gray-100"
>
<Plus className="w-4 h-4" />
</Button>
</div>
{cartQuantity > 0 && (
<span className="text-sm text-gray-600">
{cartQuantity} déjà dans le panier
</span>
)}
</div>
</div>
</div>
<Separator />
{/* Action Buttons */}
<div className="space-y-4">
<Button
onClick={handleAddToCart}
disabled={isAddingToCart || !product.purchasable || product.stock_status === 'outofstock'}
className="w-full bg-black text-white hover:bg-gray-800 py-4 text-base uppercase tracking-wide transition-all duration-300"
size="lg"
>
{isAddingToCart ? (
<div className="flex items-center gap-2">
<div className="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin" />
Ajout en cours...
</div>
) : product.stock_status === 'outofstock' ? (
'Rupture de stock'
) : productInCart ? (
<>
<ShoppingBag className="w-5 h-5 mr-2" />
Ajouter encore ({cartQuantity} dans le panier)
</>
) : (
<>
<ShoppingBag className="w-5 h-5 mr-2" />
Ajouter au panier
</>
)}
</Button>
<div className="flex gap-3">
<Button
variant="outline"
onClick={toggleWishlist}
className={cn(
"flex-1 transition-all duration-300",
isWishlisted && "border-red-500 text-red-500 hover:bg-red-50"
)}
>
<Heart className={cn("w-5 h-5 mr-2", isWishlisted && "fill-current")} />
{isWishlisted ? 'Retiré des favoris' : 'Ajouter aux favoris'}
</Button>
<Button variant="outline" onClick={shareProduct}>
<Share2 className="w-5 h-5 mr-2" />
Partager
</Button>
</div>
</div>
<Separator />
{/* Product Features */}
<div className="space-y-4">
<div className="flex items-center gap-3 text-sm">
<Truck className="w-5 h-5 text-gray-600" />
<span>Livraison gratuite à partir de 50.000 MRU</span>
</div>
<div className="flex items-center gap-3 text-sm">
<Shield className="w-5 h-5 text-gray-600" />
<span>Garantie qualité - Retour sous 30 jours</span>
</div>
<div className="flex items-center gap-3 text-sm">
<RotateCcw className="w-5 h-5 text-gray-600" />
<span>Échange gratuit en magasin</span>
</div>
</div>
</div>
);
}
// Helper function pour les couleurs
function getColorHex(colorName: string): string {
const colorMap: { [key: string]: string } = {
'noir': '#000000',
'blanc': '#ffffff',
'rouge': '#dc2626',
'bleu': '#2563eb',
'vert': '#059669',
'jaune': '#d97706',
'violet': '#7c3aed',
'rose': '#ec4899',
'gris': '#6b7280',
'marron': '#92400e',
'beige': '#d6d3d1',
'doré': '#f59e0b',
'argenté': '#9ca3af',
};
return colorMap[colorName.toLowerCase()] || '#9ca3af';
}
function Label({ children, className = '' }: { children: React.ReactNode; className?: string }): JSX.Element {
return (
<label className={cn('block text-sm font-medium text-gray-900', className)}>
{children}
</label>
);
}

View File

@@ -0,0 +1,153 @@
// src/components/product/ProductTabs.tsx
'use client';
import { WooCommerceProduct } from '@/types/woocommerce';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Card, CardContent } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import {
Star,
Truck,
RotateCcw,
Shield
} from 'lucide-react';
interface ProductTabsProps {
product: WooCommerceProduct;
}
export default function ProductTabs({ product }: ProductTabsProps) {
const hasDescription = product.description && product.description.trim() !== '';
const hasAttributes = product.attributes && product.attributes.length > 0;
return (
<div className="w-full">
<Tabs defaultValue="description" className="w-full">
<TabsList className="grid w-full grid-cols-3">
<TabsTrigger value="description">Description</TabsTrigger>
<TabsTrigger value="specifications">Caractéristiques</TabsTrigger>
<TabsTrigger value="shipping">Livraison</TabsTrigger>
</TabsList>
<TabsContent value="description" className="mt-6">
<Card>
<CardContent className="p-6">
{hasDescription ? (
<div
className="prose prose-gray max-w-none"
dangerouslySetInnerHTML={{ __html: product.description }}
/>
) : (
<div className="text-center py-8 text-gray-500">
<p>Aucune description disponible pour ce produit.</p>
</div>
)}
</CardContent>
</Card>
</TabsContent>
<TabsContent value="specifications" className="mt-6">
<Card>
<CardContent className="p-6">
<h3 className="text-lg font-medium mb-6">Caractéristiques techniques</h3>
<div className="space-y-6">
<div>
<h4 className="font-medium text-sm mb-3 text-gray-900">Informations générales</h4>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="flex justify-between py-2 border-b border-gray-100">
<span className="text-sm text-gray-600">SKU</span>
<span className="text-sm font-medium">{product.sku || 'N/A'}</span>
</div>
<div className="flex justify-between py-2 border-b border-gray-100">
<span className="text-sm text-gray-600">Stock</span>
<Badge
variant={product.stock_status === 'instock' ? 'secondary' : 'destructive'}
className="text-xs"
>
{product.stock_status === 'instock' ? 'En stock' : 'Rupture de stock'}
</Badge>
</div>
</div>
</div>
{hasAttributes && (
<div>
<h4 className="font-medium text-sm mb-3 text-gray-900">Attributs</h4>
<div className="space-y-3">
{product.attributes.map((attribute) => (
<div key={attribute.id} className="flex justify-between py-2 border-b border-gray-100">
<span className="text-sm text-gray-600">{attribute.name}</span>
<span className="text-sm font-medium">
{attribute.options.join(', ')}
</span>
</div>
))}
</div>
</div>
)}
</div>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="shipping" className="mt-6">
<Card>
<CardContent className="p-6">
<h3 className="text-lg font-medium mb-6">Livraison et retours</h3>
<div className="space-y-8">
<div>
<h4 className="font-medium text-sm mb-4 flex items-center gap-2">
<Truck className="w-5 h-5" />
Options de livraison
</h4>
<div className="space-y-4">
<div className="flex justify-between items-center p-4 border border-gray-200 rounded-lg">
<div>
<h5 className="font-medium text-sm">Livraison standard</h5>
<p className="text-xs text-gray-600">3-5 jours ouvrés</p>
</div>
<div className="text-right">
<p className="font-medium">5.000 MRU</p>
<p className="text-xs text-gray-600">Gratuite dès 50.000 MRU</p>
</div>
</div>
</div>
</div>
<div>
<h4 className="font-medium text-sm mb-4 flex items-center gap-2">
<RotateCcw className="w-5 h-5" />
Retours et échanges
</h4>
<div className="space-y-3 text-sm">
<p> <strong>30 jours</strong> pour retourner votre article</p>
<p> Retour <strong>gratuit</strong> en magasin ou par courrier</p>
<p> Article en <strong>parfait état</strong> avec étiquettes</p>
</div>
</div>
<div>
<h4 className="font-medium text-sm mb-4 flex items-center gap-2">
<Shield className="w-5 h-5" />
Garantie qualité
</h4>
<div className="space-y-3 text-sm">
<p> Garantie <strong>2 ans</strong> contre les défauts de fabrication</p>
<p> Service client dédié pour toute réclamation</p>
<p> Engagement qualité artisanale mauritanienne</p>
</div>
</div>
</div>
</CardContent>
</Card>
</TabsContent>
</Tabs>
</div>
);
}

View File

@@ -0,0 +1,154 @@
// src/components/product/RelatedProducts.tsx
'use client';
import { WooCommerceProduct } from '@/types/woocommerce';
import ProductCard from './ProductCard';
import { Button } from '@/components/ui/button';
import { ChevronLeft, ChevronRight } from 'lucide-react';
import { useRef, useState } from 'react';
import { cn } from '@/lib/utils';
interface RelatedProductsProps {
products: WooCommerceProduct[];
title?: string;
className?: string;
}
export default function RelatedProducts({
products,
title = "Produits similaires",
className = ''
}: RelatedProductsProps): JSX.Element {
const scrollContainerRef = useRef<HTMLDivElement>(null);
const [canScrollLeft, setCanScrollLeft] = useState(false);
const [canScrollRight, setCanScrollRight] = useState(true);
const checkScrollButtons = (): void => {
if (scrollContainerRef.current) {
const { scrollLeft, scrollWidth, clientWidth } = scrollContainerRef.current;
setCanScrollLeft(scrollLeft > 0);
setCanScrollRight(scrollLeft < scrollWidth - clientWidth - 1);
}
};
const scrollLeft = (): void => {
if (scrollContainerRef.current) {
scrollContainerRef.current.scrollBy({
left: -320, // Largeur d'une carte + gap
behavior: 'smooth'
});
}
};
const scrollRight = (): void => {
if (scrollContainerRef.current) {
scrollContainerRef.current.scrollBy({
left: 320, // Largeur d'une carte + gap
behavior: 'smooth'
});
}
};
if (products.length === 0) {
return <></>;
}
return (
<section className={cn('space-y-8', className)}>
{/* Header */}
<div className="flex items-center justify-between">
<h2 className="text-2xl md:text-3xl font-light tracking-wide">
{title}
</h2>
{/* Navigation Buttons - Desktop */}
<div className="hidden md:flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={scrollLeft}
disabled={!canScrollLeft}
className="p-2 h-9 w-9"
>
<ChevronLeft className="w-4 h-4" />
</Button>
<Button
variant="outline"
size="sm"
onClick={scrollRight}
disabled={!canScrollRight}
className="p-2 h-9 w-9"
>
<ChevronRight className="w-4 h-4" />
</Button>
</div>
</div>
{/* Products Scroll Container */}
<div className="relative">
<div
ref={scrollContainerRef}
className="flex gap-6 overflow-x-auto hide-scrollbar pb-4"
onScroll={checkScrollButtons}
style={{ scrollSnapType: 'x mandatory' }}
>
{products.map((product, index) => (
<div
key={product.id}
className="flex-none w-72 md:w-80"
style={{ scrollSnapAlign: 'start' }}
>
<ProductCard
product={product}
className="h-full animate-fade-in-up"
style={{
animationDelay: `${index * 100}ms`,
animationFillMode: 'both'
}}
/>
</div>
))}
</div>
{/* Gradient Overlays */}
<div className="absolute left-0 top-0 bottom-4 w-8 bg-gradient-to-r from-gray-50 to-transparent pointer-events-none" />
<div className="absolute right-0 top-0 bottom-4 w-8 bg-gradient-to-l from-gray-50 to-transparent pointer-events-none" />
</div>
{/* Mobile Navigation Dots */}
<div className="flex justify-center gap-2 md:hidden">
{products.map((_, index) => (
<button
key={index}
className={cn(
"w-2 h-2 rounded-full transition-all duration-300",
index === 0 ? "bg-black" : "bg-gray-300" // En production, calculer l'index actuel
)}
onClick={() => {
if (scrollContainerRef.current) {
scrollContainerRef.current.scrollTo({
left: index * 320,
behavior: 'smooth'
});
}
}}
/>
))}
</div>
{/* Alternative: Grid Layout for smaller screens */}
<div className="md:hidden">
<div className="grid grid-cols-2 gap-4 mt-8">
{products.slice(0, 4).map((product) => (
<ProductCard
key={product.id}
product={product}
className="w-full"
/>
))}
</div>
</div>
</section>
);
}

View File

@@ -0,0 +1,181 @@
// src/app/produit/[slug]/page.tsx
import { Metadata } from 'next';
import { notFound } from 'next/navigation';
import { WooCommerceService, formatPrice, isOnSale, getDiscountPercentage } from '@/lib/woocommerce';
import ProductImageGallery from '@/components/product/ProductImageGallery';
import ProductInfo from '@/components/product/ProductInfo';
import RelatedProducts from '@/components/product/RelatedProducts';
import ProductTabs from '@/components/product/ProductTabs';
import Breadcrumb from '@/components/ui/breadcrumb';
interface ProductPageProps {
params: {
slug: string;
};
}
export async function generateMetadata({ params }: ProductPageProps): Promise<Metadata> {
const response = await WooCommerceService.getProductBySlug(params.slug);
if (!response.success || !response.data) {
return {
title: 'Produit non trouvé',
};
}
const product = response.data;
const discountPercentage = getDiscountPercentage(product);
return {
title: `${product.name} - MELHFA`,
description: product.short_description || product.description,
openGraph: {
title: product.name,
description: product.short_description || product.description,
images: product.images.map(img => ({
url: img.src,
width: 800,
height: 1200,
alt: img.alt || product.name,
})),
type: 'product',
},
twitter: {
card: 'summary_large_image',
title: product.name,
description: product.short_description || product.description,
images: [product.images[0]?.src || ''],
},
alternates: {
canonical: `/produit/${product.slug}`,
},
other: {
'product:price:amount': product.price,
'product:price:currency': 'MRU',
'product:availability': product.stock_status === 'instock' ? 'in stock' : 'out of stock',
'product:condition': 'new',
...(isOnSale(product) && {
'product:sale_price:amount': product.sale_price,
'product:sale_price:currency': 'MRU',
}),
},
};
}
export default async function ProductPage({ params }: ProductPageProps): Promise<JSX.Element> {
// Récupérer le produit
const response = await WooCommerceService.getProductBySlug(params.slug);
if (!response.success || !response.data) {
notFound();
}
const product = response.data;
// Récupérer les produits liés
const relatedProductsResponse = await WooCommerceService.getProducts({
per_page: 4,
exclude: [product.id],
category: product.categories[0]?.slug,
});
const relatedProducts = relatedProductsResponse.data || [];
// Breadcrumb items
const breadcrumbItems = [
{ label: 'Accueil', href: '/' },
{ label: 'Boutique', href: '/boutique' },
...(product.categories.length > 0 ? [{
label: product.categories[0].name,
href: `/boutique?category=${product.categories[0].slug}`
}] : []),
{ label: product.name, href: `/produit/${product.slug}` },
];
return (
<div className="min-h-screen bg-white">
{/* Breadcrumb */}
<div className="max-w-[1400px] mx-auto px-6 pt-20 pb-6">
<Breadcrumb items={breadcrumbItems} />
</div>
{/* Product Details */}
<div className="max-w-[1400px] mx-auto px-6 pb-16">
<div className="grid grid-cols-1 lg:grid-cols-2 gap-12 lg:gap-16">
{/* Image Gallery */}
<div className="space-y-4">
<ProductImageGallery
images={product.images}
productName={product.name}
isOnSale={isOnSale(product)}
discountPercentage={getDiscountPercentage(product)}
isFeatured={product.featured}
/>
</div>
{/* Product Info */}
<div className="space-y-8">
<ProductInfo product={product} />
</div>
</div>
</div>
{/* Product Tabs */}
<div className="max-w-[1400px] mx-auto px-6 pb-16">
<ProductTabs product={product} />
</div>
{/* Related Products */}
{relatedProducts.length > 0 && (
<div className="bg-gray-50 py-16">
<div className="max-w-[1400px] mx-auto px-6">
<RelatedProducts products={relatedProducts} />
</div>
</div>
)}
{/* Structured Data for SEO */}
<script
type="application/ld+json"
dangerouslySetInnerHTML={{
__html: JSON.stringify({
'@context': 'https://schema.org',
'@type': 'Product',
name: product.name,
description: product.description,
image: product.images.map(img => img.src),
sku: product.sku,
mpn: product.sku,
brand: {
'@type': 'Brand',
name: 'MELHFA'
},
offers: {
'@type': 'Offer',
price: product.price,
priceCurrency: 'MRU',
availability: product.stock_status === 'instock'
? 'https://schema.org/InStock'
: 'https://schema.org/OutOfStock',
url: `${process.env.NEXT_PUBLIC_SITE_URL}/produit/${product.slug}`,
seller: {
'@type': 'Organization',
name: 'MELHFA'
},
...(isOnSale(product) && {
priceValidUntil: product.date_on_sale_to || '2024-12-31'
})
},
aggregateRating: product.rating_count > 0 ? {
'@type': 'AggregateRating',
ratingValue: product.average_rating,
reviewCount: product.rating_count
} : undefined,
category: product.categories.map(cat => cat.name).join(', '),
})
}}
/>
</div>
);
}

View File

@@ -0,0 +1,123 @@
// src/components/sections/HeroSection.tsx
'use client';
import { useEffect, useState } from 'react';
import Link from 'next/link';
import Image from 'next/image';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { ArrowRight } from 'lucide-react';
export default function HeroSection(): JSX.Element {
const [isVisible, setIsVisible] = useState(false);
useEffect(() => {
setIsVisible(true);
}, []);
return (
<section className="relative min-h-screen flex items-center bg-gray-50 mt-16">
<div className="max-w-[1400px] mx-auto px-6 w-full">
<div className="grid grid-cols-1 lg:grid-cols-2 gap-16 items-center">
{/* Content */}
<div className={`space-y-8 ${isVisible ? 'animate-fade-in-up' : 'opacity-0'}`}>
<div className="space-y-6">
<h1 className="text-5xl md:text-6xl lg:text-7xl font-extralight leading-none tracking-tight text-black">
MELHFA
<br />
<span className="font-light">ÉLÉGANTE</span>
</h1>
<p className="text-lg md:text-xl text-gray-600 font-light leading-relaxed max-w-lg">
Découvrez l&apos;art mauritanien à travers nos voiles d&apos;exception,
alliant tradition ancestrale et élégance contemporaine.
</p>
</div>
<div className="flex flex-col sm:flex-row gap-6 items-start sm:items-center">
<Button
size="lg"
className="bg-black text-white hover:bg-gray-800 px-12 py-4 text-sm uppercase tracking-[2px] transition-all duration-300 hover:scale-105"
asChild
>
<Link href="/boutique">
Découvrir
</Link>
</Button>
<Button
variant="link"
className="text-black hover:opacity-60 text-sm uppercase tracking-[2px] p-0 h-auto border-b border-black pb-1"
asChild
>
<Link href="/collections">
Collections
<ArrowRight className="w-4 h-4 ml-2" />
</Link>
</Button>
</div>
</div>
{/* Image Section */}
<div className={`relative ${isVisible ? 'animate-fade-in-up' : 'opacity-0'} lg:order-2`}>
<div className="relative">
{/* Image principale */}
<div className="relative h-[600px] lg:h-[700px] w-full rounded-2xl overflow-hidden shadow-2xl">
<Image
src="/images/melhfa-hero.jpg"
alt="Melhfa Élégante"
fill
className="object-cover"
priority
sizes="(max-width: 768px) 100vw, 50vw"
/>
{/* Badge sur l'image */}
<div className="absolute top-6 right-6">
<Badge className="bg-black/80 text-white px-4 py-2 text-xs uppercase tracking-wide">
Nouvelle Collection
</Badge>
</div>
</div>
{/* Carte flottante */}
<div className="absolute -bottom-8 -left-8 bg-white/95 backdrop-blur-xl p-6 rounded-xl shadow-xl max-w-[200px] border border-white/20">
<div className="space-y-4">
<div className="relative h-24 w-full rounded-lg overflow-hidden">
<Image
src="/images/melhfa-thumbnail.jpg"
alt="Melhfa Premium"
fill
className="object-cover"
/>
</div>
<div className="space-y-2">
<h3 className="text-xs font-medium uppercase tracking-wide text-black">
Melhfa Premium
</h3>
<p className="text-sm text-gray-600 font-light">
À partir de 28.000 MRU
</p>
</div>
</div>
</div>
{/* Éléments décoratifs */}
<div className="absolute -top-4 -right-4 w-24 h-24 bg-gradient-to-br from-gray-200 to-gray-300 rounded-full opacity-60 -z-10" />
<div className="absolute -bottom-12 -right-12 w-32 h-32 bg-gradient-to-tl from-gray-100 to-gray-200 rounded-full opacity-40 -z-10" />
</div>
</div>
</div>
</div>
{/* Scroll indicator */}
<div className="absolute bottom-8 left-1/2 transform -translate-x-1/2 hidden lg:block">
<div className="animate-bounce">
<div className="w-1 h-12 bg-gray-400 rounded-full" />
</div>
</div>
</section>
);
}

View File

@@ -0,0 +1,156 @@
// src/components/sections/NewsletterSection.tsx
'use client';
import { useState } from 'react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Mail, CheckCircle } from 'lucide-react';
export default function NewsletterSection(): JSX.Element {
const [email, setEmail] = useState('');
const [isSubmitting, setIsSubmitting] = useState(false);
const [isSubmitted, setIsSubmitted] = useState(false);
const [error, setError] = useState('');
const handleSubmit = async (e: React.FormEvent): Promise<void> => {
e.preventDefault();
if (!email || !email.includes('@')) {
setError('Veuillez entrer une adresse email valide');
return;
}
setIsSubmitting(true);
setError('');
try {
// Simuler l'appel API
await new Promise(resolve => setTimeout(resolve, 1000));
setIsSubmitted(true);
setEmail('');
// Reset après 3 secondes
setTimeout(() => {
setIsSubmitted(false);
}, 3000);
} catch (err) {
setError('Une erreur est survenue. Veuillez réessayer.');
} finally {
setIsSubmitting(false);
}
};
if (isSubmitted) {
return (
<section className="py-20 bg-gray-50">
<div className="max-w-[1400px] mx-auto px-6 text-center">
<div className="max-w-md mx-auto space-y-6">
<div className="w-16 h-16 bg-green-500 rounded-full flex items-center justify-center mx-auto">
<CheckCircle className="w-8 h-8 text-white" />
</div>
<h2 className="text-2xl font-light tracking-wide">Merci !</h2>
<p className="text-gray-600">
Vous êtes maintenant inscrit(e) à notre newsletter.
Vous recevrez bientôt nos dernières actualités.
</p>
</div>
</div>
</section>
);
}
return (
<section className="py-20 bg-gray-50">
<div className="max-w-[1400px] mx-auto px-6 text-center">
<div className="max-w-2xl mx-auto space-y-8">
{/* Icon */}
<div className="flex justify-center">
<div className="w-16 h-16 bg-black rounded-full flex items-center justify-center">
<Mail className="w-8 h-8 text-white" />
</div>
</div>
{/* Titre et description */}
<div className="space-y-4">
<h2 className="text-3xl md:text-4xl font-light tracking-wide text-black">
Restez informé
</h2>
<p className="text-gray-600 text-lg">
Recevez nos dernières collections et offres exclusives directement dans votre boîte mail
</p>
</div>
{/* Formulaire */}
<form onSubmit={handleSubmit} className="max-w-md mx-auto space-y-4">
<div className="flex flex-col sm:flex-row gap-4">
<div className="flex-1">
<Input
type="email"
placeholder="Votre adresse email"
value={email}
onChange={(e) => setEmail(e.target.value)}
className="w-full px-4 py-3 text-sm border border-gray-300 focus:border-black focus:ring-0 rounded-none"
disabled={isSubmitting}
/>
{error && (
<p className="text-red-500 text-xs mt-2 text-left">{error}</p>
)}
</div>
<Button
type="submit"
disabled={isSubmitting || !email}
className="bg-black text-white hover:bg-gray-800 px-8 py-3 text-sm uppercase tracking-[2px] transition-all duration-300 rounded-none whitespace-nowrap"
>
{isSubmitting ? (
<div className="flex items-center space-x-2">
<div className="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin" />
<span>Envoi...</span>
</div>
) : (
"S'abonner"
)}
</Button>
</div>
</form>
{/* Informations supplémentaires */}
<div className="space-y-3 text-sm text-gray-500">
<p>
En vous abonnant, vous acceptez de recevoir nos communications marketing.
</p>
<div className="flex justify-center space-x-6 text-xs">
<span> Pas de spam</span>
<span> Désabonnement facile</span>
<span> 1-2 emails par mois maximum</span>
</div>
</div>
{/* Avantages de l'inscription */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 pt-8 border-t border-gray-200">
<div className="space-y-2">
<h4 className="font-medium text-black">Offres exclusives</h4>
<p className="text-sm text-gray-600">
Accès privilégié à nos soldes privées
</p>
</div>
<div className="space-y-2">
<h4 className="font-medium text-black">Nouvelles collections</h4>
<p className="text-sm text-gray-600">
Soyez la première à découvrir nos créations
</p>
</div>
<div className="space-y-2">
<h4 className="font-medium text-black">Conseils style</h4>
<p className="text-sm text-gray-600">
Tips et inspirations mode mauritanienne
</p>
</div>
</div>
</div>
</div>
</section>
);
}

View File

@@ -0,0 +1,95 @@
// src/components/sections/PromoSection.tsx
'use client';
import Link from 'next/link';
import { Button } from '@/components/ui/button';
import { Sparkles } from 'lucide-react';
export default function PromoSection(): JSX.Element {
return (
<section className="relative py-20 overflow-hidden">
{/* Background avec dégradé */}
<div className="absolute inset-0 bg-gradient-to-br from-indigo-600 via-purple-700 to-indigo-800" />
{/* Motifs décoratifs */}
<div className="absolute inset-0 opacity-10">
<div className="absolute top-10 left-10 w-20 h-20 border-2 border-white rounded-full" />
<div className="absolute top-32 right-20 w-16 h-16 border border-white rounded-full" />
<div className="absolute bottom-20 left-1/4 w-12 h-12 border border-white rounded-full" />
<div className="absolute bottom-32 right-1/3 w-24 h-24 border-2 border-white rounded-full" />
</div>
<div className="relative max-w-[1400px] mx-auto px-6 text-center">
<div className="max-w-4xl mx-auto space-y-8">
{/* Icon */}
<div className="flex justify-center">
<div className="w-16 h-16 bg-white/20 rounded-full flex items-center justify-center backdrop-blur-sm">
<Sparkles className="w-8 h-8 text-white" />
</div>
</div>
{/* Titre principal */}
<div className="space-y-4">
<h2 className="text-4xl md:text-5xl lg:text-6xl font-light text-white tracking-wide">
SOLDES D&apos;É
</h2>
<p className="text-xl md:text-2xl text-white/90 font-light">
Jusqu&apos;à -40% sur une sélection de voiles premium
</p>
</div>
{/* Description */}
<p className="text-lg text-white/80 max-w-2xl mx-auto leading-relaxed">
Profitez de nos offres exceptionnelles sur les plus belles créations de nos artisans.
Une occasion unique de découvrir l&apos;élégance mauritanienne à prix réduit.
</p>
{/* CTA Buttons */}
<div className="flex flex-col sm:flex-row gap-4 justify-center items-center pt-4">
<Button
size="lg"
className="bg-white text-black hover:bg-gray-100 px-8 py-4 text-sm uppercase tracking-[2px] transition-all duration-300 hover:scale-105"
asChild
>
<Link href="/boutique?filter=sale">
Voir les offres
</Link>
</Button>
<Button
variant="ghost"
size="lg"
className="text-white border border-white/30 hover:bg-white/10 px-8 py-4 text-sm uppercase tracking-[2px] backdrop-blur-sm"
asChild
>
<Link href="/collections">
Toutes les collections
</Link>
</Button>
</div>
{/* Informations complémentaires */}
<div className="pt-8 space-y-2">
<p className="text-sm text-white/70 uppercase tracking-wide">
Offre valable jusqu&apos;au 31 août 2024
</p>
<div className="flex justify-center space-x-8 text-xs text-white/60">
<span> Livraison gratuite</span>
<span> Retour sous 30 jours</span>
<span> Paiement sécurisé</span>
</div>
</div>
</div>
</div>
{/* Particules flottantes */}
<div className="absolute inset-0 overflow-hidden pointer-events-none">
<div className="absolute top-1/4 left-1/4 w-2 h-2 bg-white rounded-full opacity-60 animate-ping" style={{ animationDelay: '0s' }} />
<div className="absolute top-1/3 right-1/4 w-1 h-1 bg-white rounded-full opacity-40 animate-ping" style={{ animationDelay: '1s' }} />
<div className="absolute bottom-1/4 left-1/3 w-1.5 h-1.5 bg-white rounded-full opacity-50 animate-ping" style={{ animationDelay: '2s' }} />
<div className="absolute bottom-1/3 right-1/3 w-1 h-1 bg-white rounded-full opacity-30 animate-ping" style={{ animationDelay: '3s' }} />
</div>
</section>
);
}

View File

@@ -0,0 +1,267 @@
// src/components/ui/MelhfaLoader.tsx
'use client';
import React from 'react';
import { cn } from '@/lib/utils';
interface MelhfaLoaderProps {
size?: 'sm' | 'md' | 'lg' | 'xl';
className?: string;
text?: string;
color?: 'blue' | 'purple' | 'pink' | 'gold' | 'emerald' | 'white';
}
const sizeClasses = {
sm: 'w-16 h-12',
md: 'w-24 h-18',
lg: 'w-32 h-24',
xl: 'w-48 h-36'
};
const colorClasses = {
blue: 'text-blue-600',
purple: 'text-purple-600',
pink: 'text-pink-500',
gold: 'text-yellow-500',
emerald: 'text-emerald-500',
white: 'text-gray-100'
};
export function MelhfaLoader({
size = 'md',
className,
text,
color = 'purple'
}: MelhfaLoaderProps) {
return (
<div className={cn("flex flex-col items-center justify-center space-y-4", className)}>
<div className={cn("relative", sizeClasses[size])}>
{/* Melhfa Mauritanien Traditionnel */}
<svg
className={cn("animate-pulse", colorClasses[color])}
viewBox="0 0 240 180"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
{/* Dégradés pour l'effet de transparence et de tissu */}
<defs>
<linearGradient id="melhfaGradient" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stopColor="currentColor" stopOpacity="0.9" />
<stop offset="30%" stopColor="currentColor" stopOpacity="0.7" />
<stop offset="70%" stopColor="currentColor" stopOpacity="0.5" />
<stop offset="100%" stopColor="currentColor" stopOpacity="0.3" />
</linearGradient>
<linearGradient id="melhfaGradient2" x1="100%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" stopColor="currentColor" stopOpacity="0.6" />
<stop offset="50%" stopColor="currentColor" stopOpacity="0.4" />
<stop offset="100%" stopColor="currentColor" stopOpacity="0.2" />
</linearGradient>
<linearGradient id="borderGradient" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" stopColor="currentColor" stopOpacity="0.8" />
<stop offset="50%" stopColor="currentColor" stopOpacity="1" />
<stop offset="100%" stopColor="currentColor" stopOpacity="0.8" />
</linearGradient>
</defs>
{/* Corps principal du melhfa - forme fluide et élégante */}
<path
d="M20 40 Q60 20 120 25 Q180 20 220 40 Q210 80 200 120 Q190 140 180 160 Q140 170 120 165 Q80 170 60 160 Q40 140 30 120 Q20 80 20 40 Z"
fill="url(#melhfaGradient)"
className="animate-melhfa-float"
/>
{/* Pli du voile - effet de profondeur */}
<path
d="M40 60 Q80 45 120 50 Q160 45 200 60 Q190 90 180 120 Q160 130 120 125 Q80 130 60 120 Q50 90 40 60 Z"
fill="url(#melhfaGradient2)"
className="animate-melhfa-fold"
/>
{/* Bordure brodée traditionnelle */}
<path
d="M20 40 Q60 20 120 25 Q180 20 220 40"
stroke="url(#borderGradient)"
strokeWidth="2"
fill="none"
className="animate-melhfa-border"
/>
{/* Motifs géométriques mauritaniens */}
<g className="animate-melhfa-patterns">
{/* Motif central */}
<circle cx="120" cy="80" r="3" fill="currentColor" opacity="0.6" />
<circle cx="120" cy="80" r="8" stroke="currentColor" strokeWidth="1" fill="none" opacity="0.4" />
{/* Motifs latéraux */}
<circle cx="80" cy="70" r="2" fill="currentColor" opacity="0.5" />
<circle cx="160" cy="70" r="2" fill="currentColor" opacity="0.5" />
{/* Petits motifs décoratifs */}
<rect x="100" y="100" width="40" height="1" fill="currentColor" opacity="0.4" />
<rect x="110" y="110" width="20" height="1" fill="currentColor" opacity="0.3" />
</g>
{/* Effet de brillance sur le tissu */}
<path
d="M60 50 Q100 40 140 50 Q130 70 120 90 Q100 85 80 90 Q70 70 60 50 Z"
fill="currentColor"
opacity="0.1"
className="animate-melhfa-shine"
/>
{/* Franges du voile */}
<g className="animate-melhfa-fringe">
<line x1="25" y1="150" x2="25" y2="165" stroke="currentColor" strokeWidth="1" opacity="0.6" />
<line x1="35" y1="155" x2="35" y2="170" stroke="currentColor" strokeWidth="1" opacity="0.5" />
<line x1="45" y1="152" x2="45" y2="167" stroke="currentColor" strokeWidth="1" opacity="0.6" />
<line x1="190" y1="150" x2="190" y2="165" stroke="currentColor" strokeWidth="1" opacity="0.6" />
<line x1="200" y1="155" x2="200" y2="170" stroke="currentColor" strokeWidth="1" opacity="0.5" />
<line x1="210" y1="152" x2="210" y2="167" stroke="currentColor" strokeWidth="1" opacity="0.6" />
</g>
</svg>
</div>
{/* Texte de chargement */}
{text && (
<p className="text-sm text-gray-600 font-medium animate-pulse">
{text}
</p>
)}
{/* Points de chargement */}
<div className="flex space-x-1">
<div className={cn("w-2 h-2 rounded-full animate-bounce", `bg-${color}-500`)} />
<div className={cn("w-2 h-2 rounded-full animate-bounce", `bg-${color}-500`)} style={{ animationDelay: '0.1s' }} />
<div className={cn("w-2 h-2 rounded-full animate-bounce", `bg-${color}-500`)} style={{ animationDelay: '0.2s' }} />
</div>
</div>
);
}
// Loader en plein écran
export function MelhfaFullScreenLoader({
text = "Chargement...",
color = 'purple'
}: {
text?: string;
color?: MelhfaLoaderProps['color'];
}) {
return (
<div className="fixed inset-0 bg-white/80 backdrop-blur-sm flex items-center justify-center z-50">
<div className="bg-white p-8 rounded-2xl shadow-xl">
<MelhfaLoader size="xl" text={text} color={color} />
</div>
</div>
);
}
// Loader en ligne
export function MelhfaInlineLoader({
text,
size = 'sm',
color = 'purple'
}: MelhfaLoaderProps) {
return (
<div className="flex items-center space-x-3">
<MelhfaLoader size={size} color={color} />
{text && (
<span className="text-gray-600 text-sm">{text}</span>
)}
</div>
);
}
// CSS à ajouter dans globals.css
export const melhfaLoaderStyles = `
@keyframes melhfa-float {
0%, 100% {
transform: translateY(0) rotate(0deg);
}
25% {
transform: translateY(-8px) rotate(1deg);
}
50% {
transform: translateY(-4px) rotate(0deg);
}
75% {
transform: translateY(-10px) rotate(-1deg);
}
}
@keyframes melhfa-fold {
0%, 100% {
transform: translateY(0) scaleY(1);
}
50% {
transform: translateY(-3px) scaleY(0.98);
}
}
@keyframes melhfa-border {
0%, 100% {
stroke-dasharray: 0, 100;
}
50% {
stroke-dasharray: 50, 50;
}
}
@keyframes melhfa-patterns {
0%, 100% {
opacity: 0.6;
transform: scale(1);
}
50% {
opacity: 0.9;
transform: scale(1.05);
}
}
@keyframes melhfa-shine {
0%, 100% {
opacity: 0.05;
transform: translateX(0);
}
50% {
opacity: 0.2;
transform: translateX(10px);
}
}
@keyframes melhfa-fringe {
0%, 100% {
transform: translateY(0) rotate(0deg);
}
25% {
transform: translateY(2px) rotate(1deg);
}
75% {
transform: translateY(-1px) rotate(-0.5deg);
}
}
.animate-melhfa-float {
animation: melhfa-float 4s ease-in-out infinite;
}
.animate-melhfa-fold {
animation: melhfa-fold 3s ease-in-out infinite 0.5s;
}
.animate-melhfa-border {
animation: melhfa-border 2s ease-in-out infinite;
}
.animate-melhfa-patterns {
animation: melhfa-patterns 3s ease-in-out infinite 1s;
}
.animate-melhfa-shine {
animation: melhfa-shine 4s ease-in-out infinite 1.5s;
}
.animate-melhfa-fringe {
animation: melhfa-fringe 2s ease-in-out infinite 0.8s;
}
`;

View File

@@ -0,0 +1,63 @@
// src/components/ui/accordion.tsx
"use client"
import * as React from "react"
import * as AccordionPrimitive from "@radix-ui/react-accordion"
import { ChevronDown } from "lucide-react"
import { cn } from "@/lib/utils"
const Accordion = AccordionPrimitive.Root
const AccordionItem = React.forwardRef<
React.ElementRef<typeof AccordionPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Item>
>(({ className, ...props }, ref) => (
<AccordionPrimitive.Item
ref={ref}
className={cn("border-b", className)}
{...props}
/>
))
AccordionItem.displayName = "AccordionItem"
const AccordionTrigger = React.forwardRef<
React.ElementRef<typeof AccordionPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<AccordionPrimitive.Header className="flex">
<AccordionPrimitive.Trigger
ref={ref}
className={cn(
"flex flex-1 items-center justify-between py-4 font-medium transition-all hover:underline [&[data-state=open]>svg]:rotate-180",
className
)}
{...props}
>
{children}
<ChevronDown className="h-4 w-4 shrink-0 transition-transform duration-200" />
</AccordionPrimitive.Trigger>
</AccordionPrimitive.Header>
))
AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName
const AccordionContent = React.forwardRef<
React.ElementRef<typeof AccordionPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<AccordionPrimitive.Content
ref={ref}
className="overflow-hidden text-sm transition-all data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down"
{...props}
>
<div className={cn("pb-4 pt-0", className)}>{children}</div>
</AccordionPrimitive.Content>
))
AccordionContent.displayName = AccordionPrimitive.Content.displayName
export {
Accordion,
AccordionItem,
AccordionTrigger,
AccordionContent,
}

View File

@@ -0,0 +1,66 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const alertVariants = cva(
"relative w-full rounded-lg border px-4 py-3 text-sm grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current",
{
variants: {
variant: {
default: "bg-card text-card-foreground",
destructive:
"text-destructive bg-card [&>svg]:text-current *:data-[slot=alert-description]:text-destructive/90",
},
},
defaultVariants: {
variant: "default",
},
}
)
function Alert({
className,
variant,
...props
}: React.ComponentProps<"div"> & VariantProps<typeof alertVariants>) {
return (
<div
data-slot="alert"
role="alert"
className={cn(alertVariants({ variant }), className)}
{...props}
/>
)
}
function AlertTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-title"
className={cn(
"col-start-2 line-clamp-1 min-h-4 font-medium tracking-tight",
className
)}
{...props}
/>
)
}
function AlertDescription({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-description"
className={cn(
"text-muted-foreground col-start-2 grid justify-items-start gap-1 text-sm [&_p]:leading-relaxed",
className
)}
{...props}
/>
)
}
export { Alert, AlertTitle, AlertDescription }

View File

@@ -0,0 +1,46 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const badgeVariants = cva(
"inline-flex items-center justify-center rounded-md border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
{
variants: {
variant: {
default:
"border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
secondary:
"border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
destructive:
"border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
},
},
defaultVariants: {
variant: "default",
},
}
)
function Badge({
className,
variant,
asChild = false,
...props
}: React.ComponentProps<"span"> &
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
const Comp = asChild ? Slot : "span"
return (
<Comp
data-slot="badge"
className={cn(badgeVariants({ variant }), className)}
{...props}
/>
)
}
export { Badge, badgeVariants }

View File

@@ -0,0 +1,52 @@
// src/components / ui / breadcrumb.tsx
import React from "react"
import Link from "next/link"
import { ChevronRight, Home } from "lucide-react"
import { cn } from "@/lib/utils"
interface BreadcrumbItem {
label: string
href: string
}
interface BreadcrumbProps {
items: BreadcrumbItem[]
className?: string
}
export default function Breadcrumb({ items, className }: BreadcrumbProps): JSX.Element {
return (
<nav aria-label="Breadcrumb" className={cn("flex", className)}>
<ol className="inline-flex items-center space-x-1 md:space-x-3">
<li className="inline-flex items-center">
<Link
href="/"
className="inline-flex items-center text-sm font-medium text-gray-700 hover:text-black"
>
<Home className="w-4 h-4 mr-2" />
Accueil
</Link>
</li>
{items.slice(1).map((item, index) => (
<li key={item.href}>
<div className="flex items-center">
<ChevronRight className="w-4 h-4 text-gray-400" />
{index === items.length - 2 ? (
<span className="ml-1 text-sm font-medium text-gray-500 md:ml-2">
{item.label}
</span>
) : (
<Link
href={item.href}
className="ml-1 text-sm font-medium text-gray-700 hover:text-black md:ml-2"
>
{item.label}
</Link>
)}
</div>
</li>
))}
</ol>
</nav>
)
}

View File

@@ -0,0 +1,59 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
{
variants: {
variant: {
default:
"bg-primary text-primary-foreground shadow-xs hover:bg-primary/90",
destructive:
"bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
secondary:
"bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80",
ghost:
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2 has-[>svg]:px-3",
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
icon: "size-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function Button({
className,
variant,
size,
asChild = false,
...props
}: React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean
}) {
const Comp = asChild ? Slot : "button"
return (
<Comp
data-slot="button"
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
)
}
export { Button, buttonVariants }

View File

@@ -0,0 +1,92 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Card({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card"
className={cn(
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
className
)}
{...props}
/>
)
}
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-header"
className={cn(
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-1.5 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
className
)}
{...props}
/>
)
}
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-title"
className={cn("leading-none font-semibold", className)}
{...props}
/>
)
}
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-action"
className={cn(
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
className
)}
{...props}
/>
)
}
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-content"
className={cn("px-6", className)}
{...props}
/>
)
}
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-footer"
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
{...props}
/>
)
}
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardAction,
CardDescription,
CardContent,
}

View File

@@ -0,0 +1,241 @@
"use client"
import * as React from "react"
import useEmblaCarousel, {
type UseEmblaCarouselType,
} from "embla-carousel-react"
import { ArrowLeft, ArrowRight } from "lucide-react"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
type CarouselApi = UseEmblaCarouselType[1]
type UseCarouselParameters = Parameters<typeof useEmblaCarousel>
type CarouselOptions = UseCarouselParameters[0]
type CarouselPlugin = UseCarouselParameters[1]
type CarouselProps = {
opts?: CarouselOptions
plugins?: CarouselPlugin
orientation?: "horizontal" | "vertical"
setApi?: (api: CarouselApi) => void
}
type CarouselContextProps = {
carouselRef: ReturnType<typeof useEmblaCarousel>[0]
api: ReturnType<typeof useEmblaCarousel>[1]
scrollPrev: () => void
scrollNext: () => void
canScrollPrev: boolean
canScrollNext: boolean
} & CarouselProps
const CarouselContext = React.createContext<CarouselContextProps | null>(null)
function useCarousel() {
const context = React.useContext(CarouselContext)
if (!context) {
throw new Error("useCarousel must be used within a <Carousel />")
}
return context
}
function Carousel({
orientation = "horizontal",
opts,
setApi,
plugins,
className,
children,
...props
}: React.ComponentProps<"div"> & CarouselProps) {
const [carouselRef, api] = useEmblaCarousel(
{
...opts,
axis: orientation === "horizontal" ? "x" : "y",
},
plugins
)
const [canScrollPrev, setCanScrollPrev] = React.useState(false)
const [canScrollNext, setCanScrollNext] = React.useState(false)
const onSelect = React.useCallback((api: CarouselApi) => {
if (!api) return
setCanScrollPrev(api.canScrollPrev())
setCanScrollNext(api.canScrollNext())
}, [])
const scrollPrev = React.useCallback(() => {
api?.scrollPrev()
}, [api])
const scrollNext = React.useCallback(() => {
api?.scrollNext()
}, [api])
const handleKeyDown = React.useCallback(
(event: React.KeyboardEvent<HTMLDivElement>) => {
if (event.key === "ArrowLeft") {
event.preventDefault()
scrollPrev()
} else if (event.key === "ArrowRight") {
event.preventDefault()
scrollNext()
}
},
[scrollPrev, scrollNext]
)
React.useEffect(() => {
if (!api || !setApi) return
setApi(api)
}, [api, setApi])
React.useEffect(() => {
if (!api) return
onSelect(api)
api.on("reInit", onSelect)
api.on("select", onSelect)
return () => {
api?.off("select", onSelect)
}
}, [api, onSelect])
return (
<CarouselContext.Provider
value={{
carouselRef,
api: api,
opts,
orientation:
orientation || (opts?.axis === "y" ? "vertical" : "horizontal"),
scrollPrev,
scrollNext,
canScrollPrev,
canScrollNext,
}}
>
<div
onKeyDownCapture={handleKeyDown}
className={cn("relative", className)}
role="region"
aria-roledescription="carousel"
data-slot="carousel"
{...props}
>
{children}
</div>
</CarouselContext.Provider>
)
}
function CarouselContent({ className, ...props }: React.ComponentProps<"div">) {
const { carouselRef, orientation } = useCarousel()
return (
<div
ref={carouselRef}
className="overflow-hidden"
data-slot="carousel-content"
>
<div
className={cn(
"flex",
orientation === "horizontal" ? "-ml-4" : "-mt-4 flex-col",
className
)}
{...props}
/>
</div>
)
}
function CarouselItem({ className, ...props }: React.ComponentProps<"div">) {
const { orientation } = useCarousel()
return (
<div
role="group"
aria-roledescription="slide"
data-slot="carousel-item"
className={cn(
"min-w-0 shrink-0 grow-0 basis-full",
orientation === "horizontal" ? "pl-4" : "pt-4",
className
)}
{...props}
/>
)
}
function CarouselPrevious({
className,
variant = "outline",
size = "icon",
...props
}: React.ComponentProps<typeof Button>) {
const { orientation, scrollPrev, canScrollPrev } = useCarousel()
return (
<Button
data-slot="carousel-previous"
variant={variant}
size={size}
className={cn(
"absolute size-8 rounded-full",
orientation === "horizontal"
? "top-1/2 -left-12 -translate-y-1/2"
: "-top-12 left-1/2 -translate-x-1/2 rotate-90",
className
)}
disabled={!canScrollPrev}
onClick={scrollPrev}
{...props}
>
<ArrowLeft />
<span className="sr-only">Previous slide</span>
</Button>
)
}
function CarouselNext({
className,
variant = "outline",
size = "icon",
...props
}: React.ComponentProps<typeof Button>) {
const { orientation, scrollNext, canScrollNext } = useCarousel()
return (
<Button
data-slot="carousel-next"
variant={variant}
size={size}
className={cn(
"absolute size-8 rounded-full",
orientation === "horizontal"
? "top-1/2 -right-12 -translate-y-1/2"
: "-bottom-12 left-1/2 -translate-x-1/2 rotate-90",
className
)}
disabled={!canScrollNext}
onClick={scrollNext}
{...props}
>
<ArrowRight />
<span className="sr-only">Next slide</span>
</Button>
)
}
export {
type CarouselApi,
Carousel,
CarouselContent,
CarouselItem,
CarouselPrevious,
CarouselNext,
}

View File

@@ -0,0 +1,30 @@
// src/components/ui/checkbox.tsx
"use client"
import * as React from "react"
import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
import { Check } from "lucide-react"
import { cn } from "@/lib/utils"
const Checkbox = React.forwardRef<
React.ElementRef<typeof CheckboxPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>
>(({ className, ...props }, ref) => (
<CheckboxPrimitive.Root
ref={ref}
className={cn(
"peer h-4 w-4 shrink-0 rounded-sm border border-primary ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground",
className
)}
{...props}
>
<CheckboxPrimitive.Indicator
className={cn("flex items-center justify-center text-current")}
>
<Check className="h-4 w-4" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
))
Checkbox.displayName = CheckboxPrimitive.Root.displayName
export { Checkbox }

View File

@@ -0,0 +1,143 @@
"use client"
import * as React from "react"
import * as DialogPrimitive from "@radix-ui/react-dialog"
import { XIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function Dialog({
...props
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />
}
function DialogTrigger({
...props
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
}
function DialogPortal({
...props
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
}
function DialogClose({
...props
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
}
function DialogOverlay({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
return (
<DialogPrimitive.Overlay
data-slot="dialog-overlay"
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
className
)}
{...props}
/>
)
}
function DialogContent({
className,
children,
showCloseButton = true,
...props
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
showCloseButton?: boolean
}) {
return (
<DialogPortal data-slot="dialog-portal">
<DialogOverlay />
<DialogPrimitive.Content
data-slot="dialog-content"
className={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
className
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close
data-slot="dialog-close"
className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
>
<XIcon />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Content>
</DialogPortal>
)
}
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-header"
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
{...props}
/>
)
}
function DialogFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-footer"
className={cn(
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
className
)}
{...props}
/>
)
}
function DialogTitle({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
return (
<DialogPrimitive.Title
data-slot="dialog-title"
className={cn("text-lg leading-none font-semibold", className)}
{...props}
/>
)
}
function DialogDescription({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
return (
<DialogPrimitive.Description
data-slot="dialog-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
export {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
}

View File

@@ -0,0 +1,257 @@
"use client"
import * as React from "react"
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function DropdownMenu({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />
}
function DropdownMenuPortal({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
return (
<DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
)
}
function DropdownMenuTrigger({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
return (
<DropdownMenuPrimitive.Trigger
data-slot="dropdown-menu-trigger"
{...props}
/>
)
}
function DropdownMenuContent({
className,
sideOffset = 4,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
return (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
data-slot="dropdown-menu-content"
sideOffset={sideOffset}
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md",
className
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
)
}
function DropdownMenuGroup({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
return (
<DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
)
}
function DropdownMenuItem({
className,
inset,
variant = "default",
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean
variant?: "default" | "destructive"
}) {
return (
<DropdownMenuPrimitive.Item
data-slot="dropdown-menu-item"
data-inset={inset}
data-variant={variant}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
)
}
function DropdownMenuCheckboxItem({
className,
children,
checked,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) {
return (
<DropdownMenuPrimitive.CheckboxItem
data-slot="dropdown-menu-checkbox-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
checked={checked}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
)
}
function DropdownMenuRadioGroup({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
return (
<DropdownMenuPrimitive.RadioGroup
data-slot="dropdown-menu-radio-group"
{...props}
/>
)
}
function DropdownMenuRadioItem({
className,
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem>) {
return (
<DropdownMenuPrimitive.RadioItem
data-slot="dropdown-menu-radio-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<CircleIcon className="size-2 fill-current" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
)
}
function DropdownMenuLabel({
className,
inset,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean
}) {
return (
<DropdownMenuPrimitive.Label
data-slot="dropdown-menu-label"
data-inset={inset}
className={cn(
"px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
className
)}
{...props}
/>
)
}
function DropdownMenuSeparator({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
return (
<DropdownMenuPrimitive.Separator
data-slot="dropdown-menu-separator"
className={cn("bg-border -mx-1 my-1 h-px", className)}
{...props}
/>
)
}
function DropdownMenuShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="dropdown-menu-shortcut"
className={cn(
"text-muted-foreground ml-auto text-xs tracking-widest",
className
)}
{...props}
/>
)
}
function DropdownMenuSub({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />
}
function DropdownMenuSubTrigger({
className,
inset,
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean
}) {
return (
<DropdownMenuPrimitive.SubTrigger
data-slot="dropdown-menu-sub-trigger"
data-inset={inset}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8",
className
)}
{...props}
>
{children}
<ChevronRightIcon className="ml-auto size-4" />
</DropdownMenuPrimitive.SubTrigger>
)
}
function DropdownMenuSubContent({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
return (
<DropdownMenuPrimitive.SubContent
data-slot="dropdown-menu-sub-content"
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg",
className
)}
{...props}
/>
)
}
export {
DropdownMenu,
DropdownMenuPortal,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuLabel,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuSub,
DropdownMenuSubTrigger,
DropdownMenuSubContent,
}

View File

@@ -0,0 +1,21 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
return (
<input
type={type}
data-slot="input"
className={cn(
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
className
)}
{...props}
/>
)
}
export { Input }

View File

@@ -0,0 +1,26 @@
// src/components/ui/label.tsx
"use client"
import * as React from "react"
import * as LabelPrimitive from "@radix-ui/react-label"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const labelVariants = cva(
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
)
const Label = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &
VariantProps<typeof labelVariants>
>(({ className, ...props }, ref) => (
<LabelPrimitive.Root
ref={ref}
className={cn(labelVariants(), className)}
{...props}
/>
))
Label.displayName = LabelPrimitive.Root.displayName
export { Label }

View File

@@ -0,0 +1,157 @@
"use client"
import * as React from "react"
import * as SelectPrimitive from "@radix-ui/react-select"
import { Check, ChevronDown, ChevronUp } from "lucide-react"
import { cn } from "@/lib/utils"
const Select = SelectPrimitive.Root
const SelectGroup = SelectPrimitive.Group
const SelectValue = SelectPrimitive.Value
const SelectTrigger = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Trigger
ref={ref}
className={cn(
"flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDown className="h-4 w-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
))
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName
const SelectScrollUpButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollUpButton
ref={ref}
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronUp className="h-4 w-4" />
</SelectPrimitive.ScrollUpButton>
))
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName
const SelectScrollDownButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollDownButton
ref={ref}
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronDown className="h-4 w-4" />
</SelectPrimitive.ScrollDownButton>
))
SelectScrollDownButton.displayName =
SelectPrimitive.ScrollDownButton.displayName
const SelectContent = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
>(({ className, children, position = "popper", ...props }, ref) => (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
ref={ref}
className={cn(
"relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className
)}
position={position}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
"p-1",
position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]"
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
))
SelectContent.displayName = SelectPrimitive.Content.displayName
const SelectLabel = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Label
ref={ref}
className={cn("py-1.5 pl-8 pr-2 text-sm font-semibold", className)}
{...props}
/>
))
SelectLabel.displayName = SelectPrimitive.Label.displayName
const SelectItem = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Item
ref={ref}
className={cn(
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
))
SelectItem.displayName = SelectPrimitive.Item.displayName
const SelectSeparator = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-muted", className)}
{...props}
/>
))
SelectSeparator.displayName = SelectPrimitive.Separator.displayName
export {
Select,
SelectGroup,
SelectValue,
SelectTrigger,
SelectContent,
SelectLabel,
SelectItem,
SelectSeparator,
SelectScrollUpButton,
SelectScrollDownButton,
}

View File

@@ -0,0 +1,39 @@
// src/components/ui/separator.tsx
"use client"
import * as React from "react"
import * as SeparatorPrimitive from "@radix-ui/react-separator"
import { cn } from "@/lib/utils"
const Separator = React.forwardRef<
React.ElementRef<typeof SeparatorPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root>
>(
(
{ className, orientation = "horizontal", decorative = true, ...props },
ref
) => (
<SeparatorPrimitive.Root
ref={ref}
decorative={decorative}
orientation={orientation}
className={cn(
"shrink-0 bg-border",
orientation === "horizontal" ? "h-[1px] w-full" : "h-full w-[1px]",
className
)}
{...props}
/>
)
)
Separator.displayName = SeparatorPrimitive.Root.displayName
export { Separator }

137
src/components/ui/sheet.tsx Normal file
View File

@@ -0,0 +1,137 @@
// src/components/ui/sheet.tsx
"use client"
import * as React from "react"
import * as SheetPrimitive from "@radix-ui/react-dialog"
import { cva, type VariantProps } from "class-variance-authority"
import { X } from "lucide-react"
import { cn } from "@/lib/utils"
const Sheet = SheetPrimitive.Root
const SheetTrigger = SheetPrimitive.Trigger
const SheetClose = SheetPrimitive.Close
const SheetPortal = SheetPrimitive.Portal
const SheetOverlay = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<SheetPrimitive.Overlay
className={cn(
"fixed inset-0 z-50 bg-background/80 backdrop-blur-sm data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className
)}
{...props}
ref={ref}
/>
))
SheetOverlay.displayName = SheetPrimitive.Overlay.displayName
const sheetVariants = cva(
"fixed z-50 gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:duration-300 data-[state=open]:duration-500",
{
variants: {
side: {
top: "inset-x-0 top-0 border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top",
bottom:
"inset-x-0 bottom-0 border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom",
left: "inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm",
right:
"inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm",
},
},
defaultVariants: {
side: "right",
},
}
)
interface SheetContentProps
extends React.ComponentPropsWithoutRef<typeof SheetPrimitive.Content>,
VariantProps<typeof sheetVariants> { }
const SheetContent = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Content>,
SheetContentProps
>(({ side = "right", className, children, ...props }, ref) => (
<SheetPortal>
<SheetOverlay />
<SheetPrimitive.Content
ref={ref}
className={cn(sheetVariants({ side }), className)}
{...props}
>
{children}
<SheetPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-secondary">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</SheetPrimitive.Close>
</SheetPrimitive.Content>
</SheetPortal>
))
SheetContent.displayName = SheetPrimitive.Content.displayName
const SheetHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col space-y-2 text-center sm:text-left",
className
)}
{...props}
/>
)
SheetHeader.displayName = "SheetHeader"
const SheetFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
className
)}
{...props}
/>
)
SheetFooter.displayName = "SheetFooter"
const SheetTitle = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Title>
>(({ className, ...props }, ref) => (
<SheetPrimitive.Title
ref={ref}
className={cn("text-lg font-semibold text-foreground", className)}
{...props}
/>
))
SheetTitle.displayName = SheetPrimitive.Title.displayName
const SheetDescription = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Description>
>(({ className, ...props }, ref) => (
<SheetPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
SheetDescription.displayName = SheetPrimitive.Description.displayName
export {
Sheet,
SheetPortal,
SheetOverlay,
SheetTrigger,
SheetClose,
SheetContent,
SheetHeader,
SheetFooter,
SheetTitle,
SheetDescription,
}

View File

@@ -0,0 +1,28 @@
// src/components/ui/slider.tsx
"use client"
import * as React from "react"
import * as SliderPrimitive from "@radix-ui/react-slider"
import { cn } from "@/lib/utils"
const Slider = React.forwardRef<
React.ElementRef<typeof SliderPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof SliderPrimitive.Root>
>(({ className, ...props }, ref) => (
<SliderPrimitive.Root
ref={ref}
className={cn(
"relative flex w-full touch-none select-none items-center",
className
)}
{...props}
>
<SliderPrimitive.Track className="relative h-2 w-full grow overflow-hidden rounded-full bg-secondary">
<SliderPrimitive.Range className="absolute h-full bg-primary" />
</SliderPrimitive.Track>
<SliderPrimitive.Thumb className="block h-5 w-5 rounded-full border-2 border-primary bg-background ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50" />
</SliderPrimitive.Root>
))
Slider.displayName = SliderPrimitive.Root.displayName
export { Slider }

View File

@@ -0,0 +1,66 @@
"use client"
import * as React from "react"
import * as TabsPrimitive from "@radix-ui/react-tabs"
import { cn } from "@/lib/utils"
function Tabs({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Root>) {
return (
<TabsPrimitive.Root
data-slot="tabs"
className={cn("flex flex-col gap-2", className)}
{...props}
/>
)
}
function TabsList({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.List>) {
return (
<TabsPrimitive.List
data-slot="tabs-list"
className={cn(
"bg-muted text-muted-foreground inline-flex h-9 w-fit items-center justify-center rounded-lg p-[3px]",
className
)}
{...props}
/>
)
}
function TabsTrigger({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
return (
<TabsPrimitive.Trigger
data-slot="tabs-trigger"
className={cn(
"data-[state=active]:bg-background dark:data-[state=active]:text-foreground focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30 text-foreground dark:text-muted-foreground inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:ring-[3px] focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:shadow-sm [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
)
}
function TabsContent({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Content>) {
return (
<TabsPrimitive.Content
data-slot="tabs-content"
className={cn("flex-1 outline-none", className)}
{...props}
/>
)
}
export { Tabs, TabsList, TabsTrigger, TabsContent }

View File

@@ -0,0 +1,25 @@
// src/components/ui/textarea.tsx
import * as React from "react"
import { cn } from "@/lib/utils"
export interface TextareaProps
extends React.TextareaHTMLAttributes<HTMLTextAreaElement> { }
const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
({ className, ...props }, ref) => {
return (
<textarea
className={cn(
"flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
className
)}
ref={ref}
{...props}
/>
)
}
)
Textarea.displayName = "Textarea"
export { Textarea }

View File

@@ -0,0 +1,208 @@
// src/contexts/CartContext.tsx
'use client';
import React, { createContext, useContext, useState, useEffect, useCallback, ReactNode } from 'react';
import { CartItem, CartState } from '@/types/woocommerce';
const CART_STORAGE_KEY = 'melhfa_cart';
export interface UseCartReturn {
cart: CartState;
addToCart: (item: Omit<CartItem, 'quantity' | 'total'>, quantity?: number) => void;
removeFromCart: (id: number) => void;
updateQuantity: (id: number, quantity: number) => void;
clearCart: () => void;
getItemQuantity: (id: number) => number;
isInCart: (id: number) => boolean;
getTotalPrice: () => number;
getItemCount: () => number;
}
const initialCartState: CartState = {
items: [],
total: 0,
itemCount: 0,
};
const CartContext = createContext<UseCartReturn | undefined>(undefined);
interface CartProviderProps {
children: ReactNode;
}
export function CartProvider({ children }: CartProviderProps) {
const [cart, setCart] = useState<CartState>(initialCartState);
const [isHydrated, setIsHydrated] = useState(false);
// Charger le panier depuis localStorage au montage du composant
useEffect(() => {
try {
const savedCart = localStorage.getItem(CART_STORAGE_KEY);
if (savedCart) {
const parsedCart = JSON.parse(savedCart) as CartState;
setCart(parsedCart);
}
} catch (error) {
console.error('Erreur lors du chargement du panier:', error);
} finally {
setIsHydrated(true);
}
}, []);
// Sauvegarder le panier dans localStorage à chaque modification
useEffect(() => {
if (isHydrated) {
try {
localStorage.setItem(CART_STORAGE_KEY, JSON.stringify(cart));
} catch (error) {
console.error('Erreur lors de la sauvegarde du panier:', error);
}
}
}, [cart, isHydrated]);
// Calculer les totaux
const calculateTotals = useCallback((items: CartItem[]): { total: number; itemCount: number } => {
const total = items.reduce((sum, item) => sum + (parseFloat(item.price) * item.quantity), 0);
const itemCount = items.reduce((sum, item) => sum + item.quantity, 0);
return { total, itemCount };
}, []);
// Ajouter un produit au panier
const addToCart = useCallback((item: Omit<CartItem, 'quantity' | 'total'>, quantity = 1) => {
setCart(prevCart => {
const existingItem = prevCart.items.find(cartItem => cartItem.id === item.id);
let newItems: CartItem[];
if (existingItem) {
// Mettre à jour la quantité si le produit existe déjà
newItems = prevCart.items.map(cartItem => {
if (cartItem.id === item.id) {
const newQuantity = cartItem.quantity + quantity;
return {
...cartItem,
quantity: newQuantity,
total: parseFloat(cartItem.price) * newQuantity,
};
}
return cartItem;
});
} else {
// Ajouter un nouveau produit
const newItem: CartItem = {
...item,
quantity,
total: parseFloat(item.price) * quantity,
};
newItems = [...prevCart.items, newItem];
}
const { total, itemCount } = calculateTotals(newItems);
return {
items: newItems,
total,
itemCount,
};
});
}, [calculateTotals]);
// Supprimer un produit du panier
const removeFromCart = useCallback((id: number) => {
setCart(prevCart => {
const newItems = prevCart.items.filter(item => item.id !== id);
const { total, itemCount } = calculateTotals(newItems);
return {
items: newItems,
total,
itemCount,
};
});
}, [calculateTotals]);
// Mettre à jour la quantité d'un produit
const updateQuantity = useCallback((id: number, quantity: number) => {
if (quantity <= 0) {
removeFromCart(id);
return;
}
setCart(prevCart => {
const newItems = prevCart.items.map(item => {
if (item.id === id) {
return {
...item,
quantity,
total: parseFloat(item.price) * quantity,
};
}
return item;
});
const { total, itemCount } = calculateTotals(newItems);
return {
items: newItems,
total,
itemCount,
};
});
}, [calculateTotals, removeFromCart]);
// Vider le panier
const clearCart = useCallback(() => {
setCart(initialCartState);
}, []);
// Obtenir la quantité d'un produit dans le panier
const getItemQuantity = useCallback((id: number): number => {
const item = cart.items.find(item => item.id === id);
return item ? item.quantity : 0;
}, [cart.items]);
// Vérifier si un produit est dans le panier
const isInCart = useCallback((id: number): boolean => {
return cart.items.some(item => item.id === id);
}, [cart.items]);
// Obtenir le prix total du panier
const getTotalPrice = useCallback((): number => {
return cart.total;
}, [cart.total]);
// Obtenir le nombre total d'articles
const getItemCount = useCallback((): number => {
return cart.itemCount;
}, [cart.itemCount]);
const contextValue: UseCartReturn = {
cart,
addToCart,
removeFromCart,
updateQuantity,
clearCart,
getItemQuantity,
isInCart,
getTotalPrice,
getItemCount,
};
return (
<CartContext.Provider value={contextValue}>
{children}
</CartContext.Provider>
);
}
export function useCart(): UseCartReturn {
const context = useContext(CartContext);
if (context === undefined) {
throw new Error('useCart doit être utilisé dans un CartProvider');
}
return context;
}

205
src/hooks/useAuth.tsx Normal file
View File

@@ -0,0 +1,205 @@
// src/hooks/useAuth.tsx
'use client';
import React, { createContext, useContext, useEffect, useState, ReactNode } from 'react';
import { AuthService } from '@/lib/auth';
import {
User,
AuthContextType,
LoginCredentials,
RegisterCredentials,
AuthResponse
} from '@/types/auth';
const AuthContext = createContext<AuthContextType | undefined>(undefined);
interface AuthProviderProps {
children: ReactNode;
}
export function AuthProvider({ children }: AuthProviderProps) {
const [user, setUser] = useState<User | null>(null);
const [token, setToken] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [isHydrated, setIsHydrated] = useState(false);
const isAuthenticated = !!user && !!token;
// Initialisation : vérifier si l'utilisateur est déjà connecté
useEffect(() => {
initializeAuth();
}, []);
async function initializeAuth() {
setIsLoading(true);
try {
// Marquer comme hydraté d'abord
setIsHydrated(true);
const storedToken = localStorage.getItem('auth_token');
const storedUser = localStorage.getItem('auth_user');
if (storedToken && storedUser) {
// Valider le token
const isValid = await AuthService.validateToken(storedToken);
if (isValid) {
setToken(storedToken);
setUser(JSON.parse(storedUser));
} else {
// Token expiré, nettoyer le storage
localStorage.removeItem('auth_token');
localStorage.removeItem('auth_user');
}
}
} catch (error) {
console.error('Erreur lors de l\'initialisation de l\'auth:', error);
} finally {
setIsLoading(false);
}
}
async function login(credentials: LoginCredentials): Promise<AuthResponse> {
setIsLoading(true);
try {
const response = await AuthService.login(credentials);
if (response.success && response.data) {
const { token: newToken, user: userData } = response.data;
// Sauvegarder dans le state
setToken(newToken);
setUser(userData);
// Sauvegarder dans localStorage seulement si hydraté
if (isHydrated) {
localStorage.setItem('auth_token', newToken);
localStorage.setItem('auth_user', JSON.stringify(userData));
}
return response;
}
return response;
} catch (error) {
console.error('Erreur de connexion:', error);
return {
success: false,
message: 'Erreur de connexion'
};
} finally {
setIsLoading(false);
}
}
async function register(credentials: RegisterCredentials): Promise<AuthResponse> {
setIsLoading(true);
try {
const response = await AuthService.register(credentials);
if (response.success && response.data) {
const { token: newToken, user: userData } = response.data;
// Connexion automatique après inscription
setToken(newToken);
setUser(userData);
if (isHydrated) {
localStorage.setItem('auth_token', newToken);
localStorage.setItem('auth_user', JSON.stringify(userData));
}
}
return response;
} catch (error) {
console.error('Erreur d\'inscription:', error);
return {
success: false,
message: 'Erreur lors de l\'inscription'
};
} finally {
setIsLoading(false);
}
}
function logout() {
setUser(null);
setToken(null);
if (isHydrated) {
localStorage.removeItem('auth_token');
localStorage.removeItem('auth_user');
}
// Optionnel : rediriger vers la page d'accueil
window.location.href = '/';
}
async function updateUser(userData: Partial<User>): Promise<boolean> {
if (!user || !token) return false;
try {
const success = await AuthService.updateUser(user.id, userData, token);
if (success) {
// Mettre à jour l'utilisateur dans le state
const updatedUser = { ...user, ...userData };
setUser(updatedUser);
if (isHydrated) {
localStorage.setItem('auth_user', JSON.stringify(updatedUser));
}
return true;
}
return false;
} catch (error) {
console.error('Erreur de mise à jour:', error);
return false;
}
}
const contextValue: AuthContextType = {
user,
token,
isLoading,
isAuthenticated,
login,
register,
logout,
updateUser,
};
return (
<AuthContext.Provider value={contextValue}>
{children}
</AuthContext.Provider>
);
}
export function useAuth(): AuthContextType {
const context = useContext(AuthContext);
if (context === undefined) {
throw new Error('useAuth doit être utilisé dans un AuthProvider');
}
return context;
}
// Hook pour protéger les routes
export function useRequireAuth() {
const { isAuthenticated, isLoading } = useAuth();
useEffect(() => {
if (!isLoading && !isAuthenticated) {
// Rediriger vers la page de connexion
window.location.href = '/auth/login?redirect=' + encodeURIComponent(window.location.pathname);
}
}, [isAuthenticated, isLoading]);
return { isAuthenticated, isLoading };
}

218
src/hooks/useCart.ts.ol Normal file
View File

@@ -0,0 +1,218 @@
// src/hooks/useCart.ts
'use client';
import { useState, useEffect, useCallback } from 'react';
// Types du panier
export interface CartItem {
id: number;
name: string;
price: string;
image: string;
quantity: number;
total: number;
}
export interface CartState {
items: CartItem[];
total: number;
itemCount: number;
}
const CART_STORAGE_KEY = 'melhfa_cart';
const initialCartState: CartState = {
items: [],
total: 0,
itemCount: 0,
};
// Hook principal
export function useCart() {
const [cart, setCart] = useState<CartState>(initialCartState);
const [isHydrated, setIsHydrated] = useState(false);
// Calculer totaux
const calculateTotals = useCallback((items: CartItem[]) => {
const total = items.reduce((sum, item) => sum + item.total, 0);
const itemCount = items.reduce((sum, item) => sum + item.quantity, 0);
console.log('🧮 Calcul totaux:', { items: items.length, total, itemCount }); // DEBUG
return { total, itemCount };
}, []);
// Charger depuis localStorage
useEffect(() => {
if (typeof window !== 'undefined') {
try {
const saved = localStorage.getItem(CART_STORAGE_KEY);
if (saved) {
const parsed = JSON.parse(saved);
console.log('📱 Panier chargé depuis localStorage:', parsed); // DEBUG
setCart(parsed);
} else {
console.log('📱 Aucun panier en localStorage'); // DEBUG
}
} catch (error) {
console.error('❌ Erreur chargement panier:', error);
} finally {
setIsHydrated(true);
console.log('✅ useCart hydraté'); // DEBUG
}
}
}, []);
// Sauvegarder
useEffect(() => {
if (isHydrated && typeof window !== 'undefined') {
try {
localStorage.setItem(CART_STORAGE_KEY, JSON.stringify(cart));
console.log('💾 Panier sauvegardé:', cart); // DEBUG
window.dispatchEvent(new CustomEvent('cartUpdated', { detail: cart }));
console.log('📢 Événement cartUpdated déclenché avec:', cart); // DEBUG
} catch (error) {
console.error('❌ Erreur sauvegarde panier:', error);
}
}
}, [cart, isHydrated]);
// Ajouter au panier
const addToCart = useCallback((item: Omit<CartItem, 'quantity' | 'total'>, quantity = 1) => {
console.log('🛒 addToCart appelé avec:', { item, quantity }); // DEBUG
setCart(prevCart => {
console.log('🛒 Panier actuel:', prevCart); // DEBUG
const existingIndex = prevCart.items.findIndex(cartItem => cartItem.id === item.id);
let newItems: CartItem[];
if (existingIndex >= 0) {
console.log('🔄 Produit existant trouvé à l\'index:', existingIndex); // DEBUG
newItems = prevCart.items.map((cartItem, index) => {
if (index === existingIndex) {
const newQuantity = cartItem.quantity + quantity;
return {
...cartItem,
quantity: newQuantity,
total: parseFloat(cartItem.price) * newQuantity,
};
}
return cartItem;
});
} else {
console.log('🆕 Nouveau produit ajouté'); // DEBUG
const newItem: CartItem = {
...item,
quantity,
total: parseFloat(item.price) * quantity,
};
newItems = [...prevCart.items, newItem];
}
const { total, itemCount } = calculateTotals(newItems);
const newCart = { items: newItems, total, itemCount };
console.log('🛒 Nouveau panier calculé:', newCart); // DEBUG
return newCart;
});
}, [calculateTotals]);
// Supprimer du panier
const removeFromCart = useCallback((id: number) => {
console.log('🗑️ removeFromCart appelé pour ID:', id); // DEBUG
setCart(prevCart => {
const newItems = prevCart.items.filter(item => item.id !== id);
const { total, itemCount } = calculateTotals(newItems);
const newCart = { items: newItems, total, itemCount };
console.log('🗑️ Panier après suppression:', newCart); // DEBUG
return newCart;
});
}, [calculateTotals]);
// Mettre à jour quantité
const updateQuantity = useCallback((id: number, quantity: number) => {
console.log('🔢 updateQuantity appelé:', { id, quantity }); // DEBUG
if (quantity <= 0) {
removeFromCart(id);
return;
}
setCart(prevCart => {
const newItems = prevCart.items.map(item => {
if (item.id === id) {
return {
...item,
quantity,
total: parseFloat(item.price) * quantity,
};
}
return item;
});
const { total, itemCount } = calculateTotals(newItems);
const newCart = { items: newItems, total, itemCount };
console.log('🔢 Panier après mise à jour quantité:', newCart); // DEBUG
return newCart;
});
}, [calculateTotals, removeFromCart]);
// Vider panier
const clearCart = useCallback(() => {
console.log('🧹 clearCart appelé'); // DEBUG
setCart(initialCartState);
}, []);
// Utilitaires
const getItemQuantity = useCallback((id: number) => {
const item = cart.items.find(item => item.id === id);
const quantity = item ? item.quantity : 0;
console.log('📊 getItemQuantity pour ID', id, ':', quantity); // DEBUG
return quantity;
}, [cart.items]);
const isInCart = useCallback((id: number) => {
const inCart = cart.items.some(item => item.id === id);
console.log('🔍 isInCart pour ID', id, ':', inCart); // DEBUG
return inCart;
}, [cart.items]);
const getTotalPrice = useCallback(() => {
console.log('💰 getTotalPrice:', cart.total); // DEBUG
return cart.total;
}, [cart.total]);
const getItemCount = useCallback(() => {
console.log('🔢 getItemCount:', cart.itemCount); // DEBUG
return cart.itemCount;
}, [cart.itemCount]);
// Log de l'état actuel du hook
console.log('🎯 useCart state actuel:', {
isHydrated,
cartItemCount: cart.itemCount,
cartTotal: cart.total,
itemsLength: cart.items.length
}); // DEBUG
return {
cart,
addToCart,
removeFromCart,
updateQuantity,
clearCart,
getItemQuantity,
isInCart,
getTotalPrice,
getItemCount,
};
}
// Export par défaut pour compatibilité
export default useCart;

76
src/hooks/useCartSync.ts Normal file
View File

@@ -0,0 +1,76 @@
// src/hooks/useCartSync.ts
'use client';
import { useEffect, useState } from 'react';
import { useCart } from './useCart';
// Hook pour forcer la synchronisation du panier
export function useCartSync() {
const cart = useCart();
const [, forceUpdate] = useState({});
// Forcer une mise à jour des composants
const forceSyncUpdate = () => {
forceUpdate({});
};
// Écouter les changements de localStorage pour synchroniser entre onglets
useEffect(() => {
const handleStorageChange = (e: StorageEvent) => {
if (e.key === 'melhfa_cart') {
forceSyncUpdate();
}
};
window.addEventListener('storage', handleStorageChange);
return () => window.removeEventListener('storage', handleStorageChange);
}, []);
return {
...cart,
forceSyncUpdate
};
}
// Hook amélioré pour les composants qui ajoutent au panier
export function useCartActions() {
const { addToCart: originalAddToCart, ...cart } = useCart();
const [, forceUpdate] = useState({});
const addToCart = async (item: Parameters<typeof originalAddToCart>[0], quantity?: number) => {
try {
// Ajouter au panier
originalAddToCart(item, quantity);
// Forcer une mise à jour après un petit délai
setTimeout(() => {
forceUpdate({});
// Déclencher un événement personnalisé pour informer les autres composants
window.dispatchEvent(new CustomEvent('cartUpdated', {
detail: { action: 'add', item, quantity }
}));
}, 100);
return { success: true };
} catch (error) {
console.error('Erreur lors de l\'ajout au panier:', error);
return { success: false, error };
}
};
// Écouter les événements de mise à jour du panier
useEffect(() => {
const handleCartUpdate = () => {
forceUpdate({});
};
window.addEventListener('cartUpdated', handleCartUpdate);
return () => window.removeEventListener('cartUpdated', handleCartUpdate);
}, []);
return {
...cart,
addToCart
};
}

29
src/hooks/useHydration.ts Normal file
View File

@@ -0,0 +1,29 @@
// src/hooks/useHydration.ts
'use client';
import { useEffect, useState } from 'react';
/**
* Hook pour gérer l'hydratation et éviter les erreurs de rendu SSR/Client
* Retourne true seulement après que le composant soit monté côté client
*/
export function useHydration() {
const [isHydrated, setIsHydrated] = useState(false);
useEffect(() => {
setIsHydrated(true);
}, []);
return isHydrated;
}
/**
* Hook pour un rendu conditionnel après hydratation
* @param fallback - Valeur à retourner avant l'hydratation
* @param value - Valeur à retourner après l'hydratation
*/
export function useClientOnly<T>(fallback: T, value: T): T {
const isHydrated = useHydration();
return isHydrated ? value : fallback;
}

0
src/hooks/useWishlist.ts Normal file
View File

205
src/lib/auth.ts Normal file
View File

@@ -0,0 +1,205 @@
// src/lib/auth.ts
import { api } from './woocommerce';
import {
AuthResponse,
LoginCredentials,
RegisterCredentials,
User,
ApiError
} from '@/types/auth';
// Configuration pour l'authentification JWT
const JWT_API_URL = process.env.NEXT_PUBLIC_WC_API_URL;
export class AuthService {
/**
* Connexion utilisateur avec JWT - VERSION SIMPLIFIÉE
*/
static async login(credentials: LoginCredentials): Promise<AuthResponse> {
try {
console.log('Tentative de connexion à:', `${JWT_API_URL}/wp-json/jwt-auth/v1/token`);
const response = await fetch(`${JWT_API_URL}/wp-json/jwt-auth/v1/token`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
username: credentials.username,
password: credentials.password,
}),
});
const data = await response.json();
console.log('Réponse complète JWT:', data);
if (!response.ok) {
return {
success: false,
message: data.message || 'Erreur de connexion',
};
}
// Créer un utilisateur basique avec les données directement disponibles
const user: User = {
id: 0,
username: credentials.username,
email: data.user_email || '',
firstName: '',
lastName: '',
displayName: data.user_display_name || data.user_nicename || credentials.username,
role: 'customer',
};
console.log('✅ Connexion réussie ! Token reçu:', data.token ? 'Oui' : 'Non');
return {
success: true,
data: {
token: data.token,
user: user,
user_email: data.user_email || '',
user_nicename: data.user_nicename || '',
user_display_name: data.user_display_name || '',
},
};
} catch (error) {
console.error('Erreur lors de la connexion:', error);
return {
success: false,
message: 'Erreur de connexion au serveur',
};
}
}
/**
* Inscription d'un nouvel utilisateur
*/
static async register(credentials: RegisterCredentials): Promise<AuthResponse> {
try {
console.log('Tentative de création de compte...');
// Créer le compte utilisateur via WooCommerce REST API
const customerData = {
username: credentials.username,
email: credentials.email,
password: credentials.password,
first_name: credentials.firstName || '',
last_name: credentials.lastName || '',
};
const response = await api.post('customers', customerData);
console.log('Réponse création client:', response.data);
if (response.data) {
// Connexion automatique après inscription
return await this.login({
username: credentials.username,
password: credentials.password,
});
}
return {
success: false,
message: 'Erreur lors de la création du compte',
};
} catch (error: any) {
console.error('Erreur lors de l\'inscription:', error);
// Gestion des erreurs spécifiques WooCommerce
if (error.response?.data?.message) {
return {
success: false,
message: error.response.data.message,
};
}
return {
success: false,
message: 'Erreur lors de la création du compte',
};
}
}
/**
* Récupérer les informations complètes de l'utilisateur - VERSION SIMPLIFIÉE
*/
static async getUserInfo(token: string): Promise<User> {
// Pour l'instant, retournons un utilisateur basique
// On implémentera la récupération complète plus tard
return {
id: 0,
username: 'utilisateur',
email: '',
firstName: '',
lastName: '',
displayName: 'Utilisateur connecté',
role: 'customer',
};
}
/**
* Mettre à jour les informations utilisateur
*/
static async updateUser(userId: number, userData: Partial<User>, token: string): Promise<boolean> {
try {
const updateData: any = {};
if (userData.firstName) updateData.first_name = userData.firstName;
if (userData.lastName) updateData.last_name = userData.lastName;
if (userData.email) updateData.email = userData.email;
if (userData.billing) updateData.billing = userData.billing;
if (userData.shipping) updateData.shipping = userData.shipping;
const response = await api.put(`customers/${userId}`, updateData);
return response.data ? true : false;
} catch (error) {
console.error('Erreur lors de la mise à jour:', error);
return false;
}
}
/**
* Valider un token JWT - VERSION SIMPLIFIÉE
*/
static async validateToken(token: string): Promise<boolean> {
try {
const response = await fetch(`${JWT_API_URL}/wp-json/jwt-auth/v1/token/validate`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`,
},
});
console.log('Validation token:', response.ok);
return response.ok;
} catch (error) {
console.log('Erreur validation token:', error);
return false;
}
}
/**
* Rafraîchir un token JWT (non implémenté dans le plugin standard)
*/
static async refreshToken(token: string): Promise<string | null> {
return null;
}
/**
* Récupérer les commandes de l'utilisateur
*/
static async getUserOrders(userId: number): Promise<any[]> {
try {
const response = await api.get('orders', { customer: userId });
return response.data || [];
} catch (error) {
console.error('Erreur lors de la récupération des commandes:', error);
return [];
}
}
}

269
src/lib/utils.ts Normal file
View File

@@ -0,0 +1,269 @@
// src/lib/utils.ts
import { type ClassValue, clsx } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]): string {
return twMerge(clsx(inputs));
}
// Formatage des prix
export function formatCurrency(
amount: number | string,
currency: string = 'MRU',
locale: string = 'fr-FR'
): string {
const numAmount = typeof amount === 'string' ? parseFloat(amount) : amount;
if (isNaN(numAmount)) return '0 MRU';
return new Intl.NumberFormat(locale, {
style: 'currency',
currency: currency === 'MRU' ? 'EUR' : currency, // Fallback pour MRU
minimumFractionDigits: 0,
maximumFractionDigits: 0,
}).format(numAmount).replace('€', 'MRU');
}
// Formatage des prix simples
export function formatPrice(price: number | string, currency: string = 'MRU'): string {
const numPrice = typeof price === 'string' ? parseFloat(price) : price;
if (isNaN(numPrice)) return '0 MRU';
return `${numPrice.toLocaleString('fr-FR')} ${currency}`;
}
// Génération d'un slug à partir d'un texte
export function generateSlug(text: string): string {
return text
.toLowerCase()
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '') // Supprimer les accents
.replace(/[^a-z0-9 -]/g, '') // Supprimer les caractères spéciaux
.replace(/\s+/g, '-') // Remplacer les espaces par des tirets
.replace(/-+/g, '-') // Supprimer les tirets multiples
.trim(); // Supprimer les tirets en début et fin
}
// Validation d'email
export function isValidEmail(email: string): boolean {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return emailRegex.test(email);
}
// Validation de téléphone mauritanien
export function isValidMauritanianPhone(phone: string): boolean {
// Format: +222 XX XX XX XX ou 222 XX XX XX XX ou XX XX XX XX
const phoneRegex = /^(\+222|222)?[0-9\s]{8,}$/;
const cleaned = phone.replace(/\s/g, '');
return phoneRegex.test(cleaned) && cleaned.length >= 8;
}
// Formatage de téléphone mauritanien
export function formatMauritanianPhone(phone: string): string {
const cleaned = phone.replace(/\D/g, '');
if (cleaned.length === 8) {
return `+222 ${cleaned.slice(0, 2)} ${cleaned.slice(2, 4)} ${cleaned.slice(4, 6)} ${cleaned.slice(6, 8)}`;
}
if (cleaned.length === 11 && cleaned.startsWith('222')) {
const withoutCountryCode = cleaned.slice(3);
return `+222 ${withoutCountryCode.slice(0, 2)} ${withoutCountryCode.slice(2, 4)} ${withoutCountryCode.slice(4, 6)} ${withoutCountryCode.slice(6, 8)}`;
}
return phone;
}
// Debounce function
export function debounce<T extends (...args: any[]) => any>(
func: T,
delay: number
): (...args: Parameters<T>) => void {
let timeoutId: NodeJS.Timeout;
return (...args: Parameters<T>) => {
clearTimeout(timeoutId);
timeoutId = setTimeout(() => func(...args), delay);
};
}
// Calculer la distance de Levenshtein pour la recherche floue
export function levenshteinDistance(str1: string, str2: string): number {
const matrix = Array(str2.length + 1).fill(null).map(() => Array(str1.length + 1).fill(null));
for (let i = 0; i <= str1.length; i++) {
matrix[0]![i] = i;
}
for (let j = 0; j <= str2.length; j++) {
matrix[j]![0] = j;
}
for (let j = 1; j <= str2.length; j++) {
for (let i = 1; i <= str1.length; i++) {
const indicator = str1[i - 1] === str2[j - 1] ? 0 : 1;
matrix[j]![i] = Math.min(
matrix[j]![i - 1]! + 1, // deletion
matrix[j - 1]![i]! + 1, // insertion
matrix[j - 1]![i - 1]! + indicator // substitution
);
}
}
return matrix[str2.length]![str1.length]!;
}
// Recherche floue dans un tableau de chaînes
export function fuzzySearch(query: string, items: string[], threshold: number = 2): string[] {
const lowerQuery = query.toLowerCase();
return items.filter(item => {
const lowerItem = item.toLowerCase();
// Correspondance exacte
if (lowerItem.includes(lowerQuery)) return true;
// Correspondance floue
const distance = levenshteinDistance(lowerQuery, lowerItem);
return distance <= threshold;
});
}
// Générer un ID unique
export function generateId(prefix: string = ''): string {
const timestamp = Date.now().toString(36);
const randomPart = Math.random().toString(36).substr(2, 5);
return `${prefix}${timestamp}${randomPart}`;
}
// Copier dans le presse-papiers
export async function copyToClipboard(text: string): Promise<boolean> {
try {
await navigator.clipboard.writeText(text);
return true;
} catch (err) {
// Fallback pour les navigateurs plus anciens
const textArea = document.createElement('textarea');
textArea.value = text;
document.body.appendChild(textArea);
textArea.focus();
textArea.select();
try {
const successful = document.execCommand('copy');
document.body.removeChild(textArea);
return successful;
} catch (err) {
document.body.removeChild(textArea);
return false;
}
}
}
// Formater une date en français
export function formatDate(
date: Date | string,
options: Intl.DateTimeFormatOptions = {
year: 'numeric',
month: 'long',
day: 'numeric'
}
): string {
const dateObj = typeof date === 'string' ? new Date(date) : date;
return dateObj.toLocaleDateString('fr-FR', options);
}
// Calculer le temps relatif (il y a X jours)
export function getRelativeTime(date: Date | string): string {
const dateObj = typeof date === 'string' ? new Date(date) : date;
const now = new Date();
const diffInSeconds = Math.floor((now.getTime() - dateObj.getTime()) / 1000);
if (diffInSeconds < 60) return 'À l\'instant';
if (diffInSeconds < 3600) return `Il y a ${Math.floor(diffInSeconds / 60)} min`;
if (diffInSeconds < 86400) return `Il y a ${Math.floor(diffInSeconds / 3600)} h`;
if (diffInSeconds < 2592000) return `Il y a ${Math.floor(diffInSeconds / 86400)} j`;
if (diffInSeconds < 31536000) return `Il y a ${Math.floor(diffInSeconds / 2592000)} mois`;
return `Il y a ${Math.floor(diffInSeconds / 31536000)} an${Math.floor(diffInSeconds / 31536000) > 1 ? 's' : ''}`;
}
// Truncate text avec ellipsis
export function truncateText(text: string, maxLength: number): string {
if (text.length <= maxLength) return text;
return text.slice(0, maxLength).trim() + '...';
}
// Capitaliser la première lettre
export function capitalize(text: string): string {
return text.charAt(0).toUpperCase() + text.slice(1).toLowerCase();
}
// Calculer la note moyenne
export function calculateAverageRating(ratings: number[]): number {
if (ratings.length === 0) return 0;
const sum = ratings.reduce((acc, rating) => acc + rating, 0);
return Number((sum / ratings.length).toFixed(1));
}
// Vérifier si on est côté client
export function isClient(): boolean {
return typeof window !== 'undefined';
}
// Attendre un délai
export function sleep(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms));
}
// Mélanger un tableau (shuffle)
export function shuffleArray<T>(array: T[]): T[] {
const shuffled = [...array];
for (let i = shuffled.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[shuffled[i], shuffled[j]] = [shuffled[j]!, shuffled[i]!];
}
return shuffled;
}
// Grouper un tableau par une clé
export function groupBy<T, K extends keyof any>(
array: T[],
key: (item: T) => K
): Record<K, T[]> {
return array.reduce((groups, item) => {
const group = key(item);
if (!groups[group]) {
groups[group] = [];
}
groups[group]!.push(item);
return groups;
}, {} as Record<K, T[]>);
}
// Supprimer les doublons d'un tableau
export function removeDuplicates<T>(array: T[], key?: (item: T) => any): T[] {
if (!key) {
return [...new Set(array)];
}
const seen = new Set();
return array.filter(item => {
const keyValue = key(item);
if (seen.has(keyValue)) {
return false;
}
seen.add(keyValue);
return true;
});
}
// Vérifier si un objet est vide
export function isEmpty(obj: any): boolean {
if (obj == null) return true;
if (Array.isArray(obj) || typeof obj === 'string') return obj.length === 0;
if (obj instanceof Map || obj instanceof Set) return obj.size === 0;
return Object.keys(obj).length === 0;
}

218
src/lib/woocommerce.ts Normal file
View File

@@ -0,0 +1,218 @@
// src/lib/woocommerce.ts
// @ts-ignore
import WooCommerceRestApi from "@woocommerce/woocommerce-rest-api";
import { WooCommerceProduct, ApiResponse } from "@/types/woocommerce";
// Configuration de l'API WooCommerce
export const api = new WooCommerceRestApi({
url: process.env.NEXT_PUBLIC_WC_API_URL || "",
consumerKey: process.env.NEXT_PUBLIC_WC_CONSUMER_KEY || "",
consumerSecret: process.env.NEXT_PUBLIC_WC_CONSUMER_SECRET || "",
version: "wc/v3",
queryStringAuth: true,
});
// Fonctions utilitaires pour les prix
export const formatPrice = (price: string | number, currency: string = 'MRU'): string => {
const numPrice = typeof price === 'string' ? parseFloat(price) : price;
if (isNaN(numPrice) || numPrice === null || numPrice === undefined) {
return '0 MRU';
}
return `${numPrice.toLocaleString('fr-FR')} ${currency}`;
};
// Vérifier si un produit est en promotion
export const isOnSale = (product: WooCommerceProduct): boolean => {
return product.on_sale && product.sale_price !== '';
};
// Calculer le pourcentage de réduction
export const getDiscountPercentage = (product: WooCommerceProduct): number => {
if (!isOnSale(product)) return 0;
const regularPrice = parseFloat(product.regular_price);
const salePrice = parseFloat(product.sale_price);
if (isNaN(regularPrice) || isNaN(salePrice) || regularPrice === 0) return 0;
return Math.round(((regularPrice - salePrice) / regularPrice) * 100);
};
// Obtenir la première image d'un produit
export const getProductImage = (product: WooCommerceProduct): string => {
return product.images && product.images.length > 0 ? product.images[0]!.src : '/placeholder-product.jpg';
};
// Obtenir toutes les images d'un produit
export const getProductImages = (product: WooCommerceProduct): string[] => {
return product.images ? product.images.map(image => image.src) : [];
};
// Fonctions utilitaires pour obtenir le hex d'une couleur (pour ProductCard)
export const getColorHex = (colorName: string): string => {
const colorMap: { [key: string]: string } = {
'blanc': '#FFFFFF',
'blanc cassé': '#F8F8FF',
'beige': '#F5F5DC',
'crème': '#FFFDD0',
'ivoire': '#FFFFF0',
'noir': '#000000',
'gris': '#808080',
'bleu': '#0000FF',
'bleu marine': '#000080',
'bleu ciel': '#87CEEB',
'rouge': '#FF0000',
'bordeaux': '#800020',
'rose': '#FFC0CB',
'vert': '#008000',
'vert olive': '#808000',
'jaune': '#FFFF00',
'orange': '#FFA500',
'violet': '#800080',
'marron': '#A52A2A',
'doré': '#FFD700',
'argenté': '#C0C0C0',
'multicolore': '#FF6B6B'
};
return colorMap[colorName.toLowerCase()] || '#CCCCCC';
};
// Service WooCommerce
export class WooCommerceService {
// Récupérer tous les produits
static async getProducts(params?: {
page?: number;
per_page?: number;
category?: string;
search?: string;
orderby?: string;
order?: 'asc' | 'desc';
on_sale?: boolean;
featured?: boolean;
}): Promise<ApiResponse<WooCommerceProduct[]>> {
try {
const defaultParams = {
page: 1,
per_page: 20,
status: 'publish',
...params
};
const response = await api.get("products", defaultParams);
return {
data: response.data as WooCommerceProduct[],
success: true,
};
} catch (error) {
console.error("Erreur lors de la récupération des produits:", error);
return {
data: [],
success: false,
message: "Erreur lors de la récupération des produits"
};
}
}
// Récupérer un produit par son slug
static async getProductBySlug(slug: string): Promise<ApiResponse<WooCommerceProduct | null>> {
try {
const response = await api.get("products", { slug, status: 'publish' });
const products = response.data as WooCommerceProduct[];
return {
data: products.length > 0 ? products[0] || null : null,
success: true,
};
} catch (error) {
console.error("Erreur lors de la récupération du produit:", error);
return {
data: null,
success: false,
message: "Produit non trouvé"
};
}
}
// Récupérer un produit par son ID
static async getProductById(id: number): Promise<ApiResponse<WooCommerceProduct | null>> {
try {
const response = await api.get(`products/${id}`);
return {
data: response.data as WooCommerceProduct,
success: true,
};
} catch (error) {
console.error("Erreur lors de la récupération du produit:", error);
return {
data: null,
success: false,
message: "Produit non trouvé"
};
}
}
// Récupérer les produits en vedette
static async getFeaturedProducts(limit: number = 6): Promise<ApiResponse<WooCommerceProduct[]>> {
return this.getProducts({
featured: true,
per_page: limit,
orderby: 'date',
order: 'desc'
});
}
// Récupérer les produits en promotion
static async getSaleProducts(limit: number = 6): Promise<ApiResponse<WooCommerceProduct[]>> {
return this.getProducts({
on_sale: true,
per_page: limit,
orderby: 'date',
order: 'desc'
});
}
// Récupérer les nouvelles arrivées
static async getNewArrivals(limit: number = 8): Promise<ApiResponse<WooCommerceProduct[]>> {
return this.getProducts({
per_page: limit,
orderby: 'date',
order: 'desc'
});
}
// Récupérer les catégories
static async getCategories(): Promise<ApiResponse<any[]>> {
try {
const response = await api.get("products/categories", {
per_page: 100,
hide_empty: true
});
return {
data: response.data,
success: true,
};
} catch (error) {
console.error("Erreur lors de la récupération des catégories:", error);
return {
data: [],
success: false,
message: "Erreur lors de la récupération des catégories"
};
}
}
// Rechercher des produits
static async searchProducts(query: string, limit: number = 20): Promise<ApiResponse<WooCommerceProduct[]>> {
return this.getProducts({
search: query,
per_page: limit
});
}
}

79
src/types/auth.ts Normal file
View File

@@ -0,0 +1,79 @@
// src/types/auth.ts
export interface User {
id: number;
username: string;
email: string;
firstName?: string;
lastName?: string;
displayName: string;
avatar?: string;
role: string;
billing?: {
first_name: string;
last_name: string;
company: string;
address_1: string;
address_2: string;
city: string;
state: string;
postcode: string;
country: string;
email: string;
phone: string;
};
shipping?: {
first_name: string;
last_name: string;
company: string;
address_1: string;
address_2: string;
city: string;
state: string;
postcode: string;
country: string;
};
}
export interface AuthResponse {
success: boolean;
data?: {
token: string;
user: User;
user_email: string;
user_nicename: string;
user_display_name: string;
};
message?: string;
error?: string;
}
export interface LoginCredentials {
username: string;
password: string;
}
export interface RegisterCredentials {
username: string;
email: string;
password: string;
firstName?: string;
lastName?: string;
}
export interface AuthContextType {
user: User | null;
token: string | null;
isLoading: boolean;
isAuthenticated: boolean;
login: (credentials: LoginCredentials) => Promise<AuthResponse>;
register: (credentials: RegisterCredentials) => Promise<AuthResponse>;
logout: () => void;
updateUser: (userData: Partial<User>) => Promise<boolean>;
}
export interface ApiError {
success: false;
message: string;
code?: string;
}

133
src/types/woocommerce.ts Normal file
View File

@@ -0,0 +1,133 @@
// src/types/woocommerce.ts
export interface WooCommerceImage {
id: number;
date_created: string;
date_created_gmt: string;
date_modified: string;
date_modified_gmt: string;
src: string;
name: string;
alt: string;
}
export interface WooCommerceCategory {
id: number;
name: string;
slug: string;
}
export interface WooCommerceAttribute {
id: number;
name: string;
position: number;
visible: boolean;
variation: boolean;
options: string[];
}
export interface WooCommerceDimensions {
length: string;
width: string;
height: string;
}
export interface WooCommerceProduct {
id: number;
name: string;
slug: string;
permalink: string;
date_created: string;
date_created_gmt: string;
date_modified: string;
date_modified_gmt: string;
type: 'simple' | 'grouped' | 'external' | 'variable';
status: 'draft' | 'pending' | 'private' | 'publish';
featured: boolean;
catalog_visibility: 'visible' | 'catalog' | 'search' | 'hidden';
description: string;
short_description: string;
sku: string;
price: string;
regular_price: string;
sale_price: string;
date_on_sale_from: string | null;
date_on_sale_from_gmt: string | null;
date_on_sale_to: string | null;
date_on_sale_to_gmt: string | null;
price_html: string;
on_sale: boolean;
purchasable: boolean;
total_sales: number;
virtual: boolean;
downloadable: boolean;
downloads: unknown[];
download_limit: number;
download_expiry: number;
external_url: string;
button_text: string;
tax_status: 'taxable' | 'shipping' | 'none';
tax_class: string;
manage_stock: boolean;
stock_quantity: number | null;
stock_status: 'instock' | 'outofstock' | 'onbackorder';
backorders: 'no' | 'notify' | 'yes';
backorders_allowed: boolean;
backordered: boolean;
sold_individually: boolean;
weight: string;
dimensions: WooCommerceDimensions;
shipping_required: boolean;
shipping_taxable: boolean;
shipping_class: string;
shipping_class_id: number;
reviews_allowed: boolean;
average_rating: string;
rating_count: number;
related_ids: number[];
upsell_ids: number[];
cross_sell_ids: number[];
parent_id: number;
purchase_note: string;
categories: WooCommerceCategory[];
tags: unknown[];
images: WooCommerceImage[];
attributes: WooCommerceAttribute[];
default_attributes: unknown[];
variations: number[];
grouped_products: number[];
menu_order: number;
meta_data: unknown[];
}
// Types pour le panier - maintenant dans useCart.ts
export interface CartItem {
id: number;
name: string;
price: string;
image: string;
quantity: number;
total: number;
}
export interface CartState {
items: CartItem[];
total: number;
itemCount: number;
}
export interface ProductFilters {
category?: string;
priceRange?: [number, number];
inStock?: boolean;
onSale?: boolean;
search?: string;
sortBy?: 'price' | 'date' | 'popularity' | 'rating';
sortOrder?: 'asc' | 'desc';
}
export interface ApiResponse<T> {
data: T;
success: boolean;
message?: string;
}

View File

@@ -0,0 +1,234 @@
// src/app/boutique/page.tsx
import { Suspense } from 'react';
import { Metadata } from 'next';
import { WooCommerceService } from '@/lib/woocommerce';
import ProductGrid from '@/components/product/ProductGrid';
import ProductFilters from '@/components/product/ProductFilters';
import { Button } from '@/components/ui/button';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Grid3X3, List, SlidersHorizontal } from 'lucide-react';
export const metadata: Metadata = {
title: 'Boutique - Collection complète de Melhfa',
description: 'Découvrez notre collection complète de melhfa mauritaniennes. Voiles traditionnels et modernes, accessoires premium et créations artisanales.',
openGraph: {
title: 'Boutique MELHFA - Collection complète',
description: 'Découvrez notre collection complète de melhfa mauritaniennes.',
images: ['/images/boutique-og.jpg'],
},
};
interface BoutiquePageProps {
searchParams: {
page?: string;
category?: string;
filter?: string;
sort?: string;
search?: string;
};
}
export default async function BoutiquePage({
searchParams
}: BoutiquePageProps): Promise<JSX.Element> {
const page = Number(searchParams.page) || 1;
const category = searchParams.category;
const filter = searchParams.filter;
const sort = searchParams.sort;
const search = searchParams.search;
// Construire les paramètres pour l'API WooCommerce
const apiParams: any = {
page,
per_page: 20,
};
if (category) apiParams.category = category;
if (search) apiParams.search = search;
if (filter === 'sale') apiParams.on_sale = true;
if (filter === 'featured') apiParams.featured = true;
if (filter === 'new') {
apiParams.orderby = 'date';
apiParams.order = 'desc';
}
switch (sort) {
case 'price-asc':
apiParams.orderby = 'price';
apiParams.order = 'asc';
break;
case 'price-desc':
apiParams.orderby = 'price';
apiParams.order = 'desc';
break;
case 'name-asc':
apiParams.orderby = 'title';
apiParams.order = 'asc';
break;
case 'name-desc':
apiParams.orderby = 'title';
apiParams.order = 'desc';
break;
default:
apiParams.orderby = 'date';
apiParams.order = 'desc';
}
// Récupérer les produits et catégories
const [productsResponse, categoriesResponse] = await Promise.all([
WooCommerceService.getProducts(apiParams),
WooCommerceService.getCategories(),
]);
const products = productsResponse.data || [];
const categories = categoriesResponse.data || [];
return (
<div className="min-h-screen bg-white">
{/* Header de la page */}
<div className="bg-gray-50 py-16 mt-16">
<div className="max-w-[1400px] mx-auto px-6">
<div className="text-center space-y-4">
<h1 className="text-4xl md:text-5xl font-light tracking-wide text-black">
{filter === 'sale' && 'Promotions'}
{filter === 'featured' && 'Produits Vedettes'}
{filter === 'new' && 'Nouvelles Arrivées'}
{category && `Catégorie: ${category}`}
{search && `Résultats pour: "${search}"`}
{!filter && !category && !search && 'Boutique'}
</h1>
<p className="text-gray-600 max-w-2xl mx-auto">
{filter === 'sale' && 'Profitez de nos offres exceptionnelles sur une sélection de melhfa premium'}
{filter === 'featured' && 'Découvrez nos créations d\'exception, sélectionnées par nos artisans'}
{filter === 'new' && 'Les dernières créations de nos ateliers mauritaniens'}
{!filter && !category && !search && 'Découvrez notre collection complète de melhfa mauritaniennes, alliant tradition et modernité'}
</p>
</div>
</div>
</div>
<div className="max-w-[1400px] mx-auto px-6 py-12">
<div className="flex flex-col lg:flex-row gap-8">
{/* Sidebar Filters */}
<aside className="lg:w-64 flex-shrink-0">
<div className="sticky top-24">
<Suspense fallback={<FiltersSkeleton />}>
<ProductFilters
categories={categories}
currentCategory={category}
currentFilter={filter}
currentSort={sort}
/>
</Suspense>
</div>
</aside>
{/* Main Content */}
<main className="flex-1">
{/* Toolbar */}
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4 mb-8 pb-6 border-b border-gray-200">
<div className="flex items-center gap-4">
<span className="text-sm text-gray-600">
{products.length} produit{products.length > 1 ? 's' : ''} trouvé{products.length > 1 ? 's' : ''}
</span>
</div>
<div className="flex items-center gap-4">
{/* View Toggle */}
<Tabs defaultValue="grid" className="hidden sm:block">
<TabsList className="grid w-fit grid-cols-2">
<TabsTrigger value="grid" className="px-3">
<Grid3X3 className="w-4 h-4" />
</TabsTrigger>
<TabsTrigger value="list" className="px-3">
<List className="w-4 h-4" />
</TabsTrigger>
</TabsList>
</Tabs>
{/* Mobile Filters */}
<Button
variant="outline"
size="sm"
className="lg:hidden"
>
<SlidersHorizontal className="w-4 h-4 mr-2" />
Filtres
</Button>
</div>
</div>
{/* Products Grid */}
<Suspense fallback={<ProductGridSkeleton />}>
<ProductGrid
products={products}
currentPage={page}
hasMore={products.length === 20} // Supposer qu'il y a plus si on a 20 produits
/>
</Suspense>
{/* Pagination */}
{products.length === 20 && (
<div className="flex justify-center mt-12">
<Button
variant="outline"
className="px-8"
asChild
>
<a href={`/boutique?${new URLSearchParams({
...searchParams,
page: (page + 1).toString()
}).toString()}`}>
Charger plus de produits
</a>
</Button>
</div>
)}
</main>
</div>
</div>
</div>
);
}
// Composants de skeleton pour le chargement
function FiltersSkeleton(): JSX.Element {
return (
<div className="space-y-6">
<div className="space-y-3">
<div className="h-4 bg-gray-200 rounded w-20" />
<div className="space-y-2">
{[...Array(5)].map((_, i) => (
<div key={i} className="h-3 bg-gray-200 rounded w-full" />
))}
</div>
</div>
<div className="space-y-3">
<div className="h-4 bg-gray-200 rounded w-16" />
<div className="space-y-2">
{[...Array(4)].map((_, i) => (
<div key={i} className="h-3 bg-gray-200 rounded w-full" />
))}
</div>
</div>
</div>
);
}
function ProductGridSkeleton(): JSX.Element {
return (
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-6">
{[...Array(12)].map((_, i) => (
<div key={i} className="animate-pulse">
<div className="aspect-[3/4] bg-gray-200 rounded-lg mb-4" />
<div className="space-y-2">
<div className="h-4 bg-gray-200 rounded w-3/4" />
<div className="h-4 bg-gray-200 rounded w-1/2" />
<div className="h-8 bg-gray-200 rounded w-full" />
</div>
</div>
))}
</div>
);
}

View File

@@ -0,0 +1,529 @@
// src/app/checkout/page.tsx
'use client';
import { useState, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { useCartActions } from '@/hooks/useCartSync';import { formatPrice } from '@/lib/woocommerce';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Separator } from '@/components/ui/separator';
import { Badge } from '@/components/ui/badge';
import { Checkbox } from '@/components/ui/checkbox';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue
} from '@/components/ui/select';
import { Textarea } from '@/components/ui/textarea';
import {
CreditCard,
Lock,
Truck,
MapPin,
Phone,
Mail,
User,
ShoppingBag,
AlertCircle,
CheckCircle
} from 'lucide-react';
import Image from 'next/image';
import Link from 'next/link';
interface CheckoutFormData {
// Informations personnelles
firstName: string;
lastName: string;
email: string;
phone: string;
// Adresse de livraison
address: string;
city: string;
postalCode: string;
country: string;
// Options
notes: string;
createAccount: boolean;
newsletterOptIn: boolean;
// Paiement
paymentMethod: 'card' | 'cash' | 'transfer';
}
export default function CheckoutPage(): JSX.Element {
const router = useRouter();
const { cart, clearCart } = useCart();
const [isLoading, setIsLoading] = useState(false);
const [errors, setErrors] = useState<Record<string, string>>({});
const [currentStep, setCurrentStep] = useState(1);
const [formData, setFormData] = useState<CheckoutFormData>({
firstName: '',
lastName: '',
email: '',
phone: '',
address: '',
city: '',
postalCode: '',
country: 'MR',
notes: '',
createAccount: false,
newsletterOptIn: false,
paymentMethod: 'card',
});
// Redirection si le panier est vide
useEffect(() => {
if (cart.items.length === 0) {
router.push('/panier');
}
}, [cart.items.length, router]);
const subtotal = cart.total;
const shipping = subtotal >= 50000 ? 0 : 5000;
const tax = 0; // Pas de TVA en Mauritanie pour la démo
const total = subtotal + shipping + tax;
const handleInputChange = (field: keyof CheckoutFormData, value: string | boolean): void => {
setFormData(prev => ({ ...prev, [field]: value }));
// Effacer l'erreur si elle existe
if (errors[field]) {
setErrors(prev => ({ ...prev, [field]: '' }));
}
};
const validateForm = (): boolean => {
const newErrors: Record<string, string> = {};
// Validation des champs obligatoires
if (!formData.firstName.trim()) newErrors.firstName = 'Le prénom est requis';
if (!formData.lastName.trim()) newErrors.lastName = 'Le nom est requis';
if (!formData.email.trim()) newErrors.email = 'L\'email est requis';
if (!formData.phone.trim()) newErrors.phone = 'Le téléphone est requis';
if (!formData.address.trim()) newErrors.address = 'L\'adresse est requise';
if (!formData.city.trim()) newErrors.city = 'La ville est requise';
// Validation de l'email
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (formData.email && !emailRegex.test(formData.email)) {
newErrors.email = 'Format d\'email invalide';
}
// Validation du téléphone mauritanien
const phoneRegex = /^(\+222|222)?[0-9]{8}$/;
if (formData.phone && !phoneRegex.test(formData.phone.replace(/\s/g, ''))) {
newErrors.phone = 'Format de téléphone invalide (ex: +222 XX XX XX XX)';
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
const handleSubmit = async (e: React.FormEvent): Promise<void> => {
e.preventDefault();
if (!validateForm()) return;
setIsLoading(true);
try {
// Simuler l'appel API
await new Promise(resolve => setTimeout(resolve, 2000));
// Simuler le succès
clearCart();
router.push('/checkout/success');
} catch (error) {
console.error('Erreur lors de la commande:', error);
// Gérer l'erreur
} finally {
setIsLoading(false);
}
};
if (cart.items.length === 0) {
return null; // Le useEffect redirigera
}
return (
<div className="min-h-screen bg-gray-50 pt-20">
<div className="max-w-7xl mx-auto px-6 py-8">
{/* Header */}
<div className="mb-8">
<h1 className="text-3xl font-light tracking-wide text-black mb-2">
Finaliser votre commande
</h1>
<p className="text-gray-600">
Complétez vos informations pour finaliser votre achat
</p>
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
{/* Formulaire */}
<div className="lg:col-span-2 space-y-6">
<form onSubmit={handleSubmit} className="space-y-6">
{/* Informations personnelles */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<User className="w-5 h-5" />
Informations personnelles
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="firstName">Prénom *</Label>
<Input
id="firstName"
value={formData.firstName}
onChange={(e) => handleInputChange('firstName', e.target.value)}
placeholder="Votre prénom"
className={errors.firstName ? 'border-red-500' : ''}
/>
{errors.firstName && (
<p className="text-sm text-red-500">{errors.firstName}</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="lastName">Nom *</Label>
<Input
id="lastName"
value={formData.lastName}
onChange={(e) => handleInputChange('lastName', e.target.value)}
placeholder="Votre nom"
className={errors.lastName ? 'border-red-500' : ''}
/>
{errors.lastName && (
<p className="text-sm text-red-500">{errors.lastName}</p>
)}
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="email">Email *</Label>
<Input
id="email"
type="email"
value={formData.email}
onChange={(e) => handleInputChange('email', e.target.value)}
placeholder="votre@email.com"
className={errors.email ? 'border-red-500' : ''}
/>
{errors.email && (
<p className="text-sm text-red-500">{errors.email}</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="phone">Téléphone *</Label>
<Input
id="phone"
value={formData.phone}
onChange={(e) => handleInputChange('phone', e.target.value)}
placeholder="+222 XX XX XX XX"
className={errors.phone ? 'border-red-500' : ''}
/>
{errors.phone && (
<p className="text-sm text-red-500">{errors.phone}</p>
)}
</div>
</div>
</CardContent>
</Card>
{/* Adresse de livraison */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Truck className="w-5 h-5" />
Adresse de livraison
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="address">Adresse *</Label>
<Input
id="address"
value={formData.address}
onChange={(e) => handleInputChange('address', e.target.value)}
placeholder="Rue, quartier..."
className={errors.address ? 'border-red-500' : ''}
/>
{errors.address && (
<p className="text-sm text-red-500">{errors.address}</p>
)}
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="space-y-2">
<Label htmlFor="city">Ville *</Label>
<Select
value={formData.city}
onValueChange={(value) => handleInputChange('city', value)}
>
<SelectTrigger className={errors.city ? 'border-red-500' : ''}>
<SelectValue placeholder="Choisir une ville" />
</SelectTrigger>
<SelectContent>
<SelectItem value="nouakchott">Nouakchott</SelectItem>
<SelectItem value="nouadhibou">Nouadhibou</SelectItem>
<SelectItem value="rosso">Rosso</SelectItem>
<SelectItem value="kaedi">Kaédi</SelectItem>
<SelectItem value="zouerate">Zouérate</SelectItem>
<SelectItem value="atar">Atar</SelectItem>
</SelectContent>
</Select>
{errors.city && (
<p className="text-sm text-red-500">{errors.city}</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="postalCode">Code postal</Label>
<Input
id="postalCode"
value={formData.postalCode}
onChange={(e) => handleInputChange('postalCode', e.target.value)}
placeholder="Code postal"
/>
</div>
<div className="space-y-2">
<Label htmlFor="country">Pays</Label>
<Select
value={formData.country}
onValueChange={(value) => handleInputChange('country', value)}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="MR">Mauritanie</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="notes">Instructions de livraison (optionnel)</Label>
<Textarea
id="notes"
value={formData.notes}
onChange={(e) => handleInputChange('notes', e.target.value)}
placeholder="Instructions spéciales pour la livraison..."
rows={3}
/>
</div>
</CardContent>
</Card>
{/* Méthode de paiement */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<CreditCard className="w-5 h-5" />
Méthode de paiement
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-3">
<div className="flex items-center space-x-2 p-4 border rounded-lg cursor-pointer hover:bg-gray-50">
<input
type="radio"
name="paymentMethod"
value="card"
checked={formData.paymentMethod === 'card'}
onChange={(e) => handleInputChange('paymentMethod', e.target.value as 'card')}
className="w-4 h-4"
/>
<div className="flex-1">
<div className="flex items-center gap-2">
<CreditCard className="w-4 h-4" />
<span className="font-medium">Carte bancaire</span>
</div>
<p className="text-sm text-gray-600">Visa, MasterCard</p>
</div>
</div>
<div className="flex items-center space-x-2 p-4 border rounded-lg cursor-pointer hover:bg-gray-50">
<input
type="radio"
name="paymentMethod"
value="cash"
checked={formData.paymentMethod === 'cash'}
onChange={(e) => handleInputChange('paymentMethod', e.target.value as 'cash')}
className="w-4 h-4"
/>
<div className="flex-1">
<div className="flex items-center gap-2">
<MapPin className="w-4 h-4" />
<span className="font-medium">Paiement à la livraison</span>
</div>
<p className="text-sm text-gray-600">Espèces uniquement</p>
</div>
</div>
<div className="flex items-center space-x-2 p-4 border rounded-lg cursor-pointer hover:bg-gray-50">
<input
type="radio"
name="paymentMethod"
value="transfer"
checked={formData.paymentMethod === 'transfer'}
onChange={(e) => handleInputChange('paymentMethod', e.target.value as 'transfer')}
className="w-4 h-4"
/>
<div className="flex-1">
<div className="flex items-center gap-2">
<Phone className="w-4 h-4" />
<span className="font-medium">Virement bancaire</span>
</div>
<p className="text-sm text-gray-600">BIM, BMCI, GBM</p>
</div>
</div>
</div>
</CardContent>
</Card>
{/* Options */}
<Card>
<CardContent className="pt-6 space-y-4">
<div className="flex items-center space-x-2">
<Checkbox
id="createAccount"
checked={formData.createAccount}
onCheckedChange={(checked) => handleInputChange('createAccount', checked as boolean)}
/>
<Label htmlFor="createAccount" className="text-sm">
Créer un compte pour suivre mes commandes
</Label>
</div>
<div className="flex items-center space-x-2">
<Checkbox
id="newsletter"
checked={formData.newsletterOptIn}
onCheckedChange={(checked) => handleInputChange('newsletterOptIn', checked as boolean)}
/>
<Label htmlFor="newsletter" className="text-sm">
Recevoir les offres et nouveautés par email
</Label>
</div>
</CardContent>
</Card>
</form>
</div>
{/* Résumé de commande */}
<div className="space-y-6">
{/* Articles */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<ShoppingBag className="w-5 h-5" />
Votre commande ({cart.itemCount} articles)
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{cart.items.map((item) => (
<div key={item.id} className="flex gap-3">
<div className="relative w-16 h-16 flex-shrink-0">
<Image
src={item.image}
alt={item.name}
fill
className="object-cover rounded-lg"
/>
</div>
<div className="flex-1 min-w-0">
<h4 className="font-medium text-sm truncate">{item.name}</h4>
<p className="text-sm text-gray-600">Qté: {item.quantity}</p>
<p className="font-medium">{formatPrice(item.total)}</p>
</div>
</div>
))}
</CardContent>
</Card>
{/* Résumé des prix */}
<Card>
<CardContent className="pt-6 space-y-3">
<div className="flex justify-between">
<span>Sous-total</span>
<span>{formatPrice(subtotal)}</span>
</div>
<div className="flex justify-between">
<span>Livraison</span>
<span>{shipping === 0 ? 'Gratuite' : formatPrice(shipping)}</span>
</div>
{tax > 0 && (
<div className="flex justify-between">
<span>TVA</span>
<span>{formatPrice(tax)}</span>
</div>
)}
<Separator />
<div className="flex justify-between text-lg font-medium">
<span>Total</span>
<span>{formatPrice(total)}</span>
</div>
</CardContent>
</Card>
{/* Bouton de commande */}
<Button
onClick={handleSubmit}
disabled={isLoading}
size="lg"
className="w-full bg-black text-white hover:bg-gray-800 py-4"
>
{isLoading ? (
<div className="flex items-center gap-2">
<div className="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin" />
Traitement en cours...
</div>
) : (
<>
<Lock className="w-5 h-5 mr-2" />
Confirmer la commande
</>
)}
</Button>
{/* Sécurité */}
<div className="text-center space-y-2">
<div className="flex items-center justify-center gap-2 text-sm text-gray-600">
<Lock className="w-4 h-4" />
<span>Paiement 100% sécurisé</span>
</div>
<p className="text-xs text-gray-500">
Vos données sont protégées par cryptage SSL
</p>
</div>
{/* Retour au panier */}
<Button variant="outline" className="w-full" asChild>
<Link href="/panier">
Retour au panier
</Link>
</Button>
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,227 @@
// src/app/checkout/success/page.tsx
'use client';
import { useEffect, useState } from 'react';
import Link from 'next/link';
import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import {
CheckCircle,
Package,
Truck,
Mail,
Phone,
Download,
Home,
ShoppingBag
} from 'lucide-react';
interface OrderDetails {
orderNumber: string;
date: string;
total: string;
paymentMethod: string;
estimatedDelivery: string;
trackingNumber?: string; // Optionnel
}
export default function CheckoutSuccessPage() {
const [orderDetails, setOrderDetails] = useState<OrderDetails | null>(null);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
// Simuler la récupération des détails de commande
const timer = setTimeout(() => {
setOrderDetails({
orderNumber: `MELHFA-${Date.now().toString().slice(-6)}`,
date: new Date().toLocaleDateString('fr-FR'),
total: '85.000 MRU',
paymentMethod: 'Carte bancaire',
estimatedDelivery: new Date(Date.now() + 3 * 24 * 60 * 60 * 1000).toLocaleDateString('fr-FR'),
// trackingNumber sera undefined - pas de problème avec l'interface
});
setIsLoading(false);
}, 1000);
return () => clearTimeout(timer);
}, []);
if (isLoading) {
return (
<div className="min-h-screen bg-gray-50 pt-20 flex items-center justify-center">
<div className="text-center space-y-4">
<div className="w-16 h-16 border-4 border-black border-t-transparent rounded-full animate-spin mx-auto" />
<p className="text-gray-600">Traitement de votre commande...</p>
</div>
</div>
);
}
return (
<div className="min-h-screen bg-gray-50 pt-20">
<div className="max-w-4xl mx-auto px-6 py-16">
{/* Success Header */}
<div className="text-center mb-12">
<div className="w-24 h-24 bg-green-100 rounded-full flex items-center justify-center mx-auto mb-6">
<CheckCircle className="w-12 h-12 text-green-600" />
</div>
<h1 className="text-4xl font-light tracking-wide text-black mb-4">
Commande confirmée !
</h1>
<p className="text-xl text-gray-600 mb-2">
Merci pour votre achat
</p>
{orderDetails && (
<p className="text-gray-600">
Commande n° <span className="font-medium">{orderDetails.orderNumber}</span>
</p>
)}
</div>
{/* Order Details */}
{orderDetails && (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8 mb-12">
{/* Order Summary */}
<Card>
<CardContent className="p-6">
<h2 className="text-lg font-medium mb-6 flex items-center gap-2">
<Package className="w-5 h-5" />
Détails de la commande
</h2>
<div className="space-y-4">
<div className="flex justify-between">
<span className="text-gray-600">Numéro de commande</span>
<span className="font-medium">{orderDetails.orderNumber}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-600">Date</span>
<span className="font-medium">{orderDetails.date}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-600">Total</span>
<span className="font-medium text-lg">{orderDetails.total}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-600">Paiement</span>
<Badge variant="secondary">{orderDetails.paymentMethod}</Badge>
</div>
<div className="border-t pt-4">
<div className="flex justify-between">
<span className="text-gray-600">Statut</span>
<Badge className="bg-green-100 text-green-800">
Confirmée
</Badge>
</div>
</div>
</div>
</CardContent>
</Card>
{/* Delivery Info */}
<Card>
<CardContent className="p-6">
<h2 className="text-lg font-medium mb-6 flex items-center gap-2">
<Truck className="w-5 h-5" />
Informations de livraison
</h2>
<div className="space-y-4">
<div className="flex justify-between">
<span className="text-gray-600">Livraison estimée</span>
<span className="font-medium">{orderDetails.estimatedDelivery}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-600">Méthode</span>
<span className="font-medium">Livraison standard</span>
</div>
{orderDetails.trackingNumber ? (
<div className="flex justify-between">
<span className="text-gray-600">Suivi</span>
<Button variant="link" className="p-0 h-auto text-blue-600">
{orderDetails.trackingNumber}
</Button>
</div>
) : (
<div className="bg-blue-50 p-4 rounded-lg">
<p className="text-sm text-blue-800">
<strong>Suivi de commande :</strong> Vous recevrez un numéro de suivi par email dès que votre commande sera expédiée.
</p>
</div>
)}
</div>
</CardContent>
</Card>
</div>
)}
{/* Next Steps */}
<Card className="mb-8">
<CardContent className="p-6">
<h2 className="text-lg font-medium mb-6">Prochaines étapes</h2>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<div className="text-center">
<div className="w-12 h-12 bg-blue-100 rounded-full flex items-center justify-center mx-auto mb-3">
<Mail className="w-6 h-6 text-blue-600" />
</div>
<h3 className="font-medium mb-2">Confirmation par email</h3>
<p className="text-sm text-gray-600">
Vous allez recevoir un email de confirmation avec tous les détails de votre commande.
</p>
</div>
<div className="text-center">
<div className="w-12 h-12 bg-yellow-100 rounded-full flex items-center justify-center mx-auto mb-3">
<Package className="w-6 h-6 text-yellow-600" />
</div>
<h3 className="font-medium mb-2">Préparation</h3>
<p className="text-sm text-gray-600">
Votre commande est en cours de préparation dans nos ateliers.
</p>
</div>
<div className="text-center">
<div className="w-12 h-12 bg-green-100 rounded-full flex items-center justify-center mx-auto mb-3">
<Truck className="w-6 h-6 text-green-600" />
</div>
<h3 className="font-medium mb-2">Livraison</h3>
<p className="text-sm text-gray-600">
Livraison estimée le {orderDetails?.estimatedDelivery} à votre adresse.
</p>
</div>
</div>
</CardContent>
</Card>
{/* Action Buttons */}
<div className="flex flex-col sm:flex-row gap-4 justify-center">
<Button size="lg" className="bg-black text-white hover:bg-gray-800" asChild>
<Link href="/boutique">
<ShoppingBag className="w-5 h-5 mr-2" />
Continuer mes achats
</Link>
</Button>
<Button variant="outline" size="lg" asChild>
<Link href="/">
<Home className="w-5 h-5 mr-2" />
Retour à l'accueil
</Link>
</Button>
</div>
</div>
</div>
);
}

BIN
src_backup/app/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

430
src_backup/app/globals.css Normal file
View File

@@ -0,0 +1,430 @@
/* ============================================== */
/* GLOBALS.CSS FINAL CORRIGÉ - MELHFA E-COMMERCE */
/* Compatible Tailwind v3 + Next.js 14 - SANS ERREURS */
/* ============================================== */
@tailwind base;
@tailwind components;
@tailwind utilities;
/* ============================================== */
/* VARIABLES CSS CUSTOM */
/* ============================================== */
@layer base {
:root {
/* Couleurs de base */
--background: 0 0% 100%;
--foreground: 222.2 84% 4.9%;
/* Composants UI */
--card: 0 0% 100%;
--card-foreground: 222.2 84% 4.9%;
--popover: 0 0% 100%;
--popover-foreground: 222.2 84% 4.9%;
/* Couleurs primaires */
--primary: 222.2 84% 4.9%;
--primary-foreground: 210 40% 98%;
--secondary: 210 40% 96%;
--secondary-foreground: 222.2 84% 4.9%;
/* États et interactions */
--muted: 210 40% 96%;
--muted-foreground: 215.4 16.3% 46.9%;
--accent: 210 40% 96%;
--accent-foreground: 222.2 84% 4.9%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 210 40% 98%;
/* Bordures et inputs */
--border: 214.3 31.8% 91.4%;
--input: 214.3 31.8% 91.4%;
--ring: 222.2 84% 4.9%;
/* Border radius global */
--radius: 0.5rem;
}
/* Mode sombre */
.dark {
--background: 222.2 84% 4.9%;
--foreground: 210 40% 98%;
--card: 222.2 84% 4.9%;
--card-foreground: 210 40% 98%;
--popover: 222.2 84% 4.9%;
--popover-foreground: 210 40% 98%;
--primary: 210 40% 98%;
--primary-foreground: 222.2 84% 4.9%;
--secondary: 217.2 32.6% 17.5%;
--secondary-foreground: 210 40% 98%;
--muted: 217.2 32.6% 17.5%;
--muted-foreground: 215 20.2% 65.1%;
--accent: 217.2 32.6% 17.5%;
--accent-foreground: 210 40% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 210 40% 98%;
--border: 217.2 32.6% 17.5%;
--input: 217.2 32.6% 17.5%;
--ring: 212.7 26.8% 83.9%;
}
}
/* ============================================== */
/* STYLES DE BASE - SANS CLASSES PROBLÉMATIQUES */
/* ============================================== */
@layer base {
html {
scroll-behavior: smooth;
font-family: system-ui, sans-serif;
}
body {
margin: 0;
background: hsl(var(--background));
color: hsl(var(--foreground));
font-feature-settings: "rlig" 1, "calt" 1;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
line-height: 1.6;
}
/* Suppression des bordures par défaut */
*,
*::before,
*::after {
box-sizing: border-box;
border-width: 0;
border-style: solid;
border-color: hsl(var(--border));
}
/* Scrollbar personnalisée */
::-webkit-scrollbar {
width: 6px;
height: 6px;
}
::-webkit-scrollbar-track {
background: #f1f1f1;
}
::-webkit-scrollbar-thumb {
background: #888;
border-radius: 3px;
}
::-webkit-scrollbar-thumb:hover {
background: #555;
}
/* Sélection de texte */
::selection {
background: hsl(var(--primary));
color: hsl(var(--primary-foreground));
}
}
/* ============================================== */
/* COMPOSANTS PERSONNALISÉS */
/* ============================================== */
@layer components {
/* Effets de hover pour cartes */
.card-hover {
@apply transition-all duration-300 hover:shadow-lg hover:-translate-y-1;
}
/* Effet zoom pour images */
.image-zoom {
@apply transition-transform duration-700 hover:scale-105;
}
/* Container parallax */
.parallax {
transform: translateZ(0);
will-change: transform;
}
/* Grid responsive pour produits */
.product-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 1.5rem;
}
@media (max-width: 640px) {
.product-grid {
grid-template-columns: repeat(2, 1fr);
gap: 1rem;
}
}
/* Styles de focus Ring personnalisés */
.focus-ring {
@apply focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2;
}
/* Estados de status/badges */
.status-success {
@apply bg-green-100 text-green-800 border border-green-200 px-3 py-1 rounded-full text-sm font-medium;
}
.status-warning {
@apply bg-yellow-100 text-yellow-800 border border-yellow-200 px-3 py-1 rounded-full text-sm font-medium;
}
.status-error {
@apply bg-red-100 text-red-800 border border-red-200 px-3 py-1 rounded-full text-sm font-medium;
}
.status-info {
@apply bg-blue-100 text-blue-800 border border-blue-200 px-3 py-1 rounded-full text-sm font-medium;
}
/* Cartes de produits MELHFA */
.melhfa-card {
@apply bg-white rounded-lg shadow-sm border border-gray-200 overflow-hidden transition-all duration-300 hover:shadow-md hover:-translate-y-1;
}
/* Boutons personnalisés */
.btn-primary {
@apply bg-black text-white px-6 py-3 rounded-lg font-medium transition-all duration-200 hover:bg-gray-800 focus:ring-2 focus:ring-black focus:ring-offset-2;
}
.btn-secondary {
@apply bg-gray-100 text-gray-900 px-6 py-3 rounded-lg font-medium transition-all duration-200 hover:bg-gray-200 focus:ring-2 focus:ring-gray-500 focus:ring-offset-2;
}
}
/* ============================================== */
/* UTILITAIRES PERSONNALISÉS */
/* ============================================== */
@layer utilities {
/* Espacement de sections */
.section-padding {
@apply py-16 md:py-20 lg:py-24;
}
.container-padding {
@apply px-6 lg:px-8;
}
/* Typographie */
.heading-xl {
@apply text-4xl md:text-5xl lg:text-6xl font-light tracking-tight leading-none;
}
.heading-lg {
@apply text-3xl md:text-4xl font-light tracking-wide leading-tight;
}
.heading-md {
@apply text-2xl md:text-3xl font-light tracking-wide;
}
.body-lg {
@apply text-lg md:text-xl text-gray-600 leading-relaxed;
}
.body-sm {
@apply text-sm text-gray-600 leading-relaxed;
}
/* Équilibrage du texte */
.text-balance {
text-wrap: balance;
}
/* Utilitaires de layout */
.center-absolute {
@apply absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2;
}
.center-flex {
@apply flex items-center justify-center;
}
/* Ratios d'aspect personnalisés */
.aspect-product {
aspect-ratio: 3 / 4;
}
.aspect-hero {
aspect-ratio: 16 / 9;
}
/* Safe area pour mobile */
.safe-top {
padding-top: env(safe-area-inset-top);
}
.safe-bottom {
padding-bottom: env(safe-area-inset-bottom);
}
/* Masquer scrollbar */
.hide-scrollbar {
-ms-overflow-style: none;
scrollbar-width: none;
}
.hide-scrollbar::-webkit-scrollbar {
display: none;
}
/* Conteneur max-width pour MELHFA */
.container-melhfa {
@apply max-w-[1400px] mx-auto px-6;
}
}
/* ============================================== */
/* ANIMATIONS PERSONNALISÉES */
/* ============================================== */
/* Keyframes */
@keyframes fade-in-up {
from {
opacity: 0;
transform: translateY(30px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes slide-in {
from {
transform: translateX(-100%);
}
to {
transform: translateX(0);
}
}
@keyframes scale-in {
from {
opacity: 0;
transform: scale(0.9);
}
to {
opacity: 1;
transform: scale(1);
}
}
@keyframes bounce-subtle {
0%,
20%,
53%,
80%,
100% {
transform: translate3d(0, 0, 0);
}
40%,
43% {
transform: translate3d(0, -8px, 0);
}
70% {
transform: translate3d(0, -4px, 0);
}
90% {
transform: translate3d(0, -2px, 0);
}
}
/* Classes d'animation */
.animate-fade-in-up {
animation: fade-in-up 0.8s ease-out;
}
.animate-slide-in {
animation: slide-in 0.5s ease-out;
}
.animate-scale-in {
animation: scale-in 0.3s ease-out;
}
.animate-bounce-subtle {
animation: bounce-subtle 1s ease-in-out;
}
/* ============================================== */
/* STYLES RESPONSIFS */
/* ============================================== */
@media (max-width: 640px) {
.heading-xl {
@apply text-3xl;
}
.heading-lg {
@apply text-2xl;
}
.section-padding {
@apply py-12;
}
}
/* ============================================== */
/* ACCESSIBILITÉ */
/* ============================================== */
@media (prefers-contrast: high) {
.melhfa-card {
@apply border-2 border-gray-800;
}
}
@media (prefers-reduced-motion: reduce) {
.animate-fade-in-up,
.animate-slide-in,
.animate-scale-in,
.animate-bounce-subtle,
.card-hover,
.image-zoom {
animation: none;
transition: none;
}
* {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
}
/* ============================================== */
/* STYLES D'IMPRESSION */
/* ============================================== */
@media print {
.no-print {
display: none !important;
}
body {
background: white !important;
color: black !important;
}
.melhfa-card {
@apply border border-gray-400 shadow-none;
}
}

222
src_backup/app/layout.tsx Normal file
View File

@@ -0,0 +1,222 @@
// src/app/layout.tsx
import type { Metadata } from 'next';
import { Inter } from 'next/font/google';
import { cn } from '@/lib/utils';
import Header from '@/components/layout/Header';
import Footer from '@/components/layout/Footer';
import './globals.css';
const inter = Inter({
subsets: ['latin'],
variable: '--font-inter',
display: 'swap',
});
export const metadata: Metadata = {
title: {
default: 'MELHFA - Voiles Mauritaniens Premium',
template: '%s | MELHFA'
},
description: 'Découvrez l\'art mauritanien à travers nos voiles d\'exception, alliant tradition ancestrale et élégance contemporaine. Boutique en ligne de melhfa premium.',
keywords: [
'melhfa',
'voile mauritanien',
'melhfa traditionnelle',
'mode mauritanienne',
'artisanat mauritanien',
'melhfa premium',
'boutique en ligne mauritanie',
'nouakchott',
'voile africain'
],
authors: [{ name: 'MELHFA' }],
creator: 'MELHFA',
publisher: 'MELHFA',
formatDetection: {
email: false,
address: false,
telephone: false,
},
metadataBase: new URL(process.env.NEXT_PUBLIC_SITE_URL || 'https://melhfa.com'),
alternates: {
canonical: '/',
},
openGraph: {
type: 'website',
locale: 'fr_FR',
url: '/',
siteName: 'MELHFA',
title: 'MELHFA - Voiles Mauritaniens Premium',
description: 'Découvrez l\'art mauritanien à travers nos voiles d\'exception, alliant tradition ancestrale et élégance contemporaine.',
images: [
{
url: '/images/og-image.jpg',
width: 1200,
height: 630,
alt: 'MELHFA - Voiles Mauritaniens Premium',
},
],
},
twitter: {
card: 'summary_large_image',
title: 'MELHFA - Voiles Mauritaniens Premium',
description: 'Découvrez l\'art mauritanien à travers nos voiles d\'exception, alliant tradition ancestrale et élégance contemporaine.',
images: ['/images/twitter-image.jpg'],
creator: '@melhfa',
},
robots: {
index: true,
follow: true,
googleBot: {
index: true,
follow: true,
'max-video-preview': -1,
'max-image-preview': 'large',
'max-snippet': -1,
},
},
verification: {
google: process.env.GOOGLE_VERIFICATION_ID,
},
};
interface RootLayoutProps {
children: React.ReactNode;
}
export default function RootLayout({ children }: RootLayoutProps): JSX.Element {
return (
<html lang="fr" className={cn(inter.variable, 'scroll-smooth')}>
<head>
{/* Preconnect pour optimiser les performances */}
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossOrigin="anonymous" />
{/* Favicon */}
<link rel="icon" href="/favicon.ico" sizes="any" />
<link rel="icon" href="/icon.svg" type="image/svg+xml" />
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
<link rel="manifest" href="/manifest.json" />
{/* Theme color for mobile browsers */}
<meta name="theme-color" content="#000000" />
{/* Preload critical resources */}
<link
rel="preload"
href="/images/melhfa-hero.jpg"
as="image"
type="image/jpeg"
/>
</head>
<body
className={cn(
'min-h-screen bg-white font-sans antialiased',
inter.className
)}
suppressHydrationWarning
>
{/* Skip to main content for accessibility */}
<a
href="#main-content"
className="sr-only focus:not-sr-only focus:absolute focus:top-4 focus:left-4 bg-black text-white px-4 py-2 rounded-md z-50"
>
Aller au contenu principal
</a>
{/* Header */}
<Header />
{/* Main Content */}
<main id="main-content" className="min-h-screen">
{children}
</main>
{/* Footer */}
<Footer />
{/* Scripts for analytics, etc. */}
{process.env.NODE_ENV === 'production' && (
<>
{/* Google Analytics */}
{process.env.NEXT_PUBLIC_GA_ID && (
<>
<script
async
src={`https://www.googletagmanager.com/gtag/js?id=${process.env.NEXT_PUBLIC_GA_ID}`}
/>
<script
dangerouslySetInnerHTML={{
__html: `
window.dataLayer = window.dataLayer || [];
function gtag(){dataLayer.push(arguments);}
gtag('js', new Date());
gtag('config', '${process.env.NEXT_PUBLIC_GA_ID}');
`,
}}
/>
</>
)}
{/* Facebook Pixel */}
{process.env.NEXT_PUBLIC_FB_PIXEL_ID && (
<script
dangerouslySetInnerHTML={{
__html: `
!function(f,b,e,v,n,t,s)
{if(f.fbq)return;n=f.fbq=function(){n.callMethod?
n.callMethod.apply(n,arguments):n.queue.push(arguments)};
if(!f._fbq)f._fbq=n;n.push=n;n.loaded=!0;n.version='2.0';
n.queue=[];t=b.createElement(e);t.async=!0;
t.src=v;s=b.getElementsByTagName(e)[0];
s.parentNode.insertBefore(t,s)}(window, document,'script',
'https://connect.facebook.net/en_US/fbevents.js');
fbq('init', '${process.env.NEXT_PUBLIC_FB_PIXEL_ID}');
fbq('track', 'PageView');
`,
}}
/>
)}
</>
)}
{/* Structured Data */}
<script
type="application/ld+json"
dangerouslySetInnerHTML={{
__html: JSON.stringify({
'@context': 'https://schema.org',
'@type': 'ClothingStore',
name: 'MELHFA',
description: 'Boutique en ligne de voiles mauritaniens premium et accessoires traditionnels',
url: process.env.NEXT_PUBLIC_SITE_URL || 'https://melhfa.com',
logo: `${process.env.NEXT_PUBLIC_SITE_URL || 'https://melhfa.com'}/images/logo.png`,
image: `${process.env.NEXT_PUBLIC_SITE_URL || 'https://melhfa.com'}/images/og-image.jpg`,
telephone: '+222 XX XX XX XX',
address: {
'@type': 'PostalAddress',
streetAddress: 'Nouakchott',
addressLocality: 'Nouakchott',
addressCountry: 'MR'
},
geo: {
'@type': 'GeoCoordinates',
latitude: '18.0735',
longitude: '-15.9582'
},
sameAs: [
'https://facebook.com/melhfa',
'https://instagram.com/melhfa',
'https://twitter.com/melhfa'
],
paymentAccepted: ['Carte de crédit', 'Virement bancaire', 'Espèces'],
currenciesAccepted: 'MRU',
areaServed: 'Mauritanie'
})
}}
/>
</body>
</html>
);
}

214
src_backup/app/page.tsx Normal file
View File

@@ -0,0 +1,214 @@
// src/app/page.tsx
import { Suspense } from 'react';
import Link from 'next/link';
import Image from 'next/image';
import { WooCommerceService } from '@/lib/woocommerce';
import ProductCard from '@/components/product/ProductCard';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { ArrowRight, Star, Truck, Shield, Heart } from 'lucide-react';
// Composants pour les sections de la page d'accueil
import HeroSection from '@/components/sections/HeroSection';
import FeaturedProductsSection from '@/components/sections/FeaturedProductsSection';
import PromoSection from '@/components/sections/PromoSection';
import NewsletterSection from '@/components/sections/NewsletterSection';
export default async function HomePage(): Promise<JSX.Element> {
// Récupérer les données des produits
const [featuredProducts, saleProducts, newArrivals] = await Promise.all([
WooCommerceService.getFeaturedProducts(6),
WooCommerceService.getSaleProducts(4),
WooCommerceService.getNewArrivals(8),
]);
return (
<main className="min-h-screen bg-white">
{/* Hero Section */}
<HeroSection />
{/* Promo Banner */}
<PromoSection />
{/* Featured Products */}
<section className="py-20 bg-gray-50">
<div className="max-w-[1400px] mx-auto px-6">
<div className="text-center mb-16">
<h2 className="text-4xl font-light mb-4 tracking-wide">
Produits Vedettes
</h2>
<p className="text-gray-600 max-w-2xl mx-auto">
Découvrez notre sélection de melhfa d&apos;exception, alliant tradition mauritanienne et élégance contemporaine
</p>
</div>
{/* Grid de produits vedettes avec design asymétrique */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-12">
{/* Produit principal - grande taille */}
<div className="lg:row-span-2">
<div className="relative h-[600px] lg:h-[824px] overflow-hidden rounded-lg group cursor-pointer">
<Image
src="/images/melhfa-featured-1.jpg"
alt="Collection Sahara"
fill
className="object-cover transition-transform duration-700 group-hover:scale-105"
/>
<div className="absolute top-6 right-6">
<Badge className="bg-red-500 text-white">-25%</Badge>
</div>
<div className="absolute inset-0 bg-gradient-to-t from-black/70 via-transparent to-transparent" />
<div className="absolute bottom-0 left-0 right-0 p-8 text-white transform translate-y-full group-hover:translate-y-0 transition-transform duration-300">
<h3 className="text-2xl font-light mb-3 tracking-wide">Collection Sahara</h3>
<p className="text-sm opacity-90 mb-4">
Melhfa artisanale aux motifs berbères traditionnels, tissée à la main dans nos ateliers de Nouakchott.
</p>
<div className="flex items-center justify-between">
<div className="text-xl font-medium">
42.000 MRU{" "}
<span className="text-sm line-through opacity-70">56.000 MRU</span>
</div>
<Button className="bg-white text-black hover:bg-gray-100">
Découvrir
</Button>
</div>
</div>
</div>
</div>
{/* Produits secondaires */}
<div className="space-y-6">
<div className="relative h-[400px] overflow-hidden rounded-lg group cursor-pointer">
<Image
src="/images/melhfa-featured-2.jpg"
alt="Melhfa Élégance"
fill
className="object-cover transition-transform duration-700 group-hover:scale-105"
/>
<div className="absolute inset-0 bg-gradient-to-t from-black/70 via-transparent to-transparent" />
<div className="absolute bottom-0 left-0 right-0 p-6 text-white transform translate-y-full group-hover:translate-y-0 transition-transform duration-300">
<h3 className="text-xl font-light mb-2 tracking-wide">Melhfa Élégance</h3>
<p className="text-sm opacity-90 mb-3">Design moderne pour femme active</p>
<div className="text-lg font-medium">32.000 MRU</div>
</div>
</div>
<div className="relative h-[400px] overflow-hidden rounded-lg group cursor-pointer">
<Image
src="/images/melhfa-featured-3.jpg"
alt="Melhfa Océan"
fill
className="object-cover transition-transform duration-700 group-hover:scale-105"
/>
<div className="absolute top-6 right-6">
<Badge className="bg-red-500 text-white">-15%</Badge>
</div>
<div className="absolute inset-0 bg-gradient-to-t from-black/70 via-transparent to-transparent" />
<div className="absolute bottom-0 left-0 right-0 p-6 text-white transform translate-y-full group-hover:translate-y-0 transition-transform duration-300">
<h3 className="text-xl font-light mb-2 tracking-wide">Melhfa Océan</h3>
<p className="text-sm opacity-90 mb-3">Nuances bleues inspirées de l&apos;Atlantique</p>
<div className="text-lg font-medium">
29.000 MRU{" "}
<span className="text-sm line-through opacity-70">34.000 MRU</span>
</div>
</div>
</div>
</div>
</div>
</div>
</section>
{/* New Arrivals Products */}
<section className="py-20">
<div className="max-w-[1400px] mx-auto px-6">
<div className="flex items-center justify-between mb-16">
<div>
<h2 className="text-4xl font-light mb-4 tracking-wide">Nouvelle Collection</h2>
<p className="text-gray-600">Les dernières créations de nos artisans</p>
</div>
<Button variant="outline" className="hidden md:flex" asChild>
<Link href="/boutique">
Voir tout
<ArrowRight className="w-4 h-4 ml-2" />
</Link>
</Button>
</div>
<Suspense fallback={<ProductGridSkeleton />}>
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-6">
{newArrivals.success && newArrivals.data.map((product) => (
<ProductCard
key={product.id}
product={product}
className="animate-fade-in-up"
/>
))}
</div>
</Suspense>
<div className="text-center mt-12 md:hidden">
<Button variant="outline" asChild>
<Link href="/boutique">
Voir tout
<ArrowRight className="w-4 h-4 ml-2" />
</Link>
</Button>
</div>
</div>
</section>
{/* Features Section */}
<section className="py-20 bg-gray-50">
<div className="max-w-[1400px] mx-auto px-6">
<div className="grid grid-cols-1 md:grid-cols-3 gap-8">
<div className="text-center group">
<div className="w-16 h-16 mx-auto mb-4 bg-black rounded-full flex items-center justify-center group-hover:scale-110 transition-transform duration-300">
<Truck className="w-8 h-8 text-white" />
</div>
<h3 className="text-lg font-medium mb-2">Livraison gratuite</h3>
<p className="text-sm text-gray-600">À partir de 50.000 MRU dans tout Nouakchott</p>
</div>
<div className="text-center group">
<div className="w-16 h-16 mx-auto mb-4 bg-black rounded-full flex items-center justify-center group-hover:scale-110 transition-transform duration-300">
<Shield className="w-8 h-8 text-white" />
</div>
<h3 className="text-lg font-medium mb-2">Garantie qualité</h3>
<p className="text-sm text-gray-600">Retour gratuit sous 30 jours</p>
</div>
<div className="text-center group">
<div className="w-16 h-16 mx-auto mb-4 bg-black rounded-full flex items-center justify-center group-hover:scale-110 transition-transform duration-300">
<Heart className="w-8 h-8 text-white" />
</div>
<h3 className="text-lg font-medium mb-2">Fait main</h3>
<p className="text-sm text-gray-600">Chaque melhfa est unique et artisanale</p>
</div>
</div>
</div>
</section>
{/* Newsletter Section */}
<NewsletterSection />
</main>
);
}
// Composant de skeleton pour le chargement
function ProductGridSkeleton(): JSX.Element {
return (
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-6">
{[...Array(8)].map((_, i) => (
<div key={i} className="animate-pulse">
<div className="aspect-[3/4] bg-gray-200 rounded-lg mb-4" />
<div className="space-y-2">
<div className="h-4 bg-gray-200 rounded w-3/4" />
<div className="h-4 bg-gray-200 rounded w-1/2" />
</div>
</div>
))}
</div>
);
}

View File

@@ -0,0 +1,322 @@
// src/app/panier/page.tsx
'use client';
import { useCartActions } from '@/hooks/useCartSync';import { formatPrice } from '@/lib/woocommerce';
import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
import { Separator } from '@/components/ui/separator';
import { Badge } from '@/components/ui/badge';
import { Input } from '@/components/ui/input';
import {
ShoppingBag,
Trash2,
Plus,
Minus,
ArrowRight,
Truck,
Shield,
Tag
} from 'lucide-react';
import Image from 'next/image';
import Link from 'next/link';
import { useState } from 'react';
import { cn } from '@/lib/utils';
export default function CartPage(): JSX.Element {
const { cart, updateQuantity, removeFromCart, clearCart } = useCart();
const [promoCode, setPromoCode] = useState('');
const [isPromoApplied, setIsPromoApplied] = useState(false);
const [promoDiscount, setPromoDiscount] = useState(0);
const subtotal = cart.total;
const shipping = subtotal >= 50000 ? 0 : 5000; // Livraison gratuite à partir de 50.000 MRU
const discount = isPromoApplied ? promoDiscount : 0;
const total = subtotal + shipping - discount;
const handleQuantityChange = (productId: number, newQuantity: number): void => {
if (newQuantity <= 0) {
removeFromCart(productId);
} else {
updateQuantity(productId, newQuantity);
}
};
const applyPromoCode = (): void => {
// Codes promo factices pour la démo
const promoCodes: { [key: string]: number } = {
'WELCOME10': 0.1,
'SUMMER20': 0.2,
'FIRST5': 0.05,
};
const discountPercent = promoCodes[promoCode.toUpperCase()];
if (discountPercent) {
setIsPromoApplied(true);
setPromoDiscount(subtotal * discountPercent);
}
};
const removePromoCode = (): void => {
setIsPromoApplied(false);
setPromoDiscount(0);
setPromoCode('');
};
if (cart.items.length === 0) {
return (
<div className="min-h-screen bg-white pt-20">
<div className="max-w-[1400px] mx-auto px-6 py-16">
<div className="text-center space-y-8">
<div className="w-32 h-32 mx-auto bg-gray-100 rounded-full flex items-center justify-center">
<ShoppingBag className="w-16 h-16 text-gray-400" />
</div>
<div className="space-y-4">
<h1 className="text-3xl font-light tracking-wide">Votre panier est vide</h1>
<p className="text-gray-600 max-w-md mx-auto">
Découvrez notre collection de melhfa exceptionnelles et commencez votre shopping.
</p>
</div>
<div className="flex flex-col sm:flex-row gap-4 justify-center">
<Button asChild size="lg" className="bg-black text-white hover:bg-gray-800">
<Link href="/boutique">
Découvrir la boutique
<ArrowRight className="w-5 h-5 ml-2" />
</Link>
</Button>
<Button variant="outline" size="lg" asChild>
<Link href="/">Retour à l'accueil</Link>
</Button>
</div>
</div>
</div>
</div>
);
}
return (
<div className="min-h-screen bg-white pt-20">
<div className="max-w-[1400px] mx-auto px-6 py-8">
{/* Header */}
<div className="mb-8">
<h1 className="text-3xl md:text-4xl font-light tracking-wide text-black mb-2">
Panier ({cart.itemCount} article{cart.itemCount > 1 ? 's' : ''})
</h1>
<p className="text-gray-600">Vérifiez vos articles avant de procéder au paiement</p>
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
{/* Cart Items */}
<div className="lg:col-span-2 space-y-6">
{/* Clear Cart Button */}
<div className="flex justify-between items-center">
<span className="text-sm text-gray-600">
{cart.items.length} produit{cart.items.length > 1 ? 's' : ''} dans votre panier
</span>
<Button
variant="ghost"
size="sm"
onClick={clearCart}
className="text-red-600 hover:text-red-700 hover:bg-red-50"
>
<Trash2 className="w-4 h-4 mr-2" />
Vider le panier
</Button>
</div>
{/* Cart Items List */}
<div className="space-y-4">
{cart.items.map((item) => (
<Card key={item.id}>
<CardContent className="p-6">
<div className="flex flex-col md:flex-row gap-4">
{/* Product Image */}
<div className="relative w-full md:w-32 h-48 md:h-32 flex-shrink-0">
<Image
src={item.image}
alt={item.name}
fill
className="object-cover rounded-lg"
sizes="(max-width: 768px) 100vw, 128px"
/>
</div>
{/* Product Info */}
<div className="flex-1 space-y-3">
<div className="flex justify-between items-start">
<div>
<h3 className="font-medium text-lg">{item.name}</h3>
<p className="text-gray-600 text-sm">Prix unitaire: {formatPrice(item.price)}</p>
</div>
<Button
variant="ghost"
size="sm"
onClick={() => removeFromCart(item.id)}
className="text-red-600 hover:text-red-700 hover:bg-red-50 p-2"
>
<Trash2 className="w-4 h-4" />
</Button>
</div>
<div className="flex justify-between items-center">
{/* Quantity Controls */}
<div className="flex items-center border border-gray-300 rounded-lg">
<Button
variant="ghost"
size="sm"
onClick={() => handleQuantityChange(item.id, item.quantity - 1)}
className="px-3 h-10 hover:bg-gray-100"
disabled={item.quantity <= 1}
>
<Minus className="w-4 h-4" />
</Button>
<span className="px-4 py-2 min-w-[3rem] text-center font-medium">
{item.quantity}
</span>
<Button
variant="ghost"
size="sm"
onClick={() => handleQuantityChange(item.id, item.quantity + 1)}
className="px-3 h-10 hover:bg-gray-100"
>
<Plus className="w-4 h-4" />
</Button>
</div>
{/* Item Total */}
<div className="text-right">
<p className="font-medium text-lg">{formatPrice(item.total)}</p>
</div>
</div>
</div>
</div>
</CardContent>
</Card>
))}
</div>
</div>
{/* Cart Summary */}
<div className="space-y-6">
{/* Promo Code */}
<Card>
<CardContent className="p-6 space-y-4">
<h3 className="font-medium flex items-center gap-2">
<Tag className="w-5 h-5" />
Code promo
</h3>
{!isPromoApplied ? (
<div className="flex gap-2">
<Input
placeholder="Entrez votre code"
value={promoCode}
onChange={(e) => setPromoCode(e.target.value)}
className="flex-1"
/>
<Button
onClick={applyPromoCode}
disabled={!promoCode.trim()}
variant="outline"
>
Appliquer
</Button>
</div>
) : (
<div className="flex items-center justify-between p-3 bg-green-50 rounded-lg">
<div className="flex items-center gap-2">
<Badge className="bg-green-500">✓</Badge>
<span className="text-sm font-medium">Code appliqué: {promoCode}</span>
</div>
<Button
variant="ghost"
size="sm"
onClick={removePromoCode}
className="text-red-600 hover:text-red-700"
>
<Trash2 className="w-4 h-4" />
</Button>
</div>
)}
<div className="text-xs text-gray-500 space-y-1">
<p>Codes de démonstration disponibles:</p>
<p>• WELCOME10 (10% de réduction)</p>
<p>• SUMMER20 (20% de réduction)</p>
</div>
</CardContent>
</Card>
{/* Order Summary */}
<Card>
<CardContent className="p-6 space-y-4">
<h3 className="font-medium text-lg">Résumé de la commande</h3>
<div className="space-y-3">
<div className="flex justify-between">
<span>Sous-total ({cart.itemCount} articles)</span>
<span>{formatPrice(subtotal)}</span>
</div>
<div className="flex justify-between">
<span className="flex items-center gap-1">
Livraison
{shipping === 0 && <Badge variant="secondary" className="text-xs">Gratuite</Badge>}
</span>
<span>{shipping === 0 ? 'Gratuite' : formatPrice(shipping)}</span>
</div>
{discount > 0 && (
<div className="flex justify-between text-green-600">
<span>Réduction</span>
<span>-{formatPrice(discount)}</span>
</div>
)}
<Separator />
<div className="flex justify-between text-lg font-medium">
<span>Total</span>
<span>{formatPrice(total)}</span>
</div>
</div>
<Button
asChild
size="lg"
className="w-full bg-black text-white hover:bg-gray-800 py-4"
>
<Link href="/checkout">
Procéder au paiement
<ArrowRight className="w-5 h-5 ml-2" />
</Link>
</Button>
{/* Trust Indicators */}
<div className="space-y-3 pt-4 border-t border-gray-200">
<div className="flex items-center gap-3 text-sm text-gray-600">
<Truck className="w-4 h-4" />
<span>Livraison gratuite à partir de 50.000 MRU</span>
</div>
<div className="flex items-center gap-3 text-sm text-gray-600">
<Shield className="w-4 h-4" />
<span>Paiement 100% sécurisé</span>
</div>
</div>
</CardContent>
</Card>
{/* Continue Shopping */}
<Button variant="outline" className="w-full" asChild>
<Link href="/boutique">
Continuer mes achats
</Link>
</Button>
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,201 @@
// src/components/layout/Footer.tsx
'use client';
import Link from 'next/link';
import { Instagram, Facebook, Twitter, Youtube } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { useState } from 'react';
interface FooterSection {
title: string;
links: Array<{
name: string;
href: string;
}>;
}
const footerSections: FooterSection[] = [
{
title: 'Produits',
links: [
{ name: 'Nouvelles Arrivées', href: '/boutique?filter=new' },
{ name: 'Collections Premium', href: '/collections/premium' },
{ name: 'Melhfa Traditionnelles', href: '/collections/traditionnelles' },
{ name: 'Accessoires', href: '/accessoires' },
],
},
{
title: 'Aide',
links: [
{ name: 'Guide des tailles', href: '/aide/tailles' },
{ name: 'Livraison', href: '/aide/livraison' },
{ name: 'Retours', href: '/aide/retours' },
{ name: 'FAQ', href: '/aide/faq' },
],
},
{
title: 'Entreprise',
links: [
{ name: 'À propos', href: '/a-propos' },
{ name: 'Carrières', href: '/carrieres' },
{ name: 'Presse', href: '/presse' },
{ name: 'Contact', href: '/contact' },
],
},
];
const socialLinks = [
{ name: 'Instagram', href: '#', icon: Instagram },
{ name: 'Facebook', href: '#', icon: Facebook },
{ name: 'Twitter', href: '#', icon: Twitter },
{ name: 'YouTube', href: '#', icon: Youtube },
];
export default function Footer(): JSX.Element {
const [email, setEmail] = useState('');
const [isSubscribed, setIsSubscribed] = useState(false);
const handleNewsletterSubmit = (e: React.FormEvent): void => {
e.preventDefault();
if (!email) return;
// Simuler l'inscription à la newsletter
setIsSubscribed(true);
setEmail('');
setTimeout(() => {
setIsSubscribed(false);
}, 3000);
};
return (
<footer className="bg-white border-t border-black/5">
{/* Newsletter Section */}
<div className="bg-gray-50 py-16">
<div className="max-w-[1400px] mx-auto px-6 text-center">
<h2 className="text-2xl font-light mb-4 tracking-wide">
Restez informé
</h2>
<p className="text-gray-600 mb-8 text-sm">
Recevez nos dernières collections et offres exclusives
</p>
<form onSubmit={handleNewsletterSubmit} className="max-w-md mx-auto">
<div className="flex gap-0">
<Input
type="email"
placeholder="Votre adresse email"
value={email}
onChange={(e) => setEmail(e.target.value)}
className="flex-1 rounded-r-none border-r-0 focus:ring-0 focus:border-black text-sm"
required
/>
<Button
type="submit"
className="bg-black text-white hover:bg-gray-800 px-6 rounded-l-none text-xs uppercase tracking-wide"
disabled={isSubscribed}
>
{isSubscribed ? 'Merci !' : "S'abonner"}
</Button>
</div>
</form>
</div>
</div>
{/* Main Footer Content */}
<div className="max-w-[1400px] mx-auto px-6 py-16">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-8 lg:gap-12">
{/* Sections de liens */}
{footerSections.map((section) => (
<div key={section.title}>
<h3 className="text-xs font-semibold uppercase tracking-widest mb-6 text-black">
{section.title}
</h3>
<ul className="space-y-3">
{section.links.map((link) => (
<li key={link.name}>
<Link
href={link.href}
className="text-sm text-gray-600 hover:text-black transition-colors"
>
{link.name}
</Link>
</li>
))}
</ul>
</div>
))}
{/* Réseaux sociaux */}
<div>
<h3 className="text-xs font-semibold uppercase tracking-widest mb-6 text-black">
Suivez-nous
</h3>
<div className="space-y-3">
{socialLinks.map((social) => {
const IconComponent = social.icon;
return (
<Link
key={social.name}
href={social.href}
className="flex items-center text-sm text-gray-600 hover:text-black transition-colors group"
>
<IconComponent className="w-4 h-4 mr-3 group-hover:scale-110 transition-transform" />
{social.name}
</Link>
);
})}
</div>
</div>
</div>
{/* Bottom Section */}
<div className="mt-16 pt-8 border-t border-gray-200 flex flex-col md:flex-row justify-between items-center space-y-4 md:space-y-0">
<div className="flex flex-col md:flex-row items-center space-y-2 md:space-y-0 md:space-x-6">
<p className="text-xs text-gray-500">
© 2024 MELHFA. Tous droits réservés.
</p>
<div className="flex space-x-6">
<Link
href="/mentions-legales"
className="text-xs text-gray-500 hover:text-black transition-colors"
>
Mentions légales
</Link>
<Link
href="/politique-confidentialite"
className="text-xs text-gray-500 hover:text-black transition-colors"
>
Confidentialité
</Link>
<Link
href="/cookies"
className="text-xs text-gray-500 hover:text-black transition-colors"
>
Cookies
</Link>
</div>
</div>
<div className="flex items-center space-x-4">
<span className="text-xs text-gray-500">Paiement sécurisé</span>
<div className="flex space-x-2">
{/* Logos des méthodes de paiement - vous pouvez remplacer par de vraies images */}
<div className="w-8 h-5 bg-gray-200 rounded-sm flex items-center justify-center">
<span className="text-[8px] font-bold text-gray-600">VISA</span>
</div>
<div className="w-8 h-5 bg-gray-200 rounded-sm flex items-center justify-center">
<span className="text-[8px] font-bold text-gray-600">MC</span>
</div>
<div className="w-8 h-5 bg-gray-200 rounded-sm flex items-center justify-center">
<span className="text-[8px] font-bold text-gray-600">PP</span>
</div>
</div>
</div>
</div>
</div>
</footer>
);
}

View File

@@ -0,0 +1,213 @@
// src/components/layout/Header.tsx
'use client';
import { useState, useEffect } from 'react';
import Link from 'next/link';
import { useCartActions } from '@/hooks/useCartSync';import { ShoppingBag, Menu, X, Search, User } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import {
Sheet,
SheetContent,
SheetTrigger,
} from '@/components/ui/sheet';
interface NavigationItem {
name: string;
href: string;
}
const navigation: NavigationItem[] = [
{ name: 'Nouvelles Arrivées', href: '/boutique?filter=new' },
{ name: 'Collections', href: '/collections' },
{ name: 'Promotions', href: '/boutique?filter=sale' },
{ name: 'Accessoires', href: '/accessoires' },
];
export default function Header(): JSX.Element {
const { getItemCount } = useCart();
const [isScrolled, setIsScrolled] = useState(false);
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
const itemCount = getItemCount();
useEffect(() => {
const handleScroll = (): void => {
setIsScrolled(window.scrollY > 100);
};
window.addEventListener('scroll', handleScroll);
return () => window.removeEventListener('scroll', handleScroll);
}, []);
return (
<header
className={`fixed top-0 w-full z-50 transition-all duration-300 ${isScrolled
? 'bg-white/98 backdrop-blur-xl shadow-sm border-b border-black/5'
: 'bg-white/95 backdrop-blur-xl border-b border-black/5'
}`}
>
<div className="max-w-[1400px] mx-auto px-6">
<div className="flex items-center justify-between py-3">
{/* Logo */}
<Link
href="/"
className="text-2xl font-semibold tracking-[3px] text-black hover:opacity-70 transition-opacity"
>
MELHFA
</Link>
{/* Navigation Desktop */}
<nav className="hidden lg:flex items-center space-x-10">
{navigation.map((item) => (
<Link
key={item.name}
href={item.href}
className="text-sm font-normal text-black hover:opacity-60 transition-opacity tracking-wide uppercase"
>
{item.name}
</Link>
))}
</nav>
{/* Actions Desktop */}
<div className="hidden lg:flex items-center space-x-6">
<Button
variant="ghost"
size="sm"
className="text-sm font-normal tracking-wide uppercase text-black hover:bg-transparent hover:opacity-60"
>
<Search className="w-4 h-4 mr-2" />
Recherche
</Button>
<Button
variant="ghost"
size="sm"
className="text-sm font-normal tracking-wide uppercase text-black hover:bg-transparent hover:opacity-60"
>
<User className="w-4 h-4 mr-2" />
Connexion
</Button>
<Button
variant="ghost"
size="sm"
className="text-sm font-normal tracking-wide uppercase text-black hover:bg-transparent hover:opacity-60"
>
Aide
</Button>
<Link href="/panier" className="relative">
<Button
variant="ghost"
size="sm"
className="p-2 hover:bg-transparent hover:opacity-60"
>
<ShoppingBag className="w-5 h-5" />
{itemCount > 0 && (
<Badge
variant="destructive"
className="absolute -top-2 -right-2 w-5 h-5 rounded-full text-xs flex items-center justify-center p-0"
>
{itemCount}
</Badge>
)}
</Button>
</Link>
</div>
{/* Menu Mobile */}
<div className="lg:hidden flex items-center space-x-4">
<Link href="/panier" className="relative">
<Button
variant="ghost"
size="sm"
className="p-2 hover:bg-transparent hover:opacity-60"
>
<ShoppingBag className="w-5 h-5" />
{itemCount > 0 && (
<Badge
variant="destructive"
className="absolute -top-2 -right-2 w-5 h-5 rounded-full text-xs flex items-center justify-center p-0"
>
{itemCount}
</Badge>
)}
</Button>
</Link>
<Sheet open={isMobileMenuOpen} onOpenChange={setIsMobileMenuOpen}>
<SheetTrigger asChild>
<Button
variant="ghost"
size="sm"
className="p-2 hover:bg-transparent hover:opacity-60"
>
<Menu className="w-5 h-5" />
</Button>
</SheetTrigger>
<SheetContent side="right" className="w-80 p-0">
<div className="flex flex-col h-full">
{/* Header du menu mobile */}
<div className="flex items-center justify-between p-6 border-b border-gray-200">
<span className="text-lg font-semibold tracking-wide">Menu</span>
<Button
variant="ghost"
size="sm"
onClick={() => setIsMobileMenuOpen(false)}
className="p-2"
>
<X className="w-5 h-5" />
</Button>
</div>
{/* Navigation mobile */}
<nav className="flex-1 p-6">
<div className="space-y-6">
{navigation.map((item) => (
<Link
key={item.name}
href={item.href}
onClick={() => setIsMobileMenuOpen(false)}
className="block text-base font-normal text-black hover:opacity-60 transition-opacity tracking-wide uppercase"
>
{item.name}
</Link>
))}
</div>
<div className="mt-8 pt-8 border-t border-gray-200 space-y-4">
<Button
variant="ghost"
className="w-full justify-start text-sm font-normal tracking-wide uppercase text-black hover:bg-transparent hover:opacity-60"
>
<Search className="w-4 h-4 mr-3" />
Recherche
</Button>
<Button
variant="ghost"
className="w-full justify-start text-sm font-normal tracking-wide uppercase text-black hover:bg-transparent hover:opacity-60"
>
<User className="w-4 h-4 mr-3" />
Connexion
</Button>
<Button
variant="ghost"
className="w-full justify-start text-sm font-normal tracking-wide uppercase text-black hover:bg-transparent hover:opacity-60"
>
Aide
</Button>
</div>
</nav>
</div>
</SheetContent>
</Sheet>
</div>
</div>
</div>
</header>
);
}

View File

@@ -0,0 +1,258 @@
// src/components/product/ProductCard.tsx
'use client';
import { useState } from 'react';
import Link from 'next/link';
import Image from 'next/image';
import { WooCommerceProduct } from '@/types/woocommerce';
import { useCartActions } from '@/hooks/useCartSync';import { formatPrice, isOnSale, getDiscountPercentage } from '@/lib/woocommerce';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Eye, ShoppingBag, Heart } from 'lucide-react';
import { cn } from '@/lib/utils';
interface ProductCardProps {
product: WooCommerceProduct;
className?: string;
showQuickView?: boolean;
}
interface ColorOption {
name: string;
hex: string;
}
export default function ProductCard({
product,
className = '',
showQuickView = true
}: ProductCardProps): JSX.Element {
const { addToCart, isInCart } = useCart();
const [isHovered, setIsHovered] = useState(false);
const [selectedColor, setSelectedColor] = useState<string | null>(null);
const [isAddingToCart, setIsAddingToCart] = useState(false);
const primaryImage = product.images?.[0]?.src || '/placeholder-product.jpg';
const secondaryImage = product.images?.[1]?.src || primaryImage;
const discountPercentage = getDiscountPercentage(product);
// Extraire les couleurs des attributs du produit
const colorAttribute = product.attributes?.find(attr =>
attr.name.toLowerCase().includes('couleur') ||
attr.name.toLowerCase().includes('color')
);
const colorOptions: ColorOption[] = colorAttribute?.options.map(color => ({
name: color,
hex: getColorHex(color), // Fonction helper pour obtenir le hex
})) || [];
const handleAddToCart = async (): Promise<void> => {
if (isAddingToCart) return;
setIsAddingToCart(true);
try {
addToCart({
id: product.id,
name: product.name,
price: product.sale_price || product.regular_price,
image: primaryImage,
});
// Simulation d'une petite attente pour l'UX
await new Promise(resolve => setTimeout(resolve, 300));
} finally {
setIsAddingToCart(false);
}
};
return (
<div
className={cn(
"group relative bg-white transition-all duration-300 hover:shadow-lg",
className
)}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
>
{/* Image Container */}
<div className="relative aspect-[3/4] overflow-hidden bg-gray-100">
<Link href={`/produit/${product.slug}`}>
<Image
src={isHovered && secondaryImage !== primaryImage ? secondaryImage : primaryImage}
alt={product.name}
fill
className="object-cover transition-all duration-500 group-hover:scale-105"
sizes="(max-width: 768px) 50vw, (max-width: 1200px) 33vw, 25vw"
/>
</Link>
{/* Badges */}
<div className="absolute top-4 left-4 flex flex-col gap-2">
{isOnSale(product) && (
<Badge className="bg-red-500 text-white text-xs px-2 py-1">
-{discountPercentage}%
</Badge>
)}
{product.featured && (
<Badge className="bg-black text-white text-xs px-2 py-1">
Nouvelle Collection
</Badge>
)}
</div>
{/* Quick Actions Overlay */}
{showQuickView && (
<div
className={cn(
"absolute inset-0 bg-black/10 flex items-center justify-center transition-opacity duration-300",
isHovered ? "opacity-100" : "opacity-0"
)}
>
<div className="flex gap-2">
<Button
variant="secondary"
size="sm"
className="bg-white hover:bg-gray-100 shadow-md"
asChild
>
<Link href={`/produit/${product.slug}`}>
<Eye className="w-4 h-4 mr-2" />
Aperçu rapide
</Link>
</Button>
</div>
</div>
)}
{/* Wishlist Button */}
<Button
variant="ghost"
size="sm"
className={cn(
"absolute top-4 right-4 w-8 h-8 p-0 bg-white/80 hover:bg-white transition-all duration-300",
isHovered ? "opacity-100 scale-100" : "opacity-0 scale-75"
)}
>
<Heart className="w-4 h-4" />
</Button>
</div>
{/* Product Info */}
<div className="p-4 space-y-3">
<div>
<Link
href={`/produit/${product.slug}`}
className="group"
>
<h3 className="text-sm font-medium text-black group-hover:opacity-70 transition-opacity tracking-wide uppercase">
{product.name}
</h3>
</Link>
{/* Prix */}
<div className="flex items-center gap-2 mt-1">
{isOnSale(product) ? (
<>
<span className="text-base font-medium text-black">
{formatPrice(product.sale_price)}
</span>
<span className="text-sm text-gray-500 line-through">
{formatPrice(product.regular_price)}
</span>
</>
) : (
<span className="text-base font-medium text-black">
{formatPrice(product.price)}
</span>
)}
</div>
</div>
{/* Couleurs disponibles */}
{colorOptions.length > 0 && (
<div className="flex items-center gap-2">
{colorOptions.slice(0, 4).map((color, index) => (
<button
key={index}
onClick={() => setSelectedColor(color.name)}
className={cn(
"w-4 h-4 rounded-full border-2 transition-all duration-200 hover:scale-110",
selectedColor === color.name
? "border-black shadow-md"
: "border-gray-200"
)}
style={{ backgroundColor: color.hex }}
title={color.name}
/>
))}
{colorOptions.length > 4 && (
<span className="text-xs text-gray-500 ml-1">
+{colorOptions.length - 4}
</span>
)}
</div>
)}
{/* Add to Cart Button */}
<Button
onClick={handleAddToCart}
disabled={isAddingToCart || !product.purchasable}
className={cn(
"w-full bg-black text-white hover:bg-gray-800 text-xs uppercase tracking-wide transition-all duration-300",
isInCart(product.id) && "bg-green-600 hover:bg-green-700"
)}
>
{isAddingToCart ? (
"Ajout..."
) : isInCart(product.id) ? (
<>
<ShoppingBag className="w-4 h-4 mr-2" />
Dans le panier
</>
) : (
"Ajouter au panier"
)}
</Button>
</div>
</div>
);
}
// Fonction helper pour obtenir la couleur hex à partir du nom
function getColorHex(colorName: string): string {
const colorMap: { [key: string]: string } = {
'noir': '#000000',
'black': '#000000',
'blanc': '#ffffff',
'white': '#ffffff',
'rouge': '#e74c3c',
'red': '#e74c3c',
'bleu': '#3498db',
'blue': '#3498db',
'vert': '#27ae60',
'green': '#27ae60',
'jaune': '#f1c40f',
'yellow': '#f1c40f',
'orange': '#e67e22',
'violet': '#8e44ad',
'purple': '#8e44ad',
'rose': '#e91e63',
'pink': '#e91e63',
'gris': '#95a5a6',
'gray': '#95a5a6',
'grey': '#95a5a6',
'marron': '#8B4513',
'brown': '#8B4513',
'beige': '#F5F5DC',
'doré': '#FFD700',
'gold': '#FFD700',
'argenté': '#C0C0C0',
'silver': '#C0C0C0',
};
const normalizedName = colorName.toLowerCase().trim();
return colorMap[normalizedName] || '#9CA3AF'; // Couleur par défaut
}

View File

@@ -0,0 +1,354 @@
// src/components/product/ProductFilters.tsx
'use client';
import { useState } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import { Label } from '@/components/ui/label';
import { Slider } from '@/components/ui/slider';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue
} from '@/components/ui/select';
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from '@/components/ui/accordion';
import { X, Filter } from 'lucide-react';
import { cn } from '@/lib/utils';
interface Category {
id: number;
name: string;
slug: string;
count: number;
}
interface ProductFiltersProps {
categories: Category[];
currentCategory?: string;
currentFilter?: string;
currentSort?: string;
}
interface ColorOption {
name: string;
value: string;
hex: string;
}
interface SizeOption {
name: string;
value: string;
}
const colorOptions: ColorOption[] = [
{ name: 'Noir', value: 'noir', hex: '#000000' },
{ name: 'Blanc', value: 'blanc', hex: '#FFFFFF' },
{ name: 'Rouge', value: 'rouge', hex: '#DC2626' },
{ name: 'Bleu', value: 'bleu', hex: '#2563EB' },
{ name: 'Vert', value: 'vert', hex: '#059669' },
{ name: 'Jaune', value: 'jaune', hex: '#D97706' },
{ name: 'Violet', value: 'violet', hex: '#7C3AED' },
{ name: 'Rose', value: 'rose', hex: '#EC4899' },
];
const sizeOptions: SizeOption[] = [
{ name: 'Standard', value: 'standard' },
{ name: 'Grande taille', value: 'grande' },
{ name: 'Petite taille', value: 'petite' },
{ name: 'Sur mesure', value: 'sur-mesure' },
];
const sortOptions = [
{ label: 'Plus récent', value: 'date-desc' },
{ label: 'Prix croissant', value: 'price-asc' },
{ label: 'Prix décroissant', value: 'price-desc' },
{ label: 'Nom A-Z', value: 'name-asc' },
{ label: 'Nom Z-A', value: 'name-desc' },
];
export default function ProductFilters({
categories,
currentCategory,
currentFilter,
currentSort = 'date-desc'
}: ProductFiltersProps): JSX.Element {
const router = useRouter();
const searchParams = useSearchParams();
const [priceRange, setPriceRange] = useState<number[]>([0, 100000]);
const [selectedColors, setSelectedColors] = useState<string[]>([]);
const [selectedSizes, setSelectedSizes] = useState<string[]>([]);
const [selectedCategories, setSelectedCategories] = useState<string[]>(
currentCategory ? [currentCategory] : []
);
const updateFilters = (newParams: Record<string, string | string[] | null>): void => {
const params = new URLSearchParams(searchParams.toString());
Object.entries(newParams).forEach(([key, value]) => {
if (value === null || value === '' || (Array.isArray(value) && value.length === 0)) {
params.delete(key);
} else if (Array.isArray(value)) {
params.delete(key);
value.forEach(v => params.append(key, v));
} else {
params.set(key, value);
}
});
// Reset page to 1 when filters change
params.set('page', '1');
router.push(`/boutique?${params.toString()}`);
};
const handleSortChange = (value: string): void => {
updateFilters({ sort: value });
};
const handleCategoryChange = (categorySlug: string, checked: boolean): void => {
const newCategories = checked
? [...selectedCategories, categorySlug]
: selectedCategories.filter(c => c !== categorySlug);
setSelectedCategories(newCategories);
updateFilters({ category: newCategories.length > 0 ? newCategories : null });
};
const handleColorChange = (colorValue: string, checked: boolean): void => {
const newColors = checked
? [...selectedColors, colorValue]
: selectedColors.filter(c => c !== colorValue);
setSelectedColors(newColors);
updateFilters({ color: newColors.length > 0 ? newColors : null });
};
const handleSizeChange = (sizeValue: string, checked: boolean): void => {
const newSizes = checked
? [...selectedSizes, sizeValue]
: selectedSizes.filter(s => s !== sizeValue);
setSelectedSizes(newSizes);
updateFilters({ size: newSizes.length > 0 ? newSizes : null });
};
const clearAllFilters = (): void => {
setPriceRange([0, 100000]);
setSelectedColors([]);
setSelectedSizes([]);
setSelectedCategories([]);
router.push('/boutique');
};
const hasActiveFilters =
selectedCategories.length > 0 ||
selectedColors.length > 0 ||
selectedSizes.length > 0 ||
priceRange[0] > 0 ||
priceRange[1] < 100000;
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Filter className="w-4 h-4" />
<h2 className="font-medium text-sm uppercase tracking-wide">Filtres</h2>
</div>
{hasActiveFilters && (
<Button
variant="ghost"
size="sm"
onClick={clearAllFilters}
className="text-xs text-gray-500 hover:text-black p-0 h-auto"
>
<X className="w-3 h-3 mr-1" />
Effacer
</Button>
)}
</div>
{/* Sort */}
<div className="space-y-3">
<Label className="text-xs font-medium uppercase tracking-wide">
Trier par
</Label>
<Select value={currentSort} onValueChange={handleSortChange}>
<SelectTrigger className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
{sortOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Filters Accordion */}
<Accordion type="multiple" defaultValue={['categories', 'colors', 'sizes', 'price']}>
{/* Categories */}
{categories.length > 0 && (
<AccordionItem value="categories">
<AccordionTrigger className="text-xs font-medium uppercase tracking-wide py-3">
Catégories
</AccordionTrigger>
<AccordionContent className="space-y-3 pt-1">
{categories.map((category) => (
<div key={category.id} className="flex items-center space-x-2">
<Checkbox
id={`category-${category.id}`}
checked={selectedCategories.includes(category.slug)}
onCheckedChange={(checked) =>
handleCategoryChange(category.slug, checked as boolean)
}
/>
<Label
htmlFor={`category-${category.id}`}
className="text-sm cursor-pointer flex-1 flex justify-between"
>
<span>{category.name}</span>
<span className="text-gray-400">({category.count})</span>
</Label>
</div>
))}
</AccordionContent>
</AccordionItem>
)}
{/* Colors */}
<AccordionItem value="colors">
<AccordionTrigger className="text-xs font-medium uppercase tracking-wide py-3">
Couleurs
</AccordionTrigger>
<AccordionContent className="space-y-3 pt-1">
<div className="grid grid-cols-4 gap-3">
{colorOptions.map((color) => (
<div key={color.value} className="flex flex-col items-center space-y-1">
<button
onClick={() => handleColorChange(color.value, !selectedColors.includes(color.value))}
className={cn(
"w-8 h-8 rounded-full border-2 transition-all duration-200",
selectedColors.includes(color.value)
? "border-black scale-110 shadow-md"
: "border-gray-200 hover:scale-105",
color.hex === '#FFFFFF' && "border-gray-300"
)}
style={{ backgroundColor: color.hex }}
title={color.name}
/>
<span className="text-xs text-gray-600">{color.name}</span>
</div>
))}
</div>
</AccordionContent>
</AccordionItem>
{/* Sizes */}
<AccordionItem value="sizes">
<AccordionTrigger className="text-xs font-medium uppercase tracking-wide py-3">
Tailles
</AccordionTrigger>
<AccordionContent className="space-y-3 pt-1">
{sizeOptions.map((size) => (
<div key={size.value} className="flex items-center space-x-2">
<Checkbox
id={`size-${size.value}`}
checked={selectedSizes.includes(size.value)}
onCheckedChange={(checked) =>
handleSizeChange(size.value, checked as boolean)
}
/>
<Label
htmlFor={`size-${size.value}`}
className="text-sm cursor-pointer"
>
{size.name}
</Label>
</div>
))}
</AccordionContent>
</AccordionItem>
{/* Price Range */}
<AccordionItem value="price">
<AccordionTrigger className="text-xs font-medium uppercase tracking-wide py-3">
Prix (MRU)
</AccordionTrigger>
<AccordionContent className="space-y-4 pt-1">
<Slider
value={priceRange}
onValueChange={setPriceRange}
max={100000}
min={0}
step={1000}
className="w-full"
/>
<div className="flex justify-between text-sm text-gray-600">
<span>{priceRange[0].toLocaleString()} MRU</span>
<span>{priceRange[1].toLocaleString()} MRU</span>
</div>
<Button
variant="outline"
size="sm"
className="w-full"
onClick={() => updateFilters({
price_min: priceRange[0].toString(),
price_max: priceRange[1].toString()
})}
>
Appliquer
</Button>
</AccordionContent>
</AccordionItem>
</Accordion>
{/* Quick Filters */}
<div className="pt-4 border-t border-gray-200 space-y-3">
<Label className="text-xs font-medium uppercase tracking-wide">
Filtres rapides
</Label>
<div className="space-y-2">
<Button
variant={currentFilter === 'sale' ? 'default' : 'outline'}
size="sm"
className="w-full justify-start text-xs"
onClick={() => updateFilters({ filter: currentFilter === 'sale' ? null : 'sale' })}
>
En promotion
</Button>
<Button
variant={currentFilter === 'featured' ? 'default' : 'outline'}
size="sm"
className="w-full justify-start text-xs"
onClick={() => updateFilters({ filter: currentFilter === 'featured' ? null : 'featured' })}
>
Produits vedettes
</Button>
<Button
variant={currentFilter === 'new' ? 'default' : 'outline'}
size="sm"
className="w-full justify-start text-xs"
onClick={() => updateFilters({ filter: currentFilter === 'new' ? null : 'new' })}
>
Nouvelles arrivées
</Button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,94 @@
// src/components/product/ProductGrid.tsx
'use client';
import { WooCommerceProduct } from '@/types/woocommerce';
import ProductCard from './ProductCard';
import { cn } from '@/lib/utils';
interface ProductGridProps {
products: WooCommerceProduct[];
currentPage?: number;
hasMore?: boolean;
className?: string;
viewMode?: 'grid' | 'list';
}
export default function ProductGrid({
products,
currentPage = 1,
hasMore = false,
className = '',
viewMode = 'grid'
}: ProductGridProps): JSX.Element {
if (products.length === 0) {
return (
<div className="text-center py-16">
<div className="max-w-md mx-auto space-y-4">
<div className="w-24 h-24 bg-gray-100 rounded-full mx-auto flex items-center justify-center">
<svg
className="w-12 h-12 text-gray-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.5}
d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4"
/>
</svg>
</div>
<h3 className="text-lg font-medium text-gray-900">
Aucun produit trouvé
</h3>
<p className="text-gray-600">
Aucun produit ne correspond à vos critères de recherche.
Essayez de modifier vos filtres.
</p>
</div>
</div>
);
}
return (
<div className={cn('space-y-8', className)}>
{/* Grid de produits */}
<div
className={cn(
'grid gap-6',
viewMode === 'grid'
? 'grid-cols-2 md:grid-cols-3 lg:grid-cols-4'
: 'grid-cols-1 md:grid-cols-2 gap-8'
)}
>
{products.map((product, index) => (
<div
key={product.id}
className="animate-fade-in-up"
style={{
animationDelay: `${index * 100}ms`,
animationFillMode: 'both'
}}
>
<ProductCard
product={product}
className={cn(
viewMode === 'list' && 'flex-row h-48'
)}
/>
</div>
))}
</div>
{/* Informations de pagination */}
{currentPage > 1 && (
<div className="text-center text-sm text-gray-600">
Page {currentPage} {products.length} produits affichés
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,215 @@
// src/components/product/ProductImageGallery.tsx
'use client';
import { useState } from 'react';
import Image from 'next/image';
import { WooCommerceImage } from '@/types/woocommerce';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { ChevronLeft, ChevronRight, Expand, Heart } from 'lucide-react';
import { cn } from '@/lib/utils';
import {
Dialog,
DialogContent,
DialogTrigger,
} from '@/components/ui/dialog';
interface ProductImageGalleryProps {
images: WooCommerceImage[];
productName: string;
isOnSale?: boolean;
discountPercentage?: number;
isFeatured?: boolean;
}
export default function ProductImageGallery({
images,
productName,
isOnSale = false,
discountPercentage = 0,
isFeatured = false,
}: ProductImageGalleryProps): JSX.Element {
const [currentImageIndex, setCurrentImageIndex] = useState(0);
const [isWishlisted, setIsWishlisted] = useState(false);
const hasMultipleImages = images.length > 1;
const currentImage = images[currentImageIndex] || images[0];
const goToPrevious = (): void => {
setCurrentImageIndex((prev) =>
prev === 0 ? images.length - 1 : prev - 1
);
};
const goToNext = (): void => {
setCurrentImageIndex((prev) =>
prev === images.length - 1 ? 0 : prev + 1
);
};
const goToImage = (index: number): void => {
setCurrentImageIndex(index);
};
const toggleWishlist = (): void => {
setIsWishlisted(!isWishlisted);
};
if (!currentImage) {
return (
<div className="aspect-[3/4] bg-gray-200 rounded-lg flex items-center justify-center">
<span className="text-gray-400">Aucune image disponible</span>
</div>
);
}
return (
<div className="space-y-4">
{/* Main Image */}
<div className="relative group">
<div className="aspect-[3/4] relative overflow-hidden rounded-lg bg-gray-100">
<Image
src={currentImage.src}
alt={currentImage.alt || productName}
fill
className="object-cover transition-transform duration-700 group-hover:scale-105"
sizes="(max-width: 768px) 100vw, 50vw"
priority
/>
{/* Badges */}
<div className="absolute top-4 left-4 flex flex-col gap-2 z-10">
{isOnSale && discountPercentage > 0 && (
<Badge className="bg-red-500 text-white">
-{discountPercentage}%
</Badge>
)}
{isFeatured && (
<Badge className="bg-black text-white">
Nouveauté
</Badge>
)}
</div>
{/* Wishlist Button */}
<Button
variant="ghost"
size="sm"
onClick={toggleWishlist}
className={cn(
"absolute top-4 right-4 w-10 h-10 p-0 rounded-full z-10 transition-all duration-300",
"bg-white/80 hover:bg-white backdrop-blur-sm",
isWishlisted && "bg-red-50 text-red-500 hover:bg-red-100"
)}
>
<Heart
className={cn(
"w-5 h-5 transition-all duration-300",
isWishlisted && "fill-current"
)}
/>
</Button>
{/* Navigation Arrows */}
{hasMultipleImages && (
<>
<Button
variant="ghost"
size="sm"
onClick={goToPrevious}
className="absolute left-4 top-1/2 -translate-y-1/2 w-10 h-10 p-0 rounded-full bg-white/80 hover:bg-white backdrop-blur-sm opacity-0 group-hover:opacity-100 transition-opacity duration-300 z-10"
>
<ChevronLeft className="w-5 h-5" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={goToNext}
className="absolute right-4 top-1/2 -translate-y-1/2 w-10 h-10 p-0 rounded-full bg-white/80 hover:bg-white backdrop-blur-sm opacity-0 group-hover:opacity-100 transition-opacity duration-300 z-10"
>
<ChevronRight className="w-5 h-5" />
</Button>
</>
)}
{/* Expand Button */}
<Dialog>
<DialogTrigger asChild>
<Button
variant="ghost"
size="sm"
className="absolute bottom-4 right-4 w-10 h-10 p-0 rounded-full bg-white/80 hover:bg-white backdrop-blur-sm opacity-0 group-hover:opacity-100 transition-opacity duration-300 z-10"
>
<Expand className="w-5 h-5" />
</Button>
</DialogTrigger>
<DialogContent className="max-w-4xl w-full h-full max-h-[90vh] p-0">
<div className="relative w-full h-full">
<Image
src={currentImage.src}
alt={currentImage.alt || productName}
fill
className="object-contain"
sizes="90vw"
/>
</div>
</DialogContent>
</Dialog>
{/* Image Counter */}
{hasMultipleImages && (
<div className="absolute bottom-4 left-4 bg-black/60 text-white px-3 py-1 rounded-full text-sm backdrop-blur-sm">
{currentImageIndex + 1} / {images.length}
</div>
)}
</div>
</div>
{/* Thumbnail Navigation */}
{hasMultipleImages && (
<div className="flex gap-3 overflow-x-auto pb-2">
{images.map((image, index) => (
<button
key={image.id}
onClick={() => goToImage(index)}
className={cn(
"relative flex-shrink-0 w-20 h-20 rounded-lg overflow-hidden border-2 transition-all duration-300",
currentImageIndex === index
? "border-black shadow-md"
: "border-gray-200 hover:border-gray-400"
)}
>
<Image
src={image.src}
alt={image.alt || `${productName} - Image ${index + 1}`}
fill
className="object-cover"
sizes="80px"
/>
</button>
))}
</div>
)}
{/* Mobile Dots Indicator */}
{hasMultipleImages && (
<div className="flex justify-center gap-2 md:hidden">
{images.map((_, index) => (
<button
key={index}
onClick={() => goToImage(index)}
className={cn(
"w-2 h-2 rounded-full transition-all duration-300",
currentImageIndex === index
? "bg-black"
: "bg-gray-300"
)}
/>
))}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,385 @@
// src/components/product/ProductInfo.tsx
'use client';
import { useState } from 'react';
import { WooCommerceProduct } from '@/types/woocommerce';
import { useCartActions } from '@/hooks/useCartSync';import { formatPrice, isOnSale, getDiscountPercentage } from '@/lib/woocommerce';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue
} from '@/components/ui/select';
import { Separator } from '@/components/ui/separator';
import {
ShoppingBag,
Heart,
Share2,
Truck,
Shield,
RotateCcw,
Star,
Plus,
Minus
} from 'lucide-react';
import { cn } from '@/lib/utils';
interface ProductInfoProps {
product: WooCommerceProduct;
}
interface ColorOption {
name: string;
value: string;
hex: string;
}
interface SizeOption {
name: string;
value: string;
available: boolean;
}
export default function ProductInfo({ product }: ProductInfoProps): JSX.Element {
const { addToCart, isInCart, getItemQuantity } = useCart();
const [selectedColor, setSelectedColor] = useState<string>('');
const [selectedSize, setSelectedSize] = useState<string>('');
const [quantity, setQuantity] = useState(1);
const [isAddingToCart, setIsAddingToCart] = useState(false);
const [isWishlisted, setIsWishlisted] = useState(false);
const discountPercentage = getDiscountPercentage(product);
const productInCart = isInCart(product.id);
const cartQuantity = getItemQuantity(product.id);
// Extraire les couleurs des attributs du produit
const colorAttribute = product.attributes?.find(attr =>
attr.name.toLowerCase().includes('couleur') ||
attr.name.toLowerCase().includes('color')
);
const sizeAttribute = product.attributes?.find(attr =>
attr.name.toLowerCase().includes('taille') ||
attr.name.toLowerCase().includes('size')
);
const colorOptions: ColorOption[] = colorAttribute?.options.map(color => ({
name: color,
value: color.toLowerCase(),
hex: getColorHex(color),
})) || [];
const sizeOptions: SizeOption[] = sizeAttribute?.options.map(size => ({
name: size,
value: size.toLowerCase(),
available: true, // Vous pouvez ajouter la logique de disponibilité ici
})) || [];
const handleAddToCart = async (): Promise<void> => {
if (isAddingToCart) return;
setIsAddingToCart(true);
try {
addToCart({
id: product.id,
name: product.name,
price: product.sale_price || product.regular_price,
image: product.images[0]?.src || '/placeholder-product.jpg',
}, quantity);
// Simulation d'une petite attente pour l'UX
await new Promise(resolve => setTimeout(resolve, 500));
} finally {
setIsAddingToCart(false);
}
};
const increaseQuantity = (): void => {
setQuantity(prev => prev + 1);
};
const decreaseQuantity = (): void => {
setQuantity(prev => Math.max(1, prev - 1));
};
const toggleWishlist = (): void => {
setIsWishlisted(!isWishlisted);
};
const shareProduct = (): void => {
if (navigator.share) {
navigator.share({
title: product.name,
text: product.short_description,
url: window.location.href,
});
} else {
// Fallback: copier l'URL dans le presse-papiers
navigator.clipboard.writeText(window.location.href);
}
};
return (
<div className="space-y-8">
{/* Product Header */}
<div className="space-y-4">
{/* Categories */}
{product.categories.length > 0 && (
<div className="flex flex-wrap gap-2">
{product.categories.map((category) => (
<Badge key={category.id} variant="outline" className="text-xs">
{category.name}
</Badge>
))}
</div>
)}
{/* Title */}
<h1 className="text-3xl md:text-4xl font-light tracking-wide text-black">
{product.name}
</h1>
{/* Ratings */}
{product.rating_count > 0 && (
<div className="flex items-center gap-2">
<div className="flex items-center">
{[...Array(5)].map((_, i) => (
<Star
key={i}
className={cn(
"w-4 h-4",
i < Math.floor(parseFloat(product.average_rating))
? "fill-yellow-400 text-yellow-400"
: "text-gray-300"
)}
/>
))}
</div>
<span className="text-sm text-gray-600">
{product.average_rating} ({product.rating_count} avis)
</span>
</div>
)}
{/* Price */}
<div className="flex items-center gap-4">
{isOnSale(product) ? (
<>
<span className="text-3xl font-medium text-black">
{formatPrice(product.sale_price)}
</span>
<span className="text-xl text-gray-500 line-through">
{formatPrice(product.regular_price)}
</span>
{discountPercentage > 0 && (
<Badge className="bg-red-500 text-white">
-{discountPercentage}%
</Badge>
)}
</>
) : (
<span className="text-3xl font-medium text-black">
{formatPrice(product.price)}
</span>
)}
</div>
{/* Short Description */}
{product.short_description && (
<div
className="text-gray-600 leading-relaxed"
dangerouslySetInnerHTML={{ __html: product.short_description }}
/>
)}
</div>
<Separator />
{/* Product Options */}
<div className="space-y-6">
{/* Colors */}
{colorOptions.length > 0 && (
<div className="space-y-3">
<Label className="text-sm font-medium">
Couleur: {selectedColor && <span className="font-normal">{selectedColor}</span>}
</Label>
<div className="flex flex-wrap gap-3">
{colorOptions.map((color) => (
<button
key={color.value}
onClick={() => setSelectedColor(color.name)}
className={cn(
"w-8 h-8 rounded-full border-2 transition-all duration-200 hover:scale-110",
selectedColor === color.name
? "border-black shadow-md scale-110"
: "border-gray-200"
)}
style={{ backgroundColor: color.hex }}
title={color.name}
/>
))}
</div>
</div>
)}
{/* Sizes */}
{sizeOptions.length > 0 && (
<div className="space-y-3">
<Label className="text-sm font-medium">Taille</Label>
<Select value={selectedSize} onValueChange={setSelectedSize}>
<SelectTrigger>
<SelectValue placeholder="Choisir une taille" />
</SelectTrigger>
<SelectContent>
{sizeOptions.map((size) => (
<SelectItem
key={size.value}
value={size.value}
disabled={!size.available}
>
{size.name} {!size.available && '(Rupture de stock)'}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
{/* Quantity */}
<div className="space-y-3">
<Label className="text-sm font-medium">Quantité</Label>
<div className="flex items-center gap-4">
<div className="flex items-center border border-gray-300 rounded-lg">
<Button
variant="ghost"
size="sm"
onClick={decreaseQuantity}
disabled={quantity <= 1}
className="px-3 h-10 hover:bg-gray-100"
>
<Minus className="w-4 h-4" />
</Button>
<span className="px-4 py-2 min-w-[3rem] text-center">{quantity}</span>
<Button
variant="ghost"
size="sm"
onClick={increaseQuantity}
className="px-3 h-10 hover:bg-gray-100"
>
<Plus className="w-4 h-4" />
</Button>
</div>
{cartQuantity > 0 && (
<span className="text-sm text-gray-600">
{cartQuantity} déjà dans le panier
</span>
)}
</div>
</div>
</div>
<Separator />
{/* Action Buttons */}
<div className="space-y-4">
<Button
onClick={handleAddToCart}
disabled={isAddingToCart || !product.purchasable || product.stock_status === 'outofstock'}
className="w-full bg-black text-white hover:bg-gray-800 py-4 text-base uppercase tracking-wide transition-all duration-300"
size="lg"
>
{isAddingToCart ? (
<div className="flex items-center gap-2">
<div className="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin" />
Ajout en cours...
</div>
) : product.stock_status === 'outofstock' ? (
'Rupture de stock'
) : productInCart ? (
<>
<ShoppingBag className="w-5 h-5 mr-2" />
Ajouter encore ({cartQuantity} dans le panier)
</>
) : (
<>
<ShoppingBag className="w-5 h-5 mr-2" />
Ajouter au panier
</>
)}
</Button>
<div className="flex gap-3">
<Button
variant="outline"
onClick={toggleWishlist}
className={cn(
"flex-1 transition-all duration-300",
isWishlisted && "border-red-500 text-red-500 hover:bg-red-50"
)}
>
<Heart className={cn("w-5 h-5 mr-2", isWishlisted && "fill-current")} />
{isWishlisted ? 'Retiré des favoris' : 'Ajouter aux favoris'}
</Button>
<Button variant="outline" onClick={shareProduct}>
<Share2 className="w-5 h-5 mr-2" />
Partager
</Button>
</div>
</div>
<Separator />
{/* Product Features */}
<div className="space-y-4">
<div className="flex items-center gap-3 text-sm">
<Truck className="w-5 h-5 text-gray-600" />
<span>Livraison gratuite à partir de 50.000 MRU</span>
</div>
<div className="flex items-center gap-3 text-sm">
<Shield className="w-5 h-5 text-gray-600" />
<span>Garantie qualité - Retour sous 30 jours</span>
</div>
<div className="flex items-center gap-3 text-sm">
<RotateCcw className="w-5 h-5 text-gray-600" />
<span>Échange gratuit en magasin</span>
</div>
</div>
</div>
);
}
// Helper function pour les couleurs
function getColorHex(colorName: string): string {
const colorMap: { [key: string]: string } = {
'noir': '#000000',
'blanc': '#ffffff',
'rouge': '#dc2626',
'bleu': '#2563eb',
'vert': '#059669',
'jaune': '#d97706',
'violet': '#7c3aed',
'rose': '#ec4899',
'gris': '#6b7280',
'marron': '#92400e',
'beige': '#d6d3d1',
'doré': '#f59e0b',
'argenté': '#9ca3af',
};
return colorMap[colorName.toLowerCase()] || '#9ca3af';
}
function Label({ children, className = '' }: { children: React.ReactNode; className?: string }): JSX.Element {
return (
<label className={cn('block text-sm font-medium text-gray-900', className)}>
{children}
</label>
);
}

View File

@@ -0,0 +1,153 @@
// src/components/product/ProductTabs.tsx
'use client';
import { WooCommerceProduct } from '@/types/woocommerce';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Card, CardContent } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import {
Star,
Truck,
RotateCcw,
Shield
} from 'lucide-react';
interface ProductTabsProps {
product: WooCommerceProduct;
}
export default function ProductTabs({ product }: ProductTabsProps) {
const hasDescription = product.description && product.description.trim() !== '';
const hasAttributes = product.attributes && product.attributes.length > 0;
return (
<div className="w-full">
<Tabs defaultValue="description" className="w-full">
<TabsList className="grid w-full grid-cols-3">
<TabsTrigger value="description">Description</TabsTrigger>
<TabsTrigger value="specifications">Caractéristiques</TabsTrigger>
<TabsTrigger value="shipping">Livraison</TabsTrigger>
</TabsList>
<TabsContent value="description" className="mt-6">
<Card>
<CardContent className="p-6">
{hasDescription ? (
<div
className="prose prose-gray max-w-none"
dangerouslySetInnerHTML={{ __html: product.description }}
/>
) : (
<div className="text-center py-8 text-gray-500">
<p>Aucune description disponible pour ce produit.</p>
</div>
)}
</CardContent>
</Card>
</TabsContent>
<TabsContent value="specifications" className="mt-6">
<Card>
<CardContent className="p-6">
<h3 className="text-lg font-medium mb-6">Caractéristiques techniques</h3>
<div className="space-y-6">
<div>
<h4 className="font-medium text-sm mb-3 text-gray-900">Informations générales</h4>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="flex justify-between py-2 border-b border-gray-100">
<span className="text-sm text-gray-600">SKU</span>
<span className="text-sm font-medium">{product.sku || 'N/A'}</span>
</div>
<div className="flex justify-between py-2 border-b border-gray-100">
<span className="text-sm text-gray-600">Stock</span>
<Badge
variant={product.stock_status === 'instock' ? 'secondary' : 'destructive'}
className="text-xs"
>
{product.stock_status === 'instock' ? 'En stock' : 'Rupture de stock'}
</Badge>
</div>
</div>
</div>
{hasAttributes && (
<div>
<h4 className="font-medium text-sm mb-3 text-gray-900">Attributs</h4>
<div className="space-y-3">
{product.attributes.map((attribute) => (
<div key={attribute.id} className="flex justify-between py-2 border-b border-gray-100">
<span className="text-sm text-gray-600">{attribute.name}</span>
<span className="text-sm font-medium">
{attribute.options.join(', ')}
</span>
</div>
))}
</div>
</div>
)}
</div>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="shipping" className="mt-6">
<Card>
<CardContent className="p-6">
<h3 className="text-lg font-medium mb-6">Livraison et retours</h3>
<div className="space-y-8">
<div>
<h4 className="font-medium text-sm mb-4 flex items-center gap-2">
<Truck className="w-5 h-5" />
Options de livraison
</h4>
<div className="space-y-4">
<div className="flex justify-between items-center p-4 border border-gray-200 rounded-lg">
<div>
<h5 className="font-medium text-sm">Livraison standard</h5>
<p className="text-xs text-gray-600">3-5 jours ouvrés</p>
</div>
<div className="text-right">
<p className="font-medium">5.000 MRU</p>
<p className="text-xs text-gray-600">Gratuite dès 50.000 MRU</p>
</div>
</div>
</div>
</div>
<div>
<h4 className="font-medium text-sm mb-4 flex items-center gap-2">
<RotateCcw className="w-5 h-5" />
Retours et échanges
</h4>
<div className="space-y-3 text-sm">
<p> <strong>30 jours</strong> pour retourner votre article</p>
<p> Retour <strong>gratuit</strong> en magasin ou par courrier</p>
<p> Article en <strong>parfait état</strong> avec étiquettes</p>
</div>
</div>
<div>
<h4 className="font-medium text-sm mb-4 flex items-center gap-2">
<Shield className="w-5 h-5" />
Garantie qualité
</h4>
<div className="space-y-3 text-sm">
<p> Garantie <strong>2 ans</strong> contre les défauts de fabrication</p>
<p> Service client dédié pour toute réclamation</p>
<p> Engagement qualité artisanale mauritanienne</p>
</div>
</div>
</div>
</CardContent>
</Card>
</TabsContent>
</Tabs>
</div>
);
}

View File

@@ -0,0 +1,154 @@
// src/components/product/RelatedProducts.tsx
'use client';
import { WooCommerceProduct } from '@/types/woocommerce';
import ProductCard from './ProductCard';
import { Button } from '@/components/ui/button';
import { ChevronLeft, ChevronRight } from 'lucide-react';
import { useRef, useState } from 'react';
import { cn } from '@/lib/utils';
interface RelatedProductsProps {
products: WooCommerceProduct[];
title?: string;
className?: string;
}
export default function RelatedProducts({
products,
title = "Produits similaires",
className = ''
}: RelatedProductsProps): JSX.Element {
const scrollContainerRef = useRef<HTMLDivElement>(null);
const [canScrollLeft, setCanScrollLeft] = useState(false);
const [canScrollRight, setCanScrollRight] = useState(true);
const checkScrollButtons = (): void => {
if (scrollContainerRef.current) {
const { scrollLeft, scrollWidth, clientWidth } = scrollContainerRef.current;
setCanScrollLeft(scrollLeft > 0);
setCanScrollRight(scrollLeft < scrollWidth - clientWidth - 1);
}
};
const scrollLeft = (): void => {
if (scrollContainerRef.current) {
scrollContainerRef.current.scrollBy({
left: -320, // Largeur d'une carte + gap
behavior: 'smooth'
});
}
};
const scrollRight = (): void => {
if (scrollContainerRef.current) {
scrollContainerRef.current.scrollBy({
left: 320, // Largeur d'une carte + gap
behavior: 'smooth'
});
}
};
if (products.length === 0) {
return <></>;
}
return (
<section className={cn('space-y-8', className)}>
{/* Header */}
<div className="flex items-center justify-between">
<h2 className="text-2xl md:text-3xl font-light tracking-wide">
{title}
</h2>
{/* Navigation Buttons - Desktop */}
<div className="hidden md:flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={scrollLeft}
disabled={!canScrollLeft}
className="p-2 h-9 w-9"
>
<ChevronLeft className="w-4 h-4" />
</Button>
<Button
variant="outline"
size="sm"
onClick={scrollRight}
disabled={!canScrollRight}
className="p-2 h-9 w-9"
>
<ChevronRight className="w-4 h-4" />
</Button>
</div>
</div>
{/* Products Scroll Container */}
<div className="relative">
<div
ref={scrollContainerRef}
className="flex gap-6 overflow-x-auto hide-scrollbar pb-4"
onScroll={checkScrollButtons}
style={{ scrollSnapType: 'x mandatory' }}
>
{products.map((product, index) => (
<div
key={product.id}
className="flex-none w-72 md:w-80"
style={{ scrollSnapAlign: 'start' }}
>
<ProductCard
product={product}
className="h-full animate-fade-in-up"
style={{
animationDelay: `${index * 100}ms`,
animationFillMode: 'both'
}}
/>
</div>
))}
</div>
{/* Gradient Overlays */}
<div className="absolute left-0 top-0 bottom-4 w-8 bg-gradient-to-r from-gray-50 to-transparent pointer-events-none" />
<div className="absolute right-0 top-0 bottom-4 w-8 bg-gradient-to-l from-gray-50 to-transparent pointer-events-none" />
</div>
{/* Mobile Navigation Dots */}
<div className="flex justify-center gap-2 md:hidden">
{products.map((_, index) => (
<button
key={index}
className={cn(
"w-2 h-2 rounded-full transition-all duration-300",
index === 0 ? "bg-black" : "bg-gray-300" // En production, calculer l'index actuel
)}
onClick={() => {
if (scrollContainerRef.current) {
scrollContainerRef.current.scrollTo({
left: index * 320,
behavior: 'smooth'
});
}
}}
/>
))}
</div>
{/* Alternative: Grid Layout for smaller screens */}
<div className="md:hidden">
<div className="grid grid-cols-2 gap-4 mt-8">
{products.slice(0, 4).map((product) => (
<ProductCard
key={product.id}
product={product}
className="w-full"
/>
))}
</div>
</div>
</section>
);
}

View File

@@ -0,0 +1,181 @@
// src/app/produit/[slug]/page.tsx
import { Metadata } from 'next';
import { notFound } from 'next/navigation';
import { WooCommerceService, formatPrice, isOnSale, getDiscountPercentage } from '@/lib/woocommerce';
import ProductImageGallery from '@/components/product/ProductImageGallery';
import ProductInfo from '@/components/product/ProductInfo';
import RelatedProducts from '@/components/product/RelatedProducts';
import ProductTabs from '@/components/product/ProductTabs';
import Breadcrumb from '@/components/ui/breadcrumb';
interface ProductPageProps {
params: {
slug: string;
};
}
export async function generateMetadata({ params }: ProductPageProps): Promise<Metadata> {
const response = await WooCommerceService.getProductBySlug(params.slug);
if (!response.success || !response.data) {
return {
title: 'Produit non trouvé',
};
}
const product = response.data;
const discountPercentage = getDiscountPercentage(product);
return {
title: `${product.name} - MELHFA`,
description: product.short_description || product.description,
openGraph: {
title: product.name,
description: product.short_description || product.description,
images: product.images.map(img => ({
url: img.src,
width: 800,
height: 1200,
alt: img.alt || product.name,
})),
type: 'product',
},
twitter: {
card: 'summary_large_image',
title: product.name,
description: product.short_description || product.description,
images: [product.images[0]?.src || ''],
},
alternates: {
canonical: `/produit/${product.slug}`,
},
other: {
'product:price:amount': product.price,
'product:price:currency': 'MRU',
'product:availability': product.stock_status === 'instock' ? 'in stock' : 'out of stock',
'product:condition': 'new',
...(isOnSale(product) && {
'product:sale_price:amount': product.sale_price,
'product:sale_price:currency': 'MRU',
}),
},
};
}
export default async function ProductPage({ params }: ProductPageProps): Promise<JSX.Element> {
// Récupérer le produit
const response = await WooCommerceService.getProductBySlug(params.slug);
if (!response.success || !response.data) {
notFound();
}
const product = response.data;
// Récupérer les produits liés
const relatedProductsResponse = await WooCommerceService.getProducts({
per_page: 4,
exclude: [product.id],
category: product.categories[0]?.slug,
});
const relatedProducts = relatedProductsResponse.data || [];
// Breadcrumb items
const breadcrumbItems = [
{ label: 'Accueil', href: '/' },
{ label: 'Boutique', href: '/boutique' },
...(product.categories.length > 0 ? [{
label: product.categories[0].name,
href: `/boutique?category=${product.categories[0].slug}`
}] : []),
{ label: product.name, href: `/produit/${product.slug}` },
];
return (
<div className="min-h-screen bg-white">
{/* Breadcrumb */}
<div className="max-w-[1400px] mx-auto px-6 pt-20 pb-6">
<Breadcrumb items={breadcrumbItems} />
</div>
{/* Product Details */}
<div className="max-w-[1400px] mx-auto px-6 pb-16">
<div className="grid grid-cols-1 lg:grid-cols-2 gap-12 lg:gap-16">
{/* Image Gallery */}
<div className="space-y-4">
<ProductImageGallery
images={product.images}
productName={product.name}
isOnSale={isOnSale(product)}
discountPercentage={getDiscountPercentage(product)}
isFeatured={product.featured}
/>
</div>
{/* Product Info */}
<div className="space-y-8">
<ProductInfo product={product} />
</div>
</div>
</div>
{/* Product Tabs */}
<div className="max-w-[1400px] mx-auto px-6 pb-16">
<ProductTabs product={product} />
</div>
{/* Related Products */}
{relatedProducts.length > 0 && (
<div className="bg-gray-50 py-16">
<div className="max-w-[1400px] mx-auto px-6">
<RelatedProducts products={relatedProducts} />
</div>
</div>
)}
{/* Structured Data for SEO */}
<script
type="application/ld+json"
dangerouslySetInnerHTML={{
__html: JSON.stringify({
'@context': 'https://schema.org',
'@type': 'Product',
name: product.name,
description: product.description,
image: product.images.map(img => img.src),
sku: product.sku,
mpn: product.sku,
brand: {
'@type': 'Brand',
name: 'MELHFA'
},
offers: {
'@type': 'Offer',
price: product.price,
priceCurrency: 'MRU',
availability: product.stock_status === 'instock'
? 'https://schema.org/InStock'
: 'https://schema.org/OutOfStock',
url: `${process.env.NEXT_PUBLIC_SITE_URL}/produit/${product.slug}`,
seller: {
'@type': 'Organization',
name: 'MELHFA'
},
...(isOnSale(product) && {
priceValidUntil: product.date_on_sale_to || '2024-12-31'
})
},
aggregateRating: product.rating_count > 0 ? {
'@type': 'AggregateRating',
ratingValue: product.average_rating,
reviewCount: product.rating_count
} : undefined,
category: product.categories.map(cat => cat.name).join(', '),
})
}}
/>
</div>
);
}

View File

@@ -0,0 +1,123 @@
// src/components/sections/HeroSection.tsx
'use client';
import { useEffect, useState } from 'react';
import Link from 'next/link';
import Image from 'next/image';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { ArrowRight } from 'lucide-react';
export default function HeroSection(): JSX.Element {
const [isVisible, setIsVisible] = useState(false);
useEffect(() => {
setIsVisible(true);
}, []);
return (
<section className="relative min-h-screen flex items-center bg-gray-50 mt-16">
<div className="max-w-[1400px] mx-auto px-6 w-full">
<div className="grid grid-cols-1 lg:grid-cols-2 gap-16 items-center">
{/* Content */}
<div className={`space-y-8 ${isVisible ? 'animate-fade-in-up' : 'opacity-0'}`}>
<div className="space-y-6">
<h1 className="text-5xl md:text-6xl lg:text-7xl font-extralight leading-none tracking-tight text-black">
MELHFA
<br />
<span className="font-light">ÉLÉGANTE</span>
</h1>
<p className="text-lg md:text-xl text-gray-600 font-light leading-relaxed max-w-lg">
Découvrez l&apos;art mauritanien à travers nos voiles d&apos;exception,
alliant tradition ancestrale et élégance contemporaine.
</p>
</div>
<div className="flex flex-col sm:flex-row gap-6 items-start sm:items-center">
<Button
size="lg"
className="bg-black text-white hover:bg-gray-800 px-12 py-4 text-sm uppercase tracking-[2px] transition-all duration-300 hover:scale-105"
asChild
>
<Link href="/boutique">
Découvrir
</Link>
</Button>
<Button
variant="link"
className="text-black hover:opacity-60 text-sm uppercase tracking-[2px] p-0 h-auto border-b border-black pb-1"
asChild
>
<Link href="/collections">
Collections
<ArrowRight className="w-4 h-4 ml-2" />
</Link>
</Button>
</div>
</div>
{/* Image Section */}
<div className={`relative ${isVisible ? 'animate-fade-in-up' : 'opacity-0'} lg:order-2`}>
<div className="relative">
{/* Image principale */}
<div className="relative h-[600px] lg:h-[700px] w-full rounded-2xl overflow-hidden shadow-2xl">
<Image
src="/images/melhfa-hero.jpg"
alt="Melhfa Élégante"
fill
className="object-cover"
priority
sizes="(max-width: 768px) 100vw, 50vw"
/>
{/* Badge sur l'image */}
<div className="absolute top-6 right-6">
<Badge className="bg-black/80 text-white px-4 py-2 text-xs uppercase tracking-wide">
Nouvelle Collection
</Badge>
</div>
</div>
{/* Carte flottante */}
<div className="absolute -bottom-8 -left-8 bg-white/95 backdrop-blur-xl p-6 rounded-xl shadow-xl max-w-[200px] border border-white/20">
<div className="space-y-4">
<div className="relative h-24 w-full rounded-lg overflow-hidden">
<Image
src="/images/melhfa-thumbnail.jpg"
alt="Melhfa Premium"
fill
className="object-cover"
/>
</div>
<div className="space-y-2">
<h3 className="text-xs font-medium uppercase tracking-wide text-black">
Melhfa Premium
</h3>
<p className="text-sm text-gray-600 font-light">
À partir de 28.000 MRU
</p>
</div>
</div>
</div>
{/* Éléments décoratifs */}
<div className="absolute -top-4 -right-4 w-24 h-24 bg-gradient-to-br from-gray-200 to-gray-300 rounded-full opacity-60 -z-10" />
<div className="absolute -bottom-12 -right-12 w-32 h-32 bg-gradient-to-tl from-gray-100 to-gray-200 rounded-full opacity-40 -z-10" />
</div>
</div>
</div>
</div>
{/* Scroll indicator */}
<div className="absolute bottom-8 left-1/2 transform -translate-x-1/2 hidden lg:block">
<div className="animate-bounce">
<div className="w-1 h-12 bg-gray-400 rounded-full" />
</div>
</div>
</section>
);
}

View File

@@ -0,0 +1,156 @@
// src/components/sections/NewsletterSection.tsx
'use client';
import { useState } from 'react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Mail, CheckCircle } from 'lucide-react';
export default function NewsletterSection(): JSX.Element {
const [email, setEmail] = useState('');
const [isSubmitting, setIsSubmitting] = useState(false);
const [isSubmitted, setIsSubmitted] = useState(false);
const [error, setError] = useState('');
const handleSubmit = async (e: React.FormEvent): Promise<void> => {
e.preventDefault();
if (!email || !email.includes('@')) {
setError('Veuillez entrer une adresse email valide');
return;
}
setIsSubmitting(true);
setError('');
try {
// Simuler l'appel API
await new Promise(resolve => setTimeout(resolve, 1000));
setIsSubmitted(true);
setEmail('');
// Reset après 3 secondes
setTimeout(() => {
setIsSubmitted(false);
}, 3000);
} catch (err) {
setError('Une erreur est survenue. Veuillez réessayer.');
} finally {
setIsSubmitting(false);
}
};
if (isSubmitted) {
return (
<section className="py-20 bg-gray-50">
<div className="max-w-[1400px] mx-auto px-6 text-center">
<div className="max-w-md mx-auto space-y-6">
<div className="w-16 h-16 bg-green-500 rounded-full flex items-center justify-center mx-auto">
<CheckCircle className="w-8 h-8 text-white" />
</div>
<h2 className="text-2xl font-light tracking-wide">Merci !</h2>
<p className="text-gray-600">
Vous êtes maintenant inscrit(e) à notre newsletter.
Vous recevrez bientôt nos dernières actualités.
</p>
</div>
</div>
</section>
);
}
return (
<section className="py-20 bg-gray-50">
<div className="max-w-[1400px] mx-auto px-6 text-center">
<div className="max-w-2xl mx-auto space-y-8">
{/* Icon */}
<div className="flex justify-center">
<div className="w-16 h-16 bg-black rounded-full flex items-center justify-center">
<Mail className="w-8 h-8 text-white" />
</div>
</div>
{/* Titre et description */}
<div className="space-y-4">
<h2 className="text-3xl md:text-4xl font-light tracking-wide text-black">
Restez informé
</h2>
<p className="text-gray-600 text-lg">
Recevez nos dernières collections et offres exclusives directement dans votre boîte mail
</p>
</div>
{/* Formulaire */}
<form onSubmit={handleSubmit} className="max-w-md mx-auto space-y-4">
<div className="flex flex-col sm:flex-row gap-4">
<div className="flex-1">
<Input
type="email"
placeholder="Votre adresse email"
value={email}
onChange={(e) => setEmail(e.target.value)}
className="w-full px-4 py-3 text-sm border border-gray-300 focus:border-black focus:ring-0 rounded-none"
disabled={isSubmitting}
/>
{error && (
<p className="text-red-500 text-xs mt-2 text-left">{error}</p>
)}
</div>
<Button
type="submit"
disabled={isSubmitting || !email}
className="bg-black text-white hover:bg-gray-800 px-8 py-3 text-sm uppercase tracking-[2px] transition-all duration-300 rounded-none whitespace-nowrap"
>
{isSubmitting ? (
<div className="flex items-center space-x-2">
<div className="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin" />
<span>Envoi...</span>
</div>
) : (
"S'abonner"
)}
</Button>
</div>
</form>
{/* Informations supplémentaires */}
<div className="space-y-3 text-sm text-gray-500">
<p>
En vous abonnant, vous acceptez de recevoir nos communications marketing.
</p>
<div className="flex justify-center space-x-6 text-xs">
<span> Pas de spam</span>
<span> Désabonnement facile</span>
<span> 1-2 emails par mois maximum</span>
</div>
</div>
{/* Avantages de l'inscription */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 pt-8 border-t border-gray-200">
<div className="space-y-2">
<h4 className="font-medium text-black">Offres exclusives</h4>
<p className="text-sm text-gray-600">
Accès privilégié à nos soldes privées
</p>
</div>
<div className="space-y-2">
<h4 className="font-medium text-black">Nouvelles collections</h4>
<p className="text-sm text-gray-600">
Soyez la première à découvrir nos créations
</p>
</div>
<div className="space-y-2">
<h4 className="font-medium text-black">Conseils style</h4>
<p className="text-sm text-gray-600">
Tips et inspirations mode mauritanienne
</p>
</div>
</div>
</div>
</div>
</section>
);
}

View File

@@ -0,0 +1,95 @@
// src/components/sections/PromoSection.tsx
'use client';
import Link from 'next/link';
import { Button } from '@/components/ui/button';
import { Sparkles } from 'lucide-react';
export default function PromoSection(): JSX.Element {
return (
<section className="relative py-20 overflow-hidden">
{/* Background avec dégradé */}
<div className="absolute inset-0 bg-gradient-to-br from-indigo-600 via-purple-700 to-indigo-800" />
{/* Motifs décoratifs */}
<div className="absolute inset-0 opacity-10">
<div className="absolute top-10 left-10 w-20 h-20 border-2 border-white rounded-full" />
<div className="absolute top-32 right-20 w-16 h-16 border border-white rounded-full" />
<div className="absolute bottom-20 left-1/4 w-12 h-12 border border-white rounded-full" />
<div className="absolute bottom-32 right-1/3 w-24 h-24 border-2 border-white rounded-full" />
</div>
<div className="relative max-w-[1400px] mx-auto px-6 text-center">
<div className="max-w-4xl mx-auto space-y-8">
{/* Icon */}
<div className="flex justify-center">
<div className="w-16 h-16 bg-white/20 rounded-full flex items-center justify-center backdrop-blur-sm">
<Sparkles className="w-8 h-8 text-white" />
</div>
</div>
{/* Titre principal */}
<div className="space-y-4">
<h2 className="text-4xl md:text-5xl lg:text-6xl font-light text-white tracking-wide">
SOLDES D&apos;É
</h2>
<p className="text-xl md:text-2xl text-white/90 font-light">
Jusqu&apos;à -40% sur une sélection de voiles premium
</p>
</div>
{/* Description */}
<p className="text-lg text-white/80 max-w-2xl mx-auto leading-relaxed">
Profitez de nos offres exceptionnelles sur les plus belles créations de nos artisans.
Une occasion unique de découvrir l&apos;élégance mauritanienne à prix réduit.
</p>
{/* CTA Buttons */}
<div className="flex flex-col sm:flex-row gap-4 justify-center items-center pt-4">
<Button
size="lg"
className="bg-white text-black hover:bg-gray-100 px-8 py-4 text-sm uppercase tracking-[2px] transition-all duration-300 hover:scale-105"
asChild
>
<Link href="/boutique?filter=sale">
Voir les offres
</Link>
</Button>
<Button
variant="ghost"
size="lg"
className="text-white border border-white/30 hover:bg-white/10 px-8 py-4 text-sm uppercase tracking-[2px] backdrop-blur-sm"
asChild
>
<Link href="/collections">
Toutes les collections
</Link>
</Button>
</div>
{/* Informations complémentaires */}
<div className="pt-8 space-y-2">
<p className="text-sm text-white/70 uppercase tracking-wide">
Offre valable jusqu&apos;au 31 août 2024
</p>
<div className="flex justify-center space-x-8 text-xs text-white/60">
<span> Livraison gratuite</span>
<span> Retour sous 30 jours</span>
<span> Paiement sécurisé</span>
</div>
</div>
</div>
</div>
{/* Particules flottantes */}
<div className="absolute inset-0 overflow-hidden pointer-events-none">
<div className="absolute top-1/4 left-1/4 w-2 h-2 bg-white rounded-full opacity-60 animate-ping" style={{ animationDelay: '0s' }} />
<div className="absolute top-1/3 right-1/4 w-1 h-1 bg-white rounded-full opacity-40 animate-ping" style={{ animationDelay: '1s' }} />
<div className="absolute bottom-1/4 left-1/3 w-1.5 h-1.5 bg-white rounded-full opacity-50 animate-ping" style={{ animationDelay: '2s' }} />
<div className="absolute bottom-1/3 right-1/3 w-1 h-1 bg-white rounded-full opacity-30 animate-ping" style={{ animationDelay: '3s' }} />
</div>
</section>
);
}

View File

@@ -0,0 +1,63 @@
// src/components/ui/accordion.tsx
"use client"
import * as React from "react"
import * as AccordionPrimitive from "@radix-ui/react-accordion"
import { ChevronDown } from "lucide-react"
import { cn } from "@/lib/utils"
const Accordion = AccordionPrimitive.Root
const AccordionItem = React.forwardRef<
React.ElementRef<typeof AccordionPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Item>
>(({ className, ...props }, ref) => (
<AccordionPrimitive.Item
ref={ref}
className={cn("border-b", className)}
{...props}
/>
))
AccordionItem.displayName = "AccordionItem"
const AccordionTrigger = React.forwardRef<
React.ElementRef<typeof AccordionPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<AccordionPrimitive.Header className="flex">
<AccordionPrimitive.Trigger
ref={ref}
className={cn(
"flex flex-1 items-center justify-between py-4 font-medium transition-all hover:underline [&[data-state=open]>svg]:rotate-180",
className
)}
{...props}
>
{children}
<ChevronDown className="h-4 w-4 shrink-0 transition-transform duration-200" />
</AccordionPrimitive.Trigger>
</AccordionPrimitive.Header>
))
AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName
const AccordionContent = React.forwardRef<
React.ElementRef<typeof AccordionPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<AccordionPrimitive.Content
ref={ref}
className="overflow-hidden text-sm transition-all data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down"
{...props}
>
<div className={cn("pb-4 pt-0", className)}>{children}</div>
</AccordionPrimitive.Content>
))
AccordionContent.displayName = AccordionPrimitive.Content.displayName
export {
Accordion,
AccordionItem,
AccordionTrigger,
AccordionContent,
}

View File

@@ -0,0 +1,46 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const badgeVariants = cva(
"inline-flex items-center justify-center rounded-md border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
{
variants: {
variant: {
default:
"border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
secondary:
"border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
destructive:
"border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
},
},
defaultVariants: {
variant: "default",
},
}
)
function Badge({
className,
variant,
asChild = false,
...props
}: React.ComponentProps<"span"> &
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
const Comp = asChild ? Slot : "span"
return (
<Comp
data-slot="badge"
className={cn(badgeVariants({ variant }), className)}
{...props}
/>
)
}
export { Badge, badgeVariants }

View File

@@ -0,0 +1,52 @@
// src/components / ui / breadcrumb.tsx
import React from "react"
import Link from "next/link"
import { ChevronRight, Home } from "lucide-react"
import { cn } from "@/lib/utils"
interface BreadcrumbItem {
label: string
href: string
}
interface BreadcrumbProps {
items: BreadcrumbItem[]
className?: string
}
export default function Breadcrumb({ items, className }: BreadcrumbProps): JSX.Element {
return (
<nav aria-label="Breadcrumb" className={cn("flex", className)}>
<ol className="inline-flex items-center space-x-1 md:space-x-3">
<li className="inline-flex items-center">
<Link
href="/"
className="inline-flex items-center text-sm font-medium text-gray-700 hover:text-black"
>
<Home className="w-4 h-4 mr-2" />
Accueil
</Link>
</li>
{items.slice(1).map((item, index) => (
<li key={item.href}>
<div className="flex items-center">
<ChevronRight className="w-4 h-4 text-gray-400" />
{index === items.length - 2 ? (
<span className="ml-1 text-sm font-medium text-gray-500 md:ml-2">
{item.label}
</span>
) : (
<Link
href={item.href}
className="ml-1 text-sm font-medium text-gray-700 hover:text-black md:ml-2"
>
{item.label}
</Link>
)}
</div>
</li>
))}
</ol>
</nav>
)
}

Some files were not shown because too many files have changed in this diff Show More