Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
53365b9dbf | ||
|
|
209ac114b0 | ||
|
|
342ba2c867 | ||
|
|
57f3311278 | ||
|
|
affff1c502 | ||
|
|
a89793a059 | ||
|
|
c4450c993b |
5
.gitignore
vendored
5
.gitignore
vendored
@@ -1,5 +1,5 @@
|
||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
.idea
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
@@ -9,7 +9,8 @@
|
||||
!.yarn/plugins
|
||||
!.yarn/releases
|
||||
!.yarn/versions
|
||||
|
||||
AGENTS.md
|
||||
CLAUDE.md
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
|
||||
@@ -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
153
app/admin/clients/page.tsx
Normal file
@@ -0,0 +1,153 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback, startTransition } 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(() => { startTransition(() => 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>
|
||||
);
|
||||
}
|
||||
174
app/admin/commandes/page.tsx
Normal file
174
app/admin/commandes/page.tsx
Normal file
@@ -0,0 +1,174 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback, startTransition } 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(() => { startTransition(() => 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -2,33 +2,40 @@
|
||||
|
||||
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, logout } = 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") {
|
||||
logout();
|
||||
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 +50,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 +70,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 +80,4 @@ export default function AdminLogin() {
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,38 +1,32 @@
|
||||
"use client";
|
||||
|
||||
import { Package, CalendarCheck, Clock, TrendingUp } from "lucide-react";
|
||||
import React, { useEffect, useState, startTransition } 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 pending = reservations.filter((r) => r.status === "pending").length;
|
||||
const confirmed = reservations.filter((r) => r.status === "confirmed").length;
|
||||
const totalValue = products.reduce((sum, p) => sum + p.price, 0);
|
||||
|
||||
const stats = [
|
||||
{ label: "Produits", value: products.length, icon: Package, color: "text-blue-600", bg: "bg-blue-100" },
|
||||
{ label: "RDV en attente", value: pending, icon: Clock, color: "text-amber-600", bg: "bg-amber-100" },
|
||||
{ label: "RDV confirmés", value: confirmed, icon: CalendarCheck, color: "text-green-600", bg: "bg-green-100" },
|
||||
{ label: "Valeur catalogue", value: `${totalValue} €`, icon: TrendingUp, color: "text-primary", bg: "bg-primary/10" },
|
||||
];
|
||||
|
||||
const upcoming = [...reservations]
|
||||
.filter((r) => r.status !== "cancelled")
|
||||
.sort((a, b) => `${a.date}${a.time}`.localeCompare(`${b.date}${b.time}`))
|
||||
.slice(0, 5);
|
||||
|
||||
function Skeleton() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h2 className="font-serif text-2xl font-semibold">Vue d'ensemble</h2>
|
||||
<p className="text-sm text-muted-foreground mt-1">Aperçu de votre activité</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{stats.map((s) => (
|
||||
{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>
|
||||
);
|
||||
}
|
||||
|
||||
function StatGrid({ cards }: { cards: { label: string; value: string | number; icon: React.ElementType; color: string; bg: string }[] }) {
|
||||
return (
|
||||
<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 +40,76 @@ export default function AdminOverview() {
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AdminOverview() {
|
||||
const { reservations } = useAdmin();
|
||||
const { t, locale } = useLanguage();
|
||||
const [stats, setStats] = useState<DashboardStats | null>(null);
|
||||
|
||||
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);
|
||||
|
||||
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" },
|
||||
]
|
||||
: [];
|
||||
|
||||
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 +119,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 +134,4 @@ export default function AdminOverview() {
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
118
app/admin/parametres/page.tsx
Normal file
118
app/admin/parametres/page.tsx
Normal file
@@ -0,0 +1,118 @@
|
||||
"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 } from "@/lib/api/settings";
|
||||
import { useLanguage } from "@/contexts/LanguageContext";
|
||||
import { ApiError } from "@/lib/api";
|
||||
import { Save } from "lucide-react";
|
||||
|
||||
const SETTING_KEYS = ["default_booking_price"] as const;
|
||||
type SettingKey = typeof SETTING_KEYS[number];
|
||||
|
||||
interface SettingMeta { label: string; description: string; type: "number" | "text" }
|
||||
|
||||
export default function AdminParametres() {
|
||||
const { t, locale } = useLanguage();
|
||||
const [values, setValues] = useState<Record<string, string>>({ default_booking_price: "0" });
|
||||
const [updatedAt, setUpdatedAt] = useState<Record<string, string>>({});
|
||||
const [saving, setSaving] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const SETTING_META: Record<SettingKey, SettingMeta> = {
|
||||
default_booking_price: {
|
||||
label: t("admin.settings.booking_price_label"),
|
||||
description: t("admin.settings.booking_price_desc"),
|
||||
type: "number",
|
||||
},
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
adminGetSettings()
|
||||
.then((rows) => {
|
||||
const vals: Record<string, string> = { default_booking_price: "0" };
|
||||
const dates: Record<string, string> = {};
|
||||
rows.forEach((r) => {
|
||||
vals[r.key] = r.value !== null && r.value !== undefined ? String(r.value) : "";
|
||||
if (r.updated_at) dates[r.key] = r.updated_at;
|
||||
});
|
||||
setValues(vals);
|
||||
setUpdatedAt(dates);
|
||||
})
|
||||
.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 as SettingKey];
|
||||
const raw = values[key] ?? "";
|
||||
const value = meta?.type === "number" ? parseFloat(raw) || 0 : raw;
|
||||
const saved = await adminUpdateSetting(key, value);
|
||||
if (saved.updated_at) setUpdatedAt((prev) => ({ ...prev, [key]: saved.updated_at }));
|
||||
toast.success(t("admin.settings.saved"));
|
||||
} catch (err) {
|
||||
toast.error(err instanceof ApiError ? err.message : t("admin.error"));
|
||||
} finally {
|
||||
setSaving(null);
|
||||
}
|
||||
};
|
||||
|
||||
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: 2 }).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">
|
||||
{SETTING_KEYS.map((key) => {
|
||||
const meta = SETTING_META[key];
|
||||
return (
|
||||
<div key={key}>
|
||||
<Label htmlFor={key}>{meta.label}</Label>
|
||||
<p className="text-xs text-muted-foreground mb-2">{meta.description}</p>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
id={key}
|
||||
type={meta.type}
|
||||
min={meta.type === "number" ? 0 : undefined}
|
||||
value={values[key] ?? ""}
|
||||
onChange={(e) => setValues((prev) => ({ ...prev, [key]: e.target.value }))}
|
||||
className="max-w-[200px]"
|
||||
/>
|
||||
<Button size="sm" onClick={() => handleSave(key)} disabled={saving === key}>
|
||||
<Save className="h-4 w-4 mr-2" />
|
||||
{saving === key ? t("admin.saving") : t("admin.save")}
|
||||
</Button>
|
||||
</div>
|
||||
{updatedAt[key] && (
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
{t("admin.settings.last_updated")} {new Date(updatedAt[key]).toLocaleDateString(locale)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
574
app/admin/planning/page.tsx
Normal file
574
app/admin/planning/page.tsx
Normal file
@@ -0,0 +1,574 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback, startTransition } 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(() => { startTransition(() => 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>
|
||||
);
|
||||
}
|
||||
@@ -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,8 @@ type FormState = {
|
||||
name: string;
|
||||
category: Product["category"];
|
||||
price: string;
|
||||
image: string;
|
||||
original_price: string;
|
||||
stock_quantity: string;
|
||||
description: string;
|
||||
colors: string;
|
||||
lengths: string;
|
||||
@@ -61,7 +45,8 @@ const emptyForm: FormState = {
|
||||
name: "",
|
||||
category: "clip-in",
|
||||
price: "",
|
||||
image: "",
|
||||
original_price: "",
|
||||
stock_quantity: "0",
|
||||
description: "",
|
||||
colors: "",
|
||||
lengths: "",
|
||||
@@ -70,15 +55,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 +87,37 @@ export default function AdminProducts() {
|
||||
name: p.name,
|
||||
category: p.category,
|
||||
price: String(p.price),
|
||||
image: p.image,
|
||||
original_price: p.originalPrice ? String(p.originalPrice) : "",
|
||||
stock_quantity: String(p.stockQuantity ?? 0),
|
||||
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 +125,105 @@ 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,
|
||||
stock_quantity: parseInt(form.stock_quantity) || 0,
|
||||
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 className="text-center">{t("admin.products.col_stock")}</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="text-center">
|
||||
<span className={p.stockQuantity === 0 ? "text-destructive font-medium" : ""}>{p.stockQuantity ?? 0}</span>
|
||||
</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 +238,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 +266,64 @@ 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 htmlFor="stock_quantity">{t("admin.products.stock_quantity")}</Label>
|
||||
<Input id="stock_quantity" type="number" min="0" step="1" value={form.stock_quantity} onChange={(e) => setForm({ ...form, stock_quantity: e.target.value })} />
|
||||
</div>
|
||||
<div className="space-y-2 col-span-2">
|
||||
<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 +332,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>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,65 +1,72 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Check, X, Trash2, Mail, Phone } from "lucide-react";
|
||||
import { Check, X, Trash2, Mail, Phone, Loader2 } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
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 [updatingId, setUpdatingId] = 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) => {
|
||||
setUpdatingId(id);
|
||||
try {
|
||||
await updateReservationStatus(id, "confirmed");
|
||||
toast.success(t("admin.bookings.confirmed_toast"));
|
||||
} catch (err) {
|
||||
toast.error(err instanceof ApiError ? err.message : t("admin.error"));
|
||||
} finally {
|
||||
setUpdatingId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancel = (id: string) => {
|
||||
updateReservationStatus(id, "cancelled");
|
||||
toast.success("Réservation annulée");
|
||||
const handleCancel = async (id: string) => {
|
||||
setUpdatingId(id);
|
||||
try {
|
||||
await updateReservationStatus(id, "cancelled");
|
||||
toast.success(t("admin.bookings.cancelled_toast"));
|
||||
} catch (err) {
|
||||
toast.error(err instanceof ApiError ? err.message : t("admin.error"));
|
||||
} finally {
|
||||
setUpdatingId(null);
|
||||
}
|
||||
};
|
||||
|
||||
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 +74,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,47 +132,58 @@ 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">
|
||||
{updatingId === r.id ? (
|
||||
<Button variant="ghost" size="icon" disabled>
|
||||
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
|
||||
</Button>
|
||||
) : (
|
||||
<>
|
||||
{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>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<AlertDialog open={!!deleteId} onOpenChange={(o) => !o && setDeleteId(null)}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Supprimer cette réservation ?</AlertDialogTitle>
|
||||
<AlertDialogDescription>Cette action est irréversible.</AlertDialogDescription>
|
||||
<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
277
app/admin/services/page.tsx
Normal file
@@ -0,0 +1,277 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, startTransition } 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(() => { startTransition(() => 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>
|
||||
);
|
||||
}
|
||||
@@ -1,30 +1,48 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Suspense, useState, useEffect, startTransition } from "react";
|
||||
import { useLanguage } from "@/contexts/LanguageContext";
|
||||
import { products, categories } from "@/data/products";
|
||||
import { useSearchParams, useRouter } from "next/navigation";
|
||||
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() {
|
||||
function ShopContent() {
|
||||
const { t } = useLanguage();
|
||||
// const [searchParams] = useSearchParams();
|
||||
// const initialCategory = searchParams.get("category") || "all";
|
||||
const [selectedCategory, setSelectedCategory] = useState("all");
|
||||
const searchParams = useSearchParams();
|
||||
const router = useRouter();
|
||||
const selectedCategory = searchParams.get("category") || "all";
|
||||
const [products, setProducts] = useState<Product[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const filtered = selectedCategory === "all" ? products : products.filter((p) => p.category === selectedCategory);
|
||||
const setCategory = (cat: string) => {
|
||||
router.push(cat === "all" ? "/boutique" : `/boutique?category=${cat}`);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
startTransition(() => 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")}
|
||||
onClick={() => setCategory("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")}
|
||||
@@ -32,9 +50,11 @@ export default function Shop() {
|
||||
{categories.map((cat) => (
|
||||
<button
|
||||
key={cat.id}
|
||||
onClick={() => setSelectedCategory(cat.id)}
|
||||
onClick={() => setCategory(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 +62,30 @@ 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>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
export default function Shop() {
|
||||
return (
|
||||
<Suspense>
|
||||
<ShopContent />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,61 +1,168 @@
|
||||
"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 { forgotPassword } from "@/lib/api/auth";
|
||||
import { ApiError } from "@/lib/api";
|
||||
import { toast } from "sonner";
|
||||
|
||||
type Mode = "login" | "register" | "forgot";
|
||||
|
||||
export default function Auth() {
|
||||
const { t } = useLanguage();
|
||||
const [isLogin, setIsLogin] = useState(true);
|
||||
const { login, register, logout, user } = useAuth();
|
||||
const router = useRouter();
|
||||
const [mode, setMode] = useState<Mode>("login");
|
||||
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 (mode === "login") {
|
||||
const profile = await login(email, password);
|
||||
if (profile.role === "admin") {
|
||||
logout();
|
||||
toast.error(t("auth.admin_not_allowed"));
|
||||
return;
|
||||
}
|
||||
toast.success(t("auth.login_success"));
|
||||
router.push("/");
|
||||
} else if (mode === "register") {
|
||||
const profile = await register(email, password, name);
|
||||
if (profile) {
|
||||
toast.success(t("auth.register_success"));
|
||||
router.push("/");
|
||||
} else {
|
||||
toast.success(t("auth.confirm_email"));
|
||||
router.push("/connexion");
|
||||
}
|
||||
} else {
|
||||
await forgotPassword(email);
|
||||
toast.success(t("auth.forgot_sent"));
|
||||
setMode("login");
|
||||
setEmail("");
|
||||
}
|
||||
} catch (err) {
|
||||
const msg = err instanceof ApiError ? err.message : t("auth.error");
|
||||
toast.error(msg);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center py-12 px-4">
|
||||
<div className="w-full max-w-sm">
|
||||
<h1 className="font-serif text-3xl text-center mb-2">
|
||||
{isLogin ? t("auth.login") : t("auth.register")}
|
||||
{mode === "login" ? t("auth.login") : mode === "register" ? t("auth.register") : t("auth.forgot_title")}
|
||||
</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"}
|
||||
{mode === "login"
|
||||
? t("auth.login_subtitle")
|
||||
: mode === "register"
|
||||
? t("auth.register_subtitle")
|
||||
: t("auth.forgot_subtitle")}
|
||||
</p>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
{!isLogin && (
|
||||
{mode === "register" && (
|
||||
<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>
|
||||
{mode !== "forgot" && (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<Label htmlFor="password">{t("auth.password")}</Label>
|
||||
<Input id="password" type="password" value={password} onChange={(e) => setPassword(e.target.value)} className="mt-1" />
|
||||
{mode === "login" && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="link"
|
||||
className="h-auto p-0 text-xs text-muted-foreground hover:text-primary"
|
||||
onClick={() => { setMode("forgot"); setPassword(""); }}
|
||||
>
|
||||
{t("auth.forgot_link")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<Button type="submit" className="w-full" size="lg">
|
||||
{isLogin ? t("auth.login") : t("auth.register")}
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
minLength={8}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<Button type="submit" className="w-full" size="lg" disabled={loading}>
|
||||
{loading
|
||||
? t("auth.loading")
|
||||
: mode === "login"
|
||||
? t("auth.login")
|
||||
: mode === "register"
|
||||
? t("auth.register")
|
||||
: t("auth.forgot_send")}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
{mode === "forgot" ? (
|
||||
<p className="text-center text-sm text-muted-foreground mt-6">
|
||||
{isLogin ? "Pas encore de compte ?" : "Déjà un compte ?"}{" "}
|
||||
<Button variant="link" onClick={() => setIsLogin(!isLogin)} className="text-primary hover:underline font-medium">
|
||||
{isLogin ? t("auth.register") : t("auth.login")}
|
||||
<Button
|
||||
type="button"
|
||||
variant="link"
|
||||
onClick={() => setMode("login")}
|
||||
className="text-primary hover:underline font-medium"
|
||||
>
|
||||
{t("auth.back_to_login")}
|
||||
</Button>
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-center text-sm text-muted-foreground mt-6">
|
||||
{mode === "login" ? t("auth.no_account") : t("auth.has_account")}{" "}
|
||||
<Button
|
||||
type="button"
|
||||
variant="link"
|
||||
onClick={() => setMode(mode === "login" ? "register" : "login")}
|
||||
className="text-primary hover:underline font-medium"
|
||||
>
|
||||
{mode === "login" ? t("auth.register") : t("auth.login")}
|
||||
</Button>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
269
app/mon-compte/page.tsx
Normal file
269
app/mon-compte/page.tsx
Normal file
@@ -0,0 +1,269 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, startTransition } 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, refreshUser } = 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");
|
||||
if (!isLoading && user?.role === "admin") router.replace("/admin");
|
||||
}, [user, isLoading, router]);
|
||||
|
||||
// Refresh profile from server on mount to get the latest full_name
|
||||
useEffect(() => {
|
||||
if (user) refreshUser().catch(() => {});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (user) {
|
||||
startTransition(() => {
|
||||
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);
|
||||
await refreshUser();
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useState, useEffect, startTransition } 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;
|
||||
startTransition(() => 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>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,31 +1,121 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useState, useEffect, startTransition } 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";
|
||||
|
||||
function toDateStr(d: Date): string {
|
||||
return d.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
export default function Booking() {
|
||||
const { t } = useLanguage();
|
||||
const [selectedService, setSelectedService] = useState<string>("");
|
||||
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) {
|
||||
startTransition(() => {
|
||||
setName(user.full_name ?? "");
|
||||
setEmail(user.email ?? "");
|
||||
setPhone(user.phone ?? "");
|
||||
});
|
||||
}
|
||||
}, [user]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedDate) return;
|
||||
startTransition(() => {
|
||||
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);
|
||||
}
|
||||
};
|
||||
|
||||
const resetBooking = () => {
|
||||
setConfirmed(false);
|
||||
setSelectedService(null);
|
||||
setSelectedDate(undefined);
|
||||
setSelectedSlot(null);
|
||||
setSlots([]);
|
||||
setPhone(user?.phone ?? "");
|
||||
};
|
||||
|
||||
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 mb-6">
|
||||
{t("booking.confirmed_desc")}
|
||||
</p>
|
||||
<Button variant="outline" onClick={resetBooking}>
|
||||
{t("booking.new_booking")}
|
||||
</Button>
|
||||
</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 +125,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 +165,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 +197,98 @@ 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" />
|
||||
<Label htmlFor="name">
|
||||
{t("auth.name")} <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
className="mt-1"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="email">{t("auth.email")}</Label>
|
||||
<Input id="email" type="email" value={email} onChange={(e) => setEmail(e.target.value)} className="mt-1" />
|
||||
<Label htmlFor="email">
|
||||
{t("auth.email")} <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<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>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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,9 +13,11 @@ 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 = [
|
||||
{ to: "/", label: t("nav.home") },
|
||||
{ to: "/boutique", label: t("nav.shop") },
|
||||
{ to: "/reservation", label: t("nav.booking") },
|
||||
{ to: "/a-propos", label: t("nav.about") },
|
||||
@@ -58,9 +61,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={user.role === "admin" ? "/admin" : "/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={user.role === "admin" ? "/admin" : "/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"
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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, startTransition, 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,103 @@ 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: (silent?: boolean) => 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 (silent = false) => {
|
||||
if (!isAdmin) return;
|
||||
if (!silent) setReservationsLoading(true);
|
||||
try {
|
||||
const res = await bookingsApi.adminListBookings();
|
||||
setReservations(res.data.map(toReservation));
|
||||
} finally {
|
||||
if (!silent) setReservationsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const addProduct = (product: Omit<Product, "id">) => {
|
||||
const newProduct: Product = { ...product, id: `p-${Date.now()}` };
|
||||
useEffect(() => {
|
||||
if (isAdmin) {
|
||||
startTransition(() => {
|
||||
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))
|
||||
);
|
||||
// Sync with server silently — no loading flash
|
||||
refreshReservations(true);
|
||||
};
|
||||
|
||||
const deleteReservation = (id: string) => {
|
||||
const deleteReservation = async (id: string) => {
|
||||
await bookingsApi.adminDeleteBooking(id);
|
||||
setReservations((prev) => prev.filter((r) => r.id !== id));
|
||||
};
|
||||
|
||||
@@ -79,13 +124,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,
|
||||
}}
|
||||
|
||||
80
contexts/AuthContext.tsx
Normal file
80
contexts/AuthContext.tsx
Normal file
@@ -0,0 +1,80 @@
|
||||
"use client";
|
||||
|
||||
import React, { createContext, useContext, useState, useEffect, startTransition, 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 | null>;
|
||||
logout: () => void;
|
||||
refreshUser: () => Promise<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) {
|
||||
startTransition(() => 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 | null> => {
|
||||
const profile = await authApi.register(email, password, name);
|
||||
if (profile) setUser(profile);
|
||||
return profile;
|
||||
};
|
||||
|
||||
const logout = () => {
|
||||
authApi.logout();
|
||||
setUser(null);
|
||||
};
|
||||
|
||||
const refreshUser = async () => {
|
||||
const profile = await authApi.getMe();
|
||||
setUser(profile);
|
||||
};
|
||||
|
||||
return (
|
||||
<AuthContext.Provider
|
||||
value={{
|
||||
user,
|
||||
isLoading,
|
||||
isAdmin: user?.role === "admin",
|
||||
login,
|
||||
register,
|
||||
logout,
|
||||
refreshUser,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export const useAuth = () => {
|
||||
const ctx = useContext(AuthContext);
|
||||
if (!ctx) throw new Error("useAuth must be used within AuthProvider");
|
||||
return ctx;
|
||||
};
|
||||
@@ -2,57 +2,392 @@
|
||||
|
||||
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.home": { fr: "Accueil", de: "Startseite", en: "Home" },
|
||||
"auth.confirm_email": { fr: "Vérifiez votre email pour confirmer votre compte", de: "Überprüfen Sie Ihre E-Mail, um Ihr Konto zu bestätigen", en: "Check your email to confirm your account" },
|
||||
"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" },
|
||||
"auth.forgot_link": { fr: "Mot de passe oublié ?", de: "Passwort vergessen?", en: "Forgot password?" },
|
||||
"auth.forgot_title": { fr: "Réinitialiser le mot de passe", de: "Passwort zurücksetzen", en: "Reset password" },
|
||||
"auth.forgot_subtitle": { fr: "Entrez votre email pour recevoir un lien de réinitialisation", de: "E-Mail eingeben, um einen Reset-Link zu erhalten", en: "Enter your email to receive a reset link" },
|
||||
"auth.forgot_send": { fr: "Envoyer le lien", de: "Link senden", en: "Send link" },
|
||||
"auth.forgot_sent": { fr: "Email envoyé ! Vérifiez votre boîte mail.", de: "E-Mail gesendet! Prüfen Sie Ihren Posteingang.", en: "Email sent! Check your inbox." },
|
||||
"auth.back_to_login": { fr: "Retour à la connexion", de: "Zurück zur Anmeldung", en: "Back to login" },
|
||||
"auth.admin_not_allowed": { fr: "Les administrateurs doivent utiliser l'espace admin", de: "Administratoren müssen den Admin-Bereich verwenden", en: "Administrators must use the admin login" },
|
||||
|
||||
// ── 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.new_booking": { fr: "Faire une nouvelle réservation", de: "Neue Buchung", en: "Make another booking" },
|
||||
"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_stock": { fr: "Stock", de: "Bestand", en: "Stock" },
|
||||
"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.stock_quantity": { fr: "Quantité en stock", de: "Lagerbestand", en: "Stock quantity" },
|
||||
"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" },
|
||||
"admin.settings.booking_price_label": { fr: "Prix de réservation par défaut (€)", de: "Standardpreis für Reservierungen (€)", en: "Default booking price (€)" },
|
||||
"admin.settings.booking_price_desc": { fr: "Prix appliqué à chaque rendez-vous (0 = gratuit)", de: "Preis pro Termin (0 = kostenlos)", en: "Price applied to each appointment (0 = free)" },
|
||||
};
|
||||
|
||||
const LOCALE_MAP: Record<Language, string> = {
|
||||
fr: "fr-FR",
|
||||
de: "de-DE",
|
||||
en: "en-GB",
|
||||
};
|
||||
|
||||
const LanguageContext = createContext<LanguageContextType | undefined>(undefined);
|
||||
@@ -60,15 +395,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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -14,6 +14,7 @@ export interface Product {
|
||||
isBestseller?: boolean;
|
||||
rating: number;
|
||||
reviewCount: number;
|
||||
stockQuantity?: number;
|
||||
}
|
||||
|
||||
export const products: Product[] = [
|
||||
|
||||
@@ -11,7 +11,7 @@ export function useIsMobile() {
|
||||
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
|
||||
}
|
||||
mql.addEventListener("change", onChange)
|
||||
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
|
||||
React.startTransition(() => setIsMobile(window.innerWidth < MOBILE_BREAKPOINT))
|
||||
return () => mql.removeEventListener("change", onChange)
|
||||
}, [])
|
||||
|
||||
|
||||
96
lib/api.ts
Normal file
96
lib/api.ts
Normal 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
18
lib/api/admin.ts
Normal 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");
|
||||
}
|
||||
55
lib/api/auth.ts
Normal file
55
lib/api/auth.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
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 | null> {
|
||||
const res = await api.post<AuthResponse | { message: string }>("/auth/register", { email, password, name });
|
||||
if ("access_token" in res) {
|
||||
setTokens(res.access_token, (res as AuthResponse).refresh_token);
|
||||
return api.get<UserProfile>("/auth/me");
|
||||
}
|
||||
// Email confirmation required — no session yet
|
||||
return null;
|
||||
}
|
||||
|
||||
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
143
lib/api/bookings.ts
Normal 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
9
lib/api/contact.ts
Normal 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
22
lib/api/customers.ts
Normal 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
61
lib/api/orders.ts
Normal 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 });
|
||||
}
|
||||
|
||||
120
lib/api/products.ts
Normal file
120
lib/api/products.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
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,
|
||||
stockQuantity: p.stock_quantity,
|
||||
};
|
||||
}
|
||||
|
||||
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
52
lib/api/services.ts
Normal 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
15
lib/api/settings.ts
Normal 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 });
|
||||
}
|
||||
Reference in New Issue
Block a user