expria-frontend/src/features/billing/pages/PricingPage.tsx
Hermann_Kitio bda7feb196 feat(billing): useStripeCheckout hook + post-redirect upgrade success
- useStripeCheckout: mutation + redirect full-page, pendingPriceType exposed
- PricingPage migré vers useStripeCheckout (suppression useMutation inline)
- useUpgradeSuccessHandler: détecte ?upgrade=success, invalide plan cache, clean URL
- UpgradeSuccessBanner: callout success dans DashboardPage
- Tests: 203 → 212 verts (+9)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 05:19:18 +03:00

228 lines
7.1 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 { usePlan } from '@/features/dashboard/hooks/usePlan'
import { type PriceType } from '../api'
import { useStripeCheckout } from '../hooks/useStripeCheckout'
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 { checkout, pendingPriceType, error } = useStripeCheckout()
// Mémorise le dernier priceType cliqué pour rattacher l'erreur globale du
// hook à la bonne carte. Sprint 5c — `useStripeCheckout` n'expose qu'un
// `error` global (l'utilisateur ne clique qu'un CTA à la fois).
const [lastClicked, setLastClicked] = useState<PriceType | null>(null)
function handleUpgrade(priceType: PriceType) {
setLastClicked(priceType)
checkout(priceType)
}
const plan = (planData?.plan as Plan | undefined) ?? 'free'
const ctaConfigs = buildCtaConfigs(plan, pendingPriceType, handleUpgrade)
const errorByType: Partial<Record<PriceType, string>> =
error && lastClicked ? { [lastClicked]: error } : {}
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>
)
}