Initial commit: JuanitoSport - Muay Thai & Yog4Fight PWA

This commit is contained in:
Wycz
2026-09-08 08:25:33 +02:00
commit a69ca86330
70 changed files with 4301 additions and 0 deletions
+61
View File
@@ -0,0 +1,61 @@
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
import Header from "@/components/Header";
const adminLinks = [
{ href: "/admin", label: "Vue d'ensemble", icon: "🏠" },
{ href: "/admin/users", label: "Membres", icon: "👥" },
{ href: "/admin/messages", label: "Messages", icon: "💬" },
{ href: "/admin/media", label: "Photos & vidéos", icon: "🎬" },
{ href: "/admin/homework", label: "Exercices", icon: "🏋️" },
{ href: "/admin/certificates", label: "Certificats", icon: "📄" },
{ href: "/admin/yoga", label: "Gestion Yoga", icon: "🧘" },
];
export default function AdminLayout({
children,
}: {
children: React.ReactNode;
}) {
const pathname = usePathname();
return (
<main className="min-h-screen pb-20">
<Header />
<div className="mx-auto max-w-3xl px-4 py-6">
<h1 className="mb-1 text-2xl font-extrabold">
⚙️ Tableau de bord Juanito
</h1>
<p className="mb-5 text-sm text-white/60">
Gère ton club JuanitoSport
</p>
<nav className="mb-6 flex gap-2 overflow-x-auto pb-2 -mx-4 px-4">
{adminLinks.map((link) => {
const active =
pathname === link.href ||
(link.href !== "/admin" && pathname.startsWith(link.href));
return (
<Link
key={link.href}
href={link.href}
className={`flex shrink-0 items-center gap-1.5 rounded-xl px-4 py-2 text-sm font-semibold transition-colors ${
active
? "bg-primary-600 text-white"
: "bg-white/5 text-white/70 hover:bg-white/10"
}`}
>
<span>{link.icon}</span>
<span>{link.label}</span>
</Link>
);
})}
</nav>
{children}
</div>
</main>
);
}
@@ -0,0 +1,112 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
export default function AdminCertificates({ certificates }: any) {
const router = useRouter();
const [filter, setFilter] = useState("PENDING");
const [viewing, setViewing] = useState<any>(null);
const filtered = certificates.filter((c: any) =>
filter === "ALL" ? true : c.status === filter
);
async function updateStatus(id: string, status: string) {
const res = await fetch("/api/certificates", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ id, status }),
});
if (res.ok) {
setViewing(null);
router.refresh();
}
}
const pendingCount = certificates.filter((c: any) => c.status === "PENDING").length;
return (
<div className="space-y-4">
<h2 className="text-lg font-bold">
📄 Certificats médical
{pendingCount > 0 && (
<span className="ml-2 rounded-full bg-amber-500 px-2 py-0.5 text-xs text-black">
{pendingCount} en attente
</span>
)}
</h2>
<div className="flex gap-2">
{(["PENDING", "VALIDATED", "REJECTED", "ALL"] as const).map((f) => (
<button
key={f}
onClick={() => setFilter(f)}
className={`rounded-xl px-3 py-2 text-sm ${
filter === f ? "bg-primary-600 text-white" : "bg-white/5 text-white/60"
}`}
>
{f === "PENDING" ? "⏳ En attente" : f === "VALIDATED" ? "✓ Validés" : f === "REJECTED" ? "✗ Rejetés" : "Tous"}
</button>
))}
</div>
<div className="space-y-3">
{filtered.length === 0 && (
<p className="card text-center text-sm text-white/50">Aucun certificat ici.</p>
)}
{filtered.map((c: any) => (
<div key={c.id} className="card">
<div className="flex items-center justify-between">
<div>
<p className="font-semibold">{c.user.name}</p>
<p className="text-xs text-white/50">{c.user.email}</p>
<p className="mt-1 text-xs text-white/40">
Reçu le {new Date(c.createdAt).toLocaleDateString("fr-FR")}
</p>
</div>
<StatusBadge status={c.status} />
</div>
<div className="mt-3 flex gap-2">
<a
href={c.fileUrl}
target="_blank"
rel="noopener noreferrer"
className="btn-secondary flex-1 !py-2 text-sm"
>
👁️ Voir le fichier
</a>
{c.status === "PENDING" && (
<>
<button
onClick={() => updateStatus(c.id, "VALIDATED")}
className="rounded-xl bg-emerald-600 px-4 py-2 text-sm font-semibold text-white hover:bg-emerald-700"
>
✓ Valider
</button>
<button
onClick={() => updateStatus(c.id, "REJECTED")}
className="btn-danger"
>
✗
</button>
</>
)}
</div>
</div>
))}
</div>
</div>
);
}
function StatusBadge({ status }: { status: string }) {
switch (status) {
case "VALIDATED":
return <span className="rounded-full bg-emerald-500/20 px-3 py-1 text-xs font-semibold text-emerald-300">✓ Validé</span>;
case "REJECTED":
return <span className="rounded-full bg-red-500/20 px-3 py-1 text-xs font-semibold text-red-300">✗ Rejeté</span>;
default:
return <span className="rounded-full bg-amber-500/20 px-3 py-1 text-xs font-semibold text-amber-300">⏳ En attente</span>;
}
}
+13
View File
@@ -0,0 +1,13 @@
import { prisma } from "@/lib/prisma";
import AdminCertificates from "./certificates-admin";
export default async function CertificatesPage() {
const certificates = await prisma.certificate.findMany({
orderBy: { createdAt: "desc" },
include: {
user: { select: { id: true, name: true, email: true } },
},
});
return <AdminCertificates certificates={certificates} />;
}
+137
View File
@@ -0,0 +1,137 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
export default function AdminHomework({ homework, members }: any) {
const router = useRouter();
const [title, setTitle] = useState("");
const [description, setDescription] = useState("");
const [videoFile, setVideoFile] = useState<File | null>(null);
const [videoUrl, setVideoUrl] = useState("");
const [target, setTarget] = useState("all");
const [saving, setSaving] = useState(false);
const [error, setError] = useState("");
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (!title || !description) return;
setSaving(true);
setError("");
try {
let finalVideoUrl = videoUrl;
if (videoFile) {
const formData = new FormData();
formData.append("file", videoFile);
formData.append("subdir", "homework");
const up = await fetch("/api/upload", { method: "POST", body: formData });
if (!up.ok) throw new Error("Upload vidéo échoué");
const { url } = await up.json();
finalVideoUrl = url;
}
const res = await fetch("/api/homework", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
title,
description,
videoUrl: finalVideoUrl,
userId: target === "all" ? null : target,
}),
});
if (!res.ok) throw new Error("Enregistrement échoué");
setTitle("");
setDescription("");
setVideoFile(null);
setVideoUrl("");
setTarget("all");
router.refresh();
} catch (err: any) {
setError(err.message || "Erreur");
} finally {
setSaving(false);
}
}
async function deleteHomework(id: string) {
if (!confirm("Supprimer cet exercice ?")) return;
await fetch("/api/homework", {
method: "DELETE",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ id }),
});
router.refresh();
}
return (
<div className="space-y-4">
<h2 className="text-lg font-bold">🏋️ Exercices à travailler</h2>
<form onSubmit={handleSubmit} className="card space-y-3 border-primary-500/30">
<h3 className="font-semibold">📝 Nouvel exercice</h3>
<div>
<label className="label">Titre</label>
<input className="input" value={title} onChange={(e) => setTitle(e.target.value)} placeholder="Ex: 10 rounds de shadow box" required />
</div>
<div>
<label className="label">Description</label>
<textarea className="input min-h-[100px]" value={description} onChange={(e) => setDescription(e.target.value)} placeholder="Décris l'exercice..." required />
</div>
<div>
<label className="label">À destination de</label>
<select className="input" value={target} onChange={(e) => setTarget(e.target.value)}>
<option value="all">Tous les membres</option>
{members.map((m: any) => (
<option key={m.id} value={m.id}>{m.name}</option>
))}
</select>
</div>
<div>
<label className="label">Vidéo démonstrative (optionnel)</label>
{videoFile ? (
<p className="mb-2 text-sm text-primary-300">{videoFile.name}</p>
) : (
<label className="btn-secondary mb-2 w-full cursor-pointer">
📹 Téléverser une vidéo
<input type="file" accept="video/*" className="hidden" onChange={(e) => setVideoFile(e.target.files?.[0] || null)} />
</label>
)}
<input className="input" value={videoUrl} onChange={(e) => setVideoUrl(e.target.value)} placeholder="...ou coller une URL de vidéo" />
</div>
{error && <p className="text-sm text-red-400">{error}</p>}
<button type="submit" className="btn-primary w-full" disabled={saving}>
{saving ? "Enregistrement..." : "✅ Publier"}
</button>
</form>
<div className="space-y-3">
{homework.length === 0 && (
<p className="card text-center text-sm text-white/50">Aucun exercice publié.</p>
)}
{homework.map((h: any) => (
<div key={h.id} className="card">
<div className="flex items-start justify-between">
<div className="flex-1">
<h3 className="font-semibold">{h.title}</h3>
<p className="mt-1 whitespace-pre-line text-sm text-white/75">{h.description}</p>
<p className="mt-2 text-xs text-white/40">
{h.user ? `Pour ${h.user.name}` : "Pour tous les membres"} ·{" "}
{new Date(h.createdAt).toLocaleDateString("fr-FR")}
</p>
{h.videoUrl && (
<video controls playsInline preload="metadata" className="mt-2 aspect-video w-full rounded-xl max-w-sm" src={h.videoUrl} />
)}
</div>
<button onClick={() => deleteHomework(h.id)} className="ml-3 shrink-0 text-sm text-red-400">
🗑️
</button>
</div>
</div>
))}
</div>
</div>
);
}
+18
View File
@@ -0,0 +1,18 @@
import { prisma } from "@/lib/prisma";
import AdminHomework from "./homework-admin";
export default async function HomeworkPage() {
const [homework, members] = await Promise.all([
prisma.homework.findMany({
orderBy: { createdAt: "desc" },
include: { user: { select: { name: true } } },
}),
prisma.user.findMany({
where: { role: "MEMBER" },
select: { id: true, name: true, isMuayThai: true },
orderBy: { name: "asc" },
}),
]);
return <AdminHomework homework={homework} members={members} />;
}
+16
View File
@@ -0,0 +1,16 @@
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth";
import { redirect } from "next/navigation";
import AdminLayout from "./admin-layout";
export default async function AdminLayoutWrapper({
children,
}: {
children: React.ReactNode;
}) {
const session = await getServerSession(authOptions);
if (!session?.user) redirect("/login");
if (session.user.role !== "ADMIN") redirect("/member");
return <AdminLayout>{children}</AdminLayout>;
}
+175
View File
@@ -0,0 +1,175 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
const VISIBILITY_OPTIONS = [
{ value: "MEMBERS_ONLY", label: "👥 Tous les adhérents" },
{ value: "MUAY_THAI_ONLY", label: "🥊 Muay Thai uniquement" },
{ value: "YOGA_ONLY", label: "🧘 Yoga uniquement" },
{ value: "PUBLIC", label: "🌍 Public" },
];
const TYPE_OPTIONS = [
{ value: "PHOTO", label: "📷 Photo" },
{ value: "VIDEO", label: "🎥 Vidéo" },
];
export default function AdminMedia({ media }: { media: any[] }) {
const router = useRouter();
const [title, setTitle] = useState("");
const [description, setDescription] = useState("");
const [type, setType] = useState("PHOTO");
const [visibility, setVisibility] = useState("MEMBERS_ONLY");
const [file, setFile] = useState<File | null>(null);
const [uploading, setUploading] = useState(false);
const [error, setError] = useState("");
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (!file) return;
setUploading(true);
setError("");
try {
const dir = type === "VIDEO" ? "media/videos" : "media/photos";
const formData = new FormData();
formData.append("file", file);
formData.append("subdir", dir);
const up = await fetch("/api/upload", { method: "POST", body: formData });
if (!up.ok) throw new Error("Upload échoué");
const { url } = await up.json();
const res = await fetch("/api/media", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ title, description, url, type, visibility }),
});
if (!res.ok) throw new Error("Enregistrement échoué");
setTitle("");
setDescription("");
setFile(null);
setType("PHOTO");
router.refresh();
} catch (err: any) {
setError(err.message || "Une erreur est survenue");
} finally {
setUploading(false);
}
}
async function deleteMedia(id: string) {
if (!confirm("Supprimer ce média ?")) return;
const res = await fetch("/api/media", {
method: "DELETE",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ id }),
});
if (res.ok) router.refresh();
}
return (
<div className="space-y-4">
<h2 className="text-lg font-bold">🎬 Photos & vidéos</h2>
<form onSubmit={handleSubmit} className="card space-y-3 border-primary-500/30">
<h3 className="font-semibold">📤 Ajouter un média</h3>
<div className="flex gap-2">
{TYPE_OPTIONS.map((t) => (
<button
key={t.value}
type="button"
onClick={() => setType(t.value)}
className={`flex-1 rounded-xl px-3 py-2 text-sm ${type === t.value ? "bg-primary-600 text-white" : "bg-white/5 text-white/60"}`}
>
{t.label}
</button>
))}
</div>
<label className="label">❌ Visibilité</label>
<div className="grid grid-cols-1 gap-2">
{VISIBILITY_OPTIONS.map((v) => (
<label key={v.value} className="flex cursor-pointer items-center gap-2 rounded-xl bg-white/5 px-3 py-2 text-sm">
<input
type="radio"
name="visibility"
checked={visibility === v.value}
onChange={() => setVisibility(v.value)}
className="accent-primary-600"
/>
{v.label}
</label>
))}
</div>
<div>
<label className="label">Titre</label>
<input className="input" value={title} onChange={(e) => setTitle(e.target.value)} placeholder="Ex: Entraînement du mardi" required />
</div>
<div>
<label className="label">Description (optionnel)</label>
<input className="input" value={description} onChange={(e) => setDescription(e.target.value)} />
</div>
<div className="flex flex-col items-center justify-center rounded-xl border-2 border-dashed border-white/20 bg-white/5 py-6">
<span className="mb-1 text-3xl">{type === "VIDEO" ? "🎥" : "📷"}</span>
{file ? (
<p className="text-sm text-primary-300">{file.name}</p>
) : (
<p className="text-sm text-white/50">
{type === "VIDEO" ? "Choisir une vidéo" : "Choisir une photo"}
</p>
)}
<label className="btn-secondary mt-3 cursor-pointer">
Choisir un fichier
<input
type="file"
accept={type === "VIDEO" ? "video/*" : "image/*"}
className="hidden"
onChange={(e) => {
setFile(e.target.files?.[0] || null);
setError("");
}}
/>
</label>
</div>
{error && <p className="text-sm text-red-400">{error}</p>}
<button type="submit" className="btn-primary w-full" disabled={uploading || !file}>
{uploading ? "Upload..." : "🚀 Publier"}
</button>
</form>
<div className="grid grid-cols-2 gap-3">
{media.length === 0 && (
<p className="card col-span-2 text-center text-sm text-white/50">Aucun média.</p>
)}
{media.map((m) => (
<div key={m.id} className="card overflow-hidden !p-0">
{m.type === "PHOTO" ? (
<img src={m.url} alt={m.title} className="aspect-square w-full object-cover" loading="lazy" />
) : (
<video controls playsInline preload="metadata" className="aspect-video w-full" src={m.url} />
)}
<div className="p-3">
<p className="truncate text-sm font-semibold">{m.title}</p>
<div className="mt-1 flex items-center justify-between">
<span className="text-xs text-white/40">
{m.visibility === "PUBLIC" ? "🌍 Public" : m.visibility === "MUAY_THAI_ONLY" ? "🥊 Muay Thai" : m.visibility === "YOGA_ONLY" ? "🧘 Yoga" : "👥 Adhérents"} ·{" "}
{new Date(m.createdAt).toLocaleDateString("fr-FR")}
</span>
<button onClick={() => deleteMedia(m.id)} className="text-xs text-red-400">
Supprimer
</button>
</div>
</div>
</div>
))}
</div>
</div>
);
}
+13
View File
@@ -0,0 +1,13 @@
import { prisma } from "@/lib/prisma";
import AdminMedia from "./media-admin";
export default async function MediaPage() {
const media = await prisma.media.findMany({
orderBy: { createdAt: "desc" },
include: {
uploadedBy: { select: { name: true } },
},
});
return <AdminMedia media={media} />;
}
+98
View File
@@ -0,0 +1,98 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
export default function AdminMessages({ messages, readMap, totalMembers }: any) {
const router = useRouter();
const [title, setTitle] = useState("");
const [content, setContent] = useState("");
const [category, setCategory] = useState("general");
const [sending, setSending] = useState(false);
async function sendMessage(e: React.FormEvent) {
e.preventDefault();
setSending(true);
const res = await fetch("/api/admin/messages", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ title, content, category }),
});
setSending(false);
if (res.ok) {
setTitle("");
setContent("");
setCategory("general");
router.refresh();
}
}
return (
<div className="space-y-4">
<h2 className="text-lg font-bold">💬 Messages / Tips</h2>
<form onSubmit={sendMessage} className="card space-y-3 border-primary-500/30">
<h3 className="font-semibold">✍️ Nouveau message</h3>
<div>
<label className="label">Titre</label>
<input className="input" value={title} onChange={(e) => setTitle(e.target.value)} placeholder="Ex: Conseil de la semaine" required />
</div>
<div>
<label className="label">Message</label>
<textarea className="input min-h-[120px]" value={content} onChange={(e) => setContent(e.target.value)} placeholder="Écris ton message... les adhérents le verront dans leur espace." required />
</div>
<div>
<label className="label">Audience</label>
<div className="flex gap-2">
{[
["general", "🌍 Tout le monde"],
["muay-thai", "🥊 Muay Thai"],
["yoga", "🧘 Yoga"],
].map(([val, label]) => (
<button
key={val}
type="button"
onClick={() => setCategory(val)}
className={`rounded-xl px-3 py-2 text-sm font-semibold ${
category === val
? "bg-primary-600 text-white"
: "bg-white/5 text-white/60"
}`}
>
{label}
</button>
))}
</div>
</div>
<button type="submit" className="btn-primary w-full" disabled={sending || !title || !content}>
{sending ? "Envoi..." : "📨 Publier le message"}
</button>
</form>
<div className="space-y-3">
{messages.length === 0 && (
<p className="card text-center text-sm text-white/50">
Aucun message publié.
</p>
)}
{messages.map((m: any) => (
<div key={m.id} className="card">
<div className="flex items-start justify-between">
<div>
<h3 className="font-semibold">{m.title}</h3>
<p className="mt-1 whitespace-pre-line text-sm text-white/75">{m.content}</p>
<p className="mt-2 text-xs text-white/40">
{new Date(m.createdAt).toLocaleDateString("fr-FR", { day: "numeric", month: "long", year: "numeric" })} ·{" "}
{m.category === "muay-thai" ? "🥊" : m.category === "yoga" ? "🧘" : "🌍"}
</p>
</div>
<span className="shrink-0 rounded-full bg-white/5 px-2 py-1 text-xs text-white/50">
{readMap[m.id] || 0} lu(s)
</span>
</div>
</div>
))}
</div>
</div>
);
}
+20
View File
@@ -0,0 +1,20 @@
import { prisma } from "@/lib/prisma";
import AdminMessages from "./messages-admin";
export default async function MessagesPage() {
const messages = await prisma.message.findMany({
orderBy: { createdAt: "desc" },
});
const readCount = await prisma.messageRead.groupBy({
by: ["messageId"],
_count: { _all: true },
});
const readMap: Record<string, number> = {};
readCount.forEach((r) => {
readMap[r.messageId] = r._count._all;
});
return <AdminMessages messages={messages} readMap={readMap} totalMembers={0} />;
}
+83
View File
@@ -0,0 +1,83 @@
"use client";
import Link from "next/link";
export default function AdminOverview({ stats }: { stats: any }) {
const cards = [
{
label: "Adhérents",
value: stats.totalMembers,
icon: "👥",
href: "/admin/users",
sub: `${stats.muayThaiMembers} Muay Thai · ${stats.yogaMembers} Yoga`,
},
{
label: "Certificats en attente",
value: stats.pendingCertificates,
icon: "📄",
href: "/admin/certificates",
sub: stats.pendingCertificates > 0 ? "à valider" : "tout est à jour",
alert: stats.pendingCertificates > 0,
},
{
label: "Photos & vidéos",
value: stats.totalMedia,
icon: "🎬",
href: "/admin/media",
sub: "médias publiés",
},
{
label: "Messages publiés",
value: stats.messages,
icon: "💬",
href: "/admin/messages",
sub: "messages envoyés",
},
];
return (
<div className="space-y-6">
<div className="grid grid-cols-2 gap-3">
{cards.map((c) => (
<Link
key={c.label}
href={c.href}
className={`card transition-colors hover:bg-white/10 ${c.alert ? "border-amber-500/50 bg-amber-500/5" : ""}`}
>
<div className="flex items-center justify-between">
<span className="text-2xl">{c.icon}</span>
{c.alert && (
<span className="h-3 w-3 rounded-full bg-amber-500" />
)}
</div>
<p className="mt-2 text-3xl font-extrabold">{c.value}</p>
<p className="text-sm font-semibold text-white/80">{c.label}</p>
<p className="mt-1 text-xs text-white/50">{c.sub}</p>
</Link>
))}
</div>
<div className="card">
<h2 className="mb-2 font-bold">Accès rapides</h2>
<div className="grid grid-cols-2 gap-2 text-sm">
<Link href="/admin/users" className="rounded-xl bg-white/5 px-3 py-2 text-center hover:bg-white/10">
👥 Gérer les membres
</Link>
<Link href="/admin/messages" className="rounded-xl bg-white/5 px-3 py-2 text-center hover:bg-white/10">
💬 Publier un message
</Link>
<Link href="/admin/media" className="rounded-xl bg-white/5 px-3 py-2 text-center hover:bg-white/10">
🎬 Ajouter une photo/vidéo
</Link>
<Link href="/admin/yoga" className="rounded-xl bg-white/5 px-3 py-2 text-center hover:bg-white/10">
🧘 Gérer les séances yoga
</Link>
</div>
</div>
<Link href="/" className="btn-secondary w-full">
← Voir le site public
</Link>
</div>
);
}
+40
View File
@@ -0,0 +1,40 @@
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import { redirect } from "next/navigation";
import AdminOverview from "./overview";
export default async function AdminIndex() {
const session = await getServerSession(authOptions);
if (!session?.user) redirect("/login");
if (session.user.role !== "ADMIN") redirect("/member");
const [
totalMembers,
muayThaiMembers,
yogaMembers,
pendingCertificates,
totalMedia,
unreadMessagesCount,
] = await Promise.all([
prisma.user.count({ where: { role: "MEMBER" } }),
prisma.user.count({ where: { isMuayThai: true } }),
prisma.user.count({ where: { isYoga: true } }),
prisma.certificate.count({ where: { status: "PENDING" } }),
prisma.media.count(),
prisma.message.count(),
]);
return (
<AdminOverview
stats={{
totalMembers,
muayThaiMembers,
yogaMembers,
pendingCertificates,
totalMedia,
messages: unreadMessagesCount,
}}
/>
);
}
+17
View File
@@ -0,0 +1,17 @@
import { prisma } from "@/lib/prisma";
import AdminUsers from "./users-admin";
export default async function UsersPage() {
const users = await prisma.user.findMany({
orderBy: { createdAt: "asc" },
include: {
muayThai: true,
yoga: true,
certificates: { orderBy: { createdAt: "desc" } },
},
});
const safeUsers = users.map(({ password, ...u }) => u);
return <AdminUsers users={safeUsers} />;
}
+260
View File
@@ -0,0 +1,260 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
export default function AdminUsers({ users }: { users: any[] }) {
const router = useRouter();
const [showCreate, setShowCreate] = useState(false);
const [filter, setFilter] = useState<"ALL" | "MUAY" | "YOGA">("ALL");
const filtered = users.filter((u) => {
if (filter === "MUAY") return u.isMuayThai;
if (filter === "YOGA") return u.isYoga;
return true;
});
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h2 className="text-lg font-bold">
👥 Adhérents ({filtered.length})
</h2>
<button
onClick={() => setShowCreate(!showCreate)}
className="btn-primary !px-4 !py-2 text-sm"
>
{showCreate ? "Fermer" : "+ Nouveau compte"}
</button>
</div>
<div className="flex gap-2">
{(
[
["ALL", "Tous"],
["MUAY", "🥊 Muay Thai"],
["YOGA", "🧘 Yoga"],
] as const
).map(([val, label]) => (
<button
key={val}
onClick={() => setFilter(val)}
className={`rounded-xl px-4 py-2 text-sm font-semibold transition-colors ${
filter === val
? "bg-primary-600 text-white"
: "bg-white/5 text-white/60"
}`}
>
{label}
</button>
))}
</div>
{showCreate && <CreateUserForm onDone={() => { setShowCreate(false); router.refresh(); }} />}
<div className="space-y-3">
{filtered.length === 0 && (
<p className="card text-center text-sm text-white/50">
Aucun adhérent.
</p>
)}
{filtered.map((user) => (
<MemberCard key={user.id} user={user} onUpdate={() => router.refresh()} />
))}
</div>
</div>
);
}
function CreateUserForm({ onDone }: { onDone: () => void }) {
const [name, setName] = useState("");
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [isMuayThai, setIsMuayThai] = useState(true);
const [isYoga, setIsYoga] = useState(false);
const [error, setError] = useState("");
const [saving, setSaving] = useState(false);
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setSaving(true);
setError("");
const res = await fetch("/api/admin/users", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name, email, password, isMuayThai, isYoga }),
});
const data = await res.json();
setSaving(false);
if (!res.ok) {
setError(data.error || "Erreur");
return;
}
onDone();
}
return (
<form onSubmit={handleSubmit} className="card space-y-4 border-primary-500/30">
<h3 className="font-semibold">✨ Créer un compte membre</h3>
<div>
<label className="label">Nom complet</label>
<input className="input" value={name} onChange={(e) => setName(e.target.value)} placeholder="Jean Dupont" required />
</div>
<div>
<label className="label">Email</label>
<input type="email" className="input" value={email} onChange={(e) => setEmail(e.target.value)} placeholder="jean@gmail.com" required />
</div>
<div>
<label className="label">Mot de passe initial</label>
<input className="input" value={password} onChange={(e) => setPassword(e.target.value)} placeholder="Mot de passe temporaire" required />
</div>
<div className="flex gap-3">
<Checkbox label="🥊 Muay Thai" checked={isMuayThai} onChange={setIsMuayThai} />
<Checkbox label="🧘 Yoga" checked={isYoga} onChange={setIsYoga} />
</div>
{error && <p className="text-sm text-red-400">{error}</p>}
<button type="submit" className="btn-primary w-full" disabled={saving}>
{saving ? "Création..." : "✅ Créer le compte"}
</button>
</form>
);
}
function Checkbox({ label, checked, onChange }: { label: string; checked: boolean; onChange: (v: boolean) => void }) {
return (
<label className="flex cursor-pointer items-center gap-2 rounded-xl bg-white/5 px-4 py-2 text-sm">
<input type="checkbox" checked={checked} onChange={(e) => onChange(e.target.checked)} className="h-4 w-4 accent-primary-600" />
{label}
</label>
);
}
function MemberCard({ user, onUpdate }: { user: any; onUpdate: () => void }) {
const [expanded, setExpanded] = useState(false);
const [editName, setEditName] = useState(user.name);
const [editPassword, setEditPassword] = useState("");
const router = useRouter();
const certStatus = user.certificates?.find((c: any) => c.status !== "REJECTED")?.status;
async function updateUser(data: any) {
const res = await fetch(`/api/admin/users/${user.id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
});
if (res.ok) {
onUpdate();
setExpanded(false);
}
}
async function deleteUser() {
if (!confirm(`Supprimer le compte de ${user.name} ?`)) return;
const res = await fetch(`/api/admin/users/${user.id}`, {
method: "DELETE",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ id: user.id }),
});
if (res.ok) onUpdate();
}
return (
<div className="card">
<div className="flex items-start justify-between gap-3">
<div className="flex items-center gap-3">
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-primary-600/20 text-lg font-bold text-primary-300">
{user.name.charAt(0).toUpperCase()}
</div>
<div>
<p className="font-semibold">{user.name}</p>
<p className="text-xs text-white/50">{user.email}</p>
<div className="mt-1 flex gap-1.5">
{user.isMuayThai && (
<span className="rounded-full bg-primary-600/20 px-2 py-0.5 text-xs text-primary-300">
🥊 Muay Thai
</span>
)}
{user.isYoga && (
<span className="rounded-full bg-emerald-600/20 px-2 py-0.5 text-xs text-emerald-300">
🧘 Yoga
</span>
)}
{user.role === "ADMIN" && (
<span className="rounded-full bg-white/10 px-2 py-0.5 text-xs text-white/70">
⭐ Admin
</span>
)}
</div>
</div>
</div>
<button
onClick={() => setExpanded(!expanded)}
className="rounded-xl bg-white/5 px-3 py-2 text-sm text-white/60"
>
{expanded ? "✕" : "⚙️"}
</button>
</div>
{certStatus && (
<div className="mt-3 flex items-center justify-between rounded-xl bg-white/5 px-3 py-2 text-xs">
<span className="text-white/60">Certificat médical</span>
<CertBadge status={certStatus} />
</div>
)}
{user.yoga && (
<div className="mt-2 flex items-center justify-between rounded-xl bg-white/5 px-3 py-2 text-xs">
<span className="text-white/60">Séances yoga restantes</span>
<span className="font-bold text-emerald-300">
{user.yoga.sessionsRemaining}
</span>
</div>
)}
{expanded && user.role !== "ADMIN" && (
<div className="mt-4 space-y-3 border-t border-white/10 pt-4">
<h4 className="text-sm font-semibold">Modifier</h4>
<div className="grid grid-cols-1 gap-3">
<div>
<label className="label">Nom</label>
<input className="input" value={editName} onChange={(e) => setEditName(e.target.value)} />
</div>
<div>
<label className="label">Nouveau mot de passe (optionnel)</label>
<input className="input" value={editPassword} onChange={(e) => setEditPassword(e.target.value)} placeholder="Laisser vide pour ne pas changer" />
</div>
</div>
<div className="flex gap-2">
<button
onClick={() => {
const data: any = { name: editName };
if (editPassword) data.password = editPassword;
updateUser(data);
}}
className="btn-primary flex-1 !py-2 text-sm"
>
💾 Enregistrer
</button>
<button onClick={deleteUser} className="btn-danger">
🗑️
</button>
</div>
</div>
)}
</div>
);
}
function CertBadge({ status }: { status: string }) {
switch (status) {
case "VALIDATED":
return <span className="font-bold text-emerald-300">✓ Validé</span>;
case "PENDING":
return <span className="font-bold text-amber-300">⏳ En attente</span>;
case "REJECTED":
return <span className="font-bold text-red-300">✗ Rejeté</span>;
default:
return null;
}
}
+34
View File
@@ -0,0 +1,34 @@
import { prisma } from "@/lib/prisma";
import AdminYoga from "./yoga-admin";
export default async function YogaAdminPage() {
const [tiers, members, recentAttendances] = await Promise.all([
prisma.yogaTier.findMany({ orderBy: { numberOfSessions: "asc" } }),
prisma.user.findMany({
where: { isYoga: true },
orderBy: { name: "asc" },
include: {
yoga: {
include: {
attendances: { orderBy: { date: "desc" }, take: 5 },
},
},
},
}),
prisma.yogaAttendance.findMany({
orderBy: { date: "desc" },
take: 10,
include: {
profile: {
include: { user: { select: { name: true } } },
},
},
}),
]);
const safeMembers = members.map(({ password, ...m }) => m);
return (
<AdminYoga tiers={tiers} members={safeMembers} recentAttendances={recentAttendances} />
);
}
+241
View File
@@ -0,0 +1,241 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
export default function AdminYoga({ tiers, members, recentAttendances }: any) {
const router = useRouter();
const [tierName, setTierName] = useState("");
const [tierSessions, setTierSessions] = useState("");
const [tierPrice, setTierPrice] = useState("");
const [savingTier, setSavingTier] = useState(false);
const [tierError, setTierError] = useState("");
async function createTier(e: React.FormEvent) {
e.preventDefault();
if (!tierName || !tierSessions || !tierPrice) return;
setSavingTier(true);
setTierError("");
const res = await fetch("/api/admin/yoga-tiers", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
name: tierName,
numberOfSessions: Number(tierSessions),
priceTotal: Number(tierPrice),
}),
});
setSavingTier(false);
if (res.ok) {
setTierName("");
setTierSessions("");
setTierPrice("");
router.refresh();
} else {
const data = await res.json();
setTierError(data.error || "Erreur");
}
}
return (
<div className="space-y-6">
<h2 className="text-lg font-bold">🧘 Gestion Yoga</h2>
{/* Tarifs dégressifs */}
<section className="card">
<h3 className="mb-3 font-bold">💰 Tarifs dégressifs</h3>
<p className="mb-4 text-sm text-white/60">
Configure les packs de séances que Juanito veut vendre. Les plus
assidus paient moins cher par séance.
</p>
<div className="mb-4 space-y-2">
{tiers.map((t: any) => (
<div key={t.id} className="flex items-center justify-between rounded-xl bg-white/5 px-4 py-3">
<div>
<p className="font-semibold">{t.name}</p>
<p className="text-xs text-white/50">
{t.numberOfSessions} séance{t.numberOfSessions > 1 ? "s" : ""} ·{" "}
<span className="font-bold text-emerald-300">
{t.priceTotal} €
</span>{" "}
({(t.priceTotal / t.numberOfSessions).toFixed(2)} €/séance)
</p>
</div>
<div className="flex gap-2">
<button
onClick={() => toggleTier(t.id, !t.isActive)}
className={`rounded-xl px-3 py-1.5 text-sm ${t.isActive ? "bg-white/5 text-white/60" : "bg-emerald-600/20 text-emerald-300"}`}
>
{t.isActive ? "Actif" : "Inactif"}
</button>
<button
onClick={() => deleteTier(t.id)}
className="rounded-xl bg-white/5 px-3 py-1.5 text-sm text-white/60"
>
🗑️
</button>
</div>
</div>
))}
{tiers.length === 0 && (
<p className="text-sm text-white/50">Aucun tarif configuré.</p>
)}
</div>
<form onSubmit={createTier} className="grid grid-cols-1 gap-2 sm:grid-cols-4">
<input className="input sm:col-span-2" placeholder="Nom (ex: Pack 10)" value={tierName} onChange={(e) => setTierName(e.target.value)} />
<input className="input" placeholder="Séances" type="number" value={tierSessions} onChange={(e) => setTierSessions(e.target.value)} />
<input className="input" placeholder="Prix (€)" type="number" step="0.01" value={tierPrice} onChange={(e) => setTierPrice(e.target.value)} />
<button type="submit" className="btn-primary sm:col-span-4" disabled={savingTier}>
{savingTier ? "..." : "+ Ajouter ce tarif"}
</button>
</form>
{tierError && <p className="mt-2 text-sm text-red-400">{tierError}</p>}
<p className="mt-3 text-xs text-white/40">
💡 Un pack de 10 à 70€ = 7€/séance au lieu de 9€. Les membres
voient le tarif dégressif dans leur espace.
</p>
</section>
{/* Compteur séances membres */}
<section className="card">
<h3 className="mb-3 font-bold">👥 Compteurs de séances des membres</h3>
<div className="space-y-3">
{members.length === 0 && (
<p className="text-sm text-white/50">
Aucun membre yoga. Crée-en un dans l&apos;onglet Membres.
</p>
)}
{members.map((m: any) => (
<YogaMemberCard key={m.id} member={m} onUpdate={() => router.refresh()} />
))}
</div>
</section>
{/* Dernières présences */}
<section className="card">
<h3 className="mb-3 font-bold">🕐 Dernières présences enregistrées</h3>
{recentAttendances.length === 0 && (
<p className="text-sm text-white/50">Aucune présence enregistrée.</p>
)}
<div className="space-y-2">
{recentAttendances.map((a: any) => (
<div key={a.id} className="flex items-center justify-between rounded-xl bg-white/5 px-3 py-2 text-sm">
<span className="font-semibold">{a.profile.user.name}</span>
<span className="text-xs text-white/50">
{new Date(a.date).toLocaleDateString("fr-FR", { day: "numeric", month: "long" })}
{a.pricePaid !== null && (
<span className="ml-2 text-emerald-300">{a.pricePaid} €</span>
)}
</span>
</div>
))}
</div>
</section>
</div>
);
async function toggleTier(id: string, isActive: boolean) {
const res = await fetch(`/api/admin/yoga-tiers/${id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ id, isActive }),
});
if (res.ok) router.refresh();
}
async function deleteTier(id: string) {
if (!confirm("Supprimer ce tarif ?")) return;
const res = await fetch(`/api/admin/yoga-tiers/${id}`, {
method: "DELETE",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ id }),
});
if (res.ok) router.refresh();
}
}
function YogaMemberCard({ member, onUpdate }: any) {
const [adding, setAdding] = useState(false);
const [count, setCount] = useState("1");
const [recordingSession, setRecordingSession] = useState(false);
const profile = member.yoga;
const remaining = profile?.sessionsRemaining ?? 0;
async function addSessions(e: React.FormEvent) {
e.preventDefault();
const res = await fetch("/api/admin/yoga-members", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ userId: member.id, sessionsToAdd: Number(count) }),
});
if (res.ok) {
setAdding(false);
setCount("1");
onUpdate();
}
}
async function recordSession() {
if (!confirm(`Enregistrer une séance suivie pour ${member.name} ? (1 séance restante va être déduite)`)) return;
setRecordingSession(true);
const res = await fetch("/api/yoga-sessions", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ userId: member.id }),
});
setRecordingSession(false);
if (res.ok) onUpdate();
}
return (
<div className="rounded-xl bg-white/5 p-4">
<div className="flex items-center justify-between">
<div>
<p className="font-semibold">{member.name}</p>
<p className="text-xs text-white/50">
<span className="font-bold text-emerald-300">{remaining}</span> séance
{remaining > 1 ? "s" : ""} restante{remaining > 1 ? "s" : ""}
</p>
</div>
<div className="flex gap-2">
<button
onClick={() => setAdding(!adding)}
className="rounded-xl bg-white/5 px-3 py-2 text-sm text-white/70 hover:bg-white/10"
>
{adding ? "✕" : "+ Séances"}
</button>
<button
onClick={recordSession}
disabled={recordingSession || remaining <= 0}
className={`rounded-xl px-3 py-2 text-sm font-semibold ${
remaining > 0
? "bg-emerald-600 text-white hover:bg-emerald-700"
: "bg-white/5 text-white/30"
}`}
>
{recordingSession ? "..." : "✓ Séance suivie"}
</button>
</div>
</div>
{adding && (
<form onSubmit={addSessions} className="mt-3 flex gap-2">
<input
type="number"
min="1"
value={count}
onChange={(e) => setCount(e.target.value)}
className="input !w-20"
/>
<button type="submit" className="btn-primary flex-1 !py-2 text-sm">
Ajouter les séances
</button>
</form>
)}
</div>
);
}