Add slots based per class, admin courses mgmt, admin roles, horizontal nav/landing, glove favicon
This commit is contained in:
@@ -5,13 +5,14 @@ import { usePathname } from "next/navigation";
|
||||
import Header from "@/components/Header";
|
||||
|
||||
const adminLinks = [
|
||||
{ href: "/admin", label: "Vue d'ensemble", icon: "🏠" },
|
||||
{ href: "/admin", label: "Accueil", icon: "🏠" },
|
||||
{ href: "/admin/users", label: "Membres", icon: "👥" },
|
||||
{ href: "/admin/messages", label: "Messages", icon: "💬" },
|
||||
{ href: "/admin/media", label: "Photos & vidéos", icon: "🎬" },
|
||||
{ href: "/admin/media", label: "Médias", icon: "🎬" },
|
||||
{ href: "/admin/classes", label: "Cours", icon: "📅" },
|
||||
{ href: "/admin/homework", label: "Exercices", icon: "🏋️" },
|
||||
{ href: "/admin/certificates", label: "Certificats", icon: "📄" },
|
||||
{ href: "/admin/yoga", label: "Gestion Yoga", icon: "🧘" },
|
||||
{ href: "/admin/yoga", label: "Yoga", icon: "🧘" },
|
||||
];
|
||||
|
||||
export default function AdminLayout({
|
||||
@@ -32,7 +33,7 @@ export default function AdminLayout({
|
||||
Gère ton club JuanitoSport
|
||||
</p>
|
||||
|
||||
<nav className="mb-6 flex gap-2 overflow-x-auto pb-2 -mx-4 px-4">
|
||||
<nav className="mb-6 grid grid-cols-4 gap-1.5 sm:gap-2">
|
||||
{adminLinks.map((link) => {
|
||||
const active =
|
||||
pathname === link.href ||
|
||||
@@ -41,14 +42,14 @@ export default function AdminLayout({
|
||||
<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 ${
|
||||
className={`flex flex-col items-center justify-center gap-0.5 rounded-xl px-1 py-2.5 text-center text-[11px] font-semibold transition-colors sm:flex-row sm:gap-1.5 sm:px-3 sm:py-2 sm:text-xs ${
|
||||
active
|
||||
? "bg-primary-600 text-white"
|
||||
: "bg-white/5 text-white/70 hover:bg-white/10"
|
||||
}`}
|
||||
>
|
||||
<span>{link.icon}</span>
|
||||
<span>{link.label}</span>
|
||||
<span className="text-base leading-none sm:text-lg">{link.icon}</span>
|
||||
<span className="leading-tight">{link.label}</span>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
const SPORT_LABEL: Record<string, { icon: string; name: string }> = {
|
||||
"muay-thai": { icon: "🥊", name: "Muay Thai" },
|
||||
yoga: { icon: "🧘", name: "Yoga" },
|
||||
};
|
||||
|
||||
export default function AdminClasses({ slots }: { slots: any[] }) {
|
||||
const router = useRouter();
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<h2 className="text-lg font-bold">📅 Cours & places disponibles</h2>
|
||||
<p className="text-sm text-white/60">
|
||||
Renseigne le nombre de places pour chaque cours. Quand les places sont
|
||||
épuisées, le cours s'affiche « COMPLET » sur le site.
|
||||
</p>
|
||||
|
||||
<div className="space-y-4">
|
||||
{slots.map((slot) => (
|
||||
<SlotCard key={slot.id} slot={slot} onUpdate={() => router.refresh()} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SlotCard({ slot, onUpdate }: { slot: any; onUpdate: () => void }) {
|
||||
const sport = SPORT_LABEL[slot.sport] || { icon: "🏅", name: slot.sport };
|
||||
const [capacity, setCapacity] = useState(String(slot.capacity));
|
||||
const [booked, setBooked] = useState(String(slot.booked));
|
||||
const [isFull, setIsFull] = useState(slot.isFull);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const capacityNum = Math.max(0, parseInt(capacity) || 0);
|
||||
const bookedNum = Math.max(0, parseInt(booked) || 0);
|
||||
const remaining = Math.max(0, capacityNum - bookedNum);
|
||||
const full = isFull || remaining === 0;
|
||||
|
||||
async function save() {
|
||||
setSaving(true);
|
||||
const res = await fetch("/api/admin/classes", {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ id: slot.id, capacity, booked, isFull }),
|
||||
});
|
||||
setSaving(false);
|
||||
if (res.ok) onUpdate();
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="card">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-2xl">{sport.icon}</span>
|
||||
<div>
|
||||
<p className="font-bold">
|
||||
{sport.name} — {slot.label}
|
||||
</p>
|
||||
<p className="text-xs text-white/50">{slot.time}</p>
|
||||
</div>
|
||||
</div>
|
||||
<span
|
||||
className={`rounded-full px-3 py-1 text-xs font-bold ${
|
||||
full
|
||||
? "bg-red-500/20 text-red-300"
|
||||
: remaining <= 3
|
||||
? "bg-amber-500/20 text-amber-300"
|
||||
: "bg-emerald-500/20 text-emerald-300"
|
||||
}`}
|
||||
>
|
||||
{full ? "❌ COMPLET" : `${remaining} places libres`}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="label">Capacité totale</label>
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
className="input"
|
||||
value={capacity}
|
||||
onChange={(e) => setCapacity(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">Places occupées</label>
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
className="input"
|
||||
value={booked}
|
||||
onChange={(e) => setBooked(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label className="mt-3 flex cursor-pointer items-center gap-2 rounded-xl bg-white/5 px-4 py-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isFull}
|
||||
onChange={(e) => setIsFull(e.target.checked)}
|
||||
className="h-4 w-4 accent-red-600"
|
||||
/>
|
||||
<span className="font-semibold text-red-300">Marquer ce cours comme COMPLET</span>
|
||||
</label>
|
||||
|
||||
<button onClick={save} className="btn-primary mt-3 w-full" disabled={saving}>
|
||||
{saving ? "Enregistrement..." : "💾 Enregistrer"}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import AdminClasses from "./classes-admin";
|
||||
|
||||
export default async function ClassesPage() {
|
||||
const slots = await prisma.classSlot.findMany({
|
||||
orderBy: { label: "asc" },
|
||||
});
|
||||
|
||||
return <AdminClasses slots={slots} />;
|
||||
}
|
||||
+125
-30
@@ -28,7 +28,7 @@ export default function AdminUsers({ users }: { users: any[] }) {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{(
|
||||
[
|
||||
["ALL", "Tous"],
|
||||
@@ -70,6 +70,7 @@ function CreateUserForm({ onDone }: { onDone: () => void }) {
|
||||
const [name, setName] = useState("");
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [role, setRole] = useState("MEMBER");
|
||||
const [isMuayThai, setIsMuayThai] = useState(true);
|
||||
const [isYoga, setIsYoga] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
@@ -82,7 +83,14 @@ function CreateUserForm({ onDone }: { onDone: () => void }) {
|
||||
const res = await fetch("/api/admin/users", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ name, email, password, isMuayThai, isYoga }),
|
||||
body: JSON.stringify({
|
||||
name,
|
||||
email,
|
||||
password,
|
||||
role,
|
||||
isMuayThai: role === "MEMBER" ? isMuayThai : false,
|
||||
isYoga: role === "MEMBER" ? isYoga : false,
|
||||
}),
|
||||
});
|
||||
const data = await res.json();
|
||||
setSaving(false);
|
||||
@@ -95,7 +103,7 @@ function CreateUserForm({ onDone }: { onDone: () => void }) {
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="card space-y-4 border-primary-500/30">
|
||||
<h3 className="font-semibold">✨ Créer un compte membre</h3>
|
||||
<h3 className="font-semibold">✨ Créer un compte</h3>
|
||||
<div>
|
||||
<label className="label">Nom complet</label>
|
||||
<input className="input" value={name} onChange={(e) => setName(e.target.value)} placeholder="Jean Dupont" required />
|
||||
@@ -108,10 +116,39 @@ function CreateUserForm({ onDone }: { onDone: () => void }) {
|
||||
<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>
|
||||
<label className="label">Rôle</label>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setRole("MEMBER")}
|
||||
className={`flex-1 rounded-xl px-3 py-2 text-sm font-semibold ${
|
||||
role === "MEMBER"
|
||||
? "bg-primary-600 text-white"
|
||||
: "bg-white/5 text-white/60"
|
||||
}`}
|
||||
>
|
||||
👤 Membre
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setRole("ADMIN")}
|
||||
className={`flex-1 rounded-xl px-3 py-2 text-sm font-semibold ${
|
||||
role === "ADMIN"
|
||||
? "bg-amber-600 text-white"
|
||||
: "bg-white/5 text-white/60"
|
||||
}`}
|
||||
>
|
||||
⭐ Admin
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{role === "MEMBER" && (
|
||||
<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"}
|
||||
@@ -133,19 +170,27 @@ 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 [editRole, setEditRole] = useState(user.role);
|
||||
const [error, setError] = useState("");
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const certStatus = user.certificates?.find((c: any) => c.status !== "REJECTED")?.status;
|
||||
|
||||
async function updateUser(data: any) {
|
||||
setSaving(true);
|
||||
setError("");
|
||||
const res = await fetch(`/api/admin/users/${user.id}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
setSaving(false);
|
||||
if (res.ok) {
|
||||
setEditPassword("");
|
||||
onUpdate();
|
||||
setExpanded(false);
|
||||
} else {
|
||||
const d = await res.json().catch(() => ({}));
|
||||
setError(d.error || "Erreur lors de la mise à jour");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -159,17 +204,31 @@ function MemberCard({ user, onUpdate }: { user: any; onUpdate: () => void }) {
|
||||
if (res.ok) onUpdate();
|
||||
}
|
||||
|
||||
const isAdmin = user.role === "ADMIN";
|
||||
const canDelete = !isAdmin;
|
||||
|
||||
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">
|
||||
<div
|
||||
className={`flex h-12 w-12 items-center justify-center rounded-full text-lg font-bold ${
|
||||
isAdmin
|
||||
? "bg-amber-500/20 text-amber-300"
|
||||
: "bg-primary-600/20 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">
|
||||
<div className="mt-1 flex flex-wrap gap-1.5">
|
||||
{isAdmin && (
|
||||
<span className="rounded-full bg-amber-500/20 px-2 py-0.5 text-xs text-amber-300">
|
||||
⭐ Admin
|
||||
</span>
|
||||
)}
|
||||
{user.isMuayThai && (
|
||||
<span className="rounded-full bg-primary-600/20 px-2 py-0.5 text-xs text-primary-300">
|
||||
🥊 Muay Thai
|
||||
@@ -180,11 +239,6 @@ function MemberCard({ user, onUpdate }: { user: any; onUpdate: () => void }) {
|
||||
🧘 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>
|
||||
@@ -212,33 +266,74 @@ function MemberCard({ user, onUpdate }: { user: any; onUpdate: () => void }) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{expanded && user.role !== "ADMIN" && (
|
||||
{expanded && (
|
||||
<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" />
|
||||
<h4 className="text-sm font-semibold">
|
||||
⚙️ Configuration — {isAdmin ? "compte admin" : "compte membre"}
|
||||
</h4>
|
||||
|
||||
<div>
|
||||
<label className="label">Nom</label>
|
||||
<input className="input" value={editName} onChange={(e) => setEditName(e.target.value)} />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="label">Rôle</label>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setEditRole("MEMBER")}
|
||||
className={`flex-1 rounded-xl px-3 py-2 text-sm font-semibold ${
|
||||
editRole === "MEMBER"
|
||||
? "bg-primary-600 text-white"
|
||||
: "bg-white/5 text-white/60"
|
||||
}`}
|
||||
>
|
||||
👤 Membre
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setEditRole("ADMIN")}
|
||||
className={`flex-1 rounded-xl px-3 py-2 text-sm font-semibold ${
|
||||
editRole === "ADMIN"
|
||||
? "bg-amber-600 text-white"
|
||||
: "bg-white/5 text-white/60"
|
||||
}`}
|
||||
>
|
||||
⭐ Admin
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="label">Réinitialiser le mot de passe</label>
|
||||
<input
|
||||
className="input"
|
||||
value={editPassword}
|
||||
onChange={(e) => setEditPassword(e.target.value)}
|
||||
placeholder="Nouveau mot de passe (laisser vide pour ne pas changer)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && <p className="text-sm text-red-400">{error}</p>}
|
||||
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => {
|
||||
const data: any = { name: editName };
|
||||
const data: any = { name: editName, role: editRole };
|
||||
if (editPassword) data.password = editPassword;
|
||||
updateUser(data);
|
||||
}}
|
||||
className="btn-primary flex-1 !py-2 text-sm"
|
||||
disabled={saving}
|
||||
>
|
||||
💾 Enregistrer
|
||||
</button>
|
||||
<button onClick={deleteUser} className="btn-danger">
|
||||
🗑️
|
||||
{saving ? "Enregistrement..." : "💾 Enregistrer"}
|
||||
</button>
|
||||
{canDelete && (
|
||||
<button onClick={deleteUser} className="btn-danger" title="Supprimer">
|
||||
🗑️
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
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, capacity, booked, isFull } = await req.json();
|
||||
if (!id) return NextResponse.json({ error: "id requis" }, { status: 400 });
|
||||
|
||||
try {
|
||||
const slot = await prisma.classSlot.update({
|
||||
where: { id },
|
||||
data: {
|
||||
...(capacity !== undefined ? { capacity: Math.max(0, Number(capacity)) } : {}),
|
||||
...(booked !== undefined ? { booked: Math.max(0, Number(booked)) } : {}),
|
||||
...(isFull !== undefined ? { isFull: !!isFull } : {}),
|
||||
},
|
||||
});
|
||||
return NextResponse.json(slot);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
return NextResponse.json({ error: "Erreur" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { requireAdmin } from "@/lib/session";
|
||||
import bcrypt from "bcryptjs";
|
||||
|
||||
export async function PATCH(req: NextRequest) {
|
||||
const admin = await requireAdmin();
|
||||
@@ -10,7 +11,7 @@ export async function PATCH(req: NextRequest) {
|
||||
if (!id) return NextResponse.json({ error: "id requis" }, { status: 400 });
|
||||
|
||||
const allowed = [
|
||||
"name", "email", "isMuayThai", "isYoga", "role", "phone", "avatar"
|
||||
"name", "email", "isMuayThai", "isYoga", "role", "phone", "avatar", "password"
|
||||
];
|
||||
|
||||
const updateData: any = {};
|
||||
@@ -20,6 +21,16 @@ export async function PATCH(req: NextRequest) {
|
||||
|
||||
if (updateData.email) updateData.email = updateData.email.toLowerCase();
|
||||
|
||||
if (updateData.password) {
|
||||
if (updateData.password.length < 6) {
|
||||
return NextResponse.json(
|
||||
{ error: "Le mot de passe doit contenir au moins 6 caractères" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
updateData.password = await bcrypt.hash(updateData.password, 10);
|
||||
}
|
||||
|
||||
try {
|
||||
if (updateData.isMuayThai === true) {
|
||||
await prisma.muayThaiProfile.upsert({
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const slots = await prisma.classSlot.findMany({
|
||||
orderBy: { label: "asc" },
|
||||
});
|
||||
return NextResponse.json(slots);
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Erreur" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,11 @@ export const metadata: Metadata = {
|
||||
description:
|
||||
"JuanitoSport - Muay Thai & Yog4Fight à Bazainville. Cours d'essai gratuit, ambiance familiale.",
|
||||
manifest: "/manifest.json",
|
||||
icons: {
|
||||
icon: "/favicon.svg",
|
||||
shortcut: "/favicon.svg",
|
||||
apple: "/icons/apple-touch-icon.png",
|
||||
},
|
||||
appleWebApp: {
|
||||
capable: true,
|
||||
statusBarStyle: "black-translucent",
|
||||
|
||||
+5
-17
@@ -2,6 +2,7 @@
|
||||
|
||||
import Header from "@/components/Header";
|
||||
import Link from "next/link";
|
||||
import Availability from "@/components/Availability";
|
||||
|
||||
const EQUIPMENT = [
|
||||
{ icon: "🪢", name: "Corde à sauter" },
|
||||
@@ -42,27 +43,14 @@ export default function MuayThaiPage() {
|
||||
|
||||
<section className="card mb-6">
|
||||
<h2 className="mb-3 text-xl font-bold">📅 Horaires</h2>
|
||||
<p className="mb-2 text-sm text-white/60">
|
||||
<p className="mb-3 text-sm text-white/60">
|
||||
Cours de Muay Thai à{" "}
|
||||
<strong className="text-white">BAZAINVILLE</strong> (salle
|
||||
"l'atelier")
|
||||
</p>
|
||||
<div className="mb-4 overflow-hidden rounded-xl border border-white/10">
|
||||
<div className="flex items-center justify-between bg-white/5 px-4 py-3">
|
||||
<span className="font-semibold">Mardi</span>
|
||||
<span className="tabular-nums text-primary-300">
|
||||
19h00 – 21h00
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between border-t border-white/10 px-4 py-3">
|
||||
<span className="font-semibold">Vendredi</span>
|
||||
<span className="tabular-nums text-primary-300">
|
||||
20h00 – 22h00
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-sm text-white/50">
|
||||
À partir du 12 septembre
|
||||
<Availability sport="muay-thai" />
|
||||
<p className="mt-3 text-sm text-white/50">
|
||||
🏁 Reprise des cours ce <strong className="text-primary-300">mardi 8 septembre</strong>
|
||||
</p>
|
||||
</section>
|
||||
|
||||
|
||||
+9
-9
@@ -29,41 +29,41 @@ export default function LandingPage() {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex w-full max-w-sm flex-col gap-4">
|
||||
<div className="grid w-full max-w-2xl grid-cols-2 gap-3 sm: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"
|
||||
className="group flex flex-col items-center justify-center rounded-2xl border border-primary-600/40 bg-gradient-to-br from-primary-800/40 to-dark-800 px-3 py-5 text-center shadow-xl transition-all hover:scale-[1.02] hover:border-primary-500 active:scale-95 sm:rounded-3xl sm:px-6 sm:py-8"
|
||||
>
|
||||
<span className="mb-3 h-20 w-20 overflow-hidden rounded-full border-2 border-primary-500">
|
||||
<span className="mb-2 h-14 w-14 overflow-hidden rounded-full border-2 border-primary-500 sm:mb-3 sm:h-20 sm:w-20">
|
||||
<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">
|
||||
<span className="text-base font-bold leading-tight text-primary-300 sm:text-2xl">
|
||||
Juanito Muay Thai
|
||||
</span>
|
||||
<span className="mt-2 text-sm text-white/60">
|
||||
<span className="mt-1.5 text-xs text-white/60 sm:text-sm">
|
||||
L'art martial du Siam
|
||||
</span>
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
href="/yog4fight"
|
||||
className="group flex flex-col items-center rounded-3xl border border-emerald-500/40 bg-gradient-to-br from-emerald-800/40 to-dark-800 p-8 text-center shadow-xl transition-all hover:scale-[1.02] hover:border-emerald-400 active:scale-95"
|
||||
className="group flex flex-col items-center justify-center rounded-2xl border border-emerald-500/40 bg-gradient-to-br from-emerald-800/40 to-dark-800 px-3 py-5 text-center shadow-xl transition-all hover:scale-[1.02] hover:border-emerald-400 active:scale-95 sm:rounded-3xl sm:px-6 sm:py-8"
|
||||
>
|
||||
<span className="mb-3 h-20 w-20 overflow-hidden rounded-full border-2 border-emerald-500">
|
||||
<span className="mb-2 h-14 w-14 overflow-hidden rounded-full border-2 border-emerald-500 sm:mb-3 sm:h-20 sm:w-20">
|
||||
<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">
|
||||
<span className="text-base font-bold leading-tight text-emerald-300 sm:text-2xl">
|
||||
Juanito Yog 4 Fight
|
||||
</span>
|
||||
<span className="mt-2 text-sm text-white/60">
|
||||
<span className="mt-1.5 text-xs text-white/60 sm:text-sm">
|
||||
#mobilité égale performance
|
||||
</span>
|
||||
</Link>
|
||||
|
||||
+4
-10
@@ -2,6 +2,7 @@
|
||||
|
||||
import Header from "@/components/Header";
|
||||
import Link from "next/link";
|
||||
import Availability from "@/components/Availability";
|
||||
|
||||
const BENEFITS = [
|
||||
{ icon: "🤸", title: "Mobilité", desc: "Améliorer votre flexibilité articulaire" },
|
||||
@@ -60,15 +61,8 @@ export default function Yog4FightPage() {
|
||||
|
||||
<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">
|
||||
<Availability sport="yoga" />
|
||||
<p className="mt-3 text-sm text-white/50">
|
||||
Dans la salle « L'atelier » à Bazainville
|
||||
</p>
|
||||
</section>
|
||||
@@ -100,7 +94,7 @@ export default function Yog4FightPage() {
|
||||
<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>
|
||||
✉️ <a href="mailto:juanitomuaythai@gmail.com" className="text-emerald-300">juanitomuaythai@gmail.com</a>
|
||||
</p>
|
||||
<a
|
||||
href="https://www.instagram.com/yog4fight/"
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
type Slot = {
|
||||
id: string;
|
||||
slug: string;
|
||||
label: string;
|
||||
sport: string;
|
||||
time: string;
|
||||
capacity: number;
|
||||
booked: number;
|
||||
isFull: boolean;
|
||||
};
|
||||
|
||||
export default function Availability({
|
||||
sport,
|
||||
}: {
|
||||
sport: "muay-thai" | "yoga";
|
||||
}) {
|
||||
const [slots, setSlots] = useState<Slot[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
fetch("/api/classes")
|
||||
.then((r) => r.json())
|
||||
.then((d) =>
|
||||
setSlots(
|
||||
(Array.isArray(d) ? d : []).filter((s: Slot) => s.sport === sport)
|
||||
)
|
||||
)
|
||||
.catch(() => {});
|
||||
}, [sport]);
|
||||
|
||||
if (slots.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{slots.map((slot) => {
|
||||
const remaining = Math.max(0, slot.capacity - slot.booked);
|
||||
const isFull = slot.isFull || remaining === 0;
|
||||
return (
|
||||
<div
|
||||
key={slot.id}
|
||||
className="flex items-center justify-between rounded-xl border border-white/10 bg-white/5 px-4 py-3"
|
||||
>
|
||||
<div>
|
||||
<p className="font-semibold">{slot.label}</p>
|
||||
<p className="text-xs text-white/50">{slot.time}</p>
|
||||
</div>
|
||||
{isFull ? (
|
||||
<span className="rounded-full bg-red-500/20 px-3 py-1 text-xs font-bold text-red-300">
|
||||
❌ COMPLET
|
||||
</span>
|
||||
) : remaining <= 3 ? (
|
||||
<span className="rounded-full bg-amber-500/20 px-3 py-1 text-xs font-bold text-amber-300">
|
||||
⚡ Plus que {remaining}{" "}
|
||||
place{remaining > 1 ? "s" : ""} !
|
||||
</span>
|
||||
) : (
|
||||
<span className="rounded-full bg-emerald-500/20 px-3 py-1 text-xs font-bold text-emerald-300">
|
||||
{remaining} places libres
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -162,3 +162,15 @@ model Absence {
|
||||
date DateTime
|
||||
reason String?
|
||||
}
|
||||
|
||||
model ClassSlot {
|
||||
id String @id @default(cuid())
|
||||
slug String @unique
|
||||
label String
|
||||
sport String
|
||||
time String
|
||||
capacity Int @default(20)
|
||||
booked Int @default(0)
|
||||
isFull Boolean @default(false)
|
||||
updatedAt DateTime @updatedAt
|
||||
}
|
||||
|
||||
@@ -43,6 +43,20 @@ async function main() {
|
||||
}
|
||||
}
|
||||
|
||||
// Créer les créneaux de cours par défaut
|
||||
const slots = [
|
||||
{ slug: "muay-mardi", label: "Mardi", sport: "muay-thai", time: "19h00 – 21h00", capacity: 20, booked: 16 },
|
||||
{ slug: "muay-vendredi", label: "Vendredi", sport: "muay-thai", time: "20h00 – 22h00", capacity: 20, booked: 6 },
|
||||
{ slug: "yoga-lundi", label: "Lundi", sport: "yoga", time: "19h30 – 20h30", capacity: 15, booked: 5 },
|
||||
];
|
||||
for (const s of slots) {
|
||||
const exists = await prisma.classSlot.findUnique({ where: { slug: s.slug } });
|
||||
if (!exists) {
|
||||
await prisma.classSlot.create({ data: s });
|
||||
console.log(`✅ Créneau créé: ${s.slug}`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log("Seed terminé ✅");
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
|
||||
<rect width="100" height="100" rx="22" fill="#0f0f0f"/>
|
||||
<g>
|
||||
<ellipse cx="48" cy="50" rx="25" ry="26" fill="#e11d48"/>
|
||||
<path d="M52 24 a8 8 0 0 1 8 -8 c4 0 7 3 7 7 -2 -1 -4 -1 -6 1 3 -1 5 0 5 3 0 2 -2 4 -5 5 l7 6 c3 3 1 8 -3 10 -4 2 -9 0 -12 -3 -1 3 -3 5 -7 6 -3 1 -7 0 -8 -2 z" fill="#be123c" opacity="0.85"/>
|
||||
<rect x="62" y="52" width="20" height="30" rx="8" fill="#f43f5e" transform="rotate(-15 72 67)"/>
|
||||
<rect x="62" y="64" width="20" height="7" rx="3" fill="#9f1239" transform="rotate(-15 72 67)"/>
|
||||
<ellipse cx="40" cy="38" rx="6" ry="10" fill="#fb7185" opacity="0.55" transform="rotate(-25 40 38)"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 705 B |
Reference in New Issue
Block a user