only front-end init

This commit is contained in:
Rustico77
2026-05-05 21:48:23 +00:00
parent ac76a80c7b
commit b32a70cd0e
53 changed files with 11684 additions and 206 deletions

56
app/admin/layout.tsx Normal file
View File

@@ -0,0 +1,56 @@
"use client";
import { SidebarProvider, SidebarTrigger } from "@/components/ui/sidebar";
import { AdminSidebar } from "@/components/admin/AdminSidebar";
import { useAdmin } from "@/contexts/AdminContext";
import { usePathname, useRouter } from "next/navigation";
import { useEffect } from "react";
export default function AdminLayout({ children }: { children: React.ReactNode }) {
const { isAdmin } = useAdmin();
const router = useRouter();
const pathname = usePathname();
const isLoginRoute = pathname === "/admin/login";
// useEffect(() => {
// if (!isAdmin && !isLoginRoute) {
// router.push("/admin/login");
// }
// }, [isAdmin, isLoginRoute, router]);
// if (!isAdmin && !isLoginRoute) {
// return (
// <div className="min-h-screen flex items-center justify-center bg-muted/30">
// <div className="text-center">Chargement...</div>
// </div>
// );
// }
if (isLoginRoute) {
return (
<div className="min-h-screen flex w-full bg-muted/30">
<div className="flex-1 flex flex-col">
<main className="flex-1 p-6">{children}</main>
</div>
</div>
);
}
// Layout pour l'admin connecté (avec la sidebar)
return (
<SidebarProvider>
<div className="min-h-screen flex w-full bg-muted/30">
<AdminSidebar />
<div className="flex-1 flex flex-col">
<header className="h-14 flex items-center border-b border-border bg-background px-4 sticky top-0 z-10">
<SidebarTrigger />
<h1 className="ml-4 text-sm font-medium text-muted-foreground">
Tableau de bord
</h1>
</header>
<main className="flex-1 p-6">{children}</main>
</div>
</div>
</SidebarProvider>
);
}

67
app/admin/login/page.tsx Normal file
View File

@@ -0,0 +1,67 @@
"use client";
import { useState } from "react";
import { Lock } from "lucide-react";
import { useAdmin } from "@/contexts/AdminContext";
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 { toast } from "sonner";
import { useRouter } from "next/navigation";
export default function AdminLogin() {
const { login, isAdmin } = useAdmin();
const route = useRouter();
const [password, setPassword] = useState("");
const [loading, setLoading] = useState(false);
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
setLoading(true);
setTimeout(() => {
const ok = login(password);
setLoading(false);
if (ok) {
toast.success("Connexion réussie");
route.push("/admin");
} else {
toast.error("Mot de passe incorrect");
}
}, 300);
};
return (
<div className="min-h-screen flex items-center justify-center bg-muted/30 px-4">
<Card className="w-full max-w-md">
<CardHeader className="text-center">
<div className="mx-auto h-12 w-12 rounded-full bg-primary/10 flex items-center justify-center mb-2">
<Lock className="h-5 w-5 text-primary" />
</div>
<CardTitle className="font-serif">Espace Admin</CardTitle>
<CardDescription>Accès réservé à la gestion du site</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="password">Mot de passe</Label>
<Input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="••••••••"
required
autoFocus
/>
<p className="text-xs text-muted-foreground">Démo : <span className="font-mono">admin123</span></p>
</div>
<Button type="submit" className="w-full p-6" disabled={loading}>
{loading ? "Connexion..." : "Se connecter"}
</Button>
</form>
</CardContent>
</Card>
</div>
);
};

79
app/admin/page.tsx Normal file
View File

@@ -0,0 +1,79 @@
"use client";
import { Package, CalendarCheck, Clock, TrendingUp } from "lucide-react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { useAdmin } from "@/contexts/AdminContext";
export default function AdminOverview() {
const { products, reservations } = useAdmin();
const pending = reservations.filter((r) => r.status === "pending").length;
const confirmed = reservations.filter((r) => r.status === "confirmed").length;
const totalValue = products.reduce((sum, p) => sum + p.price, 0);
const stats = [
{ label: "Produits", value: products.length, icon: Package, color: "text-blue-600", bg: "bg-blue-100" },
{ label: "RDV en attente", value: pending, icon: Clock, color: "text-amber-600", bg: "bg-amber-100" },
{ label: "RDV confirmés", value: confirmed, icon: CalendarCheck, color: "text-green-600", bg: "bg-green-100" },
{ label: "Valeur catalogue", value: `${totalValue}`, icon: TrendingUp, color: "text-primary", bg: "bg-primary/10" },
];
const upcoming = [...reservations]
.filter((r) => r.status !== "cancelled")
.sort((a, b) => `${a.date}${a.time}`.localeCompare(`${b.date}${b.time}`))
.slice(0, 5);
return (
<div className="space-y-6">
<div>
<h2 className="font-serif text-2xl font-semibold">Vue d'ensemble</h2>
<p className="text-sm text-muted-foreground mt-1">Aperçu de votre activité</p>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
{stats.map((s) => (
<Card key={s.label}>
<CardContent className="p-5 flex items-center justify-between">
<div>
<p className="text-sm text-muted-foreground">{s.label}</p>
<p className="text-2xl font-semibold mt-1">{s.value}</p>
</div>
<div className={`h-10 w-10 rounded-full ${s.bg} flex items-center justify-center`}>
<s.icon className={`h-5 w-5 ${s.color}`} />
</div>
</CardContent>
</Card>
))}
</div>
<Card>
<CardHeader>
<CardTitle className="text-base">Prochains rendez-vous</CardTitle>
</CardHeader>
<CardContent>
{upcoming.length === 0 ? (
<p className="text-sm text-muted-foreground">Aucun rendez-vous à venir.</p>
) : (
<div className="space-y-3">
{upcoming.map((r) => (
<div key={r.id} className="flex items-center justify-between py-3 border-b border-border last:border-0">
<div>
<p className="font-medium text-sm">{r.clientName}</p>
<p className="text-xs text-muted-foreground">{r.service}</p>
</div>
<div className="text-right">
<p className="text-sm font-medium">{new Date(r.date).toLocaleDateString("fr-FR", { day: "2-digit", month: "short" })} à {r.time}</p>
<Badge variant={r.status === "confirmed" ? "default" : "secondary"} className="mt-1 text-xs">
{r.status === "confirmed" ? "Confirmé" : "En attente"}
</Badge>
</div>
</div>
))}
</div>
)}
</CardContent>
</Card>
</div>
);
};

277
app/admin/produits/page.tsx Normal file
View File

