mirror of
http://88.130.71.182:3000/BlitTech/badoHair_fe.git
synced 2026-06-13 08:58:31 +00:00
Fix product stock, booking actions, settings, reservation UX, nav and category filter
This commit is contained in:
@@ -110,7 +110,7 @@ export default function AdminClients() {
|
||||
) : (
|
||||
customers.map((c) => (
|
||||
<TableRow key={c.id}>
|
||||
<TableCell className="font-medium">{c.full_name ?? "—"}</TableCell>
|
||||
<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>
|
||||
|
||||
@@ -6,22 +6,27 @@ 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 { 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 [settings, setSettings] = useState<StoreSetting[]>([]);
|
||||
const [values, setValues] = useState<Record<string, string>>({});
|
||||
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<string, { label: string; description: string; type: "number" | "text" }> = {
|
||||
const SETTING_META: Record<SettingKey, SettingMeta> = {
|
||||
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)",
|
||||
label: t("admin.settings.booking_price_label"),
|
||||
description: t("admin.settings.booking_price_desc"),
|
||||
type: "number",
|
||||
},
|
||||
};
|
||||
@@ -29,12 +34,14 @@ export default function AdminParametres() {
|
||||
useEffect(() => {
|
||||
adminGetSettings()
|
||||
.then((rows) => {
|
||||
setSettings(rows);
|
||||
const init: Record<string, string> = {};
|
||||
const vals: Record<string, string> = { default_booking_price: "0" };
|
||||
const dates: Record<string, string> = {};
|
||||
rows.forEach((r) => {
|
||||
init[r.key] = r.value !== null && r.value !== undefined ? String(r.value) : "";
|
||||
vals[r.key] = r.value !== null && r.value !== undefined ? String(r.value) : "";
|
||||
if (r.updated_at) dates[r.key] = r.updated_at;
|
||||
});
|
||||
setValues(init);
|
||||
setValues(vals);
|
||||
setUpdatedAt(dates);
|
||||
})
|
||||
.catch((e) => toast.error(e instanceof ApiError ? e.message : t("admin.error")))
|
||||
.finally(() => setLoading(false));
|
||||
@@ -43,10 +50,11 @@ export default function AdminParametres() {
|
||||
const handleSave = async (key: string) => {
|
||||
setSaving(key);
|
||||
try {
|
||||
const meta = SETTING_META[key];
|
||||
const meta = SETTING_META[key as SettingKey];
|
||||
const raw = values[key] ?? "";
|
||||
const value = meta?.type === "number" ? parseFloat(raw) : raw;
|
||||
await adminUpdateSetting(key, value);
|
||||
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"));
|
||||
@@ -55,9 +63,6 @@ export default function AdminParametres() {
|
||||
}
|
||||
};
|
||||
|
||||
const knownKeys = settings.filter((s) => SETTING_META[s.key]);
|
||||
const unknownKeys = settings.filter((s) => !SETTING_META[s.key]);
|
||||
|
||||
return (
|
||||
<div className="space-y-8 max-w-2xl">
|
||||
<div>
|
||||
@@ -67,76 +72,46 @@ export default function AdminParametres() {
|
||||
|
||||
{loading ? (
|
||||
<div className="space-y-4">
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
{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">
|
||||
{knownKeys.map((s) => {
|
||||
const meta = SETTING_META[s.key];
|
||||
return (
|
||||
<div key={s.key}>
|
||||
<Label htmlFor={s.key}>{meta.label}</Label>
|
||||
<p className="text-xs text-muted-foreground mb-2">{meta.description}</p>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
id={s.key}
|
||||
type={meta.type}
|
||||
value={values[s.key] ?? ""}
|
||||
onChange={(e) => setValues((prev) => ({ ...prev, [s.key]: e.target.value }))}
|
||||
className="max-w-[200px]"
|
||||
/>
|
||||
<Button size="sm" onClick={() => handleSave(s.key)} disabled={saving === s.key}>
|
||||
<Save className="h-4 w-4 mr-2" />
|
||||
{saving === s.key ? t("admin.saving") : t("admin.save")}
|
||||
</Button>
|
||||
</div>
|
||||
{s.updated_at && (
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
{t("admin.settings.last_updated")} {new Date(s.updated_at).toLocaleDateString(locale)}
|
||||
</p>
|
||||
)}
|
||||
<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>
|
||||
);
|
||||
})}
|
||||
{knownKeys.length === 0 && (
|
||||
<p className="text-sm text-muted-foreground">{t("admin.settings.no_settings")}</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{unknownKeys.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">{t("admin.settings.other")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{unknownKeys.map((s) => (
|
||||
<div key={s.key}>
|
||||
<Label htmlFor={`u-${s.key}`}>{s.key}</Label>
|
||||
<div className="flex gap-2 mt-1">
|
||||
<Input
|
||||
id={`u-${s.key}`}
|
||||
value={values[s.key] ?? ""}
|
||||
onChange={(e) => setValues((prev) => ({ ...prev, [s.key]: e.target.value }))}
|
||||
/>
|
||||
<Button size="sm" onClick={() => handleSave(s.key)} disabled={saving === s.key}>
|
||||
<Save className="h-4 w-4 mr-2" />
|
||||
{saving === s.key ? t("admin.saving") : t("admin.save")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</>
|
||||
{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>
|
||||
);
|
||||
|
||||
@@ -33,6 +33,7 @@ type FormState = {
|
||||
category: Product["category"];
|
||||
price: string;
|
||||
original_price: string;
|
||||
stock_quantity: string;
|
||||
description: string;
|
||||
colors: string;
|
||||
lengths: string;
|
||||
@@ -45,6 +46,7 @@ const emptyForm: FormState = {
|
||||
category: "clip-in",
|
||||
price: "",
|
||||
original_price: "",
|
||||
stock_quantity: "0",
|
||||
description: "",
|
||||
colors: "",
|
||||
lengths: "",
|
||||
@@ -86,6 +88,7 @@ export default function AdminProducts() {
|
||||
category: p.category,
|
||||
price: String(p.price),
|
||||
original_price: p.originalPrice ? String(p.originalPrice) : "",
|
||||
stock_quantity: String(p.stockQuantity ?? 0),
|
||||
description: p.description,
|
||||
colors: p.colors.join(", "),
|
||||
lengths: p.lengths.join(", "),
|
||||
@@ -123,6 +126,7 @@ export default function AdminProducts() {
|
||||
category: form.category,
|
||||
price,
|
||||
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,
|
||||
@@ -195,6 +199,7 @@ export default function AdminProducts() {
|
||||
<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>
|
||||
@@ -214,6 +219,9 @@ export default function AdminProducts() {
|
||||
<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">{t("admin.products.badge_new")}</Badge>}
|
||||
{p.isBestseller && <Badge>Bestseller</Badge>}
|
||||
@@ -265,6 +273,10 @@ export default function AdminProducts() {
|
||||
<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="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">
|
||||
|
||||
@@ -1,18 +1,25 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { Suspense, useState, useEffect } from "react";
|
||||
import { useLanguage } from "@/contexts/LanguageContext";
|
||||
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 [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 setCategory = (cat: string) => {
|
||||
router.push(cat === "all" ? "/boutique" : `/boutique?category=${cat}`);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true);
|
||||
listProducts({
|
||||
@@ -31,7 +38,7 @@ export default function Shop() {
|
||||
|
||||
<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"
|
||||
@@ -43,7 +50,7 @@ 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"
|
||||
@@ -74,3 +81,11 @@ export default function Shop() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Shop() {
|
||||
return (
|
||||
<Suspense>
|
||||
<ShopContent />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -21,7 +21,9 @@ export default function Auth() {
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (user) router.replace("/");
|
||||
if (user) {
|
||||
router.replace(user.role === "admin" ? "/admin" : "/");
|
||||
}
|
||||
}, [user, router]);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
@@ -29,12 +31,19 @@ export default function Auth() {
|
||||
setLoading(true);
|
||||
try {
|
||||
if (isLogin) {
|
||||
await login(email, password);
|
||||
const profile = await login(email, password);
|
||||
toast.success(t("auth.login_success"));
|
||||
router.push(profile.role === "admin" ? "/admin" : "/");
|
||||
} else {
|
||||
await register(email, password, name);
|
||||
const profile = await register(email, password, name);
|
||||
if (profile) {
|
||||
toast.success(t("auth.register_success"));
|
||||
router.push(profile.role === "admin" ? "/admin" : "/");
|
||||
} else {
|
||||
toast.success(t("auth.confirm_email"));
|
||||
router.push("/connexion");
|
||||
}
|
||||
}
|
||||
toast.success(isLogin ? t("auth.login_success") : t("auth.register_success"));
|
||||
setTimeout(() => router.push("/"), 800);
|
||||
} catch (err) {
|
||||
const msg = err instanceof ApiError ? err.message : t("auth.error");
|
||||
toast.error(msg);
|
||||
|
||||
@@ -81,6 +81,15 @@ export default function Booking() {
|
||||
}
|
||||
};
|
||||
|
||||
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">
|
||||
@@ -92,9 +101,12 @@ export default function Booking() {
|
||||
<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">
|
||||
<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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user