diff --git a/app/a-propos/page.tsx b/app/a-propos/page.tsx index 1213563..8d61610 100644 --- a/app/a-propos/page.tsx +++ b/app/a-propos/page.tsx @@ -25,15 +25,9 @@ export default function About() { {/* Story */}
-

- 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. -

-

- 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é. -

-

- 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. -

+

{t("about.p1")}

+

{t("about.p2")}

+

{t("about.p3")}

{/* Values */} @@ -42,18 +36,18 @@ export default function About() {
-

Passion

-

Chaque produit est choisi avec amour et expertise pour garantir votre satisfaction.

+

{t("about.value1_title")}

+

{t("about.value1_desc")}

-

Qualité Premium

-

100% cheveux naturels Remy, sourcés éthiquement et contrôlés rigoureusement.

+

{t("about.value2_title")}

+

{t("about.value2_desc")}

-

Expertise

-

Conseils personnalisés et pose professionnelle pour un résultat naturel.

+

{t("about.value3_title")}

+

{t("about.value3_desc")}

diff --git a/app/admin/clients/page.tsx b/app/admin/clients/page.tsx new file mode 100644 index 0000000..813b700 --- /dev/null +++ b/app/admin/clients/page.tsx @@ -0,0 +1,153 @@ +"use client"; + +import { useState, useEffect, useCallback } from "react"; +import { Card } from "@/components/ui/card"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { + Table, TableBody, TableCell, TableHead, TableHeader, TableRow, +} from "@/components/ui/table"; +import { Search, Ban, CheckCircle, RefreshCw } from "lucide-react"; +import { toast } from "sonner"; +import { adminListCustomers, adminBlockCustomer, CustomerApi } from "@/lib/api/customers"; +import { useLanguage } from "@/contexts/LanguageContext"; +import { ApiError } from "@/lib/api"; + +export default function AdminClients() { + const { t, locale } = useLanguage(); + const [customers, setCustomers] = useState([]); + const [loading, setLoading] = useState(true); + const [search, setSearch] = useState(""); + const [query, setQuery] = useState(""); + const [updating, setUpdating] = useState(null); + + const load = useCallback(async () => { + setLoading(true); + try { + const res = await adminListCustomers(query || undefined); + setCustomers(res.data); + } catch (err) { + toast.error(err instanceof ApiError ? err.message : t("admin.error")); + } finally { + setLoading(false); + } + }, [query]); + + useEffect(() => { load(); }, [load]); + + const handleSearch = (e: React.FormEvent) => { + e.preventDefault(); + setQuery(search); + }; + + const toggleBlock = async (c: CustomerApi) => { + setUpdating(c.id); + try { + const updated = await adminBlockCustomer(c.id, !c.is_blocked); + setCustomers((prev) => prev.map((x) => (x.id === c.id ? { ...x, is_blocked: updated.is_blocked } : x))); + toast.success(updated.is_blocked ? t("admin.customers.blocked") : t("admin.customers.unblocked")); + } catch (err) { + toast.error(err instanceof ApiError ? err.message : t("admin.error")); + } finally { + setUpdating(null); + } + }; + + return ( +
+
+
+

{t("admin.customers.title")}

+

{customers.length} {t("admin.customers.subtitle")}

+
+ +
+ +
+ setSearch(e.target.value)} + /> + +
+ + + {loading ? ( +
+ {Array.from({ length: 5 }).map((_, i) => ( +
+ ))} +
+ ) : ( + + + + {t("admin.customers.col_name")} + {t("admin.customers.col_email")} + {t("admin.customers.col_phone")} + {t("admin.customers.col_orders")} + {t("admin.customers.col_bookings")} + {t("admin.customers.col_spent")} + {t("admin.customers.col_joined")} + {t("admin.status")} + + + + + {customers.length === 0 ? ( + + + {t("admin.customers.none")} + + + ) : ( + customers.map((c) => ( + + {c.full_name ?? "—"} + {c.email} + {c.phone ?? "—"} + {c.orders_count} + {c.bookings_count} + {Number(c.total_spent).toFixed(2)} € + + {new Date(c.created_at).toLocaleDateString(locale, { day: "2-digit", month: "short", year: "numeric" })} + + + {c.is_blocked ? ( + {t("admin.status.blocked")} + ) : ( + {t("admin.status.active")} + )} + + + + + + )) + )} + +
+ )} + +
+ ); +} diff --git a/app/admin/commandes/page.tsx b/app/admin/commandes/page.tsx new file mode 100644 index 0000000..8d0e5f7 --- /dev/null +++ b/app/admin/commandes/page.tsx @@ -0,0 +1,174 @@ +"use client"; + +import { useState, useEffect, useCallback } from "react"; +import { Card } from "@/components/ui/card"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { + Table, TableBody, TableCell, TableHead, TableHeader, TableRow, +} from "@/components/ui/table"; +import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { + DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { ChevronDown, RefreshCw } from "lucide-react"; +import { toast } from "sonner"; +import { adminListOrders, adminUpdateOrderStatus, AdminOrderApi } from "@/lib/api/orders"; +import { useLanguage } from "@/contexts/LanguageContext"; +import { ApiError } from "@/lib/api"; + +type Status = "all" | "pending" | "paid" | "shipped" | "delivered" | "cancelled" | "refunded"; + +const NEXT_STATUSES: Record = { + pending: ["paid", "cancelled"], + paid: ["shipped", "cancelled"], + shipped: ["delivered"], + delivered: [], + cancelled: [], + refunded: [], +}; + +export default function AdminCommandes() { + const { t, locale } = useLanguage(); + const [orders, setOrders] = useState([]); + const [loading, setLoading] = useState(true); + const [filter, setFilter] = useState("all"); + const [updating, setUpdating] = useState(null); + + const STATUS_CONFIG: Record = { + pending: { label: t("admin.status.pending"), variant: "secondary" }, + paid: { label: t("admin.status.paid"), variant: "default" }, + shipped: { label: t("admin.status.shipped"), variant: "outline" }, + delivered: { label: t("admin.status.delivered"), variant: "default" }, + cancelled: { label: t("admin.status.cancelled"), variant: "destructive" }, + refunded: { label: t("admin.status.refunded"), variant: "destructive" }, + }; + + const load = useCallback(async () => { + setLoading(true); + try { + const res = await adminListOrders(filter === "all" ? undefined : filter); + setOrders(res.data); + } catch (err) { + toast.error(err instanceof ApiError ? err.message : t("admin.error")); + } finally { + setLoading(false); + } + }, [filter]); + + useEffect(() => { load(); }, [load]); + + const handleStatus = async (id: string, status: string) => { + setUpdating(id); + try { + await adminUpdateOrderStatus(id, status); + setOrders((prev) => prev.map((o) => (o.id === id ? { ...o, status } : o))); + toast.success(t("admin.settings.saved")); + } catch (err) { + toast.error(err instanceof ApiError ? err.message : t("admin.error")); + } finally { + setUpdating(null); + } + }; + + return ( +
+
+
+

{t("admin.orders.title")}

+

{orders.length} {t("admin.orders.subtitle")}

+
+ +
+ + setFilter(v as Status)}> + + {t("admin.orders.tab_all")} + {t("admin.orders.tab_pending")} + {t("admin.orders.tab_paid")} + {t("admin.orders.tab_shipped")} + {t("admin.orders.tab_delivered")} + {t("admin.orders.tab_cancelled")} + + + + + {loading ? ( +
+ {Array.from({ length: 5 }).map((_, i) => ( +
+ ))} +
+ ) : ( + + + + {t("admin.orders.col_ref")} + {t("admin.orders.col_customer")} + {t("admin.orders.col_date")} + {t("admin.orders.col_total")} + {t("admin.status")} + {t("admin.actions")} + + + + {orders.length === 0 ? ( + + + {t("admin.orders.none")} + + + ) : ( + orders.map((o) => { + const cfg = STATUS_CONFIG[o.status] ?? { label: o.status, variant: "outline" as const }; + const nextStatuses = NEXT_STATUSES[o.status] ?? []; + return ( + + + #{o.id.slice(0, 8).toUpperCase()} + + +
{o.client_name ?? "—"}
+
{o.client_email ?? ""}
+
+ + {new Date(o.created_at).toLocaleDateString(locale, { day: "2-digit", month: "short", year: "numeric" })} + + {o.total_amount.toFixed(2)} € + + {cfg.label} + + + {nextStatuses.length > 0 ? ( + + + + + + {nextStatuses.map((s) => ( + handleStatus(o.id, s)}> + {STATUS_CONFIG[s]?.label ?? s} + + ))} + + + ) : ( + + )} + +
+ ); + }) + )} +
+
+ )} + +
+ ); +} diff --git a/app/admin/layout.tsx b/app/admin/layout.tsx index 1e77a43..d942c24 100644 --- a/app/admin/layout.tsx +++ b/app/admin/layout.tsx @@ -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 { 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 ( - //
- //
Chargement...
- //
- // ); - // } + 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 ( +
+
{t("admin.loading")}
+
+ ); + } + + if (!isAdmin) { + return null; + } + // Layout pour l'admin connecté (avec la sidebar) return (
-
- -

- Tableau de bord -

+
+
+ +

+ {t("admin.header")} +

+
+
{children}
diff --git a/app/admin/login/page.tsx b/app/admin/login/page.tsx index 205a165..aa9856f 100644 --- a/app/admin/login/page.tsx +++ b/app/admin/login/page.tsx @@ -2,33 +2,39 @@ import { useState } from "react"; import { Lock } from "lucide-react"; -import { useAdmin } from "@/contexts/AdminContext"; +import { useAuth } from "@/contexts/AuthContext"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { ApiError } from "@/lib/api"; import { toast } from "sonner"; import { useRouter } from "next/navigation"; export default function AdminLogin() { - const { login, isAdmin } = useAdmin(); - const route = useRouter(); + const { login } = useAuth(); + const router = useRouter(); + const [email, setEmail] = useState(""); const [password, setPassword] = useState(""); const [loading, setLoading] = useState(false); - const handleSubmit = (e: React.FormEvent) => { + const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); setLoading(true); - setTimeout(() => { - const ok = login(password); - setLoading(false); - if (ok) { - toast.success("Connexion réussie"); - route.push("/admin"); - } else { - toast.error("Mot de passe incorrect"); + try { + const profile = await login(email, password); + if (profile.role !== "admin") { + toast.error("Accès refusé : rôle administrateur requis"); + return; } - }, 300); + 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); + } }; return ( @@ -43,6 +49,17 @@ export default function AdminLogin() {
+
+ + setEmail(e.target.value)} + required + autoFocus + /> +
setPassword(e.target.value)} placeholder="••••••••" required - autoFocus /> -

Démo : admin123

); -}; +} diff --git a/app/admin/page.tsx b/app/admin/page.tsx index cedd0b8..1960f9f 100644 --- a/app/admin/page.tsx +++ b/app/admin/page.tsx @@ -1,59 +1,111 @@ "use client"; -import { Package, CalendarCheck, Clock, TrendingUp } from "lucide-react"; +import { useEffect, useState } from "react"; +import { Package, CalendarCheck, Clock, TrendingUp, ShoppingBag, Users, AlertTriangle, Euro } from "lucide-react"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Badge } from "@/components/ui/badge"; import { useAdmin } from "@/contexts/AdminContext"; +import { useLanguage } from "@/contexts/LanguageContext"; +import { getDashboardStats, DashboardStats } from "@/lib/api/admin"; export default function AdminOverview() { - const { products, reservations } = useAdmin(); + const { reservations } = useAdmin(); + const { t, locale } = useLanguage(); + const [stats, setStats] = useState(null); - const pending = reservations.filter((r) => r.status === "pending").length; - const confirmed = reservations.filter((r) => r.status === "confirmed").length; - const totalValue = products.reduce((sum, p) => sum + p.price, 0); - - const stats = [ - { label: "Produits", value: products.length, icon: Package, color: "text-blue-600", bg: "bg-blue-100" }, - { label: "RDV en attente", value: pending, icon: Clock, color: "text-amber-600", bg: "bg-amber-100" }, - { label: "RDV confirmés", value: confirmed, icon: CalendarCheck, color: "text-green-600", bg: "bg-green-100" }, - { label: "Valeur catalogue", value: `${totalValue} €`, icon: TrendingUp, color: "text-primary", bg: "bg-primary/10" }, - ]; + useEffect(() => { + getDashboardStats().then(setStats).catch((e) => console.error("[admin] getDashboardStats failed:", e)); + }, []); const upcoming = [...reservations] .filter((r) => r.status !== "cancelled") .sort((a, b) => `${a.date}${a.time}`.localeCompare(`${b.date}${b.time}`)) .slice(0, 5); + const mainCards = stats + ? [ + { label: t("admin.overview.orders_pending"), value: stats.orders_pending, icon: ShoppingBag, color: "text-orange-600", bg: "bg-orange-100" }, + { label: t("admin.overview.bookings_pending"), value: stats.bookings_pending, icon: Clock, color: "text-amber-600", bg: "bg-amber-100" }, + { label: t("admin.overview.bookings_confirmed"),value: stats.bookings_confirmed,icon: CalendarCheck,color: "text-green-600", bg: "bg-green-100" }, + { label: t("admin.overview.products_count"), value: stats.products_count, icon: Package, color: "text-blue-600", bg: "bg-blue-100" }, + ] + : []; + + const revenueCards = stats + ? [ + { label: t("admin.overview.revenue_today"), value: `${stats.revenue_today.toFixed(2)} €`, icon: Euro, color: "text-emerald-600", bg: "bg-emerald-100" }, + { label: t("admin.overview.revenue_week"), value: `${stats.revenue_week.toFixed(2)} €`, icon: TrendingUp, color: "text-emerald-600", bg: "bg-emerald-100" }, + { label: t("admin.overview.revenue_month"), value: `${stats.revenue_month.toFixed(2)} €`, icon: TrendingUp, color: "text-primary", bg: "bg-primary/10" }, + { label: t("admin.overview.new_customers"), value: stats.new_customers_month, icon: Users, color: "text-purple-600", bg: "bg-purple-100" }, + ] + : []; + + const Skeleton = () => ( +
+ {Array.from({ length: 4 }).map((_, i) => ( + + +
+
+ + + ))} +
+ ); + + const StatGrid = ({ cards }: { cards: typeof mainCards }) => ( +
+ {cards.map((s) => ( + + +
+

{s.label}

+

{s.value}

+
+
+ +
+
+
+ ))} +
+ ); + return ( -
+
-

Vue d'ensemble

-

Aperçu de votre activité

+

{t("admin.overview.title")}

+

{t("admin.overview.subtitle")}

-
- {stats.map((s) => ( - - -
-

{s.label}

-

{s.value}

-
-
- -
-
-
- ))} +
+

{t("admin.overview.activity_section")}

+ {stats === null ? : }
+
+

{t("admin.overview.revenue_section")}

+ {stats === null ? : } +
+ + {stats && stats.low_stock_count > 0 && ( + + + +

+ {t("admin.overview.low_stock", { n: stats.low_stock_count })} +

+
+
+ )} + - Prochains rendez-vous + {t("admin.overview.upcoming_title")} {upcoming.length === 0 ? ( -

Aucun rendez-vous à venir.

+

{t("admin.overview.no_upcoming")}

) : (
{upcoming.map((r) => ( @@ -63,9 +115,11 @@ export default function AdminOverview() {

{r.service}

-

{new Date(r.date).toLocaleDateString("fr-FR", { day: "2-digit", month: "short" })} à {r.time}

+

+ {new Date(r.date).toLocaleDateString(locale, { day: "2-digit", month: "short" })} à {r.time} +

- {r.status === "confirmed" ? "Confirmé" : "En attente"} + {r.status === "confirmed" ? t("admin.status.confirmed") : t("admin.status.pending")}
@@ -76,4 +130,4 @@ export default function AdminOverview() {
); -}; +} diff --git a/app/admin/parametres/page.tsx b/app/admin/parametres/page.tsx new file mode 100644 index 0000000..4d92c52 --- /dev/null +++ b/app/admin/parametres/page.tsx @@ -0,0 +1,143 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { toast } from "sonner"; +import { adminGetSettings, adminUpdateSetting, StoreSetting } from "@/lib/api/settings"; +import { useLanguage } from "@/contexts/LanguageContext"; +import { ApiError } from "@/lib/api"; +import { Save } from "lucide-react"; + +export default function AdminParametres() { + const { t, locale } = useLanguage(); + const [settings, setSettings] = useState([]); + const [values, setValues] = useState>({}); + const [saving, setSaving] = useState(null); + const [loading, setLoading] = useState(true); + + const SETTING_META: Record = { + default_booking_price: { + label: t("booking.free") === "Free" ? "Default booking price (€)" : t("booking.free") === "Kostenlos" ? "Standardpreis für Reservierungen (€)" : "Prix de réservation par défaut (€)", + description: t("booking.free") === "Free" ? "Price applied to each appointment (0 = free)" : t("booking.free") === "Kostenlos" ? "Preis pro Termin (0 = kostenlos)" : "Prix appliqué à chaque rendez-vous (0 = gratuit)", + type: "number", + }, + }; + + useEffect(() => { + adminGetSettings() + .then((rows) => { + setSettings(rows); + const init: Record = {}; + rows.forEach((r) => { + init[r.key] = r.value !== null && r.value !== undefined ? String(r.value) : ""; + }); + setValues(init); + }) + .catch((e) => toast.error(e instanceof ApiError ? e.message : t("admin.error"))) + .finally(() => setLoading(false)); + }, []); + + const handleSave = async (key: string) => { + setSaving(key); + try { + const meta = SETTING_META[key]; + const raw = values[key] ?? ""; + const value = meta?.type === "number" ? parseFloat(raw) : raw; + await adminUpdateSetting(key, value); + toast.success(t("admin.settings.saved")); + } catch (err) { + toast.error(err instanceof ApiError ? err.message : t("admin.error")); + } finally { + setSaving(null); + } + }; + + const knownKeys = settings.filter((s) => SETTING_META[s.key]); + const unknownKeys = settings.filter((s) => !SETTING_META[s.key]); + + return ( +
+
+

{t("admin.settings.title")}

+

{t("admin.settings.subtitle")}

+
+ + {loading ? ( +
+ {Array.from({ length: 3 }).map((_, i) => ( +
+ ))} +
+ ) : ( + <> + + + {t("admin.settings.bookings")} + + + {knownKeys.map((s) => { + const meta = SETTING_META[s.key]; + return ( +
+ +

{meta.description}

+
+ setValues((prev) => ({ ...prev, [s.key]: e.target.value }))} + className="max-w-[200px]" + /> + +
+ {s.updated_at && ( +

+ {t("admin.settings.last_updated")} {new Date(s.updated_at).toLocaleDateString(locale)} +

+ )} +
+ ); + })} + {knownKeys.length === 0 && ( +

{t("admin.settings.no_settings")}

+ )} +
+
+ + {unknownKeys.length > 0 && ( + + + {t("admin.settings.other")} + + + {unknownKeys.map((s) => ( +
+ +
+ setValues((prev) => ({ ...prev, [s.key]: e.target.value }))} + /> + +
+
+ ))} +
+
+ )} + + )} +
+ ); +} diff --git a/app/admin/planning/page.tsx b/app/admin/planning/page.tsx new file mode 100644 index 0000000..3ad6145 --- /dev/null +++ b/app/admin/planning/page.tsx @@ -0,0 +1,574 @@ +"use client"; + +import { useState, useEffect, useCallback } from "react"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Badge } from "@/components/ui/badge"; +import { ChevronLeft, ChevronRight, Plus, Trash2, RefreshCw, Ban, Check } from "lucide-react"; +import { toast } from "sonner"; +import { + adminGetSchedule, adminCreateSchedule, adminDeleteSchedule, + adminListSlots, adminGenerateSlots, adminUpdateSlot, adminDeleteSlot, + adminGetBlockedDates, adminAddBlockedDate, adminRemoveBlockedDate, + WeeklySchedule, BlockedDate, TimeSlotApi, +} from "@/lib/api/bookings"; +import { useLanguage } from "@/contexts/LanguageContext"; + +const DURATIONS = [30, 45, 60, 90, 120]; + +function toDateStr(d: Date): string { + return d.toISOString().slice(0, 10); +} + +function monthStart(year: number, month: number) { + return new Date(year, month, 1); +} + +function daysInMonth(year: number, month: number) { + return new Date(year, month + 1, 0).getDate(); +} + +// Monday-first weekday index (0=Mon, 6=Sun) +function weekdayMon(date: Date) { + return (date.getDay() + 6) % 7; +} + +// ── Schedule Tab ────────────────────────────────────────────────────────────── + +function ScheduleTab() { + const { t } = useLanguage(); + const DAY_NAMES = Array.from({ length: 7 }, (_, i) => t(`admin.day.${i}`)); + + const [schedule, setSchedule] = useState([]); + 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 ( +
+ + + {t("admin.planning.weekly_title")} + + + {loading ? ( +
+ {Array.from({ length: 3 }).map((_, i) => ( +
+ ))} +
+ ) : ( +
+ {grouped.map(({ name, entries }) => ( +
+ {name} +
+ {entries.length === 0 ? ( + {t("admin.planning.not_available")} + ) : ( + entries.map((e) => ( +
+ {e.start_time.slice(0, 5)} – {e.end_time.slice(0, 5)} + ({e.slot_duration_minutes} min) + +
+ )) + )} +
+
+ ))} +
+ )} + + + + + + {t("admin.planning.add_title")} + + +
+
+ + +
+
+ + setForm((f) => ({ ...f, start_time: e.target.value }))} + /> +
+
+ + setForm((f) => ({ ...f, end_time: e.target.value }))} + /> +
+
+ + +
+
+ +
+
+ + + + {t("admin.planning.generate_title")} + + +

{t("admin.planning.generate_desc")}

+
+
+ + setGenFrom(e.target.value)} /> +
+
+ + setGenTo(e.target.value)} /> +
+ +
+
+
+
+ ); +} + +// ── 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([]); + const [loadingSlots, setLoadingSlots] = useState(false); + const [selectedDay, setSelectedDay] = useState(null); + + const loadSlots = useCallback(() => { + setLoadingSlots(true); + const from = toDateStr(monthStart(year, month)); + const last = new Date(year, month + 1, 0); + const to = toDateStr(last); + adminListSlots(from, to) + .then(setSlots) + .catch(() => toast.error(t("admin.error"))) + .finally(() => setLoadingSlots(false)); + }, [year, month]); + + useEffect(() => { loadSlots(); }, [loadSlots]); + + const prevMonth = () => { + if (month === 0) { setYear((y) => y - 1); setMonth(11); } + else setMonth((m) => m - 1); + setSelectedDay(null); + }; + + const nextMonth = () => { + if (month === 11) { setYear((y) => y + 1); setMonth(0); } + else setMonth((m) => m + 1); + setSelectedDay(null); + }; + + const blockSlot = async (slot: TimeSlotApi) => { + try { + const updated = await adminUpdateSlot(slot.id, !slot.is_blocked); + setSlots((prev) => prev.map((s) => s.id === slot.id ? updated : s)); + toast.success(slot.is_blocked ? t("admin.planning.slot_unblocked") : t("admin.planning.slot_blocked")); + } catch { + toast.error(t("admin.error")); + } + }; + + const deleteSlot = async (id: string) => { + try { + await adminDeleteSlot(id); + setSlots((prev) => prev.filter((s) => s.id !== id)); + toast.success(t("admin.planning.slot_deleted")); + } catch { + toast.error(t("admin.error")); + } + }; + + const firstDay = monthStart(year, month); + const totalDays = daysInMonth(year, month); + const startOffset = weekdayMon(firstDay); + const cells: (number | null)[] = [ + ...Array(startOffset).fill(null), + ...Array.from({ length: totalDays }, (_, i) => i + 1), + ]; + while (cells.length % 7 !== 0) cells.push(null); + + const slotsByDay: Record = {}; + slots.forEach((s) => { + (slotsByDay[s.date] ||= []).push(s); + }); + + const selectedSlots = selectedDay ? (slotsByDay[selectedDay] ?? []) : []; + + return ( +
+
+ +

{MONTH_NAMES[month]} {year}

+ +
+ + + +
+ {DAY_NAMES.map((d) => ( +
+ {d.slice(0, 3)} +
+ ))} + {cells.map((day, i) => { + if (!day) return
; + 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 ( + + ); + })} +
+ + + + {selectedDay && ( + + + + {t("admin.planning.slots_for")} {new Date(selectedDay + "T00:00:00").toLocaleDateString(locale, { + weekday: "long", day: "numeric", month: "long" + })} + + + + {loadingSlots ? ( +
{t("admin.loading")}
+ ) : selectedSlots.length === 0 ? ( +

{t("admin.planning.no_slots")}

+ ) : ( +
+ {selectedSlots + .sort((a, b) => a.start_time.localeCompare(b.start_time)) + .map((slot) => ( +
+
+ {slot.start_time.slice(0, 5)} – {slot.end_time.slice(0, 5)} + {slot.is_booked && {t("admin.status.booked")}} + {slot.is_blocked && {t("admin.status.blocked")}} + {!slot.is_booked && !slot.is_blocked && {t("admin.status.free")}} +
+ {!slot.is_booked && ( +
+ + +
+ )} +
+ ))} +
+ )} +
+
+ )} +
+ ); +} + +// ── Blocked Dates Tab ───────────────────────────────────────────────────────── + +function BlockedDatesTab() { + const { t, locale } = useLanguage(); + const [dates, setDates] = useState([]); + 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 ( +
+ + + {t("admin.planning.block_title")} + + +

