feat(progression): page /progression + section Dashboard Premium — patterns, exercices long terme, indice de préparation (Sprint 3.6c)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
a752029c19
commit
a60c298605
18 changed files with 1005 additions and 7 deletions
45
src/features/progression/components/BlurredProgression.tsx
Normal file
45
src/features/progression/components/BlurredProgression.tsx
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
/**
|
||||
* BlurredProgression — Sprint 3.6c.
|
||||
*
|
||||
* Aperçu flouté de la page /progression pour Free/Standard + CTA upgrade
|
||||
* vers Premium. Ce composant n'est JAMAIS rendu pour Premium (cf.
|
||||
* ProgressionPage) — le gating est fait en amont via hasAccess.
|
||||
*
|
||||
* Règle L : tokens Direction H exclusivement.
|
||||
*/
|
||||
|
||||
import { Lock } from 'lucide-react'
|
||||
import { Button } from '@/shared/ui/Button'
|
||||
|
||||
interface Props {
|
||||
onUpgrade: () => void
|
||||
}
|
||||
|
||||
const PLACEHOLDER_HEIGHTS = ['h-24', 'h-16', 'h-16', 'h-20'] as const
|
||||
|
||||
export function BlurredProgression({ onUpgrade }: Props) {
|
||||
return (
|
||||
<div className="relative min-h-[320px] overflow-hidden rounded-lg border border-line bg-canvas-2">
|
||||
<div className="space-y-3 p-4 opacity-25 blur-sm" aria-hidden="true">
|
||||
{PLACEHOLDER_HEIGHTS.map((h, i) => (
|
||||
<div key={i} className={`${h} rounded bg-ink-4`} />
|
||||
))}
|
||||
</div>
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center gap-3 px-4 text-center">
|
||||
<Lock className="size-6 text-ink-4" aria-hidden="true" />
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-semibold text-ink-2">
|
||||
Profil de préparation — Exclusivité Premium
|
||||
</p>
|
||||
<p className="max-w-sm text-xs text-ink-4">
|
||||
Analysez vos erreurs récurrentes, recevez des exercices ciblés long
|
||||
terme, et suivez votre indice de préparation au TCF Canada.
|
||||
</p>
|
||||
</div>
|
||||
<Button variant="upgrade" size="sm" onClick={onUpgrade}>
|
||||
Passer en Premium
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
62
src/features/progression/components/NotReadyState.tsx
Normal file
62
src/features/progression/components/NotReadyState.tsx
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
/**
|
||||
* NotReadyState — Sprint 3.6c.
|
||||
*
|
||||
* Affiché quand l'utilisateur Premium a moins de 5 productions corrigées.
|
||||
* Barre de progression N/5 + CTA pour démarrer une simulation.
|
||||
*
|
||||
* Règle L : tokens Direction H exclusivement.
|
||||
*/
|
||||
|
||||
import { Link } from 'react-router-dom'
|
||||
import { Card } from '@/shared/ui/Card'
|
||||
import { Button } from '@/shared/ui/Button'
|
||||
|
||||
interface Props {
|
||||
current: number
|
||||
minimum: number
|
||||
}
|
||||
|
||||
export function NotReadyState({ current, minimum }: Props) {
|
||||
const remaining = Math.max(0, minimum - current)
|
||||
const pct = Math.max(0, Math.min(100, (current / minimum) * 100))
|
||||
|
||||
return (
|
||||
<Card variant="raised" className="space-y-4 p-6 text-center">
|
||||
<div className="space-y-1">
|
||||
<h2 className="text-lg font-semibold text-ink-1">Profil de préparation</h2>
|
||||
<p className="text-sm leading-relaxed text-ink-3">
|
||||
Vous avez réalisé{' '}
|
||||
<span className="font-semibold text-ink-1 tabular-nums">
|
||||
{current}/{minimum}
|
||||
</span>{' '}
|
||||
simulations corrigées.{' '}
|
||||
{remaining > 0
|
||||
? `Encore ${remaining} pour débloquer votre profil.`
|
||||
: 'Votre profil va être généré à la prochaine correction.'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="relative h-2 overflow-hidden rounded-full bg-canvas-2"
|
||||
role="progressbar"
|
||||
aria-valuenow={current}
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={minimum}
|
||||
aria-label={`Progression : ${current} sur ${minimum}`}
|
||||
>
|
||||
<div
|
||||
className="h-full bg-expria transition-all duration-300"
|
||||
style={{ width: `${pct}%` }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-center">
|
||||
<Button variant="primary" size="sm">
|
||||
<Link to="/simulation/ee" className="-m-1 p-1">
|
||||
Démarrer une simulation
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
97
src/features/progression/components/PatternExerciceCard.tsx
Normal file
97
src/features/progression/components/PatternExerciceCard.tsx
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
/**
|
||||
* PatternExerciceCard — Sprint 3.6c.
|
||||
*
|
||||
* Carte d'exercice long terme : UX **leçon** (pas interactive, contrairement à
|
||||
* `ExerciceInteractive` du rapport individuel). Le candidat a déjà répété
|
||||
* cette erreur 3+ fois — l'intention est de montrer directement le bon usage
|
||||
* + l'astuce mnémotechnique pour réflexe de relecture.
|
||||
*
|
||||
* Structure :
|
||||
* - En-tête : critère + badge taxonomie + diagnostic
|
||||
* - Bloc consigne (fond neutre)
|
||||
* - Exemple incorrect (barré rouge) → Correction (fond vert)
|
||||
* - Encart astuce avec icône ampoule + fond chaud
|
||||
*
|
||||
* Règle L : tokens Direction H exclusivement.
|
||||
* Règle H : présentation pure — contenu fourni par DeepSeek via backend.
|
||||
*/
|
||||
|
||||
import { Lightbulb } from 'lucide-react'
|
||||
import ReactMarkdown from 'react-markdown'
|
||||
import { Card } from '@/shared/ui/Card'
|
||||
import { Badge } from '@/shared/ui/Badge'
|
||||
import { CRITERE_LABELS } from '@/entities/report/lib'
|
||||
import type { PatternExercice } from '@/entities/patterns/types'
|
||||
|
||||
interface Props {
|
||||
exercice: PatternExercice
|
||||
}
|
||||
|
||||
export function PatternExerciceCard({ exercice }: Props) {
|
||||
const critereLabel = CRITERE_LABELS[exercice.critere]
|
||||
|
||||
return (
|
||||
<Card variant="default" className="space-y-4 p-4">
|
||||
<div className="space-y-2">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Badge variant="neutral">{critereLabel}</Badge>
|
||||
<span className="text-xs font-medium text-ink-4">
|
||||
{exercice.code.replace(/_/g, ' ')}
|
||||
</span>
|
||||
</div>
|
||||
{exercice.diagnostic && (
|
||||
<p className="text-sm leading-relaxed text-ink-2">
|
||||
<ReactMarkdown
|
||||
disallowedElements={['script', 'iframe']}
|
||||
components={{ p: ({ children }) => <span>{children}</span> }}
|
||||
>
|
||||
{exercice.diagnostic}
|
||||
</ReactMarkdown>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{exercice.exercice.consigne && (
|
||||
<div className="space-y-1.5 rounded-md border border-line bg-canvas-2 p-3">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-widest text-ink-5">
|
||||
Consigne
|
||||
</p>
|
||||
<p className="text-sm leading-relaxed text-ink-1">
|
||||
{exercice.exercice.consigne}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
<div className="space-y-1.5 rounded-md border border-danger/30 bg-danger-bg p-3">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-widest text-danger">
|
||||
Incorrect
|
||||
</p>
|
||||
<p className="text-sm leading-relaxed text-ink-1 line-through decoration-danger decoration-1">
|
||||
{exercice.exercice.exemple}
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-1.5 rounded-md border border-success/30 bg-success-bg p-3">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-widest text-success">
|
||||
Correct
|
||||
</p>
|
||||
<p className="text-sm leading-relaxed text-ink-1">
|
||||
{exercice.exercice.correction}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3 rounded-md border border-warning/30 bg-warning-bg p-3">
|
||||
<Lightbulb className="mt-0.5 size-4 shrink-0 text-warning" aria-hidden="true" />
|
||||
<div className="space-y-1">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-widest text-warning">
|
||||
Astuce de relecture
|
||||
</p>
|
||||
<p className="text-sm leading-relaxed text-ink-1">
|
||||
{exercice.exercice.astuce}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
54
src/features/progression/components/PatternsList.tsx
Normal file
54
src/features/progression/components/PatternsList.tsx
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
/**
|
||||
* PatternsList — Sprint 3.6c.
|
||||
*
|
||||
* Liste les erreurs récurrentes détectées, groupées par critère et triées par
|
||||
* fréquence DESC (déjà fait côté backend).
|
||||
*
|
||||
* Règle L : tokens Direction H exclusivement.
|
||||
*/
|
||||
|
||||
import { Card } from '@/shared/ui/Card'
|
||||
import { Badge } from '@/shared/ui/Badge'
|
||||
import { CRITERE_LABELS } from '@/entities/report/lib'
|
||||
import type { Pattern } from '@/entities/patterns/types'
|
||||
|
||||
interface Props {
|
||||
patterns: Pattern[]
|
||||
}
|
||||
|
||||
function humanizeCode(code: string): string {
|
||||
return code.replace(/_/g, ' ')
|
||||
}
|
||||
|
||||
export function PatternsList({ patterns }: Props) {
|
||||
if (patterns.length === 0) {
|
||||
return (
|
||||
<Card variant="default" className="p-4">
|
||||
<p className="text-sm text-ink-3">
|
||||
Aucune erreur récurrente détectée sur vos 5 dernières productions.
|
||||
Continuez ainsi !
|
||||
</p>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<ul className="space-y-2">
|
||||
{patterns.map((p) => (
|
||||
<li key={`${p.critere}-${p.code}-${p.description ?? ''}`}>
|
||||
<Card variant="default" className="flex items-start justify-between gap-3 p-4">
|
||||
<div className="min-w-0 space-y-1">
|
||||
<p className="text-sm font-semibold text-ink-1">
|
||||
{p.description ?? humanizeCode(p.code)}
|
||||
</p>
|
||||
<p className="text-xs text-ink-4">{CRITERE_LABELS[p.critere]}</p>
|
||||
</div>
|
||||
<Badge variant="nclc" className="shrink-0 tabular-nums">
|
||||
{p.frequency}/5
|
||||
</Badge>
|
||||
</Card>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)
|
||||
}
|
||||
64
src/features/progression/components/PreparationIndexHero.tsx
Normal file
64
src/features/progression/components/PreparationIndexHero.tsx
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
/**
|
||||
* PreparationIndexHero — Sprint 3.6c.
|
||||
*
|
||||
* Affiche l'indice de préparation (0-100) en gros, jauge horizontale et
|
||||
* message interprétatif (<40 / 40-70 / >70).
|
||||
*
|
||||
* Règle L : tokens Direction H exclusivement.
|
||||
* Règle H : présentation pure — le message vient du backend.
|
||||
*/
|
||||
|
||||
import { Card } from '@/shared/ui/Card'
|
||||
import type { PreparationIndex } from '@/entities/patterns/types'
|
||||
|
||||
interface Props {
|
||||
index: PreparationIndex
|
||||
}
|
||||
|
||||
function gaugeColor(score: number): string {
|
||||
if (score < 40) return 'bg-danger'
|
||||
if (score <= 70) return 'bg-warning'
|
||||
return 'bg-success'
|
||||
}
|
||||
|
||||
export function PreparationIndexHero({ index }: Props) {
|
||||
const pct = Math.max(0, Math.min(100, index.score))
|
||||
const color = gaugeColor(pct)
|
||||
|
||||
return (
|
||||
<Card variant="raised" className="space-y-4 p-6">
|
||||
<div className="flex flex-wrap items-baseline justify-between gap-4">
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold uppercase tracking-widest text-ink-5">
|
||||
Indice de préparation
|
||||
</p>
|
||||
<p className="mt-1 tabular-nums text-ink-1">
|
||||
<span className="text-5xl font-bold">{index.score}</span>
|
||||
<span className="text-2xl font-medium text-ink-4">/100</span>
|
||||
</p>
|
||||
</div>
|
||||
<p className="max-w-xs text-sm leading-relaxed text-ink-2">{index.message}</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="relative h-2 overflow-hidden rounded-full bg-canvas-2"
|
||||
role="progressbar"
|
||||
aria-valuenow={pct}
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={100}
|
||||
aria-label={`Indice de préparation : ${pct} sur 100`}
|
||||
>
|
||||
<div
|
||||
className={`h-full transition-all duration-300 ${color}`}
|
||||
style={{ width: `${pct}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-between text-xs text-ink-4 tabular-nums">
|
||||
<span>0</span>
|
||||
<span>40</span>
|
||||
<span>70</span>
|
||||
<span>100</span>
|
||||
</div>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
61
src/features/progression/components/ProgressionPremium.tsx
Normal file
61
src/features/progression/components/ProgressionPremium.tsx
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
/**
|
||||
* ProgressionPremium — Sprint 3.6c.
|
||||
*
|
||||
* Orchestre le contenu de /progression pour un utilisateur Premium :
|
||||
* - not-ready → NotReadyState
|
||||
* - ready → Hero (indice) + PatternsList + PatternExerciceCard[] + footer
|
||||
*
|
||||
* Règle L : tokens Direction H exclusivement.
|
||||
* Règle H : purement présentationnel — data vient du parent via props.
|
||||
*/
|
||||
|
||||
import { Card } from '@/shared/ui/Card'
|
||||
import { formatRelativeDate } from '@/shared/lib/date'
|
||||
import type { PatternsResponse } from '@/entities/patterns/types'
|
||||
import { PreparationIndexHero } from './PreparationIndexHero'
|
||||
import { PatternsList } from './PatternsList'
|
||||
import { PatternExerciceCard } from './PatternExerciceCard'
|
||||
import { NotReadyState } from './NotReadyState'
|
||||
|
||||
interface Props {
|
||||
data: PatternsResponse
|
||||
}
|
||||
|
||||
export function ProgressionPremium({ data }: Props) {
|
||||
if (!data.ready) {
|
||||
return <NotReadyState current={data.current} minimum={data.minimum} />
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PreparationIndexHero index={data.preparation_index} />
|
||||
|
||||
<section aria-label="Erreurs récurrentes">
|
||||
<h2 className="mb-3 text-base font-semibold text-ink-1">
|
||||
Erreurs récurrentes
|
||||
</h2>
|
||||
<PatternsList patterns={data.patterns} />
|
||||
</section>
|
||||
|
||||
{data.exercises.length > 0 && (
|
||||
<section aria-label="Exercices long terme">
|
||||
<h2 className="mb-3 text-base font-semibold text-ink-1">
|
||||
Exercices long terme
|
||||
</h2>
|
||||
<div className="space-y-3">
|
||||
{data.exercises.map((ex, i) => (
|
||||
<PatternExerciceCard key={`${ex.code}-${i}`} exercice={ex} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<Card variant="default" className="p-3">
|
||||
<p className="text-center text-xs text-ink-4">
|
||||
Analyse basée sur vos {data.analyzed_productions} dernières productions —{' '}
|
||||
{formatRelativeDate(data.last_analysis)}
|
||||
</p>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,116 @@
|
|||
/**
|
||||
* Tests — ProgressionPremium (Sprint 3.6c).
|
||||
*
|
||||
* Couvre les 3 états principaux du composant (le gating plan lui-même est
|
||||
* géré en amont par ProgressionPage via hasAccess).
|
||||
*/
|
||||
|
||||
import { describe, it, expect, afterEach } from 'vitest'
|
||||
import { render, screen, cleanup } from '@testing-library/react'
|
||||
import { MemoryRouter } from 'react-router-dom'
|
||||
import { ProgressionPremium } from '../ProgressionPremium'
|
||||
import type {
|
||||
PatternsReady,
|
||||
PatternsNotReady,
|
||||
PatternExercice,
|
||||
} from '@/entities/patterns/types'
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
function renderWithRouter(ui: React.ReactNode) {
|
||||
return render(<MemoryRouter>{ui}</MemoryRouter>)
|
||||
}
|
||||
|
||||
const EXERCICE: PatternExercice = {
|
||||
code: 'accord_sujet_verbe',
|
||||
critere: 'competence_grammaticale',
|
||||
diagnostic: 'Accords fragiles sur vos 5 dernières productions.',
|
||||
exercice: {
|
||||
consigne: 'Corrigez la phrase suivante.',
|
||||
exemple: 'les enfants joue dans le parc',
|
||||
correction: 'les enfants jouent dans le parc',
|
||||
astuce: 'Pointez du doigt le sujet avant de lire le verbe.',
|
||||
},
|
||||
}
|
||||
|
||||
const READY_DATA: PatternsReady = {
|
||||
ready: true,
|
||||
patterns: [
|
||||
{ code: 'accord_sujet_verbe', critere: 'competence_grammaticale', frequency: 4, description: null },
|
||||
{ code: 'connecteurs_repetes', critere: 'coherence_cohesion', frequency: 3, description: null },
|
||||
],
|
||||
exercises: [EXERCICE],
|
||||
preparation_index: { score: 72, message: 'Vous êtes en bonne voie pour NCLC 9+' },
|
||||
analyzed_productions: 5,
|
||||
last_analysis: new Date(Date.now() - 2 * 60 * 60 * 1000).toISOString(),
|
||||
}
|
||||
|
||||
const NOT_READY: PatternsNotReady = {
|
||||
ready: false,
|
||||
minimum: 5,
|
||||
current: 3,
|
||||
}
|
||||
|
||||
describe('ProgressionPremium — état not-ready', () => {
|
||||
it('affiche le compteur N/5 et le CTA "Démarrer une simulation"', () => {
|
||||
renderWithRouter(<ProgressionPremium data={NOT_READY} />)
|
||||
|
||||
expect(screen.getByText(/3\/5/)).toBeInTheDocument()
|
||||
expect(screen.getByText(/encore 2 pour débloquer votre profil/i)).toBeInTheDocument()
|
||||
expect(screen.getByRole('link', { name: /démarrer une simulation/i })).toHaveAttribute(
|
||||
'href',
|
||||
'/simulation/ee',
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('ProgressionPremium — état ready', () => {
|
||||
it('affiche l\'indice de préparation (score + message)', () => {
|
||||
renderWithRouter(<ProgressionPremium data={READY_DATA} />)
|
||||
|
||||
expect(screen.getByText('72')).toBeInTheDocument()
|
||||
expect(screen.getByText(/NCLC 9/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('affiche les 2 patterns avec leur fréquence', () => {
|
||||
renderWithRouter(<ProgressionPremium data={READY_DATA} />)
|
||||
|
||||
expect(screen.getByText('4/5')).toBeInTheDocument()
|
||||
expect(screen.getByText('3/5')).toBeInTheDocument()
|
||||
// Libellés critères — chacun apparaît au moins une fois (pattern + exercice
|
||||
// réutilisent le même label, donc getAllByText)
|
||||
expect(screen.getAllByText(/Compétence grammaticale/i).length).toBeGreaterThan(0)
|
||||
expect(screen.getByText(/Cohérence et cohésion/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('rend l\'exercice avec consigne, exemple incorrect, correction et astuce', () => {
|
||||
renderWithRouter(<ProgressionPremium data={READY_DATA} />)
|
||||
|
||||
expect(screen.getByText(EXERCICE.exercice.consigne)).toBeInTheDocument()
|
||||
expect(screen.getByText(EXERCICE.exercice.exemple)).toBeInTheDocument()
|
||||
expect(screen.getByText(EXERCICE.exercice.correction)).toBeInTheDocument()
|
||||
expect(screen.getByText(EXERCICE.exercice.astuce)).toBeInTheDocument()
|
||||
expect(screen.getByText(/astuce de relecture/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('affiche le footer "Analyse basée sur vos 5 dernières productions"', () => {
|
||||
renderWithRouter(<ProgressionPremium data={READY_DATA} />)
|
||||
|
||||
expect(
|
||||
screen.getByText(/analyse basée sur vos 5 dernières productions/i),
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('ready sans pattern : affiche le message "Aucune erreur récurrente"', () => {
|
||||
const noPatterns: PatternsReady = {
|
||||
...READY_DATA,
|
||||
patterns: [],
|
||||
exercises: [],
|
||||
}
|
||||
renderWithRouter(<ProgressionPremium data={noPatterns} />)
|
||||
|
||||
expect(screen.getByText(/aucune erreur récurrente détectée/i)).toBeInTheDocument()
|
||||
// Pas de section "Exercices long terme" si exercises=[]
|
||||
expect(screen.queryByText(/exercices long terme/i)).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
31
src/features/progression/hooks/usePatterns.ts
Normal file
31
src/features/progression/hooks/usePatterns.ts
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
/**
|
||||
* Hook TanStack Query — analyse des patterns (Premium).
|
||||
*
|
||||
* Clé `['users', 'patterns']` partagée entre `/progression` et la section
|
||||
* dashboard Premium — un seul appel backend pour les deux affichages.
|
||||
*
|
||||
* `staleTime: 60 s` — l'analyse ne change que quand une nouvelle production est
|
||||
* corrigée ; 60 s évite les rafraîchissements inutiles.
|
||||
*
|
||||
* `enabled` : ne lance la requête QUE si l'utilisateur a la feature. Évite un
|
||||
* 403 parasite pour Free/Standard (la route backend refuse avec
|
||||
* PLAN_INSUFFICIENT — on court-circuite côté client).
|
||||
*
|
||||
* Règle H : aucune logique métier ici — wrap pur autour de `getPatterns`.
|
||||
* Règle D : le check feature utilise `hasAccess`, jamais `plan === 'premium'`.
|
||||
*/
|
||||
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { getPatterns } from '@/entities/patterns/api'
|
||||
import { hasAccess, type Plan } from '@/entities/user/lib'
|
||||
|
||||
export function usePatterns(plan: Plan | undefined) {
|
||||
const enabled = plan !== undefined && hasAccess(plan, 'pattern_analysis')
|
||||
|
||||
return useQuery({
|
||||
queryKey: ['users', 'patterns'] as const,
|
||||
queryFn: getPatterns,
|
||||
staleTime: 60 * 1000,
|
||||
enabled,
|
||||
})
|
||||
}
|
||||
76
src/features/progression/pages/ProgressionPage.tsx
Normal file
76
src/features/progression/pages/ProgressionPage.tsx
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
/**
|
||||
* Page /progression — Sprint 3.6c.
|
||||
*
|
||||
* Gating plan via `hasAccess(plan, 'pattern_analysis')` :
|
||||
* - Free + Standard → `BlurredProgression` (aperçu flouté + CTA upgrade)
|
||||
* - Premium → `ProgressionPremium` (NotReady ou contenu complet)
|
||||
*
|
||||
* Règle D : aucun `plan === 'xxx'` — tout passe par hasAccess().
|
||||
* Règle L : tokens Direction H exclusivement.
|
||||
*/
|
||||
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { Card } from '@/shared/ui/Card'
|
||||
import { Button } from '@/shared/ui/Button'
|
||||
import { hasAccess } from '@/entities/user/lib'
|
||||
import { usePlan } from '@/features/dashboard/hooks/usePlan'
|
||||
import { usePatterns } from '../hooks/usePatterns'
|
||||
import { BlurredProgression } from '../components/BlurredProgression'
|
||||
import { ProgressionPremium } from '../components/ProgressionPremium'
|
||||
|
||||
function Skeleton() {
|
||||
return (
|
||||
<div className="space-y-4" aria-busy="true" aria-label="Chargement de votre profil…">
|
||||
<div className="h-32 animate-pulse rounded-lg bg-canvas-2" />
|
||||
<div className="h-24 animate-pulse rounded-lg bg-canvas-2" />
|
||||
<div className="h-48 animate-pulse rounded-lg bg-canvas-2" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function ProgressionPage() {
|
||||
const navigate = useNavigate()
|
||||
const { data: planData, isLoading: isPlanLoading } = usePlan()
|
||||
const { data: patternsData, isLoading: isPatternsLoading, isError } = usePatterns(
|
||||
planData?.plan,
|
||||
)
|
||||
|
||||
const isPremium = planData ? hasAccess(planData.plan, 'pattern_analysis') : false
|
||||
|
||||
return (
|
||||
<main className="mx-auto max-w-3xl space-y-6 px-4 py-6">
|
||||
<header className="space-y-1">
|
||||
<h1 className="text-lg font-semibold text-ink-1">Profil de préparation</h1>
|
||||
<p className="text-sm text-ink-3">
|
||||
Repérez vos erreurs récurrentes et travaillez-les avec des exercices ciblés.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
{isPlanLoading && <Skeleton />}
|
||||
|
||||
{!isPlanLoading && planData && !isPremium && (
|
||||
<BlurredProgression onUpgrade={() => navigate('/plan')} />
|
||||
)}
|
||||
|
||||
{!isPlanLoading && planData && isPremium && (
|
||||
<>
|
||||
{isPatternsLoading && <Skeleton />}
|
||||
{isError && (
|
||||
<Card variant="default" className="border-l-4 border-l-danger p-4">
|
||||
<p className="text-sm text-danger" role="alert">
|
||||
Impossible de charger votre profil de préparation. Réessayez dans
|
||||
quelques instants.
|
||||
</p>
|
||||
<div className="mt-3">
|
||||
<Button variant="secondary" size="sm" onClick={() => navigate(0)}>
|
||||
Rafraîchir
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
{patternsData && <ProgressionPremium data={patternsData} />}
|
||||
</>
|
||||
)}
|
||||
</main>
|
||||
)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue