+ Modifier votre plan, mettre à jour votre moyen de paiement, ou consulter vos factures.
+
+
+ {portalError && (
+
+ {portalError}
+
+ )}
+ >
+ ) : (
+ <>
+
+ Vous utilisez actuellement le plan gratuit (5 simulations à vie). Découvrez les plans
+ payants pour un entraînement illimité avec correction détaillée.
+
+
+ >
+ )}
+
+ )
+}
diff --git a/src/features/billing/hooks/useCustomerPortal.ts b/src/features/billing/hooks/useCustomerPortal.ts
new file mode 100644
index 0000000..c04b6cf
--- /dev/null
+++ b/src/features/billing/hooks/useCustomerPortal.ts
@@ -0,0 +1,55 @@
+/**
+ * Sprint 5d — Hook Stripe Customer Portal.
+ *
+ * Wrap la mutation `createCustomerPortalSession` + redirect full-page vers
+ * la session Customer Portal Stripe. Utilisé par :
+ * - `AccountBillingSection` (page Paramètres) : bouton « Gérer mon abonnement ».
+ * - `PricingPage` (Standard→Premium) : redirige vers le portal qui gère
+ * nativement l'upgrade prorata + confirmation du montant.
+ *
+ * Erreur 400 `NO_ACTIVE_SUBSCRIPTION` propagée telle quelle (le backend
+ * fournit déjà un message FR exploitable directement).
+ */
+
+import { useState } from 'react'
+import { useMutation } from '@tanstack/react-query'
+import { createCustomerPortalSession } from '../api'
+
+export interface UseCustomerPortalResult {
+ openPortal: () => void
+ isLoading: boolean
+ error: string | null
+}
+
+const FALLBACK_ERROR_MESSAGE =
+ 'Impossible d’ouvrir l’espace abonnement. Réessayez dans quelques instants.'
+
+export function useCustomerPortal(): UseCustomerPortalResult {
+ const [error, setError] = useState(null)
+
+ const mutation = useMutation({
+ mutationFn: createCustomerPortalSession,
+ onSuccess: (data) => {
+ // Redirect full-page : l'utilisateur reviendra sur ${APP_URL}/dashboard
+ // (cf. backend `return_url`). Le query param `?upgrade=success` n'est PAS
+ // ajouté par le portal — pas de banner de bienvenue dans ce flow,
+ // seulement une éventuelle invalidation au refresh manuel.
+ window.location.href = data.url
+ },
+ onError: (err: unknown) => {
+ const message = err instanceof Error && err.message ? err.message : FALLBACK_ERROR_MESSAGE
+ setError(message)
+ },
+ })
+
+ function openPortal(): void {
+ setError(null)
+ mutation.mutate()
+ }
+
+ return {
+ openPortal,
+ isLoading: mutation.isPending,
+ error,
+ }
+}
diff --git a/src/features/billing/pages/PricingPage.tsx b/src/features/billing/pages/PricingPage.tsx
index 973298a..326aadc 100644
--- a/src/features/billing/pages/PricingPage.tsx
+++ b/src/features/billing/pages/PricingPage.tsx
@@ -20,6 +20,7 @@ import { useState } from 'react'
import { usePlan } from '@/features/dashboard/hooks/usePlan'
import { type PriceType } from '../api'
import { useStripeCheckout } from '../hooks/useStripeCheckout'
+import { useCustomerPortal } from '../hooks/useCustomerPortal'
import { PlanCard, type PlanCardCta } from '../components/PlanCard'
type Plan = 'free' | 'standard' | 'premium'
@@ -87,12 +88,11 @@ interface CtaConfigs {
function buildCtaConfigs(
plan: Plan,
- pendingType: PriceType | null,
+ isStandardPending: boolean,
+ isPremiumPending: boolean,
onUpgrade: (priceType: PriceType) => void,
): CtaConfigs {
- const isStandardPending = pendingType === 'standard'
- const isPremiumPending = pendingType === 'premium'
- const anyPending = pendingType !== null
+ const anyPending = isStandardPending || isPremiumPending
if (plan === 'free') {
return {
@@ -148,21 +148,33 @@ function buildCtaConfigs(
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 { checkout, pendingPriceType, error: checkoutError } = useStripeCheckout()
+ const { openPortal, isLoading: isPortalLoading, error: portalError } = useCustomerPortal()
+ // Mémorise le dernier priceType cliqué pour rattacher l'erreur (checkout OU
+ // portal) à la bonne carte. L'utilisateur ne clique qu'un CTA à la fois.
const [lastClicked, setLastClicked] = useState(null)
+ const plan = (planData?.plan as Plan | undefined) ?? 'free'
+
+ // Sprint 5d — branche Standard→Premium via Customer Portal (Stripe affiche
+ // le montant prorata + confirmation native). Free→* reste sur Checkout direct.
function handleUpgrade(priceType: PriceType) {
setLastClicked(priceType)
- checkout(priceType)
+ if (plan === 'standard') {
+ openPortal()
+ } else {
+ checkout(priceType)
+ }
}
- const plan = (planData?.plan as Plan | undefined) ?? 'free'
- const ctaConfigs = buildCtaConfigs(plan, pendingPriceType, handleUpgrade)
+ // Loading par carte : combine la source pertinente selon le plan utilisateur.
+ const isStandardPending = pendingPriceType === 'standard'
+ const isPremiumPending = plan === 'standard' ? isPortalLoading : pendingPriceType === 'premium'
+
+ const ctaConfigs = buildCtaConfigs(plan, isStandardPending, isPremiumPending, handleUpgrade)
+ const effectiveError = checkoutError ?? portalError
const errorByType: Partial> =
- error && lastClicked ? { [lastClicked]: error } : {}
+ effectiveError && lastClicked ? { [lastClicked]: effectiveError } : {}
return (