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

@ -29,6 +29,40 @@ Chaque entrée suit ce format :
--- ---
## [Unreleased] — 2026-04-25 — Sprint UI Polish — Sidebar + Topbar + Dashboard
### Added
- `src/app/Topbar.tsx` — topbar sticky avec backdrop-blur, breadcrumb "Expria {page}", barre de recherche (placeholder), icônes raccourcis clavier et notifications.
- `src/app/route-titles.ts` — mapping centralisé pathname → titre de page, consommé par Topbar.
- `src/features/dashboard/components/NclcHero.tsx` — carte hero NCLC avec jauge horizontale 5→10 + anneau SVG score circulaire. Supporte état placeholder (Free/vide).
- `src/features/dashboard/components/StatCards.tsx` — 3 cartes métriques (simulations restantes, NCLC estimé, dernier score avec delta coloré).
- `src/features/dashboard/components/RecentSimulations.tsx` — liste 3 dernières simulations avec badge NCLC coloré + navigation vers `/rapport/:id`.
- `src/features/dashboard/components/NextStepCard.tsx` — carte "Prochaine étape" recommandée, contenu statique par plan.
- `src/features/dashboard/components/DashboardFreeView.tsx` — vue dashboard Free (hero placeholder, stat cards, premiers pas, PaywallBanner).
- `src/features/dashboard/components/DashboardStandardView.tsx` — vue dashboard Standard (hero NCLC dernière simu, simulations récentes, NextStepCard).
- `src/features/dashboard/components/DashboardPremiumView.tsx` — vue dashboard Premium (tout Standard + MonProfilPreparation).
### Changed
- `src/app/Sidebar.tsx` — icônes lucide-react (LayoutGrid, Pencil, Mic, etc.), cadenas Lock sur items verrouillés, badge upgrade ArrowUpCircle sur "Mon plan", user footer (avatar initiales + nom + plan + ThemeToggle), logo header "EX|PRIA" avec séparateur et sous-titre.
- `src/app/AppLayout.tsx` — intégration Topbar sticky, padding reporté sur wrapper contenu.
- `src/features/dashboard/pages/DashboardPage.tsx` — refactoré en orchestrateur : routing vers Free/Standard/Premium via `hasAccess`. Aucun `plan === 'xxx'` (Règle D).
- `src/features/dashboard/components/PaywallBanner.tsx` — refonte full-width + correction tokens morts Boréal (`border-brand-100`, `dark:border-brand/20`).
### Removed
- `src/app/MobileHeader.tsx` — fonctionnalité reprise par Topbar + Sidebar (0 consommateur confirmé par grep).
### Notes
- Tests : 122/122 verts. Typecheck : 0 erreur.
- Contenu NextStepCard statique par plan (pas d'endpoint backend dédié).
- Hero NCLC : Premium → usePatterns, Standard → NCLC dernière simulation, Free → état placeholder.
- Timeout API intermittent (cold start Render) préexistant — cause le fallback temporaire plan=free au chargement initial.
---
## [Unreleased] — 2026-04-24 — Sprint DA Charcoal — Reskin complet ## [Unreleased] — 2026-04-24 — Sprint DA Charcoal — Reskin complet
### Changed ### Changed

View file

@ -94,6 +94,13 @@
- Renommage tokens sur ~45 composants + inversion dark:/light: shadcn - Renommage tokens sur ~45 composants + inversion dark:/light: shadcn
- ADR 006 mis à jour - ADR 006 mis à jour
## Sprint UI Polish — Sidebar + Topbar + Dashboard ✅
- Sidebar : icônes lucide, cadenas gating, badge upgrade, user footer, logo "EX|PRIA"
- Topbar : sticky backdrop-blur, breadcrumb centralisé, recherche placeholder
- Dashboard : split Free/Standard/Premium, NclcHero + StatCards + RecentSimulations + NextStepCard + PaywallBanner refonte
- MobileHeader supprimé (remplacé par Topbar)
## Sprint 4 — Simulations EO (audio) ## Sprint 4 — Simulations EO (audio)
16. MediaRecorder + upload audio EO T1/T3 16. MediaRecorder + upload audio EO T1/T3

View file

@ -1,20 +1,20 @@
/** /**
* Layout applicatif enveloppe toutes les routes privées. * Layout applicatif enveloppe toutes les routes privées.
* *
* Desktop ( 1024px) : Sidebar fixe 240px + zone contenu principale. * Desktop ( 1024px) : Sidebar fixe 230px + Topbar sticky + zone contenu.
* Mobile (< 1024px) : MobileHeader sticky + drawer slide-in + BottomNav fixe. * Mobile (< 1024px) : Topbar avec hamburger + drawer slide-in + BottomNav fixe.
* *
* Le drawer mobile se ferme automatiquement à chaque changement de route * Le drawer mobile se ferme automatiquement à chaque changement de route
* (useEffect sur location.pathname). * (useEffect sur location.pathname).
* *
* Règle L : tokens Direction H exclusivement. * Règle L : tokens du design system exclusivement.
* Règle H : aucune logique métier plan lu depuis le cache TanStack Query. * Règle H : aucune logique métier plan lu depuis le cache TanStack Query.
*/ */
import { useState, useEffect } from 'react' import { useState, useEffect } from 'react'
import { useLocation } from 'react-router-dom' import { useLocation } from 'react-router-dom'
import { Sidebar } from './Sidebar' import { Sidebar } from './Sidebar'
import { MobileHeader } from './MobileHeader' import { Topbar } from './Topbar'
import { BottomNav } from './BottomNav' import { BottomNav } from './BottomNav'
import { usePlan } from '@/features/dashboard/hooks/usePlan' import { usePlan } from '@/features/dashboard/hooks/usePlan'
import { cn } from '@/shared/lib/utils' import { cn } from '@/shared/lib/utils'
@ -31,8 +31,6 @@ export function AppLayout({ children }: AppLayoutProps) {
const plan: Plan = data?.plan ?? 'free' const plan: Plan = data?.plan ?? 'free'
// Ferme le drawer à chaque changement de route. // Ferme le drawer à chaque changement de route.
// Synchronisation UI → router state : pattern légitime (source externe = React
// Router). Bail-out React si déjà fermé = zéro cascading render en pratique.
useEffect(() => { useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect // eslint-disable-next-line react-hooks/set-state-in-effect
setIsMobileMenuOpen(false) setIsMobileMenuOpen(false)
@ -51,9 +49,6 @@ export function AppLayout({ children }: AppLayoutProps) {
<Sidebar plan={plan} /> <Sidebar plan={plan} />
</aside> </aside>
{/* ── MOBILE — Header sticky ─────────────────────────────────── */}
<MobileHeader onMenuOpen={() => setIsMobileMenuOpen(true)} />
{/* ── MOBILE — Drawer overlay ────────────────────────────────── */} {/* ── MOBILE — Drawer overlay ────────────────────────────────── */}
<div <div
aria-hidden="true" aria-hidden="true"
@ -81,6 +76,7 @@ export function AppLayout({ children }: AppLayoutProps) {
className="min-h-screen pb-16 lg:pb-0 lg:pl-[230px]" className="min-h-screen pb-16 lg:pb-0 lg:pl-[230px]"
style={{ background: mainBackground }} style={{ background: mainBackground }}
> >
<Topbar onMobileMenuOpen={() => setIsMobileMenuOpen(true)} />
<div className="mx-auto max-w-[1100px] px-5 py-6 lg:px-9 lg:py-9">{children}</div> <div className="mx-auto max-w-[1100px] px-5 py-6 lg:px-9 lg:py-9">{children}</div>
</main> </main>

View file

@ -1,35 +0,0 @@
/**
* Header mobile affiché uniquement en dessous de 1024px.
*
* Logo + ThemeToggle + bouton qui délègue l'ouverture du drawer
* à AppLayout via la prop onMenuOpen.
*
* Règle L : tokens Direction H exclusivement.
*/
import { Menu } from 'lucide-react'
import { Logo } from '@/shared/components/Logo'
import { ThemeToggle } from '@/shared/components/ThemeToggle'
interface MobileHeaderProps {
onMenuOpen: () => void
}
export function MobileHeader({ onMenuOpen }: MobileHeaderProps) {
return (
<header className="sticky top-0 z-30 flex h-14 items-center justify-between border-b border-border bg-surface px-4 lg:hidden">
<Logo size="sm" />
<div className="flex items-center gap-1">
<ThemeToggle />
<button
type="button"
aria-label="Ouvrir le menu de navigation"
onClick={onMenuOpen}
className="flex size-9 items-center justify-center rounded-md text-ink-secondary transition-colors duration-150 hover:bg-surface-hover hover:text-ink-primary focus-visible:outline-none focus-visible:shadow-focus"
>
<Menu className="size-5" aria-hidden="true" />
</button>
</div>
</header>
)
}

View file

@ -8,36 +8,103 @@
*/ */
import { NavLink } from 'react-router-dom' import { NavLink } from 'react-router-dom'
import { Lock } from 'lucide-react' import {
Activity,
ArrowUpCircle,
BookOpen,
Clock,
FileText,
LayoutGrid,
Lock,
Mic,
Pencil,
Settings,
User as UserIcon,
type LucideIcon,
} from 'lucide-react'
import { hasAccess } from '@/entities/user/lib' import { hasAccess } from '@/entities/user/lib'
import { Logo } from '@/shared/components/Logo' import { Logo } from '@/shared/components/Logo'
import { ThemeToggle } from '@/shared/components/ThemeToggle' import { ThemeToggle } from '@/shared/components/ThemeToggle'
import { useAuth } from '@/features/auth/hooks/useAuth'
import { cn } from '@/shared/lib/utils' import { cn } from '@/shared/lib/utils'
import type { Feature, Plan } from '@/entities/user/lib' import type { Feature, Plan } from '@/entities/user/lib'
import type { User } from '@/shared/lib/auth-client'
interface NavItem { interface NavItem {
label: string label: string
to: string to: string
feature: Feature | null feature: Feature | null
Icon: LucideIcon
/**
* Affiche un badge upgrade (flèche bleue) à droite du label quand
* l'utilisateur peut encore passer à un plan supérieur. Aujourd'hui
* utilisé uniquement sur "Mon plan".
*/
showUpgradeWhenUpgradable?: boolean
} }
const PREPARE_ITEMS: readonly NavItem[] = [ const PREPARE_ITEMS: readonly NavItem[] = [
{ label: 'Tableau de bord', to: '/dashboard', feature: null }, { label: 'Tableau de bord', to: '/dashboard', feature: null, Icon: LayoutGrid },
{ label: 'Expression Écrite', to: '/simulation/ee', feature: null }, { label: 'Expression Écrite', to: '/simulation/ee', feature: null, Icon: Pencil },
{ label: 'Expression Orale', to: '/simulation/eo', feature: null }, { label: 'Expression Orale', to: '/simulation/eo', feature: null, Icon: Mic },
{ label: 'Examen blanc', to: '/examen', feature: 'exam_mode' }, { label: 'Examen blanc', to: '/examen', feature: 'exam_mode', Icon: FileText },
{ label: 'Progression', to: '/progression', feature: 'pattern_analysis' }, { label: 'Progression', to: '/progression', feature: 'pattern_analysis', Icon: Activity },
{ label: 'Méthodologie', to: '/methodologie', feature: null }, { label: 'Méthodologie', to: '/methodologie', feature: null, Icon: BookOpen },
{ label: 'Historique', to: '/historique', feature: 'dashboard' }, { label: 'Historique', to: '/historique', feature: 'dashboard', Icon: Clock },
] ]
const ACCOUNT_ITEMS: readonly NavItem[] = [ const ACCOUNT_ITEMS: readonly NavItem[] = [
{ label: 'Mon plan', to: '/plan', feature: null }, {
{ label: 'Paramètres', to: '/parametres', feature: null }, label: 'Mon plan',
to: '/plan',
feature: null,
Icon: UserIcon,
showUpgradeWhenUpgradable: true,
},
{ label: 'Paramètres', to: '/parametres', feature: null, Icon: Settings },
] ]
const PLAN_LABELS: Record<Plan, string> = {
free: 'Plan Découverte',
standard: 'Plan Standard',
premium: 'Plan Premium',
}
/**
* Proxy "peut encore upgrader". Examen blanc est exclusif Premium
* (PLANS_TARIFAIRES.md §4), donc son absence d'accès = plan en dessous
* du top-tier. Utilisé uniquement pour affichage UX, pas un check de
* permission (Règle D respectée on passe par hasAccess).
*/
function isUpgradable(plan: Plan): boolean {
return !hasAccess(plan, 'exam_mode')
}
function getInitials(user: User | null): string {
if (!user) return '··'
const fullName = user.user_metadata?.full_name as string | undefined
if (fullName) {
const parts = fullName.trim().split(/\s+/)
if (parts.length >= 2 && parts[0] && parts[parts.length - 1]) {
return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase()
}
return fullName.slice(0, 2).toUpperCase()
}
const local = user.email?.split('@')[0] ?? ''
return local.slice(0, 2).toUpperCase() || '··'
}
function getDisplayName(user: User | null): string {
if (!user) return 'Invité'
const fullName = user.user_metadata?.full_name as string | undefined
if (fullName) return fullName
return user.email?.split('@')[0] ?? 'Invité'
}
function SidebarItem({ item, plan }: { item: NavItem; plan: Plan }) { function SidebarItem({ item, plan }: { item: NavItem; plan: Plan }) {
const locked = item.feature !== null && !hasAccess(plan, item.feature) const locked = item.feature !== null && !hasAccess(plan, item.feature)
const showUpgrade = item.showUpgradeWhenUpgradable === true && isUpgradable(plan)
const { Icon } = item
return ( return (
<NavLink <NavLink
@ -45,7 +112,7 @@ function SidebarItem({ item, plan }: { item: NavItem; plan: Plan }) {
aria-disabled={locked} aria-disabled={locked}
className={({ isActive }) => className={({ isActive }) =>
cn( cn(
'relative flex items-center justify-between gap-2.5 rounded-lg px-2.5 py-2', 'relative flex items-center gap-2.5 rounded-lg px-2.5 py-2',
'text-[13px] font-medium transition-colors', 'text-[13px] font-medium transition-colors',
isActive && !locked isActive && !locked
? 'bg-[var(--color-sidebar-nav-active)] font-semibold text-[var(--color-sidebar-text-active)]' ? 'bg-[var(--color-sidebar-nav-active)] font-semibold text-[var(--color-sidebar-text-active)]'
@ -63,10 +130,15 @@ function SidebarItem({ item, plan }: { item: NavItem; plan: Plan }) {
className="absolute bottom-[20%] left-0 top-[20%] w-[3px] rounded-r bg-[var(--color-brand)]" className="absolute bottom-[20%] left-0 top-[20%] w-[3px] rounded-r bg-[var(--color-brand)]"
/> />
)} )}
<span>{item.label}</span> <Icon
{locked && ( className={cn('size-4 shrink-0', isActive && !locked ? 'opacity-100' : 'opacity-60')}
<Lock aria-hidden="true"
className="size-3.5 shrink-0 text-[var(--color-sidebar-text)] opacity-60" />
<span className="flex-1">{item.label}</span>
{locked && <Lock className="size-3 shrink-0 opacity-40" aria-hidden="true" />}
{showUpgrade && !locked && (
<ArrowUpCircle
className="size-3.5 shrink-0 text-[var(--color-brand-text)]"
aria-hidden="true" aria-hidden="true"
/> />
)} )}
@ -103,6 +175,28 @@ function SidebarSection({
) )
} }
function UserFooter({ plan }: { plan: Plan }) {
const { user } = useAuth()
const initials = getInitials(user)
const displayName = getDisplayName(user)
return (
<div className="flex items-center gap-2.5">
<span
aria-hidden="true"
className="flex size-8 shrink-0 items-center justify-center rounded-full border border-white/15 bg-white/10 text-xs font-bold text-white"
>
{initials}
</span>
<div className="min-w-0 flex-1 leading-tight">
<p className="truncate text-[12.5px] font-semibold text-white">{displayName}</p>
<p className="text-[10.5px] text-white/40">{PLAN_LABELS[plan]}</p>
</div>
<ThemeToggle className="shrink-0 text-white/60 hover:text-white" />
</div>
)
}
interface SidebarProps { interface SidebarProps {
plan: Plan plan: Plan
} }
@ -110,9 +204,15 @@ interface SidebarProps {
export function Sidebar({ plan }: SidebarProps) { export function Sidebar({ plan }: SidebarProps) {
return ( return (
<div className="flex h-full w-full flex-col border-r border-[var(--color-sidebar-border)] bg-[var(--color-sidebar-bg)]"> <div className="flex h-full w-full flex-col border-r border-[var(--color-sidebar-border)] bg-[var(--color-sidebar-bg)]">
{/* Logo — forcé en blanc : la sidebar est navy dans les deux thèmes */} {/* Logo header */}
<div className="flex h-14 shrink-0 items-center border-b border-[var(--color-sidebar-border)] px-4"> <div className="flex h-14 shrink-0 items-center gap-2.5 border-b border-[var(--color-sidebar-border)] px-4">
<Logo size="sm" className="text-white" /> <Logo variant="icon" size="sm" className="text-white" />
<div className="flex flex-col leading-none">
<span className="text-lg font-extrabold tracking-wide text-white">
EX<span className="opacity-30">|</span>PRIA
</span>
<span className="mt-0.5 text-[10px] text-white/35">Préparation TCF Canada</span>
</div>
</div> </div>
{/* Navigation */} {/* Navigation */}
@ -124,12 +224,9 @@ export function Sidebar({ plan }: SidebarProps) {
<SidebarSection label="Compte" items={ACCOUNT_ITEMS} plan={plan} /> <SidebarSection label="Compte" items={ACCOUNT_ITEMS} plan={plan} />
</nav> </nav>
{/* Footer — ThemeToggle */} {/* Footer — avatar + user info + ThemeToggle */}
<div className="shrink-0 border-t border-[var(--color-sidebar-border)] px-4 py-3"> <div className="shrink-0 border-t border-[var(--color-sidebar-border)] px-3 py-3">
<div className="flex items-center justify-between"> <UserFooter plan={plan} />
<span className="text-xs text-[var(--color-sidebar-section-label)]">Thème</span>
<ThemeToggle />
</div>
</div> </div>
</div> </div>
) )

86
src/app/Topbar.tsx Normal file
View file

@ -0,0 +1,86 @@
/**
* Topbar sticky au-dessus du contenu principal.
*
* - Breadcrumb "Expria <pageTitle>" (gauche).
* - Hamburger (mobile uniquement) qui ouvre le drawer Sidebar.
* - Barre de recherche placeholder (non fonctionnelle visuel only).
* - Icônes Command (raccourcis clavier) et Bell (notifications)
* non fonctionnelles, décoratives dans ce sprint.
*
* Règle L : tokens du design system exclusivement.
* Règle H : aucune logique métier navigation uniquement.
*/
import { useLocation } from 'react-router-dom'
import { Bell, Command, Menu, Search } from 'lucide-react'
import { getRouteTitle } from './route-titles'
interface TopbarProps {
onMobileMenuOpen: () => void
}
export function Topbar({ onMobileMenuOpen }: TopbarProps) {
const { pathname } = useLocation()
const title = getRouteTitle(pathname)
return (
<header
role="banner"
className="sticky top-0 z-10 flex h-14 items-center gap-3 border-b border-border bg-[var(--color-topbar-bg)] px-5 backdrop-blur-md lg:px-9"
>
{/* Hamburger (mobile only) */}
<button
type="button"
aria-label="Ouvrir le menu de navigation"
onClick={onMobileMenuOpen}
className="flex size-9 shrink-0 items-center justify-center rounded-md text-ink-secondary transition-colors hover:bg-surface-hover hover:text-ink-primary focus-visible:outline-none focus-visible:shadow-focus lg:hidden"
>
<Menu className="size-5" aria-hidden="true" />
</button>
{/* Breadcrumb */}
<nav aria-label="Fil d'Ariane" className="min-w-0 flex-1">
<ol className="flex items-center gap-1.5 text-sm">
<li className="text-ink-secondary">Expria</li>
<li aria-hidden="true" className="text-ink-tertiary">
</li>
<li className="truncate font-semibold text-ink-primary">{title}</li>
</ol>
</nav>
{/* Right cluster: search + command + bell */}
<div className="flex shrink-0 items-center gap-2">
<div className="relative hidden sm:block">
<Search
className="pointer-events-none absolute left-2.5 top-1/2 size-4 -translate-y-1/2 text-ink-tertiary"
aria-hidden="true"
/>
<input
type="search"
disabled
placeholder="Rechercher…"
aria-label="Rechercher"
className="h-8 w-[200px] rounded-[var(--radius-sm)] border border-border bg-surface pl-8 pr-3 text-sm text-ink-primary placeholder:text-ink-tertiary disabled:cursor-not-allowed"
/>
</div>
<button
type="button"
aria-label="Raccourcis clavier"
disabled
className="hidden size-8 items-center justify-center rounded-md text-ink-tertiary opacity-60 lg:flex"
>
<Command className="size-4" aria-hidden="true" />
</button>
<button
type="button"
aria-label="Notifications"
disabled
className="flex size-8 items-center justify-center rounded-md text-ink-tertiary opacity-60"
>
<Bell className="size-4" aria-hidden="true" />
</button>
</div>
</header>
)
}

31
src/app/route-titles.ts Normal file
View file

@ -0,0 +1,31 @@
/**
* Mapping centralisé pathname titre de page.
*
* Consommé par la Topbar (breadcrumb "Expria <titre>") et tout composant
* qui a besoin du libellé humain d'une route. Maintient la source unique
* pas de duplication dans Sidebar/Topbar/helmet.
*/
const STATIC_ROUTES: Readonly<Record<string, string>> = {
'/dashboard': 'Tableau de bord',
'/simulation/ee': 'Expression Écrite',
'/simulation/eo': 'Expression Orale',
'/sujets': 'Choisir un sujet',
'/examen': 'Examen blanc',
'/progression': 'Progression',
'/methodologie': 'Méthodologie',
'/historique': 'Historique',
'/plan': 'Mon plan',
'/parametres': 'Paramètres',
'/login': 'Connexion',
'/register': 'Inscription',
'/design-system': 'Design System',
}
export function getRouteTitle(pathname: string): string {
const exact = STATIC_ROUTES[pathname]
if (exact) return exact
if (pathname.startsWith('/rapport/')) return 'Rapport'
if (pathname === '/' || pathname === '') return 'Tableau de bord'
return 'Expria'
}

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. * 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 /pricing. * Présente les features débloquées par Standard et oriente vers /plan.
* Pas de modale intégrée dans le flux de la page (cf. PARCOURS_UTILISATEURS §2). *
* 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 { Link } from 'react-router-dom'
import { Button } from '@/shared/components/ui/button' import { Plus } from 'lucide-react'
export function PaywallBanner() { export function PaywallBanner() {
return ( return (
<div className="rounded-lg border border-brand-100 bg-brand-soft p-4 dark:border-brand/20"> <section
<p className="text-sm font-semibold text-ink-primary">Passez à Standard pour débloquer :</p> aria-label="Proposition d'upgrade"
<ul className="mt-2 space-y-1 text-sm text-ink-secondary" role="list"> 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"
<li>Simulations illimitées</li> >
<li>Rapport détaillé par critère</li> <span
<li>Historique de vos productions</li> aria-hidden="true"
<li>Suivi de progression</li> className="flex size-10 shrink-0 items-center justify-center rounded-full bg-brand text-white"
</ul> >
<Button asChild size="sm" className="mt-4 w-full"> <Plus className="size-5" />
<Link to="/pricing">Passer à Standard</Link> </span>
</Button>
</div> <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>
)
}

View file

@ -1,27 +1,19 @@
/** /**
* Page dashboard affichage conditionnel selon le plan utilisateur. * DashboardPage orchestrateur minimal : charge le plan et route vers
* la vue appropriée (Free / Standard / Premium).
* *
* Toute logique de permission passe par hasAccess() et canSimulate() * Le routing par plan passe exclusivement par `hasAccess()` jamais de
* (Règles D et H jamais de if plan === '...'). * `plan === '...'` (Règles D et H).
*/ */
import { useQueryClient } from '@tanstack/react-query' import { useQueryClient } from '@tanstack/react-query'
import { useNavigate } from 'react-router-dom'
import { Button } from '@/shared/ui/Button' import { Button } from '@/shared/ui/Button'
import { Badge } from '@/shared/ui/Badge'
import { hasAccess, canSimulate } from '@/entities/user/lib' import { hasAccess, canSimulate } from '@/entities/user/lib'
import type { Plan } from '@/entities/user/types'
import { useAuth } from '@/features/auth/hooks/useAuth' import { useAuth } from '@/features/auth/hooks/useAuth'
import { usePlan } from '../hooks/usePlan' import { usePlan, PLAN_QUERY_KEY } from '../hooks/usePlan'
import { PaywallBanner } from '../components/PaywallBanner' import { DashboardFreeView } from '../components/DashboardFreeView'
import { PLAN_QUERY_KEY } from '../hooks/usePlan' import { DashboardStandardView } from '../components/DashboardStandardView'
import { MonProfilPreparation } from '../components/MonProfilPreparation' import { DashboardPremiumView } from '../components/DashboardPremiumView'
const PLAN_LABELS: Record<Plan, string> = {
free: 'Plan Découverte',
standard: 'Plan Standard',
premium: 'Plan Premium',
}
function getDisplayName( function getDisplayName(
user: { user_metadata?: { full_name?: string }; email?: string } | null, user: { user_metadata?: { full_name?: string }; email?: string } | null,
@ -36,13 +28,13 @@ function getDisplayName(
function DashboardSkeleton() { function DashboardSkeleton() {
return ( return (
<div className="space-y-6" aria-busy="true" aria-label="Chargement du tableau de bord"> <div className="space-y-6" aria-busy="true" aria-label="Chargement du tableau de bord">
<div className="h-8 w-48 animate-pulse rounded-md bg-surface" /> <div className="h-9 w-64 animate-pulse rounded-md bg-surface" />
<div className="grid grid-cols-2 gap-4"> <div className="h-48 animate-pulse rounded-lg bg-surface" />
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
<div className="h-24 animate-pulse rounded-lg bg-surface" />
<div className="h-24 animate-pulse rounded-lg bg-surface" /> <div className="h-24 animate-pulse rounded-lg bg-surface" />
<div className="h-24 animate-pulse rounded-lg bg-surface" /> <div className="h-24 animate-pulse rounded-lg bg-surface" />
</div> </div>
<div className="h-9 animate-pulse rounded-md bg-surface" />
<div className="h-16 animate-pulse rounded-lg bg-surface" />
</div> </div>
) )
} }
@ -51,76 +43,48 @@ export function DashboardPage() {
const { user } = useAuth() const { user } = useAuth()
const { data, isLoading, isError } = usePlan() const { data, isLoading, isError } = usePlan()
const queryClient = useQueryClient() const queryClient = useQueryClient()
const navigate = useNavigate()
if (isLoading) return <DashboardSkeleton />
if (isError || !data) {
return (
<div className="space-y-3 text-center">
<p className="text-sm text-danger">
Impossible de charger votre tableau de bord. Réessayez dans quelques instants.
</p>
<Button
variant="secondary"
size="sm"
onClick={() => queryClient.refetchQueries({ queryKey: PLAN_QUERY_KEY })}
>
Réessayer
</Button>
</div>
)
}
const displayName = getDisplayName(user) const displayName = getDisplayName(user)
const plan = data.plan
return ( // Route : Free → preview ; Premium (pattern_analysis) → full ; sinon Standard.
<main className="mx-auto max-w-2xl px-4 py-6"> if (!hasAccess(plan, 'dashboard')) {
{isLoading && <DashboardSkeleton />} const simulationsRemaining = data.simulations_remaining ?? 0
const canStart = canSimulate(plan, data.simulations_used).allowed
return (
<DashboardFreeView
displayName={displayName}
simulationsUsed={data.simulations_used}
simulationsRemaining={simulationsRemaining}
canStartSimulation={canStart}
/>
)
}
{isError && ( if (hasAccess(plan, 'pattern_analysis')) {
<div className="space-y-3 text-center"> return (
<p className="text-sm text-danger"> <DashboardPremiumView displayName={displayName} simulationsUsed={data.simulations_used} />
Impossible de charger votre tableau de bord. Réessayez dans quelques instants. )
</p> }
<Button
variant="secondary"
size="sm"
onClick={() => queryClient.refetchQueries({ queryKey: PLAN_QUERY_KEY })}
>
Réessayer
</Button>
</div>
)}
{data && ( return <DashboardStandardView displayName={displayName} simulationsUsed={data.simulations_used} />
<div className="space-y-6">
{/* Salutation */}
<section className="flex flex-wrap items-center gap-3">
<h1 className="text-2xl font-semibold text-ink-primary">Bonjour, {displayName}</h1>
<Badge variant="plan" planValue={data.plan}>
{PLAN_LABELS[data.plan]}
</Badge>
</section>
{/* Bannière upgrade — plan Free uniquement */}
{!hasAccess(data.plan, 'dashboard') && <PaywallBanner />}
{/* Métriques */}
<section className="grid grid-cols-2 gap-4" aria-label="Métriques de préparation">
<div className="rounded-lg border border-border bg-surface p-4">
<p className="text-xs text-ink-secondary">Simulations restantes</p>
<p className="mt-1 text-2xl font-semibold text-ink-primary">
{data.simulations_remaining === null ? 'Illimitées' : data.simulations_remaining}
</p>
</div>
<div className="rounded-lg border border-border bg-surface p-4">
<p className="text-xs text-ink-secondary">Niveau NCLC estimé</p>
<p className="mt-1 text-2xl font-semibold text-ink-primary"></p>
</div>
</section>
{/* CTA Nouvelle simulation */}
<Button
variant="primary"
className="w-full"
disabled={!canSimulate(data.plan, data.simulations_used).allowed}
onClick={() => navigate('/simulation/ee')}
>
Nouvelle simulation
</Button>
{/* Dernières simulations */}
<section aria-label="Dernières simulations">
<h2 className="text-base font-semibold text-ink-primary">Dernières simulations</h2>
<p className="mt-2 text-sm text-ink-secondary">Aucune simulation pour l'instant.</p>
</section>
{/* Mon profil de préparation — Premium uniquement (gate via hasAccess) */}
<MonProfilPreparation plan={data.plan} />
</div>
)}
</main>
)
} }