feat(dashboard): PaywallBanner + DashboardPage conditionnel (Sprint 1 étape 5)
This commit is contained in:
parent
d0f77e04f9
commit
bf778a5a4d
3 changed files with 157 additions and 12 deletions
|
|
@ -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>
|
||||
}
|
||||
/>
|
||||
|
|
|
|||
25
src/features/dashboard/components/PaywallBanner.tsx
Normal file
25
src/features/dashboard/components/PaywallBanner.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
130
src/features/dashboard/pages/DashboardPage.tsx
Normal file
130
src/features/dashboard/pages/DashboardPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue