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 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 { LoginPage } from '@/features/auth/pages/LoginPage'
|
||||||
import { RegisterPage } from '@/features/auth/pages/RegisterPage'
|
import { RegisterPage } from '@/features/auth/pages/RegisterPage'
|
||||||
import { ProtectedRoute } from '@/features/auth/components/ProtectedRoute'
|
import { ProtectedRoute } from '@/features/auth/components/ProtectedRoute'
|
||||||
import { DashboardPage } from '@/features/dashboard/pages/DashboardPage'
|
import { DashboardPage } from '@/features/dashboard/pages/DashboardPage'
|
||||||
import { SimulationPage } from '@/features/simulations/pages/SimulationPage'
|
import { SimulationPage } from '@/features/simulations/pages/SimulationPage'
|
||||||
|
import { AppLayout } from './AppLayout'
|
||||||
|
|
||||||
const DesignSystemPage = import.meta.env.DEV
|
const DesignSystemPage = import.meta.env.DEV
|
||||||
? React.lazy(() => import('@/features/design-system/DesignSystemPage'))
|
? React.lazy(() => import('@/features/design-system/DesignSystemPage'))
|
||||||
: () => null
|
: () => 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() {
|
export function AppRouter() {
|
||||||
return (
|
return (
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/" element={<Navigate to="/dashboard" replace />} />
|
{/* ── Routes publiques ─────────────────────────────────────── */}
|
||||||
<Route path="/login" element={<LoginPage />} />
|
<Route path="/login" element={<LoginPage />} />
|
||||||
<Route path="/register" element={<RegisterPage />} />
|
<Route path="/register" element={<RegisterPage />} />
|
||||||
<Route
|
|
||||||
path="/dashboard"
|
{/* ── Routes privées — ProtectedRoute + AppLayout ──────────── */}
|
||||||
element={
|
<Route element={<PrivateLayout />}>
|
||||||
<ProtectedRoute>
|
<Route path="/" element={<Navigate to="/dashboard" replace />} />
|
||||||
<DashboardPage />
|
<Route path="/dashboard" element={<DashboardPage />} />
|
||||||
</ProtectedRoute>
|
|
||||||
}
|
{/* Simulation */}
|
||||||
/>
|
<Route path="/simulation" element={<Navigate to="/simulation/ee" replace />} />
|
||||||
<Route
|
<Route path="/simulation/ee" element={<SimulationPage />} />
|
||||||
path="/simulation"
|
<Route path="/simulation/eo" element={<ComingSoon />} />
|
||||||
element={
|
|
||||||
<ProtectedRoute>
|
{/* Rapport */}
|
||||||
<SimulationPage />
|
<Route path="/rapport/:id" element={<ComingSoon />} />
|
||||||
</ProtectedRoute>
|
|
||||||
}
|
{/* 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 && (
|
{import.meta.env.DEV && (
|
||||||
<Route
|
<Route
|
||||||
path="/design-system"
|
path="/design-system"
|
||||||
|
|
|
||||||
|
|
@ -6,10 +6,9 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useQueryClient } from '@tanstack/react-query'
|
import { useQueryClient } from '@tanstack/react-query'
|
||||||
import { Logo } from '@/shared/components/Logo'
|
import { useNavigate } from 'react-router-dom'
|
||||||
import { ThemeToggle } from '@/shared/components/ThemeToggle'
|
import { Button } from '@/shared/ui/Button'
|
||||||
import { Button } from '@/shared/components/ui/button'
|
import { Badge } from '@/shared/ui/Badge'
|
||||||
import { Badge } from '@/shared/components/ui/badge'
|
|
||||||
import { hasAccess, canSimulate } from '@/entities/user/lib'
|
import { hasAccess, canSimulate } from '@/entities/user/lib'
|
||||||
import type { Plan } from '@/entities/user/types'
|
import type { Plan } from '@/entities/user/types'
|
||||||
import { useAuth } from '@/features/auth/hooks/useAuth'
|
import { useAuth } from '@/features/auth/hooks/useAuth'
|
||||||
|
|
@ -49,82 +48,80 @@ export function DashboardPage() {
|
||||||
const { user } = useAuth()
|
const { user } = useAuth()
|
||||||
const { data, isLoading, isError } = usePlan()
|
const { data, isLoading, isError } = usePlan()
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
|
const navigate = useNavigate()
|
||||||
|
|
||||||
const displayName = getDisplayName(user)
|
const displayName = getDisplayName(user)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-canvas">
|
<main className="mx-auto max-w-2xl px-4 py-6">
|
||||||
<header className="flex items-center justify-between border-b border-line bg-surface px-4 py-3">
|
{isLoading && <DashboardSkeleton />}
|
||||||
<Logo size="sm" />
|
|
||||||
<ThemeToggle />
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<main className="mx-auto max-w-2xl px-4 py-6">
|
{isError && (
|
||||||
{isLoading && <DashboardSkeleton />}
|
<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>
|
||||||
|
)}
|
||||||
|
|
||||||
{isError && (
|
{data && (
|
||||||
<div className="space-y-3 text-center">
|
<div className="space-y-6">
|
||||||
<p className="text-sm text-danger">
|
{/* Salutation */}
|
||||||
Impossible de charger votre tableau de bord. Réessayez dans quelques instants.
|
<section className="flex flex-wrap items-center gap-3">
|
||||||
</p>
|
<h1 className="text-2xl font-semibold text-ink-1">
|
||||||
<Button
|
Bonjour, {displayName}
|
||||||
variant="outline"
|
</h1>
|
||||||
size="sm"
|
<Badge variant="plan" planValue={data.plan}>
|
||||||
onClick={() => queryClient.refetchQueries({ queryKey: PLAN_QUERY_KEY })}
|
{PLAN_LABELS[data.plan]}
|
||||||
>
|
</Badge>
|
||||||
Réessayer
|
</section>
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{data && (
|
{/* Bannière upgrade — plan Free uniquement */}
|
||||||
<div className="space-y-6">
|
{!hasAccess(data.plan, 'dashboard') && <PaywallBanner />}
|
||||||
{/* Salutation */}
|
|
||||||
<section className="flex flex-wrap items-center gap-3">
|
|
||||||
<h1 className="text-2xl font-semibold text-ink-1">
|
|
||||||
Bonjour, {displayName}
|
|
||||||
</h1>
|
|
||||||
<Badge variant="secondary">{PLAN_LABELS[data.plan]}</Badge>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{/* Bannière upgrade — plan Free uniquement */}
|
{/* Métriques */}
|
||||||
{!hasAccess(data.plan, 'dashboard') && <PaywallBanner />}
|
<section
|
||||||
|
className="grid grid-cols-2 gap-4"
|
||||||
|
aria-label="Métriques de préparation"
|
||||||
|
>
|
||||||
|
<div className="rounded-lg border border-line bg-surface p-4">
|
||||||
|
<p className="text-xs text-ink-4">Simulations restantes</p>
|
||||||
|
<p className="mt-1 text-2xl font-semibold text-ink-1">
|
||||||
|
{data.simulations_remaining === null
|
||||||
|
? 'Illimitées'
|
||||||
|
: data.simulations_remaining}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-lg border border-line bg-surface p-4">
|
||||||
|
<p className="text-xs text-ink-4">Niveau NCLC estimé</p>
|
||||||
|
<p className="mt-1 text-2xl font-semibold text-ink-1">—</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
{/* Métriques */}
|
{/* CTA Nouvelle simulation */}
|
||||||
<section
|
<Button
|
||||||
className="grid grid-cols-2 gap-4"
|
variant="primary"
|
||||||
aria-label="Métriques de préparation"
|
className="w-full"
|
||||||
>
|
disabled={!canSimulate(data.plan, data.simulations_used).allowed}
|
||||||
<div className="rounded-lg border border-line bg-surface p-4">
|
onClick={() => navigate('/simulation/ee')}
|
||||||
<p className="text-xs text-ink-4">Simulations restantes</p>
|
>
|
||||||
<p className="mt-1 text-2xl font-semibold text-ink-1">
|
Nouvelle simulation
|
||||||
{data.simulations_remaining === null
|
</Button>
|
||||||
? 'Illimitées'
|
|
||||||
: data.simulations_remaining}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="rounded-lg border border-line bg-surface p-4">
|
|
||||||
<p className="text-xs text-ink-4">Niveau NCLC estimé</p>
|
|
||||||
<p className="mt-1 text-2xl font-semibold text-ink-1">—</p>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{/* CTA Nouvelle simulation */}
|
{/* Dernières simulations */}
|
||||||
<Button
|
<section aria-label="Dernières simulations">
|
||||||
className="w-full"
|
<h2 className="text-base font-semibold text-ink-1">Dernières simulations</h2>
|
||||||
disabled={!canSimulate(data.plan, data.simulations_used).allowed}
|
<p className="mt-2 text-sm text-ink-4">Aucune simulation pour l'instant.</p>
|
||||||
>
|
</section>
|
||||||
Nouvelle simulation
|
</div>
|
||||||
</Button>
|
)}
|
||||||
|
</main>
|
||||||
{/* Dernières simulations */}
|
|
||||||
<section aria-label="Dernières simulations">
|
|
||||||
<h2 className="text-base font-semibold text-ink-1">Dernières simulations</h2>
|
|
||||||
<p className="mt-2 text-sm text-ink-4">Aucune simulation pour l'instant.</p>
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,8 @@
|
||||||
import { Lock, Loader2 } from 'lucide-react'
|
import { Lock, Loader2 } from 'lucide-react'
|
||||||
import { canSimulate } from '@/entities/user/lib'
|
import { canSimulate } from '@/entities/user/lib'
|
||||||
import { cn } from '@/shared/lib/utils'
|
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 { Plan } from '@/entities/user/lib'
|
||||||
import type { CreateSimulationPayload, Tache } from '@/entities/production/types'
|
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' },
|
{ tache: null, label: 'Expression Orale', sublabel: 'Tâche 2 — Live', sprintLocked: true, lockLabel: 'Exclusivité Premium' },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
export function TaskSelector({ plan, simulationsUsed, isLoading, onSelect }: Props) {
|
export function TaskSelector({ plan, simulationsUsed, isLoading, onSelect }: Props) {
|
||||||
const simulationCheck = canSimulate(plan, simulationsUsed)
|
const simulationCheck = canSimulate(plan, simulationsUsed)
|
||||||
const quotaBlocked = !simulationCheck.allowed
|
const quotaBlocked = !simulationCheck.allowed
|
||||||
|
|
@ -70,41 +71,47 @@ export function TaskSelector({ plan, simulationsUsed, isLoading, onSelect }: Pro
|
||||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3">
|
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3">
|
||||||
{TASK_CARDS.map((card) => {
|
{TASK_CARDS.map((card) => {
|
||||||
const locked = card.sprintLocked || quotaBlocked
|
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 (
|
||||||
|
<Card
|
||||||
|
key={card.tache ?? 'eo-t2'}
|
||||||
|
variant="default"
|
||||||
|
className="flex flex-col p-4 opacity-60"
|
||||||
|
>
|
||||||
|
<Lock className="mb-2 size-4 text-ink-4" aria-hidden="true" />
|
||||||
|
<span className="text-[11px] font-semibold uppercase tracking-widest text-ink-5">
|
||||||
|
{card.label}
|
||||||
|
</span>
|
||||||
|
<span className="mt-1 text-sm font-semibold text-ink-1">{card.sublabel}</span>
|
||||||
|
{card.lockLabel && (
|
||||||
|
<span className="mt-1.5 text-xs text-ink-4">{card.lockLabel}</span>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<Card
|
||||||
key={`${card.tache ?? 'eo-t2'}`}
|
key={card.tache}
|
||||||
type="button"
|
variant="interactive"
|
||||||
disabled={disabled}
|
className={cn('relative flex flex-col p-4', isLoading && 'cursor-wait')}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (card.tache && !locked) {
|
if (!isLoading) onSelect({ tache: card.tache as Tache, mode: 'entrainement' })
|
||||||
onSelect({ tache: card.tache, mode: 'entrainement' })
|
|
||||||
}
|
|
||||||
}}
|
}}
|
||||||
className={cn(
|
|
||||||
'group relative flex flex-col rounded-lg border p-4 text-left transition-colors',
|
|
||||||
locked || card.tache === null
|
|
||||||
? 'cursor-not-allowed border-line bg-canvas-2 opacity-60'
|
|
||||||
: 'cursor-pointer border-line bg-surface hover:border-expria hover:bg-expria-50',
|
|
||||||
isLoading && !locked && 'cursor-wait',
|
|
||||||
)}
|
|
||||||
>
|
>
|
||||||
{(card.sprintLocked || card.tache === null) && (
|
<div className="mb-2 flex items-center justify-between">
|
||||||
<Lock
|
<Badge variant="neutral">{abbrev}</Badge>
|
||||||
className="mb-2 size-4 text-ink-4"
|
{isLoading && (
|
||||||
aria-hidden="true"
|
<Loader2 className="size-3.5 animate-spin text-expria" aria-hidden="true" />
|
||||||
/>
|
)}
|
||||||
)}
|
</div>
|
||||||
<span className="text-xs font-medium text-ink-4">{card.label}</span>
|
<span className="text-[11px] font-semibold uppercase tracking-widest text-ink-5">
|
||||||
<span className="mt-0.5 text-sm font-semibold text-ink-1">{card.sublabel}</span>
|
{card.label}
|
||||||
{card.lockLabel && (
|
</span>
|
||||||
<span className="mt-1.5 text-xs text-ink-4">{card.lockLabel}</span>
|
<span className="mt-1 text-sm font-semibold text-ink-1">{card.sublabel}</span>
|
||||||
)}
|
</Card>
|
||||||
{isLoading && !locked && card.tache && (
|
|
||||||
<Loader2 className="absolute right-3 top-3 size-3.5 animate-spin text-expria" aria-hidden="true" />
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -14,9 +14,7 @@ import { useEffect } from 'react'
|
||||||
import { useNavigate } from 'react-router-dom'
|
import { useNavigate } from 'react-router-dom'
|
||||||
import { useQuery } from '@tanstack/react-query'
|
import { useQuery } from '@tanstack/react-query'
|
||||||
import { getPlanStatus } from '@/entities/user/api'
|
import { getPlanStatus } from '@/entities/user/api'
|
||||||
import { Logo } from '@/shared/components/Logo'
|
import { Button } from '@/shared/ui/Button'
|
||||||
import { ThemeToggle } from '@/shared/components/ThemeToggle'
|
|
||||||
import { Button } from '@/shared/components/ui/button'
|
|
||||||
import { useSimulation } from '../hooks/useSimulation'
|
import { useSimulation } from '../hooks/useSimulation'
|
||||||
import { TaskSelector } from '../components/TaskSelector'
|
import { TaskSelector } from '../components/TaskSelector'
|
||||||
import { SimulationForm } from '../components/SimulationForm'
|
import { SimulationForm } from '../components/SimulationForm'
|
||||||
|
|
@ -66,45 +64,38 @@ export function SimulationPage() {
|
||||||
}, [step, production, navigate])
|
}, [step, production, navigate])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-canvas">
|
<main className="mx-auto max-w-2xl px-4 py-6">
|
||||||
<header className="flex items-center justify-between border-b border-line bg-surface px-4 py-3">
|
{isPlanLoading && <SimulationSkeleton />}
|
||||||
<Logo size="sm" />
|
|
||||||
<ThemeToggle />
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<main className="mx-auto max-w-2xl px-4 py-6">
|
{isPlanError && (
|
||||||
{isPlanLoading && <SimulationSkeleton />}
|
<div className="space-y-3 text-center">
|
||||||
|
<p className="text-sm text-danger">
|
||||||
|
Impossible de charger vos informations. Réessayez dans quelques instants.
|
||||||
|
</p>
|
||||||
|
<Button variant="secondary" size="sm" onClick={() => refetchPlan()}>
|
||||||
|
Réessayer
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{isPlanError && (
|
{planData && step === 'idle' && (
|
||||||
<div className="space-y-3 text-center">
|
<TaskSelector
|
||||||
<p className="text-sm text-danger">
|
plan={planData.plan}
|
||||||
Impossible de charger vos informations. Réessayez dans quelques instants.
|
simulationsUsed={planData.simulations_used}
|
||||||
</p>
|
isLoading={isCreating}
|
||||||
<Button variant="outline" size="sm" onClick={() => refetchPlan()}>
|
onSelect={selectTask}
|
||||||
Réessayer
|
/>
|
||||||
</Button>
|
)}
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{planData && step === 'idle' && (
|
{planData && (step === 'task-selected' || step === 'correcting') && production && (
|
||||||
<TaskSelector
|
<SimulationForm
|
||||||
plan={planData.plan}
|
tache={production.tache}
|
||||||
simulationsUsed={planData.simulations_used}
|
isSubmitting={isCorrecting}
|
||||||
isLoading={isCreating}
|
error={correctError}
|
||||||
onSelect={selectTask}
|
onSubmit={submitText}
|
||||||
/>
|
onBack={reset}
|
||||||
)}
|
/>
|
||||||
|
)}
|
||||||
{planData && (step === 'task-selected' || step === 'correcting') && production && (
|
</main>
|
||||||
<SimulationForm
|
|
||||||
tache={production.tache}
|
|
||||||
isSubmitting={isCorrecting}
|
|
||||||
error={correctError}
|
|
||||||
onSubmit={submitText}
|
|
||||||
onBack={reset}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
53
src/shared/ui/Badge.tsx
Normal file
53
src/shared/ui/Badge.tsx
Normal file
|
|
@ -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<BadgePlanValue, string> = {
|
||||||
|
free : 'bg-canvas-2 text-ink-4',
|
||||||
|
standard: 'bg-expria-50 text-expria',
|
||||||
|
premium : 'bg-deep text-white',
|
||||||
|
}
|
||||||
|
|
||||||
|
const variantClasses: Record<BadgeVariant, string> = {
|
||||||
|
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 (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'inline-flex items-center rounded-full px-2 py-0.5',
|
||||||
|
'text-xs font-semibold uppercase tracking-wide',
|
||||||
|
colorClasses,
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
77
src/shared/ui/Button.tsx
Normal file
77
src/shared/ui/Button.tsx
Normal file
|
|
@ -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<HTMLButtonElement> {
|
||||||
|
variant? : ButtonVariant
|
||||||
|
size? : ButtonSize
|
||||||
|
icon? : React.ReactNode
|
||||||
|
loading? : boolean
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const variantClasses: Record<ButtonVariant, string> = {
|
||||||
|
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<ButtonSize, string> = {
|
||||||
|
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 (
|
||||||
|
<button
|
||||||
|
disabled={isDisabled}
|
||||||
|
className={cn(
|
||||||
|
// base
|
||||||
|
'inline-flex cursor-pointer items-center justify-center font-medium transition-colors duration-150',
|
||||||
|
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-expria/20',
|
||||||
|
'disabled:cursor-not-allowed disabled:opacity-50',
|
||||||
|
variantClasses[variant],
|
||||||
|
sizeClasses[size],
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<Loader2 className="size-4 animate-spin" aria-hidden="true" />
|
||||||
|
) : (
|
||||||
|
icon && <span className="shrink-0">{icon}</span>
|
||||||
|
)}
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
57
src/shared/ui/Card.tsx
Normal file
57
src/shared/ui/Card.tsx
Normal file
|
|
@ -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 <button> si onClick fourni
|
||||||
|
*
|
||||||
|
* Règle L : tokens Direction H exclusivement.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { cn } from '@/shared/lib/utils'
|
||||||
|
|
||||||
|
export type CardVariant = 'default' | 'raised' | 'interactive'
|
||||||
|
|
||||||
|
interface CardBaseProps {
|
||||||
|
variant? : CardVariant
|
||||||
|
className?: string
|
||||||
|
children : React.ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CardDivProps extends CardBaseProps {
|
||||||
|
onClick?: undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CardButtonProps extends CardBaseProps {
|
||||||
|
onClick: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CardProps = CardDivProps | CardButtonProps
|
||||||
|
|
||||||
|
const baseClasses = 'rounded-lg border border-line bg-surface'
|
||||||
|
|
||||||
|
const variantClasses: Record<CardVariant, string> = {
|
||||||
|
default : 'shadow-sm',
|
||||||
|
raised : 'shadow-md',
|
||||||
|
interactive:
|
||||||
|
'shadow-sm cursor-pointer transition-colors duration-150 hover:border-expria hover:bg-surface-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-expria/20',
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Card({ variant = 'default', className, children, onClick }: CardProps) {
|
||||||
|
const classes = cn(baseClasses, variantClasses[variant], className)
|
||||||
|
|
||||||
|
if (onClick) {
|
||||||
|
return (
|
||||||
|
<button type="button" onClick={onClick} className={classes}>
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={classes}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue