feat(ui-polish): sidebar icons + topbar + dashboard redesign

- Sidebar: lucide-react icons, lock on gated items, upgrade badge on "Mon plan", user footer with avatar initials + plan label, "EX|PRIA" logo header
- Topbar: sticky with backdrop-blur, breadcrumb via centralized route-titles.ts, search placeholder, keyboard shortcuts + notifications icons
- Dashboard: split into Free/Standard/Premium views (ARCHITECTURE.md §3 aligned)
- NclcHero: NCLC display + gauge 5→10 + SVG score ring
- StatCards: simulations remaining + NCLC estimé + dernier score with delta
- RecentSimulations: 3 latest with NCLC badge + chevron nav
- NextStepCard: static recommendation per plan
- PaywallBanner: full-width redesign + fixed dead Boréal tokens
- Removed orphan MobileHeader.tsx (0 consumers)

Typecheck: OK · Tests: 122/122 

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Hermann_Kitio 2026-04-25 00:50:36 +03:00
parent b68f160bce
commit 4005673ae8
16 changed files with 1188 additions and 171 deletions

View file

@ -0,0 +1,111 @@
/**
* DashboardFreeView vue Dashboard pour le plan Découverte.
*
* Spécificités Free :
* - Pas d'appel `useSimulationsList` (gate 'dashboard' à false côté backend).
* - Hero NCLC en état placeholder (pas d'historique lisible).
* - Stat cards avec "NCLC estimé —" et "Dernier score —".
* - Recommandation statique vers la première simulation EE T2.
* - Bannière upsell Standard en bas.
*
* Règle D : aucun `plan === 'free'` c'est le parent (DashboardPage) qui
* route vers cette vue via hasAccess.
* Règle H : aucune logique métier les données viennent des props.
* Règle L : tokens du design system exclusivement.
*/
import { useNavigate } from 'react-router-dom'
import { Plus } from 'lucide-react'
import { Button } from '@/shared/ui/Button'
import { Badge } from '@/shared/ui/Badge'
import { NclcHero } from './NclcHero'
import { StatCards } from './StatCards'
import { NextStepCard } from './NextStepCard'
import { PaywallBanner } from './PaywallBanner'
interface DashboardFreeViewProps {
displayName: string
simulationsUsed: number
simulationsRemaining: number
canStartSimulation: boolean
}
const FREE_CONSEIL =
"Commencez par une simulation d'Expression Écrite pour découvrir votre niveau. " +
'Le rapport détaillé et le suivi NCLC se débloquent avec le plan Standard.'
export function DashboardFreeView({
displayName,
simulationsUsed,
simulationsRemaining,
canStartSimulation,
}: DashboardFreeViewProps) {
const navigate = useNavigate()
return (
<div className="space-y-6">
{/* Header */}
<header className="flex flex-wrap items-center justify-between gap-4">
<div className="flex flex-wrap items-center gap-3">
<h1 className="text-2xl font-bold text-ink-primary">Bonjour, {displayName}</h1>
<Badge variant="plan" planValue="free">
Plan Découverte
</Badge>
</div>
<div className="flex flex-wrap items-center gap-2">
<Button variant="secondary" size="sm" onClick={() => navigate('/plan')}>
Passer en Premium
</Button>
<Button
variant="primary"
size="sm"
icon={<Plus className="size-4" />}
disabled={!canStartSimulation}
onClick={() => navigate('/simulation/ee')}
>
Nouvelle simulation
</Button>
</div>
</header>
{/* Hero NCLC — placeholder en Free */}
<NclcHero currentNclc={null} conseil={FREE_CONSEIL} lastScore={null} />
{/* Stat cards — NCLC et dernier score vides */}
<StatCards
plan="free"
simulationsUsed={simulationsUsed}
simulationsRemaining={simulationsRemaining}
recentSimulations={[]}
/>
{/* Prochaine étape + (pas de simulations récentes en Free) */}
<div className="grid gap-4 lg:grid-cols-[1fr_360px]">
<section
aria-label="Premiers pas"
className="rounded-[var(--radius-md)] border border-border bg-surface p-6"
>
<p className="text-[11px] font-semibold uppercase tracking-wider text-ink-tertiary">
Pour bien démarrer
</p>
<h2 className="mt-2 text-lg font-semibold text-ink-primary">Votre première simulation</h2>
<p className="mt-2 text-sm text-ink-secondary">
Choisissez une tâche d'Expression Écrite pour obtenir un premier score et une estimation
NCLC. Vos 5 simulations gratuites vous attendent.
</p>
</section>
<NextStepCard
title="Démarrez par l'Écrit T2"
conseil="Article d'opinion — le format le plus représentatif du TCF Canada."
tags={['20 min', '120-150 mots']}
ctaLabel="Commencer"
ctaTo="/simulation/ee"
/>
</div>
{/* Bannière upsell */}
<PaywallBanner />
</div>
)
}

View file

@ -0,0 +1,96 @@
/**
* DashboardPremiumView vue Dashboard pour le plan Premium.
*
* Spécificités Premium :
* - Historique via `useSimulationsList`.
* - NCLC = dernière simulation (comme Standard).
* - Indice de préparation 0100 via `MonProfilPreparation` (patterns).
* - Pas de CTA "Passer en Premium" déjà au top-tier.
*
* Règle D : aucun `plan === 'premium'` routing via hasAccess côté parent.
* Règle H : logique d'affichage uniquement.
* Règle L : tokens du design system exclusivement.
*/
import { useNavigate } from 'react-router-dom'
import { Plus } from 'lucide-react'
import { Button } from '@/shared/ui/Button'
import { Badge } from '@/shared/ui/Badge'
import { useSimulationsList } from '@/features/historique/hooks/useSimulationsList'
import { NclcHero } from './NclcHero'
import { StatCards } from './StatCards'
import { RecentSimulations } from './RecentSimulations'
import { NextStepCard } from './NextStepCard'
import { MonProfilPreparation } from './MonProfilPreparation'
interface DashboardPremiumViewProps {
displayName: string
simulationsUsed: number
}
const PREMIUM_CONSEIL =
'Votre préparation est avancée. Enchaînez un Examen blanc chaque semaine pour verrouiller votre NCLC cible.'
export function DashboardPremiumView({ displayName, simulationsUsed }: DashboardPremiumViewProps) {
const navigate = useNavigate()
const { data } = useSimulationsList(1, 5)
const recent = data?.data ?? []
const totalCount = data?.pagination.total ?? 0
const firstWithNclc = recent.find((s) => s.nclc !== null) ?? null
const lastNclc = firstWithNclc?.nclc ?? null
const firstWithScore = recent.find((s) => s.score !== null) ?? null
const lastScore =
firstWithScore && firstWithScore.score !== null
? { value: firstWithScore.score, max: 20 }
: null
return (
<div className="space-y-6">
<header className="flex flex-wrap items-center justify-between gap-4">
<div className="flex flex-wrap items-center gap-3">
<h1 className="text-2xl font-bold text-ink-primary">Bonjour, {displayName}</h1>
<Badge variant="plan" planValue="premium">
Plan Premium
</Badge>
</div>
<Button
variant="primary"
size="sm"
icon={<Plus className="size-4" />}
onClick={() => navigate('/simulation/ee')}
>
Nouvelle simulation
</Button>
</header>
<NclcHero
currentNclc={lastNclc}
nclcLabel="NCLC dernière simulation"
conseil={PREMIUM_CONSEIL}
lastScore={lastScore}
/>
<StatCards
plan="premium"
simulationsUsed={simulationsUsed}
simulationsRemaining={null}
recentSimulations={recent}
/>
<div className="grid gap-4 lg:grid-cols-[1fr_360px]">
<RecentSimulations items={recent} totalCount={totalCount} />
<NextStepCard
title="Lancez un Examen blanc"
conseil="Conditions réelles : 60 min, 3 tâches, envoi automatique. Reproduisez la pression du jour J."
tags={['60 min', 'Examen']}
ctaLabel="Démarrer"
ctaTo="/examen"
/>
</div>
<MonProfilPreparation plan="premium" />
</div>
)
}

