From c4934f566990a527f3cee202583aa4e3d30f0c18 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 9 Feb 2026 19:39:33 +0000 Subject: [PATCH] feat: add admin panel to manage candidatures and approve with Stripe link - /admin page with secret-key authentication - List all candidatures with details (expandable cards) - Approve: updates status + generates Stripe checkout URL + sends email - Reject: updates status - Checkout URL displayed on screen for manual copy if Resend not configured - Protected by ADMIN_SECRET env var https://claude.ai/code/session_01H2aRGDaKgarPvhay2HxN6Y --- app/admin/page.tsx | 358 ++++++++++++++++++ .../admin/candidatures/[id]/approve/route.ts | 121 ++++++ .../admin/candidatures/[id]/reject/route.ts | 31 ++ app/api/admin/candidatures/route.ts | 29 ++ 4 files changed, 539 insertions(+) create mode 100644 app/admin/page.tsx create mode 100644 app/api/admin/candidatures/[id]/approve/route.ts create mode 100644 app/api/admin/candidatures/[id]/reject/route.ts create mode 100644 app/api/admin/candidatures/route.ts diff --git a/app/admin/page.tsx b/app/admin/page.tsx new file mode 100644 index 0000000..adbe624 --- /dev/null +++ b/app/admin/page.tsx @@ -0,0 +1,358 @@ +"use client"; + +import { useState, useEffect, useCallback } from "react"; +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 default function AdminPage() { + const [secret, setSecret] = useState(""); + const [authenticated, setAuthenticated] = useState(false); + const [candidatures, setCandidatures] = useState([]); + const [loading, setLoading] = useState(false); + const [actionLoading, setActionLoading] = useState(null); + const [checkoutUrls, setCheckoutUrls] = useState>({}); + const [expandedId, setExpandedId] = useState(null); + const [error, setError] = useState(null); + + 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]); + + 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); + } + }, []); + + useEffect(() => { + if (secret && !authenticated) { + fetchCandidatures(); + } + }, [secret, authenticated, fetchCandidatures]); + + 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 = { + pending: "bg-warning/10 text-warning", + approved: "bg-success/10 text-success", + rejected: "bg-error/10 text-error", + }; + + const statusLabels: Record = { + pending: "En attente", + approved: "Approuvée", + rejected: "Rejetée", + }; + + // Login form + if (!authenticated) { + return ( +
+
+

Admin HookLab

+
{ + e.preventDefault(); + fetchCandidatures(); + }} + > + 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 && ( +

{error}

+ )} + +
+
+
+ ); + } + + const pending = candidatures.filter((c) => c.status === "pending"); + const approved = candidatures.filter((c) => c.status === "approved"); + const rejected = candidatures.filter((c) => c.status === "rejected"); + + return ( +
+
+ {/* Header */} +
+
+ + ← Retour au site + +

Gestion des candidatures

+
+
+
+ {pending.length} en attente + {approved.length} approuvées + {rejected.length} rejetées +
+ +
+
+ + {error && ( +
+

{error}

+
+ )} + + {/* Liste des candidatures */} + {candidatures.length === 0 ? ( +
+

Aucune candidature pour le moment.

+
+ ) : ( +
+ {candidatures.map((c) => ( +
+ {/* Header row */} +
setExpandedId(expandedId === c.id ? null : c.id)} + > +
+
+ {c.firstname.charAt(0).toUpperCase()} +
+
+

{c.firstname}

+

{c.email}

+
+
+
+ + {new Date(c.created_at).toLocaleDateString("fr-FR", { + day: "numeric", + month: "short", + year: "numeric", + hour: "2-digit", + minute: "2-digit", + })} + + + {statusLabels[c.status]} + + + + +
+
+ + {/* Expanded details */} + {expandedId === c.id && ( +
+
+
+

Téléphone

+

{c.phone}

+
+
+

Âge

+

{c.age} ans

+
+
+

Profil

+

{c.persona}

+
+
+

Expérience

+

{c.experience}

+
+
+

Temps disponible

+

{c.time_daily}

+
+
+

Disponibilité

+

{c.availability}

+
+
+

Début souhaité

+

{c.start_date}

+
+
+

Objectif mensuel

+

{c.monthly_goal}

+
+ {c.tiktok_username && ( +
+

TikTok

+

{c.tiktok_username}

+
+ )} +
+ +
+
+

Motivation

+

{c.motivation}

+
+
+

Plus grande peur

+

{c.biggest_fear}

+
+
+ + {/* Checkout URL si disponible */} + {checkoutUrls[c.id] && ( +
+

Lien de paiement généré :

+
+ + +
+
+ )} + + {/* Actions */} + {c.status === "pending" && ( +
+ + +
+ )} + + {c.status === "approved" && !checkoutUrls[c.id] && ( +
+ +
+ )} +
+ )} +
+ ))} +
+ )} +
+
+ ); +} diff --git a/app/api/admin/candidatures/[id]/approve/route.ts b/app/api/admin/candidatures/[id]/approve/route.ts new file mode 100644 index 0000000..005328b --- /dev/null +++ b/app/api/admin/candidatures/[id]/approve/route.ts @@ -0,0 +1,121 @@ +import { NextResponse } from "next/server"; +import { createAdminClient } from "@/lib/supabase/server"; +import { stripe } from "@/lib/stripe/client"; +import { getBaseUrl } from "@/lib/utils"; + +export const runtime = "nodejs"; + +// POST /api/admin/candidatures/[id]/approve - Approuver une candidature +export async function POST( + 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 supabase = createAdminClient(); + + // Récupérer la candidature + const { data: candidature, error: fetchError } = await supabase + .from("candidatures") + .select("*") + .eq("id", id) + .single(); + + if (fetchError || !candidature) { + return NextResponse.json({ error: "Candidature introuvable." }, { status: 404 }); + } + + // Mettre à jour le statut + const { error: updateError } = await supabase + .from("candidatures") + .update({ status: "approved" } as never) + .eq("id", id); + + if (updateError) { + return NextResponse.json({ error: updateError.message }, { status: 500 }); + } + + // Générer le lien de paiement Stripe + let checkoutUrl: string | null = null; + if (process.env.STRIPE_SECRET_KEY && process.env.STRIPE_PRICE_ID) { + try { + const baseUrl = getBaseUrl(); + const email = (candidature as Record).email as string; + const candidatureId = (candidature as Record).id as string; + + // Créer ou récupérer le customer Stripe + const customers = await stripe.customers.list({ email, limit: 1 }); + let customerId: string; + if (customers.data.length > 0) { + customerId = customers.data[0].id; + } else { + const customer = await stripe.customers.create({ + email, + metadata: { candidature_id: candidatureId }, + }); + customerId = customer.id; + } + + const session = await stripe.checkout.sessions.create({ + customer: customerId, + mode: "subscription", + payment_method_types: ["card"], + line_items: [{ price: process.env.STRIPE_PRICE_ID, quantity: 1 }], + metadata: { candidature_id: candidatureId, email }, + success_url: `${baseUrl}/merci?session_id={CHECKOUT_SESSION_ID}`, + cancel_url: `${baseUrl}/candidature`, + allow_promotion_codes: true, + billing_address_collection: "required", + }); + + checkoutUrl = session.url; + + // Envoyer le lien par email si Resend est configuré + if (process.env.RESEND_API_KEY && process.env.RESEND_API_KEY !== "re_your-api-key") { + try { + const { Resend } = await import("resend"); + const resend = new Resend(process.env.RESEND_API_KEY); + const firstname = (candidature as Record).firstname as string; + + await resend.emails.send({ + from: "HookLab ", + to: email, + subject: "Ta candidature HookLab est acceptée !", + html: ` +
+

Félicitations ${firstname} !

+

Ta candidature au programme HookLab a été acceptée !

+

Pour finaliser ton inscription et accéder au programme, clique sur le bouton ci-dessous :

+ +

Le paiement est sécurisé via Stripe. Tu peux payer en 2 mensualités de 490€.

+

À très vite,
L'équipe HookLab

+
+ `, + }); + } catch (emailError) { + console.error("Erreur envoi email approbation:", emailError); + } + } + } catch (stripeError) { + console.error("Erreur Stripe:", stripeError); + } + } + + return NextResponse.json({ + success: true, + checkoutUrl, + message: checkoutUrl + ? "Candidature approuvée. Lien de paiement généré." + : "Candidature approuvée. Stripe non configuré, pas de lien de paiement.", + }); +} diff --git a/app/api/admin/candidatures/[id]/reject/route.ts b/app/api/admin/candidatures/[id]/reject/route.ts new file mode 100644 index 0000000..63eedcd --- /dev/null +++ b/app/api/admin/candidatures/[id]/reject/route.ts @@ -0,0 +1,31 @@ +import { NextResponse } from "next/server"; +import { createAdminClient } from "@/lib/supabase/server"; + +export const runtime = "nodejs"; + +// POST /api/admin/candidatures/[id]/reject - Rejeter une candidature +export async function POST( + 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 supabase = createAdminClient(); + + const { error } = await supabase + .from("candidatures") + .update({ status: "rejected" } as never) + .eq("id", id); + + if (error) { + return NextResponse.json({ error: error.message }, { status: 500 }); + } + + return NextResponse.json({ success: true, message: "Candidature rejetée." }); +} diff --git a/app/api/admin/candidatures/route.ts b/app/api/admin/candidatures/route.ts new file mode 100644 index 0000000..64ec502 --- /dev/null +++ b/app/api/admin/candidatures/route.ts @@ -0,0 +1,29 @@ +import { NextResponse } from "next/server"; +import { createAdminClient } from "@/lib/supabase/server"; + +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 }); + } + + const supabase = createAdminClient(); + + const { data, error } = await supabase + .from("candidatures") + .select("*") + .order("created_at", { ascending: false }); + + if (error) { + console.error("Erreur récupération candidatures:", error); + return NextResponse.json({ error: error.message }, { status: 500 }); + } + + return NextResponse.json({ candidatures: data }); +}