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:
parent
b68f160bce
commit
4005673ae8
16 changed files with 1188 additions and 171 deletions
190
src/features/dashboard/components/StatCards.tsx
Normal file
190
src/features/dashboard/components/StatCards.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue