feat(billing): page tarifaire /plan + uniformisation CTA "Voir les plans" (Sprint 5b)

- features/billing/{api,components/PlanCard,pages/PricingPage} + 5 tests
- 3 colonnes Free/Standard/Premium avec gating dynamique selon usePlan()
- POST /stripe/checkout avec redirect full-page Stripe Checkout
- env: VITE_STRIPE_PRICE_STANDARD/_PREMIUM (optionnels)
- router: /plan → PricingPage (sous PrivateLayout)
- CTA renommés "Voir les plans" : SimulationsList, RapportPage, TaskSelector,
  DashboardFreeView, PaywallBanner — au lieu de CTA orientés un seul plan
- Tests: 198 → 203 verts (+5)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Hermann_Kitio 2026-04-26 04:52:13 +03:00
parent 04019f8348
commit 9edfbb3c95
13 changed files with 551 additions and 8 deletions

View file

@ -7,3 +7,8 @@ VITE_ENABLE_T2_LIVE=false
# Optionnel — DSN Sentry pour monitoring prod (laisser commenté en dev local)
# VITE_SENTRY_DSN=https://xxxxxx@o000000.ingest.sentry.io/0000000
# Sprint 5b — price_ids Stripe publics (Dashboard Stripe → Produits → Plan → Tarif).
# Requis en dev/prod ; absents en CI tests (tests mockent features/billing/api.ts).
VITE_STRIPE_PRICE_STANDARD=price_xxx
VITE_STRIPE_PRICE_PREMIUM=price_xxx

View file

@ -17,6 +17,7 @@ import { QuestionnaireT1Page } from '@/features/simulations/pages/QuestionnaireT
import { PresentationGenereeT1Page } from '@/features/simulations/pages/PresentationGenereeT1Page'
import { HistoriquePage } from '@/features/historique/pages/HistoriquePage'
import { ProgressionPage } from '@/features/progression/pages/ProgressionPage'
import { PricingPage } from '@/features/billing/pages/PricingPage'
import { SimulationFlowProvider } from '@/features/simulations/state/SimulationFlowProvider'
import { AppLayout } from './AppLayout'
@ -89,7 +90,7 @@ export function AppRouter() {
<Route path="/progression" element={<ProgressionPage />} />
<Route path="/methodologie" element={<ComingSoon />} />
<Route path="/historique" element={<HistoriquePage />} />
<Route path="/plan" element={<ComingSoon />} />
<Route path="/plan" element={<PricingPage />} />
<Route path="/parametres" element={<ComingSoon />} />
</Route>

View file

@ -0,0 +1,135 @@
import { describe, it, expect, vi, afterEach } from 'vitest'
import { render, screen, cleanup, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { MemoryRouter } from 'react-router-dom'
// ─── Mocks ───────────────────────────────────────────────────────────────────
const { usePlanMock, createCheckoutSessionMock } = vi.hoisted(() => ({
usePlanMock: vi.fn(),
createCheckoutSessionMock: vi.fn(),
}))
vi.mock('@/features/dashboard/hooks/usePlan', () => ({
usePlan: usePlanMock,
}))
vi.mock('../api', () => ({
createCheckoutSession: createCheckoutSessionMock,
}))
import { PricingPage } from '../pages/PricingPage'
afterEach(() => {
cleanup()
usePlanMock.mockReset()
createCheckoutSessionMock.mockReset()
})
function renderPage() {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
})
return render(
<QueryClientProvider client={queryClient}>
<MemoryRouter>
<PricingPage />
</MemoryRouter>
</QueryClientProvider>,
)
}
function mockPlan(plan: 'free' | 'standard' | 'premium') {
usePlanMock.mockReturnValue({
data: { plan, permissions: {}, simulations_used: 0, simulations_remaining: null },
isLoading: false,
isError: false,
})
}
// ─── Tests ───────────────────────────────────────────────────────────────────
describe('PricingPage — plan free', () => {
it('CTA Standard et Premium actifs (plein tarif), Découverte = "Plan actuel" disabled', () => {
mockPlan('free')
renderPage()
expect(
screen.getByRole('button', { name: /Choisir Standard — 19,90 €\/4 sem\./ }),
).toBeEnabled()
expect(screen.getByRole('button', { name: /Choisir Premium — 39,90 €\/4 sem\./ })).toBeEnabled()
const planActuelButtons = screen.getAllByRole('button', { name: /Plan actuel/i })
expect(planActuelButtons).toHaveLength(1)
expect(planActuelButtons[0]).toBeDisabled()
})
})
describe('PricingPage — plan standard', () => {
it('Standard désactivé "Plan actuel" ; Premium actif "Passer en Premium" + hint prorata', () => {
mockPlan('standard')
renderPage()
expect(screen.getByRole('button', { name: /Passer en Premium$/ })).toBeEnabled()
expect(
screen.queryByRole('button', { name: /Choisir Premium — 39,90 €/ }),
).not.toBeInTheDocument()
expect(screen.getByText(/Stripe calculera automatiquement le prorata/i)).toBeInTheDocument()
const planActuelButtons = screen.getAllByRole('button', { name: /Plan actuel/i })
expect(planActuelButtons).toHaveLength(1)
expect(planActuelButtons[0]).toBeDisabled()
})
})
describe('PricingPage — plan premium', () => {
it('tous les CTA payants désactivés', () => {
mockPlan('premium')
renderPage()
const planActuelButtons = screen.getAllByRole('button', { name: /Plan actuel/i })
expect(planActuelButtons).toHaveLength(1)
expect(planActuelButtons[0]).toBeDisabled()
expect(
screen.queryByRole('button', { name: /Choisir Standard|Passer en Premium|Choisir Premium/ }),
).not.toBeInTheDocument()
const inferieurButtons = screen.getAllByRole('button', { name: /Inférieur à votre plan/i })
expect(inferieurButtons.length).toBeGreaterThanOrEqual(1)
inferieurButtons.forEach((btn) => expect(btn).toBeDisabled())
})
})
describe('PricingPage — interaction', () => {
it('clic sur "Choisir Standard" appelle createCheckoutSession("standard")', async () => {
const user = userEvent.setup()
mockPlan('free')
// Promesse non résolue : on veut juste vérifier l'appel, pas la redirection.
createCheckoutSessionMock.mockReturnValue(new Promise(() => {}))
renderPage()
await user.click(screen.getByRole('button', { name: /Choisir Standard — 19,90 €\/4 sem\./ }))
await waitFor(() => {
expect(createCheckoutSessionMock).toHaveBeenCalledTimes(1)
})
// TanStack Query injecte un 2e arg (mutationContext) → on vérifie uniquement le 1er.
expect(createCheckoutSessionMock.mock.calls[0]?.[0]).toBe('standard')
})
it("erreur de mutation → callout d'erreur affiché", async () => {
const user = userEvent.setup()
mockPlan('free')
createCheckoutSessionMock.mockRejectedValue(new Error('Configuration Stripe manquante.'))
renderPage()
await user.click(screen.getByRole('button', { name: /Choisir Standard — 19,90 €\/4 sem\./ }))
await waitFor(() => {
expect(screen.getByRole('alert')).toHaveTextContent(/Configuration Stripe manquante/i)
})
})
})

View file

@ -0,0 +1,51 @@
/**
* Sprint 5b API client billing.
*
* Wrappers TanStack-Query-friendly autour des endpoints Stripe :
* - `POST /stripe/checkout` (création session paiement plein tarif)
* - `POST /stripe/customer-portal` (Sprint 5d Customer Portal Stripe)
*
* Le frontend ne stocke jamais de clé Stripe privée. Les `price_id` (publics
* par nature, comme la clé Supabase anon) sont injectés via les variables
* d'env `VITE_STRIPE_PRICE_*` leur absence au runtime déclenche une erreur
* explicite côté CTA, pas un crash silencieux.
*/
import { apiFetch } from '@/shared/lib/api-client'
import { env } from '@/shared/config/env'
export type PriceType = 'standard' | 'premium'
interface CheckoutResponse {
url: string
}
interface CustomerPortalResponse {
url: string
}
function resolvePriceId(priceType: PriceType): string {
const id =
priceType === 'standard' ? env.VITE_STRIPE_PRICE_STANDARD : env.VITE_STRIPE_PRICE_PREMIUM
if (!id) {
throw new Error(
'Configuration Stripe manquante. Veuillez réessayer plus tard ou contacter le support.',
)
}
return id
}
export async function createCheckoutSession(priceType: PriceType): Promise<CheckoutResponse> {
return apiFetch<CheckoutResponse>('/stripe/checkout', {
method: 'POST',
body: { priceId: resolvePriceId(priceType), planName: priceType },
timeoutMs: 30_000,
})
}
export async function createCustomerPortalSession(): Promise<CustomerPortalResponse> {
return apiFetch<CustomerPortalResponse>('/stripe/customer-portal', {
method: 'POST',
timeoutMs: 15_000,
})
}

View file

@ -0,0 +1,105 @@
/**
* Carte plan tarifaire Sprint 5b.
*
* Présentationnel pur (Règle H). Tokens DA Charcoal exclusivement (Règle L).
* La logique CTA (qui est désactivé, qui est "Plan actuel", etc.) vit dans
* `PricingPage.tsx`.
*/
import { Check } from 'lucide-react'
import { Button } from '@/shared/ui/Button'
export interface PlanCardCta {
label: string
variant: 'primary' | 'secondary'
disabled?: boolean
loading?: boolean
onClick?: () => void
}
interface Props {
title: string
price: string
priceCadence?: string
description?: string
features: string[]
highlighted?: boolean
currentBadge?: boolean
cta: PlanCardCta
/** Texte additionnel sous le bouton (ex. info prorata). */
ctaHint?: string
/** Message d'erreur affiché en bas de carte (ex. erreur mutation Stripe). */
errorMessage?: string
}
export function PlanCard({
title,
price,
priceCadence,
description,
features,
highlighted = false,
currentBadge = false,
cta,
ctaHint,
errorMessage,
}: Props) {
const borderClass = highlighted
? 'border-brand shadow-[0_0_0_1px_var(--color-brand)]'
: 'border-border'
return (
<div
className={`relative flex flex-col gap-4 rounded-[var(--radius-lg)] border bg-surface p-6 shadow-card ${borderClass}`}
>
{currentBadge && (
<span className="absolute -top-3 left-6 inline-flex items-center rounded-full border border-brand/30 bg-brand-soft px-2.5 py-0.5 text-[11px] font-semibold uppercase tracking-wide text-brand-text">
Plan actuel
</span>
)}
<div>
<h2 className="text-xl font-bold text-ink-primary">{title}</h2>
{description && <p className="mt-1 text-sm text-ink-secondary">{description}</p>}
</div>
<div className="flex items-baseline gap-1">
<span className="text-3xl font-bold tracking-tight tabular-nums text-ink-primary">
{price}
</span>
{priceCadence && <span className="text-sm text-ink-tertiary">{priceCadence}</span>}
</div>
<ul className="flex-1 space-y-2">
{features.map((feature) => (
<li key={feature} className="flex items-start gap-2 text-sm text-ink-primary">
<Check className="mt-0.5 size-4 shrink-0 text-brand-text" aria-hidden="true" />
<span>{feature}</span>
</li>
))}
</ul>
<div className="space-y-2">
<Button
variant={cta.variant}
size="md"
className="w-full"
disabled={cta.disabled}
loading={cta.loading}
onClick={cta.onClick}
>
{cta.label}
</Button>
{ctaHint && <p className="text-xs text-ink-tertiary">{ctaHint}</p>}
{errorMessage && (
<p
role="alert"
className="rounded-md border border-danger/30 bg-danger-soft px-2.5 py-1.5 text-xs text-danger"
>
{errorMessage}
</p>
)}
</div>
</div>
)
}

View file

