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,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>
)
}