feat(ui-polish): sidebar icons + topbar + dashboard redesign

- Sidebar: lucide-react icons, lock on gated items, upgrade badge on "Mon plan", user footer with avatar initials + plan label, "EX|PRIA" logo header
- Topbar: sticky with backdrop-blur, breadcrumb via centralized route-titles.ts, search placeholder, keyboard shortcuts + notifications icons
- Dashboard: split into Free/Standard/Premium views (ARCHITECTURE.md §3 aligned)
- NclcHero: NCLC display + gauge 5→10 + SVG score ring
- StatCards: simulations remaining + NCLC estimé + dernier score with delta
- RecentSimulations: 3 latest with NCLC badge + chevron nav
- NextStepCard: static recommendation per plan
- PaywallBanner: full-width redesign + fixed dead Boréal tokens
- Removed orphan MobileHeader.tsx (0 consumers)

Typecheck: OK · Tests: 122/122 

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Hermann_Kitio 2026-04-25 00:50:36 +03:00
parent b68f160bce
commit 4005673ae8
16 changed files with 1188 additions and 171 deletions

View file

@ -1,27 +1,19 @@
/**
* Page dashboard affichage conditionnel selon le plan utilisateur.
* DashboardPage orchestrateur minimal : charge le plan et route vers
* la vue appropriée (Free / Standard / Premium).
*
* Toute logique de permission passe par hasAccess() et canSimulate()
* (Règles D et H jamais de if plan === '...').
* Le routing par plan passe exclusivement par `hasAccess()` jamais de
* `plan === '...'` (Règles D et H).
*/
import { useQueryClient } from '@tanstack/react-query'
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'
import { usePlan } from '../hooks/usePlan'
import { PaywallBanner } from '../components/PaywallBanner'
import { PLAN_QUERY_KEY } from '../hooks/usePlan'
import { MonProfilPreparation } from '../components/MonProfilPreparation'
const PLAN_LABELS: Record<Plan, string> = {
free: 'Plan Découverte',
standard: 'Plan Standard',
premium: 'Plan Premium',
}
import { usePlan, PLAN_QUERY_KEY } from '../hooks/usePlan'
import { DashboardFreeView } from '../components/DashboardFreeView'
import { DashboardStandardView } from '../components/DashboardStandardView'
import { DashboardPremiumView } from '../components/DashboardPremiumView'
function getDisplayName(
user: { user_metadata?: { full_name?: string }; email?: string } | null,
@ -36,13 +28,13 @@ function getDisplayName(
function DashboardSkeleton() {
return (
<div className="space-y-6" aria-busy="true" aria-label="Chargement du tableau de bord">
<div className="h-8 w-48 animate-pulse rounded-md bg-surface" />
<div className="grid grid-cols-2 gap-4">
<div className="h-9 w-64 animate-pulse rounded-md bg-surface" />
<div className="h-48 animate-pulse rounded-lg bg-surface" />
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
<div className="h-24 animate-pulse rounded-lg bg-surface" />
<div className="h-24 animate-pulse rounded-lg bg-surface" />
<div className="h-24 animate-pulse rounded-lg bg-surface" />
</div>
<div className="h-9 animate-pulse rounded-md bg-surface" />
<div className="h-16 animate-pulse rounded-lg bg-surface" />
</div>
)
}
@ -51,76 +43,48 @@ export function DashboardPage() {
const { user } = useAuth()
const { data, isLoading, isError } = usePlan()
const queryClient = useQueryClient()
const navigate = useNavigate()
if (isLoading) return <DashboardSkeleton />
if (isError || !data) {
return (
<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>
)
}
const displayName = getDisplayName(user)
const plan = data.plan
return (
<main className="mx-auto max-w-2xl px-4 py-6">
{isLoading && <DashboardSkeleton />}
// Route : Free → preview ; Premium (pattern_analysis) → full ; sinon Standard.
if (!hasAccess(plan, 'dashboard')) {
const simulationsRemaining = data.simulations_remaining ?? 0
const canStart = canSimulate(plan, data.simulations_used).allowed
return (
<DashboardFreeView
displayName={displayName}
simulationsUsed={data.simulations_used}
simulationsRemaining={simulationsRemaining}
canStartSimulation={canStart}
/>
)
}
{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>
)}
if (hasAccess(plan, 'pattern_analysis')) {
return (
<DashboardPremiumView displayName={displayName} simulationsUsed={data.simulations_used} />
)
}
{data && (
<div className="space-y-6">
{/* Salutation */}
<section className="flex flex-wrap items-center gap-3">
<h1 className="text-2xl font-semibold text-ink-primary">Bonjour, {displayName}</h1>
<Badge variant="plan" planValue={data.plan}>
{PLAN_LABELS[data.plan]}
</Badge>
</section>
{/* 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-border bg-surface p-4">
<p className="text-xs text-ink-secondary">Simulations restantes</p>
<p className="mt-1 text-2xl font-semibold text-ink-primary">
{data.simulations_remaining === null ? 'Illimitées' : data.simulations_remaining}
</p>
</div>
<div className="rounded-lg border border-border bg-surface p-4">
<p className="text-xs text-ink-secondary">Niveau NCLC estimé</p>
<p className="mt-1 text-2xl font-semibold text-ink-primary"></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>
{/* Dernières simulations */}
<section aria-label="Dernières simulations">
<h2 className="text-base font-semibold text-ink-primary">Dernières simulations</h2>
<p className="mt-2 text-sm text-ink-secondary">Aucune simulation pour l'instant.</p>
</section>
{/* Mon profil de préparation — Premium uniquement (gate via hasAccess) */}
<MonProfilPreparation plan={data.plan} />
</div>
)}
</main>
)
return <DashboardStandardView displayName={displayName} simulationsUsed={data.simulations_used} />
}