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
|
|
@ -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
|
||||
|
||||
### Changed
|
||||
|
|
|
|||
|
|
@ -94,6 +94,13 @@
|
|||
- Renommage tokens sur ~45 composants + inversion dark:/light: shadcn
|
||||
- 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)
|
||||
|
||||
16. MediaRecorder + upload audio EO T1/T3
|
||||
|
|
|
|||
|
|
@ -1,20 +1,20 @@
|
|||
/**
|
||||
* Layout applicatif — enveloppe toutes les routes privées.
|
||||
*
|
||||
* Desktop (≥ 1024px) : Sidebar fixe 240px + zone contenu principale.
|
||||
* Mobile (< 1024px) : MobileHeader sticky + drawer slide-in + BottomNav fixe.
|
||||
* Desktop (≥ 1024px) : Sidebar fixe 230px + Topbar sticky + zone contenu.
|
||||
* Mobile (< 1024px) : Topbar avec hamburger + drawer slide-in + BottomNav fixe.
|
||||
*
|
||||
* Le drawer mobile se ferme automatiquement à chaque changement de route
|
||||
* (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.
|
||||
*/
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useLocation } from 'react-router-dom'
|
||||
import { Sidebar } from './Sidebar'
|
||||
import { MobileHeader } from './MobileHeader'
|
||||
import { Topbar } from './Topbar'
|
||||
import { BottomNav } from './BottomNav'
|
||||
import { usePlan } from '@/features/dashboard/hooks/usePlan'
|
||||
import { cn } from '@/shared/lib/utils'
|
||||
|
|
@ -31,8 +31,6 @@ export function AppLayout({ children }: AppLayoutProps) {
|
|||
const plan: Plan = data?.plan ?? 'free'
|
||||
|
||||
// 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(() => {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setIsMobileMenuOpen(false)
|
||||
|
|
@ -51,9 +49,6 @@ export function AppLayout({ children }: AppLayoutProps) {
|
|||
<Sidebar plan={plan} />
|
||||
</aside>
|
||||
|
||||
{/* ── MOBILE — Header sticky ─────────────────────────────────── */}
|
||||
<MobileHeader onMenuOpen={() => setIsMobileMenuOpen(true)} />
|
||||
|
||||
{/* ── MOBILE — Drawer overlay ────────────────────────────────── */}
|
||||
<div
|
||||
aria-hidden="true"
|
||||
|
|
@ -81,6 +76,7 @@ export function AppLayout({ children }: AppLayoutProps) {
|
|||
className="min-h-screen pb-16 lg:pb-0 lg:pl-[230px]"
|
||||
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>
|
||||
</main>
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -8,36 +8,103 @@
|
|||
*/
|
||||
|
||||
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 { Logo } from '@/shared/components/Logo'
|
||||
import { ThemeToggle } from '@/shared/components/ThemeToggle'
|
||||
import { useAuth } from '@/features/auth/hooks/useAuth'
|
||||
import { cn } from '@/shared/lib/utils'
|
||||
import type { Feature, Plan } from '@/entities/user/lib'
|
||||
import type { User } from '@/shared/lib/auth-client'
|
||||
|
||||
interface NavItem {
|
||||
label: string
|
||||
to: string
|
||||
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[] = [
|
||||
{ label: 'Tableau de bord', to: '/dashboard', feature: null },
|
||||
{ label: 'Expression Écrite', to: '/simulation/ee', feature: null },
|
||||
{ label: 'Expression Orale', to: '/simulation/eo', feature: null },
|
||||
{ label: 'Examen blanc', to: '/examen', feature: 'exam_mode' },
|
||||
{ label: 'Progression', to: '/progression', feature: 'pattern_analysis' },
|
||||
{ label: 'Méthodologie', to: '/methodologie', feature: null },
|
||||
{ label: 'Historique', to: '/historique', feature: 'dashboard' },
|
||||
{ label: 'Tableau de bord', to: '/dashboard', feature: null, Icon: LayoutGrid },
|
||||
{ label: 'Expression Écrite', to: '/simulation/ee', feature: null, Icon: Pencil },
|
||||
{ label: 'Expression Orale', to: '/simulation/eo', feature: null, Icon: Mic },
|
||||
{ label: 'Examen blanc', to: '/examen', feature: 'exam_mode', Icon: FileText },
|
||||
{ label: 'Progression', to: '/progression', feature: 'pattern_analysis', Icon: Activity },
|
||||
{ label: 'Méthodologie', to: '/methodologie', feature: null, Icon: BookOpen },
|
||||
{ label: 'Historique', to: '/historique', feature: 'dashboard', Icon: Clock },
|
||||
]
|
||||
|
||||
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 }) {
|
||||
const locked = item.feature !== null && !hasAccess(plan, item.feature)
|
||||
const showUpgrade = item.showUpgradeWhenUpgradable === true && isUpgradable(plan)
|
||||
const { Icon } = item
|
||||
|
||||
return (
|
||||
<NavLink
|
||||
|
|
@ -45,7 +112,7 @@ function SidebarItem({ item, plan }: { item: NavItem; plan: Plan }) {
|
|||
aria-disabled={locked}
|
||||
className={({ isActive }) =>
|
||||
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',
|
||||
isActive && !locked
|
||||
? '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)]"
|
||||
/>
|
||||
)}
|
||||
<span>{item.label}</span>
|
||||
{locked && (
|
||||
<Lock
|
||||
className="size-3.5 shrink-0 text-[var(--color-sidebar-text)] opacity-60"
|
||||
<Icon
|
||||
className={cn('size-4 shrink-0', isActive && !locked ? 'opacity-100' : 'opacity-60')}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<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"
|
||||
/>
|
||||
)}
|
||||
|
|
@ -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 {
|
||||
plan: Plan
|
||||
}
|
||||
|
|
@ -110,9 +204,15 @@ interface SidebarProps {
|
|||
export function Sidebar({ plan }: SidebarProps) {
|
||||
return (
|
||||
<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 */}
|
||||
<div className="flex h-14 shrink-0 items-center border-b border-[var(--color-sidebar-border)] px-4">
|
||||
<Logo size="sm" className="text-white" />
|
||||
{/* Logo header */}
|
||||
<div className="flex h-14 shrink-0 items-center gap-2.5 border-b border-[var(--color-sidebar-border)] px-4">
|
||||
<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>
|
||||
|
||||
{/* Navigation */}
|
||||
|
|
@ -124,12 +224,9 @@ export function Sidebar({ plan }: SidebarProps) {
|
|||
<SidebarSection label="Compte" items={ACCOUNT_ITEMS} plan={plan} />
|
||||
</nav>
|
||||
|
||||
{/* Footer — ThemeToggle */}
|
||||
<div className="shrink-0 border-t border-[var(--color-sidebar-border)] px-4 py-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs text-[var(--color-sidebar-section-label)]">Thème</span>
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
{/* Footer — avatar + user info + ThemeToggle */}
|
||||
<div className="shrink-0 border-t border-[var(--color-sidebar-border)] px-3 py-3">
|
||||
<UserFooter plan={plan} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
|
|
|||
86
src/app/Topbar.tsx
Normal file
86
src/app/Topbar.tsx
Normal 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
31
src/app/route-titles.ts
Normal 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'
|
||||
}
|
||||
111
src/features/dashboard/components/DashboardFreeView.tsx
Normal file
111
src/features/dashboard/components/DashboardFreeView.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
96
src/features/dashboard/components/DashboardPremiumView.tsx
Normal file
96
src/features/dashboard/components/DashboardPremiumView.tsx
Normal 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 0–100 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>
|
||||
)
|
||||
}
|
||||
102
src/features/dashboard/components/DashboardStandardView.tsx
Normal file
102
src/features/dashboard/components/DashboardStandardView.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
159
src/features/dashboard/components/NclcHero.tsx
Normal file
159
src/features/dashboard/components/NclcHero.tsx
Normal 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 (5–10). `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>
|
||||
)
|
||||
}
|
||||
65
src/features/dashboard/components/NextStepCard.tsx
Normal file
65
src/features/dashboard/components/NextStepCard.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
97
src/features/dashboard/components/RecentSimulations.tsx
Normal file
97
src/features/dashboard/components/RecentSimulations.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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()
|
||||
* (Règles D et H — jamais de if plan === '...').
|
||||
* Le routing par plan passe exclusivement par `hasAccess()` — jamais de
|
||||
* `plan === '...'` (Règles D et H).
|
||||
*/
|
||||
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { Button } from '@/shared/ui/Button'
|
||||
import { Badge } from '@/shared/ui/Badge'
|
||||
import { hasAccess, canSimulate } from '@/entities/user/lib'
|
||||
import type { Plan } from '@/entities/user/types'
|
||||
import { useAuth } from '@/features/auth/hooks/useAuth'
|
||||
import { usePlan } from '../hooks/usePlan'
|
||||
import { PaywallBanner } from '../components/PaywallBanner'
|
||||
import { PLAN_QUERY_KEY } from '../hooks/usePlan'
|
||||
import { MonProfilPreparation } from '../components/MonProfilPreparation'
|
||||
|
||||
const PLAN_LABELS: Record<Plan, string> = {
|
||||
free: 'Plan Découverte',
|
||||
standard: 'Plan Standard',
|
||||
premium: 'Plan Premium',
|
||||
}
|
||||
import { usePlan, PLAN_QUERY_KEY } from '../hooks/usePlan'
|
||||
import { DashboardFreeView } from '../components/DashboardFreeView'
|
||||
import { DashboardStandardView } from '../components/DashboardStandardView'
|
||||
import { DashboardPremiumView } from '../components/DashboardPremiumView'
|
||||
|
||||
function getDisplayName(
|
||||
user: { user_metadata?: { full_name?: string }; email?: string } | null,
|
||||
|
|
@ -36,13 +28,13 @@ function getDisplayName(
|
|||
function DashboardSkeleton() {
|
||||
return (
|
||||
<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="grid grid-cols-2 gap-4">
|
||||
<div className="h-9 w-64 animate-pulse rounded-md bg-surface" />
|
||||
<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>
|
||||
<div className="h-9 animate-pulse rounded-md bg-surface" />
|
||||
<div className="h-16 animate-pulse rounded-lg bg-surface" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -51,76 +43,48 @@ export function DashboardPage() {
|
|||
const { user } = useAuth()
|
||||
const { data, isLoading, isError } = usePlan()
|
||||
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 plan = data.plan
|
||||
|
||||
return (
|
||||
<main className="mx-auto max-w-2xl px-4 py-6">
|
||||
{isLoading && <DashboardSkeleton />}
|
||||
// Route : Free → preview ; Premium (pattern_analysis) → full ; sinon Standard.
|
||||
if (!hasAccess(plan, 'dashboard')) {
|
||||
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 && (
|
||||
<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>
|
||||
)}
|
||||
if (hasAccess(plan, 'pattern_analysis')) {
|
||||
return (
|
||||
<DashboardPremiumView displayName={displayName} simulationsUsed={data.simulations_used} />
|
||||
)
|
||||
}
|
||||
|
||||
{data && (
|
||||
<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>
|
||||
)
|
||||
return <DashboardStandardView displayName={displayName} simulationsUsed={data.simulations_used} />
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue