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:
parent
b68f160bce
commit
4005673ae8
16 changed files with 1188 additions and 171 deletions
|
|
@ -29,6 +29,40 @@ Chaque entrée suit ce format :
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## [Unreleased] — 2026-04-25 — Sprint UI Polish — Sidebar + Topbar + Dashboard
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- `src/app/Topbar.tsx` — topbar sticky avec backdrop-blur, breadcrumb "Expria › {page}", barre de recherche (placeholder), icônes raccourcis clavier et notifications.
|
||||||
|
- `src/app/route-titles.ts` — mapping centralisé pathname → titre de page, consommé par Topbar.
|
||||||
|
- `src/features/dashboard/components/NclcHero.tsx` — carte hero NCLC avec jauge horizontale 5→10 + anneau SVG score circulaire. Supporte état placeholder (Free/vide).
|
||||||
|
- `src/features/dashboard/components/StatCards.tsx` — 3 cartes métriques (simulations restantes, NCLC estimé, dernier score avec delta coloré).
|
||||||
|
- `src/features/dashboard/components/RecentSimulations.tsx` — liste 3 dernières simulations avec badge NCLC coloré + navigation vers `/rapport/:id`.
|
||||||
|
- `src/features/dashboard/components/NextStepCard.tsx` — carte "Prochaine étape" recommandée, contenu statique par plan.
|
||||||
|
- `src/features/dashboard/components/DashboardFreeView.tsx` — vue dashboard Free (hero placeholder, stat cards, premiers pas, PaywallBanner).
|
||||||
|
- `src/features/dashboard/components/DashboardStandardView.tsx` — vue dashboard Standard (hero NCLC dernière simu, simulations récentes, NextStepCard).
|
||||||
|
- `src/features/dashboard/components/DashboardPremiumView.tsx` — vue dashboard Premium (tout Standard + MonProfilPreparation).
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- `src/app/Sidebar.tsx` — icônes lucide-react (LayoutGrid, Pencil, Mic, etc.), cadenas Lock sur items verrouillés, badge upgrade ArrowUpCircle sur "Mon plan", user footer (avatar initiales + nom + plan + ThemeToggle), logo header "EX|PRIA" avec séparateur et sous-titre.
|
||||||
|
- `src/app/AppLayout.tsx` — intégration Topbar sticky, padding reporté sur wrapper contenu.
|
||||||
|
- `src/features/dashboard/pages/DashboardPage.tsx` — refactoré en orchestrateur : routing vers Free/Standard/Premium via `hasAccess`. Aucun `plan === 'xxx'` (Règle D).
|
||||||
|
- `src/features/dashboard/components/PaywallBanner.tsx` — refonte full-width + correction tokens morts Boréal (`border-brand-100`, `dark:border-brand/20`).
|
||||||
|
|
||||||
|
### Removed
|
||||||
|
|
||||||
|
- `src/app/MobileHeader.tsx` — fonctionnalité reprise par Topbar + Sidebar (0 consommateur confirmé par grep).
|
||||||
|
|
||||||
|
### Notes
|
||||||
|
|
||||||
|
- Tests : 122/122 verts. Typecheck : 0 erreur.
|
||||||
|
- Contenu NextStepCard statique par plan (pas d'endpoint backend dédié).
|
||||||
|
- Hero NCLC : Premium → usePatterns, Standard → NCLC dernière simulation, Free → état placeholder.
|
||||||
|
- Timeout API intermittent (cold start Render) préexistant — cause le fallback temporaire plan=free au chargement initial.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## [Unreleased] — 2026-04-24 — Sprint DA Charcoal — Reskin complet
|
## [Unreleased] — 2026-04-24 — Sprint DA Charcoal — Reskin complet
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
|
||||||
|
|
@ -94,6 +94,13 @@
|
||||||
- Renommage tokens sur ~45 composants + inversion dark:/light: shadcn
|
- Renommage tokens sur ~45 composants + inversion dark:/light: shadcn
|
||||||
- ADR 006 mis à jour
|
- ADR 006 mis à jour
|
||||||
|
|
||||||
|
## Sprint UI Polish — Sidebar + Topbar + Dashboard ✅
|
||||||
|
|
||||||
|
- Sidebar : icônes lucide, cadenas gating, badge upgrade, user footer, logo "EX|PRIA"
|
||||||
|
- Topbar : sticky backdrop-blur, breadcrumb centralisé, recherche placeholder
|
||||||
|
- Dashboard : split Free/Standard/Premium, NclcHero + StatCards + RecentSimulations + NextStepCard + PaywallBanner refonte
|
||||||
|
- MobileHeader supprimé (remplacé par Topbar)
|
||||||
|
|
||||||
## Sprint 4 — Simulations EO (audio)
|
## Sprint 4 — Simulations EO (audio)
|
||||||
|
|
||||||
16. MediaRecorder + upload audio EO T1/T3
|
16. MediaRecorder + upload audio EO T1/T3
|
||||||
|
|
|
||||||
|
|
@ -1,20 +1,20 @@
|
||||||
/**
|
/**
|
||||||
* Layout applicatif — enveloppe toutes les routes privées.
|
* Layout applicatif — enveloppe toutes les routes privées.
|
||||||
*
|
*
|
||||||
* Desktop (≥ 1024px) : Sidebar fixe 240px + zone contenu principale.
|
* Desktop (≥ 1024px) : Sidebar fixe 230px + Topbar sticky + zone contenu.
|
||||||
* Mobile (< 1024px) : MobileHeader sticky + drawer slide-in + BottomNav fixe.
|
* Mobile (< 1024px) : Topbar avec hamburger + drawer slide-in + BottomNav fixe.
|
||||||
*
|
*
|
||||||
* Le drawer mobile se ferme automatiquement à chaque changement de route
|
* Le drawer mobile se ferme automatiquement à chaque changement de route
|
||||||
* (useEffect sur location.pathname).
|
* (useEffect sur location.pathname).
|
||||||
*
|
*
|
||||||
* Règle L : tokens Direction H exclusivement.
|
* Règle L : tokens du design system exclusivement.
|
||||||
* Règle H : aucune logique métier — plan lu depuis le cache TanStack Query.
|
* Règle H : aucune logique métier — plan lu depuis le cache TanStack Query.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import { useLocation } from 'react-router-dom'
|
import { useLocation } from 'react-router-dom'
|
||||||
import { Sidebar } from './Sidebar'
|
import { Sidebar } from './Sidebar'
|
||||||
import { MobileHeader } from './MobileHeader'
|
import { Topbar } from './Topbar'
|
||||||
import { BottomNav } from './BottomNav'
|
import { BottomNav } from './BottomNav'
|
||||||
import { usePlan } from '@/features/dashboard/hooks/usePlan'
|
import { usePlan } from '@/features/dashboard/hooks/usePlan'
|
||||||
import { cn } from '@/shared/lib/utils'
|
import { cn } from '@/shared/lib/utils'
|
||||||
|
|
@ -31,8 +31,6 @@ export function AppLayout({ children }: AppLayoutProps) {
|
||||||
const plan: Plan = data?.plan ?? 'free'
|
const plan: Plan = data?.plan ?? 'free'
|
||||||
|
|
||||||
// Ferme le drawer à chaque changement de route.
|
// Ferme le drawer à chaque changement de route.
|
||||||
// Synchronisation UI → router state : pattern légitime (source externe = React
|
|
||||||
// Router). Bail-out React si déjà fermé = zéro cascading render en pratique.
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||||
setIsMobileMenuOpen(false)
|
setIsMobileMenuOpen(false)
|
||||||
|
|
@ -51,9 +49,6 @@ export function AppLayout({ children }: AppLayoutProps) {
|
||||||
<Sidebar plan={plan} />
|
<Sidebar plan={plan} />
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
{/* ── MOBILE — Header sticky ─────────────────────────────────── */}
|
|
||||||
<MobileHeader onMenuOpen={() => setIsMobileMenuOpen(true)} />
|
|
||||||
|
|
||||||
{/* ── MOBILE — Drawer overlay ────────────────────────────────── */}
|
{/* ── MOBILE — Drawer overlay ────────────────────────────────── */}
|
||||||
<div
|
<div
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
|
|
@ -81,6 +76,7 @@ export function AppLayout({ children }: AppLayoutProps) {
|
||||||
className="min-h-screen pb-16 lg:pb-0 lg:pl-[230px]"
|
className="min-h-screen pb-16 lg:pb-0 lg:pl-[230px]"
|
||||||
style={{ background: mainBackground }}
|
style={{ background: mainBackground }}
|
||||||
>
|
>
|
||||||
|
<Topbar onMobileMenuOpen={() => setIsMobileMenuOpen(true)} />
|
||||||
<div className="mx-auto max-w-[1100px] px-5 py-6 lg:px-9 lg:py-9">{children}</div>
|
<div className="mx-auto max-w-[1100px] px-5 py-6 lg:px-9 lg:py-9">{children}</div>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,35 +0,0 @@
|
||||||
/**
|
|
||||||
* 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-border 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-secondary transition-colors duration-150 hover:bg-surface-hover hover:text-ink-primary focus-visible:outline-none focus-visible:shadow-focus"
|
|
||||||
>
|
|
||||||
<Menu className="size-5" aria-hidden="true" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -8,36 +8,103 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { NavLink } from 'react-router-dom'
|
import { NavLink } from 'react-router-dom'
|
||||||
import { Lock } from 'lucide-react'
|
import {
|
||||||
|
Activity,
|
||||||
|
ArrowUpCircle,
|
||||||
|
BookOpen,
|
||||||
|
Clock,
|
||||||
|
FileText,
|
||||||
|
LayoutGrid,
|
||||||
|
Lock,
|
||||||
|
Mic,
|
||||||
|
Pencil,
|
||||||
|
Settings,
|
||||||
|
User as UserIcon,
|
||||||
|
type LucideIcon,
|
||||||
|
} from 'lucide-react'
|
||||||
import { hasAccess } from '@/entities/user/lib'
|
import { hasAccess } from '@/entities/user/lib'
|
||||||
import { Logo } from '@/shared/components/Logo'
|
import { Logo } from '@/shared/components/Logo'
|
||||||
import { ThemeToggle } from '@/shared/components/ThemeToggle'
|
import { ThemeToggle } from '@/shared/components/ThemeToggle'
|
||||||
|
import { useAuth } from '@/features/auth/hooks/useAuth'
|
||||||
import { cn } from '@/shared/lib/utils'
|
import { cn } from '@/shared/lib/utils'
|
||||||
import type { Feature, Plan } from '@/entities/user/lib'
|
import type { Feature, Plan } from '@/entities/user/lib'
|
||||||
|
import type { User } from '@/shared/lib/auth-client'
|
||||||
|
|
||||||
interface NavItem {
|
interface NavItem {
|
||||||
label: string
|
label: string
|
||||||
to: string
|
to: string
|
||||||
feature: Feature | null
|
feature: Feature | null
|
||||||
|
Icon: LucideIcon
|
||||||
|
/**
|
||||||
|
* Affiche un badge upgrade (flèche bleue) à droite du label quand
|
||||||
|
* l'utilisateur peut encore passer à un plan supérieur. Aujourd'hui
|
||||||
|
* utilisé uniquement sur "Mon plan".
|
||||||
|
*/
|
||||||
|
showUpgradeWhenUpgradable?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const PREPARE_ITEMS: readonly NavItem[] = [
|
const PREPARE_ITEMS: readonly NavItem[] = [
|
||||||
{ label: 'Tableau de bord', to: '/dashboard', feature: null },
|
{ label: 'Tableau de bord', to: '/dashboard', feature: null, Icon: LayoutGrid },
|
||||||
{ label: 'Expression Écrite', to: '/simulation/ee', feature: null },
|
{ label: 'Expression Écrite', to: '/simulation/ee', feature: null, Icon: Pencil },
|
||||||
{ label: 'Expression Orale', to: '/simulation/eo', feature: null },
|
{ label: 'Expression Orale', to: '/simulation/eo', feature: null, Icon: Mic },
|
||||||
{ label: 'Examen blanc', to: '/examen', feature: 'exam_mode' },
|
{ label: 'Examen blanc', to: '/examen', feature: 'exam_mode', Icon: FileText },
|
||||||
{ label: 'Progression', to: '/progression', feature: 'pattern_analysis' },
|
{ label: 'Progression', to: '/progression', feature: 'pattern_analysis', Icon: Activity },
|
||||||
{ label: 'Méthodologie', to: '/methodologie', feature: null },
|
{ label: 'Méthodologie', to: '/methodologie', feature: null, Icon: BookOpen },
|
||||||
{ label: 'Historique', to: '/historique', feature: 'dashboard' },
|
{ label: 'Historique', to: '/historique', feature: 'dashboard', Icon: Clock },
|
||||||
]
|
]
|
||||||
|
|
||||||
const ACCOUNT_ITEMS: readonly NavItem[] = [
|
const ACCOUNT_ITEMS: readonly NavItem[] = [
|
||||||
{ label: 'Mon plan', to: '/plan', feature: null },
|
{
|
||||||
{ label: 'Paramètres', to: '/parametres', feature: null },
|
label: 'Mon plan',
|
||||||
|
to: '/plan',
|
||||||
|
feature: null,
|
||||||
|
Icon: UserIcon,
|
||||||
|
showUpgradeWhenUpgradable: true,
|
||||||
|
},
|
||||||
|
{ label: 'Paramètres', to: '/parametres', feature: null, Icon: Settings },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
const PLAN_LABELS: Record<Plan, string> = {
|
||||||
|
free: 'Plan Découverte',
|
||||||
|
standard: 'Plan Standard',
|
||||||
|
premium: 'Plan Premium',
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Proxy "peut encore upgrader". Examen blanc est exclusif Premium
|
||||||
|
* (PLANS_TARIFAIRES.md §4), donc son absence d'accès = plan en dessous
|
||||||
|
* du top-tier. Utilisé uniquement pour affichage UX, pas un check de
|
||||||
|
* permission (Règle D respectée — on passe par hasAccess).
|
||||||
|
*/
|
||||||
|
function isUpgradable(plan: Plan): boolean {
|
||||||
|
return !hasAccess(plan, 'exam_mode')
|
||||||
|
}
|
||||||
|
|
||||||
|
function getInitials(user: User | null): string {
|
||||||
|
if (!user) return '··'
|
||||||
|
const fullName = user.user_metadata?.full_name as string | undefined
|
||||||
|
if (fullName) {
|
||||||
|
const parts = fullName.trim().split(/\s+/)
|
||||||
|
if (parts.length >= 2 && parts[0] && parts[parts.length - 1]) {
|
||||||
|
return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase()
|
||||||
|
}
|
||||||
|
return fullName.slice(0, 2).toUpperCase()
|
||||||
|
}
|
||||||
|
const local = user.email?.split('@')[0] ?? ''
|
||||||
|
return local.slice(0, 2).toUpperCase() || '··'
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDisplayName(user: User | null): string {
|
||||||
|
if (!user) return 'Invité'
|
||||||
|
const fullName = user.user_metadata?.full_name as string | undefined
|
||||||
|
if (fullName) return fullName
|
||||||
|
return user.email?.split('@')[0] ?? 'Invité'
|
||||||
|
}
|
||||||
|
|
||||||
function SidebarItem({ item, plan }: { item: NavItem; plan: Plan }) {
|
function SidebarItem({ item, plan }: { item: NavItem; plan: Plan }) {
|
||||||
const locked = item.feature !== null && !hasAccess(plan, item.feature)
|
const locked = item.feature !== null && !hasAccess(plan, item.feature)
|
||||||
|
const showUpgrade = item.showUpgradeWhenUpgradable === true && isUpgradable(plan)
|
||||||
|
const { Icon } = item
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<NavLink
|
<NavLink
|
||||||
|
|
@ -45,7 +112,7 @@ function SidebarItem({ item, plan }: { item: NavItem; plan: Plan }) {
|
||||||
aria-disabled={locked}
|
aria-disabled={locked}
|
||||||
className={({ isActive }) =>
|
className={({ isActive }) =>
|
||||||
cn(
|
cn(
|
||||||
'relative flex items-center justify-between gap-2.5 rounded-lg px-2.5 py-2',
|
'relative flex items-center gap-2.5 rounded-lg px-2.5 py-2',
|
||||||
'text-[13px] font-medium transition-colors',
|
'text-[13px] font-medium transition-colors',
|
||||||
isActive && !locked
|
isActive && !locked
|
||||||
? 'bg-[var(--color-sidebar-nav-active)] font-semibold text-[var(--color-sidebar-text-active)]'
|
? 'bg-[var(--color-sidebar-nav-active)] font-semibold text-[var(--color-sidebar-text-active)]'
|
||||||
|
|
@ -63,10 +130,15 @@ function SidebarItem({ item, plan }: { item: NavItem; plan: Plan }) {
|
||||||
className="absolute bottom-[20%] left-0 top-[20%] w-[3px] rounded-r bg-[var(--color-brand)]"
|
className="absolute bottom-[20%] left-0 top-[20%] w-[3px] rounded-r bg-[var(--color-brand)]"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<span>{item.label}</span>
|
<Icon
|
||||||
{locked && (
|
className={cn('size-4 shrink-0', isActive && !locked ? 'opacity-100' : 'opacity-60')}
|
||||||
<Lock
|
aria-hidden="true"
|
||||||
className="size-3.5 shrink-0 text-[var(--color-sidebar-text)] opacity-60"
|
/>
|
||||||
|
<span className="flex-1">{item.label}</span>
|
||||||
|
{locked && <Lock className="size-3 shrink-0 opacity-40" aria-hidden="true" />}
|
||||||
|
{showUpgrade && !locked && (
|
||||||
|
<ArrowUpCircle
|
||||||
|
className="size-3.5 shrink-0 text-[var(--color-brand-text)]"
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
@ -103,6 +175,28 @@ function SidebarSection({
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function UserFooter({ plan }: { plan: Plan }) {
|
||||||
|
const { user } = useAuth()
|
||||||
|
const initials = getInitials(user)
|
||||||
|
const displayName = getDisplayName(user)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2.5">
|
||||||
|
<span
|
||||||
|
aria-hidden="true"
|
||||||
|
className="flex size-8 shrink-0 items-center justify-center rounded-full border border-white/15 bg-white/10 text-xs font-bold text-white"
|
||||||
|
>
|
||||||
|
{initials}
|
||||||
|
</span>
|
||||||
|
<div className="min-w-0 flex-1 leading-tight">
|
||||||
|
<p className="truncate text-[12.5px] font-semibold text-white">{displayName}</p>
|
||||||
|
<p className="text-[10.5px] text-white/40">{PLAN_LABELS[plan]}</p>
|
||||||
|
</div>
|
||||||
|
<ThemeToggle className="shrink-0 text-white/60 hover:text-white" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
interface SidebarProps {
|
interface SidebarProps {
|
||||||
plan: Plan
|
plan: Plan
|
||||||
}
|
}
|
||||||
|
|
@ -110,9 +204,15 @@ interface SidebarProps {
|
||||||
export function Sidebar({ plan }: SidebarProps) {
|
export function Sidebar({ plan }: SidebarProps) {
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full w-full flex-col border-r border-[var(--color-sidebar-border)] bg-[var(--color-sidebar-bg)]">
|
<div className="flex h-full w-full flex-col border-r border-[var(--color-sidebar-border)] bg-[var(--color-sidebar-bg)]">
|
||||||
{/* Logo — forcé en blanc : la sidebar est navy dans les deux thèmes */}
|
{/* Logo header */}
|
||||||
<div className="flex h-14 shrink-0 items-center border-b border-[var(--color-sidebar-border)] px-4">
|
<div className="flex h-14 shrink-0 items-center gap-2.5 border-b border-[var(--color-sidebar-border)] px-4">
|
||||||
<Logo size="sm" className="text-white" />
|
<Logo variant="icon" size="sm" className="text-white" />
|
||||||
|
<div className="flex flex-col leading-none">
|
||||||
|
<span className="text-lg font-extrabold tracking-wide text-white">
|
||||||
|
EX<span className="opacity-30">|</span>PRIA
|
||||||
|
</span>
|
||||||
|
<span className="mt-0.5 text-[10px] text-white/35">Préparation TCF Canada</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Navigation */}
|
{/* Navigation */}
|
||||||
|
|
@ -124,12 +224,9 @@ export function Sidebar({ plan }: SidebarProps) {
|
||||||
<SidebarSection label="Compte" items={ACCOUNT_ITEMS} plan={plan} />
|
<SidebarSection label="Compte" items={ACCOUNT_ITEMS} plan={plan} />
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
{/* Footer — ThemeToggle */}
|
{/* Footer — avatar + user info + ThemeToggle */}
|
||||||
<div className="shrink-0 border-t border-[var(--color-sidebar-border)] px-4 py-3">
|
<div className="shrink-0 border-t border-[var(--color-sidebar-border)] px-3 py-3">
|
||||||
<div className="flex items-center justify-between">
|
<UserFooter plan={plan} />
|
||||||
<span className="text-xs text-[var(--color-sidebar-section-label)]">Thème</span>
|
|
||||||
<ThemeToggle />
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
86
src/app/Topbar.tsx
Normal file
86
src/app/Topbar.tsx
Normal file
|
|
@ -0,0 +1,86 @@
|
||||||
|
/**
|
||||||
|
* Topbar sticky au-dessus du contenu principal.
|
||||||
|
*
|
||||||
|
* - Breadcrumb "Expria › <pageTitle>" (gauche).
|
||||||
|
* - Hamburger (mobile uniquement) qui ouvre le drawer Sidebar.
|
||||||
|
* - Barre de recherche placeholder (non fonctionnelle — visuel only).
|
||||||
|
* - Icônes Command (raccourcis clavier) et Bell (notifications) —
|
||||||
|
* non fonctionnelles, décoratives dans ce sprint.
|
||||||
|
*
|
||||||
|
* Règle L : tokens du design system exclusivement.
|
||||||
|
* Règle H : aucune logique métier — navigation uniquement.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useLocation } from 'react-router-dom'
|
||||||
|
import { Bell, Command, Menu, Search } from 'lucide-react'
|
||||||
|
import { getRouteTitle } from './route-titles'
|
||||||
|
|
||||||
|
interface TopbarProps {
|
||||||
|
onMobileMenuOpen: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Topbar({ onMobileMenuOpen }: TopbarProps) {
|
||||||
|
const { pathname } = useLocation()
|
||||||
|
const title = getRouteTitle(pathname)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<header
|
||||||
|
role="banner"
|
||||||
|
className="sticky top-0 z-10 flex h-14 items-center gap-3 border-b border-border bg-[var(--color-topbar-bg)] px-5 backdrop-blur-md lg:px-9"
|
||||||
|
>
|
||||||
|
{/* Hamburger (mobile only) */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label="Ouvrir le menu de navigation"
|
||||||
|
onClick={onMobileMenuOpen}
|
||||||
|
className="flex size-9 shrink-0 items-center justify-center rounded-md text-ink-secondary transition-colors hover:bg-surface-hover hover:text-ink-primary focus-visible:outline-none focus-visible:shadow-focus lg:hidden"
|
||||||
|
>
|
||||||
|
<Menu className="size-5" aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Breadcrumb */}
|
||||||
|
<nav aria-label="Fil d'Ariane" className="min-w-0 flex-1">
|
||||||
|
<ol className="flex items-center gap-1.5 text-sm">
|
||||||
|
<li className="text-ink-secondary">Expria</li>
|
||||||
|
<li aria-hidden="true" className="text-ink-tertiary">
|
||||||
|
›
|
||||||
|
</li>
|
||||||
|
<li className="truncate font-semibold text-ink-primary">{title}</li>
|
||||||
|
</ol>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
{/* Right cluster: search + command + bell */}
|
||||||
|
<div className="flex shrink-0 items-center gap-2">
|
||||||
|
<div className="relative hidden sm:block">
|
||||||
|
<Search
|
||||||
|
className="pointer-events-none absolute left-2.5 top-1/2 size-4 -translate-y-1/2 text-ink-tertiary"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="search"
|
||||||
|
disabled
|
||||||
|
placeholder="Rechercher…"
|
||||||
|
aria-label="Rechercher"
|
||||||
|
className="h-8 w-[200px] rounded-[var(--radius-sm)] border border-border bg-surface pl-8 pr-3 text-sm text-ink-primary placeholder:text-ink-tertiary disabled:cursor-not-allowed"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label="Raccourcis clavier"
|
||||||
|
disabled
|
||||||
|
className="hidden size-8 items-center justify-center rounded-md text-ink-tertiary opacity-60 lg:flex"
|
||||||
|
>
|
||||||
|
<Command className="size-4" aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label="Notifications"
|
||||||
|
disabled
|
||||||
|
className="flex size-8 items-center justify-center rounded-md text-ink-tertiary opacity-60"
|
||||||
|
>
|
||||||
|
<Bell className="size-4" aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
)
|
||||||
|
}
|
||||||
31
src/app/route-titles.ts
Normal file
31
src/app/route-titles.ts
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
/**
|
||||||
|
* Mapping centralisé pathname → titre de page.
|
||||||
|
*
|
||||||
|
* Consommé par la Topbar (breadcrumb "Expria › <titre>") et tout composant
|
||||||
|
* qui a besoin du libellé humain d'une route. Maintient la source unique
|
||||||
|
* — pas de duplication dans Sidebar/Topbar/helmet.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const STATIC_ROUTES: Readonly<Record<string, string>> = {
|
||||||
|
'/dashboard': 'Tableau de bord',
|
||||||
|
'/simulation/ee': 'Expression Écrite',
|
||||||
|
'/simulation/eo': 'Expression Orale',
|
||||||
|
'/sujets': 'Choisir un sujet',
|
||||||
|
'/examen': 'Examen blanc',
|
||||||
|
'/progression': 'Progression',
|
||||||
|
'/methodologie': 'Méthodologie',
|
||||||
|
'/historique': 'Historique',
|
||||||
|
'/plan': 'Mon plan',
|
||||||
|
'/parametres': 'Paramètres',
|
||||||
|
'/login': 'Connexion',
|
||||||
|
'/register': 'Inscription',
|
||||||
|
'/design-system': 'Design System',
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getRouteTitle(pathname: string): string {
|
||||||
|
const exact = STATIC_ROUTES[pathname]
|
||||||
|
if (exact) return exact
|
||||||
|
if (pathname.startsWith('/rapport/')) return 'Rapport'
|
||||||
|
if (pathname === '/' || pathname === '') return 'Tableau de bord'
|
||||||
|
return 'Expria'
|
||||||
|
}
|
||||||
111
src/features/dashboard/components/DashboardFreeView.tsx
Normal file
111
src/features/dashboard/components/DashboardFreeView.tsx
Normal file
|
|
@ -0,0 +1,111 @@
|
||||||
|
/**
|
||||||
|
* DashboardFreeView — vue Dashboard pour le plan Découverte.
|
||||||
|
*
|
||||||
|
* Spécificités Free :
|
||||||
|
* - Pas d'appel `useSimulationsList` (gate 'dashboard' à false côté backend).
|
||||||
|
* - Hero NCLC en état placeholder (pas d'historique lisible).
|
||||||
|
* - Stat cards avec "NCLC estimé —" et "Dernier score —".
|
||||||
|
* - Recommandation statique vers la première simulation EE T2.
|
||||||
|
* - Bannière upsell Standard en bas.
|
||||||
|
*
|
||||||
|
* Règle D : aucun `plan === 'free'` — c'est le parent (DashboardPage) qui
|
||||||
|
* route vers cette vue via hasAccess.
|
||||||
|
* Règle H : aucune logique métier — les données viennent des props.
|
||||||
|
* Règle L : tokens du design system exclusivement.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useNavigate } from 'react-router-dom'
|
||||||
|
import { Plus } from 'lucide-react'
|
||||||
|
import { Button } from '@/shared/ui/Button'
|
||||||
|
import { Badge } from '@/shared/ui/Badge'
|
||||||
|
import { NclcHero } from './NclcHero'
|
||||||
|
import { StatCards } from './StatCards'
|
||||||
|
import { NextStepCard } from './NextStepCard'
|
||||||
|
import { PaywallBanner } from './PaywallBanner'
|
||||||
|
|
||||||
|
interface DashboardFreeViewProps {
|
||||||
|
displayName: string
|
||||||
|
simulationsUsed: number
|
||||||
|
simulationsRemaining: number
|
||||||
|
canStartSimulation: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const FREE_CONSEIL =
|
||||||
|
"Commencez par une simulation d'Expression Écrite pour découvrir votre niveau. " +
|
||||||
|
'Le rapport détaillé et le suivi NCLC se débloquent avec le plan Standard.'
|
||||||
|
|
||||||
|
export function DashboardFreeView({
|
||||||
|
displayName,
|
||||||
|
simulationsUsed,
|
||||||
|
simulationsRemaining,
|
||||||
|
canStartSimulation,
|
||||||
|
}: DashboardFreeViewProps) {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Header */}
|
||||||
|
<header className="flex flex-wrap items-center justify-between gap-4">
|
||||||
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
|
<h1 className="text-2xl font-bold text-ink-primary">Bonjour, {displayName}</h1>
|
||||||
|
<Badge variant="plan" planValue="free">
|
||||||
|
Plan Découverte
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<Button variant="secondary" size="sm" onClick={() => navigate('/plan')}>
|
||||||
|
Passer en Premium →
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
size="sm"
|
||||||
|
icon={<Plus className="size-4" />}
|
||||||
|
disabled={!canStartSimulation}
|
||||||
|
onClick={() => navigate('/simulation/ee')}
|
||||||
|
>
|
||||||
|
Nouvelle simulation
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* Hero NCLC — placeholder en Free */}
|
||||||
|
<NclcHero currentNclc={null} conseil={FREE_CONSEIL} lastScore={null} />
|
||||||
|
|
||||||
|
{/* Stat cards — NCLC et dernier score vides */}
|
||||||
|
<StatCards
|
||||||
|
plan="free"
|
||||||
|
simulationsUsed={simulationsUsed}
|
||||||
|
simulationsRemaining={simulationsRemaining}
|
||||||
|
recentSimulations={[]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Prochaine étape + (pas de simulations récentes en Free) */}
|
||||||
|
<div className="grid gap-4 lg:grid-cols-[1fr_360px]">
|
||||||
|
<section
|
||||||
|
aria-label="Premiers pas"
|
||||||
|
className="rounded-[var(--radius-md)] border border-border bg-surface p-6"
|
||||||
|
>
|
||||||
|
<p className="text-[11px] font-semibold uppercase tracking-wider text-ink-tertiary">
|
||||||
|
Pour bien démarrer
|
||||||
|
</p>
|
||||||
|
<h2 className="mt-2 text-lg font-semibold text-ink-primary">Votre première simulation</h2>
|
||||||
|
<p className="mt-2 text-sm text-ink-secondary">
|
||||||
|
Choisissez une tâche d'Expression Écrite pour obtenir un premier score et une estimation
|
||||||
|
NCLC. Vos 5 simulations gratuites vous attendent.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<NextStepCard
|
||||||
|
title="Démarrez par l'Écrit T2"
|
||||||
|
conseil="Article d'opinion — le format le plus représentatif du TCF Canada."
|
||||||
|
tags={['20 min', '120-150 mots']}
|
||||||
|
ctaLabel="Commencer"
|
||||||
|
ctaTo="/simulation/ee"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Bannière upsell */}
|
||||||
|
<PaywallBanner />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
96
src/features/dashboard/components/DashboardPremiumView.tsx
Normal file
96
src/features/dashboard/components/DashboardPremiumView.tsx
Normal file
|
|
@ -0,0 +1,96 @@
|
||||||
|
/**
|
||||||
|
* DashboardPremiumView — vue Dashboard pour le plan Premium.
|
||||||
|
*
|
||||||
|
* Spécificités Premium :
|
||||||
|
* - Historique via `useSimulationsList`.
|
||||||
|
* - NCLC = dernière simulation (comme Standard).
|
||||||
|
* - Indice de préparation 0–100 via `MonProfilPreparation` (patterns).
|
||||||
|
* - Pas de CTA "Passer en Premium" — déjà au top-tier.
|
||||||
|
*
|
||||||
|
* Règle D : aucun `plan === 'premium'` — routing via hasAccess côté parent.
|
||||||
|
* Règle H : logique d'affichage uniquement.
|
||||||
|
* Règle L : tokens du design system exclusivement.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useNavigate } from 'react-router-dom'
|
||||||
|
import { Plus } from 'lucide-react'
|
||||||
|
import { Button } from '@/shared/ui/Button'
|
||||||
|
import { Badge } from '@/shared/ui/Badge'
|
||||||
|
import { useSimulationsList } from '@/features/historique/hooks/useSimulationsList'
|
||||||
|
import { NclcHero } from './NclcHero'
|
||||||
|
import { StatCards } from './StatCards'
|
||||||
|
import { RecentSimulations } from './RecentSimulations'
|
||||||
|
import { NextStepCard } from './NextStepCard'
|
||||||
|
import { MonProfilPreparation } from './MonProfilPreparation'
|
||||||
|
|
||||||
|
interface DashboardPremiumViewProps {
|
||||||
|
displayName: string
|
||||||
|
simulationsUsed: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const PREMIUM_CONSEIL =
|
||||||
|
'Votre préparation est avancée. Enchaînez un Examen blanc chaque semaine pour verrouiller votre NCLC cible.'
|
||||||
|
|
||||||
|
export function DashboardPremiumView({ displayName, simulationsUsed }: DashboardPremiumViewProps) {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const { data } = useSimulationsList(1, 5)
|
||||||
|
const recent = data?.data ?? []
|
||||||
|
const totalCount = data?.pagination.total ?? 0
|
||||||
|
|
||||||
|
const firstWithNclc = recent.find((s) => s.nclc !== null) ?? null
|
||||||
|
const lastNclc = firstWithNclc?.nclc ?? null
|
||||||
|
|
||||||
|
const firstWithScore = recent.find((s) => s.score !== null) ?? null
|
||||||
|
const lastScore =
|
||||||
|
firstWithScore && firstWithScore.score !== null
|
||||||
|
? { value: firstWithScore.score, max: 20 }
|
||||||
|
: null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<header className="flex flex-wrap items-center justify-between gap-4">
|
||||||
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
|
<h1 className="text-2xl font-bold text-ink-primary">Bonjour, {displayName}</h1>
|
||||||
|
<Badge variant="plan" planValue="premium">
|
||||||
|
Plan Premium
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
size="sm"
|
||||||
|
icon={<Plus className="size-4" />}
|
||||||
|
onClick={() => navigate('/simulation/ee')}
|
||||||
|
>
|
||||||
|
Nouvelle simulation
|
||||||
|
</Button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<NclcHero
|
||||||
|
currentNclc={lastNclc}
|
||||||
|
nclcLabel="NCLC dernière simulation"
|
||||||
|
conseil={PREMIUM_CONSEIL}
|
||||||
|
lastScore={lastScore}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<StatCards
|
||||||
|
plan="premium"
|
||||||
|
simulationsUsed={simulationsUsed}
|
||||||
|
simulationsRemaining={null}
|
||||||
|
recentSimulations={recent}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="grid gap-4 lg:grid-cols-[1fr_360px]">
|
||||||
|
<RecentSimulations items={recent} totalCount={totalCount} />
|
||||||
|
<NextStepCard
|
||||||
|
title="Lancez un Examen blanc"
|
||||||
|
conseil="Conditions réelles : 60 min, 3 tâches, envoi automatique. Reproduisez la pression du jour J."
|
||||||
|
tags={['60 min', 'Examen']}
|
||||||
|
ctaLabel="Démarrer"
|
||||||
|
ctaTo="/examen"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<MonProfilPreparation plan="premium" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
102
src/features/dashboard/components/DashboardStandardView.tsx
Normal file
102
src/features/dashboard/components/DashboardStandardView.tsx
Normal file
|
|
@ -0,0 +1,102 @@
|
||||||
|
/**
|
||||||
|
* DashboardStandardView — vue Dashboard pour le plan Standard.
|
||||||
|
*
|
||||||
|
* Spécificités Standard :
|
||||||
|
* - Historique lisible via `useSimulationsList`.
|
||||||
|
* - NCLC estimé = NCLC de la dernière simulation (premier item avec nclc non-null).
|
||||||
|
* - Pas de `MonProfilPreparation` (pattern_analysis gated Premium).
|
||||||
|
* - CTA "Passer en Premium →" + "+ Nouvelle simulation".
|
||||||
|
*
|
||||||
|
* Règle D : aucun `plan === 'standard'` — c'est le parent (DashboardPage) qui
|
||||||
|
* route vers cette vue via hasAccess.
|
||||||
|
* Règle H : logique d'affichage uniquement.
|
||||||
|
* Règle L : tokens du design system exclusivement.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useNavigate } from 'react-router-dom'
|
||||||
|
import { Plus } from 'lucide-react'
|
||||||
|
import { Button } from '@/shared/ui/Button'
|
||||||
|
import { Badge } from '@/shared/ui/Badge'
|
||||||
|
import { useSimulationsList } from '@/features/historique/hooks/useSimulationsList'
|
||||||
|
import { NclcHero } from './NclcHero'
|
||||||
|
import { StatCards } from './StatCards'
|
||||||
|
import { RecentSimulations } from './RecentSimulations'
|
||||||
|
import { NextStepCard } from './NextStepCard'
|
||||||
|
|
||||||
|
interface DashboardStandardViewProps {
|
||||||
|
displayName: string
|
||||||
|
simulationsUsed: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const STD_CONSEIL =
|
||||||
|
'Votre préparation avance. Continuez la régularité — visez une simulation tous les deux jours pour sécuriser votre NCLC cible.'
|
||||||
|
|
||||||
|
export function DashboardStandardView({
|
||||||
|
displayName,
|
||||||
|
simulationsUsed,
|
||||||
|
}: DashboardStandardViewProps) {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const { data } = useSimulationsList(1, 5)
|
||||||
|
const recent = data?.data ?? []
|
||||||
|
const totalCount = data?.pagination.total ?? 0
|
||||||
|
|
||||||
|
const firstWithNclc = recent.find((s) => s.nclc !== null) ?? null
|
||||||
|
const lastNclc = firstWithNclc?.nclc ?? null
|
||||||
|
|
||||||
|
const firstWithScore = recent.find((s) => s.score !== null) ?? null
|
||||||
|
const lastScore =
|
||||||
|
firstWithScore && firstWithScore.score !== null
|
||||||
|
? { value: firstWithScore.score, max: 20 }
|
||||||
|
: null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<header className="flex flex-wrap items-center justify-between gap-4">
|
||||||
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
|
<h1 className="text-2xl font-bold text-ink-primary">Bonjour, {displayName}</h1>
|
||||||
|
<Badge variant="plan" planValue="standard">
|
||||||
|
Plan Standard
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<Button variant="secondary" size="sm" onClick={() => navigate('/plan')}>
|
||||||
|
Passer en Premium →
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
size="sm"
|
||||||
|
icon={<Plus className="size-4" />}
|
||||||
|
onClick={() => navigate('/simulation/ee')}
|
||||||
|
>
|
||||||
|
Nouvelle simulation
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<NclcHero
|
||||||
|
currentNclc={lastNclc}
|
||||||
|
nclcLabel="NCLC dernière simulation"
|
||||||
|
conseil={STD_CONSEIL}
|
||||||
|
lastScore={lastScore}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<StatCards
|
||||||
|
plan="standard"
|
||||||
|
simulationsUsed={simulationsUsed}
|
||||||
|
simulationsRemaining={null}
|
||||||
|
recentSimulations={recent}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="grid gap-4 lg:grid-cols-[1fr_360px]">
|
||||||
|
<RecentSimulations items={recent} totalCount={totalCount} />
|
||||||
|
<NextStepCard
|
||||||
|
title="Travaillez l'Oral T1"
|
||||||
|
conseil="La présentation personnelle est souvent négligée. 10 minutes suffisent pour progresser."
|
||||||
|
tags={['10 min', 'Oral T1']}
|
||||||
|
ctaLabel="Commencer"
|
||||||
|
ctaTo="/simulation/eo"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
159
src/features/dashboard/components/NclcHero.tsx
Normal file
159
src/features/dashboard/components/NclcHero.tsx
Normal file
|
|
@ -0,0 +1,159 @@
|
||||||
|
/**
|
||||||
|
* NclcHero — carte principale du Dashboard.
|
||||||
|
*
|
||||||
|
* Affiche :
|
||||||
|
* - l'indice NCLC courant (via valeur passée par le parent — usePatterns
|
||||||
|
* en Premium, dernière simu en Standard, null en Free) ;
|
||||||
|
* - l'objectif NCLC (défaut 9) et le conseil personnalisé ;
|
||||||
|
* - la jauge horizontale 5 → 10 avec position actuelle + marqueur cible ;
|
||||||
|
* - le dernier score dans un anneau SVG (facultatif).
|
||||||
|
*
|
||||||
|
* Règle H : aucune logique métier — les valeurs sont calculées par le parent.
|
||||||
|
* Règle L : tokens du design system exclusivement.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Card } from '@/shared/ui/Card'
|
||||||
|
|
||||||
|
interface LastScore {
|
||||||
|
value: number
|
||||||
|
max: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface NclcHeroProps {
|
||||||
|
/** NCLC actuel (5–10). `null` = pas de donnée (Free ou historique vide). */
|
||||||
|
currentNclc: number | null
|
||||||
|
/** Libellé du NCLC (ex. "NCLC estimé", "NCLC dernière simulation"). */
|
||||||
|
nclcLabel?: string
|
||||||
|
/** NCLC cible (défaut 9). */
|
||||||
|
targetNclc?: number
|
||||||
|
/** Texte conseil affiché sous le NCLC. */
|
||||||
|
conseil: string
|
||||||
|
/** Dernier score pour l'anneau SVG (optionnel). */
|
||||||
|
lastScore?: LastScore | null
|
||||||
|
}
|
||||||
|
|
||||||
|
const NCLC_MIN = 5
|
||||||
|
const NCLC_MAX = 10
|
||||||
|
const CIRCLE_RADIUS = 44
|
||||||
|
const CIRCLE_CIRCUMFERENCE = 2 * Math.PI * CIRCLE_RADIUS
|
||||||
|
|
||||||
|
function clamp(value: number, min: number, max: number): number {
|
||||||
|
return Math.max(min, Math.min(max, value))
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatNclc(n: number): string {
|
||||||
|
return n.toLocaleString('fr-FR', { maximumFractionDigits: 1 })
|
||||||
|
}
|
||||||
|
|
||||||
|
function nclcToPct(n: number): number {
|
||||||
|
const clamped = clamp(n, NCLC_MIN, NCLC_MAX)
|
||||||
|
return ((clamped - NCLC_MIN) / (NCLC_MAX - NCLC_MIN)) * 100
|
||||||
|
}
|
||||||
|
|
||||||
|
function ScoreRing({ score }: { score: LastScore }) {
|
||||||
|
const pct = clamp((score.value / score.max) * 100, 0, 100)
|
||||||
|
const offset = CIRCLE_CIRCUMFERENCE * (1 - pct / 100)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative size-[140px] shrink-0">
|
||||||
|
<svg className="size-full -rotate-90" viewBox="0 0 100 100" aria-hidden="true">
|
||||||
|
<circle
|
||||||
|
cx="50"
|
||||||
|
cy="50"
|
||||||
|
r={CIRCLE_RADIUS}
|
||||||
|
fill="none"
|
||||||
|
stroke="var(--color-border)"
|
||||||
|
strokeWidth="6"
|
||||||
|
/>
|
||||||
|
<circle
|
||||||
|
cx="50"
|
||||||
|
cy="50"
|
||||||
|
r={CIRCLE_RADIUS}
|
||||||
|
fill="none"
|
||||||
|
stroke="var(--color-success)"
|
||||||
|
strokeWidth="6"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeDasharray={CIRCLE_CIRCUMFERENCE}
|
||||||
|
strokeDashoffset={offset}
|
||||||
|
className="transition-[stroke-dashoffset] duration-500"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<div className="absolute inset-0 flex flex-col items-center justify-center">
|
||||||
|
<span className="text-2xl font-extrabold tabular-nums text-ink-primary">{score.value}</span>
|
||||||
|
<span className="text-xs text-ink-tertiary tabular-nums">/{score.max}</span>
|
||||||
|
<span className="mt-1 text-[10px] font-semibold uppercase tracking-wider text-ink-tertiary">
|
||||||
|
Dernier score
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function NclcHero({
|
||||||
|
currentNclc,
|
||||||
|
nclcLabel = 'NCLC estimé',
|
||||||
|
targetNclc = 9,
|
||||||
|
conseil,
|
||||||
|
lastScore = null,
|
||||||
|
}: NclcHeroProps) {
|
||||||
|
const hasNclc = currentNclc !== null
|
||||||
|
const currentPct = hasNclc ? nclcToPct(currentNclc) : 0
|
||||||
|
const targetPct = nclcToPct(targetNclc)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card variant="raised" className="p-6 lg:p-8">
|
||||||
|
<div className="flex flex-col gap-8 lg:flex-row lg:items-start lg:justify-between">
|
||||||
|
{/* Left block */}
|
||||||
|
<div className="flex-1 space-y-4">
|
||||||
|
<p className="text-[11px] font-semibold uppercase tracking-wider text-ink-tertiary">
|
||||||
|
Indice de préparation TCF Canada
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
|
<p className="text-display font-extrabold tabular-nums leading-none text-ink-primary">
|
||||||
|
{hasNclc ? `NCLC ${formatNclc(currentNclc)}` : 'NCLC —'}
|
||||||
|
</p>
|
||||||
|
<span className="inline-flex items-center rounded-full bg-success-soft px-2.5 py-0.5 text-xs font-semibold text-success">
|
||||||
|
Objectif NCLC {targetNclc}+
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="max-w-prose text-sm text-ink-secondary">{conseil}</p>
|
||||||
|
|
||||||
|
{/* Jauge 5 → 10 */}
|
||||||
|
<div className="space-y-1.5 pt-2">
|
||||||
|
<div className="flex items-center justify-between text-[10px] font-semibold uppercase tracking-wider text-ink-tertiary">
|
||||||
|
<span>NCLC {NCLC_MIN}</span>
|
||||||
|
<span>NCLC {NCLC_MAX}</span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="relative h-2 rounded-full bg-surface-hover"
|
||||||
|
role="progressbar"
|
||||||
|
aria-valuemin={NCLC_MIN}
|
||||||
|
aria-valuemax={NCLC_MAX}
|
||||||
|
aria-valuenow={currentNclc ?? undefined}
|
||||||
|
aria-label={nclcLabel}
|
||||||
|
>
|
||||||
|
{hasNclc && (
|
||||||
|
<div
|
||||||
|
className="absolute inset-y-0 left-0 rounded-full bg-brand transition-[width] duration-500"
|
||||||
|
style={{ width: `${currentPct}%` }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{/* Marqueur cible */}
|
||||||
|
<span
|
||||||
|
aria-hidden="true"
|
||||||
|
className="absolute -top-1 h-4 w-0.5 rounded-full bg-ink-primary"
|
||||||
|
style={{ left: `${targetPct}%` }}
|
||||||
|
title={`Cible NCLC ${targetNclc}`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right block — score ring */}
|
||||||
|
{lastScore && <ScoreRing score={lastScore} />}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
65
src/features/dashboard/components/NextStepCard.tsx
Normal file
65
src/features/dashboard/components/NextStepCard.tsx
Normal file
|
|
@ -0,0 +1,65 @@
|
||||||
|
/**
|
||||||
|
* NextStepCard — encart "Prochaine étape" affiché à droite des simulations.
|
||||||
|
*
|
||||||
|
* Contenu statique par plan pour ce sprint (pas d'endpoint "recommandation"
|
||||||
|
* en V1). Le parent construit le texte, les tags et la route CTA.
|
||||||
|
*
|
||||||
|
* Règle H : aucune logique métier — affichage pur.
|
||||||
|
* Règle L : tokens du design system exclusivement.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Link } from 'react-router-dom'
|
||||||
|
import { ArrowRight, Sparkles } from 'lucide-react'
|
||||||
|
import { Card } from '@/shared/ui/Card'
|
||||||
|
|
||||||
|
interface NextStepCardProps {
|
||||||
|
title: string
|
||||||
|
conseil: string
|
||||||
|
tags: readonly string[]
|
||||||
|
ctaLabel: string
|
||||||
|
ctaTo: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function NextStepCard({ title, conseil, tags, ctaLabel, ctaTo }: NextStepCardProps) {
|
||||||
|
return (
|
||||||
|
<Card variant="raised" className="flex h-full flex-col gap-4 p-5">
|
||||||
|
<p className="text-[11px] font-semibold uppercase tracking-wider text-brand-text">
|
||||||
|
Recommandé
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="flex items-start gap-2.5">
|
||||||
|
<span
|
||||||
|
aria-hidden="true"
|
||||||
|
className="mt-0.5 flex size-8 shrink-0 items-center justify-center rounded-full bg-brand-soft text-brand-text"
|
||||||
|
>
|
||||||
|
<Sparkles className="size-4" />
|
||||||
|
</span>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<h3 className="text-base font-semibold text-ink-primary">{title}</h3>
|
||||||
|
<p className="text-sm text-ink-secondary">{conseil}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{tags.length > 0 && (
|
||||||
|
<ul role="list" className="flex flex-wrap gap-1.5">
|
||||||
|
{tags.map((tag) => (
|
||||||
|
<li
|
||||||
|
key={tag}
|
||||||
|
className="inline-flex items-center rounded-full bg-surface-hover px-2 py-0.5 text-[11px] font-medium text-ink-secondary"
|
||||||
|
>
|
||||||
|
{tag}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Link
|
||||||
|
to={ctaTo}
|
||||||
|
className="mt-auto inline-flex h-9 items-center justify-center gap-1.5 rounded-md bg-brand px-4 text-sm font-semibold text-white transition-colors hover:bg-brand-hover focus-visible:outline-none focus-visible:shadow-focus"
|
||||||
|
>
|
||||||
|
{ctaLabel}
|
||||||
|
<ArrowRight className="size-4" aria-hidden="true" />
|
||||||
|
</Link>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -1,25 +1,42 @@
|
||||||
/**
|
/**
|
||||||
* Bannière inline affichée sur le dashboard pour les utilisateurs Free.
|
* Bannière inline affichée en bas du dashboard pour les utilisateurs Free.
|
||||||
* Présente les features débloquées par Standard et oriente vers /pricing.
|
* Présente les features débloquées par Standard et oriente vers /plan.
|
||||||
* Pas de modale — intégrée dans le flux de la page (cf. PARCOURS_UTILISATEURS §2).
|
*
|
||||||
|
* DA Charcoal : surface-solid + border-border, icône + dans cercle brand.
|
||||||
|
* Intégrée dans le flux de la page (pas de modale) — cf. PARCOURS_UTILISATEURS §2.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { Link } from 'react-router-dom'
|
import { Link } from 'react-router-dom'
|
||||||
import { Button } from '@/shared/components/ui/button'
|
import { Plus } from 'lucide-react'
|
||||||
|
|
||||||
export function PaywallBanner() {
|
export function PaywallBanner() {
|
||||||
return (
|
return (
|
||||||
<div className="rounded-lg border border-brand-100 bg-brand-soft p-4 dark:border-brand/20">
|
<section
|
||||||
<p className="text-sm font-semibold text-ink-primary">Passez à Standard pour débloquer :</p>
|
aria-label="Proposition d'upgrade"
|
||||||
<ul className="mt-2 space-y-1 text-sm text-ink-secondary" role="list">
|
className="flex flex-col items-start gap-4 rounded-[var(--radius-md)] border border-border bg-surface-solid p-5 sm:flex-row sm:items-center"
|
||||||
<li>Simulations illimitées</li>
|
>
|
||||||
<li>Rapport détaillé par critère</li>
|
<span
|
||||||
<li>Historique de vos productions</li>
|
aria-hidden="true"
|
||||||
<li>Suivi de progression</li>
|
className="flex size-10 shrink-0 items-center justify-center rounded-full bg-brand text-white"
|
||||||
</ul>
|
>
|
||||||
<Button asChild size="sm" className="mt-4 w-full">
|
<Plus className="size-5" />
|
||||||
<Link to="/pricing">Passer à Standard</Link>
|
</span>
|
||||||
</Button>
|
|
||||||
</div>
|
<div className="min-w-0 flex-1 space-y-1">
|
||||||
|
<p className="text-sm font-semibold text-ink-primary">
|
||||||
|
Débloque le rapport complet et l'IA de correction détaillée
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-ink-secondary">
|
||||||
|
Plan Standard · simulations illimitées · suivi NCLC dans le temps · 19,90 € / 4 semaines
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Link
|
||||||
|
to="/plan"
|
||||||
|
className="inline-flex h-9 shrink-0 items-center justify-center rounded-md border border-border bg-surface px-4 text-sm font-semibold text-ink-primary transition-colors hover:bg-surface-hover focus-visible:outline-none focus-visible:shadow-focus"
|
||||||
|
>
|
||||||
|
Voir les offres
|
||||||
|
</Link>
|
||||||
|
</section>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
97
src/features/dashboard/components/RecentSimulations.tsx
Normal file
97
src/features/dashboard/components/RecentSimulations.tsx
Normal file
|
|
@ -0,0 +1,97 @@
|
||||||
|
/**
|
||||||
|
* RecentSimulations — liste des 3 dernières simulations sur le Dashboard.
|
||||||
|
*
|
||||||
|
* Chaque item est cliquable (→ /rapport/:id). Badge NCLC coloré selon le niveau,
|
||||||
|
* score /20, date relative, type court (EE · T2 / EO · T1).
|
||||||
|
*
|
||||||
|
* Règle H : aucune logique métier — les données viennent du parent.
|
||||||
|
* Règle L : tokens du design system exclusivement.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Link } from 'react-router-dom'
|
||||||
|
import { ChevronRight } from 'lucide-react'
|
||||||
|
import { Card } from '@/shared/ui/Card'
|
||||||
|
import { formatRelativeDate } from '@/shared/lib/date'
|
||||||
|
import { isEcrit } from '@/entities/production/lib'
|
||||||
|
import type { SimulationListItem, Tache } from '@/entities/production/types'
|
||||||
|
|
||||||
|
interface RecentSimulationsProps {
|
||||||
|
/** Items récents (max 3 affichés). */
|
||||||
|
items: readonly SimulationListItem[]
|
||||||
|
/** Total historique — affiché en badge à droite du titre. */
|
||||||
|
totalCount: number
|
||||||
|
}
|
||||||
|
|
||||||
|
function shortTacheLabel(tache: Tache): string {
|
||||||
|
const [prefix, num] = tache.split('_')
|
||||||
|
return `${prefix} · ${num}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function nclcBadgeClasses(nclc: number | null): string {
|
||||||
|
if (nclc === null) return 'bg-surface-hover text-ink-tertiary'
|
||||||
|
if (nclc >= 9) return 'bg-success-soft text-success'
|
||||||
|
if (nclc >= 7) return 'bg-brand-soft text-brand-text'
|
||||||
|
return 'bg-warning-soft text-warning'
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatNclc(n: number): string {
|
||||||
|
return n.toLocaleString('fr-FR', { maximumFractionDigits: 1 })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RecentSimulations({ items, totalCount }: RecentSimulationsProps) {
|
||||||
|
const visible = items.slice(0, 3)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section aria-label="Simulations récentes" className="space-y-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h2 className="text-base font-semibold text-ink-primary">3 dernières simulations</h2>
|
||||||
|
{totalCount > 0 && (
|
||||||
|
<span className="inline-flex items-center rounded-full bg-surface-hover px-2.5 py-0.5 text-[11px] font-semibold text-ink-secondary">
|
||||||
|
{totalCount} au total
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{visible.length === 0 ? (
|
||||||
|
<Card variant="default" className="p-6 text-center">
|
||||||
|
<p className="text-sm text-ink-secondary">Aucune simulation pour l'instant.</p>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
<Card variant="default" className="divide-y divide-[var(--color-border)] p-0">
|
||||||
|
{visible.map((item) => {
|
||||||
|
const type = isEcrit(item.tache) ? 'EE' : 'EO'
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={item.id}
|
||||||
|
to={`/rapport/${item.id}`}
|
||||||
|
className="flex items-center gap-3 px-4 py-3 transition-colors hover:bg-surface-hover focus-visible:outline-none focus-visible:shadow-focus"
|
||||||
|
>
|
||||||
|
<div className="min-w-0 flex-1 space-y-0.5">
|
||||||
|
<p className="truncate text-sm font-medium text-ink-primary">
|
||||||
|
<span className="font-semibold">{shortTacheLabel(item.tache)}</span>
|
||||||
|
<span className="text-ink-tertiary"> · {type}</span>
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-ink-tertiary">{formatRelativeDate(item.created_at)}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{item.nclc !== null && (
|
||||||
|
<span
|
||||||
|
className={`inline-flex items-center rounded-full px-2 py-0.5 text-[11px] font-semibold tabular-nums ${nclcBadgeClasses(item.nclc)}`}
|
||||||
|
>
|
||||||
|
NCLC {formatNclc(item.nclc)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<span className="hidden tabular-nums text-sm font-semibold text-ink-primary sm:inline">
|
||||||
|
{item.score === null ? '—' : `${item.score}/20`}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<ChevronRight className="size-4 shrink-0 text-ink-tertiary" aria-hidden="true" />
|
||||||
|
</Link>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
190
src/features/dashboard/components/StatCards.tsx
Normal file
190
src/features/dashboard/components/StatCards.tsx
Normal file
|
|
@ -0,0 +1,190 @@
|
||||||
|
/**
|
||||||
|
* StatCards — trois cartes synthétiques affichées sur le Dashboard.
|
||||||
|
*
|
||||||
|
* - Simulations restantes (barre de progression pour Free, "Illimitées" ailleurs)
|
||||||
|
* - NCLC estimé (dernière simulation)
|
||||||
|
* - Dernier score (+ delta vs précédent)
|
||||||
|
*
|
||||||
|
* Règle H : aucune logique métier de gating ici — le parent décide du rendu
|
||||||
|
* global via hasAccess. Ce composant ne fait que formater les
|
||||||
|
* valeurs déjà fournies.
|
||||||
|
* Règle L : tokens du design system exclusivement.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Card } from '@/shared/ui/Card'
|
||||||
|
import { formatRelativeDate } from '@/shared/lib/date'
|
||||||
|
import { isEcrit } from '@/entities/production/lib'
|
||||||
|
import type { SimulationListItem } from '@/entities/production/types'
|
||||||
|
import type { Plan } from '@/entities/user/lib'
|
||||||
|
|
||||||
|
interface StatCardsProps {
|
||||||
|
plan: Plan
|
||||||
|
simulationsUsed: number
|
||||||
|
/** null = illimité (Standard/Premium), number = reste (Free). */
|
||||||
|
simulationsRemaining: number | null
|
||||||
|
/** Liste des dernières simulations (index 0 = la plus récente). */
|
||||||
|
recentSimulations: readonly SimulationListItem[]
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatNclc(n: number): string {
|
||||||
|
return n.toLocaleString('fr-FR', { maximumFractionDigits: 1 })
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatScore(value: number): string {
|
||||||
|
return value.toLocaleString('fr-FR', { maximumFractionDigits: 1 })
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatShell({ label, children }: { label: string; children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<Card variant="default" className="p-4">
|
||||||
|
<p className="text-[11px] font-semibold uppercase tracking-wider text-ink-tertiary">
|
||||||
|
{label}
|
||||||
|
</p>
|
||||||
|
<div className="mt-2 space-y-2">{children}</div>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SimulationsRestantesCard({
|
||||||
|
plan,
|
||||||
|
simulationsUsed,
|
||||||
|
simulationsRemaining,
|
||||||
|
}: {
|
||||||
|
plan: Plan
|
||||||
|
simulationsUsed: number
|
||||||
|
simulationsRemaining: number | null
|
||||||
|
}) {
|
||||||
|
if (simulationsRemaining === null) {
|
||||||
|
return (
|
||||||
|
<StatShell label="Simulations">
|
||||||
|
<p className="text-2xl font-extrabold tabular-nums text-ink-primary">Illimitées</p>
|
||||||
|
<p className="text-xs text-ink-secondary">
|
||||||
|
{simulationsUsed} effectuée{simulationsUsed > 1 ? 's' : ''}
|
||||||
|
</p>
|
||||||
|
</StatShell>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const total = simulationsUsed + simulationsRemaining
|
||||||
|
const pct = total > 0 ? (simulationsUsed / total) * 100 : 0
|
||||||
|
|
||||||
|
return (
|
||||||
|
<StatShell label="Simulations restantes">
|
||||||
|
<p className="tabular-nums text-ink-primary">
|
||||||
|
<span className="text-2xl font-extrabold">{simulationsRemaining}</span>
|
||||||
|
<span className="text-lg font-medium text-ink-secondary">/{total}</span>
|
||||||
|
</p>
|
||||||
|
<div
|
||||||
|
className="h-1.5 overflow-hidden rounded-full bg-surface-hover"
|
||||||
|
role="progressbar"
|
||||||
|
aria-valuemin={0}
|
||||||
|
aria-valuemax={total}
|
||||||
|
aria-valuenow={simulationsUsed}
|
||||||
|
aria-label="Simulations utilisées"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="h-full bg-brand transition-[width] duration-500"
|
||||||
|
style={{ width: `${pct}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{plan === 'free' && (
|
||||||
|
<p className="text-xs text-ink-tertiary">Renouvellement offert à l'upgrade</p>
|
||||||
|
)}
|
||||||
|
</StatShell>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function NclcCard({ lastSim }: { lastSim: SimulationListItem | null }) {
|
||||||
|
if (!lastSim || lastSim.nclc === null) {
|
||||||
|
return (
|
||||||
|
<StatShell label="NCLC estimé">
|
||||||
|
<p className="text-2xl font-extrabold tabular-nums text-ink-primary">—</p>
|
||||||
|
<p className="text-xs text-ink-tertiary">
|
||||||
|
Démarrez une simulation pour estimer votre niveau.
|
||||||
|
</p>
|
||||||
|
</StatShell>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const nclc = lastSim.nclc
|
||||||
|
const inTarget = nclc >= 7
|
||||||
|
|
||||||
|
return (
|
||||||
|
<StatShell label="NCLC estimé">
|
||||||
|
<p className="text-2xl font-extrabold tabular-nums text-ink-primary">{formatNclc(nclc)}</p>
|
||||||
|
<span className="inline-flex items-center rounded-full bg-brand-soft px-2.5 py-0.5 text-[11px] font-semibold text-brand-text">
|
||||||
|
{inTarget ? 'Dans la cible CLB 7+' : 'Visez la cible CLB 7+'}
|
||||||
|
</span>
|
||||||
|
</StatShell>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DernierScoreCard({
|
||||||
|
recentSimulations,
|
||||||
|
}: {
|
||||||
|
recentSimulations: readonly SimulationListItem[]
|
||||||
|
}) {
|
||||||
|
const lastWithScore = recentSimulations.find((s) => s.score !== null) ?? null
|
||||||
|
if (!lastWithScore || lastWithScore.score === null) {
|
||||||
|
return (
|
||||||
|
<StatShell label="Dernier score">
|
||||||
|
<p className="text-2xl font-extrabold tabular-nums text-ink-primary">—</p>
|
||||||
|
<p className="text-xs text-ink-tertiary">Aucun score enregistré.</p>
|
||||||
|
</StatShell>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Précédente simulation avec score, pour calculer le delta.
|
||||||
|
const previous =
|
||||||
|
recentSimulations.filter((s) => s.id !== lastWithScore.id && s.score !== null).at(0) ?? null
|
||||||
|
const delta = previous && previous.score !== null ? lastWithScore.score - previous.score : null
|
||||||
|
|
||||||
|
const type = isEcrit(lastWithScore.tache) ? 'Écrit' : 'Oral'
|
||||||
|
const relative = formatRelativeDate(lastWithScore.created_at)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<StatShell label="Dernier score">
|
||||||
|
<p className="tabular-nums text-ink-primary">
|
||||||
|
<span className="text-2xl font-extrabold">{formatScore(lastWithScore.score)}</span>
|
||||||
|
<span className="text-lg font-medium text-ink-secondary">/20</span>
|
||||||
|
</p>
|
||||||
|
<div className="flex flex-wrap items-center gap-x-2 gap-y-1 text-xs text-ink-secondary">
|
||||||
|
<span>{type}</span>
|
||||||
|
<span aria-hidden="true" className="text-ink-tertiary">
|
||||||
|
·
|
||||||
|
</span>
|
||||||
|
<span>{relative}</span>
|
||||||
|
{delta !== null && delta !== 0 && (
|
||||||
|
<span className={delta > 0 ? 'font-semibold text-success' : 'font-semibold text-warning'}>
|
||||||
|
{delta > 0 ? '+' : ''}
|
||||||
|
{formatScore(delta)} vs précédent
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</StatShell>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function StatCards({
|
||||||
|
plan,
|
||||||
|
simulationsUsed,
|
||||||
|
simulationsRemaining,
|
||||||
|
recentSimulations,
|
||||||
|
}: StatCardsProps) {
|
||||||
|
const lastSim = recentSimulations.at(0) ?? null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section
|
||||||
|
aria-label="Indicateurs de préparation"
|
||||||
|
className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3"
|
||||||
|
>
|
||||||
|
<SimulationsRestantesCard
|
||||||
|
plan={plan}
|
||||||
|
simulationsUsed={simulationsUsed}
|
||||||
|
simulationsRemaining={simulationsRemaining}
|
||||||
|
/>
|
||||||
|
<NclcCard lastSim={lastSim} />
|
||||||
|
<DernierScoreCard recentSimulations={recentSimulations} />
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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()
|
* Le routing par plan passe exclusivement par `hasAccess()` — jamais de
|
||||||
* (Règles D et H — jamais de if plan === '...').
|
* `plan === '...'` (Règles D et H).
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useQueryClient } from '@tanstack/react-query'
|
import { useQueryClient } from '@tanstack/react-query'
|
||||||
import { useNavigate } from 'react-router-dom'
|
|
||||||
import { Button } from '@/shared/ui/Button'
|
import { Button } from '@/shared/ui/Button'
|
||||||
import { Badge } from '@/shared/ui/Badge'
|
|
||||||
import { hasAccess, canSimulate } from '@/entities/user/lib'
|
import { hasAccess, canSimulate } from '@/entities/user/lib'
|
||||||
import type { Plan } from '@/entities/user/types'
|
|
||||||
import { useAuth } from '@/features/auth/hooks/useAuth'
|
import { useAuth } from '@/features/auth/hooks/useAuth'
|
||||||
import { usePlan } from '../hooks/usePlan'
|
import { usePlan, PLAN_QUERY_KEY } from '../hooks/usePlan'
|
||||||
import { PaywallBanner } from '../components/PaywallBanner'
|
import { DashboardFreeView } from '../components/DashboardFreeView'
|
||||||
import { PLAN_QUERY_KEY } from '../hooks/usePlan'
|
import { DashboardStandardView } from '../components/DashboardStandardView'
|
||||||
import { MonProfilPreparation } from '../components/MonProfilPreparation'
|
import { DashboardPremiumView } from '../components/DashboardPremiumView'
|
||||||
|
|
||||||
const PLAN_LABELS: Record<Plan, string> = {
|
|
||||||
free: 'Plan Découverte',
|
|
||||||
standard: 'Plan Standard',
|
|
||||||
premium: 'Plan Premium',
|
|
||||||
}
|
|
||||||
|
|
||||||
function getDisplayName(
|
function getDisplayName(
|
||||||
user: { user_metadata?: { full_name?: string }; email?: string } | null,
|
user: { user_metadata?: { full_name?: string }; email?: string } | null,
|
||||||
|
|
@ -36,13 +28,13 @@ function getDisplayName(
|
||||||
function DashboardSkeleton() {
|
function DashboardSkeleton() {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6" aria-busy="true" aria-label="Chargement du tableau de bord">
|
<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="h-9 w-64 animate-pulse rounded-md bg-surface" />
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<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 className="h-24 animate-pulse rounded-lg bg-surface" />
|
<div className="h-24 animate-pulse rounded-lg bg-surface" />
|
||||||
</div>
|
</div>
|
||||||
<div className="h-9 animate-pulse rounded-md bg-surface" />
|
|
||||||
<div className="h-16 animate-pulse rounded-lg bg-surface" />
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -51,76 +43,48 @@ 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()
|
|
||||||
|
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 displayName = getDisplayName(user)
|
||||||
|
const plan = data.plan
|
||||||
|
|
||||||
return (
|
// Route : Free → preview ; Premium (pattern_analysis) → full ; sinon Standard.
|
||||||
<main className="mx-auto max-w-2xl px-4 py-6">
|
if (!hasAccess(plan, 'dashboard')) {
|
||||||
{isLoading && <DashboardSkeleton />}
|
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 && (
|
if (hasAccess(plan, 'pattern_analysis')) {
|
||||||
<div className="space-y-3 text-center">
|
return (
|
||||||
<p className="text-sm text-danger">
|
<DashboardPremiumView displayName={displayName} simulationsUsed={data.simulations_used} />
|
||||||
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>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{data && (
|
return <DashboardStandardView displayName={displayName} simulationsUsed={data.simulations_used} />
|
||||||
<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>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue