move to gitea
This commit is contained in:
291
src/app/account/page.tsx
Normal file
291
src/app/account/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
126
src/app/api/orders/create/route.ts
Normal file
126
src/app/api/orders/create/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
31
src/app/auth/login/page.tsx
Normal file
31
src/app/auth/login/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
30
src/app/auth/register/page.tsx
Normal file
30
src/app/auth/register/page.tsx
Normal 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
235
src/app/boutique/page.tsx
Normal 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
421
src/app/checkout/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
209
src/app/checkout/success/page.tsx
Normal file
209
src/app/checkout/success/page.tsx
Normal 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 été 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
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
211
src/app/favoris/page.tsx
Normal 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
583
src/app/globals.css
Normal 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
41
src/app/layout.tsx
Normal 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
214
src/app/page.tsx
Normal 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'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'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
228
src/app/panier/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
150
src/components/auth/LoginForm.tsx
Normal file
150
src/components/auth/LoginForm.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
233
src/components/auth/RegisterForm.tsx
Normal file
233
src/components/auth/RegisterForm.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
145
src/components/cart/CartNotification.tsx
Normal file
145
src/components/cart/CartNotification.tsx
Normal 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
|
||||
};
|
||||
}
|
||||
204
src/components/cart/CartPreview.tsx
Normal file
204
src/components/cart/CartPreview.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
75
src/components/cart/CartSyncProvider.tsx
Normal file
75
src/components/cart/CartSyncProvider.tsx
Normal 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;
|
||||
}
|
||||
55
src/components/layout/Footer.tsx
Normal file
55
src/components/layout/Footer.tsx
Normal 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>© 2025 Melhfa Store. Tous droits réservés.</p>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
213
src/components/layout/Header.tsx
Normal file
213
src/components/layout/Header.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
429
src/components/layout/Navbar.tsx
Normal file
429
src/components/layout/Navbar.tsx
Normal 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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
95
src/components/layout/SiteLoader.tsx
Normal file
95
src/components/layout/SiteLoader.tsx
Normal 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}</>;
|
||||
}
|
||||
276
src/components/product/ProductCard.tsx
Normal file
276
src/components/product/ProductCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
354
src/components/product/ProductFilters.tsx
Normal file
354
src/components/product/ProductFilters.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
94
src/components/product/ProductGrid.tsx
Normal file
94
src/components/product/ProductGrid.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
215
src/components/product/ProductImageGallery.tsx
Normal file
215
src/components/product/ProductImageGallery.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
385
src/components/product/ProductInfo.tsx
Normal file
385
src/components/product/ProductInfo.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
153
src/components/product/ProductTabs.tsx
Normal file
153
src/components/product/ProductTabs.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
154
src/components/product/RelatedProducts.tsx
Normal file
154
src/components/product/RelatedProducts.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
181
src/components/product/[slug]/page.tsx
Normal file
181
src/components/product/[slug]/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
123
src/components/sections/HeroSection.tsx
Normal file
123
src/components/sections/HeroSection.tsx
Normal 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'art mauritanien à travers nos voiles d'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>
|
||||
);
|
||||
}
|
||||
156
src/components/sections/NewsletterSection.tsx
Normal file
156
src/components/sections/NewsletterSection.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
95
src/components/sections/PromoSection.tsx
Normal file
95
src/components/sections/PromoSection.tsx
Normal 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'ÉTÉ
|
||||
</h2>
|
||||
<p className="text-xl md:text-2xl text-white/90 font-light">
|
||||
Jusqu'à -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'é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'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>
|
||||
);
|
||||
}
|
||||
267
src/components/ui/MelhfaLoader.tsx
Normal file
267
src/components/ui/MelhfaLoader.tsx
Normal 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;
|
||||
}
|
||||
`;
|
||||
63
src/components/ui/accordion.tsx
Normal file
63
src/components/ui/accordion.tsx
Normal 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,
|
||||
}
|
||||
66
src/components/ui/alert.tsx
Normal file
66
src/components/ui/alert.tsx
Normal 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 }
|
||||
46
src/components/ui/badge.tsx
Normal file
46
src/components/ui/badge.tsx
Normal 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 }
|
||||
52
src/components/ui/breadcrumb.tsx
Normal file
52
src/components/ui/breadcrumb.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
59
src/components/ui/button.tsx
Normal file
59
src/components/ui/button.tsx
Normal 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 }
|
||||
92
src/components/ui/card.tsx
Normal file
92
src/components/ui/card.tsx
Normal 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,
|
||||
}
|
||||
241
src/components/ui/carousel.tsx
Normal file
241
src/components/ui/carousel.tsx
Normal 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,
|
||||
}
|
||||
30
src/components/ui/checkbox.tsx
Normal file
30
src/components/ui/checkbox.tsx
Normal 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 }
|
||||
143
src/components/ui/dialog.tsx
Normal file
143
src/components/ui/dialog.tsx
Normal 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,
|
||||
}
|
||||
257
src/components/ui/dropdown-menu.tsx
Normal file
257
src/components/ui/dropdown-menu.tsx
Normal 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,
|
||||
}
|
||||
21
src/components/ui/input.tsx
Normal file
21
src/components/ui/input.tsx
Normal 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 }
|
||||
26
src/components/ui/label.tsx
Normal file
26
src/components/ui/label.tsx
Normal 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 }
|
||||
157
src/components/ui/select.tsx
Normal file
157
src/components/ui/select.tsx
Normal 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,
|
||||
}
|
||||
39
src/components/ui/separator.tsx
Normal file
39
src/components/ui/separator.tsx
Normal 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
137
src/components/ui/sheet.tsx
Normal 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,
|
||||
}
|
||||
28
src/components/ui/slider.tsx
Normal file
28
src/components/ui/slider.tsx
Normal 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 }
|
||||
66
src/components/ui/tabs.tsx
Normal file
66
src/components/ui/tabs.tsx
Normal 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 }
|
||||
25
src/components/ui/textarea.tsx
Normal file
25
src/components/ui/textarea.tsx
Normal 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 }
|
||||
208
src/contexts/CartContext.tsx
Normal file
208
src/contexts/CartContext.tsx
Normal 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
205
src/hooks/useAuth.tsx
Normal 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
218
src/hooks/useCart.ts.ol
Normal 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
76
src/hooks/useCartSync.ts
Normal 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
29
src/hooks/useHydration.ts
Normal 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
0
src/hooks/useWishlist.ts
Normal file
205
src/lib/auth.ts
Normal file
205
src/lib/auth.ts
Normal 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
269
src/lib/utils.ts
Normal 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
218
src/lib/woocommerce.ts
Normal 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
79
src/types/auth.ts
Normal 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
133
src/types/woocommerce.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user