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

63
app/a-propos/page.tsx Normal file
View File

@@ -0,0 +1,63 @@
"use client";
import { useLanguage } from "@/contexts/LanguageContext";
import { Heart, Award, Sparkles } from "lucide-react";
export default function About() {
const { t } = useLanguage();
return (
<div className="min-h-screen">
{/* Hero */}
<section className="relative h-[50vh] flex items-center overflow-hidden">
<div className="absolute inset-0">
<img
src="https://images.unsplash.com/photo-1492106087820-71f1a00d2b11?w=1600&h=600&fit=crop"
alt="À propos"
className="w-full h-full object-cover"
/>
<div className="absolute inset-0 bg-foreground/40" />
</div>
<div className="relative container mx-auto px-4 lg:px-8 text-center">
<h1 className="font-serif text-4xl lg:text-6xl text-background">{t("about.title")}</h1>
</div>
</section>
{/* Story */}
<section className="py-16 lg:py-24 container mx-auto px-4 lg:px-8 max-w-3xl">
<p className="text-lg text-muted-foreground leading-relaxed mb-6">
Passionnée par la beauté et le bien-être capillaire depuis toujours, j'ai créé BADO HAIR pour offrir à chaque femme la possibilité de sublimer sa chevelure avec des extensions de qualité exceptionnelle.
</p>
<p className="text-lg text-muted-foreground leading-relaxed mb-6">
Chaque produit est sélectionné avec soin : des cheveux 100% naturels Remy, sourcés de manière éthique, traités avec les technologies les plus avancées pour garantir douceur, brillance et longévité.
</p>
<p className="text-lg text-muted-foreground leading-relaxed">
Mon objectif est simple : vous aider à vous sentir belle et confiante, que ce soit pour un événement spécial ou au quotidien. Chaque cliente mérite une expérience personnalisée et des conseils adaptés à ses besoins.
</p>
</section>
{/* Values */}
<section className="py-16 bg-muted/50">
<div className="container mx-auto px-4 lg:px-8">
<div className="grid grid-cols-1 md:grid-cols-3 gap-8 max-w-4xl mx-auto">
<div className="text-center">
<Heart className="h-8 w-8 mx-auto mb-4 text-primary" />
<h3 className="font-serif text-lg mb-2">Passion</h3>
<p className="text-sm text-muted-foreground">Chaque produit est choisi avec amour et expertise pour garantir votre satisfaction.</p>
</div>
<div className="text-center">
<Award className="h-8 w-8 mx-auto mb-4 text-primary" />
<h3 className="font-serif text-lg mb-2">Qualité Premium</h3>
<p className="text-sm text-muted-foreground">100% cheveux naturels Remy, sourcés éthiquement et contrôlés rigoureusement.</p>
</div>
<div className="text-center">
<Sparkles className="h-8 w-8 mx-auto mb-4 text-primary" />
<h3 className="font-serif text-lg mb-2">Expertise</h3>
<p className="text-sm text-muted-foreground">Conseils personnalisés et pose professionnelle pour un résultat naturel.</p>
</div>
</div>
</div>
</section>
</div>
);
};

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>
);
};

58
app/boutique/page.tsx Normal file
View File

@@ -0,0 +1,58 @@
"use client";
import { useState } from "react";
import { useLanguage } from "@/contexts/LanguageContext";
import { products, categories } from "@/data/products";
import ProductCard from "@/components/ProductCard";
export default function Shop() {
const { t } = useLanguage();
// const [searchParams] = useSearchParams();
// const initialCategory = searchParams.get("category") || "all";
const [selectedCategory, setSelectedCategory] = useState("all");
const filtered = selectedCategory === "all" ? products : products.filter((p) => p.category === selectedCategory);
return (
<div className="min-h-screen py-20 lg:py-12">
<div className="container mx-auto px-4 lg:px-8">
<h1 className="font-serif text-3xl lg:text-5xl text-center mb-8">{t("shop.title")}</h1>
{/* Filters */}
<div className="flex flex-wrap justify-center gap-2 mb-10">
<button
onClick={() => setSelectedCategory("all")}
className={`px-4 py-2 rounded-full text-sm transition-colors ${
selectedCategory === "all" ? "bg-primary text-primary-foreground" : "bg-muted text-muted-foreground hover:bg-accent"
}`}
>
{t("shop.filter.all")}
</button>
{categories.map((cat) => (
<button
key={cat.id}
onClick={() => setSelectedCategory(cat.id)}
className={`px-4 py-2 rounded-full text-sm transition-colors cursor-pointer ${
selectedCategory === cat.id ? "bg-primary text-primary-foreground" : "bg-muted text-muted-foreground hover:bg-accent"
}`}
>
{cat.name}
</button>
))}
</div>
{/* Product Grid */}
<div className="grid grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4 lg:gap-6">
{filtered.map((product) => (
<ProductCard key={product.id} product={product} />
))}
</div>
{filtered.length === 0 && (
<p className="text-center text-muted-foreground py-20">Aucun produit trouvé.</p>
)}
</div>
</div>
);
};

61
app/connexion/page.tsx Normal file
View File

@@ -0,0 +1,61 @@
"use client";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { useLanguage } from "@/contexts/LanguageContext";
import { toast } from "sonner";
export default function Auth() {
const { t } = useLanguage();
const [isLogin, setIsLogin] = useState(true);
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [name, setName] = useState("");
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
toast.success(isLogin ? "Connexion réussie" : "Compte créé avec succès !");
};
return (
<div className="min-h-screen flex items-center justify-center py-12 px-4">
<div className="w-full max-w-sm">
<h1 className="font-serif text-3xl text-center mb-2">
{isLogin ? t("auth.login") : t("auth.register")}
</h1>
<p className="text-sm text-muted-foreground text-center mb-8">
{isLogin ? "Accédez à votre espace personnel" : "Créez votre compte en quelques secondes"}
</p>
<form onSubmit={handleSubmit} className="space-y-4">
{!isLogin && (
<div>
<Label htmlFor="name">{t("auth.name")}</Label>
<Input id="name" value={name} onChange={(e) => setName(e.target.value)} className="mt-1" />
</div>
)}
<div>
<Label htmlFor="email">{t("auth.email")}</Label>
<Input id="email" type="email" value={email} onChange={(e) => setEmail(e.target.value)} className="mt-1" />
</div>
<div>
<Label htmlFor="password">{t("auth.password")}</Label>
<Input id="password" type="password" value={password} onChange={(e) => setPassword(e.target.value)} className="mt-1" />
</div>
<Button type="submit" className="w-full" size="lg">
{isLogin ? t("auth.login") : t("auth.register")}
</Button>
</form>
<p className="text-center text-sm text-muted-foreground mt-6">
{isLogin ? "Pas encore de compte ?" : "Déjà un compte ?"}{" "}
<Button variant="link" onClick={() => setIsLogin(!isLogin)} className="text-primary hover:underline font-medium">
{isLogin ? t("auth.register") : t("auth.login")}
</Button>
</p>
</div>
</div>
);
};

82
app/contact/page.tsx Normal file
View File

@@ -0,0 +1,82 @@
"use client";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { Label } from "@/components/ui/label";
import { useLanguage } from "@/contexts/LanguageContext";
import { MapPin, Phone, Mail } from "lucide-react";
import { toast } from "sonner";
export default function Contact() {
const { t } = useLanguage();
const [name, setName] = useState("");
const [email, setEmail] = useState("");
const [message, setMessage] = useState("");
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
toast.success("Nous vous répondrons dans les plus brefs délais.");
setName("");
setEmail("");
setMessage("");
};
return (
<div className="min-h-screen py-12 lg:py-20">
<div className="container mx-auto px-4 lg:px-8 max-w-5xl">
<h1 className="font-serif text-3xl lg:text-5xl text-center mb-12">{t("contact.title")}</h1>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-12">
{/* Info */}
<div className="space-y-8">
<p className="text-muted-foreground leading-relaxed">
Une question sur nos produits, un conseil personnalisé ou une demande de rendez-vous ? N'hésitez pas à nous contacter.
</p>
<div className="space-y-4">
<div className="flex items-center gap-3">
<MapPin className="h-5 w-5 text-primary" />
<span className="text-sm">123 Rue de la Beauté, 75001 Paris</span>
</div>
<div className="flex items-center gap-3">
<Phone className="h-5 w-5 text-primary" />
<span className="text-sm">+33 1 23 45 67 89</span>
</div>
<div className="flex items-center gap-3">
<Mail className="h-5 w-5 text-primary" />
<span className="text-sm">contact@luxehair.com</span>
</div>
</div>
<div className="text-sm text-muted-foreground">
<h3 className="font-medium text-foreground mb-2">Horaires d'ouverture</h3>
<p>Lundi - Vendredi : 9h - 18h</p>
<p>Samedi : 10h - 16h</p>
<p>Dimanche : Fermé</p>
</div>
</div>
{/* Form */}
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<Label htmlFor="name">{t("auth.name")}</Label>
<Input id="name" value={name} onChange={(e) => setName(e.target.value)} className="mt-1" required />
</div>
<div>
<Label htmlFor="email">{t("auth.email")}</Label>
<Input id="email" type="email" value={email} onChange={(e) => setEmail(e.target.value)} className="mt-1" required />
</div>
<div>
<Label htmlFor="message">{t("contact.message")}</Label>
<Textarea id="message" rows={5} value={message} onChange={(e) => setMessage(e.target.value)} className="mt-1" required />
</div>
<Button type="submit" className="w-full" size="lg">
{t("contact.send")}
</Button>
</form>
</div>
</div>
</div>
);
};

View File

@@ -1,26 +1,134 @@
@import "tailwindcss";
@import "tw-animate-css";
@import "shadcn/tailwind.css";
:root {
--background: #ffffff;
--foreground: #171717;
}
@custom-variant dark (&:is(.dark *));
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--font-sans: var(--font-geist-sans);
--font-sans: var(--font-sans);
--font-mono: var(--font-geist-mono);
--font-heading: var(--font-sans);
--color-sidebar-ring: var(--sidebar-ring);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar: var(--sidebar);
--color-chart-5: var(--chart-5);
--color-chart-4: var(--chart-4);
--color-chart-3: var(--chart-3);
--color-chart-2: var(--chart-2);
--color-chart-1: var(--chart-1);
--color-ring: var(--ring);
--color-input: var(--input);
--color-border: var(--border);
--color-destructive: var(--destructive);
--color-accent-foreground: var(--accent-foreground);
--color-accent: var(--accent);
--color-muted-foreground: var(--muted-foreground);
--color-muted: var(--muted);
--color-secondary-foreground: var(--secondary-foreground);
--color-secondary: var(--secondary);
--color-primary-foreground: var(--primary-foreground);
--color-primary: var(--primary);
--color-popover-foreground: var(--popover-foreground);
--color-popover: var(--popover);
--color-card-foreground: var(--card-foreground);
--color-card: var(--card);
--radius-sm: calc(var(--radius) * 0.6);
--radius-md: calc(var(--radius) * 0.8);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) * 1.4);
--radius-2xl: calc(var(--radius) * 1.8);
--radius-3xl: calc(var(--radius) * 2.2);
--radius-4xl: calc(var(--radius) * 2.6);
}
@media (prefers-color-scheme: dark) {
:root {
--background: #0a0a0a;
--foreground: #ededed;
}
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: #723e3c;
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
--chart-1: oklch(0.87 0 0);
--chart-2: oklch(0.556 0 0);
--chart-3: oklch(0.439 0 0);
--chart-4: oklch(0.371 0 0);
--chart-5: oklch(0.269 0 0);
--radius: 0.625rem;
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.205 0 0);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0);
}
body {
background: var(--background);
color: var(--foreground);
font-family: Arial, Helvetica, sans-serif;
.dark {
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.205 0 0);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.205 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.922 0 0);
--primary-foreground: oklch(0.205 0 0);
--secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.269 0 0);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.556 0 0);
--chart-1: oklch(0.87 0 0);
--chart-2: oklch(0.556 0 0);
--chart-3: oklch(0.439 0 0);
--chart-4: oklch(0.371 0 0);
--chart-5: oklch(0.269 0 0);
--sidebar: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.556 0 0);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
font-family: "Inter", sans-serif;
}
h1, h2, h3, h4, h5, h6 {
font-family: 'Playfair Display', serif;
}
html {
@apply font-sans;
}
}

View File

@@ -1,33 +1,43 @@
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
// app/layout.tsx
'use client';
import { usePathname } from 'next/navigation';
import { LanguageProvider } from "@/contexts/LanguageContext";
import { AdminProvider } from "@/contexts/AdminContext";
import { CartProvider } from "@/contexts/CartContext";
import CartDrawer from "@/components/CartDrawer";
import "./globals.css";
import Header from "@/components/Header";
import Footer from "@/components/Footer";
import { Toaster } from "sonner";
const geistSans = Geist({
variable: "--font-geist-sans",
subsets: ["latin"],
});
export default function RootLayout({ children }: { children: React.ReactNode }) {
const pathname = usePathname();
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
subsets: ["latin"],
});
// Vérifier si on est sur une route admin
const isAdminRoute = pathname?.startsWith('/admin');
export const metadata: Metadata = {
title: "Create Next App",
description: "Generated by create next app",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html
lang="en"
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
>
<body className="min-h-full flex flex-col">{children}</body>
<html>
<body>
<LanguageProvider>
<AdminProvider>
<CartProvider>
<Toaster position="top-right" richColors />
{/* Afficher Header seulement si ce n'est pas l'admin */}
{!isAdminRoute && <Header />}
{!isAdminRoute && <CartDrawer />}
{children}
{/* Afficher Footer seulement si ce n'est pas l'admin */}
{!isAdminRoute && <Footer />}
</CartProvider>
</AdminProvider>
</LanguageProvider>
</body>
</html>
);
}

View File

@@ -1,65 +1,120 @@
import Image from "next/image";
"use client";
import { ArrowRight, Star, Calendar } from "lucide-react";
import { Button } from "@/components/ui/button";
import { useLanguage } from "@/contexts/LanguageContext";
import { products, categories, reviews } from "@/data/products";
import ProductCard from "@/components/ProductCard";
import Link from "next/link";
export default function Home() {
const { t } = useLanguage();
const bestsellers = products.filter((p) => p.isBestseller);
return (
<div className="flex flex-col flex-1 items-center justify-center bg-zinc-50 font-sans dark:bg-black">
<main className="flex flex-1 w-full max-w-3xl flex-col items-center justify-between py-32 px-16 bg-white dark:bg-black sm:items-start">
<Image
className="dark:invert"
src="/next.svg"
alt="Next.js logo"
width={100}
height={20}
priority
<div className="min-h-screen">
{/* Hero */}
<section className="relative h-[85vh] flex items-center overflow-hidden">
<div className="absolute inset-0">
<img
src="https://images.unsplash.com/photo-1519699047748-de8e457a634e?w=1600&h=900&fit=crop"
alt="Extensions de cheveux luxe"
className="w-full h-full object-cover"
/>
<div className="flex flex-col items-center gap-6 text-center sm:items-start sm:text-left">
<h1 className="max-w-xs text-3xl font-semibold leading-10 tracking-tight text-black dark:text-zinc-50">
To get started, edit the page.tsx file.
<div className="absolute inset-0 bg-gradient-to-r from-foreground/60 via-foreground/30 to-transparent" />
</div>
<div className="relative container mx-auto px-4 lg:px-8">
<div className="max-w-xl">
<h1 className="font-serif text-4xl lg:text-6xl font-semibold text-background leading-tight mb-4">
{t("hero.title")}
</h1>
<p className="max-w-md text-lg leading-8 text-zinc-600 dark:text-zinc-400">
Looking for a starting point or more instructions? Head over to{" "}
<a
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
className="font-medium text-zinc-950 dark:text-zinc-50"
>
Templates
</a>{" "}
or the{" "}
<a
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
className="font-medium text-zinc-950 dark:text-zinc-50"
>
Learning
</a>{" "}
center.
<p className="text-background/80 text-lg mb-8">
{t("hero.subtitle")}
</p>
<Link href="/boutique">
<Button size="lg" className="group text-sm tracking-wide">
{t("hero.cta")}
<ArrowRight className="h-4 w-4 group-hover:translate-x-1 transition-transform" />
</Button>
</Link>
</div>
<div className="flex flex-col gap-4 text-base font-medium sm:flex-row">
<a
className="flex h-12 w-full items-center justify-center gap-2 rounded-full bg-foreground px-5 text-background transition-colors hover:bg-[#383838] dark:hover:bg-[#ccc] md:w-[158px]"
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
<Image
className="dark:invert"
src="/vercel.svg"
alt="Vercel logomark"
width={16}
height={16}
</div>
</section>
{/* Categories */}
<section className="py-16 lg:py-24 container mx-auto px-4 lg:px-8">
<h2 className="font-serif text-3xl lg:text-4xl text-center mb-12">{t("categories.title")}</h2>
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4 lg:gap-6">
{categories.map((cat) => (
<Link key={cat.id} href={`/boutique?category=${cat.id}`} className="group relative overflow-hidden rounded-lg aspect-[3/4]">
<img src={cat.image} alt={cat.name} className="w-full h-full object-cover transition-transform duration-500 group-hover:scale-105" loading="lazy" />
<div className="absolute inset-0 bg-gradient-to-t from-foreground/70 to-transparent" />
<div className="absolute bottom-4 left-4 text-background">
<h3 className="font-serif text-lg font-semibold">{cat.name}</h3>
<p className="text-xs opacity-80">{cat.description}</p>
</div>
</Link>
))}
</div>
</section>
{/* Bestsellers */}
<section className="py-16 lg:py-24 bg-muted/50">
<div className="container mx-auto px-4 lg:px-8">
<div className="flex items-center justify-between mb-10">
<h2 className="font-serif text-3xl lg:text-4xl">{t("bestsellers.title")}</h2>
<Link href="/boutique" className="text-sm text-primary hover:underline flex items-center gap-1">
{t("nav.shop")} <ArrowRight className="h-3 w-3" />
</Link>
</div>
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4 lg:gap-6">
{bestsellers.map((p) => (
<ProductCard key={p.id} product={p} />
))}
</div>
</div>
</section>
{/* Booking Banner */}
<section className="relative py-20 lg:py-32 overflow-hidden">
<div className="absolute inset-0">
<img
src="https://images.unsplash.com/photo-1580618672591-eb180b1a973f?w=1600&h=600&fit=crop"
alt="Réservation"
className="w-full h-full object-cover"
/>
Deploy Now
</a>
<a
className="flex h-12 w-full items-center justify-center rounded-full border border-solid border-black/[.08] px-5 transition-colors hover:border-transparent hover:bg-black/[.04] dark:border-white/[.145] dark:hover:bg-[#1a1a1a] md:w-[158px]"
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
Documentation
</a>
<div className="absolute inset-0 bg-foreground/50" />
</div>
</main>
<div className="relative container mx-auto px-4 lg:px-8 text-center">
<Calendar className="h-10 w-10 text-background/80 mx-auto mb-4" />
<h2 className="font-serif text-3xl lg:text-5xl text-background mb-3">{t("booking.title")}</h2>
<p className="text-background/70 mb-8 text-lg">{t("booking.subtitle")}</p>
<Link href="/reservation">
<Button variant="outline" className="border-background p-6 text-lg cursor-pointer text-foreground hover:bg-primary hover:text-background transition-colors">
{t("booking.cta")}
</Button>
</Link>
</div>
</section>
{/* Reviews */}
<section className="py-16 lg:py-24 container mx-auto px-4 lg:px-8">
<h2 className="font-serif text-3xl lg:text-4xl text-center mb-12">{t("reviews.title")}</h2>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
{reviews.map((review) => (
<div key={review.id} className="bg-card rounded-lg p-6 border border-border">
<div className="flex gap-0.5 mb-3">
{Array.from({ length: review.rating }).map((_, i) => (
<Star key={i} className="h-4 w-4 fill-primary text-primary" />
))}
</div>
<p className="text-sm text-muted-foreground leading-relaxed mb-4">{review.text}</p>
<p className="text-sm font-medium">{review.name}</p>
</div>
))}
</div>
</section>
</div>
);
}

77
app/panier/page.tsx Normal file
View File

@@ -0,0 +1,77 @@
"use client";
import { Minus, Plus, X, ArrowLeft, ShoppingBag } from "lucide-react";
import { Button } from "@/components/ui/button";
import { useCart } from "@/contexts/CartContext";
import { useLanguage } from "@/contexts/LanguageContext";
import Link from "next/link";
export default function Cart() {
const { items, updateQuantity, removeItem, totalPrice } = useCart();
const { t } = useLanguage();
return (
<div className="min-h-screen py-8 lg:py-12">
<div className="container mx-auto px-4 lg:px-8 max-w-3xl">
<h1 className="font-serif text-3xl lg:text-4xl text-center mb-10">{t("cart.title")}</h1>
{items.length === 0 ? (
<div className="text-center py-20">
<ShoppingBag className="h-16 w-16 mx-auto mb-4 text-muted-foreground/30" />
<p className="text-muted-foreground mb-6">{t("cart.empty")}</p>
<Link href="/boutique">
<Button variant="outline">
<ArrowLeft className="h-4 w-4 mr-2" />
{t("nav.shop")}
</Button>
</Link>
</div>
) : (
<>
<div className="space-y-4">
{items.map((item) => (
<div key={`${item.product.id}-${item.selectedColor}-${item.selectedLength}`} className="flex gap-4 p-4 bg-card rounded-lg border border-border">
<img src={item.product.image} alt={item.product.name} className="w-24 h-32 object-cover rounded-md" />
<div className="flex-1">
<div className="flex justify-between items-start">
<div>
<h3 className="font-medium">{item.product.name}</h3>
<p className="text-sm text-muted-foreground mt-0.5">{item.selectedColor} {item.selectedLength}</p>
</div>
<button onClick={() => removeItem(item.product.id)} className="text-muted-foreground hover:text-destructive">
<X className="h-4 w-4" />
</button>
</div>
<div className="flex items-center justify-between mt-4">
<div className="flex items-center gap-3">
<button onClick={() => updateQuantity(item.product.id, item.quantity - 1)} className="p-1.5 border border-border rounded-md hover:bg-muted">
<Minus className="h-3 w-3" />
</button>
<span className="text-sm font-medium w-6 text-center">{item.quantity}</span>
<button onClick={() => updateQuantity(item.product.id, item.quantity + 1)} className="p-1.5 border border-border rounded-md hover:bg-muted">
<Plus className="h-3 w-3" />
</button>
</div>
<span className="font-semibold">{(item.product.price * item.quantity).toFixed(2)} </span>
</div>
</div>
</div>
))}
</div>
<div className="mt-8 bg-card rounded-lg border border-border p-6">
<div className="flex justify-between items-center text-lg mb-4">
<span className="font-medium">{t("cart.total")}</span>
<span className="font-serif text-xl font-semibold">{totalPrice.toFixed(2)} </span>
</div>
<Button className="w-full" size="lg">
{t("cart.checkout")}
</Button>
<p className="text-xs text-muted-foreground text-center mt-3">Paiement sécurisé par Stripe</p>
</div>
</>
)}
</div>
</div>
);
};

166
app/produit/[id]/page.tsx Normal file
View File

@@ -0,0 +1,166 @@
"use client";
import { useState } from "react";
import { Star, ChevronLeft, Check } from "lucide-react";
import { Button } from "@/components/ui/button";
import { useLanguage } from "@/contexts/LanguageContext";
import { useCart } from "@/contexts/CartContext";
import { products } from "@/data/products";
import ProductCard from "@/components/ProductCard";
import Link from "next/link";
import { useParams } from "next/navigation";
import { toast } from "sonner";
export default function ProductDetail() {
const { id } = useParams();
const { t } = useLanguage();
const { addItem } = useCart();
const product = products.find((p) => p.id === id);
const [selectedImage, setSelectedImage] = useState(0);
const [selectedColor, setSelectedColor] = useState("");
const [selectedLength, setSelectedLength] = useState("");
if (!product) {
return (
<div className="min-h-screen flex items-center justify-center">
<p className="text-muted-foreground">Produit non trouvé</p>
</div>
);
}
const similar = products.filter((p) => p.category === product.category && p.id !== product.id).slice(0, 4);
const handleAddToCart = () => {
const color = selectedColor || product.colors[0];
const length = selectedLength || product.lengths[0];
addItem(product, color, length);
toast.success(`Ajouté au panier - ${product.name} - ${color}, ${length}`);
};
return (
<div className="min-h-screen py-6 lg:py-12">
<div className="container mx-auto px-4 lg:px-8">
<Link href="/boutique" className="inline-flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground mb-6">
<ChevronLeft className="h-4 w-4" /> {t("nav.shop")}
</Link>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8 lg:gap-16">
{/* Images */}
<div className="space-y-3">
<div className="aspect-[3/4] rounded-lg overflow-hidden bg-muted">
<img src={product.images[selectedImage]} alt={product.name} className="w-full h-full object-cover" />
</div>
{product.images.length > 1 && (
<div className="flex gap-2">
{product.images.map((img, i) => (
<button
key={i}
onClick={() => setSelectedImage(i)}
className={`w-16 h-20 rounded-md overflow-hidden border-2 transition-colors ${
i === selectedImage ? "border-primary" : "border-transparent"
}`}
>
<img src={img} alt="" className="w-full h-full object-cover" />
</button>
))}
</div>
)}
</div>
{/* Details */}
<div className="space-y-6">
<div>
<h1 className="font-serif text-2xl lg:text-4xl font-semibold mb-2">{product.name}</h1>
<div className="flex items-center gap-2 mb-4">
<div className="flex gap-0.5">
{Array.from({ length: 5 }).map((_, i) => (
<Star key={i} className={`h-4 w-4 ${i < Math.floor(product.rating) ? "fill-primary text-primary" : "text-muted"}`} />
))}
</div>
<span className="text-sm text-muted-foreground">({product.reviewCount} avis)</span>
</div>
<div className="flex items-baseline gap-3">
<span className="text-2xl font-semibold">{product.price} </span>
{product.originalPrice && (
<span className="text-lg text-muted-foreground line-through">{product.originalPrice} </span>
)}
</div>
</div>
<p className="text-muted-foreground leading-relaxed">{product.description}</p>
{/* Color selector */}
<div>
<h3 className="text-sm font-medium mb-2">{t("product.color")}</h3>
<div className="flex flex-wrap gap-2">
{product.colors.map((color) => (
<button
key={color}
onClick={() => setSelectedColor(color)}
className={`px-3 py-1.5 rounded-full text-sm border transition-colors cursor-pointer ${
(selectedColor || product.colors[0]) === color
? "border-primary bg-accent text-accent-foreground"
: "border-border text-muted-foreground hover:border-primary"
}`}
>
{color}
</button>
))}
</div>
</div>
{/* Length selector */}
<div>
<h3 className="text-sm font-medium mb-2">{t("product.length")}</h3>
<div className="flex gap-2">
{product.lengths.map((length) => (
<button
key={length}
onClick={() => setSelectedLength(length)}
className={`px-4 py-2 rounded-md text-sm border transition-colors cursor-pointer ${
(selectedLength || product.lengths[0]) === length
? "border-primary bg-accent text-accent-foreground"
: "border-border text-muted-foreground hover:border-primary"
}`}
>
{length}
</button>
))}
</div>
</div>
<Button size="lg" className="w-full text-sm tracking-wide" onClick={handleAddToCart}>
{t("cart.add")}
</Button>
{/* Features */}
<div>
<h3 className="text-sm font-medium mb-3">{t("product.features")}</h3>
<ul className="space-y-2">
{product.features.map((feature) => (
<li key={feature} className="flex items-center gap-2 text-sm text-muted-foreground">
<Check className="h-4 w-4 text-primary" />
{feature}
</li>
))}
</ul>
</div>
</div>
</div>
{/* Similar */}
{similar.length > 0 && (
<section className="mt-16 lg:mt-24">
<h2 className="font-serif text-2xl mb-8">{t("product.similar")}</h2>
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4 lg:gap-6">
{similar.map((p) => (
<ProductCard key={p.id} product={p} />
))}
</div>
</section>
)}
</div>
</div>
);
};

141
app/reservation/page.tsx Normal file
View File

@@ -0,0 +1,141 @@
"use client";
import { useState } from "react";
import { Calendar } from "@/components/ui/calendar";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { useLanguage } from "@/contexts/LanguageContext";
import { services, generateTimeSlots } from "@/data/services";
import { Check, Clock, CalendarDays } from "lucide-react";
import { toast } from "sonner";
export default function Booking () {
const { t } = useLanguage();
const [selectedService, setSelectedService] = useState<string>("");
const [selectedDate, setSelectedDate] = useState<Date | undefined>();
const [selectedTime, setSelectedTime] = useState<string>("");
const [name, setName] = useState("");
const [email, setEmail] = useState("");
const [phone, setPhone] = useState("");
const timeSlots = generateTimeSlots();
const step = !selectedService ? 1 : !selectedDate ? 2 : !selectedTime ? 3 : 4;
const handleConfirm = () => {
toast.success(`${services.find((s) => s.id === selectedService)?.name} le ${selectedDate?.toLocaleDateString("fr-FR")} à ${selectedTime}`);
};
return (
<div className="min-h-screen py-8 lg:py-16">
<div className="container mx-auto px-4 lg:px-8 max-w-4xl">
<div className="text-center mb-12">
<CalendarDays className="h-10 w-10 mx-auto mb-4 text-primary" />
<h1 className="font-serif text-3xl lg:text-5xl mb-3">{t("booking.title")}</h1>
<p className="text-muted-foreground">{t("booking.subtitle")}</p>
</div>
{/* Progress steps */}
<div className="flex justify-center gap-2 mb-10">
{[1, 2, 3, 4].map((s) => (
<div key={s} className={`h-1.5 w-12 rounded-full transition-colors ${s <= step ? "bg-primary" : "bg-muted"}`} />
))}
</div>
{/* Step 1: Service */}
<div className="mb-10">
<h2 className="font-serif text-xl mb-4">{t("booking.select_service")}</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
{services.map((service) => (
<button
key={service.id}
onClick={() => setSelectedService(service.id)}
className={`text-left p-4 rounded-lg border-2 transition-all cursor-pointer ${
selectedService === service.id ? "border-primary" : "border-border hover:border-primary/40"
}`}
>
<div className="flex justify-between items-start">
<div>
<h3 className="font-medium text-sm">{service.name}</h3>
<p className="text-xs text-muted-foreground mt-1">{service.description}</p>
<div className="flex items-center gap-1 mt-2 text-xs text-muted-foreground">
<Clock className="h-3 w-3" />
{service.duration}
</div>
</div>
<span className="text-sm font-semibold">
{service.price === 0 ? t("booking.free") : `${service.price}`}
</span>
</div>
{selectedService === service.id && <Check className="h-4 w-4 text-primary mt-2" />}
</button>
))}
</div>
</div>
{/* Step 2: Date */}
{selectedService && (
<div className="mb-10">
<h2 className="font-serif text-xl mb-4">{t("booking.select_date")}</h2>
<div className="flex justify-center">
<Calendar
mode="single"
selected={selectedDate}
onSelect={setSelectedDate}
disabled={(date) => date < new Date() || date.getDay() === 0}
className="rounded-lg border border-border p-4"
/>
</div>
</div>
)}
{/* Step 3: Time */}
{selectedDate && (
<div className="mb-10">
<h2 className="font-serif text-xl mb-4">{t("booking.select_time")}</h2>
<div className="grid grid-cols-4 md:grid-cols-7 gap-2">
{timeSlots.map((slot) => (
<button
key={slot.time}
onClick={() => slot.available && setSelectedTime(slot.time)}
disabled={!slot.available}
className={`py-2 px-3 rounded-md text-sm transition-colors cursor-pointer ${
selectedTime === slot.time
? "bg-primary text-primary-foreground"
: slot.available
? "bg-muted text-foreground hover:bg-accent"
: "bg-muted/50 text-muted-foreground/30 cursor-not-allowed"
}`}
>
{slot.time}
</button>
))}
</div>
</div>
)}
{/* Step 4: Contact info */}
{selectedTime && (
<div className="max-w-md mx-auto space-y-4">
<h2 className="font-serif text-xl mb-4 text-center">{t("booking.confirm")}</h2>
<div>
<Label htmlFor="name">{t("auth.name")}</Label>
<Input id="name" value={name} onChange={(e) => setName(e.target.value)} className="mt-1" />
</div>
<div>
<Label htmlFor="email">{t("auth.email")}</Label>
<Input id="email" type="email" value={email} onChange={(e) => setEmail(e.target.value)} className="mt-1" />
</div>
<div>
<Label htmlFor="phone">{t("booking.phone")}</Label>
<Input id="phone" type="tel" value={phone} onChange={(e) => setPhone(e.target.value)} className="mt-1" />
</div>
<Button className="w-full" size="lg" onClick={handleConfirm} disabled={!name || !email || !phone}>
{t("booking.confirm")}
</Button>
</div>
)}
</div>
</div>
);
};

25
components.json Normal file
View File

@@ -0,0 +1,25 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "radix-nova",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "",
"css": "app/globals.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"iconLibrary": "lucide",
"rtl": false,
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"menuColor": "default",
"menuAccent": "subtle",
"registries": {}
}

89
components/CartDrawer.tsx Normal file
View File

@@ -0,0 +1,89 @@
"use client";
import { X, Plus, Minus, ShoppingBag } from "lucide-react";
import { useCart } from "@/contexts/CartContext";
import { useLanguage } from "@/contexts/LanguageContext";
import { Button } from "@/components/ui/button";
import Link from "next/link";
export default function CartDrawer() {
const { items, isCartOpen, setIsCartOpen, updateQuantity, removeItem, totalPrice, totalItems } = useCart();
const { t } = useLanguage();
if (!isCartOpen) return null;
return (
<>
{/* Overlay */}
<div className="fixed inset-0 bg-foreground/40 z-50" onClick={() => setIsCartOpen(false)} />
{/* Drawer */}
<div className="fixed right-0 top-0 h-full w-full max-w-md bg-background z-50 shadow-2xl flex flex-col animate-in slide-in-from-right duration-300">
{/* Header */}
<div className="flex items-center justify-between px-6 py-4 border-b border-border">
<h2 className="font-serif text-lg font-semibold flex items-center gap-2">
<ShoppingBag className="h-5 w-5" />
{t("cart.title")} ({totalItems})
</h2>
<button onClick={() => setIsCartOpen(false)} className="p-1 hover:bg-muted rounded-md transition-colors">
<X className="h-5 w-5" />
</button>
</div>
{/* Items */}
<div className="flex-1 overflow-y-auto px-6 py-4">
{items.length === 0 ? (
<div className="flex flex-col items-center justify-center h-full text-muted-foreground">
<ShoppingBag className="h-12 w-12 mb-4 opacity-30" />
<p className="text-sm">{t("cart.empty")}</p>
</div>
) : (
<div className="space-y-4">
{items.map((item) => (
<div key={`${item.product.id}-${item.selectedColor}-${item.selectedLength}`} className="flex gap-4 py-3 border-b border-border last:border-0">
<img
src={item.product.image}
alt={item.product.name}
className="w-20 h-24 object-cover rounded-md"
/>
<div className="flex-1 min-w-0">
<h3 className="text-sm font-medium truncate">{item.product.name}</h3>
<p className="text-xs text-muted-foreground mt-0.5">{item.selectedColor} {item.selectedLength}</p>
<p className="text-sm font-semibold mt-1">{item.product.price} </p>
<div className="flex items-center gap-2 mt-2">
<button onClick={() => updateQuantity(item.product.id, item.quantity - 1)} className="p-1 border border-border rounded hover:bg-muted">
<Minus className="h-3 w-3" />
</button>
<span className="text-sm w-6 text-center">{item.quantity}</span>
<button onClick={() => updateQuantity(item.product.id, item.quantity + 1)} className="p-1 border border-border rounded hover:bg-muted">
<Plus className="h-3 w-3" />
</button>
<button onClick={() => removeItem(item.product.id)} className="ml-auto text-xs text-muted-foreground hover:text-destructive">
<X className="h-4 w-4" />
</button>
</div>
</div>
</div>
))}
</div>
)}
</div>
{/* Footer */}
{items.length > 0 && (
<div className="border-t border-border px-6 py-4 space-y-3">
<div className="flex justify-between items-center">
<span className="font-medium">{t("cart.total")}</span>
<span className="font-serif text-lg font-semibold">{totalPrice.toFixed(2)} </span>
</div>
<Link href="/panier" onClick={() => setIsCartOpen(false)}>
<Button className="w-full" size="lg">
{t("cart.checkout")}
</Button>
</Link>
</div>
)}
</div>
</>
);
};

198
components/Footer.tsx Normal file
View File

@@ -0,0 +1,198 @@
"use client";
import { useLanguage } from "@/contexts/LanguageContext";
import Link from "next/link";
export default function Footer() {
const { t } = useLanguage();
const year = new Date().getFullYear();
return (
<footer className="bg-foreground text-background">
<div className="container mx-auto px-4 lg:px-8 py-12 lg:py-16">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-8 lg:gap-12">
{/* Brand */}
<div>
<h3 className="font-serif text-xl font-semibold mb-4">BADO HAIR</h3>
<p className="text-sm opacity-70 leading-relaxed">
Extensions de cheveux 100% naturels. Qualité premium pour sublimer
votre beauté.
</p>
</div>
{/* Navigation */}
<div>
<h4 className="text-sm font-semibold uppercase tracking-wider mb-4">
Navigation
</h4>
<ul className="space-y-2">
<li>
<Link
href="/boutique"
className="text-sm opacity-70 hover:opacity-100 transition-opacity"
>
{t("nav.shop")}
</Link>
</li>
<li>
<Link
href="/reservation"
className="text-sm opacity-70 hover:opacity-100 transition-opacity"
>
{t("nav.booking")}
</Link>
</li>
<li>
<Link
href="/a-propos"
className="text-sm opacity-70 hover:opacity-100 transition-opacity"
>
{t("nav.about")}
</Link>
</li>
<li>
<Link
href="/contact"
className="text-sm opacity-70 hover:opacity-100 transition-opacity"
>
{t("nav.contact")}
</Link>
</li>
</ul>
</div>
{/* Contact */}
<div>
<h4 className="text-sm font-semibold uppercase tracking-wider mb-4">
Contact
</h4>
<ul className="space-y-2 text-sm opacity-70">
<li>contact@badohair.com</li>
<li>+33 1 23 45 67 89</li>
<li>Paris, France</li>
</ul>
</div>
{/* Social */}
<div>
<h4 className="text-sm font-semibold uppercase tracking-wider mb-4">
Suivez-nous
</h4>
<div className="flex gap-4">
{/* Instagram */}
<a
href="#"
className="opacity-70 hover:opacity-100 transition-opacity"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="32"
height="32"
viewBox="0 0 256 256"
>
<g fill="none">
<rect
width="256"
height="256"
fill="url(#SVGWRUqebek)"
rx="60"
/>
<rect
width="256"
height="256"
fill="url(#SVGfkNpldMH)"
rx="60"
/>
<path
fill="#fff"
d="M128.009 28c-27.158 0-30.567.119-41.233.604c-10.646.488-17.913 2.173-24.271 4.646c-6.578 2.554-12.157 5.971-17.715 11.531c-5.563 5.559-8.98 11.138-11.542 17.713c-2.48 6.36-4.167 13.63-4.646 24.271c-.477 10.667-.602 14.077-.602 41.236s.12 30.557.604 41.223c.49 10.646 2.175 17.913 4.646 24.271c2.556 6.578 5.973 12.157 11.533 17.715c5.557 5.563 11.136 8.988 17.709 11.542c6.363 2.473 13.631 4.158 24.275 4.646c10.667.485 14.073.604 41.23.604c27.161 0 30.559-.119 41.225-.604c10.646-.488 17.921-2.173 24.284-4.646c6.575-2.554 12.146-5.979 17.702-11.542c5.563-5.558 8.979-11.137 11.542-17.712c2.458-6.361 4.146-13.63 4.646-24.272c.479-10.666.604-14.066.604-41.225s-.125-30.567-.604-41.234c-.5-10.646-2.188-17.912-4.646-24.27c-2.563-6.578-5.979-12.157-11.542-17.716c-5.562-5.562-11.125-8.979-17.708-11.53c-6.375-2.474-13.646-4.16-24.292-4.647c-10.667-.485-14.063-.604-41.23-.604zm-8.971 18.021c2.663-.004 5.634 0 8.971 0c26.701 0 29.865.096 40.409.575c9.75.446 15.042 2.075 18.567 3.444c4.667 1.812 7.994 3.979 11.492 7.48c3.5 3.5 5.666 6.833 7.483 11.5c1.369 3.52 3 8.812 3.444 18.562c.479 10.542.583 13.708.583 40.396s-.104 29.855-.583 40.396c-.446 9.75-2.075 15.042-3.444 18.563c-1.812 4.667-3.983 7.99-7.483 11.488c-3.5 3.5-6.823 5.666-11.492 7.479c-3.521 1.375-8.817 3-18.567 3.446c-10.542.479-13.708.583-40.409.583c-26.702 0-29.867-.104-40.408-.583c-9.75-.45-15.042-2.079-18.57-3.448c-4.666-1.813-8-3.979-11.5-7.479s-5.666-6.825-7.483-11.494c-1.369-3.521-3-8.813-3.444-18.563c-.479-10.542-.575-13.708-.575-40.413s.096-29.854.575-40.396c.446-9.75 2.075-15.042 3.444-18.567c1.813-4.667 3.983-8 7.484-11.5s6.833-5.667 11.5-7.483c3.525-1.375 8.819-3 18.569-3.448c9.225-.417 12.8-.542 31.437-.563zm62.351 16.604c-6.625 0-12 5.37-12 11.996c0 6.625 5.375 12 12 12s12-5.375 12-12s-5.375-12-12-12zm-53.38 14.021c-28.36 0-51.354 22.994-51.354 51.355s22.994 51.344 51.354 51.344c28.361 0 51.347-22.983 51.347-51.344c0-28.36-22.988-51.355-51.349-51.355zm0 18.021c18.409 0 33.334 14.923 33.334 33.334c0 18.409-14.925 33.334-33.334 33.334s-33.333-14.925-33.333-33.334c0-18.411 14.923-33.334 33.333-33.334"
/>
<defs>
<radialGradient
id="SVGWRUqebek"
cx="0"
cy="0"
r="1"
gradientTransform="matrix(0 -253.715 235.975 0 68 275.717)"
gradientUnits="userSpaceOnUse"
>
<stop stopColor="#FD5" />
<stop offset=".1" stopColor="#FD5" />
<stop offset=".5" stopColor="#FF543E" />
<stop offset="1" stopColor="#C837AB" />
</radialGradient>
<radialGradient
id="SVGfkNpldMH"
cx="0"
cy="0"
r="1"
gradientTransform="matrix(22.25952 111.2061 -458.39518 91.75449 -42.881 18.441)"
gradientUnits="userSpaceOnUse"
>
<stop stopColor="#3771C8" />
<stop offset=".128" stopColor="#3771C8" />
<stop offset="1" stopColor="#60F" stopOpacity="0" />
</radialGradient>
</defs>
</g>
</svg>
</a>
{/* Facebook */}
<a
href="#"
className="opacity-70 hover:opacity-100 transition-opacity"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="32"
height="32"
viewBox="0 0 256 256"
>
<path
fill="#1877F2"
d="M256 128C256 57.308 198.692 0 128 0S0 57.308 0 128c0 63.888 46.808 116.843 108 126.445V165H75.5v-37H108V99.8c0-32.08 19.11-49.8 48.348-49.8C170.352 50 185 52.5 185 52.5V84h-16.14C152.959 84 148 93.867 148 103.99V128h35.5l-5.675 37H148v89.445c61.192-9.602 108-62.556 108-126.445"
/>
<path
fill="#FFF"
d="m177.825 165l5.675-37H148v-24.01C148 93.866 152.959 84 168.86 84H185V52.5S170.352 50 156.347 50C127.11 50 108 67.72 108 99.8V128H75.5v37H108v89.445A129 129 0 0 0 128 256a129 129 0 0 0 20-1.555V165z"
/>
</svg>
</a>
{/* Tiktok */}
<a
href="#"
className="opacity-70 hover:opacity-100 transition-opacity"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="28.25"
height="32"
viewBox="0 0 256 290"
>
<path
fill="#FF004F"
d="M189.72 104.421c18.678 13.345 41.56 21.197 66.273 21.197v-47.53a67 67 0 0 1-13.918-1.456v37.413c-24.711 0-47.59-7.851-66.272-21.195v96.996c0 48.523-39.356 87.855-87.9 87.855c-18.113 0-34.949-5.473-48.934-14.86c15.962 16.313 38.222 26.432 62.848 26.432c48.548 0 87.905-39.332 87.905-87.857v-96.995zm17.17-47.952c-9.546-10.423-15.814-23.893-17.17-38.785v-6.113h-13.189c3.32 18.927 14.644 35.097 30.358 44.898M69.673 225.607a40 40 0 0 1-8.203-24.33c0-22.192 18.001-40.186 40.21-40.186a40.3 40.3 0 0 1 12.197 1.883v-48.593c-4.61-.631-9.262-.9-13.912-.801v37.822a40.3 40.3 0 0 0-12.203-1.882c-22.208 0-40.208 17.992-40.208 40.187c0 15.694 8.997 29.281 22.119 35.9"
/>
<path
fill="#000"
d="M175.803 92.849c18.683 13.344 41.56 21.195 66.272 21.195V76.631c-13.794-2.937-26.005-10.141-35.186-20.162c-15.715-9.802-27.038-25.972-30.358-44.898h-34.643v189.843c-.079 22.132-18.049 40.052-40.21 40.052c-13.058 0-24.66-6.221-32.007-15.86c-13.12-6.618-22.118-20.206-22.118-35.898c0-22.193 18-40.187 40.208-40.187c4.255 0 8.356.662 12.203 1.882v-37.822c-47.692.985-86.047 39.933-86.047 87.834c0 23.912 9.551 45.589 25.053 61.428c13.985 9.385 30.82 14.86 48.934 14.86c48.545 0 87.9-39.335 87.9-87.857z"
/>
<path
fill="#00F2EA"
d="M242.075 76.63V66.516a66.3 66.3 0 0 1-35.186-10.047a66.47 66.47 0 0 0 35.186 20.163M176.53 11.57a68 68 0 0 1-.728-5.457V0h-47.834v189.845c-.076 22.13-18.046 40.05-40.208 40.05a40.06 40.06 0 0 1-18.09-4.287c7.347 9.637 18.949 15.857 32.007 15.857c22.16 0 40.132-17.918 40.21-40.05V11.571zM99.966 113.58v-10.769a89 89 0 0 0-12.061-.818C39.355 101.993 0 141.327 0 189.845c0 30.419 15.467 57.227 38.971 72.996c-15.502-15.838-25.053-37.516-25.053-61.427c0-47.9 38.354-86.848 86.048-87.833"
/>
</svg>
</a>
</div>
</div>
</div>
<div className="border-t border-background/20 mt-10 pt-6 text-center text-sm opacity-50">
© {year} BADO HAIR. {t("footer.rights")}.
</div>
</div>
</footer>
);
}

98
components/Header.tsx Normal file
View File

@@ -0,0 +1,98 @@
"use client";
import { ShoppingBag, User, Menu, X } from "lucide-react";
import { useState } from "react";
import { useCart } from "@/contexts/CartContext";
import { useLanguage } from "@/contexts/LanguageContext";
import LanguageSwitcher from "./LanguageSwitcher";
import Link from "next/link";
import { usePathname } from "next/navigation";
export default function Header() {
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
const { totalItems, setIsCartOpen } = useCart();
const { t } = useLanguage();
const currentPath = usePathname();
const navLinks = [
{ to: "/boutique", label: t("nav.shop") },
{ to: "/reservation", label: t("nav.booking") },
{ to: "/a-propos", label: t("nav.about") },
{ to: "/contact", label: t("nav.contact") },
];
const isActive = (path: string) => currentPath === path;
return (
<header className="sticky top-0 z-50 bg-background/95 backdrop-blur-md border-b border-border">
<div className="container mx-auto px-4 lg:px-8">
<div className="flex items-center justify-between h-16 lg:h-20">
{/* Mobile menu button */}
<button
className="lg:hidden p-2"
onClick={() => setMobileMenuOpen(!mobileMenuOpen)}
>
{mobileMenuOpen ? <X className="h-5 w-5" /> : <Menu className="h-5 w-5" />}
</button>
{/* Logo */}
<Link href="/" className="font-serif text-xl lg:text-2xl font-semibold tracking-wide text-foreground">
BADO HAIR
</Link>
{/* Desktop nav */}
<nav className="hidden lg:flex items-center gap-8 cursor-pointer">
{navLinks.map((link) => (
<Link
key={link.to}
href={link.to}
className={`text-sm tracking-wide transition-colors hover:text-primary ${
isActive(link.to) ? "text-primary font-medium" : "text-muted-foreground"
}`}
>
{link.label}
</Link>
))}
</nav>
{/* Right icons */}
<div className="flex items-center gap-3">
<LanguageSwitcher />
<Link href="/connexion" className="p-2 text-muted-foreground hover:text-foreground transition-colors">
<User className="h-5 w-5" />
</Link>
<button
onClick={() => setIsCartOpen(true)}
className="p-2 text-muted-foreground hover:text-foreground transition-colors relative cursor-pointer"
>
<ShoppingBag className="h-5 w-5" />
{totalItems > 0 && (
<span className="absolute -top-0.5 -right-0.5 h-4 w-4 rounded-full bg-primary text-primary-foreground text-[10px] flex items-center justify-center font-medium">
{totalItems}
</span>
)}
</button>
</div>
</div>
{/* Mobile menu */}
{mobileMenuOpen && (
<nav className="lg:hidden pb-4 border-t border-border pt-4 space-y-3">
{navLinks.map((link) => (
<Link
key={link.to}
href={link.to}
onClick={() => setMobileMenuOpen(false)}
className={`block py-2 text-sm tracking-wide ${
isActive(link.to) ? "text-primary font-medium" : "text-muted-foreground"
}`}
>
{link.label}
</Link>
))}
</nav>
)}
</div>
</header>
);
};

View File

@@ -0,0 +1,42 @@
import { Globe } from "lucide-react";
import { useLanguage, Language } from "@/contexts/LanguageContext";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
const languages: { code: Language; label: string; flag: string }[] = [
{ code: "fr", label: "Français", flag: "🇫🇷" },
{ code: "de", label: "Deutsch", flag: "🇩🇪" },
{ code: "en", label: "English", flag: "🇬🇧" },
{ code: "ar", label: "العربية", flag: "🇸🇦" },
{ code: "tr", label: "Türkçe", flag: "🇹🇷" },
];
export default function LanguageSwitcher() {
const { language, setLanguage } = useLanguage();
const current = languages.find((l) => l.code === language);
return (
<DropdownMenu>
<DropdownMenuTrigger className="flex items-center gap-1.5 p-2 text-muted-foreground hover:text-foreground transition-colors text-sm cursor-pointer">
<Globe className="h-4 w-4" />
<span className="hidden sm:inline">{current?.flag}</span>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{languages.map((lang) => (
<DropdownMenuItem
key={lang.code}
onClick={() => setLanguage(lang.code)}
className={language === lang.code ? "bg-accent" : ""}
>
<span className="mr-2">{lang.flag}</span>
{lang.label}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
);
};

View File

@@ -0,0 +1,57 @@
import { Star } from "lucide-react";
import { Product } from "@/data/products";
import { Badge } from "@/components/ui/badge";
import Link from "next/link";
interface ProductCardProps {
product: Product;
}
export default function ProductCard({ product }: ProductCardProps) {
return (
<Link href={`/produit/${product.id}`} className="group block">
<div className="relative overflow-hidden rounded-lg bg-muted aspect-[3/4] mb-3">
<img
src={product.image}
alt={product.name}
className="w-full h-full object-cover transition-transform duration-500 group-hover:scale-105"
loading="lazy"
/>
<div className="absolute top-3 left-3 flex gap-2">
{product.isNew && (
<Badge className="bg-primary text-primary-foreground text-[10px] uppercase tracking-wider">
Nouveau
</Badge>
)}
{product.isBestseller && (
<Badge variant="secondary" className="text-[10px] uppercase tracking-wider">
Bestseller
</Badge>
)}
</div>
{product.originalPrice && (
<Badge className="absolute top-3 right-3 bg-destructive text-destructive-foreground text-[10px]">
-{Math.round(((product.originalPrice - product.price) / product.originalPrice) * 100)}%
</Badge>
)}
</div>
<div className="space-y-1">
<h3 className="text-sm font-medium text-foreground group-hover:text-primary transition-colors">
{product.name}
</h3>
<div className="flex items-center gap-1">
<Star className="h-3 w-3 fill-primary text-primary" />
<span className="text-xs text-muted-foreground">
{product.rating} ({product.reviewCount})
</span>
</div>
<div className="flex items-center gap-2">
<span className="text-sm font-semibold">{product.price} </span>
{product.originalPrice && (
<span className="text-xs text-muted-foreground line-through">{product.originalPrice} </span>
)}
</div>
</div>
</Link>
);
};

View File

@@ -0,0 +1,96 @@
"use client";
import { LayoutDashboard, Package, CalendarCheck, LogOut, Home } from "lucide-react";
import {
Sidebar,
SidebarContent,
SidebarGroup,
SidebarGroupContent,
SidebarGroupLabel,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
SidebarFooter,
SidebarHeader,
useSidebar,
} from "@/components/ui/sidebar";
import { useAdmin } from "@/contexts/AdminContext";
import { Button } from "@/components/ui/button";
import { usePathname, useRouter } from "next/navigation";
import Link from "next/link";
import { useState } from "react";
const items = [
{ title: "Vue d'ensemble", url: "/admin", icon: LayoutDashboard, end: true },
{ title: "Produits", url: "/admin/produits", icon: Package },
{ title: "Réservations", url: "/admin/reservations", icon: CalendarCheck },
];
export const AdminSidebar = () => {
const { state } = useSidebar();
const collapsed = state === "collapsed";
const { logout } = useAdmin();
const route = useRouter();
const pathname = usePathname();
const [isActive, setIsActive] = useState("");
const handleLogout = () => {
logout();
route.push("/admin/login");
};
return (
<Sidebar collapsible="icon">
<SidebarHeader className="border-b border-sidebar-border p-4">
{!collapsed && (
<div className="font-serif text-lg font-semibold tracking-wide">
BADO HAIR
<div className="text-xs text-muted-foreground font-sans font-normal">Admin</div>
</div>
)}
</SidebarHeader>
<SidebarContent>
<SidebarGroup>
<SidebarGroupLabel>Gestion</SidebarGroupLabel>
<SidebarGroupContent>
<SidebarMenu>
{items.map((item) => (
<SidebarMenuItem key={item.title}>
<SidebarMenuButton asChild>
<Link
href={item.url}
className={`flex items-center gap-2 ${isActive === item.url ? "bg-sidebar-accent text-sidebar-accent-foreground font-medium" : ""}`}
>
<item.icon className="h-4 w-4" />
{!collapsed && <span>{item.title}</span>}
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
))}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
</SidebarContent>
<SidebarFooter className="border-t border-sidebar-border p-2 space-y-1">
<SidebarMenuButton asChild>
<Link href="/" className="flex items-center gap-2">
<Home className="h-4 w-4" />
{!collapsed && <span>Voir le site</span>}
</Link>
</SidebarMenuButton>
<Button
variant="ghost"
size="sm"
onClick={handleLogout}
className="w-full justify-start gap-2"
>
<LogOut className="h-4 w-4" />
{!collapsed && <span>Déconnexion</span>}
</Button>
</SidebarFooter>
</Sidebar>
);
};

View File

@@ -0,0 +1,199 @@
"use client"
import * as React from "react"
import { AlertDialog as AlertDialogPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
function AlertDialog({
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Root>) {
return <AlertDialogPrimitive.Root data-slot="alert-dialog" {...props} />
}
function AlertDialogTrigger({
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Trigger>) {
return (
<AlertDialogPrimitive.Trigger data-slot="alert-dialog-trigger" {...props} />
)
}
function AlertDialogPortal({
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Portal>) {
return (
<AlertDialogPrimitive.Portal data-slot="alert-dialog-portal" {...props} />
)
}
function AlertDialogOverlay({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Overlay>) {
return (
<AlertDialogPrimitive.Overlay
data-slot="alert-dialog-overlay"
className={cn(
"fixed inset-0 z-50 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0",
className
)}
{...props}
/>
)
}
function AlertDialogContent({
className,
size = "default",
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Content> & {
size?: "default" | "sm"
}) {
return (
<AlertDialogPortal>
<AlertDialogOverlay />
<AlertDialogPrimitive.Content
data-slot="alert-dialog-content"
data-size={size}
className={cn(
"group/alert-dialog-content fixed top-1/2 left-1/2 z-50 grid w-full -translate-x-1/2 -translate-y-1/2 gap-4 rounded-xl bg-popover p-4 text-popover-foreground ring-1 ring-foreground/10 duration-100 outline-none data-[size=default]:max-w-xs data-[size=sm]:max-w-xs data-[size=default]:sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
className
)}
{...props}
/>
</AlertDialogPortal>
)
}
function AlertDialogHeader({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-dialog-header"
className={cn(
"grid grid-rows-[auto_1fr] place-items-center gap-1.5 text-center has-data-[slot=alert-dialog-media]:grid-rows-[auto_auto_1fr] has-data-[slot=alert-dialog-media]:gap-x-4 sm:group-data-[size=default]/alert-dialog-content:place-items-start sm:group-data-[size=default]/alert-dialog-content:text-left sm:group-data-[size=default]/alert-dialog-content:has-data-[slot=alert-dialog-media]:grid-rows-[auto_1fr]",
className
)}
{...props}
/>
)
}
function AlertDialogFooter({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-dialog-footer"
className={cn(
"-mx-4 -mb-4 flex flex-col-reverse gap-2 rounded-b-xl border-t bg-muted/50 p-4 group-data-[size=sm]/alert-dialog-content:grid group-data-[size=sm]/alert-dialog-content:grid-cols-2 sm:flex-row sm:justify-end",
className
)}
{...props}
/>
)
}
function AlertDialogMedia({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-dialog-media"
className={cn(
"mb-2 inline-flex size-10 items-center justify-center rounded-md bg-muted sm:group-data-[size=default]/alert-dialog-content:row-span-2 *:[svg:not([class*='size-'])]:size-6",
className
)}
{...props}
/>
)
}
function AlertDialogTitle({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Title>) {
return (
<AlertDialogPrimitive.Title
data-slot="alert-dialog-title"
className={cn(
"font-heading text-base font-medium sm:group-data-[size=default]/alert-dialog-content:group-has-data-[slot=alert-dialog-media]/alert-dialog-content:col-start-2",
className
)}
{...props}
/>
)
}
function AlertDialogDescription({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Description>) {
return (
<AlertDialogPrimitive.Description
data-slot="alert-dialog-description"
className={cn(
"text-sm text-balance text-muted-foreground md:text-pretty *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground",
className
)}
{...props}
/>
)
}
function AlertDialogAction({
className,
variant = "default",
size = "default",
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Action> &
Pick<React.ComponentProps<typeof Button>, "variant" | "size">) {
return (
<Button variant={variant} size={size} asChild>
<AlertDialogPrimitive.Action
data-slot="alert-dialog-action"
className={cn(className)}
{...props}
/>
</Button>
)
}
function AlertDialogCancel({
className,
variant = "outline",
size = "default",
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Cancel> &
Pick<React.ComponentProps<typeof Button>, "variant" | "size">) {
return (
<Button variant={variant} size={size} asChild>
<AlertDialogPrimitive.Cancel
data-slot="alert-dialog-cancel"
className={cn(className)}
{...props}
/>
</Button>
)
}
export {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogMedia,
AlertDialogOverlay,
AlertDialogPortal,
AlertDialogTitle,
AlertDialogTrigger,
}

49
components/ui/badge.tsx Normal file
View File

@@ -0,0 +1,49 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { Slot } from "radix-ui"
import { cn } from "@/lib/utils"
const badgeVariants = cva(
"group/badge inline-flex h-5 w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-4xl border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-all focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3!",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
secondary:
"bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80",
destructive:
"bg-destructive/10 text-destructive focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:focus-visible:ring-destructive/40 [a]:hover:bg-destructive/20",
outline:
"border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground",
ghost:
"hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50",
link: "text-primary underline-offset-4 hover:underline",
},
},
defaultVariants: {
variant: "default",
},
}
)
function Badge({
className,
variant = "default",
asChild = false,
...props
}: React.ComponentProps<"span"> &
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
const Comp = asChild ? Slot.Root : "span"
return (
<Comp
data-slot="badge"
data-variant={variant}
className={cn(badgeVariants({ variant }), className)}
{...props}
/>
)
}
export { Badge, badgeVariants }

67
components/ui/button.tsx Normal file
View File

@@ -0,0 +1,67 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { Slot } from "radix-ui"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"group/button inline-flex shrink-0 cursor-pointer items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
outline:
"border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
ghost:
"hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50",
destructive:
"bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default:
"h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
lg: "h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
icon: "size-8",
"icon-xs":
"size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3",
"icon-sm":
"size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg",
"icon-lg": "size-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function Button({
className,
variant = "default",
size = "default",
asChild = false,
...props
}: React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean
}) {
const Comp = asChild ? Slot.Root : "button"
return (
<Comp
data-slot="button"
data-variant={variant}
data-size={size}
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
)
}
export { Button, buttonVariants }

222
components/ui/calendar.tsx Normal file
View File

@@ -0,0 +1,222 @@
"use client"
import * as React from "react"
import {
DayPicker,
getDefaultClassNames,
type DayButton,
type Locale,
} from "react-day-picker"
import { cn } from "@/lib/utils"
import { Button, buttonVariants } from "@/components/ui/button"
import { ChevronLeftIcon, ChevronRightIcon, ChevronDownIcon } from "lucide-react"
function Calendar({
className,
classNames,
showOutsideDays = true,
captionLayout = "label",
buttonVariant = "ghost",
locale,
formatters,
components,
...props
}: React.ComponentProps<typeof DayPicker> & {
buttonVariant?: React.ComponentProps<typeof Button>["variant"]
}) {
const defaultClassNames = getDefaultClassNames()
return (
<DayPicker
showOutsideDays={showOutsideDays}
className={cn(
"group/calendar bg-background p-2 [--cell-radius:var(--radius-md)] [--cell-size:--spacing(7)] in-data-[slot=card-content]:bg-transparent in-data-[slot=popover-content]:bg-transparent",
String.raw`rtl:**:[.rdp-button\_next>svg]:rotate-180`,
String.raw`rtl:**:[.rdp-button\_previous>svg]:rotate-180`,
className
)}
captionLayout={captionLayout}
locale={locale}
formatters={{
formatMonthDropdown: (date) =>
date.toLocaleString(locale?.code, { month: "short" }),
...formatters,
}}
classNames={{
root: cn("w-fit", defaultClassNames.root),
months: cn(
"relative flex flex-col gap-4 md:flex-row",
defaultClassNames.months
),
month: cn("flex w-full flex-col gap-4", defaultClassNames.month),
nav: cn(
"absolute inset-x-0 top-0 flex w-full items-center justify-between gap-1",
defaultClassNames.nav
),
button_previous: cn(
buttonVariants({ variant: buttonVariant }),
"size-(--cell-size) p-0 select-none aria-disabled:opacity-50",
defaultClassNames.button_previous
),
button_next: cn(
buttonVariants({ variant: buttonVariant }),
"size-(--cell-size) p-0 select-none aria-disabled:opacity-50",
defaultClassNames.button_next
),
month_caption: cn(
"flex h-(--cell-size) w-full items-center justify-center px-(--cell-size)",
defaultClassNames.month_caption
),
dropdowns: cn(
"flex h-(--cell-size) w-full items-center justify-center gap-1.5 text-sm font-medium",
defaultClassNames.dropdowns
),
dropdown_root: cn(
"relative rounded-(--cell-radius)",
defaultClassNames.dropdown_root
),
dropdown: cn(
"absolute inset-0 bg-popover opacity-0",
defaultClassNames.dropdown
),
caption_label: cn(
"font-medium select-none",
captionLayout === "label"
? "text-sm"
: "flex items-center gap-1 rounded-(--cell-radius) text-sm [&>svg]:size-3.5 [&>svg]:text-muted-foreground",
defaultClassNames.caption_label
),
table: "w-full border-collapse",
weekdays: cn("flex", defaultClassNames.weekdays),
weekday: cn(
"flex-1 rounded-(--cell-radius) text-[0.8rem] font-normal text-muted-foreground select-none",
defaultClassNames.weekday
),
week: cn("mt-2 flex w-full", defaultClassNames.week),
week_number_header: cn(
"w-(--cell-size) select-none",
defaultClassNames.week_number_header
),
week_number: cn(
"text-[0.8rem] text-muted-foreground select-none",
defaultClassNames.week_number
),
day: cn(
"group/day relative aspect-square h-full w-full rounded-(--cell-radius) p-0 text-center select-none [&:last-child[data-selected=true]_button]:rounded-r-(--cell-radius)",
props.showWeekNumber
? "[&:nth-child(2)[data-selected=true]_button]:rounded-l-(--cell-radius)"
: "[&:first-child[data-selected=true]_button]:rounded-l-(--cell-radius)",
defaultClassNames.day
),
range_start: cn(
"relative isolate z-0 rounded-l-(--cell-radius) bg-muted after:absolute after:inset-y-0 after:right-0 after:w-4 after:bg-muted",
defaultClassNames.range_start
),
range_middle: cn("rounded-none", defaultClassNames.range_middle),
range_end: cn(
"relative isolate z-0 rounded-r-(--cell-radius) bg-muted after:absolute after:inset-y-0 after:left-0 after:w-4 after:bg-muted",
defaultClassNames.range_end
),
today: cn(
"rounded-(--cell-radius) bg-muted text-foreground data-[selected=true]:rounded-none",
defaultClassNames.today
),
outside: cn(
"text-muted-foreground aria-selected:text-muted-foreground",
defaultClassNames.outside
),
disabled: cn(
"text-muted-foreground opacity-50",
defaultClassNames.disabled
),
hidden: cn("invisible", defaultClassNames.hidden),
...classNames,
}}
components={{
Root: ({ className, rootRef, ...props }) => {
return (
<div
data-slot="calendar"
ref={rootRef}
className={cn(className)}
{...props}
/>
)
},
Chevron: ({ className, orientation, ...props }) => {
if (orientation === "left") {
return (
<ChevronLeftIcon className={cn("size-4", className)} {...props} />
)
}
if (orientation === "right") {
return (
<ChevronRightIcon className={cn("size-4", className)} {...props} />
)
}
return (
<ChevronDownIcon className={cn("size-4", className)} {...props} />
)
},
DayButton: ({ ...props }) => (
<CalendarDayButton locale={locale} {...props} />
),
WeekNumber: ({ children, ...props }) => {
return (
<td {...props}>
<div className="flex size-(--cell-size) items-center justify-center text-center">
{children}
</div>
</td>
)
},
...components,
}}
{...props}
/>
)
}
function CalendarDayButton({
className,
day,
modifiers,
locale,
...props
}: React.ComponentProps<typeof DayButton> & { locale?: Partial<Locale> }) {
const defaultClassNames = getDefaultClassNames()
const ref = React.useRef<HTMLButtonElement>(null)
React.useEffect(() => {
if (modifiers.focused) ref.current?.focus()
}, [modifiers.focused])
return (
<Button
ref={ref}
variant="ghost"
size="icon"
data-day={day.date.toLocaleDateString(locale?.code)}
data-selected-single={
modifiers.selected &&
!modifiers.range_start &&
!modifiers.range_end &&
!modifiers.range_middle
}
data-range-start={modifiers.range_start}
data-range-end={modifiers.range_end}
data-range-middle={modifiers.range_middle}
className={cn(
"relative isolate z-10 flex aspect-square size-auto w-full min-w-(--cell-size) flex-col gap-1 border-0 leading-none font-normal group-data-[focused=true]/day:relative group-data-[focused=true]/day:z-10 group-data-[focused=true]/day:border-ring group-data-[focused=true]/day:ring-[3px] group-data-[focused=true]/day:ring-ring/50 data-[range-end=true]:rounded-(--cell-radius) data-[range-end=true]:rounded-r-(--cell-radius) data-[range-end=true]:bg-primary data-[range-end=true]:text-primary-foreground data-[range-middle=true]:rounded-none data-[range-middle=true]:bg-muted data-[range-middle=true]:text-foreground data-[range-start=true]:rounded-(--cell-radius) data-[range-start=true]:rounded-l-(--cell-radius) data-[range-start=true]:bg-primary data-[range-start=true]:text-primary-foreground data-[selected-single=true]:bg-primary data-[selected-single=true]:text-primary-foreground dark:hover:text-foreground [&>span]:text-xs [&>span]:opacity-70",
defaultClassNames.day,
className
)}
{...props}
/>
)
}
export { Calendar, CalendarDayButton }

103
components/ui/card.tsx Normal file
View File

@@ -0,0 +1,103 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Card({
className,
size = "default",
...props
}: React.ComponentProps<"div"> & { size?: "default" | "sm" }) {
return (
<div
data-slot="card"
data-size={size}
className={cn(
"group/card flex flex-col gap-4 overflow-hidden rounded-xl bg-card py-4 text-sm text-card-foreground ring-1 ring-foreground/10 has-data-[slot=card-footer]:pb-0 has-[>img:first-child]:pt-0 data-[size=sm]:gap-3 data-[size=sm]:py-3 data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl",
className
)}
{...props}
/>
)
}
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-header"
className={cn(
"group/card-header @container/card-header grid auto-rows-min items-start gap-1 rounded-t-xl px-4 group-data-[size=sm]/card:px-3 has-data-[slot=card-action]:grid-cols-[1fr_auto] has-data-[slot=card-description]:grid-rows-[auto_auto] [.border-b]:pb-4 group-data-[size=sm]/card:[.border-b]:pb-3",
className
)}
{...props}
/>
)
}
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-title"
className={cn(
"font-heading text-base leading-snug font-medium group-data-[size=sm]/card:text-sm",
className
)}
{...props}
/>
)
}
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-description"
className={cn("text-sm text-muted-foreground", 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-4 group-data-[size=sm]/card:px-3", className)}
{...props}
/>
)
}
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-footer"
className={cn(
"flex items-center rounded-b-xl border-t bg-muted/50 p-4 group-data-[size=sm]/card:p-3",
className
)}
{...props}
/>
)
}
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardAction,
CardDescription,
CardContent,
}

195
components/ui/command.tsx Normal file
View File

@@ -0,0 +1,195 @@
"use client"
import * as React from "react"
import { Command as CommandPrimitive } from "cmdk"
import { cn } from "@/lib/utils"
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import {
InputGroup,
InputGroupAddon,
} from "@/components/ui/input-group"
import { SearchIcon, CheckIcon } from "lucide-react"
function Command({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive>) {
return (
<CommandPrimitive
data-slot="command"
className={cn(
"flex size-full flex-col overflow-hidden rounded-xl! bg-popover p-1 text-popover-foreground",
className
)}
{...props}
/>
)
}
function CommandDialog({
title = "Command Palette",
description = "Search for a command to run...",
children,
className,
showCloseButton = false,
...props
}: React.ComponentProps<typeof Dialog> & {
title?: string
description?: string
className?: string
showCloseButton?: boolean
}) {
return (
<Dialog {...props}>
<DialogHeader className="sr-only">
<DialogTitle>{title}</DialogTitle>
<DialogDescription>{description}</DialogDescription>
</DialogHeader>
<DialogContent
className={cn(
"top-1/3 translate-y-0 overflow-hidden rounded-xl! p-0",
className
)}
showCloseButton={showCloseButton}
>
{children}
</DialogContent>
</Dialog>
)
}
function CommandInput({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Input>) {
return (
<div data-slot="command-input-wrapper" className="p-1 pb-0">
<InputGroup className="h-8! rounded-lg! border-input/30 bg-input/30 shadow-none! *:data-[slot=input-group-addon]:pl-2!">
<CommandPrimitive.Input
data-slot="command-input"
className={cn(
"w-full text-sm outline-hidden disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
/>
<InputGroupAddon>
<SearchIcon className="size-4 shrink-0 opacity-50" />
</InputGroupAddon>
</InputGroup>
</div>
)
}
function CommandList({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.List>) {
return (
<CommandPrimitive.List
data-slot="command-list"
className={cn(
"no-scrollbar max-h-72 scroll-py-1 overflow-x-hidden overflow-y-auto outline-none",
className
)}
{...props}
/>
)
}
function CommandEmpty({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Empty>) {
return (
<CommandPrimitive.Empty
data-slot="command-empty"
className={cn("py-6 text-center text-sm", className)}
{...props}
/>
)
}
function CommandGroup({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Group>) {
return (
<CommandPrimitive.Group
data-slot="command-group"
className={cn(
"overflow-hidden p-1 text-foreground **:[[cmdk-group-heading]]:px-2 **:[[cmdk-group-heading]]:py-1.5 **:[[cmdk-group-heading]]:text-xs **:[[cmdk-group-heading]]:font-medium **:[[cmdk-group-heading]]:text-muted-foreground",
className
)}
{...props}
/>
)
}
function CommandSeparator({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Separator>) {
return (
<CommandPrimitive.Separator
data-slot="command-separator"
className={cn("-mx-1 h-px bg-border", className)}
{...props}
/>
)
}
function CommandItem({
className,
children,
...props
}: React.ComponentProps<typeof CommandPrimitive.Item>) {
return (
<CommandPrimitive.Item
data-slot="command-item"
className={cn(
"group/command-item relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none in-data-[slot=dialog-content]:rounded-lg! data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 data-selected:bg-muted data-selected:text-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 data-selected:*:[svg]:text-foreground",
className
)}
{...props}
>
{children}
<CheckIcon className="ml-auto opacity-0 group-has-data-[slot=command-shortcut]/command-item:hidden group-data-[checked=true]/command-item:opacity-100" />
</CommandPrimitive.Item>
)
}
function CommandShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="command-shortcut"
className={cn(
"ml-auto text-xs tracking-widest text-muted-foreground group-data-selected/command-item:text-foreground",
className
)}
{...props}
/>
)
}
export {
Command,
CommandDialog,
CommandInput,
CommandList,
CommandEmpty,
CommandGroup,
CommandItem,
CommandShortcut,
CommandSeparator,
}

168
components/ui/dialog.tsx Normal file
View File

@@ -0,0 +1,168 @@
"use client"
import * as React from "react"
import { Dialog as DialogPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { XIcon } from "lucide-react"
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(
"fixed inset-0 isolate z-50 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0",
className
)}
{...props}
/>
)
}
function DialogContent({
className,
children,
showCloseButton = true,
...props
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
showCloseButton?: boolean
}) {
return (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
data-slot="dialog-content"
className={cn(
"fixed top-1/2 left-1/2 z-50 grid w-full max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 gap-4 rounded-xl bg-popover p-4 text-sm text-popover-foreground ring-1 ring-foreground/10 duration-100 outline-none sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
className
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close data-slot="dialog-close" asChild>
<Button
variant="ghost"
className="absolute top-2 right-2"
size="icon-sm"
>
<XIcon
/>
<span className="sr-only">Close</span>
</Button>
</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", className)}
{...props}
/>
)
}
function DialogFooter({
className,
showCloseButton = false,
children,
...props
}: React.ComponentProps<"div"> & {
showCloseButton?: boolean
}) {
return (
<div
data-slot="dialog-footer"
className={cn(
"-mx-4 -mb-4 flex flex-col-reverse gap-2 rounded-b-xl border-t bg-muted/50 p-4 sm:flex-row sm:justify-end",
className
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close asChild>
<Button variant="outline">Close</Button>
</DialogPrimitive.Close>
)}
</div>
)
}
function DialogTitle({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
return (
<DialogPrimitive.Title
data-slot="dialog-title"
className={cn(
"font-heading text-base leading-none font-medium",
className
)}
{...props}
/>
)
}
function DialogDescription({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
return (
<DialogPrimitive.Description
data-slot="dialog-description"
className={cn(
"text-sm text-muted-foreground *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground",
className
)}
{...props}
/>
)
}
export {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
}

View File

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

View File

@@ -0,0 +1,156 @@
"use client"
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Textarea } from "@/components/ui/textarea"
function InputGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="input-group"
role="group"
className={cn(
"group/input-group relative flex h-8 w-full min-w-0 items-center rounded-lg border border-input transition-colors outline-none in-data-[slot=combobox-content]:focus-within:border-inherit in-data-[slot=combobox-content]:focus-within:ring-0 has-disabled:bg-input/50 has-disabled:opacity-50 has-[[data-slot=input-group-control]:focus-visible]:border-ring has-[[data-slot=input-group-control]:focus-visible]:ring-3 has-[[data-slot=input-group-control]:focus-visible]:ring-ring/50 has-[[data-slot][aria-invalid=true]]:border-destructive has-[[data-slot][aria-invalid=true]]:ring-3 has-[[data-slot][aria-invalid=true]]:ring-destructive/20 has-[>[data-align=block-end]]:h-auto has-[>[data-align=block-end]]:flex-col has-[>[data-align=block-start]]:h-auto has-[>[data-align=block-start]]:flex-col has-[>textarea]:h-auto dark:bg-input/30 dark:has-disabled:bg-input/80 dark:has-[[data-slot][aria-invalid=true]]:ring-destructive/40 has-[>[data-align=block-end]]:[&>input]:pt-3 has-[>[data-align=block-start]]:[&>input]:pb-3 has-[>[data-align=inline-end]]:[&>input]:pr-1.5 has-[>[data-align=inline-start]]:[&>input]:pl-1.5",
className
)}
{...props}
/>
)
}
const inputGroupAddonVariants = cva(
"flex h-auto cursor-text items-center justify-center gap-2 py-1.5 text-sm font-medium text-muted-foreground select-none group-data-[disabled=true]/input-group:opacity-50 [&>kbd]:rounded-[calc(var(--radius)-5px)] [&>svg:not([class*='size-'])]:size-4",
{
variants: {
align: {
"inline-start":
"order-first pl-2 has-[>button]:ml-[-0.3rem] has-[>kbd]:ml-[-0.15rem]",
"inline-end":
"order-last pr-2 has-[>button]:mr-[-0.3rem] has-[>kbd]:mr-[-0.15rem]",
"block-start":
"order-first w-full justify-start px-2.5 pt-2 group-has-[>input]/input-group:pt-2 [.border-b]:pb-2",
"block-end":
"order-last w-full justify-start px-2.5 pb-2 group-has-[>input]/input-group:pb-2 [.border-t]:pt-2",
},
},
defaultVariants: {
align: "inline-start",
},
}
)
function InputGroupAddon({
className,
align = "inline-start",
...props
}: React.ComponentProps<"div"> & VariantProps<typeof inputGroupAddonVariants>) {
return (
<div
role="group"
data-slot="input-group-addon"
data-align={align}
className={cn(inputGroupAddonVariants({ align }), className)}
onClick={(e) => {
if ((e.target as HTMLElement).closest("button")) {
return
}
e.currentTarget.parentElement?.querySelector("input")?.focus()
}}
{...props}
/>
)
}
const inputGroupButtonVariants = cva(
"flex items-center gap-2 text-sm shadow-none",
{
variants: {
size: {
xs: "h-6 gap-1 rounded-[calc(var(--radius)-3px)] px-1.5 [&>svg:not([class*='size-'])]:size-3.5",
sm: "",
"icon-xs":
"size-6 rounded-[calc(var(--radius)-3px)] p-0 has-[>svg]:p-0",
"icon-sm": "size-8 p-0 has-[>svg]:p-0",
},
},
defaultVariants: {
size: "xs",
},
}
)
function InputGroupButton({
className,
type = "button",
variant = "ghost",
size = "xs",
...props
}: Omit<React.ComponentProps<typeof Button>, "size"> &
VariantProps<typeof inputGroupButtonVariants>) {
return (
<Button
type={type}
data-size={size}
variant={variant}
className={cn(inputGroupButtonVariants({ size }), className)}
{...props}
/>
)
}
function InputGroupText({ className, ...props }: React.ComponentProps<"span">) {
return (
<span
className={cn(
"flex items-center gap-2 text-sm text-muted-foreground [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
)
}
function InputGroupInput({
className,
...props
}: React.ComponentProps<"input">) {
return (
<Input
data-slot="input-group-control"
className={cn(
"flex-1 rounded-none border-0 bg-transparent shadow-none ring-0 focus-visible:ring-0 disabled:bg-transparent aria-invalid:ring-0 dark:bg-transparent dark:disabled:bg-transparent",
className
)}
{...props}
/>
)
}
function InputGroupTextarea({
className,
...props
}: React.ComponentProps<"textarea">) {
return (
<Textarea
data-slot="input-group-control"
className={cn(
"flex-1 resize-none rounded-none border-0 bg-transparent py-2 shadow-none ring-0 focus-visible:ring-0 disabled:bg-transparent aria-invalid:ring-0 dark:bg-transparent dark:disabled:bg-transparent",
className
)}
{...props}
/>
)
}
export {
InputGroup,
InputGroupAddon,
InputGroupButton,
InputGroupText,
InputGroupInput,
InputGroupTextarea,
}

19
components/ui/input.tsx Normal file
View File

@@ -0,0 +1,19 @@
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(
"h-8 w-full min-w-0 rounded-lg border border-input bg-transparent px-2.5 py-1 text-base transition-colors outline-none file:inline-flex file:h-6 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
className
)}
{...props}
/>
)
}
export { Input }

24
components/ui/label.tsx Normal file
View File

@@ -0,0 +1,24 @@
"use client"
import * as React from "react"
import { Label as LabelPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function Label({
className,
...props
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
return (
<LabelPrimitive.Root
data-slot="label"
className={cn(
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
className
)}
{...props}
/>
)
}
export { Label }

89
components/ui/popover.tsx Normal file
View File

@@ -0,0 +1,89 @@
"use client"
import * as React from "react"
import { Popover as PopoverPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function Popover({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Root>) {
return <PopoverPrimitive.Root data-slot="popover" {...props} />
}
function PopoverTrigger({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Trigger>) {
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />
}
function PopoverContent({
className,
align = "center",
sideOffset = 4,
...props
}: React.ComponentProps<typeof PopoverPrimitive.Content>) {
return (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
data-slot="popover-content"
align={align}
sideOffset={sideOffset}
className={cn(
"z-50 flex w-72 origin-(--radix-popover-content-transform-origin) flex-col gap-2.5 rounded-lg bg-popover p-2.5 text-sm text-popover-foreground shadow-md ring-1 ring-foreground/10 outline-hidden duration-100 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 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
className
)}
{...props}
/>
</PopoverPrimitive.Portal>
)
}
function PopoverAnchor({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Anchor>) {
return <PopoverPrimitive.Anchor data-slot="popover-anchor" {...props} />
}
function PopoverHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="popover-header"
className={cn("flex flex-col gap-0.5 text-sm", className)}
{...props}
/>
)
}
function PopoverTitle({ className, ...props }: React.ComponentProps<"h2">) {
return (
<div
data-slot="popover-title"
className={cn("font-medium", className)}
{...props}
/>
)
}
function PopoverDescription({
className,
...props
}: React.ComponentProps<"p">) {
return (
<p
data-slot="popover-description"
className={cn("text-muted-foreground", className)}
{...props}
/>
)
}
export {
Popover,
PopoverAnchor,
PopoverContent,
PopoverDescription,
PopoverHeader,
PopoverTitle,
PopoverTrigger,
}

192
components/ui/select.tsx Normal file
View File

@@ -0,0 +1,192 @@
"use client"
import * as React from "react"
import { Select as SelectPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
import { ChevronDownIcon, CheckIcon, ChevronUpIcon } from "lucide-react"
function Select({
...props
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
return <SelectPrimitive.Root data-slot="select" {...props} />
}
function SelectGroup({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
return (
<SelectPrimitive.Group
data-slot="select-group"
className={cn("scroll-my-1 p-1", className)}
{...props}
/>
)
}
function SelectValue({
...props
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
return <SelectPrimitive.Value data-slot="select-value" {...props} />
}
function SelectTrigger({
className,
size = "default",
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
size?: "sm" | "default"
}) {
return (
<SelectPrimitive.Trigger
data-slot="select-trigger"
data-size={size}
className={cn(
"flex w-fit items-center justify-between gap-1.5 rounded-lg border border-input bg-transparent py-2 pr-2 pl-2.5 text-sm whitespace-nowrap transition-colors outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-placeholder:text-muted-foreground data-[size=default]:h-8 data-[size=sm]:h-7 data-[size=sm]:rounded-[min(var(--radius-md),10px)] *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-1.5 dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDownIcon className="pointer-events-none size-4 text-muted-foreground" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
)
}
function SelectContent({
className,
children,
position = "item-aligned",
align = "center",
...props
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
return (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
data-slot="select-content"
data-align-trigger={position === "item-aligned"}
className={cn("relative z-50 max-h-(--radix-select-content-available-height) min-w-36 origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-[align-trigger=true]:animate-none 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 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", 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}
align={align}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
data-position={position}
className={cn(
"data-[position=popper]:h-(--radix-select-trigger-height) data-[position=popper]:w-full data-[position=popper]:min-w-(--radix-select-trigger-width)",
position === "popper" && ""
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
)
}
function SelectLabel({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
return (
<SelectPrimitive.Label
data-slot="select-label"
className={cn("px-1.5 py-1 text-xs text-muted-foreground", className)}
{...props}
/>
)
}
function SelectItem({
className,
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
return (
<SelectPrimitive.Item
data-slot="select-item"
className={cn(
"relative flex w-full cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
className
)}
{...props}
>
<span className="pointer-events-none absolute right-2 flex size-4 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<CheckIcon className="pointer-events-none" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
)
}
function SelectSeparator({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
return (
<SelectPrimitive.Separator
data-slot="select-separator"
className={cn("pointer-events-none -mx-1 my-1 h-px bg-border", className)}
{...props}
/>
)
}
function SelectScrollUpButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
return (
<SelectPrimitive.ScrollUpButton
data-slot="select-scroll-up-button"
className={cn(
"z-10 flex cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<ChevronUpIcon
/>
</SelectPrimitive.ScrollUpButton>
)
}
function SelectScrollDownButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
return (
<SelectPrimitive.ScrollDownButton
data-slot="select-scroll-down-button"
className={cn(
"z-10 flex cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<ChevronDownIcon
/>
</SelectPrimitive.ScrollDownButton>
)
}
export {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectScrollDownButton,
SelectScrollUpButton,
SelectSeparator,
SelectTrigger,
SelectValue,
}

View File

@@ -0,0 +1,28 @@
"use client"
import * as React from "react"
import { Separator as SeparatorPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function Separator({
className,
orientation = "horizontal",
decorative = true,
...props
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
return (
<SeparatorPrimitive.Root
data-slot="separator"
decorative={decorative}
orientation={orientation}
className={cn(
"shrink-0 bg-border data-horizontal:h-px data-horizontal:w-full data-vertical:w-px data-vertical:self-stretch",
className
)}
{...props}
/>
)
}
export { Separator }

147
components/ui/sheet.tsx Normal file
View File

@@ -0,0 +1,147 @@
"use client"
import * as React from "react"
import { Dialog as SheetPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { XIcon } from "lucide-react"
function Sheet({ ...props }: React.ComponentProps<typeof SheetPrimitive.Root>) {
return <SheetPrimitive.Root data-slot="sheet" {...props} />
}
function SheetTrigger({
...props
}: React.ComponentProps<typeof SheetPrimitive.Trigger>) {
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />
}
function SheetClose({
...props
}: React.ComponentProps<typeof SheetPrimitive.Close>) {
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />
}
function SheetPortal({
...props
}: React.ComponentProps<typeof SheetPrimitive.Portal>) {
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />
}
function SheetOverlay({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Overlay>) {
return (
<SheetPrimitive.Overlay
data-slot="sheet-overlay"
className={cn(
"fixed inset-0 z-50 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0",
className
)}
{...props}
/>
)
}
function SheetContent({
className,
children,
side = "right",
showCloseButton = true,
...props
}: React.ComponentProps<typeof SheetPrimitive.Content> & {
side?: "top" | "right" | "bottom" | "left"
showCloseButton?: boolean
}) {
return (
<SheetPortal>
<SheetOverlay />
<SheetPrimitive.Content
data-slot="sheet-content"
data-side={side}
className={cn(
"fixed z-50 flex flex-col gap-4 bg-popover bg-clip-padding text-sm text-popover-foreground shadow-lg transition duration-200 ease-in-out data-[side=bottom]:inset-x-0 data-[side=bottom]:bottom-0 data-[side=bottom]:h-auto data-[side=bottom]:border-t data-[side=left]:inset-y-0 data-[side=left]:left-0 data-[side=left]:h-full data-[side=left]:w-3/4 data-[side=left]:border-r data-[side=right]:inset-y-0 data-[side=right]:right-0 data-[side=right]:h-full data-[side=right]:w-3/4 data-[side=right]:border-l data-[side=top]:inset-x-0 data-[side=top]:top-0 data-[side=top]:h-auto data-[side=top]:border-b data-[side=left]:sm:max-w-sm data-[side=right]:sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-[side=bottom]:data-open:slide-in-from-bottom-10 data-[side=left]:data-open:slide-in-from-left-10 data-[side=right]:data-open:slide-in-from-right-10 data-[side=top]:data-open:slide-in-from-top-10 data-closed:animate-out data-closed:fade-out-0 data-[side=bottom]:data-closed:slide-out-to-bottom-10 data-[side=left]:data-closed:slide-out-to-left-10 data-[side=right]:data-closed:slide-out-to-right-10 data-[side=top]:data-closed:slide-out-to-top-10",
className
)}
{...props}
>
{children}
{showCloseButton && (
<SheetPrimitive.Close data-slot="sheet-close" asChild>
<Button
variant="ghost"
className="absolute top-3 right-3"
size="icon-sm"
>
<XIcon
/>
<span className="sr-only">Close</span>
</Button>
</SheetPrimitive.Close>
)}
</SheetPrimitive.Content>
</SheetPortal>
)
}
function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sheet-header"
className={cn("flex flex-col gap-0.5 p-4", className)}
{...props}
/>
)
}
function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sheet-footer"
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
{...props}
/>
)
}
function SheetTitle({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Title>) {
return (
<SheetPrimitive.Title
data-slot="sheet-title"
className={cn(
"font-heading text-base font-medium text-foreground",
className
)}
{...props}
/>
)
}
function SheetDescription({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Description>) {
return (
<SheetPrimitive.Description
data-slot="sheet-description"
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
)
}
export {
Sheet,
SheetTrigger,
SheetClose,
SheetContent,
SheetHeader,
SheetFooter,
SheetTitle,
SheetDescription,
}

702
components/ui/sidebar.tsx Normal file
View File

@@ -0,0 +1,702 @@
"use client"
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { Slot } from "radix-ui"
import { useIsMobile } from "@/hooks/use-mobile"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Separator } from "@/components/ui/separator"
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet"
import { Skeleton } from "@/components/ui/skeleton"
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip"
import { PanelLeftIcon } from "lucide-react"
const SIDEBAR_COOKIE_NAME = "sidebar_state"
const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7
const SIDEBAR_WIDTH = "16rem"
const SIDEBAR_WIDTH_MOBILE = "18rem"
const SIDEBAR_WIDTH_ICON = "3rem"
const SIDEBAR_KEYBOARD_SHORTCUT = "b"
type SidebarContextProps = {
state: "expanded" | "collapsed"
open: boolean
setOpen: (open: boolean) => void
openMobile: boolean
setOpenMobile: (open: boolean) => void
isMobile: boolean
toggleSidebar: () => void
}
const SidebarContext = React.createContext<SidebarContextProps | null>(null)
function useSidebar() {
const context = React.useContext(SidebarContext)
if (!context) {
throw new Error("useSidebar must be used within a SidebarProvider.")
}
return context
}
function SidebarProvider({
defaultOpen = true,
open: openProp,
onOpenChange: setOpenProp,
className,
style,
children,
...props
}: React.ComponentProps<"div"> & {
defaultOpen?: boolean
open?: boolean
onOpenChange?: (open: boolean) => void
}) {
const isMobile = useIsMobile()
const [openMobile, setOpenMobile] = React.useState(false)
// This is the internal state of the sidebar.
// We use openProp and setOpenProp for control from outside the component.
const [_open, _setOpen] = React.useState(defaultOpen)
const open = openProp ?? _open
const setOpen = React.useCallback(
(value: boolean | ((value: boolean) => boolean)) => {
const openState = typeof value === "function" ? value(open) : value
if (setOpenProp) {
setOpenProp(openState)
} else {
_setOpen(openState)
}
// This sets the cookie to keep the sidebar state.
document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`
},
[setOpenProp, open]
)
// Helper to toggle the sidebar.
const toggleSidebar = React.useCallback(() => {
return isMobile ? setOpenMobile((open) => !open) : setOpen((open) => !open)
}, [isMobile, setOpen, setOpenMobile])
// Adds a keyboard shortcut to toggle the sidebar.
React.useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (
event.key === SIDEBAR_KEYBOARD_SHORTCUT &&
(event.metaKey || event.ctrlKey)
) {
event.preventDefault()
toggleSidebar()
}
}
window.addEventListener("keydown", handleKeyDown)
return () => window.removeEventListener("keydown", handleKeyDown)
}, [toggleSidebar])
// We add a state so that we can do data-state="expanded" or "collapsed".
// This makes it easier to style the sidebar with Tailwind classes.
const state = open ? "expanded" : "collapsed"
const contextValue = React.useMemo<SidebarContextProps>(
() => ({
state,
open,
setOpen,
isMobile,
openMobile,
setOpenMobile,
toggleSidebar,
}),
[state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar]
)
return (
<SidebarContext.Provider value={contextValue}>
<div
data-slot="sidebar-wrapper"
style={
{
"--sidebar-width": SIDEBAR_WIDTH,
"--sidebar-width-icon": SIDEBAR_WIDTH_ICON,
...style,
} as React.CSSProperties
}
className={cn(
"group/sidebar-wrapper flex min-h-svh w-full has-data-[variant=inset]:bg-sidebar",
className
)}
{...props}
>
{children}
</div>
</SidebarContext.Provider>
)
}
function Sidebar({
side = "left",
variant = "sidebar",
collapsible = "offcanvas",
className,
children,
dir,
...props
}: React.ComponentProps<"div"> & {
side?: "left" | "right"
variant?: "sidebar" | "floating" | "inset"
collapsible?: "offcanvas" | "icon" | "none"
}) {
const { isMobile, state, openMobile, setOpenMobile } = useSidebar()
if (collapsible === "none") {
return (
<div
data-slot="sidebar"
className={cn(
"flex h-full w-(--sidebar-width) flex-col bg-sidebar text-sidebar-foreground",
className
)}
{...props}
>
{children}
</div>
)
}
if (isMobile) {
return (
<Sheet open={openMobile} onOpenChange={setOpenMobile} {...props}>
<SheetContent
dir={dir}
data-sidebar="sidebar"
data-slot="sidebar"
data-mobile="true"
className="w-(--sidebar-width) bg-sidebar p-0 text-sidebar-foreground [&>button]:hidden"
style={
{
"--sidebar-width": SIDEBAR_WIDTH_MOBILE,
} as React.CSSProperties
}
side={side}
>
<SheetHeader className="sr-only">
<SheetTitle>Sidebar</SheetTitle>
<SheetDescription>Displays the mobile sidebar.</SheetDescription>
</SheetHeader>
<div className="flex h-full w-full flex-col">{children}</div>
</SheetContent>
</Sheet>
)
}
return (
<div
className="group peer hidden text-sidebar-foreground md:block"
data-state={state}
data-collapsible={state === "collapsed" ? collapsible : ""}
data-variant={variant}
data-side={side}
data-slot="sidebar"
>
{/* This is what handles the sidebar gap on desktop */}
<div
data-slot="sidebar-gap"
className={cn(
"relative w-(--sidebar-width) bg-transparent transition-[width] duration-200 ease-linear",
"group-data-[collapsible=offcanvas]:w-0",
"group-data-[side=right]:rotate-180",
variant === "floating" || variant === "inset"
? "group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4)))]"
: "group-data-[collapsible=icon]:w-(--sidebar-width-icon)"
)}
/>
<div
data-slot="sidebar-container"
data-side={side}
className={cn(
"fixed inset-y-0 z-10 hidden h-svh w-(--sidebar-width) transition-[left,right,width] duration-200 ease-linear data-[side=left]:left-0 data-[side=left]:group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)] data-[side=right]:right-0 data-[side=right]:group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)] md:flex",
// Adjust the padding for floating and inset variants.
variant === "floating" || variant === "inset"
? "p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4))+2px)]"
: "group-data-[collapsible=icon]:w-(--sidebar-width-icon) group-data-[side=left]:border-r group-data-[side=right]:border-l",
className
)}
{...props}
>
<div
data-sidebar="sidebar"
data-slot="sidebar-inner"
className="flex size-full flex-col bg-sidebar group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:shadow-sm group-data-[variant=floating]:ring-1 group-data-[variant=floating]:ring-sidebar-border"
>
{children}
</div>
</div>
</div>
)
}
function SidebarTrigger({
className,
onClick,
...props
}: React.ComponentProps<typeof Button>) {
const { toggleSidebar } = useSidebar()
return (
<Button
data-sidebar="trigger"
data-slot="sidebar-trigger"
variant="ghost"
size="icon-sm"
className={cn(className)}
onClick={(event) => {
onClick?.(event)
toggleSidebar()
}}
{...props}
>
<PanelLeftIcon />
<span className="sr-only">Toggle Sidebar</span>
</Button>
)
}
function SidebarRail({ className, ...props }: React.ComponentProps<"button">) {
const { toggleSidebar } = useSidebar()
return (
<button
data-sidebar="rail"
data-slot="sidebar-rail"
aria-label="Toggle Sidebar"
tabIndex={-1}
onClick={toggleSidebar}
title="Toggle Sidebar"
className={cn(
"absolute inset-y-0 z-20 hidden w-4 transition-all ease-linear group-data-[side=left]:-right-4 group-data-[side=right]:left-0 after:absolute after:inset-y-0 after:start-1/2 after:w-[2px] hover:after:bg-sidebar-border sm:flex ltr:-translate-x-1/2 rtl:-translate-x-1/2",
"in-data-[side=left]:cursor-w-resize in-data-[side=right]:cursor-e-resize",
"[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize",
"group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full hover:group-data-[collapsible=offcanvas]:bg-sidebar",
"[[data-side=left][data-collapsible=offcanvas]_&]:-right-2",
"[[data-side=right][data-collapsible=offcanvas]_&]:-left-2",
className
)}
{...props}
/>
)
}
function SidebarInset({ className, ...props }: React.ComponentProps<"main">) {
return (
<main
data-slot="sidebar-inset"
className={cn(
"relative flex w-full flex-1 flex-col bg-background md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow-sm md:peer-data-[variant=inset]:peer-data-[state=collapsed]:ml-2",
className
)}
{...props}
/>
)
}
function SidebarInput({
className,
...props
}: React.ComponentProps<typeof Input>) {
return (
<Input
data-slot="sidebar-input"
data-sidebar="input"
className={cn("h-8 w-full bg-background shadow-none", className)}
{...props}
/>
)
}
function SidebarHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-header"
data-sidebar="header"
className={cn("flex flex-col gap-2 p-2", className)}
{...props}
/>
)
}
function SidebarFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-footer"
data-sidebar="footer"
className={cn("flex flex-col gap-2 p-2", className)}
{...props}
/>
)
}
function SidebarSeparator({
className,
...props
}: React.ComponentProps<typeof Separator>) {
return (
<Separator
data-slot="sidebar-separator"
data-sidebar="separator"
className={cn("mx-2 w-auto bg-sidebar-border", className)}
{...props}
/>
)
}
function SidebarContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-content"
data-sidebar="content"
className={cn(
"no-scrollbar flex min-h-0 flex-1 flex-col gap-0 overflow-auto group-data-[collapsible=icon]:overflow-hidden",
className
)}
{...props}
/>
)
}
function SidebarGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-group"
data-sidebar="group"
className={cn("relative flex w-full min-w-0 flex-col p-2", className)}
{...props}
/>
)
}
function SidebarGroupLabel({
className,
asChild = false,
...props
}: React.ComponentProps<"div"> & { asChild?: boolean }) {
const Comp = asChild ? Slot.Root : "div"
return (
<Comp
data-slot="sidebar-group-label"
data-sidebar="group-label"
className={cn(
"flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium text-sidebar-foreground/70 ring-sidebar-ring outline-hidden transition-[margin,opacity] duration-200 ease-linear group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0 focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
className
)}
{...props}
/>
)
}
function SidebarGroupAction({
className,
asChild = false,
...props
}: React.ComponentProps<"button"> & { asChild?: boolean }) {
const Comp = asChild ? Slot.Root : "button"
return (
<Comp
data-slot="sidebar-group-action"
data-sidebar="group-action"
className={cn(
"absolute top-3.5 right-3 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground ring-sidebar-ring outline-hidden transition-transform group-data-[collapsible=icon]:hidden after:absolute after:-inset-2 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 md:after:hidden [&>svg]:size-4 [&>svg]:shrink-0",
className
)}
{...props}
/>
)
}
function SidebarGroupContent({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-group-content"
data-sidebar="group-content"
className={cn("w-full text-sm", className)}
{...props}
/>
)
}
function SidebarMenu({ className, ...props }: React.ComponentProps<"ul">) {
return (
<ul
data-slot="sidebar-menu"
data-sidebar="menu"
className={cn("flex w-full min-w-0 flex-col gap-0", className)}
{...props}
/>
)
}
function SidebarMenuItem({ className, ...props }: React.ComponentProps<"li">) {
return (
<li
data-slot="sidebar-menu-item"
data-sidebar="menu-item"
className={cn("group/menu-item relative", className)}
{...props}
/>
)
}
const sidebarMenuButtonVariants = cva(
"peer/menu-button group/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm ring-sidebar-ring outline-hidden transition-[width,height,padding] group-has-data-[sidebar=menu-action]/menu-item:pr-8 group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-open:hover:bg-sidebar-accent data-open:hover:text-sidebar-accent-foreground data-active:bg-sidebar-accent data-active:font-medium data-active:text-sidebar-accent-foreground [&_svg]:size-4 [&_svg]:shrink-0 [&>span:last-child]:truncate",
{
variants: {
variant: {
default: "hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
outline:
"bg-background shadow-[0_0_0_1px_hsl(var(--sidebar-border))] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_hsl(var(--sidebar-accent))]",
},
size: {
default: "h-8 text-sm",
sm: "h-7 text-xs",
lg: "h-12 text-sm group-data-[collapsible=icon]:p-0!",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function SidebarMenuButton({
asChild = false,
isActive = false,
variant = "default",
size = "default",
tooltip,
className,
...props
}: React.ComponentProps<"button"> & {
asChild?: boolean
isActive?: boolean
tooltip?: string | React.ComponentProps<typeof TooltipContent>
} & VariantProps<typeof sidebarMenuButtonVariants>) {
const Comp = asChild ? Slot.Root : "button"
const { isMobile, state } = useSidebar()
const button = (
<Comp
data-slot="sidebar-menu-button"
data-sidebar="menu-button"
data-size={size}
data-active={isActive}
className={cn(sidebarMenuButtonVariants({ variant, size }), className)}
{...props}
/>
)
if (!tooltip) {
return button
}
if (typeof tooltip === "string") {
tooltip = {
children: tooltip,
}
}
return (
<Tooltip>
<TooltipTrigger asChild>{button}</TooltipTrigger>
<TooltipContent
side="right"
align="center"
hidden={state !== "collapsed" || isMobile}
{...tooltip}
/>
</Tooltip>
)
}
function SidebarMenuAction({
className,
asChild = false,
showOnHover = false,
...props
}: React.ComponentProps<"button"> & {
asChild?: boolean
showOnHover?: boolean
}) {
const Comp = asChild ? Slot.Root : "button"
return (
<Comp
data-slot="sidebar-menu-action"
data-sidebar="menu-action"
className={cn(
"absolute top-1.5 right-1 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground ring-sidebar-ring outline-hidden transition-transform group-data-[collapsible=icon]:hidden peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[size=default]/menu-button:top-1.5 peer-data-[size=lg]/menu-button:top-2.5 peer-data-[size=sm]/menu-button:top-1 after:absolute after:-inset-2 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 md:after:hidden [&>svg]:size-4 [&>svg]:shrink-0",
showOnHover &&
"group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 peer-data-active/menu-button:text-sidebar-accent-foreground aria-expanded:opacity-100 md:opacity-0",
className
)}
{...props}
/>
)
}
function SidebarMenuBadge({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-menu-badge"
data-sidebar="menu-badge"
className={cn(
"pointer-events-none absolute right-1 flex h-5 min-w-5 items-center justify-center rounded-md px-1 text-xs font-medium text-sidebar-foreground tabular-nums select-none group-data-[collapsible=icon]:hidden peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[size=default]/menu-button:top-1.5 peer-data-[size=lg]/menu-button:top-2.5 peer-data-[size=sm]/menu-button:top-1 peer-data-active/menu-button:text-sidebar-accent-foreground",
className
)}
{...props}
/>
)
}
function SidebarMenuSkeleton({
className,
showIcon = false,
...props
}: React.ComponentProps<"div"> & {
showIcon?: boolean
}) {
// Random width between 50 to 90%.
const [width] = React.useState(() => {
return `${Math.floor(Math.random() * 40) + 50}%`
})
return (
<div
data-slot="sidebar-menu-skeleton"
data-sidebar="menu-skeleton"
className={cn("flex h-8 items-center gap-2 rounded-md px-2", className)}
{...props}
>
{showIcon && (
<Skeleton
className="size-4 rounded-md"
data-sidebar="menu-skeleton-icon"
/>
)}
<Skeleton
className="h-4 max-w-(--skeleton-width) flex-1"
data-sidebar="menu-skeleton-text"
style={
{
"--skeleton-width": width,
} as React.CSSProperties
}
/>
</div>
)
}
function SidebarMenuSub({ className, ...props }: React.ComponentProps<"ul">) {
return (
<ul
data-slot="sidebar-menu-sub"
data-sidebar="menu-sub"
className={cn(
"mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-l border-sidebar-border px-2.5 py-0.5 group-data-[collapsible=icon]:hidden",
className
)}
{...props}
/>
)
}
function SidebarMenuSubItem({
className,
...props
}: React.ComponentProps<"li">) {
return (
<li
data-slot="sidebar-menu-sub-item"
data-sidebar="menu-sub-item"
className={cn("group/menu-sub-item relative", className)}
{...props}
/>
)
}
function SidebarMenuSubButton({
asChild = false,
size = "md",
isActive = false,
className,
...props
}: React.ComponentProps<"a"> & {
asChild?: boolean
size?: "sm" | "md"
isActive?: boolean
}) {
const Comp = asChild ? Slot.Root : "a"
return (
<Comp
data-slot="sidebar-menu-sub-button"
data-sidebar="menu-sub-button"
data-size={size}
data-active={isActive}
className={cn(
"flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 text-sidebar-foreground ring-sidebar-ring outline-hidden group-data-[collapsible=icon]:hidden hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[size=md]:text-sm data-[size=sm]:text-xs data-active:bg-sidebar-accent data-active:text-sidebar-accent-foreground [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0 [&>svg]:text-sidebar-accent-foreground",
className
)}
{...props}
/>
)
}
export {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarGroup,
SidebarGroupAction,
SidebarGroupContent,
SidebarGroupLabel,
SidebarHeader,
SidebarInput,
SidebarInset,
SidebarMenu,
SidebarMenuAction,
SidebarMenuBadge,
SidebarMenuButton,
SidebarMenuItem,
SidebarMenuSkeleton,
SidebarMenuSub,
SidebarMenuSubButton,
SidebarMenuSubItem,
SidebarProvider,
SidebarRail,
SidebarSeparator,
SidebarTrigger,
useSidebar,
}

View File

@@ -0,0 +1,13 @@
import { cn } from "@/lib/utils"
function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="skeleton"
className={cn("animate-pulse rounded-md bg-muted", className)}
{...props}
/>
)
}
export { Skeleton }

49
components/ui/sonner.tsx Normal file
View File

@@ -0,0 +1,49 @@
"use client"
import { useTheme } from "next-themes"
import { Toaster as Sonner, type ToasterProps } from "sonner"
import { CircleCheckIcon, InfoIcon, TriangleAlertIcon, OctagonXIcon, Loader2Icon } from "lucide-react"
const Toaster = ({ ...props }: ToasterProps) => {
const { theme = "system" } = useTheme()
return (
<Sonner
theme={theme as ToasterProps["theme"]}
className="toaster group"
icons={{
success: (
<CircleCheckIcon className="size-4" />
),
info: (
<InfoIcon className="size-4" />
),
warning: (
<TriangleAlertIcon className="size-4" />
),
error: (
<OctagonXIcon className="size-4" />
),
loading: (
<Loader2Icon className="size-4 animate-spin" />
),
}}
style={
{
"--normal-bg": "var(--popover)",
"--normal-text": "var(--popover-foreground)",
"--normal-border": "var(--border)",
"--border-radius": "var(--radius)",
} as React.CSSProperties
}
toastOptions={{
classNames: {
toast: "cn-toast",
},
}}
{...props}
/>
)
}
export { Toaster }

116
components/ui/table.tsx Normal file
View File

@@ -0,0 +1,116 @@
"use client"
import * as React from "react"
import { cn } from "@/lib/utils"
function Table({ className, ...props }: React.ComponentProps<"table">) {
return (
<div
data-slot="table-container"
className="relative w-full overflow-x-auto"
>
<table
data-slot="table"
className={cn("w-full caption-bottom text-sm", className)}
{...props}
/>
</div>
)
}
function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
return (
<thead
data-slot="table-header"
className={cn("[&_tr]:border-b", className)}
{...props}
/>
)
}
function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
return (
<tbody
data-slot="table-body"
className={cn("[&_tr:last-child]:border-0", className)}
{...props}
/>
)
}
function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
return (
<tfoot
data-slot="table-footer"
className={cn(
"border-t bg-muted/50 font-medium [&>tr]:last:border-b-0",
className
)}
{...props}
/>
)
}
function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
return (
<tr
data-slot="table-row"
className={cn(
"border-b transition-colors hover:bg-muted/50 has-aria-expanded:bg-muted/50 data-[state=selected]:bg-muted",
className
)}
{...props}
/>
)
}
function TableHead({ className, ...props }: React.ComponentProps<"th">) {
return (
<th
data-slot="table-head"
className={cn(
"h-10 px-2 text-left align-middle font-medium whitespace-nowrap text-foreground [&:has([role=checkbox])]:pr-0",
className
)}
{...props}
/>
)
}
function TableCell({ className, ...props }: React.ComponentProps<"td">) {
return (
<td
data-slot="table-cell"
className={cn(
"p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0",
className
)}
{...props}
/>
)
}
function TableCaption({
className,
...props
}: React.ComponentProps<"caption">) {
return (
<caption
data-slot="table-caption"
className={cn("mt-4 text-sm text-muted-foreground", className)}
{...props}
/>
)
}
export {
Table,
TableHeader,
TableBody,
TableFooter,
TableHead,
TableRow,
TableCell,
TableCaption,
}

90
components/ui/tabs.tsx Normal file
View File

@@ -0,0 +1,90 @@
"use client"
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { Tabs as TabsPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function Tabs({
className,
orientation = "horizontal",
...props
}: React.ComponentProps<typeof TabsPrimitive.Root>) {
return (
<TabsPrimitive.Root
data-slot="tabs"
data-orientation={orientation}
className={cn(
"group/tabs flex gap-2 data-horizontal:flex-col",
className
)}
{...props}
/>
)
}
const tabsListVariants = cva(
"group/tabs-list inline-flex w-fit items-center justify-center rounded-lg p-[3px] text-muted-foreground group-data-horizontal/tabs:h-8 group-data-vertical/tabs:h-fit group-data-vertical/tabs:flex-col data-[variant=line]:rounded-none",
{
variants: {
variant: {
default: "bg-muted",
line: "gap-1 bg-transparent",
},
},
defaultVariants: {
variant: "default",
},
}
)
function TabsList({
className,
variant = "default",
...props
}: React.ComponentProps<typeof TabsPrimitive.List> &
VariantProps<typeof tabsListVariants>) {
return (
<TabsPrimitive.List
data-slot="tabs-list"
data-variant={variant}
className={cn(tabsListVariants({ variant }), className)}
{...props}
/>
)
}
function TabsTrigger({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
return (
<TabsPrimitive.Trigger
data-slot="tabs-trigger"
className={cn(
"relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-1.5 py-0.5 text-sm font-medium whitespace-nowrap text-foreground/60 transition-all group-data-vertical/tabs:w-full group-data-vertical/tabs:justify-start hover:text-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1 focus-visible:outline-ring disabled:pointer-events-none disabled:opacity-50 has-data-[icon=inline-end]:pr-1 has-data-[icon=inline-start]:pl-1 dark:text-muted-foreground dark:hover:text-foreground group-data-[variant=default]/tabs-list:data-active:shadow-sm group-data-[variant=line]/tabs-list:data-active:shadow-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
"group-data-[variant=line]/tabs-list:bg-transparent group-data-[variant=line]/tabs-list:data-active:bg-transparent dark:group-data-[variant=line]/tabs-list:data-active:border-transparent dark:group-data-[variant=line]/tabs-list:data-active:bg-transparent",
"data-active:bg-background data-active:text-foreground dark:data-active:border-input dark:data-active:bg-input/30 dark:data-active:text-foreground",
"after:absolute after:bg-foreground after:opacity-0 after:transition-opacity group-data-horizontal/tabs:after:inset-x-0 group-data-horizontal/tabs:after:bottom-[-5px] group-data-horizontal/tabs:after:h-0.5 group-data-vertical/tabs:after:inset-y-0 group-data-vertical/tabs:after:-right-1 group-data-vertical/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:data-active:after:opacity-100",
className
)}
{...props}
/>
)
}
function TabsContent({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Content>) {
return (
<TabsPrimitive.Content
data-slot="tabs-content"
className={cn("flex-1 text-sm outline-none", className)}
{...props}
/>
)
}
export { Tabs, TabsList, TabsTrigger, TabsContent, tabsListVariants }

View File

@@ -0,0 +1,18 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
return (
<textarea
data-slot="textarea"
className={cn(
"flex field-sizing-content min-h-16 w-full rounded-lg border border-input bg-transparent px-2.5 py-2 text-base transition-colors outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
className
)}
{...props}
/>
)
}
export { Textarea }

57
components/ui/tooltip.tsx Normal file
View File

@@ -0,0 +1,57 @@
"use client"
import * as React from "react"
import { Tooltip as TooltipPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function TooltipProvider({
delayDuration = 0,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
return (
<TooltipPrimitive.Provider
data-slot="tooltip-provider"
delayDuration={delayDuration}
{...props}
/>
)
}
function Tooltip({
...props
}: React.ComponentProps<typeof TooltipPrimitive.Root>) {
return <TooltipPrimitive.Root data-slot="tooltip" {...props} />
}
function TooltipTrigger({
...props
}: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />
}
function TooltipContent({
className,
sideOffset = 0,
children,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
return (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
data-slot="tooltip-content"
sideOffset={sideOffset}
className={cn(
"z-50 inline-flex w-fit max-w-xs origin-(--radix-tooltip-content-transform-origin) items-center gap-1.5 rounded-md bg-foreground px-3 py-1.5 text-xs text-background has-data-[slot=kbd]:pr-1.5 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 **:data-[slot=kbd]:relative **:data-[slot=kbd]:isolate **:data-[slot=kbd]:z-50 **:data-[slot=kbd]:rounded-sm data-[state=delayed-open]:animate-in data-[state=delayed-open]:fade-in-0 data-[state=delayed-open]:zoom-in-95 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
className
)}
{...props}
>
{children}
<TooltipPrimitive.Arrow className="z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px] bg-foreground fill-foreground" />
</TooltipPrimitive.Content>
</TooltipPrimitive.Portal>
)
}
export { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger }

102
contexts/AdminContext.tsx Normal file
View File

@@ -0,0 +1,102 @@
"use client";
import React, { useContext, useState, ReactNode, createContext } from "react";
import { Product, products } from "@/data/products";
export interface Reservation {
id: string;
clientName: string;
email: string;
phone: string;
service: string;
date: string;
time: string;
status: "pending" | "confirmed" | "cancelled";
createdAt: string;
}
const initialReservations: Reservation[] = [
{ id: "r1", clientName: "Marie Dupont", email: "marie@example.com", phone: "+33 6 12 34 56 78", service: "Pose complète", date: "2026-04-22", time: "10:00", status: "confirmed", createdAt: "2026-04-15" },
{ id: "r2", clientName: "Sophie Laurent", email: "sophie@example.com", phone: "+33 6 98 76 54 32", service: "Conseil personnalisé", date: "2026-04-23", time: "14:30", status: "pending", createdAt: "2026-04-16" },
{ id: "r3", clientName: "Amira Benali", email: "amira@example.com", phone: "+33 7 11 22 33 44", service: "Retouche", date: "2026-04-25", time: "11:00", status: "confirmed", createdAt: "2026-04-17" },
{ id: "r4", clientName: "Léa Martin", email: "lea@example.com", phone: "+33 6 55 44 33 22", service: "Pose complète", date: "2026-04-28", time: "15:00", status: "pending", createdAt: "2026-04-17" },
];
interface AdminContextType {
isAdmin: boolean;
login: (password: string) => boolean;
logout: () => void;
products: Product[];
addProduct: (product: Omit<Product, "id">) => void;
updateProduct: (id: string, product: Partial<Product>) => void;
deleteProduct: (id: string) => void;
reservations: Reservation[];
updateReservationStatus: (id: string, status: Reservation["status"]) => void;
deleteReservation: (id: string) => void;
}
const AdminContext = createContext<AdminContextType | undefined>(undefined);
const ADMIN_PASSWORD = "admin123";
export const AdminProvider = ({ children }: { children: ReactNode }) => {
const [isAdmin, setIsAdmin] = useState(false);
const [productList, setProducts] = useState<Product[]>(products);
const [reservations, setReservations] = useState<Reservation[]>(initialReservations);
const login = (password: string) => {
if (password === ADMIN_PASSWORD) {
setIsAdmin(true);
return true;
}
return false;
};
const logout = () => setIsAdmin(false);
const addProduct = (product: Omit<Product, "id">) => {
const newProduct: Product = { ...product, id: `p-${Date.now()}` };
setProducts((prev) => [newProduct, ...prev]);
};
const updateProduct = (id: string, updates: Partial<Product>) => {
setProducts((prev) => prev.map((p) => (p.id === id ? { ...p, ...updates } : p)));
};
const deleteProduct = (id: string) => {
setProducts((prev) => prev.filter((p) => p.id !== id));
};
const updateReservationStatus = (id: string, status: Reservation["status"]) => {
setReservations((prev) => prev.map((r) => (r.id === id ? { ...r, status } : r)));
};
const deleteReservation = (id: string) => {
setReservations((prev) => prev.filter((r) => r.id !== id));
};
return (
<AdminContext.Provider
value={{
isAdmin,
login,
logout,
products: productList,
addProduct,
updateProduct,
deleteProduct,
reservations,
updateReservationStatus,
deleteReservation,
}}
>
{children}
</AdminContext.Provider>
);
};
export const useAdmin = () => {
const ctx = useContext(AdminContext);
if (!ctx) throw new Error("useAdmin must be used within AdminProvider");
return ctx;
};

74
contexts/CartContext.tsx Normal file
View File

@@ -0,0 +1,74 @@
"use client";
import React, { createContext, useContext, useState, ReactNode } from "react";
import { Product } from "@/data/products";
export interface CartItem {
product: Product;
quantity: number;
selectedColor: string;
selectedLength: string;
}
interface CartContextType {
items: CartItem[];
addItem: (product: Product, color: string, length: string) => void;
removeItem: (productId: string) => void;
updateQuantity: (productId: string, quantity: number) => void;
clearCart: () => void;
totalItems: number;
totalPrice: number;
isCartOpen: boolean;
setIsCartOpen: (open: boolean) => void;
}
const CartContext = createContext<CartContextType | undefined>(undefined);
export const CartProvider = ({ children }: { children: ReactNode }) => {
const [items, setItems] = useState<CartItem[]>([]);
const [isCartOpen, setIsCartOpen] = useState(false);
const addItem = (product: Product, color: string, length: string) => {
setItems((prev) => {
const existing = prev.find((i) => i.product.id === product.id && i.selectedColor === color && i.selectedLength === length);
if (existing) {
return prev.map((i) =>
i.product.id === product.id && i.selectedColor === color && i.selectedLength === length
? { ...i, quantity: i.quantity + 1 }
: i
);
}
return [...prev, { product, quantity: 1, selectedColor: color, selectedLength: length }];
});
setIsCartOpen(true);
};
const removeItem = (productId: string) => {
setItems((prev) => prev.filter((i) => i.product.id !== productId));
};
const updateQuantity = (productId: string, quantity: number) => {
if (quantity <= 0) {
removeItem(productId);
return;
}
setItems((prev) => prev.map((i) => (i.product.id === productId ? { ...i, quantity } : i)));
};
const clearCart = () => setItems([]);
const totalItems = items.reduce((sum, i) => sum + i.quantity, 0);
const totalPrice = items.reduce((sum, i) => sum + i.product.price * i.quantity, 0);
return (
<CartContext.Provider value={{ items, addItem, removeItem, updateQuantity, clearCart, totalItems, totalPrice, isCartOpen, setIsCartOpen }}>
{children}
</CartContext.Provider>
);
};
export const useCart = () => {
const context = useContext(CartContext);
if (!context) throw new Error("useCart must be used within CartProvider");
return context;
};

View File

@@ -0,0 +1,80 @@
"use client";
import { useContext, useState, ReactNode, createContext } from "react";
export type Language = "fr" | "de" | "en" | "ar" | "tr";
interface LanguageContextType {
language: Language;
setLanguage: (lang: Language) => void;
t: (key: string) => string;
isRTL: boolean;
}
const translations: Record<string, Record<Language, string>> = {
"nav.shop": { fr: "Boutique", de: "Shop", en: "Shop", ar: "المتجر", tr: "Mağaza" },
"nav.booking": { fr: "Réservation", de: "Terminbuchung", en: "Book Appointment", ar: "حجز موعد", tr: "Randevu" },
"nav.about": { fr: "À propos", de: "Über uns", en: "About", ar: "من نحن", tr: "Hakkımızda" },
"nav.contact": { fr: "Contact", de: "Kontakt", en: "Contact", ar: "اتصل بنا", tr: "İletişim" },
"nav.account": { fr: "Mon compte", de: "Mein Konto", en: "My Account", ar: "حسابي", tr: "Hesabım" },
"hero.title": { fr: "Sublimez votre beauté naturelle", de: "Unterstreichen Sie Ihre natürliche Schönheit", en: "Enhance your natural beauty", ar: "عززي جمالك الطبيعي", tr: "Doğal güzelliğinizi ortaya çıkarın" },
"hero.subtitle": { fr: "Extensions de cheveux 100% naturels, qualité premium", de: "100% natürliche Haarverlängerungen, Premium-Qualität", en: "100% natural hair extensions, premium quality", ar: "وصلات شعر طبيعية 100%، جودة فاخرة", tr: "100% doğal saç eklentileri, premium kalite" },
"hero.cta": { fr: "Découvrir la collection", de: "Kollektion entdecken", en: "Discover the collection", ar: "اكتشفي المجموعة", tr: "Koleksiyonu keşfedin" },
"categories.title": { fr: "Nos Collections", de: "Unsere Kollektionen", en: "Our Collections", ar: "مجموعاتنا", tr: "Koleksiyonlarımız" },
"bestsellers.title": { fr: "Les Plus Vendus", de: "Bestseller", en: "Bestsellers", ar: "الأكثر مبيعاً", tr: "En Çok Satanlar" },
"booking.title": { fr: "Réservez votre rendez-vous", de: "Termin buchen", en: "Book your appointment", ar: "احجزي موعدك", tr: "Randevunuzu alın" },
"booking.subtitle": { fr: "Consultation gratuite et personnalisée", de: "Kostenlose und persönliche Beratung", en: "Free personalized consultation", ar: "استشارة مجانية وشخصية", tr: "Ücretsiz kişisel danışmanlık" },
"booking.cta": { fr: "Prendre rendez-vous", de: "Termin vereinbaren", en: "Book now", ar: "احجزي الآن", tr: "Randevu al" },
"reviews.title": { fr: "Ce que disent nos clientes", de: "Was unsere Kundinnen sagen", en: "What our clients say", ar: "ماذا تقول عميلاتنا", tr: "Müşterilerimiz ne diyor" },
"cart.title": { fr: "Mon Panier", de: "Warenkorb", en: "My Cart", ar: "سلة التسوق", tr: "Sepetim" },
"cart.empty": { fr: "Votre panier est vide", de: "Ihr Warenkorb ist leer", en: "Your cart is empty", ar: "سلة التسوق فارغة", tr: "Sepetiniz boş" },
"cart.total": { fr: "Total", de: "Gesamt", en: "Total", ar: "المجموع", tr: "Toplam" },
"cart.checkout": { fr: "Commander", de: "Bestellen", en: "Checkout", ar: "إتمام الشراء", tr: "Sipariş ver" },
"cart.add": { fr: "Ajouter au panier", de: "In den Warenkorb", en: "Add to cart", ar: "أضيفي إلى السلة", tr: "Sepete ekle" },
"product.similar": { fr: "Produits similaires", de: "Ähnliche Produkte", en: "Similar products", ar: "منتجات مشابهة", tr: "Benzer ürünler" },
"product.color": { fr: "Couleur", de: "Farbe", en: "Color", ar: "اللون", tr: "Renk" },
"product.length": { fr: "Longueur", de: "Länge", en: "Length", ar: "الطول", tr: "Uzunluk" },
"product.features": { fr: "Caractéristiques", de: "Eigenschaften", en: "Features", ar: "المميزات", tr: "Özellikler" },
"shop.title": { fr: "Notre Boutique", de: "Unser Shop", en: "Our Shop", ar: "متجرنا", tr: "Mağazamız" },
"shop.filter.all": { fr: "Tous", de: "Alle", en: "All", ar: "الكل", tr: "Tümü" },
"auth.login": { fr: "Connexion", de: "Anmelden", en: "Login", ar: "تسجيل الدخول", tr: "Giriş" },
"auth.register": { fr: "Inscription", de: "Registrieren", en: "Sign Up", ar: "إنشاء حساب", tr: "Kayıt ol" },
"auth.email": { fr: "Adresse email", de: "E-Mail-Adresse", en: "Email address", ar: "البريد الإلكتروني", tr: "E-posta adresi" },
"auth.password": { fr: "Mot de passe", de: "Passwort", en: "Password", ar: "كلمة المرور", tr: "Şifre" },
"auth.name": { fr: "Nom complet", de: "Vollständiger Name", en: "Full name", ar: "الاسم الكامل", tr: "Tam ad" },
"about.title": { fr: "Notre Histoire", de: "Unsere Geschichte", en: "Our Story", ar: "قصتنا", tr: "Hikayemiz" },
"contact.title": { fr: "Contactez-nous", de: "Kontaktieren Sie uns", en: "Contact us", ar: "اتصلي بنا", tr: "Bize ulaşın" },
"contact.send": { fr: "Envoyer", de: "Senden", en: "Send", ar: "إرسال", tr: "Gönder" },
"contact.message": { fr: "Votre message", de: "Ihre Nachricht", en: "Your message", ar: "رسالتك", tr: "Mesajınız" },
"footer.rights": { fr: "Tous droits réservés", de: "Alle Rechte vorbehalten", en: "All rights reserved", ar: "جميع الحقوق محفوظة", tr: "Tüm hakları saklıdır" },
"booking.select_service": { fr: "Choisir un service", de: "Service wählen", en: "Select service", ar: "اختاري الخدمة", tr: "Hizmet seçin" },
"booking.select_date": { fr: "Choisir une date", de: "Datum wählen", en: "Select date", ar: "اختاري التاريخ", tr: "Tarih seçin" },
"booking.select_time": { fr: "Choisir un créneau", de: "Zeitfenster wählen", en: "Select time", ar: "اختاري الوقت", tr: "Saat seçin" },
"booking.confirm": { fr: "Confirmer la réservation", de: "Buchung bestätigen", en: "Confirm booking", ar: "تأكيد الحجز", tr: "Rezervasyonu onayla" },
"booking.phone": { fr: "Téléphone", de: "Telefon", en: "Phone", ar: "الهاتف", tr: "Telefon" },
"booking.free": { fr: "Gratuit", de: "Kostenlos", en: "Free", ar: "مجاني", tr: "Ücretsiz" },
};
const LanguageContext = createContext<LanguageContextType | undefined>(undefined);
export const LanguageProvider = ({ children }: { children: ReactNode }) => {
const [language, setLanguage] = useState<Language>("fr");
const t = (key: string): string => {
return translations[key]?.[language] || key;
};
const isRTL = language === "ar";
return (
<LanguageContext.Provider value={{ language, setLanguage, t, isRTL }}>
<div dir={isRTL ? "rtl" : "ltr"}>{children}</div>
</LanguageContext.Provider>
);
};
export const useLanguage = () => {
const context = useContext(LanguageContext);
if (!context) throw new Error("useLanguage must be used within LanguageProvider");
return context;
};

147
data/products.ts Normal file
View File

@@ -0,0 +1,147 @@
export interface Product {
id: string;
name: string;
category: "clip-in" | "tape-in" | "ponytail" | "keratin";
price: number;
originalPrice?: number;
image: string;
images: string[];
colors: string[];
lengths: string[];
description: string;
features: string[];
isNew?: boolean;
isBestseller?: boolean;
rating: number;
reviewCount: number;
}
export const products: Product[] = [
{
id: "1",
name: "Extensions Clip-In Luxe",
category: "clip-in",
price: 189,
originalPrice: 229,
image: "https://images.unsplash.com/photo-1522337360788-8b13dee7a37e?w=600&h=800&fit=crop",
images: [
"https://images.unsplash.com/photo-1522337360788-8b13dee7a37e?w=600&h=800&fit=crop",
"https://images.unsplash.com/photo-1519699047748-de8e457a634e?w=600&h=800&fit=crop",
"https://images.unsplash.com/photo-1580618672591-eb180b1a973f?w=600&h=800&fit=crop",
],
colors: ["Brun Naturel", "Blond Miel", "Noir Ébène", "Châtain Doré"],
lengths: ["40 cm", "50 cm", "60 cm"],
description: "Nos extensions clip-in luxe en cheveux 100% naturels Remy. Pose facile et rapide, résultat invisible et naturel.",
features: ["100% cheveux Remy", "7 pièces par set", "Double épaisseur", "Clips silicone anti-glisse"],
isNew: false,
isBestseller: true,
rating: 4.8,
reviewCount: 124,
},
{
id: "2",
name: "Extensions Tape-In Soyeuses",
category: "tape-in",
price: 259,
image: "https://images.unsplash.com/photo-1519699047748-de8e457a634e?w=600&h=800&fit=crop",
images: [
"https://images.unsplash.com/photo-1519699047748-de8e457a634e?w=600&h=800&fit=crop",
"https://images.unsplash.com/photo-1522337360788-8b13dee7a37e?w=600&h=800&fit=crop",
],
colors: ["Blond Platine", "Brun Chocolat", "Noir Naturel", "Ombré"],
lengths: ["45 cm", "55 cm", "65 cm"],
description: "Extensions tape-in ultra-discrètes pour un résultat professionnel longue durée. Adhésif médical hypoallergénique.",
features: ["Adhésif médical", "Réutilisable 2-3 fois", "Pose professionnelle", "Invisible au toucher"],
isNew: true,
isBestseller: false,
rating: 4.9,
reviewCount: 87,
},
{
id: "3",
name: "Queue de Cheval Volumineuse",
category: "ponytail",
price: 129,
image: "https://images.unsplash.com/photo-1580618672591-eb180b1a973f?w=600&h=800&fit=crop",
images: [
"https://images.unsplash.com/photo-1580618672591-eb180b1a973f?w=600&h=800&fit=crop",
"https://images.unsplash.com/photo-1522337360788-8b13dee7a37e?w=600&h=800&fit=crop",
],
colors: ["Brun Naturel", "Blond Cendré", "Noir"],
lengths: ["50 cm", "60 cm", "70 cm"],
description: "Queue de cheval wrap-around pour un look glamour en quelques secondes. Volume et longueur instantanés.",
features: ["Fixation wrap-around", "Peignes intégrés", "Volume XXL", "Style naturel"],
isNew: false,
isBestseller: true,
rating: 4.7,
reviewCount: 56,
},
{
id: "4",
name: "Extensions Kératine Premium",
category: "keratin",
price: 349,
image: "https://images.unsplash.com/photo-1492106087820-71f1a00d2b11?w=600&h=800&fit=crop",
images: [
"https://images.unsplash.com/photo-1492106087820-71f1a00d2b11?w=600&h=800&fit=crop",
"https://images.unsplash.com/photo-1519699047748-de8e457a634e?w=600&h=800&fit=crop",
],
colors: ["Blond Miel", "Châtain", "Brun Foncé", "Noir Ébène", "Ombré Naturel"],
lengths: ["50 cm", "60 cm", "70 cm"],
description: "La solution permanente pour des cheveux longs et naturels. Pose par un professionnel, durée 3-6 mois.",
features: ["Kératine italienne", "Durée 3-6 mois", "100% invisible", "Cheveux vierges grade A"],
isNew: true,
isBestseller: false,
rating: 4.9,
reviewCount: 42,
},
{
id: "5",
name: "Clip-In Express Naturel",
category: "clip-in",
price: 139,
image: "https://images.unsplash.com/photo-1605980776721-6a4e89bab218?w=600&h=800&fit=crop",
images: [
"https://images.unsplash.com/photo-1605980776721-6a4e89bab218?w=600&h=800&fit=crop",
],
colors: ["Blond Naturel", "Châtain Clair", "Brun"],
lengths: ["35 cm", "45 cm"],
description: "Set de 5 pièces clip-in pour un volume naturel au quotidien. Parfait pour les débutantes.",
features: ["5 pièces", "Cheveux Remy", "Ultra-léger", "Idéal débutantes"],
isNew: false,
isBestseller: false,
rating: 4.6,
reviewCount: 93,
},
{
id: "6",
name: "Tape-In Ombré Collection",
category: "tape-in",
price: 289,
image: "https://images.unsplash.com/photo-1500917293891-ef795e70e1f6?w=600&h=800&fit=crop",
images: [
"https://images.unsplash.com/photo-1500917293891-ef795e70e1f6?w=600&h=800&fit=crop",
],
colors: ["Ombré Blond", "Ombré Châtain", "Ombré Caramel"],
lengths: ["50 cm", "60 cm"],
description: "Collection spéciale ombré pour un dégradé naturel et tendance. Effet balayage garanti.",
features: ["Effet ombré naturel", "Double drawn", "Qualité salon", "Tendance 2024"],
isNew: true,
isBestseller: true,
rating: 4.8,
reviewCount: 67,
},
];
export const categories = [
{ id: "clip-in", name: "Clip-In", description: "Pose en 5 minutes", image: "https://images.unsplash.com/photo-1522337360788-8b13dee7a37e?w=400&h=500&fit=crop" },
{ id: "tape-in", name: "Tape-In", description: "Résultat professionnel", image: "https://images.unsplash.com/photo-1519699047748-de8e457a634e?w=400&h=500&fit=crop" },
{ id: "ponytail", name: "Ponytail", description: "Glamour instantané", image: "https://images.unsplash.com/photo-1580618672591-eb180b1a973f?w=400&h=500&fit=crop" },
{ id: "keratin", name: "Kératine", description: "Solution longue durée", image: "https://images.unsplash.com/photo-1492106087820-71f1a00d2b11?w=400&h=500&fit=crop" },
];
export const reviews = [
{ id: "1", name: "Marie L.", rating: 5, text: "Qualité incroyable ! Mes extensions clip-in sont indétectables. Je les porte tous les jours depuis 6 mois.", date: "2024-02-15" },
{ id: "2", name: "Sophie K.", rating: 5, text: "La pose kératine est parfaite. Je ne peux plus m'en passer ! Service client au top.", date: "2024-01-28" },
{ id: "3", name: "Amira B.", rating: 4, text: "Très satisfaite de ma queue de cheval. Livraison rapide et produit conforme à la description.", date: "2024-03-05" },
];

36
data/services.ts Normal file
View File

@@ -0,0 +1,36 @@
export interface Service {
id: string;
name: string;
description: string;
duration: string;
price: number;
}
export const services: Service[] = [
{ id: "pose-complete", name: "Pose complète", description: "Pose d'extensions complète avec conseil couleur personnalisé", duration: "2h - 3h", price: 150 },
{ id: "conseil", name: "Conseil personnalisé", description: "Consultation pour choisir le type et la couleur d'extensions idéales", duration: "30 min", price: 0 },
{ id: "retouche", name: "Retouche", description: "Remontage et entretien de vos extensions existantes", duration: "1h - 1h30", price: 80 },
{ id: "depose", name: "Dépose", description: "Retrait soigneux de vos extensions", duration: "45 min - 1h", price: 50 },
];
export interface TimeSlot {
time: string;
available: boolean;
}
export const generateTimeSlots = (): TimeSlot[] => [
{ time: "09:00", available: true },
{ time: "09:30", available: false },
{ time: "10:00", available: true },
{ time: "10:30", available: true },
{ time: "11:00", available: false },
{ time: "11:30", available: true },
{ time: "13:00", available: true },
{ time: "13:30", available: true },
{ time: "14:00", available: false },
{ time: "14:30", available: true },
{ time: "15:00", available: true },
{ time: "15:30", available: false },
{ time: "16:00", available: true },
{ time: "16:30", available: true },
];

19
hooks/use-mobile.ts Normal file
View File

@@ -0,0 +1,19 @@
import * as React from "react"
const MOBILE_BREAKPOINT = 768
export function useIsMobile() {
const [isMobile, setIsMobile] = React.useState<boolean | undefined>(undefined)
React.useEffect(() => {
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`)
const onChange = () => {
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
}
mql.addEventListener("change", onChange)
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
return () => mql.removeEventListener("change", onChange)
}, [])
return !!isMobile
}

6
lib/utils.ts Normal file
View File

@@ -0,0 +1,6 @@
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}

6169
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -9,18 +9,32 @@
"lint": "eslint"
},
"dependencies": {
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"date-fns": "^4.1.0",
"lucide-react": "^1.14.0",
"next": "16.2.4",
"next-themes": "^0.4.6",
"radix-ui": "^1.4.3",
"react": "19.2.4",
"react-dom": "19.2.4"
"react-day-picker": "^9.14.0",
"react-dom": "19.2.4",
"shadcn": "^4.6.0",
"sonner": "^2.0.7",
"tailwind-merge": "^3.5.0",
"tw-animate-css": "^1.4.0"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"autoprefixer": "^10.5.0",
"eslint": "^9",
"eslint-config-next": "16.2.4",
"tailwindcss": "^4",
"postcss": "^8.5.14",
"tailwindcss": "^4.2.4",
"typescript": "^5"
}
}