feat(dashboard): PaywallBanner + DashboardPage conditionnel (Sprint 1 étape 5)

This commit is contained in:
Hermann_Kitio 2026-04-18 02:50:34 +03:00
parent d0f77e04f9
commit bf778a5a4d
3 changed files with 157 additions and 12 deletions

View file

@ -4,22 +4,12 @@ import { Navigate, Routes, Route } from 'react-router-dom'
import { LoginPage } from '@/features/auth/pages/LoginPage'
import { RegisterPage } from '@/features/auth/pages/RegisterPage'
import { ProtectedRoute } from '@/features/auth/components/ProtectedRoute'
import { DashboardPage } from '@/features/dashboard/pages/DashboardPage'
const DesignSystemPage = import.meta.env.DEV
? React.lazy(() => import('@/features/design-system/DesignSystemPage'))
: () => null
function DashboardStub() {
return (
<main className="min-h-screen bg-canvas p-6">
<h1 className="text-2xl font-semibold text-ink-1">Dashboard stub</h1>
<p className="mt-2 text-sm text-ink-3">
Cette vue sera remplacée par DashboardPage au prochain lot du Sprint 1.
</p>
</main>
)
}
export function AppRouter() {
return (
<Routes>
@ -30,7 +20,7 @@ export function AppRouter() {
path="/dashboard"
element={
<ProtectedRoute>
<DashboardStub />
<DashboardPage />
</ProtectedRoute>
}
/>

View file

@ -0,0 +1,25 @@
/**
* Bannière inline affichée sur le dashboard pour les utilisateurs Free.
* Présente les features débloquées par Standard et oriente vers /pricing.
* Pas de modale intégrée dans le flux de la page (cf. PARCOURS_UTILISATEURS §2).
*/
import { Link } from 'react-router-dom'
import { Button } from '@/shared/components/ui/button'
export function PaywallBanner() {
return (
<div className="rounded-lg border border-expria-100 bg-expria-50 p-4 dark:border-expria/20">
<p className="text-sm font-semibold text-ink-1">Passez à Standard pour débloquer :</p>
<ul className="mt-2 space-y-1 text-sm text-ink-3" role="list">
<li>Simulations illimitées</li>
<li>Rapport détaillé par critère</li>
<li>Historique de vos productions</li>
<li>Suivi de progression</li>
</ul>
<Button asChild size="sm" className="mt-4 w-full">
<Link to="/pricing">Passer à Standard</Link>
</Button>
</div>
)
}

View file

@ -0,0 +1,130 @@
/**
* Page dashboard affichage conditionnel selon le plan utilisateur.
*
* Toute logique de permission passe par hasAccess() et canSimulate()
* (Règles D et H jamais de if plan === '...').
*/
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 { 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'
const PLAN_LABELS: Record<Plan, string> = {
free: 'Plan Découverte',
standard: 'Plan Standard',
premium: 'Plan Premium',
}
function getDisplayName(user: { user_metadata?: { full_name?: string }; email?: string } | null): string {
const fullName = user?.user_metadata?.full_name
if (fullName) return fullName.split(' ')[0]
const email = user?.email
if (email) return email.split('@')[0]
return 'vous'
}
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-canvas-2" />
<div className="grid grid-cols-2 gap-4">
<div className="h-24 animate-pulse rounded-lg bg-canvas-2" />
<div className="h-24 animate-pulse rounded-lg bg-canvas-2" />
</div>
<div className="h-9 animate-pulse rounded-md bg-canvas-2" />
<div className="h-16 animate-pulse rounded-lg bg-canvas-2" />
</div>
)
}
export function DashboardPage() {
const { user } = useAuth()
const { data, isLoading, isError } = usePlan()
const queryClient = useQueryClient()
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 />}
{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="secondary">{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-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
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>
)
}