V2 + V3 : rôles, points, journal, podium et recadrage photo

V2 — authentification et administration :
- Authentification par email + mot de passe (email confirmé, un compte
  par email), abandon de l'email interne dérivé du pseudo.
- Rôles public/judge sur profiles, section Administration (juges
  uniquement, vérifiée côté serveur) pour gérer les membres.
- Verrou de pseudo : modifiable une fois par son propriétaire puis figé,
  contournable par un juge.
- RLS étendue par un trigger BEFORE UPDATE (enforce_profile_update) pour
  verrouiller les colonnes sensibles (role, points, pseudo_locked, pseudo
  figé) — la RLS seule ne peut pas exprimer une règle par colonne.

V3 — points, journal, podium, progression, photo :
- RPC award_points() (SECURITY DEFINER) : seul point d'écriture de la
  colonne points, vérifie le rôle juge côté serveur, delta signé sans
  plancher à 0, trace chaque opération dans points_log.
- Leaderboard temps réel (Supabase Realtime) avec podium top 3
  (égalités gérées), flèches de progression (previous_rank), et
  contrôles de points juges avec confirmation explicite (plus de
  debounce auto) avant envoi.
- Page /journal ("le crieur") : fil live et public des attributions de
  points.
- Recadrage photo carré + compression client (react-easy-crop + canvas)
  au signup et sur le profil.
- Passe de polish visuel : cartes/boutons cohérents, lien actif dans la
  nav, podium retravaillé.

schema.sql, README.md et CLAUDE.md mis à jour en conséquence (schéma
idempotent, instructions SMTP/rôles/migration, conventions RLS+trigger
documentées pour les futures colonnes sensibles).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Valentin ROBIN
2026-07-11 16:14:10 +02:00
parent 87cd286e5a
commit abbd079cd2
27 changed files with 1528 additions and 354 deletions
+146
View File
@@ -0,0 +1,146 @@
"use client";
import { useState } from "react";
import { createClient } from "@/lib/supabase/client";
import { Avatar } from "@/components/avatar";
type Role = "public" | "judge";
type Member = {
id: string;
pseudo: string;
role: string;
pseudo_locked: boolean;
avatar_url: string | null;
};
function MemberRow({ member, isSelf }: { member: Member; isSelf: boolean }) {
const [savedPseudo, setSavedPseudo] = useState(member.pseudo);
const [savedRole, setSavedRole] = useState<Role>(member.role === "judge" ? "judge" : "public");
const [pseudo, setPseudo] = useState(member.pseudo);
const [role, setRole] = useState<Role>(member.role === "judge" ? "judge" : "public");
const [pseudoLocked, setPseudoLocked] = useState(member.pseudo_locked);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState(false);
const dirty = pseudo.trim() !== savedPseudo || role !== savedRole;
async function handleSave() {
setError(null);
setSuccess(false);
const trimmed = pseudo.trim();
if (!trimmed) {
setError("Pseudo invalide.");
return;
}
setSaving(true);
const supabase = createClient();
const { error: updateError } = await supabase
.from("profiles")
.update({ pseudo: trimmed, role })
.eq("id", member.id);
setSaving(false);
if (updateError) {
setError(
updateError.message.includes("duplicate key")
? "Ce pseudo est déjà pris."
: "Une erreur est survenue.",
);
return;
}
setSavedPseudo(trimmed);
setSavedRole(role);
setSuccess(true);
}
async function handleUnlock() {
setError(null);
setSuccess(false);
setSaving(true);
const supabase = createClient();
const { error: updateError } = await supabase
.from("profiles")
.update({ pseudo_locked: false })
.eq("id", member.id);
setSaving(false);
if (updateError) {
setError("Une erreur est survenue.");
return;
}
setPseudoLocked(false);
}
return (
<li className="flex flex-col gap-2 rounded-lg border border-navy/10 bg-white px-4 py-3 shadow-sm sm:flex-row sm:items-center sm:gap-4">
<Avatar pseudo={member.pseudo} avatarUrl={member.avatar_url} size="sm" />
<input
type="text"
value={pseudo}
onChange={(event) => setPseudo(event.target.value)}
className="flex-1 rounded-md border border-navy/20 bg-white px-2 py-1 text-sm outline-none focus:border-gold"
/>
<select
value={role}
onChange={(event) => setRole(event.target.value as Role)}
disabled={isSelf}
className="rounded-md border border-navy/20 bg-white px-2 py-1 text-sm outline-none focus:border-gold disabled:opacity-50"
>
<option value="public">public</option>
<option value="judge">judge</option>
</select>
{pseudoLocked ? (
<button
type="button"
onClick={handleUnlock}
disabled={saving}
className="whitespace-nowrap rounded-md border border-navy/20 px-2 py-1 text-xs text-navy hover:bg-navy/5 disabled:opacity-50"
>
Déverrouiller le pseudo
</button>
) : (
<span className="whitespace-nowrap text-xs text-ink/50">pseudo libre</span>
)}
<button
type="button"
onClick={handleSave}
disabled={saving || !dirty}
className="whitespace-nowrap rounded-md bg-navy px-3 py-1.5 text-sm font-medium text-ivory transition hover:bg-navy/90 disabled:opacity-40"
>
{saving ? "…" : "Enregistrer"}
</button>
{error && <p className="text-xs text-red-700">{error}</p>}
{success && !dirty && <p className="text-xs text-green-700">OK</p>}
</li>
);
}
export function AdminMembersTable({
members,
currentUserId,
}: {
members: Member[];
currentUserId: string;
}) {
if (members.length === 0) {
return <p className="text-sm text-ink/70">Aucun membre inscrit.</p>;
}
return (
<ul className="flex flex-col gap-2">
{members.map((member) => (
<MemberRow key={member.id} member={member} isSelf={member.id === currentUserId} />
))}
</ul>
);
}
+42
View File
@@ -0,0 +1,42 @@
import { redirect } from "next/navigation";
import { createClient } from "@/lib/supabase/server";
import { AdminMembersTable } from "./admin-members-table";
import { ResetRoundButton } from "./reset-round-button";
export default async function AdminPage() {
const supabase = await createClient();
const {
data: { user },
} = await supabase.auth.getUser();
if (!user) {
redirect("/login");
}
const { data: callerProfile } = await supabase
.from("profiles")
.select("role")
.eq("id", user.id)
.single();
// Vérification côté serveur : un "public" ne doit jamais pouvoir
// atteindre cette page, même en tapant l'URL directement.
if (callerProfile?.role !== "judge") {
redirect("/leaderboard");
}
const { data: members } = await supabase
.from("profiles")
.select("id, pseudo, role, pseudo_locked, avatar_url")
.order("pseudo", { ascending: true });
return (
<div className="mx-auto w-full max-w-3xl px-4 py-8">
<div className="mb-6 flex flex-wrap items-center justify-between gap-3">
<h1 className="text-2xl font-semibold text-navy">Administration</h1>
<ResetRoundButton />
</div>
<AdminMembersTable members={members ?? []} currentUserId={user.id} />
</div>
);
}
+29
View File
@@ -0,0 +1,29 @@
"use client";
import { useState } from "react";
import { createClient } from "@/lib/supabase/client";
export function ResetRoundButton() {
const [state, setState] = useState<"idle" | "loading" | "done">("idle");
async function handleClick() {
setState("loading");
const supabase = createClient();
const { error } = await supabase.rpc("reset_rank_reference");
setState(error ? "idle" : "done");
if (!error) {
setTimeout(() => setState("idle"), 2000);
}
}
return (
<button
type="button"
onClick={handleClick}
disabled={state === "loading"}
className="rounded-md border border-navy/20 bg-white px-3 py-1.5 text-sm font-medium text-navy shadow-sm transition hover:bg-navy/5 disabled:opacity-50"
>
{state === "loading" ? "…" : state === "done" ? "Repère mis à jour ✓" : "Nouveau round (réinitialiser le repère)"}
</button>
);
}
+13
View File
@@ -31,3 +31,16 @@ body {
outline: 2px solid var(--color-gold);
outline-offset: 2px;
}
@keyframes points-flash {
0% {
background-color: color-mix(in srgb, var(--color-gold) 55%, transparent);
}
100% {
background-color: transparent;
}
}
.animate-points-flash {
animation: points-flash 900ms ease-out;
}
+78
View File
@@ -0,0 +1,78 @@
"use client";
import { useEffect, useState } from "react";
import { createClient } from "@/lib/supabase/client";
type Entry = {
id: number;
target_id: string;
judge_id: string;
delta: number;
reason: string | null;
created_at: string;
};
function formatTime(iso: string): string {
return new Date(iso).toLocaleString("fr-FR", {
dateStyle: "short",
timeStyle: "short",
timeZone: "Europe/Paris",
});
}
export function JournalView({
initialEntries,
pseudoById,
}: {
initialEntries: Entry[];
pseudoById: Record<string, string>;
}) {
const [entries, setEntries] = useState<Entry[]>(initialEntries);
useEffect(() => {
const supabase = createClient();
const channel = supabase
.channel("journal-points-log")
.on(
"postgres_changes",
{ event: "INSERT", schema: "public", table: "points_log" },
(payload) => {
const row = payload.new as Entry;
setEntries((current) => [row, ...current]);
},
)
.subscribe();
return () => {
supabase.removeChannel(channel);
};
}, []);
if (entries.length === 0) {
return <p className="text-sm text-ink/70">Aucun événement pour l&apos;instant.</p>;
}
return (
<ol className="flex flex-col gap-2">
{entries.map((entry) => {
const judgePseudo = pseudoById[entry.judge_id] ?? "Un juge";
const targetPseudo = pseudoById[entry.target_id] ?? "un membre";
const sign = entry.delta > 0 ? `+${entry.delta}` : `${entry.delta}`;
return (
<li
key={entry.id}
className="rounded-lg border border-navy/10 bg-white px-4 py-3 text-sm shadow-sm"
>
<span className="font-medium text-navy">{judgePseudo}</span> a donné{" "}
<span className={entry.delta >= 0 ? "font-semibold text-green-700" : "font-semibold text-red-700"}>
{sign}
</span>{" "}
à <span className="font-medium text-navy">{targetPseudo}</span>
{entry.reason && <span className="text-ink/70"> {entry.reason}</span>}
<div className="mt-1 text-xs text-ink/50">{formatTime(entry.created_at)}</div>
</li>
);
})}
</ol>
);
}
+31
View File
@@ -0,0 +1,31 @@
import { redirect } from "next/navigation";
import { createClient } from "@/lib/supabase/server";
import { JournalView } from "./journal-view";
export default async function JournalPage() {
const supabase = await createClient();
const {
data: { user },
} = await supabase.auth.getUser();
if (!user) {
redirect("/login");
}
const { data: profiles } = await supabase.from("profiles").select("id, pseudo");
const pseudoById = Object.fromEntries((profiles ?? []).map((p) => [p.id, p.pseudo]));
const { data: entries } = await supabase
.from("points_log")
.select("id, target_id, judge_id, delta, reason, created_at")
.order("created_at", { ascending: false })
.limit(200);
return (
<div className="mx-auto w-full max-w-2xl px-4 py-8">
<h1 className="mb-2 text-2xl font-semibold text-navy">Le crieur</h1>
<p className="mb-6 text-sm text-ink/60">Journal public de toutes les attributions de points.</p>
<JournalView initialEntries={entries ?? []} pseudoById={pseudoById} />
</div>
);
}
+11 -1
View File
@@ -29,13 +29,23 @@ export default async function RootLayout({
data: { user },
} = await supabase.auth.getUser();
let isJudge = false;
if (user) {
const { data: profile } = await supabase
.from("profiles")
.select("role")
.eq("id", user.id)
.single();
isJudge = profile?.role === "judge";
}
return (
<html
lang="fr"
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
>
<body className="min-h-full flex flex-col bg-ivory text-ink">
{user && <NavBar />}
{user && <NavBar isJudge={isJudge} />}
<main className="flex flex-1 flex-col">{children}</main>
</body>
</html>
+122
View File
@@ -0,0 +1,122 @@
"use client";
import { useEffect, useMemo, useRef, useState } from "react";
import { createClient } from "@/lib/supabase/client";
import { Avatar } from "@/components/avatar";
import { Podium } from "@/components/podium";
import { JudgePointControls } from "@/components/judge-point-controls";
import { computeRanks, computeProgress } from "@/lib/ranking";
type Profile = {
id: string;
pseudo: string;
avatar_url: string | null;
points: number;
previous_rank: number | null;
};
function ProgressBadge({ direction, amount }: { direction: string; amount: number }) {
if (direction === "up") {
return <span className="text-xs font-semibold text-green-700">{amount}</span>;
}
if (direction === "down") {
return <span className="text-xs font-semibold text-red-700">{amount}</span>;
}
if (direction === "same") {
return <span className="text-xs text-ink/40">=</span>;
}
return null;
}
export function LeaderboardView({
initialProfiles,
isJudge,
}: {
initialProfiles: Profile[];
isJudge: boolean;
}) {
const [profiles, setProfiles] = useState<Profile[]>(initialProfiles);
const [flashingId, setFlashingId] = useState<string | null>(null);
const pointsRef = useRef(new Map(initialProfiles.map((p) => [p.id, p.points])));
function applyOptimisticDelta(id: string, delta: number) {
setProfiles((current) =>
current.map((p) => (p.id === id ? { ...p, points: p.points + delta } : p)),
);
}
useEffect(() => {
const supabase = createClient();
const channel = supabase
.channel("leaderboard-profiles")
.on(
"postgres_changes",
{ event: "*", schema: "public", table: "profiles" },
(payload) => {
if (payload.eventType === "DELETE") return;
const row = payload.new as Profile;
setProfiles((current) => {
const exists = current.some((p) => p.id === row.id);
return exists
? current.map((p) => (p.id === row.id ? { ...p, ...row } : p))
: [...current, row];
});
const previousPoints = pointsRef.current.get(row.id);
if (previousPoints !== undefined && previousPoints !== row.points) {
setFlashingId(row.id);
setTimeout(() => setFlashingId((current) => (current === row.id ? null : current)), 900);
}
pointsRef.current.set(row.id, row.points);
},
)
.subscribe();
return () => {
supabase.removeChannel(channel);
};
}, []);
const ranked = useMemo(() => computeRanks(profiles), [profiles]);
const rest = ranked.filter((p) => p.rank > 3);
if (profiles.length === 0) {
return <p className="text-sm text-ink/70">Personne n&apos;est encore inscrit.</p>;
}
return (
<div>
<Podium
profiles={ranked.filter((p) => p.rank <= 3)}
isJudge={isJudge}
onApplyDelta={applyOptimisticDelta}
/>
<ol className="flex flex-col gap-2">
{rest.map((profile) => {
const progress = computeProgress(profile.rank, profile.previous_rank);
return (
<li
key={profile.id}
className={`flex flex-wrap items-center gap-3 rounded-lg border border-navy/10 bg-white px-4 py-3 shadow-sm transition-colors ${
flashingId === profile.id ? "animate-points-flash" : ""
}`}
>
<span className="w-6 shrink-0 text-center font-semibold text-navy/60">
{profile.rank}
</span>
<Avatar pseudo={profile.pseudo} avatarUrl={profile.avatar_url} size="sm" />
<span className="flex-1 truncate font-medium">{profile.pseudo}</span>
<ProgressBadge direction={progress.direction} amount={progress.amount} />
<span className="font-semibold text-navy">{profile.points}</span>
{isJudge && (
<JudgePointControls memberId={profile.id} onApplyDelta={applyOptimisticDelta} />
)}
</li>
);
})}
</ol>
</div>
);
}
+14 -35
View File
@@ -1,13 +1,24 @@
import { redirect } from "next/navigation";
import { createClient } from "@/lib/supabase/server";
import { Avatar } from "@/components/avatar";
import { LeaderboardView } from "./leaderboard-view";
export default async function LeaderboardPage() {
const supabase = await createClient();
const {
data: { user },
} = await supabase.auth.getUser();
if (!user) {
redirect("/login");
}
const { data: profiles, error } = await supabase
.from("profiles")
.select("id, pseudo, avatar_url, points")
.select("id, pseudo, avatar_url, points, previous_rank, role")
.order("points", { ascending: false });
const isJudge = profiles?.find((p) => p.id === user.id)?.role === "judge";
return (
<div className="mx-auto w-full max-w-2xl px-4 py-8">
<h1 className="mb-6 text-2xl font-semibold text-navy">Leaderboard</h1>
@@ -18,39 +29,7 @@ export default async function LeaderboardPage() {
</p>
)}
{!error && profiles?.length === 0 && (
<p className="text-sm text-ink/70">Personne n&apos;est encore inscrit.</p>
)}
{!error && profiles && profiles.length > 0 && (
<ol className="flex flex-col gap-2">
{profiles.map((profile, index) => {
const rank = index + 1;
const isFirst = rank === 1;
return (
<li
key={profile.id}
className={`flex items-center gap-4 rounded-lg border px-4 py-3 ${
isFirst
? "border-gold bg-gold/10"
: "border-navy/10 bg-white"
}`}
>
<span
className={`w-6 shrink-0 text-center font-semibold ${
isFirst ? "text-gold" : "text-navy/60"
}`}
>
{rank}
</span>
<Avatar pseudo={profile.pseudo} avatarUrl={profile.avatar_url} size="sm" />
<span className="flex-1 truncate font-medium">{profile.pseudo}</span>
<span className="font-semibold text-navy">{profile.points}</span>
</li>
);
})}
</ol>
)}
{!error && <LeaderboardView initialProfiles={profiles ?? []} isJudge={isJudge} />}
</div>
);
}
+19 -16
View File
@@ -1,14 +1,18 @@
"use client";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useState, type FormEvent } from "react";
import { createClient } from "@/lib/supabase/client";
import { internalEmail } from "@/lib/auth";
function mapSignInError(message: string): string {
if (message.toLowerCase().includes("email not confirmed")) {
return "Confirme ton adresse email avant de te connecter (vérifie ta boîte mail).";
}
return "Email ou mot de passe incorrect.";
}
export default function LoginPage() {
const router = useRouter();
const [pseudo, setPseudo] = useState("");
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
@@ -20,40 +24,39 @@ export default function LoginPage() {
const supabase = createClient();
const { error: signInError } = await supabase.auth.signInWithPassword({
email: internalEmail(pseudo),
email,
password,
});
setLoading(false);
if (signInError) {
setError("Pseudo ou mot de passe incorrect.");
setError(mapSignInError(signInError.message));
return;
}
router.push("/leaderboard");
router.refresh();
window.location.href = "/leaderboard";
}
return (
<div className="flex flex-1 items-center justify-center px-4 py-12">
<div className="w-full max-w-sm">
<div className="w-full max-w-sm rounded-xl border border-navy/10 bg-white p-6 shadow-sm sm:p-8">
<h1 className="mb-6 text-center text-2xl font-semibold text-navy">
Connexion
</h1>
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
<div className="flex flex-col gap-1">
<label htmlFor="pseudo" className="text-sm font-medium">
Pseudo
<label htmlFor="email" className="text-sm font-medium">
Email
</label>
<input
id="pseudo"
type="text"
autoComplete="username"
id="email"
type="email"
autoComplete="email"
required
value={pseudo}
onChange={(event) => setPseudo(event.target.value)}
value={email}
onChange={(event) => setEmail(event.target.value)}
className="rounded-md border border-navy/20 bg-white px-3 py-2 text-ink outline-none focus:border-gold"
/>
</div>
+11 -6
View File
@@ -14,18 +14,23 @@ export default async function ProfilePage() {
const { data: profile } = await supabase
.from("profiles")
.select("pseudo, avatar_url")
.select("pseudo, avatar_url, pseudo_locked, role")
.eq("id", user.id)
.single();
return (
<div className="mx-auto w-full max-w-sm px-4 py-8">
<h1 className="mb-6 text-2xl font-semibold text-navy">Mon profil</h1>
<ProfileForm
userId={user.id}
initialPseudo={profile?.pseudo ?? ""}
initialAvatarUrl={profile?.avatar_url ?? null}
/>
<div className="rounded-xl border border-navy/10 bg-white p-6 shadow-sm sm:p-8">
<p className="mb-4 text-sm text-ink/60">{user.email}</p>
<ProfileForm
userId={user.id}
isJudge={profile?.role === "judge"}
initialPseudo={profile?.pseudo ?? ""}
initialAvatarUrl={profile?.avatar_url ?? null}
initialPseudoLocked={profile?.pseudo_locked ?? false}
/>
</div>
</div>
);
}
+38 -22
View File
@@ -3,42 +3,57 @@
import { useRouter } from "next/navigation";
import { useState, type FormEvent } from "react";
import { createClient } from "@/lib/supabase/client";
import { slugify } from "@/lib/auth";
import { Avatar } from "@/components/avatar";
import { AvatarPicker } from "@/components/avatar-picker";
function mapError(message: string): string {
if (message.includes("duplicate key")) {
return "Ce pseudo est déjà pris.";
}
if (message.includes("pseudo is locked")) {
return "Ton pseudo est déjà figé.";
}
return "Une erreur est survenue, réessaie.";
}
export function ProfileForm({
userId,
isJudge,
initialPseudo,
initialAvatarUrl,
initialPseudoLocked,
}: {
userId: string;
isJudge: boolean;
initialPseudo: string;
initialAvatarUrl: string | null;
initialPseudoLocked: boolean;
}) {
const router = useRouter();
const [pseudo, setPseudo] = useState(initialPseudo);
const [pseudoLocked, setPseudoLocked] = useState(initialPseudoLocked);
const [avatarUrl, setAvatarUrl] = useState(initialAvatarUrl);
const [avatarFile, setAvatarFile] = useState<File | null>(null);
const [avatarBlob, setAvatarBlob] = useState<Blob | null>(null);
const [avatarPreview, setAvatarPreview] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState(false);
const [loading, setLoading] = useState(false);
const previewUrl = avatarFile ? URL.createObjectURL(avatarFile) : avatarUrl;
const pseudoChanged = pseudo.trim() !== initialPseudo;
const canEditPseudo = !pseudoLocked || isJudge;
function handleCropped(blob: Blob) {
setAvatarBlob(blob);
setAvatarPreview(URL.createObjectURL(blob));
}
async function handleSubmit(event: FormEvent) {
event.preventDefault();
setError(null);
setSuccess(false);
const slug = slugify(pseudo);
if (!slug) {
const trimmedPseudo = pseudo.trim();
if (!trimmedPseudo) {
setError("Choisis un pseudo valide.");
return;
}
@@ -48,12 +63,11 @@ export function ProfileForm({
let nextAvatarUrl = avatarUrl;
if (avatarFile) {
const ext = avatarFile.name.split(".").pop() ?? "jpg";
const path = `${userId}.${ext}`;
if (avatarBlob) {
const path = `${userId}.webp`;
const { error: uploadError } = await supabase.storage
.from("avatars")
.upload(path, avatarFile, { upsert: true });
.upload(path, avatarBlob, { upsert: true, contentType: "image/webp" });
if (uploadError) {
setLoading(false);
@@ -67,7 +81,7 @@ export function ProfileForm({
const { error: updateError } = await supabase
.from("profiles")
.update({ pseudo, slug, avatar_url: nextAvatarUrl })
.update({ pseudo: trimmedPseudo, avatar_url: nextAvatarUrl })
.eq("id", userId);
setLoading(false);
@@ -78,24 +92,20 @@ export function ProfileForm({
}
setAvatarUrl(nextAvatarUrl);
setAvatarFile(null);
setAvatarBlob(null);
setAvatarPreview(null);
setSuccess(true);
if (pseudoChanged && !isJudge) {
setPseudoLocked(true);
}
router.refresh();
}
return (
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
<div className="flex flex-col items-center gap-3">
<Avatar pseudo={pseudo || "?"} avatarUrl={previewUrl ?? null} size="lg" />
<label className="text-sm">
<span className="sr-only">Changer la photo</span>
<input
type="file"
accept="image/*"
onChange={(event) => setAvatarFile(event.target.files?.[0] ?? null)}
className="text-sm file:mr-3 file:rounded-md file:border-0 file:bg-navy file:px-3 file:py-1.5 file:text-ivory"
/>
</label>
<Avatar pseudo={pseudo || "?"} avatarUrl={avatarPreview ?? avatarUrl} size="lg" />
<AvatarPicker label="Changer la photo" onCropped={handleCropped} />
</div>
<div className="flex flex-col gap-1">
@@ -106,10 +116,16 @@ export function ProfileForm({
id="pseudo"
type="text"
required
disabled={!canEditPseudo}
value={pseudo}
onChange={(event) => setPseudo(event.target.value)}
className="rounded-md border border-navy/20 bg-white px-3 py-2 text-ink outline-none focus:border-gold"
className="rounded-md border border-navy/20 bg-white px-3 py-2 text-ink outline-none focus:border-gold disabled:bg-navy/5 disabled:text-ink/50"
/>
{!canEditPseudo && (
<p className="text-xs text-ink/60">
Pseudo figé contacte un juge pour le changer.
</p>
)}
</div>
{error && (
+87 -42
View File
@@ -1,32 +1,43 @@
"use client";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useState, type FormEvent } from "react";
import { createClient } from "@/lib/supabase/client";
import { internalEmail, slugify } from "@/lib/auth";
import { Avatar } from "@/components/avatar";
import { AvatarPicker } from "@/components/avatar-picker";
function mapError(message: string): string {
if (message.includes("duplicate key") || message.includes("already registered")) {
return "Ce pseudo est déjà pris.";
function mapSignUpError(message: string): string {
const lower = message.toLowerCase();
if (lower.includes("already registered")) {
return "Un compte existe déjà avec cet email.";
}
if (lower.includes("rate limit")) {
return "Trop d'emails envoyés récemment (limite Supabase), réessaie dans quelques minutes.";
}
return "Une erreur est survenue, réessaie.";
}
export default function SignupPage() {
const router = useRouter();
const [email, setEmail] = useState("");
const [pseudo, setPseudo] = useState("");
const [password, setPassword] = useState("");
const [avatarFile, setAvatarFile] = useState<File | null>(null);
const [avatarBlob, setAvatarBlob] = useState<Blob | null>(null);
const [avatarPreview, setAvatarPreview] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [confirmationSent, setConfirmationSent] = useState(false);
function handleCropped(blob: Blob) {
setAvatarBlob(blob);
setAvatarPreview(URL.createObjectURL(blob));
}
async function handleSubmit(event: FormEvent) {
event.preventDefault();
setError(null);
const slug = slugify(pseudo);
if (!slug) {
const trimmedPseudo = pseudo.trim();
if (!trimmedPseudo) {
setError("Choisis un pseudo valide.");
return;
}
@@ -38,59 +49,100 @@ export default function SignupPage() {
setLoading(true);
const supabase = createClient();
const { data: pseudoTaken, error: pseudoCheckError } = await supabase.rpc(
"is_pseudo_taken",
{ p_pseudo: trimmedPseudo },
);
if (!pseudoCheckError && pseudoTaken) {
setLoading(false);
setError("Ce pseudo est déjà pris.");
return;
}
const { data: signUpData, error: signUpError } = await supabase.auth.signUp({
email: internalEmail(pseudo),
email,
password,
options: { data: { pseudo: trimmedPseudo } },
});
if (signUpError || !signUpData.user) {
setLoading(false);
setError(mapError(signUpError?.message ?? ""));
setError(mapSignUpError(signUpError?.message ?? ""));
return;
}
const userId = signUpData.user.id;
let avatarUrl: string | null = null;
if (avatarFile) {
const ext = avatarFile.name.split(".").pop() ?? "jpg";
const path = `${userId}.${ext}`;
// Sans session active (confirmation email en attente), impossible
// d'uploader la photo : la policy Storage exige un utilisateur
// authentifié. On l'ajoutera depuis /profile après la 1ère connexion.
if (signUpData.session && avatarBlob) {
const path = `${signUpData.user.id}.webp`;
const { error: uploadError } = await supabase.storage
.from("avatars")
.upload(path, avatarFile, { upsert: true });
.upload(path, avatarBlob, { upsert: true, contentType: "image/webp" });
if (!uploadError) {
const { data: publicUrlData } = supabase.storage.from("avatars").getPublicUrl(path);
avatarUrl = publicUrlData.publicUrl;
await supabase
.from("profiles")
.update({ avatar_url: publicUrlData.publicUrl })
.eq("id", signUpData.user.id);
}
}
const { error: profileError } = await supabase.from("profiles").insert({
id: userId,
pseudo,
slug,
avatar_url: avatarUrl,
});
setLoading(false);
if (profileError) {
setError(mapError(profileError.message));
if (!signUpData.session) {
setConfirmationSent(true);
return;
}
router.push("/leaderboard");
router.refresh();
window.location.href = "/leaderboard";
}
if (confirmationSent) {
return (
<div className="flex flex-1 items-center justify-center px-4 py-12">
<div className="w-full max-w-sm rounded-xl border border-navy/10 bg-white p-6 text-center shadow-sm sm:p-8">
<h1 className="mb-4 text-2xl font-semibold text-navy">Compte créé !</h1>
<p className="text-sm text-ink/80">
Vérifie ta boîte mail (et les spams) pour confirmer ton adresse, puis
connecte-toi. Tu pourras ajouter ta photo de profil ensuite depuis ton
profil.
</p>
<Link
href="/login"
className="mt-6 inline-block font-medium text-navy underline hover:text-gold"
>
Aller à la connexion
</Link>
</div>
</div>
);
}
return (
<div className="flex flex-1 items-center justify-center px-4 py-12">
<div className="w-full max-w-sm">
<div className="w-full max-w-sm rounded-xl border border-navy/10 bg-white p-6 shadow-sm sm:p-8">
<h1 className="mb-6 text-center text-2xl font-semibold text-navy">
Créer un compte
</h1>
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
<div className="flex flex-col gap-1">
<label htmlFor="email" className="text-sm font-medium">
Email
</label>
<input
id="email"
type="email"
autoComplete="email"
required
value={email}
onChange={(event) => setEmail(event.target.value)}
className="rounded-md border border-navy/20 bg-white px-3 py-2 text-ink outline-none focus:border-gold"
/>
</div>
<div className="flex flex-col gap-1">
<label htmlFor="pseudo" className="text-sm font-medium">
Pseudo
@@ -122,17 +174,10 @@ export default function SignupPage() {
/>
</div>
<div className="flex flex-col gap-1">
<label htmlFor="avatar" className="text-sm font-medium">
Photo de profil (optionnel)
</label>
<input
id="avatar"
type="file"
accept="image/*"
onChange={(event) => setAvatarFile(event.target.files?.[0] ?? null)}
className="text-sm file:mr-3 file:rounded-md file:border-0 file:bg-navy file:px-3 file:py-1.5 file:text-ivory"
/>
<div className="flex flex-col items-center gap-2">
<span className="self-start text-sm font-medium">Photo de profil (optionnel)</span>
<Avatar pseudo={pseudo || "?"} avatarUrl={avatarPreview} size="lg" />
<AvatarPicker label="Photo de profil" onCropped={handleCropped} />
</div>
{error && (