feat: secure admin panel with Supabase auth + course management CRUD
- Replace ADMIN_SECRET query param with proper Supabase auth + is_admin flag - Add admin layout with auth check (redirects non-admin to /) - Add AdminShell component with sidebar navigation (Dashboard, Candidatures, Cours) - Add admin dashboard with stats (candidatures, users, modules) - Add admin candidatures page with filters and approve/reject - Add admin course management page (create, edit, delete, publish/unpublish) - Add API routes: GET/POST /api/admin/modules, GET/PUT/DELETE /api/admin/modules/[id] - Add verifyAdmin() helper for API route protection - Update database types with is_admin on profiles https://claude.ai/code/session_01H2aRGDaKgarPvhay2HxN6Y
This commit is contained in:
322
app/admin/candidatures/page.tsx
Normal file
322
app/admin/candidatures/page.tsx
Normal file
@@ -0,0 +1,322 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
|
||||
interface Candidature {
|
||||
id: string;
|
||||
email: string;
|
||||
firstname: string;
|
||||
phone: string;
|
||||
persona: string;
|
||||
age: number;
|
||||
experience: string;
|
||||
time_daily: string;
|
||||
availability: string;
|
||||
start_date: string;
|
||||
motivation: string;
|
||||
monthly_goal: string;
|
||||
biggest_fear: string;
|
||||
tiktok_username: string | null;
|
||||
status: "pending" | "approved" | "rejected";
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export default function AdminCandidaturesPage() {
|
||||
const [candidatures, setCandidatures] = useState<Candidature[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [actionLoading, setActionLoading] = useState<string | null>(null);
|
||||
const [checkoutUrls, setCheckoutUrls] = useState<Record<string, string>>({});
|
||||
const [expandedId, setExpandedId] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [filter, setFilter] = useState<"all" | "pending" | "approved" | "rejected">("all");
|
||||
|
||||
const fetchCandidatures = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await fetch("/api/admin/candidatures");
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.error);
|
||||
setCandidatures(data.candidatures);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Erreur de chargement");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchCandidatures();
|
||||
}, [fetchCandidatures]);
|
||||
|
||||
const handleApprove = async (id: string) => {
|
||||
setActionLoading(id);
|
||||
try {
|
||||
const res = await fetch(`/api/admin/candidatures/${id}/approve`, {
|
||||
method: "POST",
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.error);
|
||||
|
||||
if (data.checkoutUrl) {
|
||||
setCheckoutUrls((prev) => ({ ...prev, [id]: data.checkoutUrl }));
|
||||
}
|
||||
|
||||
await fetchCandidatures();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Erreur");
|
||||
} finally {
|
||||
setActionLoading(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleReject = async (id: string) => {
|
||||
if (!confirm("Rejeter cette candidature ?")) return;
|
||||
setActionLoading(id);
|
||||
try {
|
||||
const res = await fetch(`/api/admin/candidatures/${id}/reject`, {
|
||||
method: "POST",
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.error);
|
||||
await fetchCandidatures();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Erreur");
|
||||
} finally {
|
||||
setActionLoading(null);
|
||||
}
|
||||
};
|
||||
|
||||
const statusColors: Record<string, string> = {
|
||||
pending: "bg-warning/10 text-warning",
|
||||
approved: "bg-success/10 text-success",
|
||||
rejected: "bg-error/10 text-error",
|
||||
};
|
||||
|
||||
const statusLabels: Record<string, string> = {
|
||||
pending: "En attente",
|
||||
approved: "Approuvée",
|
||||
rejected: "Rejetée",
|
||||
};
|
||||
|
||||
const filtered = filter === "all" ? candidatures : candidatures.filter((c) => c.status === filter);
|
||||
const pending = candidatures.filter((c) => c.status === "pending");
|
||||
const approved = candidatures.filter((c) => c.status === "approved");
|
||||
const rejected = candidatures.filter((c) => c.status === "rejected");
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-20">
|
||||
<div className="animate-spin w-8 h-8 border-2 border-primary border-t-transparent rounded-full" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-6xl">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-8">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-white">Candidatures</h1>
|
||||
<p className="text-white/40 text-sm mt-1">{candidatures.length} candidature(s) au total</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={fetchCandidatures}
|
||||
className="px-3 py-1.5 bg-dark-lighter border border-dark-border rounded-lg text-white/60 text-sm hover:text-white transition-colors cursor-pointer"
|
||||
>
|
||||
Rafraîchir
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Filtres */}
|
||||
<div className="flex gap-2 mb-6">
|
||||
{[
|
||||
{ key: "all" as const, label: "Toutes", count: candidatures.length },
|
||||
{ key: "pending" as const, label: "En attente", count: pending.length },
|
||||
{ key: "approved" as const, label: "Approuvées", count: approved.length },
|
||||
{ key: "rejected" as const, label: "Rejetées", count: rejected.length },
|
||||
].map((f) => (
|
||||
<button
|
||||
key={f.key}
|
||||
onClick={() => setFilter(f.key)}
|
||||
className={`px-3 py-1.5 rounded-lg text-sm font-medium transition-colors cursor-pointer ${
|
||||
filter === f.key
|
||||
? "bg-primary/10 text-primary"
|
||||
: "bg-dark-lighter text-white/40 hover:text-white"
|
||||
}`}
|
||||
>
|
||||
{f.label} ({f.count})
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="mb-6 p-3 bg-error/10 border border-error/20 rounded-xl">
|
||||
<p className="text-error text-sm">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Liste */}
|
||||
{filtered.length === 0 ? (
|
||||
<div className="text-center py-20">
|
||||
<p className="text-white/40">Aucune candidature dans cette catégorie.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{filtered.map((c) => (
|
||||
<div
|
||||
key={c.id}
|
||||
className="bg-dark-light border border-dark-border rounded-2xl overflow-hidden"
|
||||
>
|
||||
{/* Header row */}
|
||||
<div
|
||||
className="px-6 py-4 flex items-center justify-between cursor-pointer hover:bg-dark-lighter/50 transition-colors"
|
||||
onClick={() => setExpandedId(expandedId === c.id ? null : c.id)}
|
||||
>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-10 h-10 rounded-full gradient-bg flex items-center justify-center text-sm font-bold text-white">
|
||||
{c.firstname.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-white font-medium">{c.firstname}</p>
|
||||
<p className="text-white/40 text-sm">{c.email}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-white/30 text-xs">
|
||||
{new Date(c.created_at).toLocaleDateString("fr-FR", {
|
||||
day: "numeric",
|
||||
month: "short",
|
||||
year: "numeric",
|
||||
})}
|
||||
</span>
|
||||
<span className={`px-2.5 py-1 rounded-lg text-xs font-medium ${statusColors[c.status]}`}>
|
||||
{statusLabels[c.status]}
|
||||
</span>
|
||||
<svg
|
||||
className={`w-5 h-5 text-white/30 transition-transform ${expandedId === c.id ? "rotate-180" : ""}`}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Expanded details */}
|
||||
{expandedId === c.id && (
|
||||
<div className="px-6 pb-5 border-t border-dark-border">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 py-4">
|
||||
<div>
|
||||
<p className="text-white/40 text-xs mb-1">Téléphone</p>
|
||||
<p className="text-white text-sm">{c.phone}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-white/40 text-xs mb-1">Âge</p>
|
||||
<p className="text-white text-sm">{c.age} ans</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-white/40 text-xs mb-1">Profil</p>
|
||||
<p className="text-white text-sm capitalize">{c.persona}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-white/40 text-xs mb-1">Expérience</p>
|
||||
<p className="text-white text-sm">{c.experience}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-white/40 text-xs mb-1">Temps disponible</p>
|
||||
<p className="text-white text-sm">{c.time_daily}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-white/40 text-xs mb-1">Disponibilité</p>
|
||||
<p className="text-white text-sm">{c.availability}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-white/40 text-xs mb-1">Début souhaité</p>
|
||||
<p className="text-white text-sm">{c.start_date}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-white/40 text-xs mb-1">Objectif mensuel</p>
|
||||
<p className="text-white text-sm">{c.monthly_goal}</p>
|
||||
</div>
|
||||
{c.tiktok_username && (
|
||||
<div>
|
||||
<p className="text-white/40 text-xs mb-1">TikTok</p>
|
||||
<p className="text-white text-sm">{c.tiktok_username}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-3 py-3">
|
||||
<div>
|
||||
<p className="text-white/40 text-xs mb-1">Motivation</p>
|
||||
<p className="text-white/80 text-sm bg-dark-lighter rounded-xl p-3">{c.motivation}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-white/40 text-xs mb-1">Plus grande peur</p>
|
||||
<p className="text-white/80 text-sm bg-dark-lighter rounded-xl p-3">{c.biggest_fear}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Checkout URL */}
|
||||
{checkoutUrls[c.id] && (
|
||||
<div className="mt-3 p-3 bg-success/10 border border-success/20 rounded-xl">
|
||||
<p className="text-success text-xs font-medium mb-1">Lien de paiement généré :</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="text"
|
||||
readOnly
|
||||
value={checkoutUrls[c.id]}
|
||||
className="flex-1 bg-dark-lighter border border-dark-border rounded-lg px-3 py-2 text-white text-xs"
|
||||
/>
|
||||
<button
|
||||
onClick={() => navigator.clipboard.writeText(checkoutUrls[c.id])}
|
||||
className="px-3 py-2 bg-success/20 text-success rounded-lg text-xs font-medium hover:bg-success/30 transition-colors cursor-pointer"
|
||||
>
|
||||
Copier
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
{c.status === "pending" && (
|
||||
<div className="flex items-center gap-3 mt-4 pt-4 border-t border-dark-border">
|
||||
<button
|
||||
onClick={() => handleApprove(c.id)}
|
||||
disabled={actionLoading === c.id}
|
||||
className="px-4 py-2 bg-success/10 text-success border border-success/20 rounded-xl text-sm font-medium hover:bg-success/20 transition-colors disabled:opacity-50 cursor-pointer"
|
||||
>
|
||||
{actionLoading === c.id ? "Approbation..." : "Approuver + Envoyer lien paiement"}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleReject(c.id)}
|
||||
disabled={actionLoading === c.id}
|
||||
className="px-4 py-2 bg-error/10 text-error border border-error/20 rounded-xl text-sm font-medium hover:bg-error/20 transition-colors disabled:opacity-50 cursor-pointer"
|
||||
>
|
||||
Rejeter
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{c.status === "approved" && !checkoutUrls[c.id] && (
|
||||
<div className="flex items-center gap-3 mt-4 pt-4 border-t border-dark-border">
|
||||
<button
|
||||
onClick={() => handleApprove(c.id)}
|
||||
disabled={actionLoading === c.id}
|
||||
className="px-4 py-2 bg-primary/10 text-primary border border-primary/20 rounded-xl text-sm font-medium hover:bg-primary/20 transition-colors disabled:opacity-50 cursor-pointer"
|
||||
>
|
||||
{actionLoading === c.id ? "Génération..." : "Regénérer le lien de paiement"}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
563
app/admin/cours/page.tsx
Normal file
563
app/admin/cours/page.tsx
Normal file
@@ -0,0 +1,563 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
|
||||
interface Module {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string | null;
|
||||
week_number: number;
|
||||
order_index: number;
|
||||
content_type: "video" | "pdf" | "text" | "quiz" | null;
|
||||
content_url: string | null;
|
||||
duration_minutes: number | null;
|
||||
is_published: boolean;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
type FormData = {
|
||||
title: string;
|
||||
description: string;
|
||||
week_number: number;
|
||||
order_index: number;
|
||||
content_type: "video" | "pdf" | "text" | "quiz" | "";
|
||||
content_url: string;
|
||||
duration_minutes: number | "";
|
||||
is_published: boolean;
|
||||
};
|
||||
|
||||
const emptyForm: FormData = {
|
||||
title: "",
|
||||
description: "",
|
||||
week_number: 1,
|
||||
order_index: 0,
|
||||
content_type: "video",
|
||||
content_url: "",
|
||||
duration_minutes: "",
|
||||
is_published: false,
|
||||
};
|
||||
|
||||
export default function AdminCoursPage() {
|
||||
const [modules, setModules] = useState<Module[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [success, setSuccess] = useState<string | null>(null);
|
||||
|
||||
// Vue : "list" ou "form"
|
||||
const [view, setView] = useState<"list" | "form">("list");
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
const [form, setForm] = useState<FormData>(emptyForm);
|
||||
const [deleteConfirm, setDeleteConfirm] = useState<string | null>(null);
|
||||
|
||||
const fetchModules = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await fetch("/api/admin/modules");
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.error);
|
||||
setModules(data.modules);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Erreur de chargement");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchModules();
|
||||
}, [fetchModules]);
|
||||
|
||||
// Auto-clear success message
|
||||
useEffect(() => {
|
||||
if (success) {
|
||||
const t = setTimeout(() => setSuccess(null), 3000);
|
||||
return () => clearTimeout(t);
|
||||
}
|
||||
}, [success]);
|
||||
|
||||
const openNew = () => {
|
||||
// Calculer automatiquement le prochain order_index
|
||||
const maxOrder = modules.reduce((max, m) => Math.max(max, m.order_index), -1);
|
||||
setForm({ ...emptyForm, order_index: maxOrder + 1 });
|
||||
setEditingId(null);
|
||||
setView("form");
|
||||
setError(null);
|
||||
};
|
||||
|
||||
const openEdit = (mod: Module) => {
|
||||
setForm({
|
||||
title: mod.title,
|
||||
description: mod.description || "",
|
||||
week_number: mod.week_number,
|
||||
order_index: mod.order_index,
|
||||
content_type: mod.content_type || "",
|
||||
content_url: mod.content_url || "",
|
||||
duration_minutes: mod.duration_minutes ?? "",
|
||||
is_published: mod.is_published,
|
||||
});
|
||||
setEditingId(mod.id);
|
||||
setView("form");
|
||||
setError(null);
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!form.title.trim()) {
|
||||
setError("Le titre est obligatoire.");
|
||||
return;
|
||||
}
|
||||
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const payload = {
|
||||
title: form.title.trim(),
|
||||
description: form.description.trim() || null,
|
||||
week_number: form.week_number,
|
||||
order_index: form.order_index,
|
||||
content_type: form.content_type || null,
|
||||
content_url: form.content_url.trim() || null,
|
||||
duration_minutes: form.duration_minutes === "" ? null : Number(form.duration_minutes),
|
||||
is_published: form.is_published,
|
||||
};
|
||||
|
||||
let res: Response;
|
||||
if (editingId) {
|
||||
res = await fetch(`/api/admin/modules/${editingId}`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
} else {
|
||||
res = await fetch("/api/admin/modules", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.error);
|
||||
|
||||
setSuccess(editingId ? "Cours mis à jour !" : "Cours créé !");
|
||||
setView("list");
|
||||
await fetchModules();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Erreur");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
try {
|
||||
const res = await fetch(`/api/admin/modules/${id}`, { method: "DELETE" });
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.error);
|
||||
setSuccess("Cours supprimé !");
|
||||
setDeleteConfirm(null);
|
||||
await fetchModules();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Erreur");
|
||||
}
|
||||
};
|
||||
|
||||
const handleTogglePublish = async (mod: Module) => {
|
||||
try {
|
||||
const res = await fetch(`/api/admin/modules/${mod.id}`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ is_published: !mod.is_published }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.error);
|
||||
await fetchModules();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Erreur");
|
||||
}
|
||||
};
|
||||
|
||||
const contentTypeLabels: Record<string, string> = {
|
||||
video: "Vidéo",
|
||||
pdf: "PDF",
|
||||
text: "Texte",
|
||||
quiz: "Quiz",
|
||||
};
|
||||
|
||||
// Grouper par semaine pour l'affichage liste
|
||||
const modulesByWeek = modules.reduce(
|
||||
(acc, mod) => {
|
||||
const w = mod.week_number;
|
||||
if (!acc[w]) acc[w] = [];
|
||||
acc[w].push(mod);
|
||||
return acc;
|
||||
},
|
||||
{} as Record<number, Module[]>
|
||||
);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-20">
|
||||
<div className="animate-spin w-8 h-8 border-2 border-primary border-t-transparent rounded-full" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ========== FORMULAIRE ==========
|
||||
if (view === "form") {
|
||||
return (
|
||||
<div className="max-w-2xl">
|
||||
<button
|
||||
onClick={() => setView("list")}
|
||||
className="text-white/40 hover:text-white text-sm transition-colors mb-6 flex items-center gap-1 cursor-pointer"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
Retour à la liste
|
||||
</button>
|
||||
|
||||
<h1 className="text-2xl font-bold text-white mb-8">
|
||||
{editingId ? "Modifier le cours" : "Nouveau cours"}
|
||||
</h1>
|
||||
|
||||
{error && (
|
||||
<div className="mb-6 p-3 bg-error/10 border border-error/20 rounded-xl">
|
||||
<p className="text-error text-sm">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-6">
|
||||
{/* Titre */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-white/80 mb-1.5">
|
||||
Titre du cours *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={form.title}
|
||||
onChange={(e) => setForm({ ...form, title: e.target.value })}
|
||||
placeholder="Ex : Introduction au TikTok Shop"
|
||||
className="w-full px-4 py-3 bg-dark-lighter border border-dark-border rounded-xl text-white placeholder-white/30 text-sm focus:outline-none focus:border-primary"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-white/80 mb-1.5">
|
||||
Description
|
||||
</label>
|
||||
<textarea
|
||||
value={form.description}
|
||||
onChange={(e) => setForm({ ...form, description: e.target.value })}
|
||||
rows={3}
|
||||
placeholder="Description courte du module..."
|
||||
className="w-full px-4 py-3 bg-dark-lighter border border-dark-border rounded-xl text-white placeholder-white/30 text-sm focus:outline-none focus:border-primary resize-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Semaine + Ordre */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-white/80 mb-1.5">
|
||||
Semaine
|
||||
</label>
|
||||
<select
|
||||
value={form.week_number}
|
||||
onChange={(e) => setForm({ ...form, week_number: Number(e.target.value) })}
|
||||
className="w-full px-4 py-3 bg-dark-lighter border border-dark-border rounded-xl text-white text-sm focus:outline-none focus:border-primary"
|
||||
>
|
||||
{[1, 2, 3, 4, 5, 6, 7, 8].map((w) => (
|
||||
<option key={w} value={w}>
|
||||
Semaine {w}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-white/80 mb-1.5">
|
||||
Ordre d'affichage
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={form.order_index}
|
||||
onChange={(e) => setForm({ ...form, order_index: Number(e.target.value) })}
|
||||
min={0}
|
||||
className="w-full px-4 py-3 bg-dark-lighter border border-dark-border rounded-xl text-white text-sm focus:outline-none focus:border-primary"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Type de contenu */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-white/80 mb-1.5">
|
||||
Type de contenu
|
||||
</label>
|
||||
<div className="grid grid-cols-4 gap-2">
|
||||
{(["video", "pdf", "text", "quiz"] as const).map((type) => (
|
||||
<button
|
||||
key={type}
|
||||
type="button"
|
||||
onClick={() => setForm({ ...form, content_type: type })}
|
||||
className={`px-3 py-2.5 rounded-xl text-sm font-medium transition-colors cursor-pointer ${
|
||||
form.content_type === type
|
||||
? "bg-primary/10 text-primary border border-primary/30"
|
||||
: "bg-dark-lighter text-white/40 border border-dark-border hover:text-white"
|
||||
}`}
|
||||
>
|
||||
{contentTypeLabels[type]}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* URL du contenu */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-white/80 mb-1.5">
|
||||
URL du contenu
|
||||
</label>
|
||||
<input
|
||||
type="url"
|
||||
value={form.content_url}
|
||||
onChange={(e) => setForm({ ...form, content_url: e.target.value })}
|
||||
placeholder={
|
||||
form.content_type === "video"
|
||||
? "https://www.youtube.com/embed/..."
|
||||
: form.content_type === "pdf"
|
||||
? "https://drive.google.com/file/..."
|
||||
: "https://..."
|
||||
}
|
||||
className="w-full px-4 py-3 bg-dark-lighter border border-dark-border rounded-xl text-white placeholder-white/30 text-sm focus:outline-none focus:border-primary"
|
||||
/>
|
||||
<p className="text-white/20 text-xs mt-1">
|
||||
{form.content_type === "video"
|
||||
? "Utilise l'URL d'intégration YouTube (embed). Ex : https://www.youtube.com/embed/VIDEO_ID"
|
||||
: "Lien direct vers le fichier ou la page."}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Durée */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-white/80 mb-1.5">
|
||||
Durée (minutes)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={form.duration_minutes}
|
||||
onChange={(e) =>
|
||||
setForm({
|
||||
...form,
|
||||
duration_minutes: e.target.value === "" ? "" : Number(e.target.value),
|
||||
})
|
||||
}
|
||||
min={0}
|
||||
placeholder="15"
|
||||
className="w-full px-4 py-3 bg-dark-lighter border border-dark-border rounded-xl text-white placeholder-white/30 text-sm focus:outline-none focus:border-primary"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Publié */}
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setForm({ ...form, is_published: !form.is_published })}
|
||||
className={`relative w-12 h-6 rounded-full transition-colors cursor-pointer ${
|
||||
form.is_published ? "bg-success" : "bg-dark-lighter"
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`absolute top-0.5 w-5 h-5 bg-white rounded-full transition-transform ${
|
||||
form.is_published ? "translate-x-6.5" : "translate-x-0.5"
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
<span className="text-white/80 text-sm">
|
||||
{form.is_published ? "Publié (visible par les élèves)" : "Brouillon (non visible)"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Boutons */}
|
||||
<div className="flex items-center gap-3 pt-4 border-t border-dark-border">
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={saving}
|
||||
className="px-6 py-3 gradient-bg text-white font-semibold rounded-xl disabled:opacity-50 cursor-pointer"
|
||||
>
|
||||
{saving ? "Enregistrement..." : editingId ? "Mettre à jour" : "Créer le cours"}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setView("list")}
|
||||
className="px-6 py-3 bg-dark-lighter text-white/60 rounded-xl hover:text-white transition-colors cursor-pointer"
|
||||
>
|
||||
Annuler
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ========== LISTE ==========
|
||||
return (
|
||||
<div className="max-w-6xl">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-8">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-white">Gestion des cours</h1>
|
||||
<p className="text-white/40 text-sm mt-1">
|
||||
{modules.length} cours · {modules.filter((m) => m.is_published).length} publiés
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={openNew}
|
||||
className="px-4 py-2.5 gradient-bg text-white font-semibold rounded-xl text-sm flex items-center gap-2 cursor-pointer"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
|
||||
</svg>
|
||||
Nouveau cours
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="mb-6 p-3 bg-error/10 border border-error/20 rounded-xl">
|
||||
<p className="text-error text-sm">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{success && (
|
||||
<div className="mb-6 p-3 bg-success/10 border border-success/20 rounded-xl">
|
||||
<p className="text-success text-sm">{success}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{modules.length === 0 ? (
|
||||
<div className="text-center py-20 bg-dark-light border border-dark-border rounded-[20px]">
|
||||
<div className="text-5xl mb-4">📚</div>
|
||||
<h3 className="text-white font-semibold text-lg mb-2">Aucun cours</h3>
|
||||
<p className="text-white/40 text-sm mb-6">Crée ton premier cours pour commencer.</p>
|
||||
<button
|
||||
onClick={openNew}
|
||||
className="px-6 py-3 gradient-bg text-white font-semibold rounded-xl cursor-pointer"
|
||||
>
|
||||
Créer un cours
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-8">
|
||||
{Object.entries(modulesByWeek)
|
||||
.sort(([a], [b]) => Number(a) - Number(b))
|
||||
.map(([week, weekModules]) => (
|
||||
<div key={week}>
|
||||
<h2 className="text-lg font-bold text-white mb-3 flex items-center gap-2">
|
||||
<span className="px-2.5 py-1 bg-primary/10 text-primary rounded-lg text-xs font-medium">
|
||||
Semaine {week}
|
||||
</span>
|
||||
<span className="text-white/30 text-xs font-normal">
|
||||
{weekModules.length} cours
|
||||
</span>
|
||||
</h2>
|
||||
<div className="space-y-2">
|
||||
{weekModules
|
||||
.sort((a, b) => a.order_index - b.order_index)
|
||||
.map((mod) => (
|
||||
<div
|
||||
key={mod.id}
|
||||
className="bg-dark-light border border-dark-border rounded-xl px-5 py-4 flex items-center justify-between group"
|
||||
>
|
||||
<div className="flex items-center gap-4 flex-1 min-w-0">
|
||||
{/* Grip handle (visuel) */}
|
||||
<span className="text-white/15 group-hover:text-white/30 transition-colors">
|
||||
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path d="M7 2a2 2 0 10.001 4.001A2 2 0 007 2zm0 6a2 2 0 10.001 4.001A2 2 0 007 8zm0 6a2 2 0 10.001 4.001A2 2 0 007 14zm6-8a2 2 0 10-.001-4.001A2 2 0 0013 6zm0 2a2 2 0 10.001 4.001A2 2 0 0013 8zm0 6a2 2 0 10.001 4.001A2 2 0 0013 14z" />
|
||||
</svg>
|
||||
</span>
|
||||
|
||||
{/* Status dot */}
|
||||
<span
|
||||
className={`w-2.5 h-2.5 rounded-full flex-shrink-0 ${
|
||||
mod.is_published ? "bg-success" : "bg-white/20"
|
||||
}`}
|
||||
/>
|
||||
|
||||
{/* Info */}
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-white font-medium text-sm truncate">{mod.title}</p>
|
||||
<div className="flex items-center gap-2 mt-0.5">
|
||||
{mod.content_type && (
|
||||
<span className="text-white/30 text-xs uppercase">
|
||||
{contentTypeLabels[mod.content_type] || mod.content_type}
|
||||
</span>
|
||||
)}
|
||||
{mod.duration_minutes && (
|
||||
<span className="text-white/20 text-xs">{mod.duration_minutes} min</span>
|
||||
)}
|
||||
<span className="text-white/20 text-xs">
|
||||
#{mod.order_index}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center gap-2 ml-4">
|
||||
{/* Toggle publish */}
|
||||
<button
|
||||
onClick={() => handleTogglePublish(mod)}
|
||||
className={`px-2.5 py-1 rounded-lg text-xs font-medium transition-colors cursor-pointer ${
|
||||
mod.is_published
|
||||
? "bg-success/10 text-success hover:bg-success/20"
|
||||
: "bg-white/5 text-white/30 hover:text-white hover:bg-white/10"
|
||||
}`}
|
||||
>
|
||||
{mod.is_published ? "Publié" : "Brouillon"}
|
||||
</button>
|
||||
|
||||
{/* Edit */}
|
||||
<button
|
||||
onClick={() => openEdit(mod)}
|
||||
className="p-2 text-white/30 hover:text-primary transition-colors cursor-pointer rounded-lg hover:bg-primary/5"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{/* Delete */}
|
||||
{deleteConfirm === mod.id ? (
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={() => handleDelete(mod.id)}
|
||||
className="px-2 py-1 bg-error/10 text-error rounded-lg text-xs font-medium cursor-pointer hover:bg-error/20"
|
||||
>
|
||||
Confirmer
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setDeleteConfirm(null)}
|
||||
className="px-2 py-1 text-white/30 text-xs cursor-pointer hover:text-white"
|
||||
>
|
||||
Annuler
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => setDeleteConfirm(mod.id)}
|
||||
className="p-2 text-white/30 hover:text-error transition-colors cursor-pointer rounded-lg hover:bg-error/5"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
46
app/admin/layout.tsx
Normal file
46
app/admin/layout.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { createClient, createAdminClient } from "@/lib/supabase/server";
|
||||
import AdminShell from "@/components/admin/AdminShell";
|
||||
import type { Profile } from "@/types/database.types";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
export default async function AdminLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const supabase = await createClient();
|
||||
|
||||
// Vérifier l'authentification
|
||||
const {
|
||||
data: { user },
|
||||
} = await supabase.auth.getUser();
|
||||
|
||||
if (!user) {
|
||||
redirect("/login?redirect=/admin");
|
||||
}
|
||||
|
||||
// Vérifier le statut admin via service role (pas de RLS)
|
||||
const adminClient = createAdminClient();
|
||||
const { data: profile } = await adminClient
|
||||
.from("profiles")
|
||||
.select("*")
|
||||
.eq("id", user.id)
|
||||
.single();
|
||||
|
||||
const typedProfile = profile as Profile | null;
|
||||
|
||||
if (!typedProfile || !typedProfile.is_admin) {
|
||||
redirect("/");
|
||||
}
|
||||
|
||||
return (
|
||||
<AdminShell
|
||||
adminName={typedProfile.full_name || "Admin"}
|
||||
adminEmail={typedProfile.email}
|
||||
>
|
||||
{children}
|
||||
</AdminShell>
|
||||
);
|
||||
}
|
||||
@@ -1,358 +1,143 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { createAdminClient } from "@/lib/supabase/server";
|
||||
import Link from "next/link";
|
||||
|
||||
interface Candidature {
|
||||
id: string;
|
||||
email: string;
|
||||
firstname: string;
|
||||
phone: string;
|
||||
persona: string;
|
||||
age: number;
|
||||
experience: string;
|
||||
time_daily: string;
|
||||
availability: string;
|
||||
start_date: string;
|
||||
motivation: string;
|
||||
monthly_goal: string;
|
||||
biggest_fear: string;
|
||||
tiktok_username: string | null;
|
||||
status: "pending" | "approved" | "rejected";
|
||||
created_at: string;
|
||||
}
|
||||
export const runtime = "nodejs";
|
||||
|
||||
export default function AdminPage() {
|
||||
const [secret, setSecret] = useState("");
|
||||
const [authenticated, setAuthenticated] = useState(false);
|
||||
const [candidatures, setCandidatures] = useState<Candidature[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [actionLoading, setActionLoading] = useState<string | null>(null);
|
||||
const [checkoutUrls, setCheckoutUrls] = useState<Record<string, string>>({});
|
||||
const [expandedId, setExpandedId] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
export default async function AdminDashboard() {
|
||||
const supabase = createAdminClient();
|
||||
|
||||
const fetchCandidatures = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await fetch(`/api/admin/candidatures?secret=${encodeURIComponent(secret)}`);
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.error);
|
||||
setCandidatures(data.candidatures);
|
||||
setAuthenticated(true);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Erreur de chargement");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [secret]);
|
||||
// Récupérer les stats en parallèle
|
||||
const [candidaturesRes, modulesRes, profilesRes] = await Promise.all([
|
||||
supabase.from("candidatures").select("*"),
|
||||
supabase.from("modules").select("*"),
|
||||
supabase.from("profiles").select("*"),
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
// Vérifier si le secret est dans l'URL
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const urlSecret = params.get("secret");
|
||||
if (urlSecret) {
|
||||
setSecret(urlSecret);
|
||||
}
|
||||
}, []);
|
||||
const candidatures = (candidaturesRes.data || []) as { id: string; status: string; created_at: string }[];
|
||||
const modules = (modulesRes.data || []) as { id: string; is_published: boolean }[];
|
||||
const profiles = (profilesRes.data || []) as { id: string; subscription_status: string; created_at: string }[];
|
||||
|
||||
useEffect(() => {
|
||||
if (secret && !authenticated) {
|
||||
fetchCandidatures();
|
||||
}
|
||||
}, [secret, authenticated, fetchCandidatures]);
|
||||
const pendingCount = candidatures.filter((c) => c.status === "pending").length;
|
||||
const approvedCount = candidatures.filter((c) => c.status === "approved").length;
|
||||
const publishedModules = modules.filter((m) => m.is_published).length;
|
||||
const activeUsers = profiles.filter((p) => p.subscription_status === "active").length;
|
||||
|
||||
const handleApprove = async (id: string) => {
|
||||
setActionLoading(id);
|
||||
try {
|
||||
const res = await fetch(`/api/admin/candidatures/${id}/approve`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ secret }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.error);
|
||||
|
||||
if (data.checkoutUrl) {
|
||||
setCheckoutUrls((prev) => ({ ...prev, [id]: data.checkoutUrl }));
|
||||
}
|
||||
|
||||
// Rafraîchir la liste
|
||||
await fetchCandidatures();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Erreur");
|
||||
} finally {
|
||||
setActionLoading(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleReject = async (id: string) => {
|
||||
if (!confirm("Rejeter cette candidature ?")) return;
|
||||
setActionLoading(id);
|
||||
try {
|
||||
const res = await fetch(`/api/admin/candidatures/${id}/reject`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ secret }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.error);
|
||||
await fetchCandidatures();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Erreur");
|
||||
} finally {
|
||||
setActionLoading(null);
|
||||
}
|
||||
};
|
||||
|
||||
const statusColors: Record<string, string> = {
|
||||
pending: "bg-warning/10 text-warning",
|
||||
approved: "bg-success/10 text-success",
|
||||
rejected: "bg-error/10 text-error",
|
||||
};
|
||||
|
||||
const statusLabels: Record<string, string> = {
|
||||
pending: "En attente",
|
||||
approved: "Approuvée",
|
||||
rejected: "Rejetée",
|
||||
};
|
||||
|
||||
// Login form
|
||||
if (!authenticated) {
|
||||
return (
|
||||
<main className="min-h-screen flex items-center justify-center">
|
||||
<div className="bg-dark-light border border-dark-border rounded-[20px] p-8 w-full max-w-sm">
|
||||
<h1 className="text-xl font-bold text-white mb-6 text-center">Admin HookLab</h1>
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
fetchCandidatures();
|
||||
}}
|
||||
>
|
||||
<input
|
||||
type="password"
|
||||
placeholder="Clé secrète admin"
|
||||
value={secret}
|
||||
onChange={(e) => setSecret(e.target.value)}
|
||||
className="w-full px-4 py-3 bg-dark-lighter border border-dark-border rounded-xl text-white placeholder-white/30 text-sm mb-4 focus:outline-none focus:border-primary"
|
||||
/>
|
||||
{error && (
|
||||
<p className="text-error text-sm mb-4">{error}</p>
|
||||
)}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading || !secret}
|
||||
className="w-full py-3 gradient-bg text-white font-semibold rounded-xl disabled:opacity-50 cursor-pointer"
|
||||
>
|
||||
{loading ? "Connexion..." : "Accéder"}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
const pending = candidatures.filter((c) => c.status === "pending");
|
||||
const approved = candidatures.filter((c) => c.status === "approved");
|
||||
const rejected = candidatures.filter((c) => c.status === "rejected");
|
||||
// Candidatures récentes (5 dernières)
|
||||
const recentCandidatures = candidatures
|
||||
.sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime())
|
||||
.slice(0, 5);
|
||||
|
||||
return (
|
||||
<main className="min-h-screen py-10">
|
||||
<div className="max-w-6xl mx-auto px-4 sm:px-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-8">
|
||||
<div>
|
||||
<Link href="/" className="text-white/40 hover:text-white text-sm transition-colors mb-2 inline-block">
|
||||
← Retour au site
|
||||
<div className="max-w-6xl">
|
||||
<div className="mb-10">
|
||||
<h1 className="text-3xl font-bold text-white mb-2">Dashboard Admin</h1>
|
||||
<p className="text-white/60">Vue d'ensemble de HookLab.</p>
|
||||
</div>
|
||||
|
||||
{/* Stats cards */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 mb-10">
|
||||
<div className="bg-dark-light border border-dark-border rounded-[20px] p-6">
|
||||
<p className="text-white/40 text-sm mb-1">Candidatures en attente</p>
|
||||
<p className="text-3xl font-bold text-warning">{pendingCount}</p>
|
||||
<p className="text-white/30 text-xs mt-1">{candidatures.length} au total</p>
|
||||
</div>
|
||||
<div className="bg-dark-light border border-dark-border rounded-[20px] p-6">
|
||||
<p className="text-white/40 text-sm mb-1">Candidatures approuvées</p>
|
||||
<p className="text-3xl font-bold text-success">{approvedCount}</p>
|
||||
<p className="text-white/30 text-xs mt-1">
|
||||
{candidatures.length > 0 ? Math.round((approvedCount / candidatures.length) * 100) : 0}% de conversion
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-dark-light border border-dark-border rounded-[20px] p-6">
|
||||
<p className="text-white/40 text-sm mb-1">Utilisateurs actifs</p>
|
||||
<p className="text-3xl font-bold text-primary">{activeUsers}</p>
|
||||
<p className="text-white/30 text-xs mt-1">{profiles.length} inscrits</p>
|
||||
</div>
|
||||
<div className="bg-dark-light border border-dark-border rounded-[20px] p-6">
|
||||
<p className="text-white/40 text-sm mb-1">Cours publiés</p>
|
||||
<p className="text-3xl font-bold text-white">{publishedModules}</p>
|
||||
<p className="text-white/30 text-xs mt-1">{modules.length} au total</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions rapides */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 mb-10">
|
||||
{/* Candidatures récentes */}
|
||||
<div className="bg-dark-light border border-dark-border rounded-[20px] p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-lg font-bold text-white">Candidatures récentes</h2>
|
||||
<Link href="/admin/candidatures" className="text-primary text-sm hover:underline">
|
||||
Tout voir
|
||||
</Link>
|
||||
<h1 className="text-2xl font-bold text-white">Gestion des candidatures</h1>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex gap-2 text-xs">
|
||||
<span className="px-2 py-1 bg-warning/10 text-warning rounded-lg">{pending.length} en attente</span>
|
||||
<span className="px-2 py-1 bg-success/10 text-success rounded-lg">{approved.length} approuvées</span>
|
||||
<span className="px-2 py-1 bg-error/10 text-error rounded-lg">{rejected.length} rejetées</span>
|
||||
{recentCandidatures.length === 0 ? (
|
||||
<p className="text-white/30 text-sm">Aucune candidature.</p>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{recentCandidatures.map((c) => (
|
||||
<div key={c.id} className="flex items-center justify-between">
|
||||
<span className="text-white/60 text-sm truncate">
|
||||
{new Date(c.created_at).toLocaleDateString("fr-FR", {
|
||||
day: "numeric",
|
||||
month: "short",
|
||||
})}
|
||||
</span>
|
||||
<span
|
||||
className={`px-2 py-0.5 rounded-lg text-xs font-medium ${
|
||||
c.status === "pending"
|
||||
? "bg-warning/10 text-warning"
|
||||
: c.status === "approved"
|
||||
? "bg-success/10 text-success"
|
||||
: "bg-error/10 text-error"
|
||||
}`}
|
||||
>
|
||||
{c.status === "pending" ? "En attente" : c.status === "approved" ? "Approuvée" : "Rejetée"}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
onClick={fetchCandidatures}
|
||||
className="px-3 py-1.5 bg-dark-lighter border border-dark-border rounded-lg text-white/60 text-sm hover:text-white transition-colors cursor-pointer"
|
||||
>
|
||||
Rafraîchir
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="mb-6 p-3 bg-error/10 border border-error/20 rounded-xl">
|
||||
<p className="text-error text-sm">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Liste des candidatures */}
|
||||
{candidatures.length === 0 ? (
|
||||
<div className="text-center py-20">
|
||||
<p className="text-white/40">Aucune candidature pour le moment.</p>
|
||||
</div>
|
||||
) : (
|
||||
{/* Actions rapides */}
|
||||
<div className="bg-dark-light border border-dark-border rounded-[20px] p-6">
|
||||
<h2 className="text-lg font-bold text-white mb-4">Actions rapides</h2>
|
||||
<div className="space-y-3">
|
||||
{candidatures.map((c) => (
|
||||
<div
|
||||
key={c.id}
|
||||
className="bg-dark-light border border-dark-border rounded-2xl overflow-hidden"
|
||||
>
|
||||
{/* Header row */}
|
||||
<div
|
||||
className="px-6 py-4 flex items-center justify-between cursor-pointer hover:bg-dark-lighter/50 transition-colors"
|
||||
onClick={() => setExpandedId(expandedId === c.id ? null : c.id)}
|
||||
>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-10 h-10 rounded-full gradient-bg flex items-center justify-center text-sm font-bold text-white">
|
||||
{c.firstname.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-white font-medium">{c.firstname}</p>
|
||||
<p className="text-white/40 text-sm">{c.email}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-white/30 text-xs">
|
||||
{new Date(c.created_at).toLocaleDateString("fr-FR", {
|
||||
day: "numeric",
|
||||
month: "short",
|
||||
year: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
})}
|
||||
</span>
|
||||
<span className={`px-2.5 py-1 rounded-lg text-xs font-medium ${statusColors[c.status]}`}>
|
||||
{statusLabels[c.status]}
|
||||
</span>
|
||||
<svg
|
||||
className={`w-5 h-5 text-white/30 transition-transform ${expandedId === c.id ? "rotate-180" : ""}`}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Expanded details */}
|
||||
{expandedId === c.id && (
|
||||
<div className="px-6 pb-5 border-t border-dark-border">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 py-4">
|
||||
<div>
|
||||
<p className="text-white/40 text-xs mb-1">Téléphone</p>
|
||||
<p className="text-white text-sm">{c.phone}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-white/40 text-xs mb-1">Âge</p>
|
||||
<p className="text-white text-sm">{c.age} ans</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-white/40 text-xs mb-1">Profil</p>
|
||||
<p className="text-white text-sm capitalize">{c.persona}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-white/40 text-xs mb-1">Expérience</p>
|
||||
<p className="text-white text-sm">{c.experience}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-white/40 text-xs mb-1">Temps disponible</p>
|
||||
<p className="text-white text-sm">{c.time_daily}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-white/40 text-xs mb-1">Disponibilité</p>
|
||||
<p className="text-white text-sm">{c.availability}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-white/40 text-xs mb-1">Début souhaité</p>
|
||||
<p className="text-white text-sm">{c.start_date}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-white/40 text-xs mb-1">Objectif mensuel</p>
|
||||
<p className="text-white text-sm">{c.monthly_goal}</p>
|
||||
</div>
|
||||
{c.tiktok_username && (
|
||||
<div>
|
||||
<p className="text-white/40 text-xs mb-1">TikTok</p>
|
||||
<p className="text-white text-sm">{c.tiktok_username}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-3 py-3">
|
||||
<div>
|
||||
<p className="text-white/40 text-xs mb-1">Motivation</p>
|
||||
<p className="text-white/80 text-sm bg-dark-lighter rounded-xl p-3">{c.motivation}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-white/40 text-xs mb-1">Plus grande peur</p>
|
||||
<p className="text-white/80 text-sm bg-dark-lighter rounded-xl p-3">{c.biggest_fear}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Checkout URL si disponible */}
|
||||
{checkoutUrls[c.id] && (
|
||||
<div className="mt-3 p-3 bg-success/10 border border-success/20 rounded-xl">
|
||||
<p className="text-success text-xs font-medium mb-1">Lien de paiement généré :</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="text"
|
||||
readOnly
|
||||
value={checkoutUrls[c.id]}
|
||||
className="flex-1 bg-dark-lighter border border-dark-border rounded-lg px-3 py-2 text-white text-xs"
|
||||
/>
|
||||
<button
|
||||
onClick={() => navigator.clipboard.writeText(checkoutUrls[c.id])}
|
||||
className="px-3 py-2 bg-success/20 text-success rounded-lg text-xs font-medium hover:bg-success/30 transition-colors cursor-pointer"
|
||||
>
|
||||
Copier
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
{c.status === "pending" && (
|
||||
<div className="flex items-center gap-3 mt-4 pt-4 border-t border-dark-border">
|
||||
<button
|
||||
onClick={() => handleApprove(c.id)}
|
||||
disabled={actionLoading === c.id}
|
||||
className="px-4 py-2 bg-success/10 text-success border border-success/20 rounded-xl text-sm font-medium hover:bg-success/20 transition-colors disabled:opacity-50 cursor-pointer"
|
||||
>
|
||||
{actionLoading === c.id ? "Approbation..." : "Approuver + Envoyer lien paiement"}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleReject(c.id)}
|
||||
disabled={actionLoading === c.id}
|
||||
className="px-4 py-2 bg-error/10 text-error border border-error/20 rounded-xl text-sm font-medium hover:bg-error/20 transition-colors disabled:opacity-50 cursor-pointer"
|
||||
>
|
||||
Rejeter
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{c.status === "approved" && !checkoutUrls[c.id] && (
|
||||
<div className="flex items-center gap-3 mt-4 pt-4 border-t border-dark-border">
|
||||
<button
|
||||
onClick={() => handleApprove(c.id)}
|
||||
disabled={actionLoading === c.id}
|
||||
className="px-4 py-2 bg-primary/10 text-primary border border-primary/20 rounded-xl text-sm font-medium hover:bg-primary/20 transition-colors disabled:opacity-50 cursor-pointer"
|
||||
>
|
||||
{actionLoading === c.id ? "Génération..." : "Regénérer le lien de paiement"}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<Link
|
||||
href="/admin/candidatures"
|
||||
className="flex items-center gap-3 p-3 rounded-xl bg-dark-lighter hover:bg-dark-lighter/80 transition-colors group"
|
||||
>
|
||||
<div className="w-10 h-10 rounded-xl bg-warning/10 flex items-center justify-center">
|
||||
<svg className="w-5 h-5 text-warning" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" />
|
||||
</svg>
|
||||
</div>
|
||||
))}
|
||||
<div>
|
||||
<p className="text-white text-sm font-medium group-hover:text-primary transition-colors">
|
||||
Gérer les candidatures
|
||||
</p>
|
||||
<p className="text-white/30 text-xs">{pendingCount} en attente de traitement</p>
|
||||
</div>
|
||||
</Link>
|
||||
<Link
|
||||
href="/admin/cours"
|
||||
className="flex items-center gap-3 p-3 rounded-xl bg-dark-lighter hover:bg-dark-lighter/80 transition-colors group"
|
||||
>
|
||||
<div className="w-10 h-10 rounded-xl bg-primary/10 flex items-center justify-center">
|
||||
<svg className="w-5 h-5 text-primary" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-white text-sm font-medium group-hover:text-primary transition-colors">
|
||||
Ajouter un cours
|
||||
</p>
|
||||
<p className="text-white/30 text-xs">{publishedModules} cours publiés</p>
|
||||
</div>
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { createAdminClient } from "@/lib/supabase/server";
|
||||
import { verifyAdmin, isAdminError } from "@/lib/admin";
|
||||
import { stripe } from "@/lib/stripe/client";
|
||||
import { getBaseUrl } from "@/lib/utils";
|
||||
|
||||
@@ -7,17 +8,15 @@ export const runtime = "nodejs";
|
||||
|
||||
// POST /api/admin/candidatures/[id]/approve - Approuver une candidature
|
||||
export async function POST(
|
||||
request: Request,
|
||||
_request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const { id } = await params;
|
||||
const body = await request.json();
|
||||
const { secret } = body;
|
||||
|
||||
if (!process.env.ADMIN_SECRET || secret !== process.env.ADMIN_SECRET) {
|
||||
return NextResponse.json({ error: "Non autorisé." }, { status: 401 });
|
||||
const auth = await verifyAdmin();
|
||||
if (isAdminError(auth)) {
|
||||
return NextResponse.json({ error: auth.error }, { status: auth.status });
|
||||
}
|
||||
|
||||
const { id } = await params;
|
||||
const supabase = createAdminClient();
|
||||
|
||||
// Récupérer la candidature
|
||||
|
||||
@@ -1,21 +1,20 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { createAdminClient } from "@/lib/supabase/server";
|
||||
import { verifyAdmin, isAdminError } from "@/lib/admin";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
// POST /api/admin/candidatures/[id]/reject - Rejeter une candidature
|
||||
export async function POST(
|
||||
request: Request,
|
||||
_request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const { id } = await params;
|
||||
const body = await request.json();
|
||||
const { secret } = body;
|
||||
|
||||
if (!process.env.ADMIN_SECRET || secret !== process.env.ADMIN_SECRET) {
|
||||
return NextResponse.json({ error: "Non autorisé." }, { status: 401 });
|
||||
const auth = await verifyAdmin();
|
||||
if (isAdminError(auth)) {
|
||||
return NextResponse.json({ error: auth.error }, { status: auth.status });
|
||||
}
|
||||
|
||||
const { id } = await params;
|
||||
const supabase = createAdminClient();
|
||||
|
||||
const { error } = await supabase
|
||||
|
||||
@@ -1,16 +1,15 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { createAdminClient } from "@/lib/supabase/server";
|
||||
import { verifyAdmin, isAdminError } from "@/lib/admin";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
// GET /api/admin/candidatures - Lister toutes les candidatures
|
||||
// Protégé par ADMIN_SECRET en query param
|
||||
export async function GET(request: Request) {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const secret = searchParams.get("secret");
|
||||
|
||||
if (!process.env.ADMIN_SECRET || secret !== process.env.ADMIN_SECRET) {
|
||||
return NextResponse.json({ error: "Non autorisé." }, { status: 401 });
|
||||
// Sécurisé par auth Supabase + vérification is_admin
|
||||
export async function GET() {
|
||||
const auth = await verifyAdmin();
|
||||
if (isAdminError(auth)) {
|
||||
return NextResponse.json({ error: auth.error }, { status: auth.status });
|
||||
}
|
||||
|
||||
const supabase = createAdminClient();
|
||||
|
||||
104
app/api/admin/modules/[id]/route.ts
Normal file
104
app/api/admin/modules/[id]/route.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { createAdminClient } from "@/lib/supabase/server";
|
||||
import { verifyAdmin, isAdminError } from "@/lib/admin";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
// GET /api/admin/modules/[id] - Récupérer un module
|
||||
export async function GET(
|
||||
_request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const auth = await verifyAdmin();
|
||||
if (isAdminError(auth)) {
|
||||
return NextResponse.json({ error: auth.error }, { status: auth.status });
|
||||
}
|
||||
|
||||
const { id } = await params;
|
||||
const supabase = createAdminClient();
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from("modules")
|
||||
.select("*")
|
||||
.eq("id", id)
|
||||
.single();
|
||||
|
||||
if (error || !data) {
|
||||
return NextResponse.json({ error: "Module introuvable." }, { status: 404 });
|
||||
}
|
||||
|
||||
return NextResponse.json({ module: data });
|
||||
}
|
||||
|
||||
// PUT /api/admin/modules/[id] - Mettre à jour un module
|
||||
export async function PUT(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const auth = await verifyAdmin();
|
||||
if (isAdminError(auth)) {
|
||||
return NextResponse.json({ error: auth.error }, { status: auth.status });
|
||||
}
|
||||
|
||||
const { id } = await params;
|
||||
const body = await request.json();
|
||||
|
||||
// Construire l'objet de mise à jour (seulement les champs fournis)
|
||||
const updates: Record<string, unknown> = {};
|
||||
if (body.title !== undefined) updates.title = body.title;
|
||||
if (body.description !== undefined) updates.description = body.description;
|
||||
if (body.week_number !== undefined) updates.week_number = body.week_number;
|
||||
if (body.order_index !== undefined) updates.order_index = body.order_index;
|
||||
if (body.content_type !== undefined) updates.content_type = body.content_type;
|
||||
if (body.content_url !== undefined) updates.content_url = body.content_url;
|
||||
if (body.duration_minutes !== undefined) updates.duration_minutes = body.duration_minutes;
|
||||
if (body.is_published !== undefined) updates.is_published = body.is_published;
|
||||
|
||||
if (Object.keys(updates).length === 0) {
|
||||
return NextResponse.json({ error: "Aucune modification fournie." }, { status: 400 });
|
||||
}
|
||||
|
||||
const supabase = createAdminClient();
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from("modules")
|
||||
.update(updates as never)
|
||||
.eq("id", id)
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (error) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 });
|
||||
}
|
||||
|
||||
return NextResponse.json({ module: data });
|
||||
}
|
||||
|
||||
// DELETE /api/admin/modules/[id] - Supprimer un module
|
||||
export async function DELETE(
|
||||
_request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const auth = await verifyAdmin();
|
||||
if (isAdminError(auth)) {
|
||||
return NextResponse.json({ error: auth.error }, { status: auth.status });
|
||||
}
|
||||
|
||||
const { id } = await params;
|
||||
const supabase = createAdminClient();
|
||||
|
||||
// D'abord supprimer les progressions liées
|
||||
await supabase.from("user_progress").delete().eq("module_id", id);
|
||||
|
||||
// Puis supprimer le module
|
||||
const { error } = await supabase
|
||||
.from("modules")
|
||||
.delete()
|
||||
.eq("id", id);
|
||||
|
||||
if (error) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 });
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true, message: "Module supprimé." });
|
||||
}
|
||||
65
app/api/admin/modules/route.ts
Normal file
65
app/api/admin/modules/route.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { createAdminClient } from "@/lib/supabase/server";
|
||||
import { verifyAdmin, isAdminError } from "@/lib/admin";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
// GET /api/admin/modules - Lister tous les modules (admin)
|
||||
export async function GET() {
|
||||
const auth = await verifyAdmin();
|
||||
if (isAdminError(auth)) {
|
||||
return NextResponse.json({ error: auth.error }, { status: auth.status });
|
||||
}
|
||||
|
||||
const supabase = createAdminClient();
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from("modules")
|
||||
.select("*")
|
||||
.order("week_number", { ascending: true })
|
||||
.order("order_index", { ascending: true });
|
||||
|
||||
if (error) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 });
|
||||
}
|
||||
|
||||
return NextResponse.json({ modules: data });
|
||||
}
|
||||
|
||||
// POST /api/admin/modules - Créer un nouveau module
|
||||
export async function POST(request: Request) {
|
||||
const auth = await verifyAdmin();
|
||||
if (isAdminError(auth)) {
|
||||
return NextResponse.json({ error: auth.error }, { status: auth.status });
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const { title, description, week_number, order_index, content_type, content_url, duration_minutes, is_published } = body;
|
||||
|
||||
if (!title || !week_number) {
|
||||
return NextResponse.json({ error: "Titre et semaine obligatoires." }, { status: 400 });
|
||||
}
|
||||
|
||||
const supabase = createAdminClient();
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from("modules")
|
||||
.insert({
|
||||
title,
|
||||
description: description || null,
|
||||
week_number,
|
||||
order_index: order_index ?? 0,
|
||||
content_type: content_type || null,
|
||||
content_url: content_url || null,
|
||||
duration_minutes: duration_minutes ?? null,
|
||||
is_published: is_published ?? false,
|
||||
} as never)
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (error) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 });
|
||||
}
|
||||
|
||||
return NextResponse.json({ module: data }, { status: 201 });
|
||||
}
|
||||
139
components/admin/AdminShell.tsx
Normal file
139
components/admin/AdminShell.tsx
Normal file
@@ -0,0 +1,139 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
import { createClient } from "@/lib/supabase/client";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface AdminShellProps {
|
||||
children: React.ReactNode;
|
||||
adminName: string;
|
||||
adminEmail: string;
|
||||
}
|
||||
|
||||
const navItems = [
|
||||
{
|
||||
label: "Dashboard",
|
||||
href: "/admin",
|
||||
icon: (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M4 5a1 1 0 011-1h4a1 1 0 011 1v5a1 1 0 01-1 1H5a1 1 0 01-1-1V5zm10 0a1 1 0 011-1h4a1 1 0 011 1v2a1 1 0 01-1 1h-4a1 1 0 01-1-1V5zM4 15a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1H5a1 1 0 01-1-1v-4zm10-2a1 1 0 011-1h4a1 1 0 011 1v6a1 1 0 01-1 1h-4a1 1 0 01-1-1v-6z" />
|
||||
</svg>
|
||||
),
|
||||
exact: true,
|
||||
},
|
||||
{
|
||||
label: "Candidatures",
|
||||
href: "/admin/candidatures",
|
||||
icon: (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
label: "Cours",
|
||||
href: "/admin/cours",
|
||||
icon: (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
export default function AdminShell({ children, adminName, adminEmail }: AdminShellProps) {
|
||||
const pathname = usePathname();
|
||||
const router = useRouter();
|
||||
|
||||
const handleLogout = async () => {
|
||||
const supabase = createClient();
|
||||
await supabase.auth.signOut();
|
||||
router.push("/login");
|
||||
router.refresh();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen">
|
||||
{/* Sidebar */}
|
||||
<aside className="w-64 min-h-screen bg-dark-light border-r border-dark-border p-6 flex flex-col">
|
||||
{/* Logo */}
|
||||
<Link href="/admin" className="flex items-center gap-2 mb-2">
|
||||
<div className="w-8 h-8 gradient-bg rounded-lg flex items-center justify-center">
|
||||
<span className="text-white font-bold text-sm">H</span>
|
||||
</div>
|
||||
<span className="text-xl font-bold text-white">
|
||||
Hook<span className="gradient-text">Lab</span>
|
||||
</span>
|
||||
</Link>
|
||||
<span className="text-xs text-primary font-medium mb-8 ml-10">Admin</span>
|
||||
|
||||
{/* Navigation */}
|
||||
<nav className="flex-1 space-y-1">
|
||||
{navItems.map((item) => {
|
||||
const isActive = item.exact
|
||||
? pathname === item.href
|
||||
: pathname.startsWith(item.href);
|
||||
|
||||
return (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
className={cn(
|
||||
"flex items-center gap-3 px-4 py-3 rounded-xl text-sm font-medium transition-all",
|
||||
isActive
|
||||
? "bg-primary/10 text-primary"
|
||||
: "text-white/50 hover:text-white hover:bg-white/5"
|
||||
)}
|
||||
>
|
||||
{item.icon}
|
||||
{item.label}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Séparateur */}
|
||||
<div className="border-t border-dark-border my-4" />
|
||||
|
||||
{/* Lien vers le site */}
|
||||
<Link
|
||||
href="/"
|
||||
className="flex items-center gap-3 px-4 py-3 rounded-xl text-sm font-medium text-white/30 hover:text-white hover:bg-white/5 transition-all"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
|
||||
</svg>
|
||||
Voir le site
|
||||
</Link>
|
||||
</nav>
|
||||
|
||||
{/* User info */}
|
||||
<div className="border-t border-dark-border pt-4 mt-4">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="w-9 h-9 gradient-bg rounded-full flex items-center justify-center text-sm font-bold text-white">
|
||||
{(adminName || adminEmail)[0].toUpperCase()}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-white text-sm font-medium truncate">{adminName || "Admin"}</p>
|
||||
<p className="text-white/40 text-xs truncate">{adminEmail}</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="flex items-center gap-2 text-white/40 hover:text-error text-sm transition-colors cursor-pointer w-full"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1" />
|
||||
</svg>
|
||||
Déconnexion
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* Main content */}
|
||||
<main className="flex-1 p-6 md:p-10 overflow-y-auto">
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
40
lib/admin.ts
Normal file
40
lib/admin.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { createClient, createAdminClient } from "@/lib/supabase/server";
|
||||
import type { Profile } from "@/types/database.types";
|
||||
|
||||
// Vérifie que l'utilisateur connecté est admin
|
||||
// Utilisé dans les API routes admin
|
||||
export async function verifyAdmin(): Promise<{ admin: Profile } | { error: string; status: number }> {
|
||||
const supabase = await createClient();
|
||||
|
||||
const {
|
||||
data: { user },
|
||||
error: authError,
|
||||
} = await supabase.auth.getUser();
|
||||
|
||||
if (authError || !user) {
|
||||
return { error: "Non authentifié.", status: 401 };
|
||||
}
|
||||
|
||||
// Utiliser le client admin pour lire le profil (pas de RLS)
|
||||
const adminClient = createAdminClient();
|
||||
const { data: profile, error: profileError } = await adminClient
|
||||
.from("profiles")
|
||||
.select("*")
|
||||
.eq("id", user.id)
|
||||
.single();
|
||||
|
||||
if (profileError || !profile) {
|
||||
return { error: "Profil introuvable.", status: 404 };
|
||||
}
|
||||
|
||||
if (!(profile as Profile).is_admin) {
|
||||
return { error: "Accès refusé.", status: 403 };
|
||||
}
|
||||
|
||||
return { admin: profile as Profile };
|
||||
}
|
||||
|
||||
// Helper pour répondre avec erreur si non-admin
|
||||
export function isAdminError(result: { admin: Profile } | { error: string; status: number }): result is { error: string; status: number } {
|
||||
return "error" in result;
|
||||
}
|
||||
@@ -11,6 +11,7 @@ export type Database = {
|
||||
stripe_customer_id: string | null;
|
||||
subscription_status: "inactive" | "active" | "cancelled" | "paused";
|
||||
subscription_end_date: string | null;
|
||||
is_admin: boolean;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
};
|
||||
@@ -22,6 +23,7 @@ export type Database = {
|
||||
stripe_customer_id?: string | null;
|
||||
subscription_status?: "inactive" | "active" | "cancelled" | "paused";
|
||||
subscription_end_date?: string | null;
|
||||
is_admin?: boolean;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
};
|
||||
@@ -33,6 +35,7 @@ export type Database = {
|
||||
stripe_customer_id?: string | null;
|
||||
subscription_status?: "inactive" | "active" | "cancelled" | "paused";
|
||||
subscription_end_date?: string | null;
|
||||
is_admin?: boolean;
|
||||
updated_at?: string;
|
||||
};
|
||||
};
|
||||
@@ -188,3 +191,5 @@ export type CandidatureInsert = Database["public"]["Tables"]["candidatures"]["In
|
||||
export type Module = Database["public"]["Tables"]["modules"]["Row"];
|
||||
export type UserProgress = Database["public"]["Tables"]["user_progress"]["Row"];
|
||||
export type Payment = Database["public"]["Tables"]["payments"]["Row"];
|
||||
export type ModuleInsert = Database["public"]["Tables"]["modules"]["Insert"];
|
||||
export type ModuleUpdate = Database["public"]["Tables"]["modules"]["Update"];
|
||||
|
||||
Reference in New Issue
Block a user