feat: sprint 3 — profil utilisateur, badge plan, dropdown Header

- AuthContext.User : plan? { slug, name, level } | null
- UserBadge : nickname + badge plan.slug (fallback free)
- Header : dropdown click (Profil / Déconnexion) + click-outside
- ProfilePage : infos compte, badge plan, edit nickname (PATCH /users/me + re-fetch /auth/me → setUser)
- App : route /profile protégée
- useAuth : réexporte depuis AuthContext, fin de la dérive
This commit is contained in:
2026-03-14 22:33:47 +01:00
parent 30ef7312b5
commit 4e8c1aa849
6 changed files with 209 additions and 44 deletions

View File

@@ -0,0 +1,144 @@
import { useState } from 'react';
import { apiFetch, ApiError } from '../lib/api';
import { useAuthContext } from '../context/AuthContext';
import type { User } from '../context/AuthContext';
interface MeResponse {
success: boolean;
data: { user: User };
}
export default function ProfilePage() {
const { user, setUser } = useAuthContext();
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState(user?.nickname ?? '');
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
if (!user) return null;
function handleEdit() {
setDraft(user!.nickname);
setError(null);
setEditing(true);
}
function handleCancel() {
setEditing(false);
setError(null);
}
async function handleSave() {
const trimmed = draft.trim();
if (!trimmed || trimmed === user!.nickname) {
setEditing(false);
return;
}
setSaving(true);
setError(null);
try {
await apiFetch('/users/me', {
method: 'PATCH',
body: JSON.stringify({ nickname: trimmed }),
});
const res = await apiFetch<MeResponse>('/auth/me');
setUser(res.data.user);
setEditing(false);
} catch (e) {
setError(e instanceof ApiError ? `Erreur ${e.status}` : 'Erreur réseau');
} finally {
setSaving(false);
}
}
const planLabel = user.plan?.name ?? 'Free';
const planSlug = user.plan?.slug ?? 'free';
return (
<div className="max-w-lg space-y-8">
<h1 className="font-mono text-sm text-od-accent">Profil</h1>
{/* Infos compte */}
<section className="space-y-4">
<h2 className="text-xs font-semibold text-od-muted uppercase tracking-widest">
Compte
</h2>
<div className="rounded border border-od-border bg-od-surface divide-y divide-od-border">
{/* Email */}
<div className="flex items-center justify-between px-4 py-3">
<span className="text-xs text-od-muted">Email</span>
<span className="font-mono text-xs text-od-text">
{user.email ?? '—'}
</span>
</div>
{/* Nickname */}
<div className="flex items-center justify-between px-4 py-3">
<span className="text-xs text-od-muted">Pseudo</span>
{editing ? (
<div className="flex flex-col items-end gap-1">
<div className="flex items-center gap-2">
<input
value={draft}
onChange={(e) => setDraft(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') handleSave(); if (e.key === 'Escape') handleCancel(); }}
maxLength={32}
disabled={saving}
className="w-36 rounded border border-od-border bg-od-bg px-2 py-0.5 font-mono text-xs text-od-text focus:border-od-accent focus:outline-none disabled:opacity-50"
autoFocus
/>
<button
onClick={handleSave}
disabled={saving}
className="font-mono text-xs text-od-accent hover:opacity-80 transition-opacity disabled:opacity-40"
>
{saving ? '…' : '✓'}
</button>
<button
onClick={handleCancel}
disabled={saving}
className="font-mono text-xs text-od-muted hover:text-od-crit transition-colors disabled:opacity-40"
>
</button>
</div>
{error && (
<span className="font-mono text-[10px] text-od-crit">{error}</span>
)}
</div>
) : (
<div className="flex items-center gap-3">
<span className="font-mono text-xs text-od-text">{user.nickname}</span>
<button
onClick={handleEdit}
className="font-mono text-xs text-od-muted hover:text-od-accent transition-colors"
>
modifier
</button>
</div>
)}
</div>
</div>
</section>
{/* Plan */}
<section className="space-y-4">
<h2 className="text-xs font-semibold text-od-muted uppercase tracking-widest">
Abonnement
</h2>
<div className="rounded border border-od-border bg-od-surface px-4 py-3 flex items-center justify-between">
<p className="text-xs text-od-text">{planLabel}</p>
<span className="font-mono text-[10px] px-1.5 py-0.5 rounded border border-od-border text-od-muted">
{planSlug}
</span>
</div>
</section>
</div>
);
}