Initial commit: JuanitoSport - Muay Thai & Yog4Fight PWA

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

+48
View File
@@ -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"
}
]
}
+46
View File
@@ -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);
});
+37
View File
@@ -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;
+26
View File
@@ -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"]
}
+30
View File
@@ -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;
}
}