@ -0,0 +1,241 @@
/**
* Page tarifaire `/plan` Sprint 5b.
*
* 3 colonnes (Découverte / Standard / Premium). Le CTA de chaque carte dépend
* du plan actuel de l'utilisateur :
* - free CTA Standard et Premium actifs (plein tarif).
* - standard Standard désactivé "Plan actuel" ; Premium = "Passer en Premium"
* (sans prix affiché Stripe calcule le prorata côté serveur).
* - premium Tous désactivés ; Premium marqué "Plan actuel".
*
* Le clic sur un CTA payant déclenche `createCheckoutSession(priceType)` puis
* redirige le navigateur en full-page vers l'URL Stripe Checkout retournée.
*
* Règle D : aucun `plan === 'xxx'` exposé la sélection du CTA passe par
* une fonction `getCtaConfig(plan)` qui mappe explicitement chaque plan vers
* ses CTA, sans `if/else` éparpillés.
*/
import { useState } from 'react'
import { useMutation } from '@tanstack/react-query'
import { usePlan } from '@/features/dashboard/hooks/usePlan'
import { createCheckoutSession, type PriceType } from '../api'
import { PlanCard, type PlanCardCta } from '../components/PlanCard'
type Plan = 'free' | 'standard' | 'premium'
interface PlanColumn {
key: 'free' | 'standard' | 'premium'
title: string
price: string
priceCadence?: string
description: string
features: string[]
highlighted: boolean
}
const COLUMNS: PlanColumn[] = [
{
key: 'free',
title: 'Découverte',
price: 'Gratuit',
description: 'Goûter le produit, voir comment ça marche.',
features: [
'5 simulations à vie',
'Score global et niveau NCLC',
'Feedback court (2-3 lignes)',
'Accès EE T1, T2, T3 et EO T1, T3',
],
highlighted: false,
},
{
key: 'standard',
title: 'Standard',
price: '19,90 €',
priceCadence: '/ 4 semaines',
description: 'Progression sérieuse — toutes les corrections détaillées.',
features: [
'Simulations illimitées',
'Rapport détaillé par critère',
'Suggestions, exercices, production modèle',
'Historique complet et dashboard',
'Indice de préparation après 5 productions',
],
highlighted: true,
},
{
key: 'premium',
title: 'Premium',
price: '39,90 €',
priceCadence: '/ 4 semaines',
description: 'Tout Standard, plus les outils de simulation réelle.',
features: [
'Tout le plan Standard',
'Mode Examen (60 min EE / 12 min EO)',
'EO Tâche 2 — dialogue live avec lexaminateur IA',
'Analyse des patterns sur 5 dernières productions',
'Exercices long terme personnalisés',
],
highlighted: false,
},
]
interface CtaConfigs {
standard: { cta: PlanCardCta; hint?: string }
premium: { cta: PlanCardCta; hint?: string }
}
function buildCtaConfigs(
plan: Plan,
pendingType: PriceType | null,
onUpgrade: (priceType: PriceType) => void,
): CtaConfigs {
const isStandardPending = pendingType === 'standard'
const isPremiumPending = pendingType === 'premium'
const anyPending = pendingType !== null
if (plan === 'free') {
return {
standard: {
cta: {
label: 'Choisir Standard — 19,90 €/4 sem.',
variant: 'primary',
loading: isStandardPending,
disabled: anyPending,
onClick: () => onUpgrade('standard'),
},
},
premium: {
cta: {
label: 'Choisir Premium — 39,90 €/4 sem.',
variant: 'primary',
loading: isPremiumPending,
disabled: anyPending,
onClick: () => onUpgrade('premium'),
},
},
}
}
if (plan === 'standard') {
return {
standard: {
cta: { label: 'Plan actuel', variant: 'secondary', disabled: true },
},
premium: {
cta: {
label: 'Passer en Premium',
variant: 'primary',
loading: isPremiumPending,
disabled: anyPending,
onClick: () => onUpgrade('premium'),
},
hint: 'Stripe calculera automatiquement le prorata sur votre abonnement en cours.',
},
}
}
// premium
return {
standard: {
cta: { label: 'Inférieur à votre plan', variant: 'secondary', disabled: true },
},
premium: {
cta: { label: 'Plan actuel', variant: 'secondary', disabled: true },
},
}
}
export function PricingPage() {
const { data: planData, isLoading } = usePlan()
const [pendingType, setPendingType] = useState<PriceType | null>(null)
const [errorByType, setErrorByType] = useState<Partial<Record<PriceType, string>>>({})
const mutation = useMutation({
mutationFn: createCheckoutSession,
onSuccess: (data) => {
// Redirection full-page vers Stripe Checkout. L'utilisateur reviendra
// sur /dashboard?upgrade=success après paiement (cf. backend success_url).
window.location.href = data.url
},
onError: (err: Error, priceType) => {
setErrorByType((prev) => ({
...prev,
[priceType]:
err.message || 'Impossible de démarrer le paiement. Réessayez dans quelques instants.',
}))
setPendingType(null)
},
})
function handleUpgrade(priceType: PriceType) {
setErrorByType((prev) => ({ ...prev, [priceType]: undefined }))
setPendingType(priceType)
mutation.mutate(priceType)
}
const plan = (planData?.plan as Plan | undefined) ?? 'free'
const ctaConfigs = buildCtaConfigs(plan, pendingType, handleUpgrade)
return (
<div className="mx-auto w-full max-w-[1100px] px-5 py-6 lg:px-9 lg:py-9">
<header className="mb-8">
<p className="text-[11px] font-semibold uppercase tracking-[0.073em] text-ink-tertiary">
Tarifs
</p>
<h1 className="mt-1 text-[32px] font-bold tracking-[-0.02em] text-ink-primary">
Choisissez votre plan
</h1>
<p className="mt-2 text-sm text-ink-secondary">
Toutes les offres incluent laccès aux 5 tâches du TCF Canada (EE T1/T2/T3, EO T1/T3).
Annulation libre à tout moment depuis votre espace abonnement.
</p>
</header>
<div className="grid grid-cols-1 gap-6 md:grid-cols-3">
{COLUMNS.map((col) => {
if (col.key === 'free') {
return (
<PlanCard
key={col.key}
title={col.title}
price={col.price}
description={col.description}
features={col.features}
highlighted={col.highlighted}
currentBadge={plan === 'free'}
cta={{
label: plan === 'free' ? 'Plan actuel' : 'Inférieur à votre plan',
variant: 'secondary',
disabled: true,
}}
/>
)
}
const config = ctaConfigs[col.key]
return (
<PlanCard
key={col.key}
title={col.title}
price={col.price}
priceCadence={col.priceCadence}
description={col.description}
features={col.features}
highlighted={col.highlighted}
currentBadge={plan === col.key}
cta={config.cta}
ctaHint={config.hint}
errorMessage={errorByType[col.key]}
/>
)
})}
</div>
{isLoading && (
<p aria-live="polite" className="mt-6 text-center text-xs text-ink-tertiary">
Chargement de votre plan
</p>
)}
</div>
)
}

View file

@ -54,7 +54,7 @@ export function DashboardFreeView({
</div>
<div className="flex flex-wrap items-center gap-2">
<Button variant="secondary" size="sm" onClick={() => navigate('/plan')}>
Passer en Premium
Voir les plans
</Button>
<Button
variant="primary"

View file

@ -35,7 +35,7 @@ export function PaywallBanner() {
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
Voir les plans
</Link>
</section>
)

View file

@ -43,7 +43,7 @@ function BlurredPreview({ onUpgrade }: { onUpgrade: () => void }) {
<Lock className="size-5 text-ink-secondary" aria-hidden="true" />
<p className="text-sm font-medium text-ink-primary">Historique disponible en Standard</p>
<Button variant="upgrade" size="sm" onClick={onUpgrade}>
Passer en Standard
Voir les plans
</Button>
</div>
</div>

View file

@ -66,7 +66,7 @@ describe('SimulationsList — gating Free', () => {
expect(screen.queryByText(/EE · Tâche 1/i)).not.toBeInTheDocument()
})
it('clic sur "Passer en Standard" appelle onUpgrade', async () => {
it('clic sur "Voir les plans" appelle onUpgrade', async () => {
const user = userEvent.setup()
const onUpgrade = vi.fn()
renderWithRouter(
@ -79,7 +79,7 @@ describe('SimulationsList — gating Free', () => {
onUpgrade={onUpgrade}
/>,
)
await user.click(screen.getByRole('button', { name: /passer en standard/i }))
await user.click(screen.getByRole('button', { name: /voir les plans/i }))
expect(onUpgrade).toHaveBeenCalledTimes(1)
})
})

View file

@ -74,7 +74,7 @@ export function TaskSelector({ type, plan, simulationsUsed, isLoading, onSelect
>
Vous avez utilisé vos 5 simulations gratuites.{' '}
<a href="/pricing" className="underline underline-offset-4">
Passer en Standard
Voir les plans
</a>{' '}
pour continuer votre préparation.
</div>

View file

@ -74,7 +74,7 @@ function BlurredSection({
<Lock className="size-5 text-ink-secondary" aria-hidden="true" />
<p className="text-sm font-medium text-ink-primary">Disponible en Standard</p>
<Button variant="upgrade" size="sm" onClick={onUpgrade}>
Passer en Standard
Voir les plans
</Button>
</div>
</div>

View file

@ -7,6 +7,11 @@ const envSchema = z.object({
VITE_ENABLE_T2_LIVE: z.enum(['true', 'false']).optional(),
VITE_SENTRY_DSN: z.string().url().optional(),
VITE_MAINTENANCE_MODE: z.enum(['true', 'false']).optional(),
// Sprint 5b — price_ids Stripe (publics — visibles dans le dashboard Stripe).
// Optionnels : permet à la suite de tests Vitest de tourner sans config Stripe.
// Au runtime, l'absence déclenche une erreur explicite côté `features/billing/api.ts`.
VITE_STRIPE_PRICE_STANDARD: z.string().min(1).optional(),
VITE_STRIPE_PRICE_PREMIUM: z.string().min(1).optional(),
})
const parsed = envSchema.safeParse(import.meta.env)