@@ -0,0 +1,277 @@
"use client";
import { useState } from "react";
import { Plus, Pencil, Trash2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { Badge } from "@/components/ui/badge";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
DialogDescription,
} from "@/components/ui/dialog";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { useAdmin } from "@/contexts/AdminContext";
import { Product } from "@/data/products";
import { toast } from "sonner";
type FormState = {
name: string;
category: Product["category"];
price: string;
image: string;
description: string;
colors: string;
lengths: string;
isNew: boolean;
isBestseller: boolean;
};
const emptyForm: FormState = {
name: "",
category: "clip-in",
price: "",
image: "",
description: "",
colors: "",
lengths: "",
isNew: false,
isBestseller: false,
};
export default function AdminProducts() {
const { products, addProduct, updateProduct, deleteProduct } = useAdmin();
const [dialogOpen, setDialogOpen] = useState(false);
const [editingId, setEditingId] = useState<string | null>(null);
const [form, setForm] = useState<FormState>(emptyForm);
const [deleteId, setDeleteId] = useState<string | null>(null);
const openCreate = () => {
setEditingId(null);
setForm(emptyForm);
setDialogOpen(true);
};
const openEdit = (p: Product) => {
setEditingId(p.id);
setForm({
name: p.name,
category: p.category,
price: String(p.price),
image: p.image,
description: p.description,
colors: p.colors.join(", "),
lengths: p.lengths.join(", "),
isNew: !!p.isNew,
isBestseller: !!p.isBestseller,
});
setDialogOpen(true);
};
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
const price = parseFloat(form.price);
if (!form.name || isNaN(price)) {
toast.error("Nom et prix valides requis");
return;
}
const payload = {
name: form.name,
category: form.category,
price,
image: form.image || "https://images.unsplash.com/photo-1522337360788-8b13dee7a37e?w=600&h=800&fit=crop",
images: [form.image || "https://images.unsplash.com/photo-1522337360788-8b13dee7a37e?w=600&h=800&fit=crop"],
colors: form.colors.split(",").map((c) => c.trim()).filter(Boolean),
lengths: form.lengths.split(",").map((l) => l.trim()).filter(Boolean),
description: form.description,
features: [],
isNew: form.isNew,
isBestseller: form.isBestseller,
rating: 5,
reviewCount: 0,
};
if (editingId) {
updateProduct(editingId, payload);
toast.success("Produit modifié");
} else {
addProduct(payload);
toast.success("Produit ajouté");
}
setDialogOpen(false);
};
const handleDelete = () => {
if (deleteId) {
deleteProduct(deleteId);
toast.success("Produit supprimé");
setDeleteId(null);
}
};
const categoryLabels: Record<Product["category"], string> = {
"clip-in": "Clip-In",
"tape-in": "Tape-In",
"ponytail": "Ponytail",
"keratin": "Kératine",
};
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h2 className="font-serif text-2xl font-semibold">Produits</h2>
<p className="text-sm text-muted-foreground mt-1">{products.length} produit{products.length > 1 ? "s" : ""} au catalogue</p>
</div>
<Button onClick={openCreate}>
<Plus className="h-4 w-4" />
Ajouter
</Button>
</div>
<Card>
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-16">Image</TableHead>
<TableHead>Nom</TableHead>
<TableHead>Catégorie</TableHead>
<TableHead>Prix</TableHead>
<TableHead>Statut</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{products.map((p) => (
<TableRow key={p.id}>
<TableCell>
<img src={p.image} alt={p.name} className="h-12 w-12 rounded object-cover" />
</TableCell>
<TableCell className="font-medium">{p.name}</TableCell>
<TableCell className="text-muted-foreground">{categoryLabels[p.category]}</TableCell>
<TableCell>{p.price} </TableCell>
<TableCell className="space-x-1">
{p.isNew && <Badge variant="secondary">Nouveau</Badge>}
{p.isBestseller && <Badge>Bestseller</Badge>}
</TableCell>
<TableCell className="text-right space-x-2">
<Button variant="ghost" size="icon" onClick={() => openEdit(p)}>
<Pencil className="h-4 w-4" />
</Button>
<Button variant="ghost" size="icon" onClick={() => setDeleteId(p.id)}>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</Card>
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogContent className="lg:min-w-2xl max-w-2xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>{editingId ? "Modifier le produit" : "Nouveau produit"}</DialogTitle>
<DialogDescription>Renseignez les informations du produit</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2 col-span-2">
<Label htmlFor="name">Nom</Label>
<Input id="name" value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} required />
</div>
<div className="space-y-2">
<Label>Catégorie</Label>
<Select value={form.category} onValueChange={(v) => setForm({ ...form, category: v as Product["category"] })}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="clip-in">Clip-In</SelectItem>
<SelectItem value="tape-in">Tape-In</SelectItem>
<SelectItem value="ponytail">Ponytail</SelectItem>
<SelectItem value="keratin">Kératine</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="price">Prix ()</Label>
<Input id="price" type="number" step="0.01" value={form.price} onChange={(e) => setForm({ ...form, price: e.target.value })} required />
</div>
<div className="space-y-2 col-span-2">
<Label htmlFor="image">URL de l'image</Label>
<Input id="image" value={form.image} onChange={(e) => setForm({ ...form, image: e.target.value })} placeholder="https://..." />
</div>
<div className="space-y-2 col-span-2">
<Label htmlFor="description">Description</Label>
<Textarea id="description" value={form.description} onChange={(e) => setForm({ ...form, description: e.target.value })} rows={3} />
</div>
<div className="space-y-2">
<Label htmlFor="colors">Couleurs (séparées par des virgules)</Label>
<Input id="colors" value={form.colors} onChange={(e) => setForm({ ...form, colors: e.target.value })} placeholder="Brun, Blond, Noir" />
</div>
<div className="space-y-2">
<Label htmlFor="lengths">Longueurs (séparées par des virgules)</Label>
<Input id="lengths" value={form.lengths} onChange={(e) => setForm({ ...form, lengths: e.target.value })} placeholder="40 cm, 50 cm" />
</div>
<label className="flex items-center gap-2 text-sm">
<input type="checkbox" checked={form.isNew} onChange={(e) => setForm({ ...form, isNew: e.target.checked })} />
Marquer comme Nouveau
</label>
<label className="flex items-center gap-2 text-sm">
<input type="checkbox" checked={form.isBestseller} onChange={(e) => setForm({ ...form, isBestseller: e.target.checked })} />
Marquer comme Bestseller
</label>
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => setDialogOpen(false)}>Annuler</Button>
<Button type="submit">{editingId ? "Enregistrer" : "Créer"}</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
<AlertDialog open={!!deleteId} onOpenChange={(o) => !o && setDeleteId(null)}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Supprimer ce produit ?</AlertDialogTitle>
<AlertDialogDescription>Cette action est irréversible.</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Annuler</AlertDialogCancel>
<AlertDialogAction onClick={handleDelete}>Supprimer</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
};

