feat(sprint-0.5-bis): AppLayout + primitives UI + refonte pages
- AppLayout (sidebar fixe, drawer mobile, BottomNav) - MobileHeader sticky + Sidebar avec verrouillage hasAccess() - Primitives src/shared/ui/ : Button, Card, Badge - SimulationPage + DashboardPage : suppression headers internes - TaskSelector : Card interactive + Badge EE/EO + eyebrow - router.tsx : layout routes + ComingSoon inline
This commit is contained in:
parent
997f39bd33
commit
8450265449
11 changed files with 752 additions and 161 deletions
79
src/app/AppLayout.tsx
Normal file
79
src/app/AppLayout.tsx
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
/**
|
||||
* 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.
|
||||
*
|
||||
* Le drawer mobile se ferme automatiquement à chaque changement de route
|
||||
* (useEffect sur location.pathname).
|
||||
*
|
||||
* Règle L : tokens Direction H 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 { BottomNav } from './BottomNav'
|
||||
import { usePlan } from '@/features/dashboard/hooks/usePlan'
|
||||
import { cn } from '@/shared/lib/utils'
|
||||
import type { Plan } from '@/entities/user/lib'
|
||||
|
||||
interface AppLayoutProps {
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
export function AppLayout({ children }: AppLayoutProps) {
|
||||
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false)
|
||||
const location = useLocation()
|
||||
const { data } = usePlan()
|
||||
const plan: Plan = data?.plan ?? 'free'
|
||||
|
||||
// Ferme le drawer à chaque changement de route
|
||||
useEffect(() => {
|
||||
setIsMobileMenuOpen(false)
|
||||
}, [location.pathname])
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-canvas">
|
||||
{/* ── DESKTOP — Sidebar fixe 240px ───────────────────────────── */}
|
||||
<aside className="hidden lg:fixed lg:inset-y-0 lg:left-0 lg:flex lg:w-60 lg:flex-col">
|
||||
<Sidebar plan={plan} />
|
||||
</aside>
|
||||
|
||||
{/* ── MOBILE — Header sticky ─────────────────────────────────── */}
|
||||
<MobileHeader onMenuOpen={() => setIsMobileMenuOpen(true)} />
|
||||
|
||||
{/* ── MOBILE — Drawer overlay ────────────────────────────────── */}
|
||||
<div
|
||||
aria-hidden="true"
|
||||
onClick={() => 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 ──────────────────────────────────── */}
|
||||
<div
|
||||
className={cn(
|
||||
'fixed inset-y-0 left-0 z-50 flex w-60 flex-col transition-transform duration-200 ease-out lg:hidden',
|
||||
isMobileMenuOpen ? 'translate-x-0' : '-translate-x-full',
|
||||
)}
|
||||
aria-hidden={!isMobileMenuOpen}
|
||||
>
|
||||
<Sidebar plan={plan} />
|
||||
</div>
|
||||
|
||||
{/* ── Zone de contenu ────────────────────────────────────────── */}
|
||||
{/* pb-16 sur mobile pour ne pas être masqué par le BottomNav fixe */}
|
||||
<div className="pb-16 lg:pl-60 lg:pb-0">
|
||||
{children}
|
||||
</div>
|
||||
|
||||
{/* ── MOBILE — BottomNav fixe ────────────────────────────────── */}
|
||||
<BottomNav />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
148
src/app/BottomNav.tsx
Normal file
148
src/app/BottomNav.tsx
Normal file
|
|
@ -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 && (
|
||||
<div
|
||||
className="fixed inset-0 z-40 lg:hidden"
|
||||
aria-hidden="true"
|
||||
onClick={() => setIsSheetOpen(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Bottom sheet */}
|
||||
{isSheetOpen && (
|
||||
<div
|
||||
role="dialog"
|
||||
aria-label="Choisir une simulation"
|
||||
className="fixed bottom-16 left-0 right-0 z-50 rounded-t-xl border-t border-line bg-surface px-2 py-2 shadow-md lg:hidden"
|
||||
>
|
||||
<p className="px-3 pb-2 pt-1 text-[11px] font-semibold uppercase tracking-widest text-ink-5">
|
||||
Simuler
|
||||
</p>
|
||||
<ul role="list">
|
||||
{SHEET_ITEMS.map((item) => (
|
||||
<li key={item.to}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleSheetNavigate(item.to)}
|
||||
className="flex min-h-[44px] w-full items-center rounded-md px-3 text-sm text-ink-2 transition-colors duration-150 hover:bg-canvas hover:text-ink-1"
|
||||
>
|
||||
{item.label}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Bottom nav bar */}
|
||||
<nav
|
||||
aria-label="Navigation mobile"
|
||||
className="fixed bottom-0 left-0 right-0 z-30 flex h-16 items-center border-t border-line bg-surface lg:hidden"
|
||||
>
|
||||
{/* Accueil */}
|
||||
<Link
|
||||
to="/dashboard"
|
||||
aria-label="Accueil"
|
||||
className={cn(
|
||||
'flex min-h-[44px] flex-1 flex-col items-center justify-center gap-0.5 text-[10px] font-medium transition-colors duration-150',
|
||||
isActive('/dashboard') ? 'text-expria' : 'text-ink-4 hover:text-ink-2',
|
||||
)}
|
||||
>
|
||||
<Home
|
||||
className={cn('size-5', isActive('/dashboard') && 'text-expria')}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
Accueil
|
||||
</Link>
|
||||
|
||||
{/* Simuler */}
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Simuler"
|
||||
aria-expanded={isSheetOpen}
|
||||
onClick={() => setIsSheetOpen((v) => !v)}
|
||||
className={cn(
|
||||
'flex min-h-[44px] flex-1 flex-col items-center justify-center gap-0.5 text-[10px] font-medium transition-colors duration-150',
|
||||
isActive('/simulation') || isSheetOpen ? 'text-expria' : 'text-ink-4 hover:text-ink-2',
|
||||
)}
|
||||
>
|
||||
<BookOpen
|
||||
className={cn(
|
||||
'size-5',
|
||||
(isActive('/simulation') || isSheetOpen) && 'text-expria',
|
||||
)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
Simuler
|
||||
</button>
|
||||
|
||||
{/* Progression */}
|
||||
<Link
|
||||
to="/progression"
|
||||
aria-label="Progression"
|
||||
className={cn(
|
||||
'flex min-h-[44px] flex-1 flex-col items-center justify-center gap-0.5 text-[10px] font-medium transition-colors duration-150',
|
||||
isActive('/progression') ? 'text-expria' : 'text-ink-4 hover:text-ink-2',
|
||||
)}
|
||||
>
|
||||
<TrendingUp
|
||||
className={cn('size-5', isActive('/progression') && 'text-expria')}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
Progression
|
||||
</Link>
|
||||
|
||||
{/* Compte */}
|
||||
<Link
|
||||
to="/parametres"
|
||||
aria-label="Compte"
|
||||
className={cn(
|
||||
'flex min-h-[44px] flex-1 flex-col items-center justify-center gap-0.5 text-[10px] font-medium transition-colors duration-150',
|
||||
isActive('/parametres') ? 'text-expria' : 'text-ink-4 hover:text-ink-2',
|
||||
)}
|
||||
>
|
||||
<User
|
||||
className={cn('size-5', isActive('/parametres') && 'text-expria')}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
Compte
|
||||
</Link>
|
||||
</nav>
|
||||
</>
|
||||
)
|
||||
}
|
||||
35
src/app/MobileHeader.tsx
Normal file
35
src/app/MobileHeader.tsx
Normal file
|
|
@ -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 (
|
||||
<header className="sticky top-0 z-30 flex h-14 items-center justify-between border-b border-line 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-3 transition-colors duration-150 hover:bg-canvas hover:text-ink-1 focus-visible:outline-none focus-visible:shadow-focus"
|
||||
>
|
||||
<Menu className="size-5" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
)
|
||||
}
|
||||
119
src/app/Sidebar.tsx
Normal file
119
src/app/Sidebar.tsx
Normal file
|
|
@ -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 (
|
||||
<NavLink
|
||||
to={item.to}
|
||||
aria-disabled={locked}
|
||||
className={({ isActive }) =>
|
||||
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',
|
||||
)
|
||||
}
|
||||
>
|
||||
<span>{item.label}</span>
|
||||
{locked && <Lock className="size-3.5 shrink-0 text-ink-5" aria-hidden="true" />}
|
||||
</NavLink>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarSection({
|
||||
label,
|
||||
items,
|
||||
plan,
|
||||
className,
|
||||
}: {
|
||||
label: string
|
||||
items: readonly NavItem[]
|
||||
plan: Plan
|
||||
className?: string
|
||||
}) {
|
||||
return (
|
||||
<div className={className}>
|
||||
<p className="mb-1 px-3 text-[11px] font-semibold uppercase tracking-widest text-ink-5">
|
||||
{label}
|
||||
</p>
|
||||
<ul role="list" className="space-y-0.5">
|
||||
{items.map((item) => (
|
||||
<li key={item.to}>
|
||||
<SidebarItem item={item} plan={plan} />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface SidebarProps {
|
||||
plan: Plan
|
||||
}
|
||||
|
||||
export function Sidebar({ plan }: SidebarProps) {
|
||||
return (
|
||||
<div className="flex h-full w-full flex-col border-r border-line bg-surface">
|
||||
{/* Logo */}
|
||||
<div className="flex h-14 shrink-0 items-center border-b border-line px-4">
|
||||
<Logo size="sm" />
|
||||
</div>
|
||||
|
||||
{/* Navigation */}
|
||||
<nav
|
||||
className="flex flex-1 flex-col gap-6 overflow-y-auto px-3 py-4"
|
||||
aria-label="Navigation principale"
|
||||
>
|
||||
<SidebarSection label="Préparer" items={PREPARE_ITEMS} plan={plan} />
|
||||
<SidebarSection label="Compte" items={ACCOUNT_ITEMS} plan={plan} />
|
||||
</nav>
|
||||
|
||||
{/* Footer — ThemeToggle */}
|
||||
<div className="shrink-0 border-t border-line px-4 py-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs text-ink-5">Thème</span>
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -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 (
|
||||
<div className="flex min-h-[60vh] flex-col items-center justify-center gap-2 px-4 text-center">
|
||||
<p className="text-sm font-medium text-ink-2">Page en cours de développement</p>
|
||||
<p className="text-xs text-ink-4">Disponible dans une prochaine version.</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function PrivateLayout() {
|
||||
return (
|
||||
<ProtectedRoute>
|
||||
<AppLayout>
|
||||
<Outlet />
|
||||
</AppLayout>
|
||||
</ProtectedRoute>
|
||||
)
|
||||
}
|
||||
|
||||
export function AppRouter() {
|
||||
return (
|
||||
<Routes>
|
||||
<Route path="/" element={<Navigate to="/dashboard" replace />} />
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
{/* ── Routes publiques ─────────────────────────────────────── */}
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
<Route path="/register" element={<RegisterPage />} />
|
||||
<Route
|
||||
path="/dashboard"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<DashboardPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/simulation"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<SimulationPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* ── Routes privées — ProtectedRoute + AppLayout ──────────── */}
|
||||
<Route element={<PrivateLayout />}>
|
||||
<Route path="/" element={<Navigate to="/dashboard" replace />} />
|
||||
<Route path="/dashboard" element={<DashboardPage />} />
|
||||
|
||||
{/* Simulation */}
|
||||
<Route path="/simulation" element={<Navigate to="/simulation/ee" replace />} />
|
||||
<Route path="/simulation/ee" element={<SimulationPage />} />
|
||||
<Route path="/simulation/eo" element={<ComingSoon />} />
|
||||
|
||||
{/* Rapport */}
|
||||
<Route path="/rapport/:id" element={<ComingSoon />} />
|
||||
|
||||
{/* Autres sections — Sprint 4+ */}
|
||||
<Route path="/examen" element={<ComingSoon />} />
|
||||
<Route path="/progression" element={<ComingSoon />} />
|
||||
<Route path="/methodologie" element={<ComingSoon />} />
|
||||
<Route path="/historique" element={<ComingSoon />} />
|
||||
<Route path="/plan" element={<ComingSoon />} />
|
||||
<Route path="/parametres" element={<ComingSoon />} />
|
||||
</Route>
|
||||
|
||||
{/* ── Dev only ─────────────────────────────────────────────── */}
|
||||
{import.meta.env.DEV && (
|
||||
<Route
|
||||
path="/design-system"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue