Initial commit: JuanitoSport - Muay Thai & Yog4Fight PWA
This commit is contained in:
@@ -0,0 +1,4 @@
|
|||||||
|
DATABASE_URL="postgresql://juanito_user:JuanitoSport2026!@10.10.10.10:5432/juanitosport?schema=public"
|
||||||
|
NEXTAUTH_SECRET="CHANGE_ME_PLEASE"
|
||||||
|
NEXTAUTH_URL="https://juanito.secourit.fr"
|
||||||
|
UPLOAD_DIR="/opt/juanitosport/uploads"
|
||||||
+13
@@ -0,0 +1,13 @@
|
|||||||
|
node_modules/
|
||||||
|
.next/
|
||||||
|
out/
|
||||||
|
build/
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.production
|
||||||
|
*.log
|
||||||
|
.DS_Store
|
||||||
|
public/sw/
|
||||||
|
public/workbox-*.js
|
||||||
|
public/worker-*.js
|
||||||
|
public/ffmpeg/
|
||||||
@@ -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>;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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} />;
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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} />;
|
||||||
|
}
|
||||||
@@ -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>;
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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} />;
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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} />;
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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} />;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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} />
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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'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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { requireAuth } from "@/lib/session";
|
||||||
|
|
||||||
|
export async function GET(req: NextRequest) {
|
||||||
|
const session = await requireAuth();
|
||||||
|
if (!session) return NextResponse.json({ error: "Non autorisé" }, { status: 401 });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const absences = await prisma.absence.findMany({
|
||||||
|
where: { userId: session.user.id },
|
||||||
|
orderBy: { date: "desc" },
|
||||||
|
});
|
||||||
|
return NextResponse.json(absences);
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: "Erreur" }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(req: NextRequest) {
|
||||||
|
const session = await requireAuth();
|
||||||
|
if (!session) return NextResponse.json({ error: "Non autorisé" }, { status: 401 });
|
||||||
|
|
||||||
|
const { date, reason } = await req.json();
|
||||||
|
if (!date) return NextResponse.json({ error: "Date requise" }, { status: 400 });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const absence = await prisma.absence.create({
|
||||||
|
data: {
|
||||||
|
userId: session.user.id,
|
||||||
|
date: new Date(date),
|
||||||
|
reason: reason || null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return NextResponse.json(absence, { status: 201 });
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: "Erreur" }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { requireAdmin } from "@/lib/session";
|
||||||
|
|
||||||
|
export async function POST(req: NextRequest) {
|
||||||
|
const admin = await requireAdmin();
|
||||||
|
if (!admin) return NextResponse.json({ error: "Admin requis" }, { status: 403 });
|
||||||
|
|
||||||
|
const { title, content, category } = await req.json();
|
||||||
|
if (!title || !content) {
|
||||||
|
return NextResponse.json({ error: "Titre et contenu requis" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const message = await prisma.message.create({
|
||||||
|
data: {
|
||||||
|
title,
|
||||||
|
content,
|
||||||
|
category: category || null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return NextResponse.json(message, { status: 201 });
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: "Erreur" }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function DELETE(req: NextRequest) {
|
||||||
|
const admin = await requireAdmin();
|
||||||
|
if (!admin) return NextResponse.json({ error: "Admin requis" }, { status: 403 });
|
||||||
|
|
||||||
|
const { id } = await req.json();
|
||||||
|
if (!id) return NextResponse.json({ error: "id requis" }, { status: 400 });
|
||||||
|
|
||||||
|
try {
|
||||||
|
await prisma.message.delete({ where: { id } });
|
||||||
|
return NextResponse.json({ ok: true });
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: "Erreur" }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { requireAdmin } from "@/lib/session";
|
||||||
|
|
||||||
|
export async function PATCH(req: NextRequest) {
|
||||||
|
const admin = await requireAdmin();
|
||||||
|
if (!admin) return NextResponse.json({ error: "Admin requis" }, { status: 403 });
|
||||||
|
|
||||||
|
const { id, ...data } = await req.json();
|
||||||
|
if (!id) return NextResponse.json({ error: "id requis" }, { status: 400 });
|
||||||
|
|
||||||
|
const allowed = [
|
||||||
|
"name", "email", "isMuayThai", "isYoga", "role", "phone", "avatar"
|
||||||
|
];
|
||||||
|
|
||||||
|
const updateData: any = {};
|
||||||
|
for (const key of allowed) {
|
||||||
|
if (data[key] !== undefined) updateData[key] = data[key];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (updateData.email) updateData.email = updateData.email.toLowerCase();
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (updateData.isMuayThai === true) {
|
||||||
|
await prisma.muayThaiProfile.upsert({
|
||||||
|
where: { userId: id },
|
||||||
|
update: {},
|
||||||
|
create: { userId: id },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (updateData.isYoga === true) {
|
||||||
|
await prisma.yogaProfile.upsert({
|
||||||
|
where: { userId: id },
|
||||||
|
update: {},
|
||||||
|
create: { userId: id },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await prisma.user.update({
|
||||||
|
where: { id },
|
||||||
|
data: updateData,
|
||||||
|
});
|
||||||
|
const { password: _, ...safeUser } = user;
|
||||||
|
return NextResponse.json(safeUser);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
return NextResponse.json({ error: "Erreur" }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function DELETE(req: NextRequest) {
|
||||||
|
const admin = await requireAdmin();
|
||||||
|
if (!admin) return NextResponse.json({ error: "Admin requis" }, { status: 403 });
|
||||||
|
|
||||||
|
const { id } = await req.json();
|
||||||
|
if (!id) return NextResponse.json({ error: "id requis" }, { status: 400 });
|
||||||
|
|
||||||
|
try {
|
||||||
|
await prisma.user.delete({ where: { id } });
|
||||||
|
return NextResponse.json({ ok: true });
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: "Erreur" }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { requireAdmin } from "@/lib/session";
|
||||||
|
import bcrypt from "bcryptjs";
|
||||||
|
import { Role } from "@prisma/client";
|
||||||
|
|
||||||
|
export async function GET(req: NextRequest) {
|
||||||
|
const admin = await requireAdmin();
|
||||||
|
if (!admin) return NextResponse.json({ error: "Admin requis" }, { status: 403 });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const users = await prisma.user.findMany({
|
||||||
|
orderBy: { createdAt: "asc" },
|
||||||
|
include: {
|
||||||
|
muayThai: true,
|
||||||
|
yoga: true,
|
||||||
|
certificates: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return NextResponse.json(users);
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: "Erreur" }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(req: NextRequest) {
|
||||||
|
const admin = await requireAdmin();
|
||||||
|
if (!admin) return NextResponse.json({ error: "Admin requis" }, { status: 403 });
|
||||||
|
|
||||||
|
const { name, email, password, isMuayThai, isYoga, role } = await req.json();
|
||||||
|
|
||||||
|
if (!name || !email || !password) {
|
||||||
|
return NextResponse.json({ error: "Nom, email et mot de passe requis" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = await prisma.user.findUnique({ where: { email: email.toLowerCase() } });
|
||||||
|
if (existing) {
|
||||||
|
return NextResponse.json({ error: "Un compte avec cet email existe déjà" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const hashed = await bcrypt.hash(password, 10);
|
||||||
|
const user = await prisma.user.create({
|
||||||
|
data: {
|
||||||
|
name,
|
||||||
|
email: email.toLowerCase(),
|
||||||
|
password: hashed,
|
||||||
|
role: (role as Role) || "MEMBER",
|
||||||
|
isMuayThai: !!isMuayThai,
|
||||||
|
isYoga: !!isYoga,
|
||||||
|
muayThai: isMuayThai ? { create: {} } : undefined,
|
||||||
|
yoga: isYoga ? { create: {} } : undefined,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
|
const { password: _, ...safeUser } = user;
|
||||||
|
return NextResponse.json(safeUser, { status: 201 });
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
return NextResponse.json({ error: "Erreur" }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { requireAdmin } from "@/lib/session";
|
||||||
|
|
||||||
|
export async function PATCH(req: NextRequest) {
|
||||||
|
const admin = await requireAdmin();
|
||||||
|
if (!admin) return NextResponse.json({ error: "Admin requis" }, { status: 403 });
|
||||||
|
|
||||||
|
const { userId, sessionsToAdd, sessionsToRemove } = await req.json();
|
||||||
|
if (!userId) return NextResponse.json({ error: "userId requis" }, { status: 400 });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const profile = await prisma.yogaProfile.upsert({
|
||||||
|
where: { userId },
|
||||||
|
update: {},
|
||||||
|
create: { userId },
|
||||||
|
});
|
||||||
|
|
||||||
|
let newCount = profile.sessionsRemaining;
|
||||||
|
if (sessionsToAdd) newCount += Number(sessionsToAdd);
|
||||||
|
if (sessionsToRemove) newCount = Math.max(0, newCount - Number(sessionsToRemove));
|
||||||
|
|
||||||
|
const updated = await prisma.yogaProfile.update({
|
||||||
|
where: { id: profile.id },
|
||||||
|
data: { sessionsRemaining: newCount },
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json(updated);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
return NextResponse.json({ error: "Erreur" }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { requireAdmin } from "@/lib/session";
|
||||||
|
|
||||||
|
export async function PATCH(req: NextRequest) {
|
||||||
|
const admin = await requireAdmin();
|
||||||
|
if (!admin) return NextResponse.json({ error: "Admin requis" }, { status: 403 });
|
||||||
|
|
||||||
|
const { id, name, numberOfSessions, priceTotal, isActive } = await req.json();
|
||||||
|
if (!id) return NextResponse.json({ error: "id requis" }, { status: 400 });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const tier = await prisma.yogaTier.update({
|
||||||
|
where: { id },
|
||||||
|
data: {
|
||||||
|
...(name !== undefined ? { name } : {}),
|
||||||
|
...(numberOfSessions !== undefined ? { numberOfSessions: Number(numberOfSessions) } : {}),
|
||||||
|
...(priceTotal !== undefined ? { priceTotal: Number(priceTotal) } : {}),
|
||||||
|
...(isActive !== undefined ? { isActive } : {}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return NextResponse.json(tier);
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: "Erreur" }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function DELETE(req: NextRequest) {
|
||||||
|
const admin = await requireAdmin();
|
||||||
|
if (!admin) return NextResponse.json({ error: "Admin requis" }, { status: 403 });
|
||||||
|
|
||||||
|
const { id } = await req.json();
|
||||||
|
if (!id) return NextResponse.json({ error: "id requis" }, { status: 400 });
|
||||||
|
|
||||||
|
try {
|
||||||
|
await prisma.yogaTier.delete({ where: { id } });
|
||||||
|
return NextResponse.json({ ok: true });
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: "Erreur" }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { requireAdmin } from "@/lib/session";
|
||||||
|
|
||||||
|
export async function GET(req: NextRequest) {
|
||||||
|
const admin = await requireAdmin();
|
||||||
|
if (!admin) return NextResponse.json({ error: "Admin requis" }, { status: 403 });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const tiers = await prisma.yogaTier.findMany({ orderBy: { numberOfSessions: "asc" } });
|
||||||
|
return NextResponse.json(tiers);
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: "Erreur" }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(req: NextRequest) {
|
||||||
|
const admin = await requireAdmin();
|
||||||
|
if (!admin) return NextResponse.json({ error: "Admin requis" }, { status: 403 });
|
||||||
|
|
||||||
|
const { name, numberOfSessions, priceTotal } = await req.json();
|
||||||
|
if (!name || !numberOfSessions || !priceTotal) {
|
||||||
|
return NextResponse.json({ error: "Champs requis" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const tier = await prisma.yogaTier.create({
|
||||||
|
data: { name, numberOfSessions: Number(numberOfSessions), priceTotal: Number(priceTotal) },
|
||||||
|
});
|
||||||
|
return NextResponse.json(tier, { status: 201 });
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: "Erreur" }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import NextAuth from "next-auth";
|
||||||
|
import { authOptions } from "@/lib/auth";
|
||||||
|
|
||||||
|
const handler = NextAuth(authOptions);
|
||||||
|
|
||||||
|
export { handler as GET, handler as POST };
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { requireAdmin, requireAuth } from "@/lib/session";
|
||||||
|
import { CertificateStatus } from "@prisma/client";
|
||||||
|
|
||||||
|
export async function GET(req: NextRequest) {
|
||||||
|
const session = await requireAuth();
|
||||||
|
if (!session) return NextResponse.json({ error: "Non autorisé" }, { status: 401 });
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (session.user.role === "ADMIN") {
|
||||||
|
const certificates = await prisma.certificate.findMany({
|
||||||
|
orderBy: { createdAt: "desc" },
|
||||||
|
include: { user: { select: { id: true, name: true, email: true } } },
|
||||||
|
});
|
||||||
|
return NextResponse.json(certificates);
|
||||||
|
}
|
||||||
|
|
||||||
|
const certs = await prisma.certificate.findMany({
|
||||||
|
where: { userId: session.user.id },
|
||||||
|
orderBy: { createdAt: "desc" },
|
||||||
|
});
|
||||||
|
return NextResponse.json(certs);
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: "Erreur" }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(req: NextRequest) {
|
||||||
|
const session = await requireAuth();
|
||||||
|
if (!session) return NextResponse.json({ error: "Non autorisé" }, { status: 401 });
|
||||||
|
|
||||||
|
const { fileUrl } = await req.json();
|
||||||
|
if (!fileUrl) return NextResponse.json({ error: "Fichier requis" }, { status: 400 });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const cert = await prisma.certificate.create({
|
||||||
|
data: {
|
||||||
|
userId: session.user.id,
|
||||||
|
fileUrl,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return NextResponse.json(cert, { status: 201 });
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
return NextResponse.json({ error: "Erreur" }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function PATCH(req: NextRequest) {
|
||||||
|
const admin = await requireAdmin();
|
||||||
|
if (!admin) return NextResponse.json({ error: "Admin requis" }, { status: 403 });
|
||||||
|
|
||||||
|
const { id, status } = await req.json();
|
||||||
|
if (!id || !status) return NextResponse.json({ error: "id et status requis" }, { status: 400 });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const cert = await prisma.certificate.update({
|
||||||
|
where: { id },
|
||||||
|
data: {
|
||||||
|
status: status as CertificateStatus,
|
||||||
|
reviewedAt: new Date(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return NextResponse.json(cert);
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: "Erreur" }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { requireAdmin, requireAuth } from "@/lib/session";
|
||||||
|
|
||||||
|
export async function GET(req: NextRequest) {
|
||||||
|
const session = await requireAuth();
|
||||||
|
if (!session) return NextResponse.json({ error: "Non autorisé" }, { status: 401 });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const homework = await prisma.homework.findMany({
|
||||||
|
orderBy: { createdAt: "desc" },
|
||||||
|
where: session.user.role === "ADMIN" ? {} : {
|
||||||
|
OR: [
|
||||||
|
{ userId: null },
|
||||||
|
{ userId: session.user.id },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return NextResponse.json(homework);
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: "Erreur" }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(req: NextRequest) {
|
||||||
|
const admin = await requireAdmin();
|
||||||
|
if (!admin) return NextResponse.json({ error: "Admin requis" }, { status: 403 });
|
||||||
|
|
||||||
|
const { title, description, videoUrl, userId } = await req.json();
|
||||||
|
if (!title || !description) {
|
||||||
|
return NextResponse.json({ error: "Titre et description requis" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const homework = await prisma.homework.create({
|
||||||
|
data: {
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
videoUrl: videoUrl || null,
|
||||||
|
userId: userId || null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return NextResponse.json(homework, { status: 201 });
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: "Erreur" }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function DELETE(req: NextRequest) {
|
||||||
|
const admin = await requireAdmin();
|
||||||
|
if (!admin) return NextResponse.json({ error: "Admin requis" }, { status: 403 });
|
||||||
|
|
||||||
|
const { id } = await req.json();
|
||||||
|
if (!id) return NextResponse.json({ error: "id requis" }, { status: 400 });
|
||||||
|
|
||||||
|
try {
|
||||||
|
await prisma.homework.delete({ where: { id } });
|
||||||
|
return NextResponse.json({ ok: true });
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: "Erreur" }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { requireAuth } from "@/lib/session";
|
||||||
|
|
||||||
|
export async function GET(req: NextRequest) {
|
||||||
|
const session = await requireAuth();
|
||||||
|
if (!session) return NextResponse.json({ error: "Non autorisé" }, { status: 401 });
|
||||||
|
|
||||||
|
const user = await prisma.user.findUnique({
|
||||||
|
where: { id: session.user.id },
|
||||||
|
include: {
|
||||||
|
muayThai: {
|
||||||
|
include: {
|
||||||
|
sessions: { orderBy: { date: "desc" } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
yoga: {
|
||||||
|
include: {
|
||||||
|
attendances: { orderBy: { date: "desc" } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
certificates: { orderBy: { createdAt: "desc" } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user) return NextResponse.json({ error: "Utilisateur introuvable" }, { status: 404 });
|
||||||
|
|
||||||
|
return NextResponse.json(user);
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { requireAdmin, requireAuth } from "@/lib/session";
|
||||||
|
import { MediaVisibility, MediaType } from "@prisma/client";
|
||||||
|
|
||||||
|
export async function GET(req: NextRequest) {
|
||||||
|
const session = await requireAuth();
|
||||||
|
if (!session) {
|
||||||
|
return NextResponse.json({ error: "Non autorisé" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const orderBy = "desc";
|
||||||
|
try {
|
||||||
|
const media = await prisma.media.findMany({
|
||||||
|
orderBy: { createdAt: orderBy as "desc" },
|
||||||
|
});
|
||||||
|
return NextResponse.json(media);
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: "Erreur" }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(req: NextRequest) {
|
||||||
|
const admin = await requireAdmin();
|
||||||
|
if (!admin) {
|
||||||
|
return NextResponse.json({ error: "Admin requis" }, { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await req.json();
|
||||||
|
const { title, description, url, type, visibility } = body;
|
||||||
|
|
||||||
|
if (!title || !url) {
|
||||||
|
return NextResponse.json({ error: "Titre et URL requis" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const media = await prisma.media.create({
|
||||||
|
data: {
|
||||||
|
title,
|
||||||
|
description: description || null,
|
||||||
|
url,
|
||||||
|
type: (type as MediaType) || "PHOTO",
|
||||||
|
visibility: (visibility as MediaVisibility) || "MEMBERS_ONLY",
|
||||||
|
uploadedById: admin.user.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return NextResponse.json(media, { status: 201 });
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
return NextResponse.json({ error: "Erreur" }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function DELETE(req: NextRequest) {
|
||||||
|
const admin = await requireAdmin();
|
||||||
|
if (!admin) {
|
||||||
|
return NextResponse.json({ error: "Admin requis" }, { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id } = await req.json();
|
||||||
|
if (!id) {
|
||||||
|
return NextResponse.json({ error: "id requis" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await prisma.media.delete({ where: { id } });
|
||||||
|
return NextResponse.json({ ok: true });
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
return NextResponse.json({ error: "Erreur" }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { requireAuth } from "@/lib/session";
|
||||||
|
|
||||||
|
export async function GET(req: NextRequest) {
|
||||||
|
const session = await requireAuth();
|
||||||
|
if (!session) return NextResponse.json({ error: "Non autorisé" }, { status: 401 });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const messages = await prisma.message.findMany({
|
||||||
|
orderBy: { createdAt: "desc" },
|
||||||
|
include: {
|
||||||
|
readBy: {
|
||||||
|
where: { userId: session.user.id },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = messages.map((m) => ({
|
||||||
|
...m,
|
||||||
|
isRead: m.readBy.length > 0,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return NextResponse.json(result);
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: "Erreur" }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(req: NextRequest) {
|
||||||
|
const session = await requireAuth();
|
||||||
|
if (!session) return NextResponse.json({ error: "Non autorisé" }, { status: 401 });
|
||||||
|
|
||||||
|
const { messageId } = await req.json();
|
||||||
|
if (!messageId) return NextResponse.json({ error: "messageId requis" }, { status: 400 });
|
||||||
|
|
||||||
|
try {
|
||||||
|
await prisma.messageRead.upsert({
|
||||||
|
where: {
|
||||||
|
messageId_userId: {
|
||||||
|
messageId,
|
||||||
|
userId: session.user.id,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
update: {},
|
||||||
|
create: {
|
||||||
|
messageId,
|
||||||
|
userId: session.user.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return NextResponse.json({ ok: true });
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: "Erreur" }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { requireAuth } from "@/lib/session";
|
||||||
|
|
||||||
|
export async function POST(req: NextRequest) {
|
||||||
|
const session = await requireAuth();
|
||||||
|
if (!session) return NextResponse.json({ error: "Non autorisé" }, { status: 401 });
|
||||||
|
|
||||||
|
const { jumpRopeTime, burpee100Time, notes } = await req.json();
|
||||||
|
|
||||||
|
if (!jumpRopeTime && !burpee100Time) {
|
||||||
|
return NextResponse.json({ error: "Au moins un chrono requis" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const profile = await prisma.muayThaiProfile.upsert({
|
||||||
|
where: { userId: session.user.id },
|
||||||
|
update: {},
|
||||||
|
create: { userId: session.user.id },
|
||||||
|
});
|
||||||
|
|
||||||
|
const sessionEntry = await prisma.trainingSession.create({
|
||||||
|
data: {
|
||||||
|
profileId: profile.id,
|
||||||
|
jumpRopeTime: jumpRopeTime ? Number(jumpRopeTime) : null,
|
||||||
|
burpee100Time: burpee100Time ? Number(burpee100Time) : null,
|
||||||
|
notes: notes || null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Mettre à jour les meilleurs temps
|
||||||
|
if (jumpRopeTime && profile.jumpRopeBestTime === null) {
|
||||||
|
await prisma.muayThaiProfile.update({
|
||||||
|
where: { id: profile.id },
|
||||||
|
data: { jumpRopeBestTime: Number(jumpRopeTime) },
|
||||||
|
});
|
||||||
|
} else if (jumpRopeTime && Number(jumpRopeTime) < (profile.jumpRopeBestTime || Infinity)) {
|
||||||
|
await prisma.muayThaiProfile.update({
|
||||||
|
where: { id: profile.id },
|
||||||
|
data: { jumpRopeBestTime: Number(jumpRopeTime) },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (burpee100Time && profile.burpee100BestTime === null) {
|
||||||
|
await prisma.muayThaiProfile.update({
|
||||||
|
where: { id: profile.id },
|
||||||
|
data: { burpee100BestTime: Number(burpee100Time) },
|
||||||
|
});
|
||||||
|
} else if (burpee100Time && Number(burpee100Time) < (profile.burpee100BestTime || Infinity)) {
|
||||||
|
await prisma.muayThaiProfile.update({
|
||||||
|
where: { id: profile.id },
|
||||||
|
data: { burpee100BestTime: Number(burpee100Time) },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json(sessionEntry, { status: 201 });
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
return NextResponse.json({ error: "Erreur" }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { getSession } from "@/lib/session";
|
||||||
|
import { writeFile, mkdir } from "fs/promises";
|
||||||
|
import path from "path";
|
||||||
|
import { sanitizeFilename } from "@/lib/upload";
|
||||||
|
|
||||||
|
function getUploadDirFar(root: string) {
|
||||||
|
const dir = process.env.UPLOAD_DIR || path.join(process.cwd(), "public", "uploads");
|
||||||
|
return path.join(dir, root.replace(/^\//, ""));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(req: NextRequest) {
|
||||||
|
const session = await getSession();
|
||||||
|
if (!session) {
|
||||||
|
return NextResponse.json({ error: "Non autorisé" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const formData = await req.formData();
|
||||||
|
const file = formData.get("file") as File | null;
|
||||||
|
const subdirRaw = (formData.get("subdir") as string) || "";
|
||||||
|
const subdir = subdirRaw.replace(/[^a-zA-Z0-9_/-]/g, "");
|
||||||
|
|
||||||
|
if (!file) {
|
||||||
|
return NextResponse.json({ error: "Aucun fichier fourni" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const originalName = sanitizeFilename(file.name);
|
||||||
|
const timestamp = Date.now();
|
||||||
|
const ext = path.extname(originalName) || ".bin";
|
||||||
|
const base = path.basename(originalName, ext);
|
||||||
|
const filename = `${timestamp}-${base}${ext}`;
|
||||||
|
|
||||||
|
const targetDir = getUploadDirFar(subdir);
|
||||||
|
await mkdir(targetDir, { recursive: true });
|
||||||
|
|
||||||
|
const bytes = Buffer.from(await file.arrayBuffer());
|
||||||
|
await writeFile(path.join(targetDir, filename), bytes);
|
||||||
|
|
||||||
|
const publicPath = `/uploads${subdir ? "/" + subdir : ""}/${filename}`;
|
||||||
|
|
||||||
|
return NextResponse.json({ url: publicPath, filename }, { status: 200 });
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Upload error:", error);
|
||||||
|
return NextResponse.json({ error: "Erreur lors de l'upload" }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { requireAdmin, requireAuth } from "@/lib/session";
|
||||||
|
|
||||||
|
export async function GET(req: NextRequest) {
|
||||||
|
const session = await requireAuth();
|
||||||
|
if (!session) return NextResponse.json({ error: "Non autorisé" }, { status: 401 });
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (session.user.role === "ADMIN") {
|
||||||
|
const attendances = await prisma.yogaAttendance.findMany({
|
||||||
|
orderBy: { date: "desc" },
|
||||||
|
include: { profile: { include: { user: { select: { id: true, name: true, email: true } } } } },
|
||||||
|
});
|
||||||
|
return NextResponse.json(attendances);
|
||||||
|
}
|
||||||
|
|
||||||
|
const profile = await prisma.yogaProfile.findUnique({
|
||||||
|
where: { userId: session.user.id },
|
||||||
|
include: { attendances: { orderBy: { date: "desc" } } },
|
||||||
|
});
|
||||||
|
if (!profile) return NextResponse.json({ error: "Profil yoga introuvable" }, { status: 404 });
|
||||||
|
return NextResponse.json(profile);
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: "Erreur" }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(req: NextRequest) {
|
||||||
|
const admin = await requireAdmin();
|
||||||
|
if (!admin) return NextResponse.json({ error: "Admin requis" }, { status: 403 });
|
||||||
|
|
||||||
|
const { userId, pricePaid, notes } = await req.json();
|
||||||
|
if (!userId) return NextResponse.json({ error: "userId requis" }, { status: 400 });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const profile = await prisma.yogaProfile.upsert({
|
||||||
|
where: { userId },
|
||||||
|
update: {},
|
||||||
|
create: { userId },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (profile.sessionsRemaining <= 0) {
|
||||||
|
return NextResponse.json({ error: "Le membre n'a plus de séances restantes" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const attendance = await prisma.yogaAttendance.create({
|
||||||
|
data: {
|
||||||
|
profileId: profile.id,
|
||||||
|
pricePaid: pricePaid ? Number(pricePaid) : null,
|
||||||
|
notes: notes || null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await prisma.yogaProfile.update({
|
||||||
|
where: { id: profile.id },
|
||||||
|
data: { sessionsRemaining: { decrement: 1 } },
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json(attendance, { status: 201 });
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
return NextResponse.json({ error: "Erreur" }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import Header from "@/components/Header";
|
||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
|
export default function ErrorPage() {
|
||||||
|
return (
|
||||||
|
<main className="min-h-screen">
|
||||||
|
<Header />
|
||||||
|
<div className="mx-auto flex max-w-3xl flex-col items-center justify-center px-4 py-20 text-center">
|
||||||
|
<div className="mb-4 text-6xl">😤</div>
|
||||||
|
<h1 className="text-3xl font-extrabold">Oups, une erreur !</h1>
|
||||||
|
<p className="mt-2 text-white/60">
|
||||||
|
Quelque chose a mal tourné. Réessaie.
|
||||||
|
</p>
|
||||||
|
<Link href="/" className="btn-primary mt-8">
|
||||||
|
← Retour à l'accueil
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
// Tailwind global styles
|
||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
|
|
||||||
|
@layer base {
|
||||||
|
html {
|
||||||
|
-webkit-tap-highlight-color: transparent;
|
||||||
|
}
|
||||||
|
body {
|
||||||
|
@apply bg-dark-900 text-white antialiased;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer components {
|
||||||
|
.btn-primary {
|
||||||
|
@apply inline-flex items-center justify-center rounded-xl bg-primary-600 px-6 py-3 font-semibold text-white transition-all hover:bg-primary-700 active:scale-95;
|
||||||
|
}
|
||||||
|
.btn-secondary {
|
||||||
|
@apply inline-flex items-center justify-center rounded-xl border border-white/20 bg-white/5 px-6 py-3 font-semibold text-white transition-all hover:bg-white/10 active:scale-95;
|
||||||
|
}
|
||||||
|
.btn-danger {
|
||||||
|
@apply inline-flex items-center justify-center rounded-xl bg-red-600 px-4 py-2 font-semibold text-white transition-all hover:bg-red-700 active:scale-95;
|
||||||
|
}
|
||||||
|
.input {
|
||||||
|
@apply w-full rounded-xl border border-white/10 bg-white/5 px-4 py-3 text-white placeholder-white/40 outline-none transition-all focus:border-primary-500 focus:ring-2 focus:ring-primary-500/30;
|
||||||
|
}
|
||||||
|
.label {
|
||||||
|
@apply mb-1 block text-sm font-medium text-white/70;
|
||||||
|
}
|
||||||
|
.card {
|
||||||
|
@apply rounded-2xl border border-white/10 bg-white/5 p-5;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import type { Metadata, Viewport } from "next/types";
|
||||||
|
import "./globals.css";
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: {
|
||||||
|
default: "JuanitoSport",
|
||||||
|
template: "%s | JuanitoSport",
|
||||||
|
},
|
||||||
|
description:
|
||||||
|
"JuanitoSport - Muay Thai & Yog4Fight à Bazainville. Cours d'essai gratuit, ambiance familiale.",
|
||||||
|
manifest: "/manifest.json",
|
||||||
|
appleWebApp: {
|
||||||
|
capable: true,
|
||||||
|
statusBarStyle: "black-translucent",
|
||||||
|
title: "JuanitoSport",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const viewport: Viewport = {
|
||||||
|
width: "device-width",
|
||||||
|
initialScale: 1,
|
||||||
|
maximumScale: 1,
|
||||||
|
themeColor: "#0f0f0f",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function RootLayout({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<html lang="fr">
|
||||||
|
<head>
|
||||||
|
<link rel="apple-touch-icon" href="/icons/icon-192x192.png" />
|
||||||
|
</head>
|
||||||
|
<body className="bg-dark-900 text-white antialiased">
|
||||||
|
{children}
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { signIn } from "next-auth/react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import Link from "next/link";
|
||||||
|
import Header from "@/components/Header";
|
||||||
|
|
||||||
|
export default function LoginPage() {
|
||||||
|
const router = useRouter();
|
||||||
|
const [email, setEmail] = useState("");
|
||||||
|
const [password, setPassword] = useState("");
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
async function handleSubmit(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
setError("");
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
const res = await signIn("credentials", {
|
||||||
|
email,
|
||||||
|
password,
|
||||||
|
redirect: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res?.error) {
|
||||||
|
setError(res.error);
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
router.push("/member");
|
||||||
|
router.refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="flex min-h-screen flex-col">
|
||||||
|
<Header />
|
||||||
|
<div className="flex flex-1 items-center justify-center px-4 py-8">
|
||||||
|
<div className="w-full max-w-sm">
|
||||||
|
<div className="mb-6 text-center">
|
||||||
|
<div className="mx-auto mb-3 flex h-16 w-16 items-center justify-center rounded-full bg-primary-600 text-2xl">
|
||||||
|
🥊
|
||||||
|
</div>
|
||||||
|
<h1 className="text-2xl font-bold">Connexion</h1>
|
||||||
|
<p className="mt-1 text-sm text-white/60">
|
||||||
|
Espace adhérents JuanitoSport
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="card space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="label">Email</label>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
className="input"
|
||||||
|
placeholder="vous@email.com"
|
||||||
|
required
|
||||||
|
autoComplete="email"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="label">Mot de passe</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
className="input"
|
||||||
|
placeholder="••••••••"
|
||||||
|
required
|
||||||
|
autoComplete="current-password"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<p className="rounded-xl border border-red-500/30 bg-red-500/10 px-4 py-3 text-sm text-red-300">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="btn-primary w-full"
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
{loading ? "Connexion..." : "Se connecter"}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<p className="mt-4 text-center text-xs text-white/40">
|
||||||
|
Pas de compte ? Contactez Juanito pour en créer un.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Link
|
||||||
|
href="/"
|
||||||
|
className="mt-6 block text-center text-sm text-white/50 hover:text-white"
|
||||||
|
>
|
||||||
|
← Retour à l'accueil
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import Link from "next/link";
|
||||||
|
import Header from "@/components/Header";
|
||||||
|
|
||||||
|
export default function AbsenceForm() {
|
||||||
|
const router = useRouter();
|
||||||
|
const [date, setDate] = useState("");
|
||||||
|
const [reason, setReason] = useState("");
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [done, setDone] = useState(false);
|
||||||
|
|
||||||
|
async function handleSubmit(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!date) return;
|
||||||
|
setSaving(true);
|
||||||
|
const res = await fetch("/api/absences", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ date, reason }),
|
||||||
|
});
|
||||||
|
setSaving(false);
|
||||||
|
if (res.ok) {
|
||||||
|
setDone(true);
|
||||||
|
setTimeout(() => {
|
||||||
|
router.push("/member/muay-thai");
|
||||||
|
router.refresh();
|
||||||
|
}, 1500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="min-h-screen">
|
||||||
|
<Header />
|
||||||
|
<div className="mx-auto max-w-3xl px-4 py-8">
|
||||||
|
<Link href="/member/muay-thai" className="mb-4 block text-sm text-primary-300">
|
||||||
|
← Retour à mon espace
|
||||||
|
</Link>
|
||||||
|
<h1 className="mb-4 text-2xl font-bold">📅 Signaler une absence</h1>
|
||||||
|
|
||||||
|
{done ? (
|
||||||
|
<div className="card border-emerald-500/30 text-center">
|
||||||
|
<p className="text-2xl">👍</p>
|
||||||
|
<p className="mt-2 font-semibold text-emerald-300">
|
||||||
|
Absence signalée, merci !
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-sm text-white/50">Meilleure forme, à bientôt.</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<form onSubmit={handleSubmit} className="card space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="label">Date du cours manqué</label>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={date}
|
||||||
|
onChange={(e) => setDate(e.target.value)}
|
||||||
|
className="input"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="label">Raison (optionnelle)</label>
|
||||||
|
<textarea
|
||||||
|
value={reason}
|
||||||
|
onChange={(e) => setReason(e.target.value)}
|
||||||
|
className="input min-h-[100px]"
|
||||||
|
placeholder="Ex: blessure, travail, vacances..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button type="submit" className="btn-primary w-full" disabled={saving}>
|
||||||
|
{saving ? "Envoi..." : "✅ Confirmer mon absence"}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { getServerSession } from "next-auth";
|
||||||
|
import { authOptions } from "@/lib/auth";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import AbsenceForm from "./absence-form";
|
||||||
|
|
||||||
|
export default async function AbsencePage() {
|
||||||
|
const session = await getServerSession(authOptions);
|
||||||
|
if (!session?.user) redirect("/login");
|
||||||
|
return <AbsenceForm />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import Link from "next/link";
|
||||||
|
import Header from "@/components/Header";
|
||||||
|
|
||||||
|
export default function CertificateUpload({ certificates }: { certificates: any[] }) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [file, setFile] = useState<File | null>(null);
|
||||||
|
const [uploading, setUploading] = useState(false);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const [success, setSuccess] = useState("");
|
||||||
|
|
||||||
|
const latest = certificates[0];
|
||||||
|
|
||||||
|
async function handleSubmit(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!file) {
|
||||||
|
setError("Choisis un fichier d'abord");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setUploading(true);
|
||||||
|
setError("");
|
||||||
|
setSuccess("");
|
||||||
|
|
||||||
|
try {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append("file", file);
|
||||||
|
formData.append("subdir", "certificates");
|
||||||
|
|
||||||
|
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/certificates", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ fileUrl: url }),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error("Enregistrement échoué");
|
||||||
|
|
||||||
|
setSuccess("Certificat envoyé ! Juanito va le vérifier.");
|
||||||
|
setFile(null);
|
||||||
|
router.refresh();
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.message || "Une erreur est survenue");
|
||||||
|
} finally {
|
||||||
|
setUploading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="min-h-screen">
|
||||||
|
<Header />
|
||||||
|
<div className="mx-auto max-w-3xl px-4 py-8">
|
||||||
|
<Link href="/member/muay-thai" className="mb-4 block text-sm text-primary-300">
|
||||||
|
← Retour à mon espace
|
||||||
|
</Link>
|
||||||
|
<h1 className="mb-4 text-2xl font-bold">📄 Certificat médical</h1>
|
||||||
|
|
||||||
|
<div className="card mb-6 border-amber-500/30 bg-amber-500/5">
|
||||||
|
<p className="text-sm text-amber-300">
|
||||||
|
⚠️ Le certificat médical est obligatoire pour pratiquer le Muay
|
||||||
|
Thai. Envoie une photo ou un scan de ton certificat, Juanito le
|
||||||
|
validera rapidement.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{latest && (
|
||||||
|
<div className="card mb-6">
|
||||||
|
<h2 className="mb-2 font-semibold">Statut actuel</h2>
|
||||||
|
<StatusBadge status={latest.status} />
|
||||||
|
<p className="mt-2 text-xs text-white/50">
|
||||||
|
Envoyé le{" "}
|
||||||
|
{new Date(latest.createdAt).toLocaleDateString("fr-FR", {
|
||||||
|
day: "numeric",
|
||||||
|
month: "long",
|
||||||
|
year: "numeric",
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="card space-y-4">
|
||||||
|
<h2 className="font-semibold">
|
||||||
|
{latest?.status === "VALIDATED"
|
||||||
|
? "Renvoyer un certificat (si le tien expire)"
|
||||||
|
: "Envoyer votre certificat"}
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<div className="flex flex-col items-center justify-center rounded-xl border-2 border-dashed border-white/20 bg-white/5 py-8">
|
||||||
|
<span className="mb-2 text-4xl">📎</span>
|
||||||
|
{file ? (
|
||||||
|
<p className="text-sm text-primary-300">{file.name}</p>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-white/50">
|
||||||
|
Photo ou PDF du certificat
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<label className="btn-secondary mt-4 cursor-pointer">
|
||||||
|
{file ? "Changer de fichier" : "Choisir un fichier"}
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
accept=".pdf,.jpg,.jpeg,.png,.webp,.heic"
|
||||||
|
className="hidden"
|
||||||
|
onChange={(e) => {
|
||||||
|
setFile(e.target.files?.[0] || null);
|
||||||
|
setError("");
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <p className="text-sm text-red-400">{error}</p>}
|
||||||
|
{success && (
|
||||||
|
<p className="rounded-xl border border-emerald-500/30 bg-emerald-500/10 px-4 py-3 text-sm text-emerald-300">
|
||||||
|
✅ {success}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="btn-primary w-full"
|
||||||
|
disabled={uploading || !file}
|
||||||
|
>
|
||||||
|
{uploading ? "Envoi en cours..." : "📤 Envoyer le certificat"}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatusBadge({ status }: { status: string }) {
|
||||||
|
switch (status) {
|
||||||
|
case "VALIDATED":
|
||||||
|
return (
|
||||||
|
<span className="inline-flex rounded-full bg-emerald-500/20 px-3 py-1 text-sm font-semibold text-emerald-300">
|
||||||
|
✓ Validé
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
case "REJECTED":
|
||||||
|
return (
|
||||||
|
<span className="inline-flex rounded-full bg-red-500/20 px-3 py-1 text-sm font-semibold text-red-300">
|
||||||
|
✗ Rejeté
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
default:
|
||||||
|
return (
|
||||||
|
<span className="inline-flex rounded-full bg-amber-500/20 px-3 py-1 text-sm font-semibold text-amber-300">
|
||||||
|
⏳ En attente de validation
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { getServerSession } from "next-auth";
|
||||||
|
import { authOptions } from "@/lib/auth";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import CertificateUpload from "./certificate-upload";
|
||||||
|
|
||||||
|
export default async function CertificatePage() {
|
||||||
|
const session = await getServerSession(authOptions);
|
||||||
|
if (!session?.user) redirect("/login");
|
||||||
|
|
||||||
|
const certificates = await prisma.certificate.findMany({
|
||||||
|
where: { userId: session.user.id },
|
||||||
|
orderBy: { createdAt: "desc" },
|
||||||
|
});
|
||||||
|
|
||||||
|
return <CertificateUpload certificates={certificates} />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,399 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import Header from "@/components/Header";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { signOut } from "next-auth/react";
|
||||||
|
|
||||||
|
type DashboardProps = {
|
||||||
|
user: any;
|
||||||
|
messages: any[];
|
||||||
|
unreadCount: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
function formatDuration(seconds: number | null) {
|
||||||
|
if (!seconds) return "—";
|
||||||
|
const m = Math.floor(seconds / 60);
|
||||||
|
const s = seconds % 60;
|
||||||
|
if (m === 0) return `${s}s`;
|
||||||
|
return `${m}m${s.toString().padStart(2, "0")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function MuayThaiDashboard({
|
||||||
|
user,
|
||||||
|
messages,
|
||||||
|
unreadCount,
|
||||||
|
}: DashboardProps) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [media, setMedia] = useState<any[]>([]);
|
||||||
|
const [showMedia, setShowMedia] = useState(false);
|
||||||
|
const [showHomework, setShowHomework] = useState(false);
|
||||||
|
const [showMyPerf, setShowMyPerf] = useState(false);
|
||||||
|
|
||||||
|
const profile = user?.muayThai;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetch("/api/media")
|
||||||
|
.then((r) => r.json())
|
||||||
|
.then((data) =>
|
||||||
|
setMedia(
|
||||||
|
(data || []).filter(
|
||||||
|
(m: any) =>
|
||||||
|
m.visibility === "PUBLIC" ||
|
||||||
|
m.visibility === "MEMBERS_ONLY" ||
|
||||||
|
m.visibility === "MUAY_THAI_ONLY"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.catch(() => {});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
async function markAsRead(messageId: string) {
|
||||||
|
try {
|
||||||
|
await fetch("/api/messages", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ messageId }),
|
||||||
|
});
|
||||||
|
router.refresh();
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="min-h-screen pb-16">
|
||||||
|
<Header />
|
||||||
|
<div className="mx-auto max-w-3xl px-4 py-6">
|
||||||
|
<div className="mb-6 flex items-start justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-extrabold">
|
||||||
|
👋 Salut, {user.name.split(" ")[0]} !
|
||||||
|
</h1>
|
||||||
|
<p className="mt-1 text-sm text-white/60">
|
||||||
|
Voici ton espace Muay Thai
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => signOut({ callbackUrl: "/" })}
|
||||||
|
className="rounded-xl border border-white/10 bg-white/5 p-2 text-sm text-white/60"
|
||||||
|
title="Déconnexion"
|
||||||
|
>
|
||||||
|
🚪
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Messages de Juanito */}
|
||||||
|
<section className="mb-6">
|
||||||
|
<div className="mb-3 flex items-center justify-between">
|
||||||
|
<h2 className="text-lg font-bold">
|
||||||
|
💬 Messages de Juanito{" "}
|
||||||
|
{unreadCount > 0 && (
|
||||||
|
<span className="ml-1 rounded-full bg-primary-600 px-2 py-0.5 text-xs font-bold text-white">
|
||||||
|
{unreadCount} nouveau(x)
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{messages.length === 0 && (
|
||||||
|
<p className="card text-sm text-white/50">
|
||||||
|
Aucun message pour le moment. Juanito ne va pas tarder !
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{messages.map((msg) => (
|
||||||
|
<div
|
||||||
|
key={msg.id}
|
||||||
|
className={`card ${!msg.isRead ? "border-primary-500/40 bg-primary-500/5" : ""}`}
|
||||||
|
onClick={() => !msg.isRead && markAsRead(msg.id)}
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<h3 className="font-semibold">{msg.title}</h3>
|
||||||
|
<p className="mt-1 whitespace-pre-line text-sm text-white/75">
|
||||||
|
{msg.content}
|
||||||
|
</p>
|
||||||
|
<p className="mt-2 text-xs text-white/40">
|
||||||
|
{new Date(msg.createdAt).toLocaleDateString("fr-FR", {
|
||||||
|
day: "numeric",
|
||||||
|
month: "long",
|
||||||
|
year: "numeric",
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{!msg.isRead && (
|
||||||
|
<span className="mt-1 h-3 w-3 shrink-0 rounded-full bg-primary-500" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Section Performance */}
|
||||||
|
<section className="mb-6">
|
||||||
|
<div className="mb-3 flex items-center justify-between">
|
||||||
|
<h2 className="text-lg font-bold">🏅 Ta performance</h2>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowMyPerf(!showMyPerf)}
|
||||||
|
className="text-sm text-primary-300 hover:text-primary-200"
|
||||||
|
>
|
||||||
|
{showMyPerf ? "Voir la suite" : "+ Ajouter un chrono"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showMyPerf && <AddPerfForm profileId={profile?.id} onDone={() => { setShowMyPerf(false); router.refresh(); }} />}
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div className="card text-center">
|
||||||
|
<p className="text-xs text-white/50">🪢 Meilleur chrono corde</p>
|
||||||
|
<p className="mt-1 text-2xl font-bold text-primary-300">
|
||||||
|
{formatDuration(profile?.jumpRopeBestTime)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="card text-center">
|
||||||
|
<p className="text-xs text-white/50">💪 100 burpees</p>
|
||||||
|
<p className="mt-1 text-2xl font-bold text-primary-300">
|
||||||
|
{formatDuration(profile?.burpee100BestTime)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-3 card">
|
||||||
|
<h3 className="mb-2 text-sm font-semibold text-white/70">
|
||||||
|
Historique des entraînements
|
||||||
|
</h3>
|
||||||
|
{profile?.sessions?.length === 0 && (
|
||||||
|
<p className="text-sm text-white/50">
|
||||||
|
Aucun entraînement enregistré pour l'instant.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<div className="max-h-56 space-y-2 overflow-y-auto">
|
||||||
|
{profile?.sessions?.map((s: any) => (
|
||||||
|
<div
|
||||||
|
key={s.id}
|
||||||
|
className="flex items-center justify-between rounded-xl bg-white/5 px-3 py-2 text-sm"
|
||||||
|
>
|
||||||
|
<span className="text-white/70">
|
||||||
|
{new Date(s.date).toLocaleDateString("fr-FR", {
|
||||||
|
day: "2-digit",
|
||||||
|
month: "short",
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
<span className="flex gap-3 text-xs tabular-nums text-white/50">
|
||||||
|
{s.jumpRopeTime && (
|
||||||
|
<span>🪢 {formatDuration(s.jumpRopeTime)}</span>
|
||||||
|
)}
|
||||||
|
{s.burpee100Time && (
|
||||||
|
<span>💪 {formatDuration(s.burpee100Time)}</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Actions rapides */}
|
||||||
|
<section className="mb-6 grid grid-cols-2 gap-3">
|
||||||
|
<Link href="/member/muay-thai/certificate" className="card flex flex-col items-center justify-center py-5 text-center transition-colors hover:bg-white/10">
|
||||||
|
<span className="mb-2 text-3xl">📄</span>
|
||||||
|
<span className="text-sm font-semibold">Certificat médical</span>
|
||||||
|
<span className="mt-1 text-xs text-white/50">
|
||||||
|
{user.certificates?.some((c: any) => c.status === "VALIDATED")
|
||||||
|
? "✓ Validé"
|
||||||
|
: user.certificates?.some((c: any) => c.status === "PENDING")
|
||||||
|
? "⏳ En cours"
|
||||||
|
: "À fournir"}
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
<Link href="/member/muay-thai/absence" className="card flex flex-col items-center justify-center py-5 text-center transition-colors hover:bg-white/10">
|
||||||
|
<span className="mb-2 text-3xl">📅</span>
|
||||||
|
<span className="text-sm font-semibold">Signaler une absence</span>
|
||||||
|
</Link>
|
||||||
|
<button onClick={() => setShowMedia(!showMedia)} className="card flex flex-col items-center justify-center py-5 text-center transition-colors hover:bg-white/10">
|
||||||
|
<span className="mb-2 text-3xl">🎬</span>
|
||||||
|
<span className="text-sm font-semibold">Photos & vidéos</span>
|
||||||
|
</button>
|
||||||
|
<button onClick={() => setShowHomework(!showHomework)} className="card flex flex-col items-center justify-center py-5 text-center transition-colors hover:bg-white/10">
|
||||||
|
<span className="mb-2 text-3xl">🏋️</span>
|
||||||
|
<span className="text-sm font-semibold">Trucs à travailler</span>
|
||||||
|
</button>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Médias */}
|
||||||
|
{showMedia && (
|
||||||
|
<section className="card mb-6">
|
||||||
|
<h3 className="mb-3 font-bold">🎬 Photos & vidéos des adhérents</h3>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
{media.length === 0 && (
|
||||||
|
<p className="col-span-2 text-sm text-white/50">
|
||||||
|
Pas encore de médias publiés.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{media.map((m) =>
|
||||||
|
m.type === "PHOTO" ? (
|
||||||
|
<a
|
||||||
|
key={m.id}
|
||||||
|
href={m.url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="overflow-hidden rounded-xl bg-white/5"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={m.url}
|
||||||
|
alt={m.title}
|
||||||
|
className="aspect-square w-full object-cover"
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
<p className="truncate p-2 text-xs text-white/70">
|
||||||
|
{m.title}
|
||||||
|
</p>
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
|
<div key={m.id} className="overflow-hidden rounded-xl bg-white/5">
|
||||||
|
<video controls playsInline preload="metadata" className="aspect-video w-full" src={m.url} />
|
||||||
|
<p className="truncate p-2 text-xs text-white/70">{m.title}</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Homework */}
|
||||||
|
{showHomework && (
|
||||||
|
<section className="card mb-6">
|
||||||
|
<h3 className="mb-3 font-bold">🏋️ Exercices à travailler</h3>
|
||||||
|
{user.homework?.length === 0 && (
|
||||||
|
<p className="text-sm text-white/50">
|
||||||
|
Aucun exercice pour l'instant. Juanito va t'en envoyer !
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<div className="space-y-3">
|
||||||
|
{user.homework?.map((h: any) => (
|
||||||
|
<div key={h.id} className="rounded-xl bg-white/5 p-4">
|
||||||
|
<h4 className="font-semibold text-primary-300">{h.title}</h4>
|
||||||
|
<p className="mt-1 whitespace-pre-line text-sm text-white/75">
|
||||||
|
{h.description}
|
||||||
|
</p>
|
||||||
|
{h.videoUrl && (
|
||||||
|
<video
|
||||||
|
controls
|
||||||
|
playsInline
|
||||||
|
preload="metadata"
|
||||||
|
className="mt-3 aspect-video w-full rounded-xl"
|
||||||
|
src={h.videoUrl}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<p className="mt-2 text-xs text-white/40">
|
||||||
|
{new Date(h.createdAt).toLocaleDateString("fr-FR")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AddPerfForm({
|
||||||
|
profileId,
|
||||||
|
onDone,
|
||||||
|
}: {
|
||||||
|
profileId?: string;
|
||||||
|
onDone: () => void;
|
||||||
|
}) {
|
||||||
|
const [jumpRopeTime, setJumpRopeTime] = useState("");
|
||||||
|
const [burpee100Time, setBurpee100Time] = useState("");
|
||||||
|
const [notes, setNotes] = useState("");
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
|
||||||
|
function parseTimeToSeconds(input: string): number | null {
|
||||||
|
// Accepte "3:45", "3m45", "45" (secondes)
|
||||||
|
const clean = input.trim();
|
||||||
|
if (!clean) return null;
|
||||||
|
const colonMatch = clean.match(/^(\d+):(\d{1,2})$/);
|
||||||
|
if (colonMatch) {
|
||||||
|
return parseInt(colonMatch[1]) * 60 + parseInt(colonMatch[2]);
|
||||||
|
}
|
||||||
|
const mMatch = clean.match(/^(\d+)m(\d{1,2})$/i);
|
||||||
|
if (mMatch) {
|
||||||
|
return parseInt(mMatch[1]) * 60 + parseInt(mMatch[2]);
|
||||||
|
}
|
||||||
|
const sMatch = clean.match(/^(\d+)s?$/i);
|
||||||
|
if (sMatch) return parseInt(sMatch[1]);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSubmit(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!jumpRopeTime && !burpee100Time) {
|
||||||
|
setError("Renseigne au moins un chrono");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSaving(true);
|
||||||
|
setError("");
|
||||||
|
|
||||||
|
const payload: any = {};
|
||||||
|
const jr = parseTimeToSeconds(jumpRopeTime);
|
||||||
|
const bp = parseTimeToSeconds(burpee100Time);
|
||||||
|
if (jr !== null) payload.jumpRopeTime = jr;
|
||||||
|
if (bp !== null) payload.burpee100Time = bp;
|
||||||
|
if (notes) payload.notes = notes;
|
||||||
|
|
||||||
|
const res = await fetch("/api/training", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
onDone();
|
||||||
|
} else {
|
||||||
|
setError("Erreur lors de l'enregistrement");
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={handleSubmit} className="card mb-3 border-primary-500/30">
|
||||||
|
<h3 className="mb-3 font-semibold">⏱️ Enregistrer un chrono</h3>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<label className="label">🪢 Corde à sauter</label>
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
placeholder="ex: 3:45 ou 225"
|
||||||
|
value={jumpRopeTime}
|
||||||
|
onChange={(e) => setJumpRopeTime(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="label">💪 100 burpees</label>
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
placeholder="ex: 5:30"
|
||||||
|
value={burpee100Time}
|
||||||
|
onChange={(e) => setBurpee100Time(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-3">
|
||||||
|
<label className="label">Notes (optionnel)</label>
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
placeholder="Ta séance en quelques mots..."
|
||||||
|
value={notes}
|
||||||
|
onChange={(e) => setNotes(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{error && <p className="mt-2 text-sm text-red-400">{error}</p>}
|
||||||
|
<button type="submit" className="btn-primary mt-3 w-full" disabled={saving}>
|
||||||
|
{saving ? "Enregistrement..." : "✅ Enregistrer"}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import { getServerSession } from "next-auth";
|
||||||
|
import { authOptions } from "@/lib/auth";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import MuayThaiDashboard from "./dashboard";
|
||||||
|
|
||||||
|
export default async function MuayThaiPage() {
|
||||||
|
const session = await getServerSession(authOptions);
|
||||||
|
if (!session?.user) redirect("/login");
|
||||||
|
|
||||||
|
const user = await prisma.user.findUnique({
|
||||||
|
where: { id: session.user.id },
|
||||||
|
include: {
|
||||||
|
muayThai: {
|
||||||
|
include: {
|
||||||
|
sessions: { orderBy: { date: "desc" }, take: 20 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
certificates: { orderBy: { createdAt: "desc" } },
|
||||||
|
absences: { orderBy: { date: "desc" } },
|
||||||
|
homework: { orderBy: { createdAt: "desc" } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user) redirect("/login");
|
||||||
|
|
||||||
|
const messages = await prisma.message.findMany({
|
||||||
|
orderBy: { createdAt: "desc" },
|
||||||
|
where: {
|
||||||
|
OR: [{ category: null }, { category: "general" }, { category: "muay-thai" }],
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
readBy: { where: { userId: user.id } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const unreadCount = messages.filter((m) => m.readBy.length === 0).length;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<MuayThaiDashboard
|
||||||
|
user={user}
|
||||||
|
messages={messages.map((m) => ({ ...m, isRead: m.readBy.length > 0 }))}
|
||||||
|
unreadCount={unreadCount}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { getServerSession } from "next-auth";
|
||||||
|
import { authOptions } from "@/lib/auth";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
|
||||||
|
export default async function MemberIndex() {
|
||||||
|
const session = await getServerSession(authOptions);
|
||||||
|
|
||||||
|
if (!session?.user) {
|
||||||
|
redirect("/login");
|
||||||
|
}
|
||||||
|
|
||||||
|
const role = session.user.role;
|
||||||
|
const isMuayThai = session.user.isMuayThai;
|
||||||
|
|
||||||
|
if (role === "ADMIN") {
|
||||||
|
redirect("/admin");
|
||||||
|
} else if (isMuayThai) {
|
||||||
|
redirect("/member/muay-thai");
|
||||||
|
} else {
|
||||||
|
redirect("/member/yoga");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,164 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import Header from "@/components/Header";
|
||||||
|
import { signOut } from "next-auth/react";
|
||||||
|
|
||||||
|
export default function YogaDashboard({ user, messages, unreadCount }: any) {
|
||||||
|
const router = useRouter();
|
||||||
|
const profile = user?.yoga;
|
||||||
|
const attendances = profile?.attendances || [];
|
||||||
|
const remaining = profile?.sessionsRemaining ?? 0;
|
||||||
|
|
||||||
|
const pricePerSession = 9;
|
||||||
|
const sessionsDone = attendances.length;
|
||||||
|
const totalAttendedCost = sessionsDone * pricePerSession;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="min-h-screen pb-16">
|
||||||
|
<Header />
|
||||||
|
<div className="mx-auto max-w-3xl px-4 py-6">
|
||||||
|
<div className="mb-6 flex items-start justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-extrabold">
|
||||||
|
🧘 Salut, {user.name.split(" ")[0]} !
|
||||||
|
</h1>
|
||||||
|
<p className="mt-1 text-sm text-white/60">
|
||||||
|
Ton espace Yog4Fight
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => signOut({ callbackUrl: "/" })}
|
||||||
|
className="rounded-xl border border-white/10 bg-white/5 p-2 text-sm text-white/60"
|
||||||
|
title="Déconnexion"
|
||||||
|
>
|
||||||
|
🚪
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Compteur de séances */}
|
||||||
|
<section className="mb-6">
|
||||||
|
<div className="card text-center">
|
||||||
|
<p className="text-sm text-white/60">Séances restantes</p>
|
||||||
|
<div className="my-2 flex items-center justify-center gap-1 text-5xl font-extrabold text-emerald-300">
|
||||||
|
{remaining}
|
||||||
|
<span className="text-xl">
|
||||||
|
séance{remaining > 1 ? "s" : ""}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-white/50">
|
||||||
|
{sessionsDone} séance{sessionsDone > 1 ? "s" : ""} suivie
|
||||||
|
{sessionsDone > 1 ? "s" : ""}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Coût */}
|
||||||
|
<section className="mb-6">
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div className="card text-center">
|
||||||
|
<p className="text-xs text-white/50">Prix par séance</p>
|
||||||
|
<p className="mt-1 text-xl font-bold text-emerald-300">
|
||||||
|
{pricePerSession} €
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="card text-center">
|
||||||
|
<p className="text-xs text-white/50">Coût si assidu</p>
|
||||||
|
<p className="mt-1 text-xl font-bold text-emerald-300">
|
||||||
|
Tarif dégressif
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="mt-2 text-center text-xs text-white/50">
|
||||||
|
Demande à Juanito le tarif dégressif qui s'applique à
|
||||||
|
l'assiduité 💪
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Messages de Juanito */}
|
||||||
|
<section className="mb-6">
|
||||||
|
<div className="mb-3 flex items-center justify-between">
|
||||||
|
<h2 className="text-lg font-bold">
|
||||||
|
💬 Messages de Juanito{" "}
|
||||||
|
{unreadCount > 0 && (
|
||||||
|
<span className="ml-1 rounded-full bg-emerald-600 px-2 py-0.5 text-xs font-bold text-white">
|
||||||
|
{unreadCount} nouveau(x)
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{messages.length === 0 && (
|
||||||
|
<p className="card text-sm text-white/50">
|
||||||
|
Aucun message pour le moment.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{messages.map((msg: any) => (
|
||||||
|
<div
|
||||||
|
key={msg.id}
|
||||||
|
className={`card ${!msg.isRead ? "border-emerald-500/40 bg-emerald-500/5" : ""}`}
|
||||||
|
onClick={() => {
|
||||||
|
if (!msg.isRead) {
|
||||||
|
fetch("/api/messages", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ messageId: msg.id }),
|
||||||
|
}).then(() => router.refresh());
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<h3 className="font-semibold">{msg.title}</h3>
|
||||||
|
<p className="mt-1 whitespace-pre-line text-sm text-white/75">
|
||||||
|
{msg.content}
|
||||||
|
</p>
|
||||||
|
<p className="mt-2 text-xs text-white/40">
|
||||||
|
{new Date(msg.createdAt).toLocaleDateString("fr-FR")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{!msg.isRead && (
|
||||||
|
<span className="mt-1 h-3 w-3 shrink-0 rounded-full bg-emerald-500" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Historique séances */}
|
||||||
|
<section className="card">
|
||||||
|
<h3 className="mb-3 font-bold">📋 Mes séances suivies</h3>
|
||||||
|
{attendances.length === 0 && (
|
||||||
|
<p className="text-sm text-white/50">
|
||||||
|
Aucune séance enregistrée pour l'instant.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<div className="max-h-64 space-y-2 overflow-y-auto">
|
||||||
|
{attendances.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="text-white/70">
|
||||||
|
{new Date(a.date).toLocaleDateString("fr-FR", {
|
||||||
|
weekday: "long",
|
||||||
|
day: "numeric",
|
||||||
|
month: "long",
|
||||||
|
year: "numeric",
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
{a.pricePaid !== null && (
|
||||||
|
<span className="tabular-nums text-emerald-300 text-xs">
|
||||||
|
{a.pricePaid} €
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import { getServerSession } from "next-auth";
|
||||||
|
import { authOptions } from "@/lib/auth";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import YogaDashboard from "./dashboard";
|
||||||
|
|
||||||
|
export default async function YogaPage() {
|
||||||
|
const session = await getServerSession(authOptions);
|
||||||
|
if (!session?.user) redirect("/login");
|
||||||
|
|
||||||
|
const user = await prisma.user.findUnique({
|
||||||
|
where: { id: session.user.id },
|
||||||
|
include: {
|
||||||
|
yoga: {
|
||||||
|
include: {
|
||||||
|
attendances: { orderBy: { date: "desc" } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user) redirect("/login");
|
||||||
|
|
||||||
|
const messages = await prisma.message.findMany({
|
||||||
|
orderBy: { createdAt: "desc" },
|
||||||
|
where: {
|
||||||
|
OR: [{ category: null }, { category: "general" }, { category: "yoga" }],
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
readBy: { where: { userId: user.id } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const unreadCount = messages.filter((m) => m.readBy.length === 0).length;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<YogaDashboard
|
||||||
|
user={user}
|
||||||
|
messages={messages.map((m) => ({ ...m, isRead: m.readBy.length > 0 }))}
|
||||||
|
unreadCount={unreadCount}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import Header from "@/components/Header";
|
||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
|
const EQUIPMENT = [
|
||||||
|
{ icon: "🪢", name: "Corde à sauter" },
|
||||||
|
{ icon: "🛡️", name: "Coquille" },
|
||||||
|
{ icon: "🦷", name: "Protège-dents" },
|
||||||
|
{ icon: "🥊", name: "Gants de boxe" },
|
||||||
|
{ icon: "🦵", name: "Protège-tibias" },
|
||||||
|
{ icon: "⛑️", name: "Casque (idéalement)" },
|
||||||
|
{ icon: "💧", name: "Bouteille d'eau" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function MuayThaiPage() {
|
||||||
|
return (
|
||||||
|
<main className="min-h-screen">
|
||||||
|
<Header />
|
||||||
|
<div className="mx-auto max-w-3xl px-4 py-8">
|
||||||
|
<div className="mb-8 text-center">
|
||||||
|
<div className="mx-auto mb-4 flex h-24 w-24 items-center justify-center rounded-full bg-primary-600 text-4xl">
|
||||||
|
🥊
|
||||||
|
</div>
|
||||||
|
<h1 className="text-3xl font-extrabold text-primary-300">
|
||||||
|
Juanito Muay Thai
|
||||||
|
</h1>
|
||||||
|
<p className="mt-2 text-white/60">
|
||||||
|
🇹🇭 L'authentique Muay Thai dans une ambiance familiale
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section className="card mb-6">
|
||||||
|
<h2 className="mb-3 text-xl font-bold">À propos</h2>
|
||||||
|
<p className="text-white/80">
|
||||||
|
Venez pratiquer l'authentique Muay Thai dans une ambiance
|
||||||
|
familiale où chacun apprend à son rythme.{" "}
|
||||||
|
<strong className="text-primary-300">Cours d'essai gratuit.</strong>{" "}
|
||||||
|
🥊🇹🇭
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="card mb-6">
|
||||||
|
<h2 className="mb-3 text-xl font-bold">📅 Horaires</h2>
|
||||||
|
<p className="mb-2 text-sm text-white/60">
|
||||||
|
Cours de Muay Thai à{" "}
|
||||||
|
<strong className="text-white">BAZAINVILLE</strong> (salle
|
||||||
|
"l'atelier")
|
||||||
|
</p>
|
||||||
|
<div className="mb-4 overflow-hidden rounded-xl border border-white/10">
|
||||||
|
<div className="flex items-center justify-between bg-white/5 px-4 py-3">
|
||||||
|
<span className="font-semibold">Mardi</span>
|
||||||
|
<span className="tabular-nums text-primary-300">
|
||||||
|
19h00 – 21h00
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between border-t border-white/10 px-4 py-3">
|
||||||
|
<span className="font-semibold">Vendredi</span>
|
||||||
|
<span className="tabular-nums text-primary-300">
|
||||||
|
20h00 – 22h00
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-white/50">
|
||||||
|
À partir du 12 septembre
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="card mb-6">
|
||||||
|
<h2 className="mb-3 text-xl font-bold">💰 Tarifs</h2>
|
||||||
|
<ul className="space-y-2 text-white/80">
|
||||||
|
<li>📝 Inscription annuelle : <strong className="text-white">150 €</strong></li>
|
||||||
|
<li>✍️ Pré-inscription : <strong className="text-white">130 €</strong></li>
|
||||||
|
<li className="pt-2 font-semibold text-primary-300">
|
||||||
|
🎉 1er cours d'essai GRATUIT
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<p className="mt-4 rounded-xl border border-amber-500/30 bg-amber-500/10 px-4 py-3 text-sm text-amber-300">
|
||||||
|
⚠️ CERTIFICAT MÉDICAL OBLIGATOIRE
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="card mb-6">
|
||||||
|
<h2 className="mb-3 text-xl font-bold">🛡️ Équipement à prévoir</h2>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
{EQUIPMENT.map((item) => (
|
||||||
|
<div
|
||||||
|
key={item.name}
|
||||||
|
className="flex items-center gap-2 rounded-xl bg-white/5 px-3 py-2 text-sm"
|
||||||
|
>
|
||||||
|
<span>{item.icon}</span>
|
||||||
|
<span className="text-white/80">{item.name}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="card mb-6">
|
||||||
|
<h2 className="mb-3 text-xl font-bold">📞 Contact</h2>
|
||||||
|
<div className="space-y-2 text-white/80">
|
||||||
|
<p>📱 <a href="tel:0761175627" className="text-primary-300">07 61 17 56 27</a></p>
|
||||||
|
<p>✉️ <a href="mailto:juanitomuaythai@gmail.com" className="text-primary-300">juanitomuaythai@gmail.com</a></p>
|
||||||
|
</div>
|
||||||
|
<a
|
||||||
|
href="https://www.facebook.com/profile.php?id=100057495646143&locale=fr_FR"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="btn-secondary mt-4 w-full"
|
||||||
|
>
|
||||||
|
📘 Suivre sur Facebook
|
||||||
|
</a>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div className="mt-8 flex flex-col gap-3">
|
||||||
|
<Link href="/login" className="btn-primary w-full">
|
||||||
|
Espace adhérent
|
||||||
|
</Link>
|
||||||
|
<Link href="/" className="btn-secondary w-full">
|
||||||
|
← Retour
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import Header from "@/components/Header";
|
||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
|
export default function NotFound() {
|
||||||
|
return (
|
||||||
|
<main className="min-h-screen">
|
||||||
|
<Header />
|
||||||
|
<div className="mx-auto flex max-w-3xl flex-col items-center justify-center px-4 py-20 text-center">
|
||||||
|
<div className="mb-4 text-6xl">🥊</div>
|
||||||
|
<h1 className="text-3xl font-extrabold">404</h1>
|
||||||
|
<p className="mt-2 text-white/60">
|
||||||
|
Cette page n'existe pas... comme une feinte manquée !
|
||||||
|
</p>
|
||||||
|
<Link href="/" className="btn-primary mt-8">
|
||||||
|
← Retour à l'accueil
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useSession } from "next-auth/react";
|
||||||
|
import Header from "@/components/Header";
|
||||||
|
|
||||||
|
export default function LandingPage() {
|
||||||
|
const { data: session } = useSession();
|
||||||
|
|
||||||
|
const isAdmin = session?.user?.role === "ADMIN";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="min-h-screen flex flex-col">
|
||||||
|
<Header />
|
||||||
|
<div className="flex flex-1 flex-col items-center justify-center px-4 py-8">
|
||||||
|
<div className="mb-8 mt-4 text-center">
|
||||||
|
<div className="mx-auto mb-4 h-24 w-24 overflow-hidden rounded-full border-2 border-primary-500">
|
||||||
|
<img
|
||||||
|
src="/images/logo-muaythai.jpg"
|
||||||
|
alt="Juanito Logo"
|
||||||
|
className="h-full w-full object-cover"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<h1 className="text-3xl font-extrabold tracking-tight">
|
||||||
|
Juanito<span className="text-primary-500">Sport</span>
|
||||||
|
</h1>
|
||||||
|
<p className="mt-2 text-white/60">
|
||||||
|
Force, Mobilité & Performance
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex w-full max-w-sm flex-col gap-4">
|
||||||
|
<Link
|
||||||
|
href="/muay-thai"
|
||||||
|
className="group flex flex-col items-center rounded-3xl border border-primary-600/40 bg-gradient-to-br from-primary-800/40 to-dark-800 p-8 text-center shadow-xl transition-all hover:scale-[1.02] hover:border-primary-500 active:scale-95"
|
||||||
|
>
|
||||||
|
<span className="mb-3 h-20 w-20 overflow-hidden rounded-full border-2 border-primary-500">
|
||||||
|
<img
|
||||||
|
src="/images/logo-muaythai.jpg"
|
||||||
|
alt="Muay Thai"
|
||||||
|
className="h-full w-full object-cover"
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
<span className="text-2xl font-bold text-primary-300">
|
||||||
|
Juanito Muay Thai
|
||||||
|
</span>
|
||||||
|
<span className="mt-2 text-sm text-white/60">
|
||||||
|
L'art martial du Siam
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<Link
|
||||||
|
href="/yog4fight"
|
||||||
|
className="group flex flex-col items-center rounded-3xl border border-emerald-500/40 bg-gradient-to-br from-emerald-800/40 to-dark-800 p-8 text-center shadow-xl transition-all hover:scale-[1.02] hover:border-emerald-400 active:scale-95"
|
||||||
|
>
|
||||||
|
<span className="mb-3 h-20 w-20 overflow-hidden rounded-full border-2 border-emerald-500">
|
||||||
|
<img
|
||||||
|
src="/images/logo-yoga.png"
|
||||||
|
alt="Yog4Fight"
|
||||||
|
className="h-full w-full object-cover"
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
<span className="text-2xl font-bold text-emerald-300">
|
||||||
|
Juanito Yog 4 Fight
|
||||||
|
</span>
|
||||||
|
<span className="mt-2 text-sm text-white/60">
|
||||||
|
#mobilité égale performance
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isAdmin && (
|
||||||
|
<Link
|
||||||
|
href="/admin"
|
||||||
|
className="btn-secondary mt-8 text-sm"
|
||||||
|
>
|
||||||
|
⚙️ Tableau de bord admin
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import Header from "@/components/Header";
|
||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
|
const BENEFITS = [
|
||||||
|
{ icon: "🤸", title: "Mobilité", desc: "Améliorer votre flexibilité articulaire" },
|
||||||
|
{ icon: "🔄", title: "Récupération", desc: "Optimiser la récupération après l'effort" },
|
||||||
|
{ icon: "🧘", title: "Souplesse", desc: "Développer votre amplitude de mouvement" },
|
||||||
|
{ icon: "🛡️", title: "Prévention", desc: "Prévenir les blessures" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function Yog4FightPage() {
|
||||||
|
return (
|
||||||
|
<main className="min-h-screen">
|
||||||
|
<Header />
|
||||||
|
<div className="mx-auto max-w-3xl px-4 py-8">
|
||||||
|
<div className="mb-8 text-center">
|
||||||
|
<div className="mx-auto mb-4 flex h-24 w-24 items-center justify-center rounded-full bg-emerald-600 text-4xl">
|
||||||
|
🧘
|
||||||
|
</div>
|
||||||
|
<h1 className="text-3xl font-extrabold text-emerald-300">
|
||||||
|
Juanito Yog 4 Fight
|
||||||
|
</h1>
|
||||||
|
<p className="mt-2 text-white/60">
|
||||||
|
Une approche plus technique du yoga
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section className="card mb-6">
|
||||||
|
<h2 className="mb-3 text-xl font-bold">À propos</h2>
|
||||||
|
<p className="text-white/80">
|
||||||
|
Une approche plus technique du yoga, pensée pour les
|
||||||
|
sportifs et les combattants.
|
||||||
|
</p>
|
||||||
|
<p className="mt-3 rounded-xl border border-emerald-500/30 bg-emerald-500/10 px-4 py-3 text-center font-bold text-emerald-300">
|
||||||
|
#mobilité égale performance
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="card mb-6">
|
||||||
|
<h2 className="mb-3 text-xl font-bold">💪 Les bénéfices</h2>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
{BENEFITS.map((b) => (
|
||||||
|
<div
|
||||||
|
key={b.title}
|
||||||
|
className="rounded-xl bg-white/5 p-4 text-center"
|
||||||
|
>
|
||||||
|
<span className="mb-1 block text-2xl">{b.icon}</span>
|
||||||
|
<span className="block font-semibold text-emerald-300">
|
||||||
|
{b.title}
|
||||||
|
</span>
|
||||||
|
<span className="mt-1 block text-xs text-white/60">
|
||||||
|
{b.desc}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="card mb-6">
|
||||||
|
<h2 className="mb-3 text-xl font-bold">📅 Horaires</h2>
|
||||||
|
<div className="mb-2 overflow-hidden rounded-xl border border-white/10">
|
||||||
|
<div className="flex items-center justify-between px-4 py-3">
|
||||||
|
<span className="font-semibold">Lundi</span>
|
||||||
|
<span className="tabular-nums text-emerald-300">
|
||||||
|
19h30 – 20h30
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-white/50">
|
||||||
|
Dans la salle « L'atelier » à Bazainville
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="card mb-6">
|
||||||
|
<h2 className="mb-3 text-xl font-bold">💰 Tarif</h2>
|
||||||
|
<div className="mb-3 flex items-center justify-between rounded-xl bg-emerald-500/10 px-4 py-3">
|
||||||
|
<span className="font-semibold">Séance</span>
|
||||||
|
<span className="text-2xl font-bold text-emerald-300">9 € TTC</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-white/60">
|
||||||
|
💡 Tarif dégressif pour les plus assidus — renseignez-vous auprès
|
||||||
|
de Juanito
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="card mb-6">
|
||||||
|
<h2 className="mb-3 text-xl font-bold">🎒 À ramener</h2>
|
||||||
|
<div className="flex flex-wrap gap-3">
|
||||||
|
<span className="rounded-xl bg-white/5 px-4 py-2 text-sm">
|
||||||
|
🧘 Tapis de yoga
|
||||||
|
</span>
|
||||||
|
<span className="rounded-xl bg-white/5 px-4 py-2 text-sm">
|
||||||
|
💧 Bouteille d'eau
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="card mb-6">
|
||||||
|
<h2 className="mb-3 text-xl font-bold">📞 Contact</h2>
|
||||||
|
<p className="text-white/80">
|
||||||
|
✉️ <a href="mailto:contact@yogafight.fr" className="text-emerald-300">contact@yogafight.fr</a>
|
||||||
|
</p>
|
||||||
|
<a
|
||||||
|
href="https://www.instagram.com/yog4fight/"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="btn-secondary mt-4 w-full"
|
||||||
|
>
|
||||||
|
📸 Suivre sur Instagram
|
||||||
|
</a>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div className="mt-8 flex flex-col gap-3">
|
||||||
|
<Link href="/login" className="btn-primary w-full">
|
||||||
|
Espace adhérent
|
||||||
|
</Link>
|
||||||
|
<Link href="/" className="btn-secondary w-full">
|
||||||
|
← Retour
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
import { signOut } from "next-auth/react";
|
||||||
|
import { useSession } from "next-auth/react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
|
||||||
|
export default function Header() {
|
||||||
|
const { data: session } = useSession();
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<header className="sticky top-0 z-50 border-b border-white/10 bg-dark-900/90 backdrop-blur-md">
|
||||||
|
<div className="mx-auto flex max-w-3xl items-center justify-between px-4 py-3">
|
||||||
|
<Link href="/" className="text-lg font-bold">
|
||||||
|
🥊 Juanito<span className="text-primary-500">Sport</span>
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
{session?.user ? (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Link
|
||||||
|
href={
|
||||||
|
session.user.role === "ADMIN"
|
||||||
|
? "/admin"
|
||||||
|
: session.user.isMuayThai
|
||||||
|
? "/member/muay-thai"
|
||||||
|
: "/member/yoga"
|
||||||
|
}
|
||||||
|
className="btn-primary !px-4 !py-2 text-sm"
|
||||||
|
>
|
||||||
|
Mon espace
|
||||||
|
</Link>
|
||||||
|
<button
|
||||||
|
onClick={() => signOut({ callbackUrl: "/" })}
|
||||||
|
className="text-sm text-white/60 hover:text-white"
|
||||||
|
>
|
||||||
|
Déconnexion
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Link href="/login" className="btn-primary !px-4 !py-2 text-sm">
|
||||||
|
Connexion
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { SessionProvider } from "next-auth/react";
|
||||||
|
|
||||||
|
export default function Providers({ children }: { children: React.ReactNode }) {
|
||||||
|
return <SessionProvider>{children}</SessionProvider>;
|
||||||
|
}
|
||||||
+78
@@ -0,0 +1,78 @@
|
|||||||
|
import type { NextAuthOptions } from "next-auth";
|
||||||
|
import CredentialsProvider from "next-auth/providers/credentials";
|
||||||
|
import bcrypt from "bcryptjs";
|
||||||
|
import { prisma } from "./prisma";
|
||||||
|
|
||||||
|
export const authOptions: NextAuthOptions = {
|
||||||
|
session: {
|
||||||
|
strategy: "jwt",
|
||||||
|
},
|
||||||
|
pages: {
|
||||||
|
signIn: "/login",
|
||||||
|
},
|
||||||
|
providers: [
|
||||||
|
CredentialsProvider({
|
||||||
|
name: "Credentials",
|
||||||
|
credentials: {
|
||||||
|
email: { label: "Email", type: "email" },
|
||||||
|
password: { label: "Mot de passe", type: "password" },
|
||||||
|
},
|
||||||
|
async authorize(credentials) {
|
||||||
|
if (!credentials?.email || !credentials?.password) {
|
||||||
|
throw new Error("Email et mot de passe requis");
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await prisma.user.findUnique({
|
||||||
|
where: { email: credentials.email.toLowerCase() },
|
||||||
|
include: {
|
||||||
|
muayThai: true,
|
||||||
|
yoga: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
throw new Error("Aucun compte trouvé avec cet email");
|
||||||
|
}
|
||||||
|
|
||||||
|
const isValid = await bcrypt.compare(
|
||||||
|
credentials.password,
|
||||||
|
user.password
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!isValid) {
|
||||||
|
throw new Error("Mot de passe incorrect");
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: user.id,
|
||||||
|
email: user.email,
|
||||||
|
name: user.name,
|
||||||
|
role: user.role,
|
||||||
|
isMuayThai: user.isMuayThai,
|
||||||
|
isYoga: user.isYoga,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
callbacks: {
|
||||||
|
async jwt({ token, user }) {
|
||||||
|
if (user) {
|
||||||
|
token.id = user.id;
|
||||||
|
token.role = user.role;
|
||||||
|
token.isMuayThai = user.isMuayThai;
|
||||||
|
token.isYoga = user.isYoga;
|
||||||
|
}
|
||||||
|
return token;
|
||||||
|
},
|
||||||
|
async session({ session, token }) {
|
||||||
|
if (session.user) {
|
||||||
|
session.user.id = token.id as string;
|
||||||
|
session.user.role = token.role as string;
|
||||||
|
session.user.isMuayThai = token.isMuayThai as boolean;
|
||||||
|
session.user.isYoga = token.isYoga as boolean;
|
||||||
|
}
|
||||||
|
return session;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
secret: process.env.NEXTAUTH_SECRET,
|
||||||
|
};
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import { PrismaClient } from "@prisma/client";
|
||||||
|
|
||||||
|
const globalForPrisma = globalThis as unknown as {
|
||||||
|
prisma: PrismaClient | undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const prisma = globalForPrisma.prisma ?? new PrismaClient();
|
||||||
|
|
||||||
|
if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = prisma;
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { NextAuthOptions } from "next-auth";
|
||||||
|
import { getServerSession } from "next-auth";
|
||||||
|
import { authOptions } from "./auth";
|
||||||
|
|
||||||
|
export function getSession() {
|
||||||
|
return getServerSession(authOptions);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function requireAuth() {
|
||||||
|
const session = await getSession();
|
||||||
|
if (!session?.user) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return session;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function requireAdmin() {
|
||||||
|
const session = await getSession();
|
||||||
|
if (!session?.user || session.user.role !== "ADMIN") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return session;
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import path from "path";
|
||||||
|
import fs from "fs";
|
||||||
|
|
||||||
|
const UPLOAD_DIR = process.env.UPLOAD_DIR || path.join(process.cwd(), "public", "uploads");
|
||||||
|
|
||||||
|
export function getUploadDir(subdir = "") {
|
||||||
|
const dir = path.join(UPLOAD_DIR, subdir);
|
||||||
|
if (!fs.existsSync(dir)) {
|
||||||
|
fs.mkdirSync(dir, { recursive: true });
|
||||||
|
}
|
||||||
|
return dir;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getPublicUrl(filename: string, subdir = "") {
|
||||||
|
const base = subdir ? `/${subdir}` : "";
|
||||||
|
return `${base}/${filename}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sanitizeFilename(name: string) {
|
||||||
|
return name
|
||||||
|
.normalize("NFD")
|
||||||
|
.replace(/[\u0300-\u036f]/g, "")
|
||||||
|
.replace(/[^a-zA-Z0-9._-]/g, "_")
|
||||||
|
.toLowerCase();
|
||||||
|
}
|
||||||
Vendored
+5
@@ -0,0 +1,5 @@
|
|||||||
|
/// <reference types="next" />
|
||||||
|
/// <reference types="next/image-types/global" />
|
||||||
|
|
||||||
|
// NOTE: This file should not be edited
|
||||||
|
// see https://nextjs.org/docs/basic-features/typescript for more information.
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import withPWA from "next-pwa";
|
||||||
|
|
||||||
|
const nextConfig = {
|
||||||
|
reactStrictMode: true,
|
||||||
|
images: {
|
||||||
|
unoptimized: true,
|
||||||
|
domains: ["juanito.secourit.fr", "localhost"],
|
||||||
|
},
|
||||||
|
async headers() {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
source: "/uploads/:path*",
|
||||||
|
headers: [
|
||||||
|
{ key: "Cache-Control", value: "public, max-age=31536000, immutable" },
|
||||||
|
{ key: "Access-Control-Allow-Origin", value: "*" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default withPWA({
|
||||||
|
dest: "public/sw",
|
||||||
|
register: true,
|
||||||
|
skipWaiting: true,
|
||||||
|
disable: process.env.NODE_ENV === "development",
|
||||||
|
})(nextConfig);
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
{
|
||||||
|
"name": "juanitosport",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"dev": "next dev",
|
||||||
|
"build": "next build",
|
||||||
|
"start": "next start",
|
||||||
|
"postinstall": "prisma generate",
|
||||||
|
"prisma:migrate": "prisma migrate deploy",
|
||||||
|
"prisma:push": "prisma db push",
|
||||||
|
"prisma:seed": "tsx prisma/seed.ts",
|
||||||
|
"icons": "node scripts/generate-icons.mjs"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@prisma/client": "^5.22.0",
|
||||||
|
"bcryptjs": "^2.4.3",
|
||||||
|
"next": "14.2.15",
|
||||||
|
"next-auth": "^4.24.10",
|
||||||
|
"next-pwa": "^5.6.0",
|
||||||
|
"react": "^18.3.1",
|
||||||
|
"react-dom": "^18.3.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/bcryptjs": "^2.4.6",
|
||||||
|
"@types/node": "^20.14.0",
|
||||||
|
"@types/react": "^18.3.0",
|
||||||
|
"@types/react-dom": "^18.3.0",
|
||||||
|
"autoprefixer": "^10.4.20",
|
||||||
|
"postcss": "^8.4.47",
|
||||||
|
"prisma": "^5.22.0",
|
||||||
|
"sharp": "^0.33.5",
|
||||||
|
"tailwindcss": "^3.4.13",
|
||||||
|
"tsx": "^4.19.1",
|
||||||
|
"typescript": "^5.6.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,165 @@
|
|||||||
|
generator client {
|
||||||
|
provider = "prisma-client-js"
|
||||||
|
}
|
||||||
|
|
||||||
|
datasource db {
|
||||||
|
provider = "postgresql"
|
||||||
|
url = env("DATABASE_URL")
|
||||||
|
}
|
||||||
|
|
||||||
|
enum Role {
|
||||||
|
ADMIN
|
||||||
|
MEMBER
|
||||||
|
}
|
||||||
|
|
||||||
|
enum MediaType {
|
||||||
|
PHOTO
|
||||||
|
VIDEO
|
||||||
|
}
|
||||||
|
|
||||||
|
enum MediaVisibility {
|
||||||
|
PUBLIC
|
||||||
|
MEMBERS_ONLY
|
||||||
|
MUAY_THAI_ONLY
|
||||||
|
YOGA_ONLY
|
||||||
|
}
|
||||||
|
|
||||||
|
enum CertificateStatus {
|
||||||
|
PENDING
|
||||||
|
VALIDATED
|
||||||
|
REJECTED
|
||||||
|
}
|
||||||
|
|
||||||
|
model User {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
email String @unique
|
||||||
|
name String
|
||||||
|
password String
|
||||||
|
role Role @default(MEMBER)
|
||||||
|
avatar String?
|
||||||
|
phone String?
|
||||||
|
isMuayThai Boolean @default(false)
|
||||||
|
isYoga Boolean @default(false)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
muayThai MuayThaiProfile?
|
||||||
|
yoga YogaProfile?
|
||||||
|
media Media[]
|
||||||
|
messagesRead MessageRead[]
|
||||||
|
certificates Certificate[]
|
||||||
|
absences Absence[]
|
||||||
|
homework Homework[]
|
||||||
|
yogaAttend YogaAttendance[]
|
||||||
|
}
|
||||||
|
|
||||||
|
model MuayThaiProfile {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
userId String @unique
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
jumpRopeBestTime Int? // en secondes
|
||||||
|
burpee100BestTime Int? // en secondes
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
sessions TrainingSession[]
|
||||||
|
}
|
||||||
|
|
||||||
|
model TrainingSession {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
profileId String
|
||||||
|
profile MuayThaiProfile @relation(fields: [profileId], references: [id], onDelete: Cascade)
|
||||||
|
date DateTime @default(now())
|
||||||
|
jumpRopeTime Int? // en secondes
|
||||||
|
burpee100Time Int? // en secondes
|
||||||
|
notes String?
|
||||||
|
}
|
||||||
|
|
||||||
|
model YogaProfile {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
userId String @unique
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
sessionsRemaining Int @default(1)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
attendances YogaAttendance[]
|
||||||
|
}
|
||||||
|
|
||||||
|
model YogaAttendance {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
profileId String
|
||||||
|
profile YogaProfile @relation(fields: [profileId], references: [id], onDelete: Cascade)
|
||||||
|
date DateTime @default(now())
|
||||||
|
pricePaid Float?
|
||||||
|
notes String?
|
||||||
|
}
|
||||||
|
|
||||||
|
model YogaTier {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
name String // ex: "Séance unique", "Pack 5", "Pack 10"
|
||||||
|
numberOfSessions Int
|
||||||
|
priceTotal Float
|
||||||
|
isActive Boolean @default(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
model Media {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
title String
|
||||||
|
description String?
|
||||||
|
type MediaType @default(PHOTO)
|
||||||
|
url String
|
||||||
|
thumbnail String?
|
||||||
|
visibility MediaVisibility @default(MEMBERS_ONLY)
|
||||||
|
uploadedById String?
|
||||||
|
uploadedBy User? @relation(fields: [uploadedById], references: [id], onDelete: SetNull)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
}
|
||||||
|
|
||||||
|
model Message {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
title String
|
||||||
|
content String
|
||||||
|
category String? // muay-thai | yoga | general
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
readBy MessageRead[]
|
||||||
|
}
|
||||||
|
|
||||||
|
model MessageRead {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
messageId String
|
||||||
|
message Message @relation(fields: [messageId], references: [id], onDelete: Cascade)
|
||||||
|
userId String
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
readAt DateTime @default(now())
|
||||||
|
|
||||||
|
@@unique([messageId, userId])
|
||||||
|
}
|
||||||
|
|
||||||
|
model Homework {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
title String
|
||||||
|
description String
|
||||||
|
videoUrl String?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
userId String?
|
||||||
|
user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
|
||||||
|
}
|
||||||
|
|
||||||
|
model Certificate {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
userId String
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
fileUrl String
|
||||||
|
status CertificateStatus @default(PENDING)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
reviewedAt DateTime?
|
||||||
|
}
|
||||||
|
|
||||||
|
model Absence {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
userId String
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
date DateTime
|
||||||
|
reason String?
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import { PrismaClient } from "@prisma/client";
|
||||||
|
import bcrypt from "bcryptjs";
|
||||||
|
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const adminEmail = process.env.ADMIN_EMAIL || "pierre@secourit.fr";
|
||||||
|
const adminPassword = process.env.ADMIN_PASSWORD || "JuanitoAdmin2026!";
|
||||||
|
|
||||||
|
const existing = await prisma.user.findUnique({
|
||||||
|
where: { email: adminEmail },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!existing) {
|
||||||
|
const hashed = await bcrypt.hash(adminPassword, 10);
|
||||||
|
await prisma.user.create({
|
||||||
|
data: {
|
||||||
|
email: adminEmail,
|
||||||
|
name: "Juanito",
|
||||||
|
password: hashed,
|
||||||
|
role: "ADMIN",
|
||||||
|
muayThai: { create: {} },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
console.log(`✅ Admin créé: ${adminEmail}`);
|
||||||
|
} else {
|
||||||
|
console.log(`Admin existe déjà: ${adminEmail}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Créer les tiers Yoga par défaut
|
||||||
|
const tiers = [
|
||||||
|
{ name: "Séance unique", numberOfSessions: 1, priceTotal: 9 },
|
||||||
|
{ name: "Pack 5 séances", numberOfSessions: 5, priceTotal: 40 },
|
||||||
|
{ name: "Pack 10 séances", numberOfSessions: 10, priceTotal: 70 },
|
||||||
|
];
|
||||||
|
for (const t of tiers) {
|
||||||
|
const exists = await prisma.yogaTier.findFirst({
|
||||||
|
where: { name: t.name },
|
||||||
|
});
|
||||||
|
if (!exists) {
|
||||||
|
await prisma.yogaTier.create({ data: t });
|
||||||
|
console.log(`✅ Tier yoga créé: ${t.name}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("Seed terminé ✅");
|
||||||
|
}
|
||||||
|
|
||||||
|
main()
|
||||||
|
.catch((e) => {
|
||||||
|
console.error(e);
|
||||||
|
process.exit(1);
|
||||||
|
})
|
||||||
|
.finally(async () => {
|
||||||
|
await prisma.$disconnect();
|
||||||
|
});
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 32 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 306 KiB |
@@ -0,0 +1,48 @@
|
|||||||
|
{
|
||||||
|
"name": "JuanitoSport - Muay Thai & Yoga",
|
||||||
|
"short_name": "JuanitoSport",
|
||||||
|
"description": "Muay Thai & Yog4Fight à Bazainville",
|
||||||
|
"start_url": "/",
|
||||||
|
"display": "standalone",
|
||||||
|
"background_color": "#0f0f0f",
|
||||||
|
"theme_color": "#0f0f0f",
|
||||||
|
"orientation": "portrait",
|
||||||
|
"icons": [
|
||||||
|
{
|
||||||
|
"src": "/icons/icon-72x72.png",
|
||||||
|
"sizes": "72x72",
|
||||||
|
"type": "image/png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/icons/icon-96x96.png",
|
||||||
|
"sizes": "96x96",
|
||||||
|
"type": "image/png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/icons/icon-128x128.png",
|
||||||
|
"sizes": "128x128",
|
||||||
|
"type": "image/png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/icons/icon-192x192.png",
|
||||||
|
"sizes": "192x192",
|
||||||
|
"type": "image/png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/icons/icon-256x256.png",
|
||||||
|
"sizes": "256x256",
|
||||||
|
"type": "image/png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/icons/icon-384x384.png",
|
||||||
|
"sizes": "384x384",
|
||||||
|
"type": "image/png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/icons/icon-512x512.png",
|
||||||
|
"sizes": "512x512",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "any maskable"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import { mkdir } from "fs/promises";
|
||||||
|
import path from "path";
|
||||||
|
import sharp from "sharp";
|
||||||
|
|
||||||
|
const SOURCE = path.join(process.cwd(), "public", "images", "logo-muaythai.jpg");
|
||||||
|
const OUT_DIR = path.join(process.cwd(), "public", "icons");
|
||||||
|
|
||||||
|
const sizes = [72, 96, 128, 192, 256, 384, 512];
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
await mkdir(OUT_DIR, { recursive: true });
|
||||||
|
|
||||||
|
const source = sharp(SOURCE).resize(512, 512, {
|
||||||
|
fit: "cover",
|
||||||
|
position: "centre",
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const size of sizes) {
|
||||||
|
await source
|
||||||
|
.clone()
|
||||||
|
.resize(size, size)
|
||||||
|
.png()
|
||||||
|
.toFile(path.join(OUT_DIR, `icon-${size}x${size}.png`));
|
||||||
|
console.log(`✓ icon-${size}x${size}.png`);
|
||||||
|
}
|
||||||
|
|
||||||
|
await source
|
||||||
|
.clone()
|
||||||
|
.resize(180, 180)
|
||||||
|
.png()
|
||||||
|
.toFile(path.join(OUT_DIR, "apple-touch-icon.png"));
|
||||||
|
console.log(`✓ apple-touch-icon.png`);
|
||||||
|
|
||||||
|
await source.clone().resize(32, 32).png().toFile(path.join(OUT_DIR, "favicon-32.png"));
|
||||||
|
console.log(`✓ favicon-32.png`);
|
||||||
|
|
||||||
|
await source.clone().resize(16, 16).png().toFile(path.join(OUT_DIR, "favicon-16.png"));
|
||||||
|
console.log(`✓ favicon-16.png`);
|
||||||
|
|
||||||
|
console.log("Icônes PWA générées ✅");
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((e) => {
|
||||||
|
console.error(e);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import type { Config } from "tailwindcss";
|
||||||
|
|
||||||
|
const config: Config = {
|
||||||
|
content: [
|
||||||
|
"./pages/**/*.{js,ts,jsx,tsx,mdx}",
|
||||||
|
"./components/**/*.{js,ts,jsx,tsx,mdx}",
|
||||||
|
"./app/**/*.{js,ts,jsx,tsx,mdx}",
|
||||||
|
],
|
||||||
|
theme: {
|
||||||
|
extend: {
|
||||||
|
colors: {
|
||||||
|
primary: {
|
||||||
|
50: "#fff1f2",
|
||||||
|
100: "#ffe4e6",
|
||||||
|
200: "#fecdd3",
|
||||||
|
300: "#fda4af",
|
||||||
|
400: "#fb7185",
|
||||||
|
500: "#f43f5e",
|
||||||
|
600: "#e11d48",
|
||||||
|
700: "#be123c",
|
||||||
|
800: "#9f1239",
|
||||||
|
900: "#881337",
|
||||||
|
},
|
||||||
|
dark: {
|
||||||
|
900: "#0f0f0f",
|
||||||
|
800: "#1a1a1a",
|
||||||
|
700: "#242424",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
fontFamily: {
|
||||||
|
sans: ["Inter", "system-ui", "sans-serif"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
plugins: [],
|
||||||
|
};
|
||||||
|
export default config;
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"lib": ["dom", "dom.iterable", "esnext"],
|
||||||
|
"allowJs": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"strict": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"module": "esnext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"jsx": "preserve",
|
||||||
|
"incremental": true,
|
||||||
|
"plugins": [
|
||||||
|
{
|
||||||
|
"name": "next"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./*"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
||||||
|
"exclude": ["node_modules"]
|
||||||
|
}
|
||||||
Vendored
+30
@@ -0,0 +1,30 @@
|
|||||||
|
import { NextAuthOptions } from "next-auth";
|
||||||
|
|
||||||
|
declare module "next-auth" {
|
||||||
|
interface Session {
|
||||||
|
user: {
|
||||||
|
id: string;
|
||||||
|
email: string;
|
||||||
|
name: string;
|
||||||
|
role: string;
|
||||||
|
isMuayThai: boolean;
|
||||||
|
isYoga: boolean;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface User {
|
||||||
|
id: string;
|
||||||
|
role: string;
|
||||||
|
isMuayThai: boolean;
|
||||||
|
isYoga: boolean;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
declare module "next-auth/jwt" {
|
||||||
|
interface JWT {
|
||||||
|
id?: string;
|
||||||
|
role?: string;
|
||||||
|
isMuayThai?: boolean;
|
||||||
|
isYoga?: boolean;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user