View file

@ -0,0 +1,102 @@
/**
* DashboardStandardView vue Dashboard pour le plan Standard.
*
* Spécificités Standard :
* - Historique lisible via `useSimulationsList`.
* - NCLC estimé = NCLC de la dernière simulation (premier item avec nclc non-null).
* - Pas de `MonProfilPreparation` (pattern_analysis gated Premium).
* - CTA "Passer en Premium →" + "+ Nouvelle simulation".
*
* Règle D : aucun `plan === 'standard'` c'est le parent (DashboardPage) qui
* route vers cette vue via hasAccess.
* Règle H : logique d'affichage uniquement.
* Règle L : tokens du design system exclusivement.
*/
import { useNavigate } from 'react-router-dom'
import { Plus } from 'lucide-react'
import { Button } from '@/shared/ui/Button'
import { Badge } from '@/shared/ui/Badge'
import { useSimulationsList } from '@/features/historique/hooks/useSimulationsList'
import { NclcHero } from './NclcHero'
import { StatCards } from './StatCards'
import { RecentSimulations } from './RecentSimulations'
import { NextStepCard } from './NextStepCard'
interface DashboardStandardViewProps {
displayName: string
simulationsUsed: number
}
const STD_CONSEIL =
'Votre préparation avance. Continuez la régularité — visez une simulation tous les deux jours pour sécuriser votre NCLC cible.'
export function DashboardStandardView({
displayName,
simulationsUsed,
}: DashboardStandardViewProps) {
const navigate = useNavigate()
const { data } = useSimulationsList(1, 5)
const recent = data?.data ?? []
const totalCount = data?.pagination.total ?? 0
const firstWithNclc = recent.find((s) => s.nclc !== null) ?? null
const lastNclc = firstWithNclc?.nclc ?? null
const firstWithScore = recent.find((s) => s.score !== null) ?? null
const lastScore =
firstWithScore && firstWithScore.score !== null
? { value: firstWithScore.score, max: 20 }
: null
return (
<div className="space-y-6">
<header className="flex flex-wrap items-center justify-between gap-4">
<div className="flex flex-wrap items-center gap-3">
<h1 className="text-2xl font-bold text-ink-primary">Bonjour, {displayName}</h1>
<Badge variant="plan" planValue="standard">
Plan Standard
</Badge>
</div>
<div className="flex flex-wrap items-center gap-2">
<Button variant="secondary" size="sm" onClick={() => navigate('/plan')}>
Passer en Premium
</Button>
<Button
variant="primary"
size="sm"
icon={<Plus className="size-4" />}
onClick={() => navigate('/simulation/ee')}
>
Nouvelle simulation
</Button>
</div>
</header>
<NclcHero
currentNclc={lastNclc}
nclcLabel="NCLC dernière simulation"
conseil={STD_CONSEIL}
lastScore={lastScore}
/>
<StatCards
plan="standard"
simulationsUsed={simulationsUsed}
simulationsRemaining={null}
recentSimulations={recent}
/>
<div className="grid gap-4 lg:grid-cols-[1fr_360px]">
<RecentSimulations items={recent} totalCount={totalCount} />
<NextStepCard
title="Travaillez l'Oral T1"
conseil="La présentation personnelle est souvent négligée. 10 minutes suffisent pour progresser."
tags={['10 min', 'Oral T1']}
ctaLabel="Commencer"
ctaTo="/simulation/eo"
/>
</div>
</div>
)
}

View file