{t("admin.planning.block_desc")}

+
+
+ + setNewDate(e.target.value)} /> +
+
+ + setNewReason(e.target.value)} /> +
+ +
+
+
+ + + + {t("admin.planning.blocked_list")} + + + {loading ? ( +
+ {Array.from({ length: 3 }).map((_, i) => ( +
+ ))} +
+ ) : dates.length === 0 ? ( +

{t("admin.planning.no_blocked")}

+ ) : ( +
+ {dates.map((d) => ( +
+
+ + {new Date(d.date + "T00:00:00").toLocaleDateString(locale, { + weekday: "long", day: "numeric", month: "long", year: "numeric", + })} + + {d.reason && — {d.reason}} +
+ +
+ ))} +
+ )} + + +
+ ); +} + +// ── Main Page ───────────────────────────────────────────────────────────────── + +type Tab = "schedule" | "calendar" | "blocked"; + +export default function PlanningPage() { + const { t } = useLanguage(); + const [tab, setTab] = useState("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 ( +
+
+

{t("admin.planning.title")}

+

{t("admin.planning.subtitle")}

+
+ +
+ {tabs.map((item) => ( + + ))} +
+ + {tab === "schedule" && } + {tab === "calendar" && } + {tab === "blocked" && } +
+ ); +} diff --git a/app/admin/produits/page.tsx b/app/admin/produits/page.tsx index 7834c57..eaef62d 100644 --- a/app/admin/produits/page.tsx +++ b/app/admin/produits/page.tsx @@ -1,7 +1,7 @@ "use client"; -import { useState } from "react"; -import { Plus, Pencil, Trash2 } from "lucide-react"; +import { useState, useRef } from "react"; +import { Plus, Pencil, Trash2, Upload, X } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Card } from "@/components/ui/card"; import { Input } from "@/components/ui/input"; @@ -9,39 +9,22 @@ import { Label } from "@/components/ui/label"; import { Textarea } from "@/components/ui/textarea"; import { Badge } from "@/components/ui/badge"; import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, + Table, TableBody, TableCell, TableHead, TableHeader, TableRow, } from "@/components/ui/table"; import { - Dialog, - DialogContent, - DialogFooter, - DialogHeader, - DialogTitle, - DialogDescription, + Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle, DialogDescription, } from "@/components/ui/dialog"; import { - AlertDialog, - AlertDialogAction, - AlertDialogCancel, - AlertDialogContent, - AlertDialogDescription, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogTitle, + AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, + AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, } from "@/components/ui/alert-dialog"; import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, + Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@/components/ui/select"; import { useAdmin } from "@/contexts/AdminContext"; +import { useLanguage } from "@/contexts/LanguageContext"; +import { ApiError } from "@/lib/api"; +import { adminUploadProductImage } from "@/lib/api/products"; import { Product } from "@/data/products"; import { toast } from "sonner"; @@ -49,7 +32,7 @@ type FormState = { name: string; category: Product["category"]; price: string; - image: string; + original_price: string; description: string; colors: string; lengths: string; @@ -61,7 +44,7 @@ const emptyForm: FormState = { name: "", category: "clip-in", price: "", - image: "", + original_price: "", description: "", colors: "", lengths: "", @@ -70,15 +53,29 @@ const emptyForm: FormState = { }; export default function AdminProducts() { - const { products, addProduct, updateProduct, deleteProduct } = useAdmin(); + const { products, productsLoading, addProduct, updateProduct, deleteProduct, refreshProducts } = useAdmin(); + const { t } = useLanguage(); const [dialogOpen, setDialogOpen] = useState(false); const [editingId, setEditingId] = useState(null); const [form, setForm] = useState(emptyForm); const [deleteId, setDeleteId] = useState(null); + const [saving, setSaving] = useState(false); + const [imageFile, setImageFile] = useState(null); + const [imagePreview, setImagePreview] = useState(null); + const fileInputRef = useRef(null); + + const categoryLabels: Record = { + "clip-in": "Clip-In", + "tape-in": "Tape-In", + "ponytail": "Ponytail", + "keratin": t("admin.products.category") === "Kategorie" ? "Keratin" : t("admin.products.category") === "Category" ? "Keratin" : "Kératine", + }; const openCreate = () => { setEditingId(null); setForm(emptyForm); + setImageFile(null); + setImagePreview(null); setDialogOpen(true); }; @@ -88,21 +85,36 @@ export default function AdminProducts() { name: p.name, category: p.category, price: String(p.price), - image: p.image, + original_price: p.originalPrice ? String(p.originalPrice) : "", description: p.description, colors: p.colors.join(", "), lengths: p.lengths.join(", "), isNew: !!p.isNew, isBestseller: !!p.isBestseller, }); + setImageFile(null); + setImagePreview(p.image || null); setDialogOpen(true); }; - const handleSubmit = (e: React.FormEvent) => { + const handleFileChange = (e: React.ChangeEvent) => { + 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,109 +122,131 @@ export default function AdminProducts() { name: form.name, category: form.category, price, - image: form.image || "https://images.unsplash.com/photo-1522337360788-8b13dee7a37e?w=600&h=800&fit=crop", - images: [form.image || "https://images.unsplash.com/photo-1522337360788-8b13dee7a37e?w=600&h=800&fit=crop"], + original_price: form.original_price ? parseFloat(form.original_price) : undefined, colors: form.colors.split(",").map((c) => c.trim()).filter(Boolean), lengths: form.lengths.split(",").map((l) => l.trim()).filter(Boolean), description: form.description, features: [], - isNew: form.isNew, - isBestseller: form.isBestseller, - rating: 5, - reviewCount: 0, + is_new: form.isNew, + is_bestseller: form.isBestseller, }; - if (editingId) { - updateProduct(editingId, payload); - toast.success("Produit modifié"); - } else { - addProduct(payload); - toast.success("Produit ajouté"); + setSaving(true); + try { + let savedId = editingId; + if (editingId) { + await updateProduct(editingId, payload); + } else { + 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); } - setDialogOpen(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 = { - "clip-in": "Clip-In", - "tape-in": "Tape-In", - "ponytail": "Ponytail", - "keratin": "Kératine", - }; - return (
-

Produits

-

{products.length} produit{products.length > 1 ? "s" : ""} au catalogue

+

{t("admin.products.title")}

+

+ {products.length} {t("admin.products.subtitle")} +

- - - - Image - Nom - Catégorie - Prix - Statut - Actions - - - - {products.map((p) => ( - - - {p.name} - - {p.name} - {categoryLabels[p.category]} - {p.price} € - - {p.isNew && Nouveau} - {p.isBestseller && Bestseller} - - - - - - + {productsLoading ? ( +
+ {Array.from({ length: 4 }).map((_, i) => ( +
))} - -
+
+ ) : ( + + + + {t("admin.products.col_image")} + {t("admin.products.col_name")} + {t("admin.products.col_category")} + {t("admin.products.col_price")} + {t("admin.products.col_status")} + {t("admin.actions")} + + + + {products.map((p) => ( + + + {p.image ? ( + {p.name} + ) : ( +
+ +
+ )} +
+ {p.name} + {categoryLabels[p.category]} + {p.price} € + + {p.isNew && {t("admin.products.badge_new")}} + {p.isBestseller && Bestseller} + + + + + +
+ ))} +
+
+ )}
- {editingId ? "Modifier le produit" : "Nouveau produit"} - Renseignez les informations du produit + {editingId ? t("admin.products.edit_title") : t("admin.products.create_title")} + {t("admin.products.form_desc")}
- + setForm({ ...form, name: e.target.value })} required />
- +
- + setForm({ ...form, price: e.target.value })} required />
-
- - setForm({ ...form, image: e.target.value })} placeholder="https://..." /> +
+ + setForm({ ...form, original_price: e.target.value })} placeholder={t("admin.products.optional")} />
- + +
+ {imagePreview && ( +
+ preview + {imageFile && ( + + )} +
+ )} +
+ + + {imageFile &&

{imageFile.name}

} +
+
+
+
+