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

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