- 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>
228 lines
7.1 KiB
TypeScript
228 lines
7.1 KiB
TypeScript
/**
|
||
* 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 l’examinateur 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 l’accè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>
|
||
)
|
||
}
|