@ -0,0 +1,159 @@
/**
* NclcHero carte principale du Dashboard.
*
* Affiche :
* - l'indice NCLC courant (via valeur passée par le parent usePatterns
* en Premium, dernière simu en Standard, null en Free) ;
* - l'objectif NCLC (défaut 9) et le conseil personnalisé ;
* - la jauge horizontale 5 10 avec position actuelle + marqueur cible ;
* - le dernier score dans un anneau SVG (facultatif).
*
* Règle H : aucune logique métier les valeurs sont calculées par le parent.
* Règle L : tokens du design system exclusivement.
*/
import { Card } from '@/shared/ui/Card'
interface LastScore {
value: number
max: number
}
interface NclcHeroProps {
/** NCLC actuel (510). `null` = pas de donnée (Free ou historique vide). */
currentNclc: number | null
/** Libellé du NCLC (ex. "NCLC estimé", "NCLC dernière simulation"). */
nclcLabel?: string
/** NCLC cible (défaut 9). */
targetNclc?: number
/** Texte conseil affiché sous le NCLC. */
conseil: string
/** Dernier score pour l'anneau SVG (optionnel). */
lastScore?: LastScore | null
}
const NCLC_MIN = 5
const NCLC_MAX = 10
const CIRCLE_RADIUS = 44
const CIRCLE_CIRCUMFERENCE = 2 * Math.PI * CIRCLE_RADIUS
function clamp(value: number, min: number, max: number): number {
return Math.max(min, Math.min(max, value))
}
function formatNclc(n: number): string {
return n.toLocaleString('fr-FR', { maximumFractionDigits: 1 })
}
function nclcToPct(n: number): number {
const clamped = clamp(n, NCLC_MIN, NCLC_MAX)
return ((clamped - NCLC_MIN) / (NCLC_MAX - NCLC_MIN)) * 100
}
function ScoreRing({ score }: { score: LastScore }) {
const pct = clamp((score.value / score.max) * 100, 0, 100)
const offset = CIRCLE_CIRCUMFERENCE * (1 - pct / 100)
return (
<div className="relative size-[140px] shrink-0">
<svg className="size-full -rotate-90" viewBox="0 0 100 100" aria-hidden="true">
<circle
cx="50"
cy="50"
r={CIRCLE_RADIUS}
fill="none"
stroke="var(--color-border)"
strokeWidth="6"
/>
<circle
cx="50"
cy="50"
r={CIRCLE_RADIUS}
fill="none"
stroke="var(--color-success)"
strokeWidth="6"
strokeLinecap="round"
strokeDasharray={CIRCLE_CIRCUMFERENCE}
strokeDashoffset={offset}
className="transition-[stroke-dashoffset] duration-500"
/>
</svg>
<div className="absolute inset-0 flex flex-col items-center justify-center">
<span className="text-2xl font-extrabold tabular-nums text-ink-primary">{score.value}</span>
<span className="text-xs text-ink-tertiary tabular-nums">/{score.max}</span>
<span className="mt-1 text-[10px] font-semibold uppercase tracking-wider text-ink-tertiary">
Dernier score
</span>
</div>
</div>
)
}
export function NclcHero({
currentNclc,
nclcLabel = 'NCLC estimé',
targetNclc = 9,
conseil,
lastScore = null,
}: NclcHeroProps) {
const hasNclc = currentNclc !== null
const currentPct = hasNclc ? nclcToPct(currentNclc) : 0
const targetPct = nclcToPct(targetNclc)
return (
<Card variant="raised" className="p-6 lg:p-8">
<div className="flex flex-col gap-8 lg:flex-row lg:items-start lg:justify-between">
{/* Left block */}
<div className="flex-1 space-y-4">
<p className="text-[11px] font-semibold uppercase tracking-wider text-ink-tertiary">
Indice de préparation TCF Canada
</p>
<div className="flex flex-wrap items-center gap-3">
<p className="text-display font-extrabold tabular-nums leading-none text-ink-primary">
{hasNclc ? `NCLC ${formatNclc(currentNclc)}` : 'NCLC —'}
</p>
<span className="inline-flex items-center rounded-full bg-success-soft px-2.5 py-0.5 text-xs font-semibold text-success">
Objectif NCLC {targetNclc}+
</span>
</div>
<p className="max-w-prose text-sm text-ink-secondary">{conseil}</p>
{/* Jauge 5 → 10 */}
<div className="space-y-1.5 pt-2">
<div className="flex items-center justify-between text-[10px] font-semibold uppercase tracking-wider text-ink-tertiary">
<span>NCLC {NCLC_MIN}</span>
<span>NCLC {NCLC_MAX}</span>
</div>
<div
className="relative h-2 rounded-full bg-surface-hover"
role="progressbar"
aria-valuemin={NCLC_MIN}
aria-valuemax={NCLC_MAX}
aria-valuenow={currentNclc ?? undefined}
aria-label={nclcLabel}
>
{hasNclc && (
<div
className="absolute inset-y-0 left-0 rounded-full bg-brand transition-[width] duration-500"
style={{ width: `${currentPct}%` }}
/>
)}
{/* Marqueur cible */}
<span
aria-hidden="true"
className="absolute -top-1 h-4 w-0.5 rounded-full bg-ink-primary"
style={{ left: `${targetPct}%` }}
title={`Cible NCLC ${targetNclc}`}
/>
</div>
</div>
</div>
{/* Right block — score ring */}
{lastScore && <ScoreRing score={lastScore} />}
</div>
</Card>
)
}

View file

@ -0,0 +1,65 @@
/**
* NextStepCard encart "Prochaine étape" affiché à droite des simulations.
*
* Contenu statique par plan pour ce sprint (pas d'endpoint "recommandation"
* en V1). Le parent construit le texte, les tags et la route CTA.
*
* Règle H : aucune logique métier affichage pur.
* Règle L : tokens du design system exclusivement.
*/
import { Link } from 'react-router-dom'
import { ArrowRight, Sparkles } from 'lucide-react'
import { Card } from '@/shared/ui/Card'
interface NextStepCardProps {
title: string
conseil: string
tags: readonly string[]
ctaLabel: string
ctaTo: string
}
export function NextStepCard({ title, conseil, tags, ctaLabel, ctaTo }: NextStepCardProps) {
return (
<Card variant="raised" className="flex h-full flex-col gap-4 p-5">
<p className="text-[11px] font-semibold uppercase tracking-wider text-brand-text">
Recommandé
</p>
<div className="flex items-start gap-2.5">
<span
aria-hidden="true"
className="mt-0.5 flex size-8 shrink-0 items-center justify-center rounded-full bg-brand-soft text-brand-text"
>
<Sparkles className="size-4" />
</span>
<div className="space-y-1">
<h3 className="text-base font-semibold text-ink-primary">{title}</h3>
<p className="text-sm text-ink-secondary">{conseil}</p>
</div>
</div>
{tags.length > 0 && (
<ul role="list" className="flex flex-wrap gap-1.5">
{tags.map((tag) => (
<li
key={tag}
className="inline-flex items-center rounded-full bg-surface-hover px-2 py-0.5 text-[11px] font-medium text-ink-secondary"
>
{tag}
</li>
))}
</ul>
)}
<Link
to={ctaTo}
className="mt-auto inline-flex h-9 items-center justify-center gap-1.5 rounded-md bg-brand px-4 text-sm font-semibold text-white transition-colors hover:bg-brand-hover focus-visible:outline-none focus-visible:shadow-focus"
>
{ctaLabel}
<ArrowRight className="size-4" aria-hidden="true" />
</Link>
</Card>
)
}

View file

@ -1,25 +1,42 @@
/**
* Bannière inline affichée sur le dashboard pour les utilisateurs Free.
* Présente les features débloquées par Standard et oriente vers /pricing.
* Pas de modale intégrée dans le flux de la page (cf. PARCOURS_UTILISATEURS §2).
* Bannière inline affichée en bas du dashboard pour les utilisateurs Free.
* Présente les features débloquées par Standard et oriente vers /plan.
*
* DA Charcoal : surface-solid + border-border, icône + dans cercle brand.
* Intégrée dans le flux de la page (pas de modale) cf. PARCOURS_UTILISATEURS §2.
*/
import { Link } from 'react-router-dom'
import { Button } from '@/shared/components/ui/button'
import { Plus } from 'lucide-react'
export function PaywallBanner() {
return (
<div className="rounded-lg border border-brand-100 bg-brand-soft p-4 dark:border-brand/20">
<p className="text-sm font-semibold text-ink-primary">Passez à Standard pour débloquer :</p>
<ul className="mt-2 space-y-1 text-sm text-ink-secondary" role="list">
<li>Simulations illimitées</li>
<li>Rapport détaillé par critère</li>
<li>Historique de vos productions</li>
<li>Suivi de progression</li>
</ul>
<Button asChild size="sm" className="mt-4 w-full">
<Link to="/pricing">Passer à Standard</Link>
</Button>
</div>
<section
aria-label="Proposition d'upgrade"
className="flex flex-col items-start gap-4 rounded-[var(--radius-md)] border border-border bg-surface-solid p-5 sm:flex-row sm:items-center"
>
<span
aria-hidden="true"
className="flex size-10 shrink-0 items-center justify-center rounded-full bg-brand text-white"
>
<Plus className="size-5" />
</span>
<div className="min-w-0 flex-1 space-y-1">
<p className="text-sm font-semibold text-ink-primary">
Débloque le rapport complet et l'IA de correction détaillée
</p>
<p className="text-xs text-ink-secondary">
Plan Standard · simulations illimitées · suivi NCLC dans le temps · 19,90 / 4 semaines
</p>
</div>
<Link
to="/plan"
className="inline-flex h-9 shrink-0 items-center justify-center rounded-md border border-border bg-surface px-4 text-sm font-semibold text-ink-primary transition-colors hover:bg-surface-hover focus-visible:outline-none focus-visible:shadow-focus"
>
Voir les offres
</Link>
</section>
)
}

View file

@ -0,0 +1,97 @@
/**
* RecentSimulations liste des 3 dernières simulations sur le Dashboard.
*
* Chaque item est cliquable ( /rapport/:id). Badge NCLC coloré selon le niveau,
* score /20, date relative, type court (EE · T2 / EO · T1).
*
* Règle H : aucune logique métier les données viennent du parent.
* Règle L : tokens du design system exclusivement.
*/
import { Link } from 'react-router-dom'
import { ChevronRight } from 'lucide-react'
import { Card } from '@/shared/ui/Card'
import { formatRelativeDate } from '@/shared/lib/date'
import { isEcrit } from '@/entities/production/lib'
import type { SimulationListItem, Tache } from '@/entities/production/types'
interface RecentSimulationsProps {
/** Items récents (max 3 affichés). */
items: readonly SimulationListItem[]
/** Total historique — affiché en badge à droite du titre. */
totalCount: number
}
function shortTacheLabel(tache: Tache): string {
const [prefix, num] = tache.split('_')
return `${prefix} · ${num}`
}
function nclcBadgeClasses(nclc: number | null): string {
if (nclc === null) return 'bg-surface-hover text-ink-tertiary'
if (nclc >= 9) return 'bg-success-soft text-success'
if (nclc >= 7) return 'bg-brand-soft text-brand-text'
return 'bg-warning-soft text-warning'
}
function formatNclc(n: number): string {
return n.toLocaleString('fr-FR', { maximumFractionDigits: 1 })
}
export function RecentSimulations({ items, totalCount }: RecentSimulationsProps) {
const visible = items.slice(0, 3)
return (
<section aria-label="Simulations récentes" className="space-y-3">
<div className="flex items-center justify-between">
<h2 className="text-base font-semibold text-ink-primary">3 dernières simulations</h2>
{totalCount > 0 && (
<span className="inline-flex items-center rounded-full bg-surface-hover px-2.5 py-0.5 text-[11px] font-semibold text-ink-secondary">
{totalCount} au total
</span>
)}
</div>
{visible.length === 0 ? (
<Card variant="default" className="p-6 text-center">
<p className="text-sm text-ink-secondary">Aucune simulation pour l'instant.</p>
</Card>
) : (
<Card variant="default" className="divide-y divide-[var(--color-border)] p-0">
{visible.map((item) => {
const type = isEcrit(item.tache) ? 'EE' : 'EO'
return (
<Link
key={item.id}
to={`/rapport/${item.id}`}
className="flex items-center gap-3 px-4 py-3 transition-colors hover:bg-surface-hover focus-visible:outline-none focus-visible:shadow-focus"
>
<div className="min-w-0 flex-1 space-y-0.5">
<p className="truncate text-sm font-medium text-ink-primary">
<span className="font-semibold">{shortTacheLabel(item.tache)}</span>
<span className="text-ink-tertiary"> · {type}</span>
</p>
<p className="text-xs text-ink-tertiary">{formatRelativeDate(item.created_at)}</p>
</div>
{item.nclc !== null && (
<span
className={`inline-flex items-center rounded-full px-2 py-0.5 text-[11px] font-semibold tabular-nums ${nclcBadgeClasses(item.nclc)}`}
>
NCLC {formatNclc(item.nclc)}
</span>
)}
<span className="hidden tabular-nums text-sm font-semibold text-ink-primary sm:inline">
{item.score === null ? '—' : `${item.score}/20`}
</span>
<ChevronRight className="size-4 shrink-0 text-ink-tertiary" aria-hidden="true" />
</Link>
)
})}
</Card>
)}
</section>
)
}

