diff --git a/src/app/AppLayout.tsx b/src/app/AppLayout.tsx new file mode 100644 index 0000000..e026f20 --- /dev/null +++ b/src/app/AppLayout.tsx @@ -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 ( +
+ {/* ── DESKTOP — Sidebar fixe 240px ───────────────────────────── */} + + + {/* ── MOBILE — Header sticky ─────────────────────────────────── */} + setIsMobileMenuOpen(true)} /> + + {/* ── MOBILE — Drawer overlay ────────────────────────────────── */} + + ) +} 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 && ( + + {/* 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
{TASK_CARDS.map((card) => { const locked = card.sprintLocked || quotaBlocked - const disabled = locked || isLoading || card.tache === null + const abbrev = (card.tache?.split('_')[0]) ?? 'EO' + + if (locked || card.tache === null) { + return ( + + + ) + } return ( - +
+ {abbrev} + {isLoading && ( +
+ + {card.label} + + {card.sublabel} + ) })}
diff --git a/src/features/simulations/pages/SimulationPage.tsx b/src/features/simulations/pages/SimulationPage.tsx index b4c889c..c669d8e 100644 --- a/src/features/simulations/pages/SimulationPage.tsx +++ b/src/features/simulations/pages/SimulationPage.tsx @@ -14,9 +14,7 @@ import { useEffect } from 'react' import { useNavigate } from 'react-router-dom' import { useQuery } from '@tanstack/react-query' import { getPlanStatus } from '@/entities/user/api' -import { Logo } from '@/shared/components/Logo' -import { ThemeToggle } from '@/shared/components/ThemeToggle' -import { Button } from '@/shared/components/ui/button' +import { Button } from '@/shared/ui/Button' import { useSimulation } from '../hooks/useSimulation' import { TaskSelector } from '../components/TaskSelector' import { SimulationForm } from '../components/SimulationForm' @@ -66,45 +64,38 @@ export function SimulationPage() { }, [step, production, navigate]) return ( -
-
- - -
+
+ {isPlanLoading && } -
- {isPlanLoading && } + {isPlanError && ( +
+

+ Impossible de charger vos informations. Réessayez dans quelques instants. +

+ +
+ )} - {isPlanError && ( -
-

- Impossible de charger vos informations. Réessayez dans quelques instants. -

- -
- )} + {planData && step === 'idle' && ( + + )} - {planData && step === 'idle' && ( - - )} - - {planData && (step === 'task-selected' || step === 'correcting') && production && ( - - )} -
-
+ {planData && (step === 'task-selected' || step === 'correcting') && production && ( + + )} + ) } diff --git a/src/shared/ui/Badge.tsx b/src/shared/ui/Badge.tsx new file mode 100644 index 0000000..ae04e41 --- /dev/null +++ b/src/shared/ui/Badge.tsx @@ -0,0 +1,53 @@ +/** + * Primitive Badge — Design System Expria (DESIGN_SYSTEM.md §4). + * + * Variants : + * plan — couleur selon le plan (free / standard / premium) + * nclc — score NCLC (bleu Expria) + * neutral — étiquette générique (gris) + * + * Taille fixe, text-xs uppercase tracking-wide (DESIGN_SYSTEM.md §3 — eyebrow style). + * Règle L : tokens Direction H exclusivement. + */ + +import { cn } from '@/shared/lib/utils' + +export type BadgeVariant = 'plan' | 'nclc' | 'neutral' +export type BadgePlanValue = 'free' | 'standard' | 'premium' + +export interface BadgeProps { + variant : BadgeVariant + planValue?: BadgePlanValue + className?: string + children : React.ReactNode +} + +const planClasses: Record = { + free : 'bg-canvas-2 text-ink-4', + standard: 'bg-expria-50 text-expria', + premium : 'bg-deep text-white', +} + +const variantClasses: Record = { + plan : '', // résolu dynamiquement via planValue + nclc : 'bg-expria-50 text-expria', + neutral: 'bg-canvas-2 text-ink-4', +} + +export function Badge({ variant, planValue, className, children }: BadgeProps) { + const colorClasses = + variant === 'plan' && planValue ? planClasses[planValue] : variantClasses[variant] + + return ( + + {children} + + ) +} diff --git a/src/shared/ui/Button.tsx b/src/shared/ui/Button.tsx new file mode 100644 index 0000000..3fe1a8a --- /dev/null +++ b/src/shared/ui/Button.tsx @@ -0,0 +1,77 @@ +/** + * Primitive Button — Design System Expria (DESIGN_SYSTEM.md §4). + * + * Variants : primary / secondary / ghost / upgrade + * Sizes : sm / md (défaut) / lg + * États : loading (spinner + disabled auto), disabled + * + * Règle L : tokens Direction H exclusivement. + * DESIGN_SYSTEM.md §8 : pas de nouvelle dépendance — lucide-react déjà présent. + */ + +import { Loader2 } from 'lucide-react' +import { cn } from '@/shared/lib/utils' + +export type ButtonVariant = 'primary' | 'secondary' | 'ghost' | 'upgrade' +export type ButtonSize = 'sm' | 'md' | 'lg' + +export interface ButtonProps extends React.ButtonHTMLAttributes { + variant? : ButtonVariant + size? : ButtonSize + icon? : React.ReactNode + loading? : boolean + className?: string +} + +const variantClasses: Record = { + primary: + 'bg-expria text-white hover:bg-expria-hover active:bg-expria-hover disabled:bg-expria/50', + secondary: + 'border border-line bg-surface text-ink-2 hover:bg-canvas hover:text-ink-1 disabled:text-ink-4', + ghost: + 'bg-transparent text-ink-3 hover:bg-canvas hover:text-ink-1 disabled:text-ink-5', + upgrade: + 'bg-deep text-white hover:bg-deep-2 active:bg-deep-2 disabled:bg-deep/50', +} + +const sizeClasses: Record = { + sm: 'h-8 gap-1.5 rounded-md px-3 text-xs', + md: 'h-9 gap-2 rounded-md px-4 text-sm', + lg: 'h-11 gap-2 rounded-lg px-6 text-base', +} + +export function Button({ + variant = 'primary', + size = 'md', + icon, + loading = false, + disabled, + className, + children, + ...props +}: ButtonProps) { + const isDisabled = disabled || loading + + return ( + + ) +} diff --git a/src/shared/ui/Card.tsx b/src/shared/ui/Card.tsx new file mode 100644 index 0000000..b1ba51f --- /dev/null +++ b/src/shared/ui/Card.tsx @@ -0,0 +1,57 @@ +/** + * Primitive Card — Design System Expria (DESIGN_SYSTEM.md §4). + * + * Variants : + * default — surface bordée, ombre légère + * raised — élévation plus marquée (MetricCard hero, recommandations) + * interactive — hover state + curseur pointer ; rendu en + ) + } + + return ( +
+ {children} +
+ ) +}