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:
Hermann_Kitio 2026-04-22 23:12:23 +03:00
parent a752029c19
commit a60c298605
18 changed files with 1005 additions and 7 deletions

View file

@ -0,0 +1,118 @@
/**
* MonProfilPreparation Sprint 3.6c.
*
* Section compacte du Dashboard Premium qui résume l'analyse des patterns :
* - Premium + ready indice de préparation + « N erreurs récurrentes » + CTA
* - Premium + not-ready message compact « Encore X simulations »
* - Free + Standard ne rend rien (composant court-circuite)
*
* Le hook `usePatterns` court-circuite déjà la requête côté client si
* !hasAccess(plan, 'pattern_analysis'), donc aucun appel backend parasite
* pour Free/Standard. La garde locale ici empêche aussi un flash de contenu
* si le composant est monté par erreur.
*
* Règle D : gating via hasAccess, jamais `plan === 'premium'`.
* Règle L : tokens Direction H exclusivement.
*/
import { Link } from 'react-router-dom'
import { ArrowRight } from 'lucide-react'
import { Card } from '@/shared/ui/Card'
import { Button } from '@/shared/ui/Button'
import { hasAccess, type Plan } from '@/entities/user/lib'
import { usePatterns } from '@/features/progression/hooks/usePatterns'
interface Props {
plan: Plan
}
function gaugeColor(score: number): string {
if (score < 40) return 'bg-danger'
if (score <= 70) return 'bg-warning'
return 'bg-success'
}
export function MonProfilPreparation({ plan }: Props) {
// Garde explicite (cohérent avec la logique du hook qui a déjà `enabled`).
if (!hasAccess(plan, 'pattern_analysis')) return null
const { data, isLoading, isError } = usePatterns(plan)
if (isLoading || isError || !data) {
return (
<Card variant="default" className="p-4">
<p className="text-[11px] font-semibold uppercase tracking-widest text-ink-5">
Mon profil de préparation
</p>
<p className="mt-2 text-sm text-ink-4">
{isError ? 'Profil temporairement indisponible.' : 'Chargement…'}
</p>
</Card>
)
}
if (!data.ready) {
const remaining = Math.max(0, data.minimum - data.current)
return (
<Card variant="default" className="space-y-2 p-4">
<p className="text-[11px] font-semibold uppercase tracking-widest text-ink-5">
Mon profil de préparation
</p>
<p className="text-sm text-ink-2">
Encore{' '}
<span className="font-semibold tabular-nums">{remaining}</span>{' '}
{remaining > 1 ? 'simulations' : 'simulation'} pour débloquer votre profil.
</p>
<p className="text-xs text-ink-4 tabular-nums">
{data.current}/{data.minimum} simulations corrigées
</p>
</Card>
)
}
const patternsCount = data.patterns.length
const pct = Math.max(0, Math.min(100, data.preparation_index.score))
const color = gaugeColor(pct)
return (
<Card variant="raised" className="space-y-3 p-4">
<div className="flex items-start justify-between gap-3">
<div className="space-y-1">
<p className="text-[11px] font-semibold uppercase tracking-widest text-ink-5">
Indice de préparation
</p>
<p className="tabular-nums text-ink-1">
<span className="text-3xl font-bold">{data.preparation_index.score}</span>
<span className="text-lg font-medium text-ink-4">/100</span>
</p>
</div>
<p className="max-w-[180px] text-right text-xs text-ink-3">
{data.preparation_index.message}
</p>
</div>
<div className="h-1.5 overflow-hidden rounded-full bg-canvas-2">
<div
className={`h-full transition-all duration-300 ${color}`}
style={{ width: `${pct}%` }}
/>
</div>
<p className="text-sm text-ink-2">
{patternsCount === 0
? 'Aucune erreur récurrente identifiée — continuez !'
: `${patternsCount} ${patternsCount > 1 ? 'erreurs récurrentes identifiées' : 'erreur récurrente identifiée'}.`}
</p>
<Button variant="secondary" size="sm" className="w-full">
<Link
to="/progression"
className="-m-1 flex items-center justify-center gap-1.5 p-1"
>
Voir mon profil de préparation
<ArrowRight className="size-3.5" aria-hidden="true" />
</Link>
</Button>
</Card>
)
}

View file

@ -0,0 +1,141 @@
/**
* Tests MonProfilPreparation (Sprint 3.6c).
*
* Couvre le gating plan : absent Free/Standard, visible Premium (ready + not-ready).
* Le hook `usePatterns` est mocké pour isoler la présentation.
*/
import { describe, it, expect, vi, afterEach } from 'vitest'
import { render, screen, cleanup } from '@testing-library/react'
import { MemoryRouter } from 'react-router-dom'
vi.mock('@/features/progression/hooks/usePatterns', () => ({
usePatterns: vi.fn(),
}))
import { usePatterns } from '@/features/progression/hooks/usePatterns'
import { MonProfilPreparation } from '../MonProfilPreparation'
afterEach(cleanup)
function renderWithRouter(ui: React.ReactNode) {
return render(<MemoryRouter>{ui}</MemoryRouter>)
}
describe('MonProfilPreparation — gating plan', () => {
it('plan free → ne rend rien', () => {
const { container } = renderWithRouter(<MonProfilPreparation plan="free" />)
expect(container).toBeEmptyDOMElement()
})
it('plan standard → ne rend rien', () => {
const { container } = renderWithRouter(<MonProfilPreparation plan="standard" />)
expect(container).toBeEmptyDOMElement()
})
})
describe('MonProfilPreparation — plan premium', () => {
it('ready: true → affiche score, message, nb patterns, CTA /progression', () => {
vi.mocked(usePatterns).mockReturnValue({
data: {
ready: true,
patterns: [
{
code: 'accord_sujet_verbe',
critere: 'competence_grammaticale',
frequency: 4,
description: null,
},
{
code: 'connecteurs_repetes',
critere: 'coherence_cohesion',
frequency: 3,
description: null,
},
{
code: 'repetition_lexicale',
critere: 'competence_lexicale',
frequency: 3,
description: null,
},
],
exercises: [],
preparation_index: { score: 72, message: 'Vous êtes en bonne voie pour NCLC 9+' },
analyzed_productions: 5,
last_analysis: '2026-04-22T12:00:00Z',
},
isLoading: false,
isError: false,
} as unknown as ReturnType<typeof usePatterns>)
renderWithRouter(<MonProfilPreparation plan="premium" />)
expect(screen.getByText('72')).toBeInTheDocument()
expect(screen.getByText(/NCLC 9/)).toBeInTheDocument()
expect(screen.getByText(/3 erreurs récurrentes identifiées/i)).toBeInTheDocument()
expect(screen.getByRole('link', { name: /voir mon profil de préparation/i })).toHaveAttribute(
'href',
'/progression',
)
})
it('ready: false → message compact "Encore X simulations"', () => {
vi.mocked(usePatterns).mockReturnValue({
data: { ready: false, minimum: 5, current: 2 },
isLoading: false,
isError: false,
} as unknown as ReturnType<typeof usePatterns>)
renderWithRouter(<MonProfilPreparation plan="premium" />)
expect(screen.getByText(/encore/i)).toBeInTheDocument()
// Le nombre restant (3) est dans un span séparé du mot "simulations"
expect(screen.getByText('3')).toBeInTheDocument()
expect(screen.getByText(/pour débloquer votre profil/i)).toBeInTheDocument()
expect(screen.getByText(/2\/5 simulations corrigées/i)).toBeInTheDocument()
})
it('isLoading → placeholder "Chargement"', () => {
vi.mocked(usePatterns).mockReturnValue({
data: undefined,
isLoading: true,
isError: false,
} as unknown as ReturnType<typeof usePatterns>)
renderWithRouter(<MonProfilPreparation plan="premium" />)
expect(screen.getByText(/chargement/i)).toBeInTheDocument()
})
it('isError → message "temporairement indisponible"', () => {
vi.mocked(usePatterns).mockReturnValue({
data: undefined,
isLoading: false,
isError: true,
} as unknown as ReturnType<typeof usePatterns>)
renderWithRouter(<MonProfilPreparation plan="premium" />)
expect(screen.getByText(/temporairement indisponible/i)).toBeInTheDocument()
})
it('ready: true avec 0 pattern → message "Aucune erreur récurrente"', () => {
vi.mocked(usePatterns).mockReturnValue({
data: {
ready: true,
patterns: [],
exercises: [],
preparation_index: { score: 85, message: 'Vous êtes en bonne voie pour NCLC 9+' },
analyzed_productions: 5,
last_analysis: '2026-04-22T12:00:00Z',
},
isLoading: false,
isError: false,
} as unknown as ReturnType<typeof usePatterns>)
renderWithRouter(<MonProfilPreparation plan="premium" />)
expect(screen.getByText(/aucune erreur récurrente/i)).toBeInTheDocument()
expect(screen.getByText('85')).toBeInTheDocument()
})
})

View file

@ -15,6 +15,7 @@ import { useAuth } from '@/features/auth/hooks/useAuth'
import { usePlan } from '../hooks/usePlan'
import { PaywallBanner } from '../components/PaywallBanner'
import { PLAN_QUERY_KEY } from '../hooks/usePlan'
import { MonProfilPreparation } from '../components/MonProfilPreparation'
const PLAN_LABELS: Record<Plan, string> = {
free: 'Plan Découverte',
@ -120,6 +121,9 @@ export function DashboardPage() {
<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>
{/* Mon profil de préparation — Premium uniquement (gate via hasAccess) */}
<MonProfilPreparation plan={data.plan} />
</div>
)}
</main>

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View file

@ -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()
})
})

View 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,
})
}

View 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>
)
}