expria-frontend/src/features/billing/pages/PricingPage.tsx
Hermann_Kitio 9edfbb3c95 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>
2026-04-26 04:52:13 +03:00

241 lines
7.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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>
)
}