feat: page profil /me avec dashboard compact

- Identité (nom, email) avec bouton Inviter
- Carte réputation avec score, rang, barre de progression vers le prochain rang
- Lien vers l'historique de réputation
- Le nom dans le header devient un lien vers /me
- Ajout getNextRankThreshold dans permissions
This commit is contained in:
Jalil Arfaoui 2026-03-08 00:12:30 +01:00
parent 5d4fdcbd14
commit 3b63a7b98b
5 changed files with 209 additions and 7 deletions

View file

@ -17,5 +17,10 @@ export async function getAuthenticatedContributor() {
if (!data) return null
return { id: data.id, reputation: data.reputation ?? 0 }
return {
id: data.id,
reputation: data.reputation ?? 0,
name: (user.user_metadata?.name as string) ?? null,
email: user.email ?? null,
}
}

122
app/me/me.module.css Normal file
View file

@ -0,0 +1,122 @@
.title {
font-family: var(--font-gotham-bold);
font-size: 2em;
color: var(--debats-red);
text-transform: uppercase;
letter-spacing: 2px;
margin: 0 0 32px;
}
.identity {
display: flex;
align-items: center;
justify-content: space-between;
padding: 20px 0;
margin-bottom: 24px;
border-bottom: 1px solid var(--border-light);
}
.identityInfo {
display: flex;
flex-direction: column;
gap: 4px;
}
.displayName {
font-family: var(--font-gotham-bold);
font-size: 1.2em;
color: var(--text-dark);
}
.email {
font-family: var(--font-gotham-book);
font-size: 13px;
color: var(--text-light);
}
.reputationCard {
background: var(--bg-light, #fafafa);
border: 1px solid var(--border-light);
border-radius: 8px;
padding: 24px;
}
.reputationHeader {
display: flex;
align-items: baseline;
justify-content: space-between;
margin-bottom: 16px;
}
.reputationScore {
display: flex;
align-items: baseline;
gap: 8px;
}
.score {
font-family: var(--font-gotham-bold);
font-size: 2em;
color: var(--text-dark);
}
.scoreLabel {
font-family: var(--font-gotham-book);
font-size: 14px;
color: var(--text-light);
}
.rank {
font-family: var(--font-gotham-bold);
font-size: 14px;
color: var(--debats-red);
text-transform: uppercase;
letter-spacing: 1px;
}
.progressBar {
height: 6px;
background: var(--border-light);
border-radius: 3px;
overflow: hidden;
margin-bottom: 8px;
}
.progressFill {
height: 100%;
background: var(--debats-red);
border-radius: 3px;
transition: width 0.3s ease;
}
.progressLabel {
font-family: var(--font-gotham-book);
font-size: 12px;
color: var(--text-light);
}
.historyLink {
display: inline-block;
margin-top: 16px;
font-family: var(--font-gotham-book);
font-size: 13px;
color: var(--debats-red);
text-decoration: none;
}
.historyLink:hover {
text-decoration: underline;
}
@media (max-width: 768px) {
.identity {
flex-direction: column;
align-items: flex-start;
gap: 16px;
}
.reputationHeader {
flex-direction: column;
gap: 8px;
}
}

71
app/me/page.tsx Normal file
View file

@ -0,0 +1,71 @@
import { redirect } from 'next/navigation'
import Link from 'next/link'
import { getAuthenticatedContributor } from '../actions/get-authenticated-contributor'
import { getRank, getNextRankThreshold } from '../../domain/reputation/permissions'
import ContentWithSidebar from '../../components/layout/ContentWithSidebar'
import Button from '../../components/ui/Button'
import styles from './me.module.css'
export const metadata = {
title: 'Mon compte — Débats.co',
}
function formatScore(n: number): string {
return n.toLocaleString('fr-FR')
}
export default async function MePage() {
const contributor = await getAuthenticatedContributor()
if (!contributor) {
redirect(
'/?notice=' + encodeURIComponent('Vous devez être connecté·e pour accéder à votre profil.'),
)
}
const displayName = contributor.name ?? contributor.email ?? 'Contributeur·rice'
const rank = getRank(contributor.reputation)
const nextThreshold = getNextRankThreshold(contributor.reputation)
const progress = nextThreshold
? Math.min((contributor.reputation / nextThreshold) * 100, 100)
: 100
return (
<ContentWithSidebar topMargin>
<h1 className={styles.title}>Mon compte</h1>
<div className={styles.identity}>
<div className={styles.identityInfo}>
<span className={styles.displayName}>{displayName}</span>
{contributor.name && contributor.email && (
<span className={styles.email}>{contributor.email}</span>
)}
</div>
<Button href="/inviter" size="small">
Inviter
</Button>
</div>
<div className={styles.reputationCard}>
<div className={styles.reputationHeader}>
<div className={styles.reputationScore}>
<span className={styles.score}>{formatScore(contributor.reputation)}</span>
<span className={styles.scoreLabel}>points</span>
</div>
<span className={styles.rank}>{rank}</span>
</div>
<div className={styles.progressBar}>
<div className={styles.progressFill} style={{ width: `${progress}%` }} />
</div>
{nextThreshold && (
<span className={styles.progressLabel}>
{formatScore(nextThreshold - contributor.reputation)} pts avant le prochain rang
</span>
)}
<Link href="/reputation" className={styles.historyLink}>
Voir l&apos;historique
</Link>
</div>
</ContentWithSidebar>
)
}

View file

@ -30,12 +30,8 @@ export default function AuthSection() {
const displayName = user.user_metadata?.name || user.email
return (
<div className={styles.section}>
<span className={styles.userName}>{displayName}</span>
<Link href="/reputation" className={styles.inviteLink}>
Réputation
</Link>
<Link href="/inviter" className={styles.inviteLink}>
Inviter
<Link href="/me" className={styles.userName}>
{displayName}
</Link>
<Button
variant="link"

View file

@ -12,6 +12,14 @@ const RANK_THRESHOLDS = {
[Rank.Fondateur]: 1000000,
} as const
export function getNextRankThreshold(reputation: number): number | null {
if (reputation < 0) return 0
if (reputation < RANK_THRESHOLDS[Rank.Eloquent]) return RANK_THRESHOLDS[Rank.Eloquent]
if (reputation < RANK_THRESHOLDS[Rank.Idealiste]) return RANK_THRESHOLDS[Rank.Idealiste]
if (reputation < RANK_THRESHOLDS[Rank.Fondateur]) return RANK_THRESHOLDS[Rank.Fondateur]
return null
}
export function getRank(reputation: number): Rank {
if (reputation < 0) return Rank.Sophiste
if (reputation >= RANK_THRESHOLDS[Rank.Fondateur]) return Rank.Fondateur