setIsMobileMenuOpen(false)}
+ className={cn(
+ 'fixed inset-0 z-40 bg-ink-1/30 transition-opacity duration-200 ease-out lg:hidden',
+ isMobileMenuOpen ? 'opacity-100' : 'pointer-events-none opacity-0',
+ )}
+ />
+
+ {/* ── MOBILE — Drawer panel ──────────────────────────────────── */}
+
+
+
+
+ {/* ── Zone de contenu ────────────────────────────────────────── */}
+ {/* pb-16 sur mobile pour ne pas être masqué par le BottomNav fixe */}
+
+ {children}
+
+
+ {/* ── MOBILE — BottomNav fixe ────────────────────────────────── */}
+
+
+ )
+}
diff --git a/src/app/BottomNav.tsx b/src/app/BottomNav.tsx
new file mode 100644
index 0000000..b5f14ae
--- /dev/null
+++ b/src/app/BottomNav.tsx
@@ -0,0 +1,148 @@
+/**
+ * Navigation mobile fixe — affichée uniquement en dessous de 1024px.
+ *
+ * 4 items : Accueil / Simuler / Progression / Compte.
+ * "Simuler" ouvre une bottom sheet (EE / EO / Examen blanc).
+ * Tap target 44×44px minimum (DESIGN_SYSTEM.md §7).
+ *
+ * Règle L : tokens Direction H exclusivement.
+ * Règle H : aucune logique métier — navigation uniquement.
+ */
+
+import { useState } from 'react'
+import { Link, useLocation, useNavigate } from 'react-router-dom'
+import { Home, BookOpen, TrendingUp, User } from 'lucide-react'
+import { cn } from '@/shared/lib/utils'
+
+const SHEET_ITEMS = [
+ { label: 'Expression Écrite', to: '/simulation/ee' },
+ { label: 'Expression Orale', to: '/simulation/eo' },
+ { label: 'Examen blanc', to: '/examen' },
+] as const
+
+export function BottomNav() {
+ const [isSheetOpen, setIsSheetOpen] = useState(false)
+ const location = useLocation()
+ const navigate = useNavigate()
+
+ const isActive = (prefix: string) => location.pathname.startsWith(prefix)
+
+ function handleSheetNavigate(to: string) {
+ setIsSheetOpen(false)
+ navigate(to)
+ }
+
+ return (
+ <>
+ {/* Bottom sheet overlay */}
+ {isSheetOpen && (
+ setIsSheetOpen(false)}
+ />
+ )}
+
+ {/* Bottom sheet */}
+ {isSheetOpen && (
+
+
+ Simuler
+
+
+ {SHEET_ITEMS.map((item) => (
+ -
+
+
+ ))}
+
+
+ )}
+
+ {/* Bottom nav bar */}
+
+ >
+ )
+}
diff --git a/src/app/MobileHeader.tsx b/src/app/MobileHeader.tsx
new file mode 100644
index 0000000..72e5009
--- /dev/null
+++ b/src/app/MobileHeader.tsx
@@ -0,0 +1,35 @@
+/**
+ * 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 (
+
+ )
+}
diff --git a/src/app/Sidebar.tsx b/src/app/Sidebar.tsx
new file mode 100644
index 0000000..beedf45
--- /dev/null
+++ b/src/app/Sidebar.tsx
@@ -0,0 +1,119 @@
+/**
+ * Sidebar desktop — navigation principale (≥ 1024px).
+ *
+ * Règle D : le verrouillage des items passe par hasAccess(),
+ * jamais par if (plan === '...').
+ * Règle L : tokens Direction H exclusivement.
+ */
+
+import { NavLink } from 'react-router-dom'
+import { Lock } from 'lucide-react'
+import { hasAccess } from '@/entities/user/lib'
+import { Logo } from '@/shared/components/Logo'
+import { ThemeToggle } from '@/shared/components/ThemeToggle'
+import { cn } from '@/shared/lib/utils'
+import type { Feature, Plan } from '@/entities/user/lib'
+
+interface NavItem {
+ label: string
+ to: string
+ feature: Feature | null
+}
+
+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' },
+]
+
+const ACCOUNT_ITEMS: readonly NavItem[] = [
+ { label: 'Mon plan', to: '/plan', feature: null },
+ { label: 'Paramètres', to: '/parametres', feature: null },
+]
+
+function SidebarItem({ item, plan }: { item: NavItem; plan: Plan }) {
+ const locked = item.feature !== null && !hasAccess(plan, item.feature)
+
+ return (
+
+ cn(
+ 'flex items-center justify-between rounded-md px-3 py-2 text-sm transition-colors duration-150',
+ isActive && !locked
+ ? 'bg-expria-50 font-medium text-expria'
+ : locked
+ ? 'cursor-default text-ink-4 opacity-50'
+ : 'text-ink-3 hover:bg-canvas hover:text-ink-1',
+ )
+ }
+ >
+ {item.label}
+ {locked && }
+
+ )
+}
+
+function SidebarSection({
+ label,
+ items,
+ plan,
+ className,
+}: {
+ label: string
+ items: readonly NavItem[]
+ plan: Plan
+ className?: string
+}) {
+ return (
+
+
+ {label}
+
+
+ {items.map((item) => (
+ -
+
+
+ ))}
+
+
+ )
+}
+
+interface SidebarProps {
+ plan: Plan
+}
+
+export function Sidebar({ plan }: SidebarProps) {
+ return (
+
+ {/* Logo */}
+
+
+
+
+ {/* Navigation */}
+
+
+ {/* Footer — ThemeToggle */}
+
+
+ )
+}
diff --git a/src/app/router.tsx b/src/app/router.tsx
index 28de9a8..7f62926 100644
--- a/src/app/router.tsx
+++ b/src/app/router.tsx
@@ -1,38 +1,66 @@
import React, { Suspense } from 'react'
-import { Navigate, Routes, Route } from 'react-router-dom'
+import { Navigate, Outlet, Routes, Route } from 'react-router-dom'
import { LoginPage } from '@/features/auth/pages/LoginPage'
import { RegisterPage } from '@/features/auth/pages/RegisterPage'
import { ProtectedRoute } from '@/features/auth/components/ProtectedRoute'
import { DashboardPage } from '@/features/dashboard/pages/DashboardPage'
import { SimulationPage } from '@/features/simulations/pages/SimulationPage'
+import { AppLayout } from './AppLayout'
const DesignSystemPage = import.meta.env.DEV
? React.lazy(() => import('@/features/design-system/DesignSystemPage'))
: () => null
+function ComingSoon() {
+ return (
+
+
Page en cours de développement
+
Disponible dans une prochaine version.
+
+ )
+}
+
+function PrivateLayout() {
+ return (
+
+
+
+
+
+ )
+}
+
export function AppRouter() {
return (
- } />
- } />
+ {/* ── Routes publiques ─────────────────────────────────────── */}
+ } />
} />
-
-
-
- }
- />
-
-
-
- }
- />
+
+ {/* ── Routes privées — ProtectedRoute + AppLayout ──────────── */}
+ }>
+ } />
+ } />
+
+ {/* Simulation */}
+ } />
+ } />
+ } />
+
+ {/* Rapport */}
+ } />
+
+ {/* Autres sections — Sprint 4+ */}
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+
+
+ {/* ── Dev only ─────────────────────────────────────────────── */}
{import.meta.env.DEV && (
-
+
+ {isLoading && }
-
- {isLoading && }
+ {isError && (
+
+
+ Impossible de charger votre tableau de bord. Réessayez dans quelques instants.
+
+
+
+ )}
- {isError && (
-
-
- Impossible de charger votre tableau de bord. Réessayez dans quelques instants.
-
-
-
- )}
+ {data && (
+
+ {/* Salutation */}
+
+
+ Bonjour, {displayName}
+
+
+ {PLAN_LABELS[data.plan]}
+
+
- {data && (
-
- {/* Salutation */}
-
-
- Bonjour, {displayName}
-
- {PLAN_LABELS[data.plan]}
-
+ {/* Bannière upgrade — plan Free uniquement */}
+ {!hasAccess(data.plan, 'dashboard') &&
}
- {/* 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é
+
—
+
+
- {/* Métriques */}
-
-
-
Simulations restantes
-
- {data.simulations_remaining === null
- ? 'Illimitées'
- : data.simulations_remaining}
-
-
-
-
Niveau NCLC estimé
-
—
-
-
+ {/* CTA Nouvelle simulation */}
+
- {/* CTA Nouvelle simulation */}
-
-
- {/* Dernières simulations */}
-
- Dernières simulations
- Aucune simulation pour l'instant.
-
-
- )}
-
-
+ {/* Dernières simulations */}
+
+ Dernières simulations
+ Aucune simulation pour l'instant.
+
+
+ )}
+
)
}
diff --git a/src/features/simulations/components/TaskSelector.tsx b/src/features/simulations/components/TaskSelector.tsx
index e4b85a6..1ddce14 100644
--- a/src/features/simulations/components/TaskSelector.tsx
+++ b/src/features/simulations/components/TaskSelector.tsx
@@ -13,6 +13,8 @@
import { Lock, Loader2 } from 'lucide-react'
import { canSimulate } from '@/entities/user/lib'
import { cn } from '@/shared/lib/utils'
+import { Card } from '@/shared/ui/Card'
+import { Badge } from '@/shared/ui/Badge'
import type { Plan } from '@/entities/user/lib'
import type { CreateSimulationPayload, Tache } from '@/entities/production/types'
@@ -40,7 +42,6 @@ const TASK_CARDS: readonly TaskCard[] = [
{ tache: null, label: 'Expression Orale', sublabel: 'Tâche 2 — Live', sprintLocked: true, lockLabel: 'Exclusivité Premium' },
]
-
export function TaskSelector({ plan, simulationsUsed, isLoading, onSelect }: Props) {
const simulationCheck = canSimulate(plan, simulationsUsed)
const quotaBlocked = !simulationCheck.allowed
@@ -70,41 +71,47 @@ export function TaskSelector({ plan, simulationsUsed, isLoading, onSelect }: Pro