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:
Hermann_Kitio 2026-04-20 02:37:19 +03:00
parent 997f39bd33
commit 8450265449
11 changed files with 752 additions and 161 deletions

View file

@ -6,10 +6,9 @@
*/
import { useQueryClient } from '@tanstack/react-query'
import { Logo } from '@/shared/components/Logo'
import { ThemeToggle } from '@/shared/components/ThemeToggle'
import { Button } from '@/shared/components/ui/button'
import { Badge } from '@/shared/components/ui/badge'
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'
@ -49,82 +48,80 @@ export function DashboardPage() {
const { user } = useAuth()
const { data, isLoading, isError } = usePlan()
const queryClient = useQueryClient()
const navigate = useNavigate()
const displayName = getDisplayName(user)
return (
<div className="min-h-screen bg-canvas">
<header className="flex items-center justify-between border-b border-line bg-surface px-4 py-3">
<Logo size="sm" />
<ThemeToggle />
</header>
<main className="mx-auto max-w-2xl px-4 py-6">
{isLoading && <DashboardSkeleton />}
<main className="mx-auto max-w-2xl px-4 py-6">
{isLoading && <DashboardSkeleton />}
{isError && (
<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 && (
<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="outline"
size="sm"
onClick={() => queryClient.refetchQueries({ queryKey: PLAN_QUERY_KEY })}
>
Réessayer
</Button>
</div>
)}
{data && (
<div className="space-y-6">
{/* Salutation */}
<section className="flex flex-wrap items-center gap-3">
<h1 className="text-2xl font-semibold text-ink-1">
Bonjour, {displayName}
</h1>
<Badge variant="plan" planValue={data.plan}>
{PLAN_LABELS[data.plan]}
</Badge>
</section>
{data && (
<div className="space-y-6">
{/* 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 */}
{!hasAccess(data.plan, 'dashboard') && <PaywallBanner />}
{/* Bannière upgrade — plan Free uniquement */}
{!hasAccess(data.plan, 'dashboard') && <PaywallBanner />}
{/* Métriques */}
<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 */}
<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>
{/* CTA Nouvelle simulation */}
<Button
variant="primary"
className="w-full"
disabled={!canSimulate(data.plan, data.simulations_used).allowed}
onClick={() => navigate('/simulation/ee')}
>
Nouvelle simulation
</Button>
{/* CTA Nouvelle simulation */}
<Button
className="w-full"
disabled={!canSimulate(data.plan, data.simulations_used).allowed}
>
Nouvelle simulation
</Button>
{/* 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>
{/* 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>
)
}

View file

@ -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
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3">
{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 (
<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 (
<button
key={`${card.tache ?? 'eo-t2'}`}
type="button"
disabled={disabled}
<Card
key={card.tache}
variant="interactive"
className={cn('relative flex flex-col p-4', isLoading && 'cursor-wait')}
onClick={() => {
if (card.tache && !locked) {
onSelect({ tache: card.tache, mode: 'entrainement' })
}
if (!isLoading) onSelect({ tache: card.tache as 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) && (
<Lock
className="mb-2 size-4 text-ink-4"
aria-hidden="true"
/>
)}
<span className="text-xs font-medium text-ink-4">{card.label}</span>
<span className="mt-0.5 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>
)}
{isLoading && !locked && card.tache && (
<Loader2 className="absolute right-3 top-3 size-3.5 animate-spin text-expria" aria-hidden="true" />
)}
</button>
<div className="mb-2 flex items-center justify-between">
<Badge variant="neutral">{abbrev}</Badge>
{isLoading && (
<Loader2 className="size-3.5 animate-spin text-expria" aria-hidden="true" />
)}
</div>
<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>
)
})}
</div>

View file

@ -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 (
<div className="min-h-screen bg-canvas">
<header className="flex items-center justify-between border-b border-line bg-surface px-4 py-3">
<Logo size="sm" />
<ThemeToggle />
</header>
<main className="mx-auto max-w-2xl px-4 py-6">
{isPlanLoading && <SimulationSkeleton />}
<main className="mx-auto max-w-2xl px-4 py-6">
{isPlanLoading && <SimulationSkeleton />}
{isPlanError && (
<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 && (
<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="outline" size="sm" onClick={() => refetchPlan()}>
Réessayer
</Button>
</div>
)}
{planData && step === 'idle' && (
<TaskSelector
plan={planData.plan}
simulationsUsed={planData.simulations_used}
isLoading={isCreating}
onSelect={selectTask}
/>
)}
{planData && step === 'idle' && (
<TaskSelector
plan={planData.plan}
simulationsUsed={planData.simulations_used}
isLoading={isCreating}
onSelect={selectTask}
/>
)}
{planData && (step === 'task-selected' || step === 'correcting') && production && (
<SimulationForm
tache={production.tache}
isSubmitting={isCorrecting}
error={correctError}
onSubmit={submitText}
onBack={reset}
/>
)}
</main>
</div>
{planData && (step === 'task-selected' || step === 'correcting') && production && (
<SimulationForm
tache={production.tache}
isSubmitting={isCorrecting}
error={correctError}
onSubmit={submitText}
onBack={reset}
/>
)}
</main>
)
}