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
@@ -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}
/>
);
}