View File

@@ -0,0 +1,162 @@
"use client";
import { useState } from "react";
import { Check, X, Trash2, Mail, Phone } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import {
Tabs,
TabsList,
TabsTrigger,
} from "@/components/ui/tabs";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { useAdmin, Reservation } from "@/contexts/AdminContext";
import { toast } from "sonner";
const statusConfig: Record<Reservation["status"], { label: string; variant: "default" | "secondary" | "destructive" }> = {
pending: { label: "En attente", variant: "secondary" },
confirmed: { label: "Confirmé", variant: "default" },
cancelled: { label: "Annulé", variant: "destructive" },
};
export default function AdminReservations() {
const { reservations, updateReservationStatus, deleteReservation } = useAdmin();
const [filter, setFilter] = useState<"all" | Reservation["status"]>("all");
const [deleteId, setDeleteId] = useState<string | null>(null);
const filtered = reservations
.filter((r) => filter === "all" || r.status === filter)
.sort((a, b) => `${b.date}${b.time}`.localeCompare(`${a.date}${a.time}`));
const handleConfirm = (id: string) => {
updateReservationStatus(id, "confirmed");
toast.success("Réservation confirmée");
};
const handleCancel = (id: string) => {
updateReservationStatus(id, "cancelled");
toast.success("Réservation annulée");
};
const handleDelete = () => {
if (deleteId) {
deleteReservation(deleteId);
toast.success("Réservation supprimée");
setDeleteId(null);
}
};
return (
<div className="space-y-6">
<div>
<h2 className="font-serif text-2xl font-semibold">Réservations</h2>
<p className="text-sm text-muted-foreground mt-1">{reservations.length} réservation{reservations.length > 1 ? "s" : ""} au total</p>
</div>
<Tabs value={filter} onValueChange={(v) => setFilter(v as typeof filter)}>
<TabsList>
<TabsTrigger value="all">Toutes</TabsTrigger>
<TabsTrigger value="pending">En attente</TabsTrigger>
<TabsTrigger value="confirmed">Confirmées</TabsTrigger>
<TabsTrigger value="cancelled">Annulées</TabsTrigger>
</TabsList>
</Tabs>
<Card>
<Table>
<TableHeader>
<TableRow>
<TableHead>Cliente</TableHead>
<TableHead>Contact</TableHead>
<TableHead>Service</TableHead>
<TableHead>Date & Heure</TableHead>
<TableHead>Statut</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filtered.length === 0 ? (
<TableRow>
<TableCell colSpan={6} className="text-center text-muted-foreground py-8">
Aucune réservation
</TableCell>
</TableRow>
) : (
filtered.map((r) => (
<TableRow key={r.id}>
<TableCell className="font-medium">{r.clientName}</TableCell>
<TableCell>
<div className="text-xs space-y-1">
<div className="flex items-center gap-1 text-muted-foreground">
<Mail className="h-3 w-3" /> {r.email}
</div>
<div className="flex items-center gap-1 text-muted-foreground">
<Phone className="h-3 w-3" /> {r.phone}
</div>
</div>
</TableCell>
<TableCell>{r.service}</TableCell>
<TableCell>
<div className="text-sm">
{new Date(r.date).toLocaleDateString("fr-FR", { day: "2-digit", month: "short", year: "numeric" })}
</div>
<div className="text-xs text-muted-foreground">{r.time}</div>
</TableCell>
<TableCell>
<Badge variant={statusConfig[r.status].variant}>{statusConfig[r.status].label}</Badge>
</TableCell>
<TableCell className="text-right space-x-1">
{r.status !== "confirmed" && (
<Button variant="ghost" size="icon" onClick={() => handleConfirm(r.id)} title="Confirmer">
<Check className="h-4 w-4 text-primary" />
</Button>
)}
{r.status !== "cancelled" && (
<Button variant="ghost" size="icon" onClick={() => handleCancel(r.id)} title="Annuler">
<X className="h-4 w-4 text-muted-foreground" />
</Button>
)}
<Button variant="ghost" size="icon" onClick={() => setDeleteId(r.id)} title="Supprimer">
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</Card>
<AlertDialog open={!!deleteId} onOpenChange={(o) => !o && setDeleteId(null)}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Supprimer cette réservation ?</AlertDialogTitle>
<AlertDialogDescription>Cette action est irréversible.</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Annuler</AlertDialogCancel>
<AlertDialogAction onClick={handleDelete}>Supprimer</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
};