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:
parent
04019f8348
commit
9edfbb3c95
13 changed files with 551 additions and 8 deletions
241
src/features/billing/pages/PricingPage.tsx
Normal file
241
src/features/billing/pages/PricingPage.tsx
Normal 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 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 [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 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>
|
||||
)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue