Update May 12 by Elvis

This commit is contained in:
belviskhoremk
2026-05-12 00:28:37 +00:00
parent b32a70cd0e
commit c4450c993b
37 changed files with 3749 additions and 600 deletions

View File

@@ -25,15 +25,9 @@ export default function About() {
{/* 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>
<p className="text-lg text-muted-foreground leading-relaxed mb-6">{t("about.p1")}</p>
<p className="text-lg text-muted-foreground leading-relaxed mb-6">{t("about.p2")}</p>
<p className="text-lg text-muted-foreground leading-relaxed">{t("about.p3")}</p>
</section>
{/* Values */}
@@ -42,18 +36,18 @@ export default function About() {
<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>
<h3 className="font-serif text-lg mb-2">{t("about.value1_title")}</h3>
<p className="text-sm text-muted-foreground">{t("about.value1_desc")}</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>
<h3 className="font-serif text-lg mb-2">{t("about.value2_title")}</h3>
<p className="text-sm text-muted-foreground">{t("about.value2_desc")}</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>
<h3 className="font-serif text-lg mb-2">{t("about.value3_title")}</h3>
<p className="text-sm text-muted-foreground">{t("about.value3_desc")}</p>
</div>
</div>
</div>

153
app/admin/clients/page.tsx Normal file
View File

@@ -0,0 +1,153 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import { Card } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
Table, TableBody, TableCell, TableHead, TableHeader, TableRow,
} from "@/components/ui/table";
import { Search, Ban, CheckCircle, RefreshCw } from "lucide-react";
import { toast } from "sonner";
import { adminListCustomers, adminBlockCustomer, CustomerApi } from "@/lib/api/customers";
import { useLanguage } from "@/contexts/LanguageContext";
import { ApiError } from "@/lib/api";
export default function AdminClients() {
const { t, locale } = useLanguage();
const [customers, setCustomers] = useState<CustomerApi[]>([]);
const [loading, setLoading] = useState(true);
const [search, setSearch] = useState("");
const [query, setQuery] = useState("");
const [updating, setUpdating] = useState<string | null>(null);
const load = useCallback(async () => {
setLoading(true);
try {
const res = await adminListCustomers(query || undefined);
setCustomers(res.data);
} catch (err) {
toast.error(err instanceof ApiError ? err.message : t("admin.error"));
} finally {
setLoading(false);
}
}, [query]);
useEffect(() => { load(); }, [load]);
const handleSearch = (e: React.FormEvent) => {
e.preventDefault();
setQuery(search);
};
const toggleBlock = async (c: CustomerApi) => {
setUpdating(c.id);
try {
const updated = await adminBlockCustomer(c.id, !c.is_blocked);
setCustomers((prev) => prev.map((x) => (x.id === c.id ? { ...x, is_blocked: updated.is_blocked } : x)));
toast.success(updated.is_blocked ? t("admin.customers.blocked") : t("admin.customers.unblocked"));
} catch (err) {
toast.error(err instanceof ApiError ? err.message : t("admin.error"));
} finally {
setUpdating(null);
}
};
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h2 className="font-serif text-2xl font-semibold">{t("admin.customers.title")}</h2>
<p className="text-sm text-muted-foreground mt-1">{customers.length} {t("admin.customers.subtitle")}</p>
</div>
<Button variant="outline" size="sm" onClick={load} disabled={loading}>
<RefreshCw className={`h-4 w-4 mr-2 ${loading ? "animate-spin" : ""}`} />
{t("admin.refresh")}
</Button>
</div>
<form onSubmit={handleSearch} className="flex gap-2 max-w-sm">
<Input
placeholder={t("admin.customers.search_ph")}
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
<Button type="submit" variant="outline" size="icon">
<Search className="h-4 w-4" />
</Button>
</form>
<Card>
{loading ? (
<div className="p-8 space-y-3">
{Array.from({ length: 5 }).map((_, i) => (
<div key={i} className="h-12 bg-muted animate-pulse rounded" />
))}
</div>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>{t("admin.customers.col_name")}</TableHead>
<TableHead>{t("admin.customers.col_email")}</TableHead>
<TableHead>{t("admin.customers.col_phone")}</TableHead>
<TableHead className="text-center">{t("admin.customers.col_orders")}</TableHead>
<TableHead className="text-center">{t("admin.customers.col_bookings")}</TableHead>
<TableHead className="text-right">{t("admin.customers.col_spent")}</TableHead>
<TableHead>{t("admin.customers.col_joined")}</TableHead>
<TableHead>{t("admin.status")}</TableHead>
<TableHead />
</TableRow>
</TableHeader>
<TableBody>
{customers.length === 0 ? (
<TableRow>
<TableCell colSpan={9} className="text-center text-muted-foreground py-8">
{t("admin.customers.none")}
</TableCell>
</TableRow>
) : (
customers.map((c) => (
<TableRow key={c.id}>
<TableCell className="font-medium">{c.full_name ?? "—"}</TableCell>
<TableCell className="text-sm text-muted-foreground">{c.email}</TableCell>
<TableCell className="text-sm text-muted-foreground">{c.phone ?? "—"}</TableCell>
<TableCell className="text-center text-sm">{c.orders_count}</TableCell>
<TableCell className="text-center text-sm">{c.bookings_count}</TableCell>
<TableCell className="text-right font-semibold">{Number(c.total_spent).toFixed(2)} </TableCell>
<TableCell className="text-sm text-muted-foreground">
{new Date(c.created_at).toLocaleDateString(locale, { day: "2-digit", month: "short", year: "numeric" })}
</TableCell>
<TableCell>
{c.is_blocked ? (
<Badge variant="destructive">{t("admin.status.blocked")}</Badge>
) : (
<Badge variant="secondary">{t("admin.status.active")}</Badge>
)}
</TableCell>
<TableCell>
<Button
variant="ghost"
size="icon"
disabled={updating === c.id}
onClick={() => toggleBlock(c)}
title={c.is_blocked ? t("admin.status.active") : t("admin.status.blocked")}
>
{c.is_blocked ? (
<CheckCircle className="h-4 w-4 text-primary" />
) : (
<Ban className="h-4 w-4 text-muted-foreground" />
)}
</Button>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
)}
</Card>
</div>
);
}

View File

@@ -0,0 +1,174 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import { Card } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Table, TableBody, TableCell, TableHead, TableHeader, TableRow,
} from "@/components/ui/table";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import {
DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { ChevronDown, RefreshCw } from "lucide-react";
import { toast } from "sonner";
import { adminListOrders, adminUpdateOrderStatus, AdminOrderApi } from "@/lib/api/orders";
import { useLanguage } from "@/contexts/LanguageContext";
import { ApiError } from "@/lib/api";
type Status = "all" | "pending" | "paid" | "shipped" | "delivered" | "cancelled" | "refunded";
const NEXT_STATUSES: Record<string, string[]> = {
pending: ["paid", "cancelled"],
paid: ["shipped", "cancelled"],
shipped: ["delivered"],
delivered: [],
cancelled: [],
refunded: [],
};
export default function AdminCommandes() {
const { t, locale } = useLanguage();
const [orders, setOrders] = useState<AdminOrderApi[]>([]);
const [loading, setLoading] = useState(true);
const [filter, setFilter] = useState<Status>("all");
const [updating, setUpdating] = useState<string | null>(null);
const STATUS_CONFIG: Record<string, { label: string; variant: "default" | "secondary" | "destructive" | "outline" }> = {
pending: { label: t("admin.status.pending"), variant: "secondary" },
paid: { label: t("admin.status.paid"), variant: "default" },
shipped: { label: t("admin.status.shipped"), variant: "outline" },
delivered: { label: t("admin.status.delivered"), variant: "default" },
cancelled: { label: t("admin.status.cancelled"), variant: "destructive" },
refunded: { label: t("admin.status.refunded"), variant: "destructive" },
};
const load = useCallback(async () => {
setLoading(true);
try {
const res = await adminListOrders(filter === "all" ? undefined : filter);
setOrders(res.data);
} catch (err) {
toast.error(err instanceof ApiError ? err.message : t("admin.error"));
} finally {
setLoading(false);
}
}, [filter]);
useEffect(() => { load(); }, [load]);
const handleStatus = async (id: string, status: string) => {
setUpdating(id);
try {
await adminUpdateOrderStatus(id, status);
setOrders((prev) => prev.map((o) => (o.id === id ? { ...o, status } : o)));
toast.success(t("admin.settings.saved"));
} catch (err) {
toast.error(err instanceof ApiError ? err.message : t("admin.error"));
} finally {
setUpdating(null);
}
};
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h2 className="font-serif text-2xl font-semibold">{t("admin.orders.title")}</h2>
<p className="text-sm text-muted-foreground mt-1">{orders.length} {t("admin.orders.subtitle")}</p>
</div>
<Button variant="outline" size="sm" onClick={load} disabled={loading}>
<RefreshCw className={`h-4 w-4 mr-2 ${loading ? "animate-spin" : ""}`} />
{t("admin.refresh")}
</Button>
</div>
<Tabs value={filter} onValueChange={(v) => setFilter(v as Status)}>
<TabsList className="flex-wrap">
<TabsTrigger value="all">{t("admin.orders.tab_all")}</TabsTrigger>
<TabsTrigger value="pending">{t("admin.orders.tab_pending")}</TabsTrigger>
<TabsTrigger value="paid">{t("admin.orders.tab_paid")}</TabsTrigger>
<TabsTrigger value="shipped">{t("admin.orders.tab_shipped")}</TabsTrigger>
<TabsTrigger value="delivered">{t("admin.orders.tab_delivered")}</TabsTrigger>
<TabsTrigger value="cancelled">{t("admin.orders.tab_cancelled")}</TabsTrigger>
</TabsList>
</Tabs>
<Card>
{loading ? (
<div className="p-8 space-y-3">
{Array.from({ length: 5 }).map((_, i) => (
<div key={i} className="h-12 bg-muted animate-pulse rounded" />
))}
</div>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>{t("admin.orders.col_ref")}</TableHead>
<TableHead>{t("admin.orders.col_customer")}</TableHead>
<TableHead>{t("admin.orders.col_date")}</TableHead>
<TableHead>{t("admin.orders.col_total")}</TableHead>
<TableHead>{t("admin.status")}</TableHead>
<TableHead className="text-right">{t("admin.actions")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{orders.length === 0 ? (
<TableRow>
<TableCell colSpan={6} className="text-center text-muted-foreground py-8">
{t("admin.orders.none")}
</TableCell>
</TableRow>
) : (
orders.map((o) => {
const cfg = STATUS_CONFIG[o.status] ?? { label: o.status, variant: "outline" as const };
const nextStatuses = NEXT_STATUSES[o.status] ?? [];
return (
<TableRow key={o.id}>
<TableCell className="font-mono text-xs text-muted-foreground">
#{o.id.slice(0, 8).toUpperCase()}
</TableCell>
<TableCell>
<div className="font-medium text-sm">{o.client_name ?? "—"}</div>
<div className="text-xs text-muted-foreground">{o.client_email ?? ""}</div>
</TableCell>
<TableCell className="text-sm">
{new Date(o.created_at).toLocaleDateString(locale, { day: "2-digit", month: "short", year: "numeric" })}
</TableCell>
<TableCell className="font-semibold">{o.total_amount.toFixed(2)} </TableCell>
<TableCell>
<Badge variant={cfg.variant}>{cfg.label}</Badge>
</TableCell>
<TableCell className="text-right">
{nextStatuses.length > 0 ? (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm" disabled={updating === o.id}>
{t("admin.orders.update_btn")} <ChevronDown className="h-3 w-3 ml-1" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{nextStatuses.map((s) => (
<DropdownMenuItem key={s} onClick={() => handleStatus(o.id, s)}>
{STATUS_CONFIG[s]?.label ?? s}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
) : (
<span className="text-xs text-muted-foreground"></span>
)}
</TableCell>
</TableRow>
);
})
)}
</TableBody>
</Table>
)}
</Card>
</div>
);
}

View File

@@ -2,29 +2,26 @@
import { SidebarProvider, SidebarTrigger } from "@/components/ui/sidebar";
import { AdminSidebar } from "@/components/admin/AdminSidebar";
import { useAdmin } from "@/contexts/AdminContext";
import { useAuth } from "@/contexts/AuthContext";
import { useLanguage } from "@/contexts/LanguageContext";
import { getToken } from "@/lib/api";
import { usePathname, useRouter } from "next/navigation";
import { useEffect } from "react";
import LanguageSwitcher from "@/components/LanguageSwitcher";
export default function AdminLayout({ children }: { children: React.ReactNode }) {
const { isAdmin } = useAdmin();
const { isAdmin, isLoading } = useAuth();
const { t } = useLanguage();
const router = useRouter();
const pathname = usePathname();
const isLoginRoute = pathname === "/admin/login";
const hasToken = typeof window !== "undefined" && !!getToken();
// 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>
// );
// }
useEffect(() => {
if (!isLoading && !isAdmin && !isLoginRoute) {
router.push("/admin/login");
}
}, [isAdmin, isLoading, isLoginRoute, router]);
if (isLoginRoute) {
return (
@@ -36,17 +33,33 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
);
}
// Still loading auth state, or token exists but isAdmin not yet committed (post-login race)
if (isLoading || (!isAdmin && hasToken)) {
return (
<div className="min-h-screen flex items-center justify-center bg-muted/30">
<div className="text-sm text-muted-foreground">{t("admin.loading")}</div>
</div>
);
}
if (!isAdmin) {
return null;
}
// 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">
<header className="h-14 flex items-center justify-between border-b border-border bg-background px-4 sticky top-0 z-10">
<div className="flex items-center">
<SidebarTrigger />
<h1 className="ml-4 text-sm font-medium text-muted-foreground">
Tableau de bord
{t("admin.header")}
</h1>
</div>
<LanguageSwitcher />
</header>
<main className="flex-1 p-6">{children}</main>
</div>

View File

@@ -2,33 +2,39 @@
import { useState } from "react";
import { Lock } from "lucide-react";
import { useAdmin } from "@/contexts/AdminContext";
import { useAuth } from "@/contexts/AuthContext";
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 { ApiError } from "@/lib/api";
import { toast } from "sonner";
import { useRouter } from "next/navigation";
export default function AdminLogin() {
const { login, isAdmin } = useAdmin();
const route = useRouter();
const { login } = useAuth();
const router = useRouter();
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [loading, setLoading] = useState(false);
const handleSubmit = (e: React.FormEvent) => {
const handleSubmit = async (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");
try {
const profile = await login(email, password);
if (profile.role !== "admin") {
toast.error("Accès refusé : rôle administrateur requis");
return;
}
toast.success("Connexion réussie");
router.push("/admin");
} catch (err) {
const msg = err instanceof ApiError ? err.message : "Identifiants incorrects";
toast.error(msg);
} finally {
setLoading(false);
}
}, 300);
};
return (
@@ -43,6 +49,17 @@ export default function AdminLogin() {
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="email">Email</Label>
<Input
id="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
autoFocus
/>
</div>
<div className="space-y-2">
<Label htmlFor="password">Mot de passe</Label>
<Input
@@ -52,9 +69,7 @@ export default function AdminLogin() {
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"}
@@ -64,4 +79,4 @@ export default function AdminLogin() {
</Card>
</div>
);
};
}

View File

@@ -1,38 +1,61 @@
"use client";
import { Package, CalendarCheck, Clock, TrendingUp } from "lucide-react";
import { useEffect, useState } from "react";
import { Package, CalendarCheck, Clock, TrendingUp, ShoppingBag, Users, AlertTriangle, Euro } from "lucide-react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { useAdmin } from "@/contexts/AdminContext";
import { useLanguage } from "@/contexts/LanguageContext";
import { getDashboardStats, DashboardStats } from "@/lib/api/admin";
export default function AdminOverview() {
const { products, reservations } = useAdmin();
const { reservations } = useAdmin();
const { t, locale } = useLanguage();
const [stats, setStats] = useState<DashboardStats | null>(null);
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" },
];
useEffect(() => {
getDashboardStats().then(setStats).catch((e) => console.error("[admin] getDashboardStats failed:", e));
}, []);
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>
const mainCards = stats
? [
{ label: t("admin.overview.orders_pending"), value: stats.orders_pending, icon: ShoppingBag, color: "text-orange-600", bg: "bg-orange-100" },
{ label: t("admin.overview.bookings_pending"), value: stats.bookings_pending, icon: Clock, color: "text-amber-600", bg: "bg-amber-100" },
{ label: t("admin.overview.bookings_confirmed"),value: stats.bookings_confirmed,icon: CalendarCheck,color: "text-green-600", bg: "bg-green-100" },
{ label: t("admin.overview.products_count"), value: stats.products_count, icon: Package, color: "text-blue-600", bg: "bg-blue-100" },
]
: [];
const revenueCards = stats
? [
{ label: t("admin.overview.revenue_today"), value: `${stats.revenue_today.toFixed(2)}`, icon: Euro, color: "text-emerald-600", bg: "bg-emerald-100" },
{ label: t("admin.overview.revenue_week"), value: `${stats.revenue_week.toFixed(2)}`, icon: TrendingUp, color: "text-emerald-600", bg: "bg-emerald-100" },
{ label: t("admin.overview.revenue_month"), value: `${stats.revenue_month.toFixed(2)}`, icon: TrendingUp, color: "text-primary", bg: "bg-primary/10" },
{ label: t("admin.overview.new_customers"), value: stats.new_customers_month, icon: Users, color: "text-purple-600", bg: "bg-purple-100" },
]
: [];
const Skeleton = () => (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
{stats.map((s) => (
{Array.from({ length: 4 }).map((_, i) => (
<Card key={i}>
<CardContent className="p-5">
<div className="h-4 bg-muted animate-pulse rounded w-2/3 mb-3" />
<div className="h-7 bg-muted animate-pulse rounded w-1/3" />
</CardContent>
</Card>
))}
</div>
);
const StatGrid = ({ cards }: { cards: typeof mainCards }) => (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
{cards.map((s) => (
<Card key={s.label}>
<CardContent className="p-5 flex items-center justify-between">
<div>
@@ -46,14 +69,43 @@ export default function AdminOverview() {
</Card>
))}
</div>
);
return (
<div className="space-y-8">
<div>
<h2 className="font-serif text-2xl font-semibold">{t("admin.overview.title")}</h2>
<p className="text-sm text-muted-foreground mt-1">{t("admin.overview.subtitle")}</p>
</div>
<div className="space-y-2">
<h3 className="text-sm font-medium text-muted-foreground uppercase tracking-wide">{t("admin.overview.activity_section")}</h3>
{stats === null ? <Skeleton /> : <StatGrid cards={mainCards} />}
</div>
<div className="space-y-2">
<h3 className="text-sm font-medium text-muted-foreground uppercase tracking-wide">{t("admin.overview.revenue_section")}</h3>
{stats === null ? <Skeleton /> : <StatGrid cards={revenueCards} />}
</div>
{stats && stats.low_stock_count > 0 && (
<Card className="border-amber-200 bg-amber-50">
<CardContent className="p-4 flex items-center gap-3">
<AlertTriangle className="h-5 w-5 text-amber-600 flex-shrink-0" />
<p className="text-sm text-amber-800">
{t("admin.overview.low_stock", { n: stats.low_stock_count })}
</p>
</CardContent>
</Card>
)}
<Card>
<CardHeader>
<CardTitle className="text-base">Prochains rendez-vous</CardTitle>
<CardTitle className="text-base">{t("admin.overview.upcoming_title")}</CardTitle>
</CardHeader>
<CardContent>
{upcoming.length === 0 ? (
<p className="text-sm text-muted-foreground">Aucun rendez-vous à venir.</p>
<p className="text-sm text-muted-foreground">{t("admin.overview.no_upcoming")}</p>
) : (
<div className="space-y-3">
{upcoming.map((r) => (
@@ -63,9 +115,11 @@ export default function AdminOverview() {
<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>
<p className="text-sm font-medium">
{new Date(r.date).toLocaleDateString(locale, { 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"}
{r.status === "confirmed" ? t("admin.status.confirmed") : t("admin.status.pending")}
</Badge>
</div>
</div>
@@ -76,4 +130,4 @@ export default function AdminOverview() {
</Card>
</div>
);
};
}

View File

@@ -0,0 +1,143 @@
"use client";
import { useState, useEffect } from "react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { toast } from "sonner";
import { adminGetSettings, adminUpdateSetting, StoreSetting } from "@/lib/api/settings";
import { useLanguage } from "@/contexts/LanguageContext";
import { ApiError } from "@/lib/api";
import { Save } from "lucide-react";
export default function AdminParametres() {
const { t, locale } = useLanguage();
const [settings, setSettings] = useState<StoreSetting[]>([]);
const [values, setValues] = useState<Record<string, string>>({});
const [saving, setSaving] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const SETTING_META: Record<string, { label: string; description: string; type: "number" | "text" }> = {
default_booking_price: {
label: t("booking.free") === "Free" ? "Default booking price (€)" : t("booking.free") === "Kostenlos" ? "Standardpreis für Reservierungen (€)" : "Prix de réservation par défaut (€)",
description: t("booking.free") === "Free" ? "Price applied to each appointment (0 = free)" : t("booking.free") === "Kostenlos" ? "Preis pro Termin (0 = kostenlos)" : "Prix appliqué à chaque rendez-vous (0 = gratuit)",
type: "number",
},
};
useEffect(() => {
adminGetSettings()
.then((rows) => {
setSettings(rows);
const init: Record<string, string> = {};
rows.forEach((r) => {
init[r.key] = r.value !== null && r.value !== undefined ? String(r.value) : "";
});
setValues(init);
})
.catch((e) => toast.error(e instanceof ApiError ? e.message : t("admin.error")))
.finally(() => setLoading(false));
}, []);
const handleSave = async (key: string) => {
setSaving(key);
try {
const meta = SETTING_META[key];
const raw = values[key] ?? "";
const value = meta?.type === "number" ? parseFloat(raw) : raw;
await adminUpdateSetting(key, value);
toast.success(t("admin.settings.saved"));
} catch (err) {
toast.error(err instanceof ApiError ? err.message : t("admin.error"));
} finally {
setSaving(null);
}
};
const knownKeys = settings.filter((s) => SETTING_META[s.key]);
const unknownKeys = settings.filter((s) => !SETTING_META[s.key]);
return (
<div className="space-y-8 max-w-2xl">
<div>
<h2 className="font-serif text-2xl font-semibold">{t("admin.settings.title")}</h2>
<p className="text-sm text-muted-foreground mt-1">{t("admin.settings.subtitle")}</p>
</div>
{loading ? (
<div className="space-y-4">
{Array.from({ length: 3 }).map((_, i) => (
<div key={i} className="h-20 bg-muted animate-pulse rounded-lg" />
))}
</div>
) : (
<>
<Card>
<CardHeader>
<CardTitle className="text-base">{t("admin.settings.bookings")}</CardTitle>
</CardHeader>
<CardContent className="space-y-6">
{knownKeys.map((s) => {
const meta = SETTING_META[s.key];
return (
<div key={s.key}>
<Label htmlFor={s.key}>{meta.label}</Label>
<p className="text-xs text-muted-foreground mb-2">{meta.description}</p>
<div className="flex gap-2">
<Input
id={s.key}
type={meta.type}
value={values[s.key] ?? ""}
onChange={(e) => setValues((prev) => ({ ...prev, [s.key]: e.target.value }))}
className="max-w-[200px]"
/>
<Button size="sm" onClick={() => handleSave(s.key)} disabled={saving === s.key}>
<Save className="h-4 w-4 mr-2" />
{saving === s.key ? t("admin.saving") : t("admin.save")}
</Button>
</div>
{s.updated_at && (
<p className="text-xs text-muted-foreground mt-1">
{t("admin.settings.last_updated")} {new Date(s.updated_at).toLocaleDateString(locale)}
</p>
)}
</div>
);
})}
{knownKeys.length === 0 && (
<p className="text-sm text-muted-foreground">{t("admin.settings.no_settings")}</p>
)}
</CardContent>
</Card>
{unknownKeys.length > 0 && (
<Card>
<CardHeader>
<CardTitle className="text-base">{t("admin.settings.other")}</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{unknownKeys.map((s) => (
<div key={s.key}>
<Label htmlFor={`u-${s.key}`}>{s.key}</Label>
<div className="flex gap-2 mt-1">
<Input
id={`u-${s.key}`}
value={values[s.key] ?? ""}
onChange={(e) => setValues((prev) => ({ ...prev, [s.key]: e.target.value }))}
/>
<Button size="sm" onClick={() => handleSave(s.key)} disabled={saving === s.key}>
<Save className="h-4 w-4 mr-2" />
{saving === s.key ? t("admin.saving") : t("admin.save")}
</Button>
</div>
</div>
))}
</CardContent>
</Card>
)}
</>
)}
</div>
);
}

574
app/admin/planning/page.tsx Normal file
View File

@@ -0,0 +1,574 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Badge } from "@/components/ui/badge";
import { ChevronLeft, ChevronRight, Plus, Trash2, RefreshCw, Ban, Check } from "lucide-react";
import { toast } from "sonner";
import {
adminGetSchedule, adminCreateSchedule, adminDeleteSchedule,
adminListSlots, adminGenerateSlots, adminUpdateSlot, adminDeleteSlot,
adminGetBlockedDates, adminAddBlockedDate, adminRemoveBlockedDate,
WeeklySchedule, BlockedDate, TimeSlotApi,
} from "@/lib/api/bookings";
import { useLanguage } from "@/contexts/LanguageContext";
const DURATIONS = [30, 45, 60, 90, 120];
function toDateStr(d: Date): string {
return d.toISOString().slice(0, 10);
}
function monthStart(year: number, month: number) {
return new Date(year, month, 1);
}
function daysInMonth(year: number, month: number) {
return new Date(year, month + 1, 0).getDate();
}
// Monday-first weekday index (0=Mon, 6=Sun)
function weekdayMon(date: Date) {
return (date.getDay() + 6) % 7;
}
// ── Schedule Tab ──────────────────────────────────────────────────────────────
function ScheduleTab() {
const { t } = useLanguage();
const DAY_NAMES = Array.from({ length: 7 }, (_, i) => t(`admin.day.${i}`));
const [schedule, setSchedule] = useState<WeeklySchedule[]>([]);
const [loading, setLoading] = useState(true);
const [form, setForm] = useState({
day_of_week: 0,
start_time: "09:00",
end_time: "18:00",
slot_duration_minutes: 60,
});
const [saving, setSaving] = useState(false);
const [genFrom, setGenFrom] = useState(toDateStr(new Date()));
const [genTo, setGenTo] = useState(() => {
const d = new Date();
d.setDate(d.getDate() + 30);
return toDateStr(d);
});
const [generating, setGenerating] = useState(false);
useEffect(() => {
adminGetSchedule()
.then(setSchedule)
.catch(() => toast.error(t("admin.error")))
.finally(() => setLoading(false));
}, []);
const addEntry = async () => {
setSaving(true);
try {
const entry = await adminCreateSchedule({
day_of_week: Number(form.day_of_week),
start_time: form.start_time + ":00",
end_time: form.end_time + ":00",
slot_duration_minutes: Number(form.slot_duration_minutes),
});
setSchedule((prev) => [...prev, entry].sort((a, b) => a.day_of_week - b.day_of_week));
toast.success(t("admin.planning.schedule_added"));
} catch {
toast.error(t("admin.error"));
} finally {
setSaving(false);
}
};
const removeEntry = async (id: string) => {
try {
await adminDeleteSchedule(id);
setSchedule((prev) => prev.filter((e) => e.id !== id));
toast.success(t("admin.planning.schedule_deleted"));
} catch {
toast.error(t("admin.error"));
}
};
const generate = async () => {
setGenerating(true);
try {
const res = await adminGenerateSlots(genFrom, genTo);
toast.success(t("admin.planning.generated", { n: res.created }));
} catch {
toast.error(t("admin.error"));
} finally {
setGenerating(false);
}
};
const grouped = DAY_NAMES.map((name, idx) => ({
name,
entries: schedule.filter((e) => e.day_of_week === idx),
}));
return (
<div className="space-y-6">
<Card>
<CardHeader>
<CardTitle className="text-base">{t("admin.planning.weekly_title")}</CardTitle>
</CardHeader>
<CardContent>
{loading ? (
<div className="space-y-2">
{Array.from({ length: 3 }).map((_, i) => (
<div key={i} className="h-10 bg-muted animate-pulse rounded" />
))}
</div>
) : (
<div className="space-y-3">
{grouped.map(({ name, entries }) => (
<div key={name} className="flex items-start gap-3">
<span className="w-24 text-sm font-medium pt-1 shrink-0">{name}</span>
<div className="flex flex-wrap gap-2 flex-1">
{entries.length === 0 ? (
<span className="text-xs text-muted-foreground pt-1">{t("admin.planning.not_available")}</span>
) : (
entries.map((e) => (
<div key={e.id} className="flex items-center gap-1.5 bg-muted rounded px-2 py-1 text-xs">
<span>{e.start_time.slice(0, 5)} {e.end_time.slice(0, 5)}</span>
<span className="text-muted-foreground">({e.slot_duration_minutes} min)</span>
<button
onClick={() => removeEntry(e.id)}
className="text-muted-foreground hover:text-destructive ml-1"
>
<Trash2 className="h-3 w-3" />
</button>
</div>
))
)}
</div>
</div>
))}
</div>
)}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-base">{t("admin.planning.add_title")}</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
<div>
<Label className="text-xs">{t("admin.planning.day")}</Label>
<select
className="w-full mt-1 text-sm border border-input rounded-md px-3 py-2 bg-background"
value={form.day_of_week}
onChange={(e) => setForm((f) => ({ ...f, day_of_week: Number(e.target.value) }))}
>
{DAY_NAMES.map((d, i) => (
<option key={i} value={i}>{d}</option>
))}
</select>
</div>
<div>
<Label className="text-xs">{t("admin.planning.start")}</Label>
<Input
type="time"
className="mt-1"
value={form.start_time}
onChange={(e) => setForm((f) => ({ ...f, start_time: e.target.value }))}
/>
</div>
<div>
<Label className="text-xs">{t("admin.planning.end")}</Label>
<Input
type="time"
className="mt-1"
value={form.end_time}
onChange={(e) => setForm((f) => ({ ...f, end_time: e.target.value }))}
/>
</div>
<div>
<Label className="text-xs">{t("admin.planning.duration_min")}</Label>
<select
className="w-full mt-1 text-sm border border-input rounded-md px-3 py-2 bg-background"
value={form.slot_duration_minutes}
onChange={(e) => setForm((f) => ({ ...f, slot_duration_minutes: Number(e.target.value) }))}
>
{DURATIONS.map((d) => (
<option key={d} value={d}>{d} min</option>
))}
</select>
</div>
</div>
<Button onClick={addEntry} disabled={saving} className="mt-3" size="sm">
<Plus className="h-4 w-4 mr-1.5" />
{saving ? t("admin.planning.adding") : t("admin.add")}
</Button>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-base">{t("admin.planning.generate_title")}</CardTitle>
</CardHeader>
<CardContent>
<p className="text-xs text-muted-foreground mb-3">{t("admin.planning.generate_desc")}</p>
<div className="flex flex-wrap gap-3 items-end">
<div>
<Label className="text-xs">{t("admin.planning.from")}</Label>
<Input type="date" className="mt-1 w-40" value={genFrom}
onChange={(e) => setGenFrom(e.target.value)} />
</div>
<div>
<Label className="text-xs">{t("admin.planning.to")}</Label>
<Input type="date" className="mt-1 w-40" value={genTo}
onChange={(e) => setGenTo(e.target.value)} />
</div>
<Button onClick={generate} disabled={generating} size="sm">
<RefreshCw className={`h-4 w-4 mr-1.5 ${generating ? "animate-spin" : ""}`} />
{generating ? t("admin.planning.generating") : t("admin.planning.generate_btn")}
</Button>
</div>
</CardContent>
</Card>
</div>
);
}
// ── Calendar Tab ──────────────────────────────────────────────────────────────
function CalendarTab() {
const { t, locale } = useLanguage();
const DAY_NAMES = Array.from({ length: 7 }, (_, i) => t(`admin.day.${i}`));
const MONTH_NAMES = Array.from({ length: 12 }, (_, i) => t(`admin.month.${i}`));
const today = new Date();
const [year, setYear] = useState(today.getFullYear());
const [month, setMonth] = useState(today.getMonth());
const [slots, setSlots] = useState<TimeSlotApi[]>([]);
const [loadingSlots, setLoadingSlots] = useState(false);
const [selectedDay, setSelectedDay] = useState<string | null>(null);
const loadSlots = useCallback(() => {
setLoadingSlots(true);
const from = toDateStr(monthStart(year, month));
const last = new Date(year, month + 1, 0);
const to = toDateStr(last);
adminListSlots(from, to)
.then(setSlots)
.catch(() => toast.error(t("admin.error")))
.finally(() => setLoadingSlots(false));
}, [year, month]);
useEffect(() => { loadSlots(); }, [loadSlots]);
const prevMonth = () => {
if (month === 0) { setYear((y) => y - 1); setMonth(11); }
else setMonth((m) => m - 1);
setSelectedDay(null);
};
const nextMonth = () => {
if (month === 11) { setYear((y) => y + 1); setMonth(0); }
else setMonth((m) => m + 1);
setSelectedDay(null);
};
const blockSlot = async (slot: TimeSlotApi) => {
try {
const updated = await adminUpdateSlot(slot.id, !slot.is_blocked);
setSlots((prev) => prev.map((s) => s.id === slot.id ? updated : s));
toast.success(slot.is_blocked ? t("admin.planning.slot_unblocked") : t("admin.planning.slot_blocked"));
} catch {
toast.error(t("admin.error"));
}
};
const deleteSlot = async (id: string) => {
try {
await adminDeleteSlot(id);
setSlots((prev) => prev.filter((s) => s.id !== id));
toast.success(t("admin.planning.slot_deleted"));
} catch {
toast.error(t("admin.error"));
}
};
const firstDay = monthStart(year, month);
const totalDays = daysInMonth(year, month);
const startOffset = weekdayMon(firstDay);
const cells: (number | null)[] = [
...Array(startOffset).fill(null),
...Array.from({ length: totalDays }, (_, i) => i + 1),
];
while (cells.length % 7 !== 0) cells.push(null);
const slotsByDay: Record<string, TimeSlotApi[]> = {};
slots.forEach((s) => {
(slotsByDay[s.date] ||= []).push(s);
});
const selectedSlots = selectedDay ? (slotsByDay[selectedDay] ?? []) : [];
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<Button variant="outline" size="icon" onClick={prevMonth}><ChevronLeft className="h-4 w-4" /></Button>
<h3 className="font-medium">{MONTH_NAMES[month]} {year}</h3>
<Button variant="outline" size="icon" onClick={nextMonth}><ChevronRight className="h-4 w-4" /></Button>
</div>
<Card>
<CardContent className="p-3">
<div className="grid grid-cols-7 gap-px">
{DAY_NAMES.map((d) => (
<div key={d} className="text-center text-xs font-medium text-muted-foreground py-2">
{d.slice(0, 3)}
</div>
))}
{cells.map((day, i) => {
if (!day) return <div key={i} />;
const dateStr = `${year}-${String(month + 1).padStart(2, "0")}-${String(day).padStart(2, "0")}`;
const daySlots = slotsByDay[dateStr] ?? [];
const available = daySlots.filter((s) => !s.is_blocked && !s.is_booked).length;
const booked = daySlots.filter((s) => s.is_booked).length;
const blocked = daySlots.filter((s) => s.is_blocked).length;
const isToday = dateStr === toDateStr(today);
const isSelected = dateStr === selectedDay;
return (
<button
key={i}
onClick={() => setSelectedDay(isSelected ? null : dateStr)}
className={`rounded-lg p-1.5 text-left transition-colors min-h-[60px] ${
isSelected ? "bg-primary/10 ring-1 ring-primary" :
isToday ? "bg-muted ring-1 ring-muted-foreground/30" : "hover:bg-muted/50"
}`}
>
<div className={`text-xs font-medium mb-1 ${isToday ? "text-primary" : ""}`}>{day}</div>
{daySlots.length > 0 && (
<div className="space-y-0.5">
{available > 0 && <div className="text-[10px] text-green-600">{available} {t("admin.status.free").toLowerCase()}</div>}
{booked > 0 && <div className="text-[10px] text-blue-600">{booked} {t("admin.status.booked").toLowerCase()}</div>}
{blocked > 0 && <div className="text-[10px] text-red-500">{blocked} {t("admin.status.blocked").toLowerCase()}</div>}
</div>
)}
</button>
);
})}
</div>
</CardContent>
</Card>
{selectedDay && (
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm">
{t("admin.planning.slots_for")} {new Date(selectedDay + "T00:00:00").toLocaleDateString(locale, {
weekday: "long", day: "numeric", month: "long"
})}
</CardTitle>
</CardHeader>
<CardContent>
{loadingSlots ? (
<div className="text-sm text-muted-foreground">{t("admin.loading")}</div>
) : selectedSlots.length === 0 ? (
<p className="text-sm text-muted-foreground">{t("admin.planning.no_slots")}</p>
) : (
<div className="space-y-2">
{selectedSlots
.sort((a, b) => a.start_time.localeCompare(b.start_time))
.map((slot) => (
<div key={slot.id} className="flex items-center justify-between py-2 border-b border-border last:border-0">
<div className="flex items-center gap-2">
<span className="text-sm font-medium">{slot.start_time.slice(0, 5)} {slot.end_time.slice(0, 5)}</span>
{slot.is_booked && <Badge variant="secondary" className="text-xs">{t("admin.status.booked")}</Badge>}
{slot.is_blocked && <Badge variant="destructive" className="text-xs">{t("admin.status.blocked")}</Badge>}
{!slot.is_booked && !slot.is_blocked && <Badge variant="outline" className="text-xs text-green-600 border-green-300">{t("admin.status.free")}</Badge>}
</div>
{!slot.is_booked && (
<div className="flex gap-1">
<Button
variant="ghost" size="icon"
className="h-7 w-7"
title={slot.is_blocked ? t("admin.planning.unblock_title") : t("admin.planning.block_btn")}
onClick={() => blockSlot(slot)}
>
{slot.is_blocked
? <Check className="h-3.5 w-3.5 text-green-600" />
: <Ban className="h-3.5 w-3.5 text-amber-500" />
}
</Button>
<Button
variant="ghost" size="icon"
className="h-7 w-7"
title={t("admin.delete")}
onClick={() => deleteSlot(slot.id)}
>
<Trash2 className="h-3.5 w-3.5 text-destructive" />
</Button>
</div>
)}
</div>
))}
</div>
)}
</CardContent>
</Card>
)}
</div>
);
}
// ── Blocked Dates Tab ─────────────────────────────────────────────────────────
function BlockedDatesTab() {
const { t, locale } = useLanguage();
const [dates, setDates] = useState<BlockedDate[]>([]);
const [loading, setLoading] = useState(true);
const [newDate, setNewDate] = useState(toDateStr(new Date()));
const [newReason, setNewReason] = useState("");
const [adding, setAdding] = useState(false);
useEffect(() => {
adminGetBlockedDates()
.then(setDates)
.catch(() => toast.error(t("admin.error")))
.finally(() => setLoading(false));
}, []);
const add = async () => {
setAdding(true);
try {
const entry = await adminAddBlockedDate(newDate, newReason || undefined);
setDates((prev) => [...prev, entry].sort((a, b) => a.date.localeCompare(b.date)));
setNewReason("");
toast.success(t("admin.planning.date_blocked"));
} catch {
toast.error(t("admin.error"));
} finally {
setAdding(false);
}
};
const remove = async (id: string) => {
try {
await adminRemoveBlockedDate(id);
setDates((prev) => prev.filter((d) => d.id !== id));
toast.success(t("admin.planning.date_unblocked"));
} catch {
toast.error(t("admin.error"));
}
};
return (
<div className="space-y-4">
<Card>
<CardHeader>
<CardTitle className="text-base">{t("admin.planning.block_title")}</CardTitle>
</CardHeader>
<CardContent>
<p className="text-xs text-muted-foreground mb-3">{t("admin.planning.block_desc")}</p>
<div className="flex flex-wrap gap-3 items-end">
<div>
<Label className="text-xs">{t("admin.planning.date_lbl")}</Label>
<Input type="date" className="mt-1 w-44" value={newDate}
onChange={(e) => setNewDate(e.target.value)} />
</div>
<div>
<Label className="text-xs">{t("admin.planning.reason")}</Label>
<Input className="mt-1 w-56" placeholder={t("admin.planning.reason_ph")} value={newReason}
onChange={(e) => setNewReason(e.target.value)} />
</div>
<Button onClick={add} disabled={adding} size="sm">
<Plus className="h-4 w-4 mr-1.5" />
{adding ? t("admin.planning.blocking") : t("admin.planning.block_btn")}
</Button>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-base">{t("admin.planning.blocked_list")}</CardTitle>
</CardHeader>
<CardContent>
{loading ? (
<div className="space-y-2">
{Array.from({ length: 3 }).map((_, i) => (
<div key={i} className="h-10 bg-muted animate-pulse rounded" />
))}
</div>
) : dates.length === 0 ? (
<p className="text-sm text-muted-foreground">{t("admin.planning.no_blocked")}</p>
) : (
<div className="space-y-2">
{dates.map((d) => (
<div key={d.id} className="flex items-center justify-between py-2 border-b border-border last:border-0">
<div>
<span className="text-sm font-medium">
{new Date(d.date + "T00:00:00").toLocaleDateString(locale, {
weekday: "long", day: "numeric", month: "long", year: "numeric",
})}
</span>
{d.reason && <span className="text-xs text-muted-foreground ml-2"> {d.reason}</span>}
</div>
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => remove(d.id)}>
<Trash2 className="h-3.5 w-3.5 text-destructive" />
</Button>
</div>
))}
</div>
)}
</CardContent>
</Card>
</div>
);
}
// ── Main Page ─────────────────────────────────────────────────────────────────
type Tab = "schedule" | "calendar" | "blocked";
export default function PlanningPage() {
const { t } = useLanguage();
const [tab, setTab] = useState<Tab>("schedule");
const tabs: { id: Tab; label: string }[] = [
{ id: "schedule", label: t("admin.planning.tab_schedule") },
{ id: "calendar", label: t("admin.planning.tab_calendar") },
{ id: "blocked", label: t("admin.planning.tab_blocked") },
];
return (
<div className="space-y-6">
<div>
<h2 className="font-serif text-2xl font-semibold">{t("admin.planning.title")}</h2>
<p className="text-sm text-muted-foreground mt-1">{t("admin.planning.subtitle")}</p>
</div>
<div className="flex gap-1 bg-muted p-1 rounded-lg w-fit">
{tabs.map((item) => (
<button
key={item.id}
onClick={() => setTab(item.id)}
className={`px-4 py-1.5 text-sm rounded-md transition-colors ${
tab === item.id
? "bg-background shadow-sm font-medium"
: "text-muted-foreground hover:text-foreground"
}`}
>
{item.label}
</button>
))}
</div>
{tab === "schedule" && <ScheduleTab />}
{tab === "calendar" && <CalendarTab />}
{tab === "blocked" && <BlockedDatesTab />}
</div>
);
}

View File

@@ -1,7 +1,7 @@
"use client";
import { useState } from "react";
import { Plus, Pencil, Trash2 } from "lucide-react";
import { useState, useRef } from "react";
import { Plus, Pencil, Trash2, Upload, X } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
@@ -9,39 +9,22 @@ 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,
Table, TableBody, TableCell, TableHead, TableHeader, TableRow,
} from "@/components/ui/table";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
DialogDescription,
Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle, DialogDescription,
} from "@/components/ui/dialog";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent,
AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
} from "@/components/ui/select";
import { useAdmin } from "@/contexts/AdminContext";
import { useLanguage } from "@/contexts/LanguageContext";
import { ApiError } from "@/lib/api";
import { adminUploadProductImage } from "@/lib/api/products";
import { Product } from "@/data/products";
import { toast } from "sonner";
@@ -49,7 +32,7 @@ type FormState = {
name: string;
category: Product["category"];
price: string;
image: string;
original_price: string;
description: string;
colors: string;
lengths: string;
@@ -61,7 +44,7 @@ const emptyForm: FormState = {
name: "",
category: "clip-in",
price: "",
image: "",
original_price: "",
description: "",
colors: "",
lengths: "",
@@ -70,15 +53,29 @@ const emptyForm: FormState = {
};
export default function AdminProducts() {
const { products, addProduct, updateProduct, deleteProduct } = useAdmin();
const { products, productsLoading, addProduct, updateProduct, deleteProduct, refreshProducts } = useAdmin();
const { t } = useLanguage();
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 [saving, setSaving] = useState(false);
const [imageFile, setImageFile] = useState<File | null>(null);
const [imagePreview, setImagePreview] = useState<string | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const categoryLabels: Record<Product["category"], string> = {
"clip-in": "Clip-In",
"tape-in": "Tape-In",
"ponytail": "Ponytail",
"keratin": t("admin.products.category") === "Kategorie" ? "Keratin" : t("admin.products.category") === "Category" ? "Keratin" : "Kératine",
};
const openCreate = () => {
setEditingId(null);
setForm(emptyForm);
setImageFile(null);
setImagePreview(null);
setDialogOpen(true);
};
@@ -88,21 +85,36 @@ export default function AdminProducts() {
name: p.name,
category: p.category,
price: String(p.price),
image: p.image,
original_price: p.originalPrice ? String(p.originalPrice) : "",
description: p.description,
colors: p.colors.join(", "),
lengths: p.lengths.join(", "),
isNew: !!p.isNew,
isBestseller: !!p.isBestseller,
});
setImageFile(null);
setImagePreview(p.image || null);
setDialogOpen(true);
};
const handleSubmit = (e: React.FormEvent) => {
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
setImageFile(file);
setImagePreview(URL.createObjectURL(file));
};
const clearImage = () => {
setImageFile(null);
setImagePreview(null);
if (fileInputRef.current) fileInputRef.current.value = "";
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
const price = parseFloat(form.price);
if (!form.name || isNaN(price)) {
toast.error("Nom et prix valides requis");
toast.error(t("admin.products.valid_required"));
return;
}
@@ -110,79 +122,100 @@ export default function AdminProducts() {
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"],
original_price: form.original_price ? parseFloat(form.original_price) : undefined,
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,
is_new: form.isNew,
is_bestseller: form.isBestseller,
};
setSaving(true);
try {
let savedId = editingId;
if (editingId) {
updateProduct(editingId, payload);
toast.success("Produit modifié");
await updateProduct(editingId, payload);
} else {
addProduct(payload);
toast.success("Produit ajouté");
const created = await addProduct(payload);
savedId = created.id;
}
if (imageFile && savedId) {
await adminUploadProductImage(savedId, imageFile);
await refreshProducts();
}
toast.success(editingId ? t("admin.products.saved") : t("admin.products.added"));
setDialogOpen(false);
} catch (err) {
toast.error(err instanceof ApiError ? err.message : t("admin.products.save_error"));
} finally {
setSaving(false);
}
};
const handleDelete = () => {
if (deleteId) {
deleteProduct(deleteId);
toast.success("Produit supprimé");
const handleDelete = async () => {
if (!deleteId) return;
try {
await deleteProduct(deleteId);
toast.success(t("admin.products.deleted"));
} catch (err) {
toast.error(err instanceof ApiError ? err.message : t("admin.products.delete_error"));
} finally {
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>
<h2 className="font-serif text-2xl font-semibold">{t("admin.products.title")}</h2>
<p className="text-sm text-muted-foreground mt-1">
{products.length} {t("admin.products.subtitle")}
</p>
</div>
<Button onClick={openCreate}>
<Plus className="h-4 w-4" />
Ajouter
{t("admin.add")}
</Button>
</div>
<Card>
{productsLoading ? (
<div className="p-8 space-y-3">
{Array.from({ length: 4 }).map((_, i) => (
<div key={i} className="h-12 bg-muted animate-pulse rounded" />
))}
</div>
) : (
<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>
<TableHead className="w-16">{t("admin.products.col_image")}</TableHead>
<TableHead>{t("admin.products.col_name")}</TableHead>
<TableHead>{t("admin.products.col_category")}</TableHead>
<TableHead>{t("admin.products.col_price")}</TableHead>
<TableHead>{t("admin.products.col_status")}</TableHead>
<TableHead className="text-right">{t("admin.actions")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{products.map((p) => (
<TableRow key={p.id}>
<TableCell>
{p.image ? (
<img src={p.image} alt={p.name} className="h-12 w-12 rounded object-cover" />
) : (
<div className="h-12 w-12 rounded bg-muted flex items-center justify-center">
<Upload className="h-4 w-4 text-muted-foreground" />
</div>
)}
</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.isNew && <Badge variant="secondary">{t("admin.products.badge_new")}</Badge>}
{p.isBestseller && <Badge>Bestseller</Badge>}
</TableCell>
<TableCell className="text-right space-x-2">
@@ -197,22 +230,23 @@ export default function AdminProducts() {
))}
</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>
<DialogTitle>{editingId ? t("admin.products.edit_title") : t("admin.products.create_title")}</DialogTitle>
<DialogDescription>{t("admin.products.form_desc")}</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>
<Label htmlFor="name">{t("admin.products.name")}</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>
<Label>{t("admin.products.category")}</Label>
<Select value={form.category} onValueChange={(v) => setForm({ ...form, category: v as Product["category"] })}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
@@ -224,37 +258,60 @@ export default function AdminProducts() {
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="price">Prix ()</Label>
<Label htmlFor="price">{t("admin.products.price")}</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 className="space-y-2">
<Label htmlFor="original_price">{t("admin.products.original_price")}</Label>
<Input id="original_price" type="number" step="0.01" value={form.original_price} onChange={(e) => setForm({ ...form, original_price: e.target.value })} placeholder={t("admin.products.optional")} />
</div>
<div className="space-y-2 col-span-2">
<Label htmlFor="description">Description</Label>
<Label>{t("admin.products.image")}</Label>
<div className="flex items-start gap-4">
{imagePreview && (
<div className="relative shrink-0">
<img src={imagePreview} alt="preview" className="h-24 w-24 rounded object-cover border border-border" />
{imageFile && (
<button type="button" onClick={clearImage} className="absolute -top-2 -right-2 bg-destructive text-destructive-foreground rounded-full p-0.5">
<X className="h-3 w-3" />
</button>
)}
</div>
)}
<div className="flex-1">
<input ref={fileInputRef} type="file" accept="image/*" className="hidden" onChange={handleFileChange} />
<Button type="button" variant="outline" size="sm" onClick={() => fileInputRef.current?.click()}>
<Upload className="h-4 w-4 mr-2" />
{imagePreview ? t("admin.products.change_image") : t("admin.products.choose_image")}
</Button>
{imageFile && <p className="text-xs text-muted-foreground mt-1.5">{imageFile.name}</p>}
</div>
</div>
</div>
<div className="space-y-2 col-span-2">
<Label htmlFor="description">{t("admin.products.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>
<Label htmlFor="colors">{t("admin.products.colors")}</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>
<Label htmlFor="lengths">{t("admin.products.lengths")}</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
{t("admin.products.mark_new")}
</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
{t("admin.products.mark_bestseller")}
</label>
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => setDialogOpen(false)}>Annuler</Button>
<Button type="submit">{editingId ? "Enregistrer" : "Créer"}</Button>
<Button type="button" variant="outline" onClick={() => setDialogOpen(false)}>{t("admin.cancel")}</Button>
<Button type="submit" disabled={saving}>{saving ? t("admin.saving") : editingId ? t("admin.save") : t("admin.create")}</Button>
</DialogFooter>
</form>
</DialogContent>
@@ -263,15 +320,15 @@ export default function AdminProducts() {
<AlertDialog open={!!deleteId} onOpenChange={(o) => !o && setDeleteId(null)}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Supprimer ce produit ?</AlertDialogTitle>
<AlertDialogDescription>Cette action est irréversible.</AlertDialogDescription>
<AlertDialogTitle>{t("admin.products.delete_title")}</AlertDialogTitle>
<AlertDialogDescription>{t("admin.irreversible")}</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Annuler</AlertDialogCancel>
<AlertDialogAction onClick={handleDelete}>Supprimer</AlertDialogAction>
<AlertDialogCancel>{t("admin.cancel")}</AlertDialogCancel>
<AlertDialogAction onClick={handleDelete}>{t("admin.delete")}</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
};
}

View File

@@ -6,60 +6,60 @@ 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,
Table, TableBody, TableCell, TableHead, TableHeader, TableRow,
} from "@/components/ui/table";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import {
Tabs,
TabsList,
TabsTrigger,
} from "@/components/ui/tabs";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent,
AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { useAdmin, Reservation } from "@/contexts/AdminContext";
import { useLanguage } from "@/contexts/LanguageContext";
import { ApiError } from "@/lib/api";
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 { reservations, reservationsLoading, updateReservationStatus, deleteReservation } = useAdmin();
const { t, locale } = useLanguage();
const [filter, setFilter] = useState<"all" | Reservation["status"]>("all");
const [deleteId, setDeleteId] = useState<string | null>(null);
const statusConfig: Record<Reservation["status"], { label: string; variant: "default" | "secondary" | "destructive" }> = {
pending: { label: t("admin.status.pending"), variant: "secondary" },
confirmed: { label: t("admin.status.confirmed"), variant: "default" },
cancelled: { label: t("admin.status.cancelled"), variant: "destructive" },
};
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 handleConfirm = async (id: string) => {
try {
await updateReservationStatus(id, "confirmed");
toast.success(t("admin.bookings.confirmed_toast"));
} catch (err) {
toast.error(err instanceof ApiError ? err.message : t("admin.error"));
}
};
const handleCancel = (id: string) => {
updateReservationStatus(id, "cancelled");
toast.success("Réservation annulée");
const handleCancel = async (id: string) => {
try {
await updateReservationStatus(id, "cancelled");
toast.success(t("admin.bookings.cancelled_toast"));
} catch (err) {
toast.error(err instanceof ApiError ? err.message : t("admin.error"));
}
};
const handleDelete = () => {
if (deleteId) {
deleteReservation(deleteId);
toast.success("Réservation supprimée");
const handleDelete = async () => {
if (!deleteId) return;
try {
await deleteReservation(deleteId);
toast.success(t("admin.bookings.deleted_toast"));
} catch (err) {
toast.error(err instanceof ApiError ? err.message : t("admin.error"));
} finally {
setDeleteId(null);
}
};
@@ -67,36 +67,45 @@ export default function AdminReservations() {
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>
<h2 className="font-serif text-2xl font-semibold">{t("admin.bookings.title")}</h2>
<p className="text-sm text-muted-foreground mt-1">
{reservations.length} {t("admin.bookings.subtitle")}
</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>
<TabsTrigger value="all">{t("admin.bookings.tab_all")}</TabsTrigger>
<TabsTrigger value="pending">{t("admin.bookings.tab_pending")}</TabsTrigger>
<TabsTrigger value="confirmed">{t("admin.bookings.tab_confirmed")}</TabsTrigger>
<TabsTrigger value="cancelled">{t("admin.bookings.tab_cancelled")}</TabsTrigger>
</TabsList>
</Tabs>
<Card>
{reservationsLoading ? (
<div className="p-8 space-y-3">
{Array.from({ length: 4 }).map((_, i) => (
<div key={i} className="h-12 bg-muted animate-pulse rounded" />
))}
</div>
) : (
<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>
<TableHead>{t("admin.bookings.col_client")}</TableHead>
<TableHead>{t("admin.bookings.col_contact")}</TableHead>
<TableHead>{t("admin.bookings.col_service")}</TableHead>
<TableHead>{t("admin.bookings.col_datetime")}</TableHead>
<TableHead>{t("admin.status")}</TableHead>
<TableHead className="text-right">{t("admin.actions")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filtered.length === 0 ? (
<TableRow>
<TableCell colSpan={6} className="text-center text-muted-foreground py-8">
Aucune réservation
{t("admin.bookings.none")}
</TableCell>
</TableRow>
) : (
@@ -116,25 +125,27 @@ export default function AdminReservations() {
<TableCell>{r.service}</TableCell>
<TableCell>
<div className="text-sm">
{new Date(r.date).toLocaleDateString("fr-FR", { day: "2-digit", month: "short", year: "numeric" })}
{new Date(r.date).toLocaleDateString(locale, { 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>
<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">
<Button variant="ghost" size="icon" onClick={() => handleConfirm(r.id)} title={t("admin.status.confirmed")}>
<Check className="h-4 w-4 text-primary" />
</Button>
)}
{r.status !== "cancelled" && (
<Button variant="ghost" size="icon" onClick={() => handleCancel(r.id)} title="Annuler">
<Button variant="ghost" size="icon" onClick={() => handleCancel(r.id)} title={t("admin.status.cancelled")}>
<X className="h-4 w-4 text-muted-foreground" />
</Button>
)}
<Button variant="ghost" size="icon" onClick={() => setDeleteId(r.id)} title="Supprimer">
<Button variant="ghost" size="icon" onClick={() => setDeleteId(r.id)} title={t("admin.delete")}>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
</TableCell>
@@ -143,20 +154,21 @@ export default function AdminReservations() {
)}
</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>
<AlertDialogTitle>{t("admin.bookings.delete_title")}</AlertDialogTitle>
<AlertDialogDescription>{t("admin.irreversible")}</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Annuler</AlertDialogCancel>
<AlertDialogAction onClick={handleDelete}>Supprimer</AlertDialogAction>
<AlertDialogCancel>{t("admin.cancel")}</AlertDialogCancel>
<AlertDialogAction onClick={handleDelete}>{t("admin.delete")}</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
};
}

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

@@ -0,0 +1,277 @@
"use client";
import { useState, useEffect } from "react";
import { Card } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Badge } from "@/components/ui/badge";
import {
Table, TableBody, TableCell, TableHead, TableHeader, TableRow,
} from "@/components/ui/table";
import {
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter,
} from "@/components/ui/dialog";
import {
AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent,
AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { Plus, Pencil, Trash2, Clock } from "lucide-react";
import { toast } from "sonner";
import {
adminListServices, adminCreateService, adminUpdateService, adminDeleteService,
AdminServiceApi, ServicePayload, formatDuration,
} from "@/lib/api/services";
import { ApiError } from "@/lib/api";
import { useLanguage } from "@/contexts/LanguageContext";
const EMPTY: ServicePayload = {
name: "",
description: "",
duration_minutes: 60,
price: 0,
is_active: true,
};
export default function AdminServices() {
const { t } = useLanguage();
const [services, setServices] = useState<AdminServiceApi[]>([]);
const [loading, setLoading] = useState(true);
const [dialogOpen, setDialogOpen] = useState(false);
const [editing, setEditing] = useState<AdminServiceApi | null>(null);
const [form, setForm] = useState<ServicePayload>(EMPTY);
const [saving, setSaving] = useState(false);
const [deleteId, setDeleteId] = useState<string | null>(null);
const load = async () => {
setLoading(true);
try {
setServices(await adminListServices());
} catch (err) {
toast.error(err instanceof ApiError ? err.message : t("admin.error"));
} finally {
setLoading(false);
}
};
useEffect(() => { load(); }, []);
const openCreate = () => {
setEditing(null);
setForm(EMPTY);
setDialogOpen(true);
};
const openEdit = (s: AdminServiceApi) => {
setEditing(s);
setForm({
name: s.name,
description: s.description ?? "",
duration_minutes: s.duration_minutes,
price: s.price,
is_active: s.is_active,
});
setDialogOpen(true);
};
const handleSave = async () => {
if (!form.name.trim()) { toast.error(t("admin.services.name_req")); return; }
setSaving(true);
try {
if (editing) {
const updated = await adminUpdateService(editing.id, form);
setServices((prev) => prev.map((s) => (s.id === editing.id ? updated : s)));
toast.success(t("admin.services.updated"));
} else {
const created = await adminCreateService(form);
setServices((prev) => [...prev, created]);
toast.success(t("admin.services.created"));
}
setDialogOpen(false);
} catch (err) {
toast.error(err instanceof ApiError ? err.message : t("admin.error"));
} finally {
setSaving(false);
}
};
const handleDelete = async () => {
if (!deleteId) return;
try {
await adminDeleteService(deleteId);
setServices((prev) => prev.filter((s) => s.id !== deleteId));
toast.success(t("admin.services.deleted"));
} catch (err) {
toast.error(err instanceof ApiError ? err.message : t("admin.error"));
} finally {
setDeleteId(null);
}
};
const set = (k: keyof ServicePayload, v: ServicePayload[keyof ServicePayload]) =>
setForm((prev) => ({ ...prev, [k]: v }));
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h2 className="font-serif text-2xl font-semibold">{t("admin.services.title")}</h2>
<p className="text-sm text-muted-foreground mt-1">{t("admin.services.subtitle")}</p>
</div>
<Button onClick={openCreate}>
<Plus className="h-4 w-4 mr-2" /> {t("admin.services.new_btn")}
</Button>
</div>
<Card>
{loading ? (
<div className="p-8 space-y-3">
{Array.from({ length: 3 }).map((_, i) => (
<div key={i} className="h-12 bg-muted animate-pulse rounded" />
))}
</div>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>{t("admin.services.col_name")}</TableHead>
<TableHead>{t("admin.services.col_desc")}</TableHead>
<TableHead>{t("admin.services.col_duration")}</TableHead>
<TableHead>{t("admin.services.col_price")}</TableHead>
<TableHead>{t("admin.status")}</TableHead>
<TableHead className="text-right">{t("admin.actions")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{services.length === 0 ? (
<TableRow>
<TableCell colSpan={6} className="text-center text-muted-foreground py-10">
{t("admin.services.none")}
</TableCell>
</TableRow>
) : (
services.map((s) => (
<TableRow key={s.id}>
<TableCell className="font-medium">{s.name}</TableCell>
<TableCell className="text-sm text-muted-foreground max-w-[250px] truncate">
{s.description ?? "—"}
</TableCell>
<TableCell>
<div className="flex items-center gap-1 text-sm text-muted-foreground">
<Clock className="h-3.5 w-3.5" />
{formatDuration(s.duration_minutes)}
</div>
</TableCell>
<TableCell className="font-semibold">
{s.price === 0 ? t("admin.services.free") : `${s.price}`}
</TableCell>
<TableCell>
<Badge variant={s.is_active ? "default" : "secondary"}>
{s.is_active ? t("admin.status.active") : t("admin.status.inactive")}
</Badge>
</TableCell>
<TableCell className="text-right space-x-1">
<Button variant="ghost" size="icon" onClick={() => openEdit(s)}>
<Pencil className="h-4 w-4" />
</Button>
<Button variant="ghost" size="icon" onClick={() => setDeleteId(s.id)}>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
)}
</Card>
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>{editing ? t("admin.services.edit_title") : t("admin.services.create_title")}</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-2">
<div>
<Label htmlFor="s-name">{t("admin.services.col_name")} *</Label>
<Input
id="s-name"
value={form.name}
onChange={(e) => set("name", e.target.value)}
className="mt-1"
placeholder={t("admin.services.name_ph")}
/>
</div>
<div>
<Label htmlFor="s-desc">{t("admin.services.col_desc")}</Label>
<Input
id="s-desc"
value={form.description ?? ""}
onChange={(e) => set("description", e.target.value)}
className="mt-1"
placeholder={t("admin.services.desc_ph")}
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<Label htmlFor="s-dur">{t("admin.services.duration")}</Label>
<Input
id="s-dur"
type="number"
min={5}
max={480}
value={form.duration_minutes}
onChange={(e) => set("duration_minutes", parseInt(e.target.value) || 60)}
className="mt-1"
/>
</div>
<div>
<Label htmlFor="s-price">{t("admin.services.price")}</Label>
<Input
id="s-price"
type="number"
min={0}
step={0.01}
value={form.price}
onChange={(e) => set("price", parseFloat(e.target.value) || 0)}
className="mt-1"
/>
</div>
</div>
<div className="flex items-center gap-2">
<input
id="s-active"
type="checkbox"
checked={form.is_active}
onChange={(e) => set("is_active", e.target.checked)}
className="h-4 w-4"
/>
<Label htmlFor="s-active" className="font-normal cursor-pointer">
{t("admin.services.active_label")}
</Label>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setDialogOpen(false)}>{t("admin.cancel")}</Button>
<Button onClick={handleSave} disabled={saving}>
{saving ? t("admin.saving") : t("admin.save")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<AlertDialog open={!!deleteId} onOpenChange={(o) => !o && setDeleteId(null)}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t("admin.services.delete_title")}</AlertDialogTitle>
<AlertDialogDescription>{t("admin.irreversible")}</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{t("admin.cancel")}</AlertDialogCancel>
<AlertDialogAction onClick={handleDelete}>{t("admin.delete")}</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}

View File

@@ -1,30 +1,41 @@
"use client";
import { useState } from "react";
import { useState, useEffect } from "react";
import { useLanguage } from "@/contexts/LanguageContext";
import { products, categories } from "@/data/products";
import { categories } from "@/data/products";
import type { Product } from "@/data/products";
import { listProducts } from "@/lib/api/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 [products, setProducts] = useState<Product[]>([]);
const [loading, setLoading] = useState(true);
const filtered = selectedCategory === "all" ? products : products.filter((p) => p.category === selectedCategory);
useEffect(() => {
setLoading(true);
listProducts({
per_page: 100,
category: selectedCategory === "all" ? undefined : selectedCategory,
})
.then((res) => setProducts(res.data))
.catch((e) => { console.error("[boutique] listProducts failed:", e); setProducts([]); })
.finally(() => setLoading(false));
}, [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"
selectedCategory === "all"
? "bg-primary text-primary-foreground"
: "bg-muted text-muted-foreground hover:bg-accent"
}`}
>
{t("shop.filter.all")}
@@ -34,7 +45,9 @@ export default function Shop() {
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"
selectedCategory === cat.id
? "bg-primary text-primary-foreground"
: "bg-muted text-muted-foreground hover:bg-accent"
}`}
>
{cat.name}
@@ -42,17 +55,22 @@ export default function Shop() {
))}
</div>
{/* Product Grid */}
{loading ? (
<div className="grid grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4 lg:gap-6">
{filtered.map((product) => (
{Array.from({ length: 8 }).map((_, i) => (
<div key={i} className="aspect-[3/4] bg-muted animate-pulse rounded-lg" />
))}
</div>
) : products.length === 0 ? (
<p className="text-center text-muted-foreground py-20">{t("shop.no_products")}</p>
) : (
<div className="grid grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4 lg:gap-6">
{products.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>
);
};
}

View File

@@ -1,22 +1,46 @@
"use client";
import { useState } from "react";
import { useState, useEffect } from "react";
import { useRouter } from "next/navigation";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { useLanguage } from "@/contexts/LanguageContext";
import { useAuth } from "@/contexts/AuthContext";
import { ApiError } from "@/lib/api";
import { toast } from "sonner";
export default function Auth() {
const { t } = useLanguage();
const { login, register, user } = useAuth();
const router = useRouter();
const [isLogin, setIsLogin] = useState(true);
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [name, setName] = useState("");
const [loading, setLoading] = useState(false);
const handleSubmit = (e: React.FormEvent) => {
useEffect(() => {
if (user) router.replace("/");
}, [user, router]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
toast.success(isLogin ? "Connexion réussie" : "Compte créé avec succès !");
setLoading(true);
try {
if (isLogin) {
await login(email, password);
} else {
await register(email, password, name);
}
toast.success(isLogin ? t("auth.login_success") : t("auth.register_success"));
setTimeout(() => router.push("/"), 800);
} catch (err) {
const msg = err instanceof ApiError ? err.message : t("auth.error");
toast.error(msg);
} finally {
setLoading(false);
}
};
return (
@@ -26,36 +50,63 @@ export default function Auth() {
{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"}
{isLogin ? t("auth.login_subtitle") : t("auth.register_subtitle")}
</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" />
<Input
id="name"
value={name}
onChange={(e) => setName(e.target.value)}
className="mt-1"
required
minLength={2}
/>
</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" />
<Input
id="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
className="mt-1"
required
/>
</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" />
<Input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="mt-1"
required
minLength={8}
/>
</div>
<Button type="submit" className="w-full" size="lg">
{isLogin ? t("auth.login") : t("auth.register")}
<Button type="submit" className="w-full" size="lg" disabled={loading}>
{loading ? t("auth.loading") : 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.no_account") : t("auth.has_account")}{" "}
<Button
type="button"
variant="link"
onClick={() => setIsLogin(!isLogin)}
className="text-primary hover:underline font-medium"
>
{isLogin ? t("auth.register") : t("auth.login")}
</Button>
</p>
</div>
</div>
);
};
}

View File

@@ -6,6 +6,8 @@ import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { Label } from "@/components/ui/label";
import { useLanguage } from "@/contexts/LanguageContext";
import { submitContact } from "@/lib/api/contact";
import { ApiError } from "@/lib/api";
import { MapPin, Phone, Mail } from "lucide-react";
import { toast } from "sonner";
@@ -14,13 +16,23 @@ export default function Contact() {
const [name, setName] = useState("");
const [email, setEmail] = useState("");
const [message, setMessage] = useState("");
const [loading, setLoading] = useState(false);
const handleSubmit = (e: React.FormEvent) => {
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
toast.success("Nous vous répondrons dans les plus brefs délais.");
setLoading(true);
try {
await submitContact(name, email, message);
toast.success(t("contact.success"));
setName("");
setEmail("");
setMessage("");
} catch (err) {
const msg = err instanceof ApiError ? err.message : t("contact.error");
toast.error(msg);
} finally {
setLoading(false);
}
};
return (
@@ -29,11 +41,8 @@ export default function Contact() {
<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>
<p className="text-muted-foreground leading-relaxed">{t("contact.description")}</p>
<div className="space-y-4">
<div className="flex items-center gap-3">
<MapPin className="h-5 w-5 text-primary" />
@@ -50,33 +59,52 @@ export default function Contact() {
</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>
<h3 className="font-medium text-foreground mb-2">{t("contact.hours_title")}</h3>
<p>{t("contact.hours_weekdays")}</p>
<p>{t("contact.hours_saturday")}</p>
<p>{t("contact.hours_sunday")}</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 />
<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 />
<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 />
<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 type="submit" className="w-full" size="lg" disabled={loading}>
{loading ? t("contact.sending") : t("contact.send")}
</Button>
</form>
</div>
</div>
</div>
);
};
}

View File

@@ -3,6 +3,7 @@
import { usePathname } from 'next/navigation';
import { LanguageProvider } from "@/contexts/LanguageContext";
import { AuthProvider } from "@/contexts/AuthContext";
import { AdminProvider } from "@/contexts/AdminContext";
import { CartProvider } from "@/contexts/CartContext";
import CartDrawer from "@/components/CartDrawer";
@@ -21,6 +22,7 @@ export default function RootLayout({ children }: { children: React.ReactNode })
<html>
<body>
<LanguageProvider>
<AuthProvider>
<AdminProvider>
<CartProvider>
<Toaster position="top-right" richColors />
@@ -36,6 +38,7 @@ export default function RootLayout({ children }: { children: React.ReactNode })
</CartProvider>
</AdminProvider>
</AuthProvider>
</LanguageProvider>
</body>
</html>

259
app/mon-compte/page.tsx Normal file
View File

@@ -0,0 +1,259 @@
"use client";
import { useState, useEffect } from "react";
import { useRouter } from "next/navigation";
import { useAuth } from "@/contexts/AuthContext";
import { useLanguage } from "@/contexts/LanguageContext";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { updateProfile } from "@/lib/api/auth";
import { listMyBookings, cancelBooking, BookingApi } from "@/lib/api/bookings";
import { listMyOrders, MyOrderApi } from "@/lib/api/orders";
import { ApiError } from "@/lib/api";
import { toast } from "sonner";
import { User, CalendarDays, ShoppingBag, X } from "lucide-react";
export default function MonCompte() {
const { user, isLoading } = useAuth();
const { t, locale } = useLanguage();
const router = useRouter();
const [name, setName] = useState("");
const [phone, setPhone] = useState("");
const [savingProfile, setSavingProfile] = useState(false);
const [bookings, setBookings] = useState<BookingApi[]>([]);
const [bookingsLoading, setBookingsLoading] = useState(true);
const [orders, setOrders] = useState<MyOrderApi[]>([]);
const [ordersLoading, setOrdersLoading] = useState(true);
const [cancellingId, setCancellingId] = useState<string | null>(null);
useEffect(() => {
if (!isLoading && !user) router.replace("/connexion");
}, [user, isLoading, router]);
useEffect(() => {
if (user) {
setName(user.full_name ?? "");
setPhone(user.phone ?? "");
}
}, [user]);
useEffect(() => {
if (!user) return;
listMyBookings()
.then((res) => setBookings(res.data))
.catch(() => setBookings([]))
.finally(() => setBookingsLoading(false));
listMyOrders()
.then((res) => setOrders(res.data))
.catch(() => setOrders([]))
.finally(() => setOrdersLoading(false));
}, [user]);
const handleSaveProfile = async (e: React.FormEvent) => {
e.preventDefault();
setSavingProfile(true);
try {
await updateProfile(name, phone || null);
toast.success(t("account.profile_saved"));
} catch (err) {
toast.error(err instanceof ApiError ? err.message : t("auth.error"));
} finally {
setSavingProfile(false);
}
};
const handleCancel = async (id: string) => {
setCancellingId(id);
try {
await cancelBooking(id);
setBookings((prev) => prev.map((b) => b.id === id ? { ...b, status: "cancelled" } : b));
toast.success(t("account.booking_cancelled"));
} catch (err) {
toast.error(err instanceof ApiError ? err.message : t("auth.error"));
} finally {
setCancellingId(null);
}
};
if (isLoading || !user) return null;
const bookingStatusConfig: Record<string, { label: string; variant: "default" | "secondary" | "destructive" | "outline" }> = {
pending: { label: t("admin.status.pending"), variant: "secondary" },
confirmed: { label: t("admin.status.confirmed"), variant: "default" },
cancelled: { label: t("admin.status.cancelled"), variant: "destructive" },
completed: { label: t("admin.status.completed"), variant: "outline" },
no_show: { label: t("admin.status.no_show"), variant: "destructive" },
};
const orderStatusConfig: Record<string, { label: string; variant: "default" | "secondary" | "destructive" | "outline" }> = {
pending: { label: t("admin.status.pending"), variant: "secondary" },
paid: { label: t("admin.status.paid"), variant: "default" },
shipped: { label: t("admin.status.shipped"), variant: "outline" },
delivered: { label: t("admin.status.delivered"), variant: "default" },
cancelled: { label: t("admin.status.cancelled"), variant: "destructive" },
refunded: { label: t("admin.status.refunded"), variant: "destructive" },
};
return (
<div className="min-h-screen py-12 px-4">
<div className="container mx-auto max-w-3xl">
<h1 className="font-serif text-3xl lg:text-4xl mb-8 text-center">{t("account.title")}</h1>
<Tabs defaultValue="profil">
<TabsList className="w-full mb-8">
<TabsTrigger value="profil" className="flex-1">
<User className="h-4 w-4 mr-2" /> {t("account.tab_profile")}
</TabsTrigger>
<TabsTrigger value="reservations" className="flex-1">
<CalendarDays className="h-4 w-4 mr-2" /> {t("account.tab_bookings")}
</TabsTrigger>
<TabsTrigger value="commandes" className="flex-1">
<ShoppingBag className="h-4 w-4 mr-2" /> {t("account.tab_orders")}
</TabsTrigger>
</TabsList>
<TabsContent value="profil">
<Card>
<CardHeader>
<CardTitle className="font-serif text-xl">{t("account.personal_info")}</CardTitle>
</CardHeader>
<CardContent>
<form onSubmit={handleSaveProfile} className="space-y-4 max-w-sm">
<div>
<Label htmlFor="email">{t("auth.email")}</Label>
<Input id="email" value={user.email} disabled className="mt-1" />
</div>
<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="phone">{t("booking.phone")}</Label>
<Input
id="phone"
type="tel"
value={phone}
onChange={(e) => setPhone(e.target.value)}
className="mt-1"
/>
</div>
<Button type="submit" disabled={savingProfile}>
{savingProfile ? t("admin.saving") : t("admin.save")}
</Button>
</form>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="reservations">
<Card>
<CardHeader>
<CardTitle className="font-serif text-xl">{t("account.tab_bookings")}</CardTitle>
</CardHeader>
<CardContent>
{bookingsLoading ? (
<div className="space-y-3">
{Array.from({ length: 3 }).map((_, i) => (
<div key={i} className="h-16 bg-muted animate-pulse rounded" />
))}
</div>
) : bookings.length === 0 ? (
<p className="text-sm text-muted-foreground py-6 text-center">
{t("account.no_bookings")}
</p>
) : (
<div className="space-y-3">
{bookings.map((b) => {
const cfg = bookingStatusConfig[b.status] ?? { label: b.status, variant: "outline" as const };
const canCancel = b.status === "pending" || b.status === "confirmed";
return (
<div key={b.id} className="flex items-center justify-between p-4 bg-muted/40 rounded-lg border border-border">
<div>
<div className="font-medium text-sm">{b.service_note ?? t("account.appt_default")}</div>
<div className="text-xs text-muted-foreground mt-0.5">
{new Date(b.slot_date + "T00:00:00").toLocaleDateString(locale, { day: "2-digit", month: "short", year: "numeric" })} {t("booking.confirmed_at")} {b.slot_start}
</div>
</div>
<div className="flex items-center gap-3">
<Badge variant={cfg.variant}>{cfg.label}</Badge>
{canCancel && (
<Button
variant="ghost"
size="icon"
onClick={() => handleCancel(b.id)}
disabled={cancellingId === b.id}
title={t("account.cancel")}
>
<X className="h-4 w-4 text-muted-foreground" />
</Button>
)}
</div>
</div>
);
})}
</div>
)}
</CardContent>
</Card>
</TabsContent>
<TabsContent value="commandes">
<Card>
<CardHeader>
<CardTitle className="font-serif text-xl">{t("account.tab_orders")}</CardTitle>
</CardHeader>
<CardContent>
{ordersLoading ? (
<div className="space-y-3">
{Array.from({ length: 3 }).map((_, i) => (
<div key={i} className="h-16 bg-muted animate-pulse rounded" />
))}
</div>
) : orders.length === 0 ? (
<p className="text-sm text-muted-foreground py-6 text-center">
{t("account.no_orders")}
</p>
) : (
<div className="space-y-3">
{orders.map((o) => {
const cfg = orderStatusConfig[o.status] ?? { label: o.status, variant: "outline" as const };
return (
<div key={o.id} className="flex items-center justify-between p-4 bg-muted/40 rounded-lg border border-border">
<div>
<div className="font-mono text-xs text-muted-foreground">
#{o.id.slice(0, 8).toUpperCase()}
</div>
<div className="text-sm font-medium mt-0.5">{o.total_amount.toFixed(2)} </div>
<div className="text-xs text-muted-foreground">
{new Date(o.created_at).toLocaleDateString(locale, {
day: "2-digit", month: "short", year: "numeric",
})}
</div>
</div>
<Badge variant={cfg.variant}>{cfg.label}</Badge>
</div>
);
})}
</div>
)}
</CardContent>
</Card>
</TabsContent>
</Tabs>
</div>
</div>
);
}

View File

@@ -1,14 +1,62 @@
"use client";
import { Minus, Plus, X, ArrowLeft, ShoppingBag } from "lucide-react";
import { useState } from "react";
import { Minus, Plus, X, ArrowLeft, ShoppingBag, Check } from "lucide-react";
import { Button } from "@/components/ui/button";
import { useCart } from "@/contexts/CartContext";
import { useAuth } from "@/contexts/AuthContext";
import { useLanguage } from "@/contexts/LanguageContext";
import { createOrder } from "@/lib/api/orders";
import { ApiError } from "@/lib/api";
import Link from "next/link";
import { toast } from "sonner";
export default function Cart() {
const { items, updateQuantity, removeItem, totalPrice } = useCart();
const { items, updateQuantity, removeItem, clearCart, totalPrice } = useCart();
const { user } = useAuth();
const { t } = useLanguage();
const [loading, setLoading] = useState(false);
const [ordered, setOrdered] = useState(false);
const handleCheckout = async () => {
if (!user) {
toast.error(t("cart.login_required"));
return;
}
setLoading(true);
try {
await createOrder({
items: items.map((i) => ({ product_id: i.product.id, quantity: i.quantity })),
});
clearCart();
setOrdered(true);
} catch (err) {
const msg = err instanceof ApiError ? err.message : t("cart.error");
toast.error(msg);
} finally {
setLoading(false);
}
};
if (ordered) {
return (
<div className="min-h-screen flex items-center justify-center py-12 px-4">
<div className="text-center max-w-md">
<div className="h-16 w-16 rounded-full bg-primary/10 flex items-center justify-center mx-auto mb-6">
<Check className="h-8 w-8 text-primary" />
</div>
<h2 className="font-serif text-3xl mb-3">{t("cart.confirmed_title")}</h2>
<p className="text-muted-foreground mb-6">{t("cart.confirmed_desc")}</p>
<Link href="/boutique">
<Button variant="outline">
<ArrowLeft className="h-4 w-4 mr-2" />
{t("cart.continue_shopping")}
</Button>
</Link>
</div>
</div>
);
}
return (
<div className="min-h-screen py-8 lg:py-12">
@@ -30,29 +78,49 @@ export default function Cart() {
<>
<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
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>
<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">
<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">
<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">
<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>
<span className="font-semibold">
{(item.product.price * item.quantity).toFixed(2)}
</span>
</div>
</div>
</div>
@@ -64,14 +132,27 @@ export default function Cart() {
<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")}
{!user && (
<p className="text-sm text-muted-foreground mb-3 text-center">
<Link href="/connexion" className="text-primary hover:underline">{t("cart.login_link")}</Link>{" "}
{t("cart.login_suffix")}
</p>
)}
<Button
className="w-full"
size="lg"
onClick={handleCheckout}
disabled={loading || !user}
>
{loading ? t("cart.processing") : t("cart.checkout")}
</Button>
<p className="text-xs text-muted-foreground text-center mt-3">Paiement sécurisé par Stripe</p>
<p className="text-xs text-muted-foreground text-center mt-3">
{t("cart.payment_note")}
</p>
</div>
</>
)}
</div>
</div>
);
};
}

View File

@@ -1,11 +1,12 @@
"use client";
import { useState } from "react";
import { useState, useEffect } 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 { getProduct, listProducts } from "@/lib/api/products";
import type { Product } from "@/data/products";
import ProductCard from "@/components/ProductCard";
import Link from "next/link";
import { useParams } from "next/navigation";
@@ -15,27 +16,60 @@ export default function ProductDetail() {
const { id } = useParams();
const { t } = useLanguage();
const { addItem } = useCart();
const product = products.find((p) => p.id === id);
const [product, setProduct] = useState<Product | null>(null);
const [similar, setSimilar] = useState<Product[]>([]);
const [loading, setLoading] = useState(true);
const [selectedImage, setSelectedImage] = useState(0);
const [selectedColor, setSelectedColor] = useState("");
const [selectedLength, setSelectedLength] = useState("");
if (!product) {
useEffect(() => {
if (!id) return;
setLoading(true);
getProduct(id as string)
.then((p) => {
setProduct(p);
setSelectedImage(0);
setSelectedColor(p.colors[0] ?? "");
setSelectedLength(p.lengths[0] ?? "");
return listProducts({ category: p.category, exclude: p.id, per_page: 4 });
})
.then((res) => setSimilar(res.data))
.catch((e) => console.error("[produit] fetch failed:", e))
.finally(() => setLoading(false));
}, [id]);
if (loading) {
return (
<div className="min-h-screen flex items-center justify-center">
<p className="text-muted-foreground">Produit non trouvé</p>
<div className="min-h-screen py-6 lg:py-12">
<div className="container mx-auto px-4 lg:px-8">
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8 lg:gap-16">
<div className="aspect-[3/4] bg-muted animate-pulse rounded-lg" />
<div className="space-y-4">
<div className="h-8 bg-muted animate-pulse rounded w-3/4" />
<div className="h-4 bg-muted animate-pulse rounded w-1/4" />
<div className="h-24 bg-muted animate-pulse rounded" />
</div>
</div>
</div>
</div>
);
}
const similar = products.filter((p) => p.category === product.category && p.id !== product.id).slice(0, 4);
if (!product) {
return (
<div className="min-h-screen flex items-center justify-center">
<p className="text-muted-foreground">{t("product.not_found")}</p>
</div>
);
}
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}`);
toast.success(`${t("product.added_to_cart")} ${product.name} ${color}, ${length}`);
};
return (
@@ -46,10 +80,13 @@ export default function ProductDetail() {
</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" />
<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">
@@ -68,29 +105,34 @@ export default function ProductDetail() {
)}
</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"}`} />
<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>
<span className="text-sm text-muted-foreground">({product.reviewCount} {t("product.reviews")})</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>
<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">
@@ -99,7 +141,7 @@ export default function ProductDetail() {
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
selectedColor === color
? "border-primary bg-accent text-accent-foreground"
: "border-border text-muted-foreground hover:border-primary"
}`}
@@ -110,7 +152,6 @@ export default function ProductDetail() {
</div>
</div>
{/* Length selector */}
<div>
<h3 className="text-sm font-medium mb-2">{t("product.length")}</h3>
<div className="flex gap-2">
@@ -119,7 +160,7 @@ export default function ProductDetail() {
key={length}
onClick={() => setSelectedLength(length)}
className={`px-4 py-2 rounded-md text-sm border transition-colors cursor-pointer ${
(selectedLength || product.lengths[0]) === length
selectedLength === length
? "border-primary bg-accent text-accent-foreground"
: "border-border text-muted-foreground hover:border-primary"
}`}
@@ -134,7 +175,7 @@ export default function ProductDetail() {
{t("cart.add")}
</Button>
{/* Features */}
{product.features.length > 0 && (
<div>
<h3 className="text-sm font-medium mb-3">{t("product.features")}</h3>
<ul className="space-y-2">
@@ -146,10 +187,10 @@ export default function ProductDetail() {
))}
</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>
@@ -163,4 +204,4 @@ export default function ProductDetail() {
</div>
</div>
);
};
}

View File

@@ -1,31 +1,105 @@
"use client";
import { useState } from "react";
import { useState, useEffect } 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 { useAuth } from "@/contexts/AuthContext";
import { listServices, formatDuration, ApiService } from "@/lib/api/services";
import { getAvailableSlots, createBooking, TimeSlotApi } from "@/lib/api/bookings";
import { ApiError } from "@/lib/api";
import { Check, Clock, CalendarDays } from "lucide-react";
import { toast } from "sonner";
export default function Booking () {
const { t } = useLanguage();
const [selectedService, setSelectedService] = useState<string>("");
function toDateStr(d: Date): string {
return d.toISOString().slice(0, 10);
}
export default function Booking() {
const { t, locale } = useLanguage();
const { user } = useAuth();
const [services, setServices] = useState<ApiService[]>([]);
const [servicesLoading, setServicesLoading] = useState(true);
const [selectedService, setSelectedService] = useState<ApiService | null>(null);
const [selectedDate, setSelectedDate] = useState<Date | undefined>();
const [selectedTime, setSelectedTime] = useState<string>("");
const [name, setName] = useState("");
const [email, setEmail] = useState("");
const [phone, setPhone] = useState("");
const [slots, setSlots] = useState<TimeSlotApi[]>([]);
const [slotsLoading, setSlotsLoading] = useState(false);
const [selectedSlot, setSelectedSlot] = useState<TimeSlotApi | null>(null);
const [name, setName] = useState(user?.full_name ?? "");
const [email, setEmail] = useState(user?.email ?? "");
const [phone, setPhone] = useState(user?.phone ?? "");
const [submitting, setSubmitting] = useState(false);
const [confirmed, setConfirmed] = useState(false);
const timeSlots = generateTimeSlots();
const step = !selectedService ? 1 : !selectedDate ? 2 : !selectedTime ? 3 : 4;
useEffect(() => {
listServices()
.then(setServices)
.catch((e) => { console.error("[reservation] listServices failed:", e); setServices([]); })
.finally(() => setServicesLoading(false));
}, []);
const handleConfirm = () => {
toast.success(`${services.find((s) => s.id === selectedService)?.name} le ${selectedDate?.toLocaleDateString("fr-FR")} à ${selectedTime}`);
useEffect(() => {
if (user) {
setName(user.full_name ?? "");
setEmail(user.email ?? "");
setPhone(user.phone ?? "");
}
}, [user]);
useEffect(() => {
if (!selectedDate) return;
setSlotsLoading(true);
setSelectedSlot(null);
const dateStr = toDateStr(selectedDate);
getAvailableSlots(dateStr, dateStr)
.then(setSlots)
.catch((e) => { console.error("[reservation] getAvailableSlots failed:", e); setSlots([]); })
.finally(() => setSlotsLoading(false));
}, [selectedDate]);
const step = !selectedService ? 1 : !selectedDate ? 2 : !selectedSlot ? 3 : 4;
const handleConfirm = async () => {
if (!selectedSlot || !selectedService) return;
setSubmitting(true);
try {
await createBooking({
slot_id: selectedSlot.id,
service_note: selectedService.name,
...(user ? {} : { guest_name: name, guest_email: email, guest_phone: phone }),
});
setConfirmed(true);
toast.success(t("booking.success"));
} catch (err) {
const msg = err instanceof ApiError ? err.message : t("booking.error");
toast.error(msg);
} finally {
setSubmitting(false);
}
};
if (confirmed) {
return (
<div className="min-h-screen flex items-center justify-center py-12 px-4">
<div className="text-center max-w-md">
<div className="h-16 w-16 rounded-full bg-primary/10 flex items-center justify-center mx-auto mb-6">
<Check className="h-8 w-8 text-primary" />
</div>
<h2 className="font-serif text-3xl mb-3">{t("booking.confirmed_title")}</h2>
<p className="text-muted-foreground mb-2">
{selectedService?.name} {selectedDate?.toLocaleDateString(locale)} {t("booking.confirmed_at")} {selectedSlot?.start_time.slice(0, 5)}
</p>
<p className="text-sm text-muted-foreground">
{t("booking.confirmed_desc")}
</p>
</div>
</div>
);
}
return (
<div className="min-h-screen py-8 lg:py-16">
<div className="container mx-auto px-4 lg:px-8 max-w-4xl">
@@ -35,23 +109,38 @@ export default function Booking () {
<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
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>
{servicesLoading ? (
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
{Array.from({ length: 4 }).map((_, i) => (
<div key={i} className="h-24 bg-muted animate-pulse rounded-lg" />
))}
</div>
) : services.length === 0 ? (
<p className="text-sm text-muted-foreground">{t("booking.no_services")}</p>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
{services.map((service) => (
<button
key={service.id}
onClick={() => setSelectedService(service.id)}
onClick={() => setSelectedService(service)}
className={`text-left p-4 rounded-lg border-2 transition-all cursor-pointer ${
selectedService === service.id ? "border-primary" : "border-border hover:border-primary/40"
selectedService?.id === service.id
? "border-primary"
: "border-border hover:border-primary/40"
}`}
>
<div className="flex justify-between items-start">
@@ -60,17 +149,20 @@ export default function Booking () {
<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}
{formatDuration(service.duration_minutes)}
</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" />}
{selectedService?.id === service.id && (
<Check className="h-4 w-4 text-primary mt-2" />
)}
</button>
))}
</div>
)}
</div>
{/* Step 2: Date */}
@@ -89,53 +181,94 @@ export default function Booking () {
</div>
)}
{/* Step 3: Time */}
{/* Step 3: Time slot */}
{selectedDate && (
<div className="mb-10">
<h2 className="font-serif text-xl mb-4">{t("booking.select_time")}</h2>
{slotsLoading ? (
<div className="grid grid-cols-4 md:grid-cols-7 gap-2">
{timeSlots.map((slot) => (
{Array.from({ length: 7 }).map((_, i) => (
<div key={i} className="h-10 bg-muted animate-pulse rounded-md" />
))}
</div>
) : slots.length === 0 ? (
<p className="text-sm text-muted-foreground">{t("booking.no_slots")}</p>
) : (
<div className="grid grid-cols-4 md:grid-cols-7 gap-2">
{slots.map((slot) => (
<button
key={slot.time}
onClick={() => slot.available && setSelectedTime(slot.time)}
disabled={!slot.available}
key={slot.id}
onClick={() => setSelectedSlot(slot)}
className={`py-2 px-3 rounded-md text-sm transition-colors cursor-pointer ${
selectedTime === slot.time
selectedSlot?.id === slot.id
? "bg-primary text-primary-foreground"
: slot.available
? "bg-muted text-foreground hover:bg-accent"
: "bg-muted/50 text-muted-foreground/30 cursor-not-allowed"
: "bg-muted text-foreground hover:bg-accent"
}`}
>
{slot.time}
{slot.start_time.slice(0, 5)}
</button>
))}
</div>
)}
</div>
)}
{/* Step 4: Contact info */}
{selectedTime && (
{/* Step 4: Contact */}
{selectedSlot && (
<div className="max-w-md mx-auto space-y-4">
<h2 className="font-serif text-xl mb-4 text-center">{t("booking.confirm")}</h2>
{!user && (
<>
<div>
<Label htmlFor="name">{t("auth.name")}</Label>
<Input id="name" value={name} onChange={(e) => setName(e.target.value)} className="mt-1" />
<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" />
<Input
id="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
className="mt-1"
required
/>
</div>
</>
)}
{user && (
<div className="p-3 bg-muted rounded-lg text-sm">
<p className="font-medium">{user.full_name}</p>
<p className="text-muted-foreground">{user.email}</p>
</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" />
<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
className="w-full"
size="lg"
onClick={handleConfirm}
disabled={submitting || (!user && (!name || !email))}
>
{submitting ? t("booking.submitting") : t("booking.confirm")}
</Button>
</div>
)}
</div>
</div>
);
};
}

View File

@@ -14,10 +14,7 @@ export default function Footer() {
{/* 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>
<p className="text-sm opacity-70 leading-relaxed">{t("footer.tagline")}</p>
</div>
{/* Navigation */}
@@ -76,7 +73,7 @@ export default function Footer() {
{/* Social */}
<div>
<h4 className="text-sm font-semibold uppercase tracking-wider mb-4">
Suivez-nous
{t("footer.follow_us")}
</h4>
<div className="flex gap-4">
{/* Instagram */}

View File

@@ -1,9 +1,10 @@
"use client";
import { ShoppingBag, User, Menu, X } from "lucide-react";
import { ShoppingBag, User, Menu, X, LogOut } from "lucide-react";
import { useState } from "react";
import { useCart } from "@/contexts/CartContext";
import { useLanguage } from "@/contexts/LanguageContext";
import { useAuth } from "@/contexts/AuthContext";
import LanguageSwitcher from "./LanguageSwitcher";
import Link from "next/link";
import { usePathname } from "next/navigation";
@@ -12,6 +13,7 @@ export default function Header() {
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
const { totalItems, setIsCartOpen } = useCart();
const { t } = useLanguage();
const { user, logout } = useAuth();
const currentPath = usePathname();
const navLinks = [
@@ -58,9 +60,36 @@ export default function Header() {
{/* Right icons */}
<div className="flex items-center gap-3">
<LanguageSwitcher />
{user ? (
<div className="flex items-center gap-2">
<Link
href="/mon-compte"
className="hidden lg:flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground transition-colors"
title={t("nav.account")}
>
<User className="h-4 w-4" />
{user.full_name ?? user.email}
</Link>
<Link
href="/mon-compte"
className="lg:hidden p-2 text-muted-foreground hover:text-foreground transition-colors"
title={t("nav.account")}
>
<User className="h-5 w-5" />
</Link>
<button
onClick={logout}
className="p-2 text-muted-foreground hover:text-foreground transition-colors"
title={t("auth.logout")}
>
<LogOut className="h-5 w-5" />
</button>
</div>
) : (
<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"

View File

@@ -11,8 +11,6 @@ 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() {
@@ -39,4 +37,4 @@ export default function LanguageSwitcher() {
</DropdownMenuContent>
</DropdownMenu>
);
};
}

View File

@@ -1,26 +1,35 @@
"use client";
import { Star } from "lucide-react";
import { Product } from "@/data/products";
import { Badge } from "@/components/ui/badge";
import Link from "next/link";
import { useLanguage } from "@/contexts/LanguageContext";
interface ProductCardProps {
product: Product;
}
export default function ProductCard({ product }: ProductCardProps) {
const { t } = useLanguage();
return (
<Link href={`/produit/${product.id}`} className="group block">
<div className="relative overflow-hidden rounded-lg bg-muted aspect-[3/4] mb-3">
{product.image ? (
<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="w-full h-full bg-muted" />
)}
<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
{t("product.badge_new")}
</Badge>
)}
{product.isBestseller && (
@@ -42,7 +51,7 @@ export default function ProductCard({ product }: ProductCardProps) {
<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})
{product.rating} ({product.reviewCount} {t("product.reviews")})
</span>
</div>
<div className="flex items-center gap-2">
@@ -54,4 +63,4 @@ export default function ProductCard({ product }: ProductCardProps) {
</div>
</Link>
);
};
}

View File

@@ -1,6 +1,6 @@
"use client";
import { LayoutDashboard, Package, CalendarCheck, LogOut, Home } from "lucide-react";
import { LayoutDashboard, Package, CalendarCheck, CalendarDays, ShoppingBag, Users, Settings, Scissors, LogOut, Home } from "lucide-react";
import {
Sidebar,
SidebarContent,
@@ -14,26 +14,33 @@ import {
SidebarHeader,
useSidebar,
} from "@/components/ui/sidebar";
import { useAdmin } from "@/contexts/AdminContext";
import { useAuth } from "@/contexts/AuthContext";
import { useLanguage } from "@/contexts/LanguageContext";
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 { logout } = useAuth();
const { t } = useLanguage();
const route = useRouter();
const pathname = usePathname();
const [isActive, setIsActive] = useState("");
const items = [
{ title: t("admin.nav.overview"), url: "/admin", icon: LayoutDashboard, exact: true },
{ title: t("admin.nav.products"), url: "/admin/produits", icon: Package },
{ title: t("admin.nav.orders"), url: "/admin/commandes", icon: ShoppingBag },
{ title: t("admin.nav.bookings"), url: "/admin/reservations", icon: CalendarCheck },
{ title: t("admin.nav.planning"), url: "/admin/planning", icon: CalendarDays },
{ title: t("admin.nav.services"), url: "/admin/services", icon: Scissors },
{ title: t("admin.nav.customers"), url: "/admin/clients", icon: Users },
{ title: t("admin.nav.settings"), url: "/admin/parametres", icon: Settings },
];
const isActive = (item: typeof items[0]) =>
item.exact ? pathname === item.url : pathname.startsWith(item.url);
const handleLogout = () => {
logout();
@@ -53,15 +60,15 @@ export const AdminSidebar = () => {
<SidebarContent>
<SidebarGroup>
<SidebarGroupLabel>Gestion</SidebarGroupLabel>
<SidebarGroupLabel>{t("admin.nav.management")}</SidebarGroupLabel>
<SidebarGroupContent>
<SidebarMenu>
{items.map((item) => (
<SidebarMenuItem key={item.title}>
<SidebarMenuItem key={item.url}>
<SidebarMenuButton asChild>
<Link
href={item.url}
className={`flex items-center gap-2 ${isActive === item.url ? "bg-sidebar-accent text-sidebar-accent-foreground font-medium" : ""}`}
className={`flex items-center gap-2 ${isActive(item) ? "bg-sidebar-accent text-sidebar-accent-foreground font-medium" : ""}`}
>
<item.icon className="h-4 w-4" />
{!collapsed && <span>{item.title}</span>}
@@ -78,7 +85,7 @@ export const AdminSidebar = () => {
<SidebarMenuButton asChild>
<Link href="/" className="flex items-center gap-2">
<Home className="h-4 w-4" />
{!collapsed && <span>Voir le site</span>}
{!collapsed && <span>{t("admin.nav.view_site")}</span>}
</Link>
</SidebarMenuButton>
<Button
@@ -88,7 +95,7 @@ export const AdminSidebar = () => {
className="w-full justify-start gap-2"
>
<LogOut className="h-4 w-4" />
{!collapsed && <span>Déconnexion</span>}
{!collapsed && <span>{t("admin.logout")}</span>}
</Button>
</SidebarFooter>
</Sidebar>

View File

@@ -1,8 +1,13 @@
"use client";
import React, { useContext, useState, ReactNode, createContext } from "react";
import { Product, products } from "@/data/products";
import React, { useContext, useState, useEffect, ReactNode, createContext } from "react";
import { useAuth } from "@/contexts/AuthContext";
import * as productsApi from "@/lib/api/products";
import * as bookingsApi from "@/lib/api/bookings";
import { Product } from "@/data/products";
import { ApiError } from "@/lib/api";
// Adapter: maps BookingApi to the Reservation shape used by admin pages
export interface Reservation {
id: string;
clientName: string;
@@ -15,63 +20,99 @@ export interface Reservation {
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" },
];
function toReservation(b: bookingsApi.BookingApi): Reservation {
return {
id: b.id,
clientName: b.client_name ?? "—",
email: b.client_email ?? "—",
phone: b.client_phone ?? "—",
service: b.service_note ?? "—",
date: b.slot_date,
time: b.slot_start,
status: b.status === "completed" || b.status === "no_show" ? "confirmed" : b.status as Reservation["status"],
createdAt: b.created_at.slice(0, 10),
};
}
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;
productsLoading: boolean;
refreshProducts: () => Promise<void>;
addProduct: (payload: productsApi.ProductPayload) => Promise<Product>;
updateProduct: (id: string, payload: Partial<productsApi.ProductPayload>) => Promise<void>;
deleteProduct: (id: string) => Promise<void>;
reservations: Reservation[];
updateReservationStatus: (id: string, status: Reservation["status"]) => void;
deleteReservation: (id: string) => void;
reservationsLoading: boolean;
refreshReservations: () => Promise<void>;
updateReservationStatus: (id: string, status: "confirmed" | "cancelled") => Promise<void>;
deleteReservation: (id: string) => Promise<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 { isAdmin } = useAuth();
const [products, setProducts] = useState<Product[]>([]);
const [productsLoading, setProductsLoading] = useState(false);
const [reservations, setReservations] = useState<Reservation[]>([]);
const [reservationsLoading, setReservationsLoading] = useState(false);
const login = (password: string) => {
if (password === ADMIN_PASSWORD) {
setIsAdmin(true);
return true;
const refreshProducts = async () => {
if (!isAdmin) return;
setProductsLoading(true);
try {
const res = await productsApi.adminListProducts();
setProducts(res.data);
} finally {
setProductsLoading(false);
}
return false;
};
const logout = () => setIsAdmin(false);
const refreshReservations = async () => {
if (!isAdmin) return;
setReservationsLoading(true);
try {
const res = await bookingsApi.adminListBookings();
setReservations(res.data.map(toReservation));
} finally {
setReservationsLoading(false);
}
};
const addProduct = (product: Omit<Product, "id">) => {
const newProduct: Product = { ...product, id: `p-${Date.now()}` };
useEffect(() => {
if (isAdmin) {
refreshProducts();
refreshReservations();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isAdmin]);
const addProduct = async (payload: productsApi.ProductPayload): Promise<Product> => {
const newProduct = await productsApi.adminCreateProduct(payload);
setProducts((prev) => [newProduct, ...prev]);
return newProduct;
};
const updateProduct = (id: string, updates: Partial<Product>) => {
setProducts((prev) => prev.map((p) => (p.id === id ? { ...p, ...updates } : p)));
const updateProduct = async (id: string, payload: Partial<productsApi.ProductPayload>) => {
const updated = await productsApi.adminUpdateProduct(id, payload);
setProducts((prev) => prev.map((p) => (p.id === id ? updated : p)));
};
const deleteProduct = (id: string) => {
const deleteProduct = async (id: string) => {
await productsApi.adminDeleteProduct(id);
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 updateReservationStatus = async (id: string, status: "confirmed" | "cancelled") => {
await bookingsApi.adminUpdateBookingStatus(id, status);
setReservations((prev) =>
prev.map((r) => (r.id === id ? { ...r, status } : r))
);
};
const deleteReservation = (id: string) => {
const deleteReservation = async (id: string) => {
await bookingsApi.adminDeleteBooking(id);
setReservations((prev) => prev.filter((r) => r.id !== id));
};
@@ -79,13 +120,15 @@ export const AdminProvider = ({ children }: { children: ReactNode }) => {
<AdminContext.Provider
value={{
isAdmin,
login,
logout,
products: productList,
products,
productsLoading,
refreshProducts,
addProduct,
updateProduct,
deleteProduct,
reservations,
reservationsLoading,
refreshReservations,
updateReservationStatus,
deleteReservation,
}}

73
contexts/AuthContext.tsx Normal file
View File

@@ -0,0 +1,73 @@
"use client";
import React, { createContext, useContext, useState, useEffect, ReactNode } from "react";
import { getToken } from "@/lib/api";
import * as authApi from "@/lib/api/auth";
export type UserProfile = authApi.UserProfile;
interface AuthContextType {
user: UserProfile | null;
isLoading: boolean;
isAdmin: boolean;
login: (email: string, password: string) => Promise<UserProfile>;
register: (email: string, password: string, name: string) => Promise<UserProfile>;
logout: () => void;
}
const AuthContext = createContext<AuthContextType | undefined>(undefined);
export const AuthProvider = ({ children }: { children: ReactNode }) => {
const [user, setUser] = useState<UserProfile | null>(null);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
const token = getToken();
if (!token) {
setIsLoading(false);
return;
}
authApi.getMe()
.then(setUser)
.catch(() => authApi.logout())
.finally(() => setIsLoading(false));
}, []);
const login = async (email: string, password: string): Promise<UserProfile> => {
const profile = await authApi.login(email, password);
setUser(profile);
return profile;
};
const register = async (email: string, password: string, name: string): Promise<UserProfile> => {
const profile = await authApi.register(email, password, name);
setUser(profile);
return profile;
};
const logout = () => {
authApi.logout();
setUser(null);
};
return (
<AuthContext.Provider
value={{
user,
isLoading,
isAdmin: user?.role === "admin",
login,
register,
logout,
}}
>
{children}
</AuthContext.Provider>
);
};
export const useAuth = () => {
const ctx = useContext(AuthContext);
if (!ctx) throw new Error("useAuth must be used within AuthProvider");
return ctx;
};

View File

@@ -2,57 +2,378 @@
import { useContext, useState, ReactNode, createContext } from "react";
export type Language = "fr" | "de" | "en" | "ar" | "tr";
export type Language = "fr" | "de" | "en";
interface LanguageContextType {
language: Language;
setLanguage: (lang: Language) => void;
t: (key: string) => string;
isRTL: boolean;
t: (key: string, vars?: Record<string, string | number>) => string;
locale: string;
}
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" },
// ── Public nav ────────────────────────────────────────────────────────────────
"nav.shop": { fr: "Boutique", de: "Shop", en: "Shop" },
"nav.booking": { fr: "Réservation", de: "Terminbuchung", en: "Book Appointment" },
"nav.about": { fr: "À propos", de: "Über uns", en: "About" },
"nav.contact": { fr: "Contact", de: "Kontakt", en: "Contact" },
"nav.account": { fr: "Mon compte", de: "Mein Konto", en: "My Account" },
// ── Public pages ─────────────────────────────────────────────────────────────
"hero.title": { fr: "Sublimez votre beauté naturelle", de: "Unterstreichen Sie Ihre natürliche Schönheit", en: "Enhance your natural beauty" },
"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" },
"hero.cta": { fr: "Découvrir la collection", de: "Kollektion entdecken", en: "Discover the collection" },
"categories.title": { fr: "Nos Collections", de: "Unsere Kollektionen", en: "Our Collections" },
"bestsellers.title": { fr: "Les Plus Vendus", de: "Bestseller", en: "Bestsellers" },
"booking.title": { fr: "Réservez votre rendez-vous", de: "Termin buchen", en: "Book your appointment" },
"booking.subtitle": { fr: "Consultation gratuite et personnalisée", de: "Kostenlose und persönliche Beratung", en: "Free personalized consultation" },
"booking.cta": { fr: "Prendre rendez-vous", de: "Termin vereinbaren", en: "Book now" },
"reviews.title": { fr: "Ce que disent nos clientes", de: "Was unsere Kundinnen sagen", en: "What our clients say" },
"shop.title": { fr: "Notre Boutique", de: "Unser Shop", en: "Our Shop" },
"shop.filter.all": { fr: "Tous", de: "Alle", en: "All" },
"shop.no_products": { fr: "Aucun produit trouvé.", de: "Keine Produkte gefunden.", en: "No products found." },
"about.title": { fr: "Notre Histoire", de: "Unsere Geschichte", en: "Our Story" },
"contact.title": { fr: "Contactez-nous", de: "Kontaktieren Sie uns", en: "Contact us" },
"contact.send": { fr: "Envoyer", de: "Senden", en: "Send" },
"contact.message": { fr: "Votre message", de: "Ihre Nachricht", en: "Your message" },
"footer.rights": { fr: "Tous droits réservés", de: "Alle Rechte vorbehalten", en: "All rights reserved" },
"footer.tagline": { fr: "Extensions de cheveux 100% naturels. Qualité premium pour sublimer votre beauté.", de: "100% natürliche Haarverlängerungen. Premium-Qualität für Ihren Auftritt.", en: "100% natural hair extensions. Premium quality to enhance your beauty." },
"footer.follow_us": { fr: "Suivez-nous", de: "Folgt uns", en: "Follow us" },
// ── Cart ─────────────────────────────────────────────────────────────────────
"cart.title": { fr: "Mon Panier", de: "Warenkorb", en: "My Cart" },
"cart.empty": { fr: "Votre panier est vide", de: "Ihr Warenkorb ist leer", en: "Your cart is empty" },
"cart.total": { fr: "Total", de: "Gesamt", en: "Total" },
"cart.checkout": { fr: "Commander", de: "Bestellen", en: "Checkout" },
"cart.add": { fr: "Ajouter au panier", de: "In den Warenkorb", en: "Add to cart" },
"cart.confirmed_title": { fr: "Commande confirmée", de: "Bestellung bestätigt", en: "Order confirmed" },
"cart.confirmed_desc": { fr: "Votre commande a été enregistrée. Vous recevrez un email de confirmation avec les détails du paiement.", de: "Ihre Bestellung wurde aufgenommen. Sie erhalten eine Bestätigungs-E-Mail mit den Zahlungsdetails.", en: "Your order has been placed. You will receive a confirmation email with payment details." },
"cart.continue_shopping": { fr: "Continuer les achats", de: "Weiter einkaufen", en: "Continue shopping" },
"cart.login_link": { fr: "Connectez-vous", de: "Melden Sie sich an", en: "Log in" },
"cart.login_suffix": { fr: "pour passer commande", de: "um zu bestellen", en: "to place your order" },
"cart.login_required": { fr: "Connectez-vous pour passer commande", de: "Melden Sie sich an, um zu bestellen", en: "Log in to place your order" },
"cart.processing": { fr: "Traitement…", de: "Wird verarbeitet…", en: "Processing…" },
"cart.error": { fr: "Erreur lors de la commande", de: "Bestellfehler", en: "Order error" },
"cart.payment_note": { fr: "Nous vous contacterons pour finaliser le paiement.", de: "Wir werden Sie kontaktieren, um die Zahlung abzuschließen.", en: "We will contact you to finalize the payment." },
// ── Product ───────────────────────────────────────────────────────────────────
"product.similar": { fr: "Produits similaires", de: "Ähnliche Produkte", en: "Similar products" },
"product.color": { fr: "Couleur", de: "Farbe", en: "Color" },
"product.length": { fr: "Longueur", de: "Länge", en: "Length" },
"product.features": { fr: "Caractéristiques", de: "Eigenschaften", en: "Features" },
"product.badge_new": { fr: "Nouveau", de: "Neu", en: "New" },
"product.reviews": { fr: "avis", de: "Bewertungen", en: "reviews" },
"product.not_found": { fr: "Produit non trouvé", de: "Produkt nicht gefunden", en: "Product not found" },
"product.added_to_cart": { fr: "Ajouté au panier", de: "In den Warenkorb gelegt", en: "Added to cart" },
// ── Auth ──────────────────────────────────────────────────────────────────────
"auth.login": { fr: "Connexion", de: "Anmelden", en: "Login" },
"auth.register": { fr: "Inscription", de: "Registrieren", en: "Sign Up" },
"auth.email": { fr: "Adresse email", de: "E-Mail-Adresse", en: "Email address" },
"auth.password": { fr: "Mot de passe", de: "Passwort", en: "Password" },
"auth.name": { fr: "Nom complet", de: "Vollständiger Name", en: "Full name" },
"auth.login_subtitle": { fr: "Accédez à votre espace personnel", de: "Zugang zu Ihrem persönlichen Bereich", en: "Access your personal space" },
"auth.register_subtitle": { fr: "Créez votre compte en quelques secondes", de: "Erstellen Sie Ihr Konto in wenigen Sekunden", en: "Create your account in seconds" },
"auth.login_success": { fr: "Connexion réussie", de: "Erfolgreich angemeldet", en: "Successfully logged in" },
"auth.register_success": { fr: "Compte créé avec succès !", de: "Konto erfolgreich erstellt!", en: "Account created successfully!" },
"auth.error": { fr: "Une erreur est survenue", de: "Ein Fehler ist aufgetreten", en: "An error occurred" },
"auth.loading": { fr: "Chargement…", de: "Wird geladen…", en: "Loading…" },
"auth.no_account": { fr: "Pas encore de compte ?", de: "Noch kein Konto?", en: "No account yet?" },
"auth.has_account": { fr: "Déjà un compte ?", de: "Bereits ein Konto?", en: "Already have an account?" },
"auth.logout": { fr: "Se déconnecter", de: "Abmelden", en: "Log out" },
// ── Booking (public page) ────────────────────────────────────────────────────
"booking.select_service": { fr: "Choisir un service", de: "Service wählen", en: "Select service" },
"booking.select_date": { fr: "Choisir une date", de: "Datum wählen", en: "Select date" },
"booking.select_time": { fr: "Choisir un créneau", de: "Zeitfenster wählen", en: "Select time" },
"booking.confirm": { fr: "Confirmer la réservation", de: "Buchung bestätigen", en: "Confirm booking" },
"booking.phone": { fr: "Téléphone", de: "Telefon", en: "Phone" },
"booking.free": { fr: "Gratuit", de: "Kostenlos", en: "Free" },
"booking.confirmed_title": { fr: "Réservation confirmée", de: "Buchung bestätigt", en: "Booking confirmed" },
"booking.confirmed_at": { fr: "à", de: "um", en: "at" },
"booking.confirmed_desc": { fr: "Vous recevrez une confirmation par email. Nous vous contacterons pour finaliser le paiement.", de: "Sie erhalten eine Bestätigungs-E-Mail. Wir werden Sie kontaktieren, um die Zahlung abzuschließen.", en: "You will receive a confirmation email. We will contact you to finalize the payment." },
"booking.no_services": { fr: "Aucun service disponible pour le moment.", de: "Derzeit keine Dienstleistungen verfügbar.", en: "No services available at the moment." },
"booking.no_slots": { fr: "Aucun créneau disponible ce jour. Choisissez une autre date.", de: "Keine Zeitfenster an diesem Tag. Wählen Sie ein anderes Datum.", en: "No slots available this day. Choose another date." },
"booking.submitting": { fr: "Envoi en cours…", de: "Wird gesendet…", en: "Submitting…" },
"booking.success": { fr: "Réservation confirmée !", de: "Buchung bestätigt!", en: "Booking confirmed!" },
"booking.error": { fr: "Erreur lors de la réservation", de: "Buchungsfehler", en: "Booking error" },
// ── Contact ───────────────────────────────────────────────────────────────────
"contact.description": { fr: "Une question sur nos produits, un conseil personnalisé ou une demande de rendez-vous ? N'hésitez pas à nous contacter.", de: "Haben Sie Fragen zu unseren Produkten, wünschen Sie eine persönliche Beratung oder möchten einen Termin anfragen? Zögern Sie nicht, uns zu kontaktieren.", en: "Have a question about our products, need personalized advice, or want to book an appointment? Feel free to contact us." },
"contact.hours_title": { fr: "Horaires d'ouverture", de: "Öffnungszeiten", en: "Opening hours" },
"contact.hours_weekdays":{ fr: "Lundi - Vendredi : 9h - 18h", de: "Mo. - Fr.: 9:00 - 18:00 Uhr", en: "Mon - Fri: 9am - 6pm" },
"contact.hours_saturday":{ fr: "Samedi : 10h - 16h", de: "Samstag: 10:00 - 16:00 Uhr", en: "Saturday: 10am - 4pm" },
"contact.hours_sunday": { fr: "Dimanche : Fermé", de: "Sonntag: Geschlossen", en: "Sunday: Closed" },
"contact.success": { fr: "Nous vous répondrons dans les plus brefs délais.", de: "Wir werden Ihnen so schnell wie möglich antworten.", en: "We will get back to you as soon as possible." },
"contact.error": { fr: "Erreur lors de l'envoi", de: "Fehler beim Senden", en: "Error sending message" },
"contact.sending": { fr: "Envoi en cours…", de: "Wird gesendet…", en: "Sending…" },
// ── About ─────────────────────────────────────────────────────────────────────
"about.p1": { fr: "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.", de: "Als leidenschaftliche Verfechterin von Schönheit und Haarpflege habe ich BADO HAIR gegründet, um jeder Frau die Möglichkeit zu geben, ihr Haar mit hochwertigen Extensions zu veredeln.", en: "Passionate about beauty and hair wellness, I created BADO HAIR to give every woman the opportunity to enhance their hair with exceptional quality extensions." },
"about.p2": { fr: "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é.", de: "Jedes Produkt wird sorgfältig ausgewählt: 100% natürliches Remy-Haar, ethisch beschafft und mit modernsten Technologien behandelt, um Weichheit, Glanz und Langlebigkeit zu gewährleisten.", en: "Each product is carefully selected: 100% natural Remy hair, ethically sourced, treated with the most advanced technologies to ensure softness, shine, and longevity." },
"about.p3": { fr: "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.", de: "Mein Ziel ist einfach: Ihnen zu helfen, sich schön und selbstbewusst zu fühlen ob für ein besonderes Event oder den Alltag. Jede Kundin verdient ein persönliches Erlebnis und auf ihre Bedürfnisse zugeschnittene Beratung.", en: "My goal is simple: to help you feel beautiful and confident, whether for a special event or everyday life. Every client deserves a personalized experience and advice tailored to their needs." },
"about.value1_title":{ fr: "Passion", de: "Leidenschaft", en: "Passion" },
"about.value1_desc": { fr: "Chaque produit est choisi avec amour et expertise pour garantir votre satisfaction.", de: "Jedes Produkt wird mit Liebe und Fachkenntnis ausgewählt, um Ihre Zufriedenheit zu garantieren.", en: "Each product is chosen with love and expertise to guarantee your satisfaction." },
"about.value2_title":{ fr: "Qualité Premium", de: "Premium-Qualität", en: "Premium Quality" },
"about.value2_desc": { fr: "100% cheveux naturels Remy, sourcés éthiquement et contrôlés rigoureusement.", de: "100% natürliches Remy-Haar, ethisch bezogen und streng kontrolliert.", en: "100% natural Remy hair, ethically sourced and rigorously controlled." },
"about.value3_title":{ fr: "Expertise", de: "Expertise", en: "Expertise" },
"about.value3_desc": { fr: "Conseils personnalisés et pose professionnelle pour un résultat naturel.", de: "Persönliche Beratung und professionelle Anwendung für ein natürliches Ergebnis.", en: "Personalized advice and professional application for a natural result." },
// ── Account (mon-compte) ─────────────────────────────────────────────────────
"account.title": { fr: "Mon espace", de: "Mein Bereich", en: "My Space" },
"account.tab_profile": { fr: "Mon profil", de: "Mein Profil", en: "My Profile" },
"account.tab_bookings": { fr: "Mes réservations", de: "Meine Buchungen", en: "My Bookings" },
"account.tab_orders": { fr: "Mes commandes", de: "Meine Bestellungen", en: "My Orders" },
"account.personal_info": { fr: "Informations personnelles", de: "Persönliche Daten", en: "Personal information" },
"account.profile_saved": { fr: "Profil mis à jour", de: "Profil aktualisiert", en: "Profile updated" },
"account.booking_cancelled": { fr: "Réservation annulée", de: "Buchung storniert", en: "Booking cancelled" },
"account.no_bookings": { fr: "Aucune réservation pour le moment.", de: "Noch keine Buchungen.", en: "No bookings yet." },
"account.no_orders": { fr: "Aucune commande pour le moment.", de: "Noch keine Bestellungen.", en: "No orders yet." },
"account.appt_default": { fr: "Rendez-vous", de: "Termin", en: "Appointment" },
"account.cancel": { fr: "Annuler", de: "Stornieren", en: "Cancel" },
// ── Shop ─────────────────────────────────────────────────────────────────────
// ── Admin — Layout & common ───────────────────────────────────────────────────
"admin.loading": { fr: "Chargement…", de: "Wird geladen…", en: "Loading…" },
"admin.header": { fr: "Tableau de bord", de: "Dashboard", en: "Dashboard" },
"admin.save": { fr: "Enregistrer", de: "Speichern", en: "Save" },
"admin.saving": { fr: "Enregistrement…", de: "Wird gespeichert…", en: "Saving…" },
"admin.cancel": { fr: "Annuler", de: "Abbrechen", en: "Cancel" },
"admin.delete": { fr: "Supprimer", de: "Löschen", en: "Delete" },
"admin.add": { fr: "Ajouter", de: "Hinzufügen", en: "Add" },
"admin.edit": { fr: "Modifier", de: "Bearbeiten", en: "Edit" },
"admin.create": { fr: "Créer", de: "Erstellen", en: "Create" },
"admin.refresh": { fr: "Actualiser", de: "Aktualisieren", en: "Refresh" },
"admin.search": { fr: "Rechercher…", de: "Suchen…", en: "Search…" },
"admin.actions": { fr: "Actions", de: "Aktionen", en: "Actions" },
"admin.irreversible": { fr: "Cette action est irréversible.", de: "Diese Aktion ist nicht rückgängig zu machen.", en: "This action is irreversible." },
// ── Admin — Status labels ─────────────────────────────────────────────────────
"admin.status.pending": { fr: "En attente", de: "Ausstehend", en: "Pending" },
"admin.status.confirmed": { fr: "Confirmé", de: "Bestätigt", en: "Confirmed" },
"admin.status.cancelled": { fr: "Annulé", de: "Storniert", en: "Cancelled" },
"admin.status.completed": { fr: "Terminé", de: "Abgeschlossen", en: "Completed" },
"admin.status.paid": { fr: "Payé", de: "Bezahlt", en: "Paid" },
"admin.status.shipped": { fr: "Expédié", de: "Versendet", en: "Shipped" },
"admin.status.delivered": { fr: "Livré", de: "Geliefert", en: "Delivered" },
"admin.status.refunded": { fr: "Remboursé", de: "Erstattet", en: "Refunded" },
"admin.status.active": { fr: "Actif", de: "Aktiv", en: "Active" },
"admin.status.inactive": { fr: "Inactif", de: "Inaktiv", en: "Inactive" },
"admin.status.blocked": { fr: "Bloqué", de: "Gesperrt", en: "Blocked" },
"admin.status.booked": { fr: "Réservé", de: "Gebucht", en: "Booked" },
"admin.status.free": { fr: "Libre", de: "Frei", en: "Free" },
"admin.status.no_show": { fr: "Absent", de: "Nicht erschienen", en: "No show" },
// ── Admin — Sidebar ───────────────────────────────────────────────────────────
"admin.nav.management": { fr: "Gestion", de: "Verwaltung", en: "Management" },
"admin.nav.overview": { fr: "Vue d'ensemble", de: "Übersicht", en: "Overview" },
"admin.nav.products": { fr: "Produits", de: "Produkte", en: "Products" },
"admin.nav.orders": { fr: "Commandes", de: "Bestellungen", en: "Orders" },
"admin.nav.bookings": { fr: "Réservations", de: "Reservierungen", en: "Bookings" },
"admin.nav.planning": { fr: "Planning", de: "Planung", en: "Planning" },
"admin.nav.services": { fr: "Services", de: "Dienstleistungen", en: "Services" },
"admin.nav.customers": { fr: "Clients", de: "Kunden", en: "Customers" },
"admin.nav.settings": { fr: "Paramètres", de: "Einstellungen", en: "Settings" },
"admin.nav.view_site": { fr: "Voir le site", de: "Website ansehen", en: "View site" },
"admin.logout": { fr: "Déconnexion", de: "Abmelden", en: "Logout" },
// ── Admin — Dashboard ─────────────────────────────────────────────────────────
"admin.overview.title": { fr: "Vue d'ensemble", de: "Übersicht", en: "Overview" },
"admin.overview.subtitle": { fr: "Aperçu de votre activité", de: "Ihre Aktivitätsübersicht", en: "Your activity overview" },
"admin.overview.activity_section": { fr: "Activité", de: "Aktivität", en: "Activity" },
"admin.overview.revenue_section": { fr: "Revenus & Clients", de: "Einnahmen & Kunden", en: "Revenue & Customers" },
"admin.overview.orders_pending": { fr: "Commandes en attente", de: "Ausstehende Bestellungen", en: "Pending orders" },
"admin.overview.bookings_pending": { fr: "RDV en attente", de: "Ausstehende Termine", en: "Pending bookings" },
"admin.overview.bookings_confirmed": { fr: "RDV confirmés (à venir)", de: "Bestätigte Termine (bevorstehend)", en: "Confirmed bookings (upcoming)" },
"admin.overview.products_count": { fr: "Produits", de: "Produkte", en: "Products" },
"admin.overview.revenue_today": { fr: "Chiffre d'affaires aujourd'hui", de: "Umsatz heute", en: "Revenue today" },
"admin.overview.revenue_week": { fr: "Cette semaine", de: "Diese Woche", en: "This week" },
"admin.overview.revenue_month": { fr: "Ce mois", de: "Diesen Monat", en: "This month" },
"admin.overview.new_customers": { fr: "Nouveaux clients ce mois", de: "Neue Kunden diesen Monat", en: "New customers this month" },
"admin.overview.upcoming_title": { fr: "Prochains rendez-vous", de: "Bevorstehende Termine", en: "Upcoming appointments" },
"admin.overview.no_upcoming": { fr: "Aucun rendez-vous à venir.", de: "Keine bevorstehenden Termine.", en: "No upcoming appointments." },
"admin.overview.low_stock": { fr: "{n} produit(s) en stock faible (≤ 5 unités). Pensez à réapprovisionner.", de: "{n} Produkt(e) mit niedrigem Bestand (≤ 5 Stück). Bitte nachbestellen.", en: "{n} product(s) low in stock (≤ 5 units). Please restock." },
// ── Admin — Orders ────────────────────────────────────────────────────────────
"admin.orders.title": { fr: "Commandes", de: "Bestellungen", en: "Orders" },
"admin.orders.subtitle": { fr: "commande(s)", de: "Bestellung(en)", en: "order(s)" },
"admin.orders.tab_all": { fr: "Toutes", de: "Alle", en: "All" },
"admin.orders.tab_pending": { fr: "En attente", de: "Ausstehend", en: "Pending" },
"admin.orders.tab_paid": { fr: "Payées", de: "Bezahlt", en: "Paid" },
"admin.orders.tab_shipped": { fr: "Expédiées", de: "Versendet", en: "Shipped" },
"admin.orders.tab_delivered": { fr: "Livrées", de: "Geliefert", en: "Delivered" },
"admin.orders.tab_cancelled": { fr: "Annulées", de: "Storniert", en: "Cancelled" },
"admin.orders.col_ref": { fr: "Référence", de: "Referenz", en: "Reference" },
"admin.orders.col_customer": { fr: "Cliente", de: "Kundin", en: "Customer" },
"admin.orders.col_date": { fr: "Date", de: "Datum", en: "Date" },
"admin.orders.col_total": { fr: "Total", de: "Gesamt", en: "Total" },
"admin.orders.none": { fr: "Aucune commande", de: "Keine Bestellungen", en: "No orders" },
"admin.orders.update_btn": { fr: "Modifier", de: "Aktualisieren", en: "Update" },
// ── Admin — Bookings ─────────────────────────────────────────────────────────
"admin.bookings.title": { fr: "Réservations", de: "Reservierungen", en: "Bookings" },
"admin.bookings.subtitle": { fr: "réservation(s) au total", de: "Reservierung(en) gesamt", en: "booking(s) total" },
"admin.bookings.tab_all": { fr: "Toutes", de: "Alle", en: "All" },
"admin.bookings.tab_pending": { fr: "En attente", de: "Ausstehend", en: "Pending" },
"admin.bookings.tab_confirmed": { fr: "Confirmées", de: "Bestätigt", en: "Confirmed" },
"admin.bookings.tab_cancelled": { fr: "Annulées", de: "Storniert", en: "Cancelled" },
"admin.bookings.col_client": { fr: "Cliente", de: "Kundin", en: "Customer" },
"admin.bookings.col_contact": { fr: "Contact", de: "Kontakt", en: "Contact" },
"admin.bookings.col_service": { fr: "Service", de: "Dienstleistung", en: "Service" },
"admin.bookings.col_datetime": { fr: "Date & Heure", de: "Datum & Uhrzeit", en: "Date & Time" },
"admin.bookings.none": { fr: "Aucune réservation", de: "Keine Reservierungen", en: "No bookings" },
"admin.bookings.confirmed_toast": { fr: "Réservation confirmée", de: "Reservierung bestätigt", en: "Booking confirmed" },
"admin.bookings.cancelled_toast": { fr: "Réservation annulée", de: "Reservierung storniert", en: "Booking cancelled" },
"admin.bookings.deleted_toast": { fr: "Réservation supprimée", de: "Reservierung gelöscht", en: "Booking deleted" },
"admin.bookings.delete_title": { fr: "Supprimer cette réservation ?", de: "Diese Reservierung löschen?", en: "Delete this booking?" },
// ── Admin — Planning ─────────────────────────────────────────────────────────
"admin.planning.title": { fr: "Planning & Disponibilités", de: "Planung & Verfügbarkeit", en: "Planning & Availability" },
"admin.planning.subtitle": { fr: "Gérez vos horaires hebdomadaires et vos créneaux de disponibilité", de: "Verwalten Sie Ihren Wochenplan und Verfügbarkeitsslots", en: "Manage your weekly schedule and available time slots" },
"admin.planning.tab_schedule": { fr: "Horaires", de: "Zeiten", en: "Schedule" },
"admin.planning.tab_calendar": { fr: "Calendrier", de: "Kalender", en: "Calendar" },
"admin.planning.tab_blocked": { fr: "Dates bloquées", de: "Gesperrte Daten", en: "Blocked dates" },
"admin.planning.weekly_title": { fr: "Horaires hebdomadaires", de: "Wochenplan", en: "Weekly schedule" },
"admin.planning.add_title": { fr: "Ajouter un horaire", de: "Zeitplan hinzufügen", en: "Add a schedule" },
"admin.planning.not_available": { fr: "Pas disponible", de: "Nicht verfügbar", en: "Not available" },
"admin.planning.day": { fr: "Jour", de: "Tag", en: "Day" },
"admin.planning.start": { fr: "Début", de: "Beginn", en: "Start" },
"admin.planning.end": { fr: "Fin", de: "Ende", en: "End" },
"admin.planning.duration_min": { fr: "Durée (min)", de: "Dauer (Min)", en: "Duration (min)" },
"admin.planning.adding": { fr: "Ajout…", de: "Wird hinzugefügt…", en: "Adding…" },
"admin.planning.generate_title": { fr: "Générer les créneaux", de: "Slots generieren", en: "Generate slots" },
"admin.planning.generate_desc": { fr: "Génère automatiquement les créneaux disponibles à partir des horaires ci-dessus.", de: "Generiert automatisch verfügbare Slots aus dem obigen Zeitplan.", en: "Automatically generates available slots from the schedule above." },
"admin.planning.from": { fr: "Du", de: "Von", en: "From" },
"admin.planning.to": { fr: "Au", de: "Bis", en: "To" },
"admin.planning.generating": { fr: "Génération…", de: "Wird generiert…", en: "Generating…" },
"admin.planning.generate_btn": { fr: "Générer", de: "Generieren", en: "Generate" },
"admin.planning.slots_for": { fr: "Créneaux du", de: "Slots für den", en: "Slots for" },
"admin.planning.no_slots": { fr: "Aucun créneau ce jour.", de: "Keine Slots an diesem Tag.", en: "No slots on this day." },
"admin.planning.block_title": { fr: "Bloquer une date", de: "Datum sperren", en: "Block a date" },
"admin.planning.block_desc": { fr: "Les dates bloquées n'apparaissent pas dans le calendrier de réservation.", de: "Gesperrte Daten erscheinen nicht im Buchungskalender.", en: "Blocked dates do not appear in the booking calendar." },
"admin.planning.reason": { fr: "Raison (optionnel)", de: "Grund (optional)", en: "Reason (optional)" },
"admin.planning.reason_ph": { fr: "Congés, férié…", de: "Urlaub, Feiertag…", en: "Vacation, holiday…" },
"admin.planning.blocking": { fr: "Ajout…", de: "Wird gesperrt…", en: "Blocking…" },
"admin.planning.block_btn": { fr: "Bloquer", de: "Sperren", en: "Block" },
"admin.planning.blocked_list": { fr: "Dates bloquées", de: "Gesperrte Daten", en: "Blocked dates" },
"admin.planning.no_blocked": { fr: "Aucune date bloquée.", de: "Keine gesperrten Daten.", en: "No blocked dates." },
"admin.planning.generated": { fr: "{n} créneaux générés", de: "{n} Slots generiert", en: "{n} slots generated" },
"admin.planning.slot_unblocked": { fr: "Créneau débloqué", de: "Slot entsperrt", en: "Slot unblocked" },
"admin.planning.slot_blocked": { fr: "Créneau bloqué", de: "Slot gesperrt", en: "Slot blocked" },
"admin.planning.slot_deleted": { fr: "Créneau supprimé", de: "Slot gelöscht", en: "Slot deleted" },
"admin.planning.date_unblocked": { fr: "Date débloquée", de: "Datum entsperrt", en: "Date unblocked" },
"admin.planning.date_blocked": { fr: "Date bloquée", de: "Datum gesperrt", en: "Date blocked" },
"admin.planning.schedule_added": { fr: "Horaire ajouté", de: "Zeitplan hinzugefügt", en: "Schedule added" },
"admin.planning.schedule_deleted": { fr: "Horaire supprimé", de: "Zeitplan gelöscht", en: "Schedule deleted" },
"admin.planning.unblock_title": { fr: "Débloquer", de: "Entsperren", en: "Unblock" },
// ── Admin — Day names ─────────────────────────────────────────────────────────
"admin.day.0": { fr: "Lundi", de: "Montag", en: "Monday" },
"admin.day.1": { fr: "Mardi", de: "Dienstag", en: "Tuesday" },
"admin.day.2": { fr: "Mercredi", de: "Mittwoch", en: "Wednesday" },
"admin.day.3": { fr: "Jeudi", de: "Donnerstag", en: "Thursday" },
"admin.day.4": { fr: "Vendredi", de: "Freitag", en: "Friday" },
"admin.day.5": { fr: "Samedi", de: "Samstag", en: "Saturday" },
"admin.day.6": { fr: "Dimanche", de: "Sonntag", en: "Sunday" },
// ── Admin — Month names ───────────────────────────────────────────────────────
"admin.month.0": { fr: "Janvier", de: "Januar", en: "January" },
"admin.month.1": { fr: "Février", de: "Februar", en: "February" },
"admin.month.2": { fr: "Mars", de: "März", en: "March" },
"admin.month.3": { fr: "Avril", de: "April", en: "April" },
"admin.month.4": { fr: "Mai", de: "Mai", en: "May" },
"admin.month.5": { fr: "Juin", de: "Juni", en: "June" },
"admin.month.6": { fr: "Juillet", de: "Juli", en: "July" },
"admin.month.7": { fr: "Août", de: "August", en: "August" },
"admin.month.8": { fr: "Septembre", de: "September", en: "September" },
"admin.month.9": { fr: "Octobre", de: "Oktober", en: "October" },
"admin.month.10": { fr: "Novembre", de: "November", en: "November" },
"admin.month.11": { fr: "Décembre", de: "Dezember", en: "December" },
// ── Admin — Products ─────────────────────────────────────────────────────────
"admin.products.title": { fr: "Produits", de: "Produkte", en: "Products" },
"admin.products.subtitle": { fr: "produit(s) au catalogue", de: "Produkt(e) im Katalog", en: "product(s) in catalog" },
"admin.products.col_image": { fr: "Image", de: "Bild", en: "Image" },
"admin.products.col_name": { fr: "Nom", de: "Name", en: "Name" },
"admin.products.col_category": { fr: "Catégorie", de: "Kategorie", en: "Category" },
"admin.products.col_price": { fr: "Prix", de: "Preis", en: "Price" },
"admin.products.col_status": { fr: "Statut", de: "Status", en: "Status" },
"admin.products.badge_new": { fr: "Nouveau", de: "Neu", en: "New" },
"admin.products.create_title": { fr: "Nouveau produit", de: "Neues Produkt", en: "New product" },
"admin.products.edit_title": { fr: "Modifier le produit", de: "Produkt bearbeiten", en: "Edit product" },
"admin.products.form_desc": { fr: "Renseignez les informations du produit", de: "Produktinformationen eingeben", en: "Fill in the product information" },
"admin.products.name": { fr: "Nom", de: "Name", en: "Name" },
"admin.products.category": { fr: "Catégorie", de: "Kategorie", en: "Category" },
"admin.products.price": { fr: "Prix (€)", de: "Preis (€)", en: "Price (€)" },
"admin.products.original_price": { fr: "Prix barré (€)", de: "Durchgestrichener Preis (€)", en: "Original price (€)" },
"admin.products.optional": { fr: "Optionnel", de: "Optional", en: "Optional" },
"admin.products.image": { fr: "Image", de: "Bild", en: "Image" },
"admin.products.description": { fr: "Description", de: "Beschreibung", en: "Description" },
"admin.products.colors": { fr: "Couleurs (séparées par des virgules)", de: "Farben (kommagetrennt)", en: "Colors (comma-separated)" },
"admin.products.lengths": { fr: "Longueurs (séparées par des virgules)", de: "Längen (kommagetrennt)", en: "Lengths (comma-separated)" },
"admin.products.mark_new": { fr: "Marquer comme Nouveau", de: "Als Neu markieren", en: "Mark as New" },
"admin.products.mark_bestseller": { fr: "Marquer comme Bestseller", de: "Als Bestseller markieren", en: "Mark as Bestseller" },
"admin.products.change_image": { fr: "Changer l'image", de: "Bild ändern", en: "Change image" },
"admin.products.choose_image": { fr: "Choisir une image", de: "Bild auswählen", en: "Choose an image" },
"admin.products.valid_required": { fr: "Nom et prix valides requis", de: "Gültiger Name und Preis erforderlich", en: "Valid name and price required" },
"admin.products.saved": { fr: "Produit modifié", de: "Produkt aktualisiert", en: "Product updated" },
"admin.products.added": { fr: "Produit ajouté", de: "Produkt hinzugefügt", en: "Product added" },
"admin.products.deleted": { fr: "Produit supprimé", de: "Produkt gelöscht", en: "Product deleted" },
"admin.products.delete_title": { fr: "Supprimer ce produit ?", de: "Dieses Produkt löschen?", en: "Delete this product?" },
// ── Admin — Services ─────────────────────────────────────────────────────────
"admin.services.title": { fr: "Services", de: "Dienstleistungen", en: "Services" },
"admin.services.subtitle": { fr: "Ces services apparaissent sur la page de réservation publique.", de: "Diese Dienstleistungen erscheinen auf der öffentlichen Buchungsseite.", en: "These services appear on the public booking page." },
"admin.services.new_btn": { fr: "Nouveau service", de: "Neue Dienstleistung", en: "New service" },
"admin.services.col_name": { fr: "Nom", de: "Name", en: "Name" },
"admin.services.col_desc": { fr: "Description", de: "Beschreibung", en: "Description" },
"admin.services.col_duration": { fr: "Durée", de: "Dauer", en: "Duration" },
"admin.services.col_price": { fr: "Prix", de: "Preis", en: "Price" },
"admin.services.none": { fr: "Aucun service. Créez-en un pour que la page de réservation affiche des options.", de: "Keine Dienstleistungen. Erstellen Sie eine, damit die Buchungsseite Optionen anzeigt.", en: "No services. Create one for the booking page to show options." },
"admin.services.create_title": { fr: "Nouveau service", de: "Neue Dienstleistung", en: "New service" },
"admin.services.edit_title": { fr: "Modifier le service", de: "Dienstleistung bearbeiten", en: "Edit service" },
"admin.services.name_req": { fr: "Le nom est requis", de: "Name ist erforderlich", en: "Name is required" },
"admin.services.name_ph": { fr: "Ex : Pose perruque", de: "z.B. Perückenanlage", en: "e.g. Wig application" },
"admin.services.desc_ph": { fr: "Courte description du service", de: "Kurze Beschreibung", en: "Short description" },
"admin.services.duration": { fr: "Durée (minutes)", de: "Dauer (Minuten)", en: "Duration (minutes)" },
"admin.services.price": { fr: "Prix (€)", de: "Preis (€)", en: "Price (€)" },
"admin.services.active_label": { fr: "Service actif (visible sur la page de réservation)", de: "Dienst aktiv (auf Buchungsseite sichtbar)", en: "Service active (visible on booking page)" },
"admin.services.free": { fr: "Gratuit", de: "Kostenlos", en: "Free" },
"admin.services.created": { fr: "Service créé", de: "Dienstleistung erstellt", en: "Service created" },
"admin.services.updated": { fr: "Service mis à jour", de: "Dienstleistung aktualisiert", en: "Service updated" },
"admin.services.deleted": { fr: "Service supprimé", de: "Dienstleistung gelöscht", en: "Service deleted" },
"admin.services.delete_title": { fr: "Supprimer ce service ?", de: "Diese Dienstleistung löschen?", en: "Delete this service?" },
// ── Admin — Customers ─────────────────────────────────────────────────────────
"admin.customers.title": { fr: "Clients", de: "Kunden", en: "Customers" },
"admin.customers.subtitle": { fr: "client(s)", de: "Kunde/n", en: "customer(s)" },
"admin.customers.search_ph": { fr: "Rechercher par nom ou email…", de: "Nach Name oder E-Mail suchen…", en: "Search by name or email…" },
"admin.customers.col_name": { fr: "Nom", de: "Name", en: "Name" },
"admin.customers.col_email": { fr: "Email", de: "E-Mail", en: "Email" },
"admin.customers.col_phone": { fr: "Téléphone", de: "Telefon", en: "Phone" },
"admin.customers.col_orders": { fr: "Commandes", de: "Bestellungen", en: "Orders" },
"admin.customers.col_bookings":{ fr: "RDV", de: "Termine", en: "Bookings" },
"admin.customers.col_spent": { fr: "Total dépensé", de: "Gesamtausgaben", en: "Total spent" },
"admin.customers.col_joined": { fr: "Inscrit le", de: "Beigetreten am", en: "Joined" },
"admin.customers.none": { fr: "Aucun client trouvé", de: "Keine Kunden gefunden", en: "No customers found" },
"admin.customers.blocked": { fr: "Client bloqué", de: "Kunde gesperrt", en: "Customer blocked" },
"admin.customers.unblocked": { fr: "Client débloqué", de: "Kunde entsperrt", en: "Customer unblocked" },
// ── Admin — Settings ─────────────────────────────────────────────────────────
"admin.settings.title": { fr: "Paramètres", de: "Einstellungen", en: "Settings" },
"admin.settings.subtitle": { fr: "Configuration générale de la boutique", de: "Allgemeine Shop-Konfiguration", en: "General store configuration" },
"admin.settings.bookings": { fr: "Réservations", de: "Reservierungen", en: "Bookings" },
"admin.settings.no_settings": { fr: "Aucun paramètre configuré. Les valeurs par défaut sont utilisées.", de: "Keine Einstellungen konfiguriert. Standardwerte werden verwendet.", en: "No settings configured. Default values are used." },
"admin.settings.other": { fr: "Autres paramètres", de: "Weitere Einstellungen", en: "Other settings" },
"admin.settings.last_updated": { fr: "Dernière modification :", de: "Zuletzt geändert:", en: "Last updated:" },
"admin.settings.saved": { fr: "Paramètre enregistré", de: "Einstellung gespeichert", en: "Setting saved" },
};
const LOCALE_MAP: Record<Language, string> = {
fr: "fr-FR",
de: "de-DE",
en: "en-GB",
};
const LanguageContext = createContext<LanguageContextType | undefined>(undefined);
@@ -60,15 +381,19 @@ 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 t = (key: string, vars?: Record<string, string | number>): string => {
let str = translations[key]?.[language] ?? key;
if (vars) {
Object.entries(vars).forEach(([k, v]) => {
str = str.replace(`{${k}}`, String(v));
});
}
return str;
};
const isRTL = language === "ar";
return (
<LanguageContext.Provider value={{ language, setLanguage, t, isRTL }}>
<div dir={isRTL ? "rtl" : "ltr"}>{children}</div>
<LanguageContext.Provider value={{ language, setLanguage, t, locale: LOCALE_MAP[language] }}>
{children}
</LanguageContext.Provider>
);
};

96
lib/api.ts Normal file
View File

@@ -0,0 +1,96 @@
const BASE = process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:8000";
const PREFIX = "/api/v1";
if (typeof window !== "undefined") {
console.log("[API] base URL:", `${BASE}${PREFIX}`);
}
// ── Token management ──────────────────────────────────────────────────────────
export function getToken(): string | null {
if (typeof window === "undefined") return null;
return localStorage.getItem("bado_token");
}
export function setTokens(access: string, refresh: string) {
localStorage.setItem("bado_token", access);
localStorage.setItem("bado_refresh", refresh);
}
export function clearTokens() {
localStorage.removeItem("bado_token");
localStorage.removeItem("bado_refresh");
}
// ── Error ─────────────────────────────────────────────────────────────────────
export class ApiError extends Error {
code: string;
status: number;
details?: unknown;
constructor(code: string, message: string, status: number, details?: unknown) {
super(message);
this.code = code;
this.status = status;
this.details = details;
}
}
// ── Paginated result shape ────────────────────────────────────────────────────
export interface PaginatedResult<T> {
data: T[];
meta: { total: number; page: number; per_page: number; pages: number };
}
// ── Core request ──────────────────────────────────────────────────────────────
async function request<T>(path: string, init: RequestInit = {}): Promise<T> {
const token = getToken();
const method = (init.method ?? "GET").toUpperCase();
const headers: Record<string, string> = {
...(init.headers as Record<string, string>),
};
if (method !== "GET" && method !== "DELETE" && !(init.body instanceof FormData)) {
headers["Content-Type"] = "application/json";
}
if (token) headers["Authorization"] = `Bearer ${token}`;
const res = await fetch(`${BASE}${PREFIX}${path}`, { ...init, headers });
if (res.status === 204) return undefined as unknown as T;
const body = await res.json();
if (body.success === false) {
const err = body.error ?? {};
throw new ApiError(
err.code ?? "UNKNOWN",
err.message ?? "Une erreur est survenue",
res.status,
err.details
);
}
if ("meta" in body) {
return { data: body.data, meta: body.meta } as T;
}
return body.data as T;
}
export const api = {
get: <T>(path: string) => request<T>(path),
post: <T>(path: string, data?: unknown) =>
request<T>(path, {
method: "POST",
body: data !== undefined ? JSON.stringify(data) : undefined,
}),
put: <T>(path: string, data?: unknown) =>
request<T>(path, { method: "PUT", body: JSON.stringify(data) }),
patch: <T>(path: string, data?: unknown) =>
request<T>(path, { method: "PATCH", body: JSON.stringify(data) }),
del: <T = void>(path: string) => request<T>(path, { method: "DELETE" }),
upload: <T>(path: string, formData: FormData) =>
request<T>(path, { method: "POST", body: formData }),
};

18
lib/api/admin.ts Normal file
View File

@@ -0,0 +1,18 @@
import { api } from "@/lib/api";
export interface DashboardStats {
revenue_today: number;
revenue_week: number;
revenue_month: number;
orders_pending: number;
bookings_pending: number;
bookings_confirmed: number;
products_count: number;
catalog_value: number;
low_stock_count: number;
new_customers_month: number;
}
export async function getDashboardStats(): Promise<DashboardStats> {
return api.get<DashboardStats>("/admin/stats/overview");
}

53
lib/api/auth.ts Normal file
View File

@@ -0,0 +1,53 @@
import { api, setTokens, clearTokens } from "@/lib/api";
export interface UserProfile {
id: string;
email: string;
full_name: string | null;
phone: string | null;
role: "client" | "admin";
}
interface AuthResponse {
access_token: string;
refresh_token: string;
token_type: string;
expires_in: number;
}
export async function login(email: string, password: string): Promise<UserProfile> {
const tokens = await api.post<AuthResponse>("/auth/login", { email, password });
setTokens(tokens.access_token, tokens.refresh_token);
return api.get<UserProfile>("/auth/me");
}
export async function register(
email: string,
password: string,
name: string
): Promise<UserProfile> {
const tokens = await api.post<AuthResponse>("/auth/register", { email, password, name });
if ("access_token" in (tokens as object)) {
setTokens((tokens as AuthResponse).access_token, (tokens as AuthResponse).refresh_token);
}
return api.get<UserProfile>("/auth/me");
}
export async function getMe(): Promise<UserProfile> {
return api.get<UserProfile>("/auth/me");
}
export async function updateProfile(
full_name: string,
phone: string | null
): Promise<UserProfile> {
return api.patch<UserProfile>("/auth/me", { full_name, phone });
}
export async function forgotPassword(email: string): Promise<void> {
await api.post("/auth/forgot-password", { email });
}
export function logout() {
clearTokens();
}

143
lib/api/bookings.ts Normal file
View File

@@ -0,0 +1,143 @@
import { api, PaginatedResult } from "@/lib/api";
export interface TimeSlotApi {
id: string;
date: string;
start_time: string;
end_time: string;
is_blocked: boolean;
is_booked: boolean;
}
export interface BookingApi {
id: string;
user_id: string | null;
slot_id: string;
slot_date: string;
slot_start: string;
slot_end: string;
service_note: string | null;
client_name: string | null;
client_email: string | null;
client_phone: string | null;
status: "pending" | "confirmed" | "cancelled" | "completed" | "no_show";
amount_paid: number | null;
stripe_payment_intent_id: string | null;
admin_notes: string | null;
created_at: string;
}
export interface CreateBookingPayload {
slot_id: string;
service_note?: string;
guest_name?: string;
guest_email?: string;
guest_phone?: string;
}
export interface BookingResult {
booking_id: string;
}
export async function getAvailableSlots(
from_date: string,
to_date: string
): Promise<TimeSlotApi[]> {
return api.get<TimeSlotApi[]>(
`/bookings/slots?from_date=${from_date}&to_date=${to_date}`
);
}
export async function createBooking(payload: CreateBookingPayload): Promise<BookingResult> {
return api.post<BookingResult>("/bookings", payload);
}
export async function listMyBookings(): Promise<PaginatedResult<BookingApi>> {
return api.get<PaginatedResult<BookingApi>>("/bookings");
}
export async function cancelBooking(id: string): Promise<void> {
await api.del(`/bookings/${id}`);
}
// ── Admin — Schedule ──────────────────────────────────────────────────────────
export interface WeeklySchedule {
id: string;
day_of_week: number; // 0=Monday, 6=Sunday
start_time: string;
end_time: string;
slot_duration_minutes: number;
is_active: boolean;
}
export interface BlockedDate {
id: string;
date: string;
reason: string | null;
}
export async function adminGetSchedule(): Promise<WeeklySchedule[]> {
return api.get<WeeklySchedule[]>("/admin/schedule");
}
export async function adminCreateSchedule(payload: {
day_of_week: number;
start_time: string;
end_time: string;
slot_duration_minutes: number;
}): Promise<WeeklySchedule> {
return api.post<WeeklySchedule>("/admin/schedule", payload);
}
export async function adminDeleteSchedule(id: string): Promise<void> {
await api.del(`/admin/schedule/${id}`);
}
export async function adminListSlots(from_date: string, to_date: string): Promise<TimeSlotApi[]> {
return api.get<TimeSlotApi[]>(`/admin/slots?from_date=${from_date}&to_date=${to_date}`);
}
export async function adminGenerateSlots(from_date: string, to_date: string): Promise<{ created: number }> {
return api.post<{ created: number }>("/admin/slots/generate", { from_date, to_date });
}
export async function adminUpdateSlot(id: string, is_blocked: boolean, block_reason?: string): Promise<TimeSlotApi> {
return api.patch<TimeSlotApi>(`/admin/slots/${id}`, { is_blocked, block_reason });
}
export async function adminDeleteSlot(id: string): Promise<void> {
await api.del(`/admin/slots/${id}`);
}
export async function adminGetBlockedDates(): Promise<BlockedDate[]> {
return api.get<BlockedDate[]>("/admin/blocked-dates");
}
export async function adminAddBlockedDate(date: string, reason?: string): Promise<BlockedDate> {
return api.post<BlockedDate>("/admin/blocked-dates", { date, reason });
}
export async function adminRemoveBlockedDate(id: string): Promise<void> {
await api.del(`/admin/blocked-dates/${id}`);
}
// ── Admin — Bookings ──────────────────────────────────────────────────────────
export async function adminListBookings(
status?: string
): Promise<PaginatedResult<BookingApi>> {
const qs = status ? `?status=${status}&per_page=100` : "?per_page=100";
return api.get<PaginatedResult<BookingApi>>(`/admin/bookings${qs}`);
}
export async function adminUpdateBookingStatus(
id: string,
status: string
): Promise<BookingApi> {
return api.patch<BookingApi>(`/admin/bookings/${id}`, { status });
}
export async function adminDeleteBooking(id: string): Promise<void> {
await api.del(`/admin/bookings/${id}`);
}

9
lib/api/contact.ts Normal file
View File

@@ -0,0 +1,9 @@
import { api } from "@/lib/api";
export async function submitContact(
name: string,
email: string,
message: string
): Promise<void> {
await api.post("/contact", { name, email, message });
}

22
lib/api/customers.ts Normal file
View File

@@ -0,0 +1,22 @@
import { api, PaginatedResult } from "@/lib/api";
export interface CustomerApi {
id: string;
email: string;
full_name: string | null;
phone: string | null;
is_blocked: boolean;
created_at: string;
orders_count: number;
bookings_count: number;
total_spent: number;
}
export async function adminListCustomers(search?: string): Promise<PaginatedResult<CustomerApi>> {
const qs = search ? `?search=${encodeURIComponent(search)}&per_page=100` : "?per_page=100";
return api.get<PaginatedResult<CustomerApi>>(`/admin/customers${qs}`);
}
export async function adminBlockCustomer(id: string, is_blocked: boolean): Promise<CustomerApi> {
return api.patch<CustomerApi>(`/admin/customers/${id}`, { is_blocked });
}

61
lib/api/orders.ts Normal file
View File

@@ -0,0 +1,61 @@
import { api, PaginatedResult } from "@/lib/api";
export interface OrderItem {
product_id: string;
quantity: number;
}
export interface CreateOrderPayload {
items: OrderItem[];
notes?: string;
shipping_address?: string;
}
export interface OrderResult {
order_id: string;
amount: number;
}
export interface MyOrderApi {
id: string;
status: string;
total_amount: number;
shipping_address: string | null;
notes: string | null;
created_at: string;
updated_at: string;
}
export interface AdminOrderApi {
id: string;
user_id: string | null;
status: string;
total_amount: number;
shipping_address: string | null;
notes: string | null;
created_at: string;
updated_at: string;
client_name: string | null;
client_email: string | null;
client_phone: string | null;
}
export async function createOrder(payload: CreateOrderPayload): Promise<OrderResult> {
return api.post<OrderResult>("/orders", payload);
}
export async function listMyOrders(): Promise<PaginatedResult<MyOrderApi>> {
return api.get<PaginatedResult<MyOrderApi>>("/orders?per_page=50");
}
// ── Admin ─────────────────────────────────────────────────────────────────────
export async function adminListOrders(status?: string): Promise<PaginatedResult<AdminOrderApi>> {
const qs = status ? `?status=${status}&per_page=100` : "?per_page=100";
return api.get<PaginatedResult<AdminOrderApi>>(`/admin/orders${qs}`);
}
export async function adminUpdateOrderStatus(id: string, status: string): Promise<{ id: string; status: string }> {
return api.patch<{ id: string; status: string }>(`/admin/orders/${id}/status`, { status });
}

119
lib/api/products.ts Normal file
View File

@@ -0,0 +1,119 @@
import { api, PaginatedResult } from "@/lib/api";
import type { Product } from "@/data/products";
// Backend shape (snake_case)
interface ApiProduct {
id: string;
name: string;
category: "clip-in" | "tape-in" | "ponytail" | "keratin";
price: number;
original_price?: number;
image: string;
images: string[];
colors: string[];
lengths: string[];
description: string;
features: string[];
is_new?: boolean;
is_bestseller?: boolean;
rating: number;
review_count: number;
stock_quantity: number;
is_featured: boolean;
is_hidden: boolean;
}
function toProduct(p: ApiProduct): Product {
return {
id: p.id,
name: p.name,
category: p.category,
price: p.price,
originalPrice: p.original_price,
image: p.image,
images: p.images,
colors: p.colors,
lengths: p.lengths,
description: p.description,
features: p.features,
isNew: p.is_new,
isBestseller: p.is_bestseller,
rating: p.rating,
reviewCount: p.review_count,
};
}
export interface ProductFilters {
page?: number;
per_page?: number;
search?: string;
category?: string;
bestseller?: boolean;
is_new?: boolean;
exclude?: string;
}
export async function listProducts(
filters: ProductFilters = {}
): Promise<PaginatedResult<Product>> {
const params = new URLSearchParams();
if (filters.page) params.set("page", String(filters.page));
if (filters.per_page) params.set("per_page", String(filters.per_page));
if (filters.search) params.set("search", filters.search);
if (filters.category) params.set("category", filters.category);
if (filters.bestseller) params.set("bestseller", "true");
if (filters.is_new) params.set("is_new", "true");
if (filters.exclude) params.set("exclude", filters.exclude);
const qs = params.toString();
const res = await api.get<PaginatedResult<ApiProduct>>(`/products${qs ? `?${qs}` : ""}`);
return { data: res.data.map(toProduct), meta: res.meta };
}
export async function getProduct(id: string): Promise<Product> {
const p = await api.get<ApiProduct>(`/products/${id}`);
return toProduct(p);
}
// ── Admin ─────────────────────────────────────────────────────────────────────
export interface ProductPayload {
name: string;
category: string;
price: number;
original_price?: number;
description?: string;
images?: string[];
colors?: string[];
lengths?: string[];
features?: string[];
is_new?: boolean;
is_bestseller?: boolean;
stock_quantity?: number;
}
export async function adminListProducts(): Promise<PaginatedResult<Product>> {
const res = await api.get<PaginatedResult<ApiProduct>>("/admin/products?per_page=100&include_hidden=true");
return { data: res.data.map(toProduct), meta: res.meta };
}
export async function adminCreateProduct(payload: ProductPayload): Promise<Product> {
const p = await api.post<ApiProduct>("/admin/products", payload);
return toProduct(p);
}
export async function adminUpdateProduct(id: string, payload: Partial<ProductPayload>): Promise<Product> {
const p = await api.put<ApiProduct>(`/admin/products/${id}`, payload);
return toProduct(p);
}
export async function adminDeleteProduct(id: string): Promise<void> {
await api.del(`/admin/products/${id}`);
}
export async function adminUploadProductImage(productId: string, file: File): Promise<Product> {
const form = new FormData();
form.append("file", file);
const p = await api.upload<ApiProduct>(`/admin/products/${productId}/images`, form);
return toProduct(p);
}

52
lib/api/services.ts Normal file
View File

@@ -0,0 +1,52 @@
import { api } from "@/lib/api";
export interface ApiService {
id: string;
name: string;
description: string;
duration_minutes: number;
price: number;
}
export function formatDuration(minutes: number): string {
if (minutes < 60) return `${minutes} min`;
const h = Math.floor(minutes / 60);
const m = minutes % 60;
return m > 0 ? `${h}h ${m}min` : `${h}h`;
}
export async function listServices(): Promise<ApiService[]> {
return api.get<ApiService[]>("/services");
}
// ── Admin ─────────────────────────────────────────────────────────────────────
export interface AdminServiceApi extends ApiService {
is_active: boolean;
created_at: string;
updated_at: string;
}
export interface ServicePayload {
name: string;
description?: string;
duration_minutes: number;
price: number;
is_active: boolean;
}
export async function adminListServices(): Promise<AdminServiceApi[]> {
return api.get<AdminServiceApi[]>("/admin/services");
}
export async function adminCreateService(payload: ServicePayload): Promise<AdminServiceApi> {
return api.post<AdminServiceApi>("/admin/services", payload);
}
export async function adminUpdateService(id: string, payload: Partial<ServicePayload>): Promise<AdminServiceApi> {
return api.put<AdminServiceApi>(`/admin/services/${id}`, payload);
}
export async function adminDeleteService(id: string): Promise<void> {
await api.del(`/admin/services/${id}`);
}

15
lib/api/settings.ts Normal file
View File

@@ -0,0 +1,15 @@
import { api } from "@/lib/api";
export interface StoreSetting {
key: string;
value: unknown;
updated_at: string;
}
export async function adminGetSettings(): Promise<StoreSetting[]> {
return api.get<StoreSetting[]>("/admin/settings");
}
export async function adminUpdateSetting(key: string, value: unknown): Promise<StoreSetting> {
return api.put<StoreSetting>(`/admin/settings/${key}`, { value });
}