View file

@ -0,0 +1,190 @@
/**
* StatCards trois cartes synthétiques affichées sur le Dashboard.
*
* - Simulations restantes (barre de progression pour Free, "Illimitées" ailleurs)
* - NCLC estimé (dernière simulation)
* - Dernier score (+ delta vs précédent)
*
* Règle H : aucune logique métier de gating ici le parent décide du rendu
* global via hasAccess. Ce composant ne fait que formater les
* valeurs déjà fournies.
* Règle L : tokens du design system exclusivement.
*/
import { Card } from '@/shared/ui/Card'
import { formatRelativeDate } from '@/shared/lib/date'
import { isEcrit } from '@/entities/production/lib'
import type { SimulationListItem } from '@/entities/production/types'
import type { Plan } from '@/entities/user/lib'
interface StatCardsProps {
plan: Plan
simulationsUsed: number
/** null = illimité (Standard/Premium), number = reste (Free). */
simulationsRemaining: number | null
/** Liste des dernières simulations (index 0 = la plus récente). */
recentSimulations: readonly SimulationListItem[]
}
function formatNclc(n: number): string {
return n.toLocaleString('fr-FR', { maximumFractionDigits: 1 })
}
function formatScore(value: number): string {
return value.toLocaleString('fr-FR', { maximumFractionDigits: 1 })
}
function StatShell({ label, children }: { label: string; children: React.ReactNode }) {
return (
<Card variant="default" className="p-4">
<p className="text-[11px] font-semibold uppercase tracking-wider text-ink-tertiary">
{label}
</p>
<div className="mt-2 space-y-2">{children}</div>
</Card>
)
}
function SimulationsRestantesCard({
plan,
simulationsUsed,
simulationsRemaining,
}: {
plan: Plan
simulationsUsed: number
simulationsRemaining: number | null
}) {
if (simulationsRemaining === null) {
return (
<StatShell label="Simulations">
<p className="text-2xl font-extrabold tabular-nums text-ink-primary">Illimitées</p>
<p className="text-xs text-ink-secondary">
{simulationsUsed} effectuée{simulationsUsed > 1 ? 's' : ''}
</p>
</StatShell>
)
}
const total = simulationsUsed + simulationsRemaining
const pct = total > 0 ? (simulationsUsed / total) * 100 : 0
return (
<StatShell label="Simulations restantes">
<p className="tabular-nums text-ink-primary">
<span className="text-2xl font-extrabold">{simulationsRemaining}</span>
<span className="text-lg font-medium text-ink-secondary">/{total}</span>
</p>
<div
className="h-1.5 overflow-hidden rounded-full bg-surface-hover"
role="progressbar"
aria-valuemin={0}
aria-valuemax={total}
aria-valuenow={simulationsUsed}
aria-label="Simulations utilisées"
>
<div
className="h-full bg-brand transition-[width] duration-500"
style={{ width: `${pct}%` }}
/>
</div>
{plan === 'free' && (
<p className="text-xs text-ink-tertiary">Renouvellement offert à l'upgrade</p>
)}
</StatShell>
)
}
function NclcCard({ lastSim }: { lastSim: SimulationListItem | null }) {
if (!lastSim || lastSim.nclc === null) {
return (
<StatShell label="NCLC estimé">
<p className="text-2xl font-extrabold tabular-nums text-ink-primary"></p>
<p className="text-xs text-ink-tertiary">
Démarrez une simulation pour estimer votre niveau.
</p>
</StatShell>
)
}
const nclc = lastSim.nclc
const inTarget = nclc >= 7
return (
<StatShell label="NCLC estimé">
<p className="text-2xl font-extrabold tabular-nums text-ink-primary">{formatNclc(nclc)}</p>
<span className="inline-flex items-center rounded-full bg-brand-soft px-2.5 py-0.5 text-[11px] font-semibold text-brand-text">
{inTarget ? 'Dans la cible CLB 7+' : 'Visez la cible CLB 7+'}
</span>
</StatShell>
)
}
function DernierScoreCard({
recentSimulations,
}: {
recentSimulations: readonly SimulationListItem[]
}) {
const lastWithScore = recentSimulations.find((s) => s.score !== null) ?? null
if (!lastWithScore || lastWithScore.score === null) {
return (
<StatShell label="Dernier score">
<p className="text-2xl font-extrabold tabular-nums text-ink-primary"></p>
<p className="text-xs text-ink-tertiary">Aucun score enregistré.</p>
</StatShell>
)
}
// Précédente simulation avec score, pour calculer le delta.
const previous =
recentSimulations.filter((s) => s.id !== lastWithScore.id && s.score !== null).at(0) ?? null
const delta = previous && previous.score !== null ? lastWithScore.score - previous.score : null
const type = isEcrit(lastWithScore.tache) ? 'Écrit' : 'Oral'
const relative = formatRelativeDate(lastWithScore.created_at)
return (
<StatShell label="Dernier score">
<p className="tabular-nums text-ink-primary">
<span className="text-2xl font-extrabold">{formatScore(lastWithScore.score)}</span>
<span className="text-lg font-medium text-ink-secondary">/20</span>
</p>
<div className="flex flex-wrap items-center gap-x-2 gap-y-1 text-xs text-ink-secondary">
<span>{type}</span>
<span aria-hidden="true" className="text-ink-tertiary">
·
</span>
<span>{relative}</span>
{delta !== null && delta !== 0 && (
<span className={delta > 0 ? 'font-semibold text-success' : 'font-semibold text-warning'}>
{delta > 0 ? '+' : ''}
{formatScore(delta)} vs précédent
</span>
)}
</div>
</StatShell>
)
}
export function StatCards({
plan,
simulationsUsed,
simulationsRemaining,
recentSimulations,
}: StatCardsProps) {
const lastSim = recentSimulations.at(0) ?? null
return (
<section
aria-label="Indicateurs de préparation"
className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3"
>
<SimulationsRestantesCard
plan={plan}
simulationsUsed={simulationsUsed}
simulationsRemaining={simulationsRemaining}
/>
<NclcCard lastSim={lastSim} />
<DernierScoreCard recentSimulations={recentSimulations} />
</section>
)
}