- 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>
190 lines
6.1 KiB
TypeScript
190 lines
6.1 KiB
TypeScript
/**
|
|
* 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>
|
|
)
|
|
}
|