- {/* Logo — forcé en blanc : la sidebar est navy dans les deux thèmes */}
-
-
+ {/* Logo header */}
+
+
+
+
+ EX| PRIA
+
+ Préparation TCF Canada
+
{/* Navigation */}
@@ -124,12 +224,9 @@ export function Sidebar({ plan }: SidebarProps) {
- {/* Footer — ThemeToggle */}
-
-
- Thème
-
-
+ {/* Footer — avatar + user info + ThemeToggle */}
+
+
)
diff --git a/src/app/Topbar.tsx b/src/app/Topbar.tsx
new file mode 100644
index 0000000..3f76202
--- /dev/null
+++ b/src/app/Topbar.tsx
@@ -0,0 +1,86 @@
+/**
+ * Topbar sticky au-dessus du contenu principal.
+ *
+ * - Breadcrumb "Expria ›
" (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 (
+
+ {/* Hamburger (mobile only) */}
+
+
+
+
+ {/* Breadcrumb */}
+
+
+ Expria
+
+ ›
+
+ {title}
+
+
+
+ {/* Right cluster: search + command + bell */}
+
+
+ )
+}
diff --git a/src/app/route-titles.ts b/src/app/route-titles.ts
new file mode 100644
index 0000000..2699600
--- /dev/null
+++ b/src/app/route-titles.ts
@@ -0,0 +1,31 @@
+/**
+ * Mapping centralisé pathname → titre de page.
+ *
+ * Consommé par la Topbar (breadcrumb "Expria › ") 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> = {
+ '/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'
+}
diff --git a/src/features/dashboard/components/DashboardFreeView.tsx b/src/features/dashboard/components/DashboardFreeView.tsx
new file mode 100644
index 0000000..4102b3d
--- /dev/null
+++ b/src/features/dashboard/components/DashboardFreeView.tsx
@@ -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 (
+
+ {/* Header */}
+
+
+
Bonjour, {displayName}
+
+ Plan Découverte
+
+
+
+ navigate('/plan')}>
+ Passer en Premium →
+
+ }
+ disabled={!canStartSimulation}
+ onClick={() => navigate('/simulation/ee')}
+ >
+ Nouvelle simulation
+
+
+
+
+ {/* Hero NCLC — placeholder en Free */}
+
+
+ {/* Stat cards — NCLC et dernier score vides */}
+
+
+ {/* Prochaine étape + (pas de simulations récentes en Free) */}
+
+
+
+ Pour bien démarrer
+
+ Votre première simulation
+
+ Choisissez une tâche d'Expression Écrite pour obtenir un premier score et une estimation
+ NCLC. Vos 5 simulations gratuites vous attendent.
+
+
+
+
+
+
+ {/* Bannière upsell */}
+
+
+ )
+}
diff --git a/src/features/dashboard/components/DashboardPremiumView.tsx b/src/features/dashboard/components/DashboardPremiumView.tsx
new file mode 100644
index 0000000..6f7fabd
--- /dev/null
+++ b/src/features/dashboard/components/DashboardPremiumView.tsx
@@ -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 (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ )
+}
diff --git a/src/features/dashboard/components/DashboardStandardView.tsx b/src/features/dashboard/components/DashboardStandardView.tsx
new file mode 100644
index 0000000..5f281dd
--- /dev/null
+++ b/src/features/dashboard/components/DashboardStandardView.tsx
@@ -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 (
+
+
+
+
Bonjour, {displayName}
+
+ Plan Standard
+
+
+
+ navigate('/plan')}>
+ Passer en Premium →
+
+ }
+ onClick={() => navigate('/simulation/ee')}
+ >
+ Nouvelle simulation
+
+
+
+
+
+
+
+
+
+
+
+
+
+ )
+}
diff --git a/src/features/dashboard/components/NclcHero.tsx b/src/features/dashboard/components/NclcHero.tsx
new file mode 100644
index 0000000..f91c7bb
--- /dev/null
+++ b/src/features/dashboard/components/NclcHero.tsx
@@ -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 (
+
+
+
+
+
+
+ {score.value}
+ /{score.max}
+
+ Dernier score
+
+
+
+ )
+}
+
+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 (
+
+
+ {/* Left block */}
+
+
+ Indice de préparation TCF Canada
+
+
+
+
+ {hasNclc ? `NCLC ${formatNclc(currentNclc)}` : 'NCLC —'}
+
+
+ Objectif NCLC {targetNclc}+
+
+
+
+
{conseil}
+
+ {/* Jauge 5 → 10 */}
+
+
+ NCLC {NCLC_MIN}
+ NCLC {NCLC_MAX}
+
+
+ {hasNclc && (
+
+ )}
+ {/* Marqueur cible */}
+
+
+
+
+
+ {/* Right block — score ring */}
+ {lastScore &&
}
+
+
+ )
+}
diff --git a/src/features/dashboard/components/NextStepCard.tsx b/src/features/dashboard/components/NextStepCard.tsx
new file mode 100644
index 0000000..5b5cd4a
--- /dev/null
+++ b/src/features/dashboard/components/NextStepCard.tsx
@@ -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 (
+
+
+ Recommandé
+
+
+
+
+ {tags.length > 0 && (
+
+ {tags.map((tag) => (
+
+ {tag}
+
+ ))}
+
+ )}
+
+
+ {ctaLabel}
+
+
+
+ )
+}
diff --git a/src/features/dashboard/components/PaywallBanner.tsx b/src/features/dashboard/components/PaywallBanner.tsx
index e853e46..ccb8632 100644
--- a/src/features/dashboard/components/PaywallBanner.tsx
+++ b/src/features/dashboard/components/PaywallBanner.tsx
@@ -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 (
-
-
Passez à Standard pour débloquer :
-
- Simulations illimitées
- Rapport détaillé par critère
- Historique de vos productions
- Suivi de progression
-
-
- Passer à Standard
-
-
+
+
+
+
+
+
+
+ Débloque le rapport complet et l'IA de correction détaillée
+
+
+ Plan Standard · simulations illimitées · suivi NCLC dans le temps · 19,90 € / 4 semaines
+
+
+
+
+ Voir les offres
+
+
)
}
diff --git a/src/features/dashboard/components/RecentSimulations.tsx b/src/features/dashboard/components/RecentSimulations.tsx
new file mode 100644
index 0000000..2af42c6
--- /dev/null
+++ b/src/features/dashboard/components/RecentSimulations.tsx
@@ -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 (
+
+
+
3 dernières simulations
+ {totalCount > 0 && (
+
+ {totalCount} au total
+
+ )}
+
+
+ {visible.length === 0 ? (
+
+ Aucune simulation pour l'instant.
+
+ ) : (
+
+ {visible.map((item) => {
+ const type = isEcrit(item.tache) ? 'EE' : 'EO'
+ return (
+
+
+
+ {shortTacheLabel(item.tache)}
+ · {type}
+
+
{formatRelativeDate(item.created_at)}
+
+
+ {item.nclc !== null && (
+
+ NCLC {formatNclc(item.nclc)}
+
+ )}
+
+
+ {item.score === null ? '—' : `${item.score}/20`}
+
+
+
+
+ )
+ })}
+
+ )}
+
+ )
+}
diff --git a/src/features/dashboard/components/StatCards.tsx b/src/features/dashboard/components/StatCards.tsx
new file mode 100644
index 0000000..2957cbe
--- /dev/null
+++ b/src/features/dashboard/components/StatCards.tsx
@@ -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 (
+
+
+ {label}
+
+ {children}
+
+ )
+}
+
+function SimulationsRestantesCard({
+ plan,
+ simulationsUsed,
+ simulationsRemaining,
+}: {
+ plan: Plan
+ simulationsUsed: number
+ simulationsRemaining: number | null
+}) {
+ if (simulationsRemaining === null) {
+ return (
+
+ Illimitées
+
+ {simulationsUsed} effectuée{simulationsUsed > 1 ? 's' : ''}
+
+
+ )
+ }
+
+ const total = simulationsUsed + simulationsRemaining
+ const pct = total > 0 ? (simulationsUsed / total) * 100 : 0
+
+ return (
+
+
+ {simulationsRemaining}
+ /{total}
+
+
+ {plan === 'free' && (
+ Renouvellement offert à l'upgrade
+ )}
+
+ )
+}
+
+function NclcCard({ lastSim }: { lastSim: SimulationListItem | null }) {
+ if (!lastSim || lastSim.nclc === null) {
+ return (
+
+ —
+
+ Démarrez une simulation pour estimer votre niveau.
+
+
+ )
+ }
+
+ const nclc = lastSim.nclc
+ const inTarget = nclc >= 7
+
+ return (
+
+ {formatNclc(nclc)}
+
+ {inTarget ? 'Dans la cible CLB 7+' : 'Visez la cible CLB 7+'}
+
+
+ )
+}
+
+function DernierScoreCard({
+ recentSimulations,
+}: {
+ recentSimulations: readonly SimulationListItem[]
+}) {
+ const lastWithScore = recentSimulations.find((s) => s.score !== null) ?? null
+ if (!lastWithScore || lastWithScore.score === null) {
+ return (
+
+ —
+ Aucun score enregistré.
+
+ )
+ }
+
+ // 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 (
+
+
+ {formatScore(lastWithScore.score)}
+ /20
+
+
+ {type}
+
+ ·
+
+ {relative}
+ {delta !== null && delta !== 0 && (
+ 0 ? 'font-semibold text-success' : 'font-semibold text-warning'}>
+ {delta > 0 ? '+' : ''}
+ {formatScore(delta)} vs précédent
+
+ )}
+
+
+ )
+}
+
+export function StatCards({
+ plan,
+ simulationsUsed,
+ simulationsRemaining,
+ recentSimulations,
+}: StatCardsProps) {
+ const lastSim = recentSimulations.at(0) ?? null
+
+ return (
+
+ )
+}
diff --git a/src/features/dashboard/pages/DashboardPage.tsx b/src/features/dashboard/pages/DashboardPage.tsx
index 79bc7a7..11b906d 100644
--- a/src/features/dashboard/pages/DashboardPage.tsx
+++ b/src/features/dashboard/pages/DashboardPage.tsx
@@ -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 = {
- 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 (
-
-
)
}
@@ -51,76 +43,48 @@ export function DashboardPage() {
const { user } = useAuth()
const { data, isLoading, isError } = usePlan()
const queryClient = useQueryClient()
- const navigate = useNavigate()
+
+ if (isLoading) return
+
+ if (isError || !data) {
+ return (
+
+
+ Impossible de charger votre tableau de bord. Réessayez dans quelques instants.
+
+
queryClient.refetchQueries({ queryKey: PLAN_QUERY_KEY })}
+ >
+ Réessayer
+
+
+ )
+ }
const displayName = getDisplayName(user)
+ const plan = data.plan
- return (
-
- {isLoading && }
+ // 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 (
+
+ )
+ }
- {isError && (
-
-
- Impossible de charger votre tableau de bord. Réessayez dans quelques instants.
-
-
queryClient.refetchQueries({ queryKey: PLAN_QUERY_KEY })}
- >
- Réessayer
-
-
- )}
+ if (hasAccess(plan, 'pattern_analysis')) {
+ return (
+
+ )
+ }
- {data && (
-
- {/* Salutation */}
-
- Bonjour, {displayName}
-
- {PLAN_LABELS[data.plan]}
-
-
-
- {/* Bannière upgrade — plan Free uniquement */}
- {!hasAccess(data.plan, 'dashboard') &&
}
-
- {/* Métriques */}
-
-
-
Simulations restantes
-
- {data.simulations_remaining === null ? 'Illimitées' : data.simulations_remaining}
-
-
-
-
Niveau NCLC estimé
-
—
-
-
-
- {/* CTA Nouvelle simulation */}
-
navigate('/simulation/ee')}
- >
- Nouvelle simulation
-
-
- {/* Dernières simulations */}
-
- Dernières simulations
- Aucune simulation pour l'instant.
-
-
- {/* Mon profil de préparation — Premium uniquement (gate via hasAccess) */}
-
-
- )}
-
- )
+ return
}