feat(billing): Customer Portal + page Paramètres + Standard→Premium via portal
- useCustomerPortal hook (mutation + redirect full-page) - AccountBillingSection: badge plan + bouton Gérer mon abonnement (Standard/Premium) - ParametresPage: page conteneur /parametres avec section billing - PricingPage: Standard→Premium redirige vers Customer Portal (prorata natif Stripe) - Tests: 212 → 219 verts (+7) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
bda7feb196
commit
de16deede3
8 changed files with 453 additions and 17 deletions
|
|
@ -18,6 +18,7 @@ import { PresentationGenereeT1Page } from '@/features/simulations/pages/Presenta
|
|||
import { HistoriquePage } from '@/features/historique/pages/HistoriquePage'
|
||||
import { ProgressionPage } from '@/features/progression/pages/ProgressionPage'
|
||||
import { PricingPage } from '@/features/billing/pages/PricingPage'
|
||||
import { ParametresPage } from '@/features/account/pages/ParametresPage'
|
||||
import { SimulationFlowProvider } from '@/features/simulations/state/SimulationFlowProvider'
|
||||
import { AppLayout } from './AppLayout'
|
||||
|
||||
|
|
@ -91,7 +92,7 @@ export function AppRouter() {
|
|||
<Route path="/methodologie" element={<ComingSoon />} />
|
||||
<Route path="/historique" element={<HistoriquePage />} />
|
||||
<Route path="/plan" element={<PricingPage />} />
|
||||
<Route path="/parametres" element={<ComingSoon />} />
|
||||
<Route path="/parametres" element={<ParametresPage />} />
|
||||
</Route>
|
||||
|
||||
{/* ── Dev only ─────────────────────────────────────────────── */}
|
||||
|
|
|
|||
67
src/features/account/pages/ParametresPage.tsx
Normal file
67
src/features/account/pages/ParametresPage.tsx
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
/**
|
||||
* Page Paramètres — Sprint 5d.
|
||||
*
|
||||
* Page minimale conteneur pour les sections de gestion du compte :
|
||||
* - Abonnement (`AccountBillingSection`) — Stripe.
|
||||
* - Session — bouton de déconnexion.
|
||||
* Future : préférences langue, sécurité (changement mot de passe),
|
||||
* suppression compte, etc.
|
||||
*
|
||||
* Wrapper layout standard 1100px (cohérent avec convention Sprint 4.7).
|
||||
*/
|
||||
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { LogOut } from 'lucide-react'
|
||||
import { Button } from '@/shared/ui/Button'
|
||||
import { Card } from '@/shared/ui/Card'
|
||||
import { signOut } from '@/shared/lib/auth-client'
|
||||
import { AccountBillingSection } from '@/features/billing/components/AccountBillingSection'
|
||||
|
||||
export function ParametresPage() {
|
||||
const navigate = useNavigate()
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
async function handleSignOut() {
|
||||
await signOut()
|
||||
queryClient.clear()
|
||||
navigate('/login', { replace: true })
|
||||
}
|
||||
|
||||
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">
|
||||
Paramètres
|
||||
</p>
|
||||
<h1 className="mt-1 text-[32px] font-bold tracking-[-0.02em] text-ink-primary">
|
||||
Mon compte
|
||||
</h1>
|
||||
<p className="mt-2 text-sm text-ink-secondary">
|
||||
Gérez votre abonnement, vos préférences et la sécurité de votre compte.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div className="space-y-6">
|
||||
<AccountBillingSection />
|
||||
|
||||
<Card variant="default" className="space-y-4 p-6">
|
||||
<header>
|
||||
<h2 className="text-lg font-semibold text-ink-primary">Session</h2>
|
||||
<p className="mt-1 text-sm text-ink-secondary">
|
||||
Terminer votre session sur cet appareil.
|
||||
</p>
|
||||
</header>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="md"
|
||||
icon={<LogOut className="size-4" aria-hidden="true" />}
|
||||
onClick={handleSignOut}
|
||||
>
|
||||
Se déconnecter
|
||||
</Button>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,99 @@
|
|||
import { describe, it, expect, vi, afterEach } from 'vitest'
|
||||
import { render, screen, cleanup } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { MemoryRouter } from 'react-router-dom'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
|
||||
// ─── Mocks ───────────────────────────────────────────────────────────────────
|
||||
|
||||
const { usePlanMock, useCustomerPortalMock } = vi.hoisted(() => ({
|
||||
usePlanMock: vi.fn(),
|
||||
useCustomerPortalMock: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/features/dashboard/hooks/usePlan', () => ({
|
||||
usePlan: usePlanMock,
|
||||
}))
|
||||
|
||||
vi.mock('../hooks/useCustomerPortal', () => ({
|
||||
useCustomerPortal: useCustomerPortalMock,
|
||||
}))
|
||||
|
||||
import { AccountBillingSection } from '../components/AccountBillingSection'
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
usePlanMock.mockReset()
|
||||
useCustomerPortalMock.mockReset()
|
||||
})
|
||||
|
||||
function renderSection() {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
|
||||
})
|
||||
return render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<MemoryRouter>
|
||||
<AccountBillingSection />
|
||||
</MemoryRouter>
|
||||
</QueryClientProvider>,
|
||||
)
|
||||
}
|
||||
|
||||
function mockPlan(plan: 'free' | 'standard' | 'premium') {
|
||||
usePlanMock.mockReturnValue({
|
||||
data: { plan, permissions: {}, simulations_used: 0, simulations_remaining: null },
|
||||
isLoading: false,
|
||||
})
|
||||
}
|
||||
|
||||
function mockPortal(overrides: Partial<ReturnType<typeof useCustomerPortalMock>> = {}) {
|
||||
const openPortal = vi.fn()
|
||||
useCustomerPortalMock.mockReturnValue({
|
||||
openPortal,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
...overrides,
|
||||
})
|
||||
return openPortal
|
||||
}
|
||||
|
||||
// ─── Tests ───────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('AccountBillingSection — plan free', () => {
|
||||
it('affiche le badge Découverte + lien "Voir les plans" → /plan', () => {
|
||||
mockPlan('free')
|
||||
mockPortal()
|
||||
renderSection()
|
||||
|
||||
expect(screen.getByText('Plan Découverte')).toBeInTheDocument()
|
||||
const link = screen.getByRole('link', { name: /voir les plans/i })
|
||||
expect(link).toHaveAttribute('href', '/plan')
|
||||
expect(screen.queryByRole('button', { name: /gérer mon abonnement/i })).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('AccountBillingSection — plan standard', () => {
|
||||
it('clic sur "Gérer mon abonnement" appelle openPortal', async () => {
|
||||
const user = userEvent.setup()
|
||||
mockPlan('standard')
|
||||
const openPortal = mockPortal()
|
||||
renderSection()
|
||||
|
||||
expect(screen.getByText('Plan Standard')).toBeInTheDocument()
|
||||
const btn = screen.getByRole('button', { name: /gérer mon abonnement/i })
|
||||
expect(btn).toBeEnabled()
|
||||
|
||||
await user.click(btn)
|
||||
expect(openPortal).toHaveBeenCalledTimes(1)
|
||||
expect(screen.queryByRole('link', { name: /voir les plans/i })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it("affiche un callout d'erreur quand le hook expose error", () => {
|
||||
mockPlan('premium')
|
||||
mockPortal({ error: 'Aucun abonnement actif trouvé.' })
|
||||
renderSection()
|
||||
|
||||
expect(screen.getByRole('alert')).toHaveTextContent(/Aucun abonnement actif trouvé/i)
|
||||
})
|
||||
})
|
||||
|
|
@ -6,10 +6,13 @@ import { MemoryRouter } from 'react-router-dom'
|
|||
|
||||
// ─── Mocks ───────────────────────────────────────────────────────────────────
|
||||
|
||||
const { usePlanMock, createCheckoutSessionMock } = vi.hoisted(() => ({
|
||||
const { usePlanMock, createCheckoutSessionMock, createCustomerPortalSessionMock } = vi.hoisted(
|
||||
() => ({
|
||||
usePlanMock: vi.fn(),
|
||||
createCheckoutSessionMock: vi.fn(),
|
||||
}))
|
||||
createCustomerPortalSessionMock: vi.fn(),
|
||||
}),
|
||||
)
|
||||
|
||||
vi.mock('@/features/dashboard/hooks/usePlan', () => ({
|
||||
usePlan: usePlanMock,
|
||||
|
|
@ -17,6 +20,7 @@ vi.mock('@/features/dashboard/hooks/usePlan', () => ({
|
|||
|
||||
vi.mock('../api', () => ({
|
||||
createCheckoutSession: createCheckoutSessionMock,
|
||||
createCustomerPortalSession: createCustomerPortalSessionMock,
|
||||
}))
|
||||
|
||||
import { PricingPage } from '../pages/PricingPage'
|
||||
|
|
@ -25,6 +29,7 @@ afterEach(() => {
|
|||
cleanup()
|
||||
usePlanMock.mockReset()
|
||||
createCheckoutSessionMock.mockReset()
|
||||
createCustomerPortalSessionMock.mockReset()
|
||||
})
|
||||
|
||||
function renderPage() {
|
||||
|
|
@ -120,6 +125,20 @@ describe('PricingPage — interaction', () => {
|
|||
expect(createCheckoutSessionMock.mock.calls[0]?.[0]).toBe('standard')
|
||||
})
|
||||
|
||||
it('Standard user clique "Passer en Premium" → createCustomerPortalSession (PAS createCheckoutSession)', async () => {
|
||||
const user = userEvent.setup()
|
||||
mockPlan('standard')
|
||||
createCustomerPortalSessionMock.mockReturnValue(new Promise(() => {}))
|
||||
|
||||
renderPage()
|
||||
await user.click(screen.getByRole('button', { name: /Passer en Premium$/ }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(createCustomerPortalSessionMock).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
expect(createCheckoutSessionMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("erreur de mutation → callout d'erreur affiché", async () => {
|
||||
const user = userEvent.setup()
|
||||
mockPlan('free')
|
||||
|
|
|
|||
97
src/features/billing/__tests__/useCustomerPortal.test.tsx
Normal file
97
src/features/billing/__tests__/useCustomerPortal.test.tsx
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
import { describe, it, expect, vi, afterEach } from 'vitest'
|
||||
import { renderHook, act, waitFor } from '@testing-library/react'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
// ─── Mocks ───────────────────────────────────────────────────────────────────
|
||||
|
||||
const { createCustomerPortalSessionMock } = vi.hoisted(() => ({
|
||||
createCustomerPortalSessionMock: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('../api', () => ({
|
||||
createCustomerPortalSession: createCustomerPortalSessionMock,
|
||||
}))
|
||||
|
||||
import { useCustomerPortal } from '../hooks/useCustomerPortal'
|
||||
|
||||
afterEach(() => {
|
||||
createCustomerPortalSessionMock.mockReset()
|
||||
})
|
||||
|
||||
function wrapper({ children }: { children: ReactNode }) {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
|
||||
})
|
||||
return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||
}
|
||||
|
||||
// ─── Tests ───────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('useCustomerPortal', () => {
|
||||
it("openPortal() succès → window.location.href set sur l'URL portal", async () => {
|
||||
const originalLocation = window.location
|
||||
const hrefSetter = vi.fn()
|
||||
Object.defineProperty(window, 'location', {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value: {
|
||||
...originalLocation,
|
||||
get href() {
|
||||
return originalLocation.href
|
||||
},
|
||||
set href(v: string) {
|
||||
hrefSetter(v)
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
createCustomerPortalSessionMock.mockResolvedValue({
|
||||
url: 'https://billing.stripe.com/p/session/abc',
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => useCustomerPortal(), { wrapper })
|
||||
act(() => {
|
||||
result.current.openPortal()
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(hrefSetter).toHaveBeenCalledWith('https://billing.stripe.com/p/session/abc')
|
||||
})
|
||||
|
||||
Object.defineProperty(window, 'location', {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value: originalLocation,
|
||||
})
|
||||
})
|
||||
|
||||
it('erreur backend → message backend propagé dans `error`', async () => {
|
||||
createCustomerPortalSessionMock.mockRejectedValue(
|
||||
new Error('Aucun abonnement actif trouvé. Souscrivez d’abord à un plan.'),
|
||||
)
|
||||
const { result } = renderHook(() => useCustomerPortal(), { wrapper })
|
||||
|
||||
act(() => {
|
||||
result.current.openPortal()
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.error).toMatch(/Aucun abonnement actif trouvé/)
|
||||
})
|
||||
})
|
||||
|
||||
it('isLoading vrai pendant la mutation pending', async () => {
|
||||
createCustomerPortalSessionMock.mockReturnValue(new Promise(() => {}))
|
||||
const { result } = renderHook(() => useCustomerPortal(), { wrapper })
|
||||
|
||||
expect(result.current.isLoading).toBe(false)
|
||||
act(() => {
|
||||
result.current.openPortal()
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isLoading).toBe(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
86
src/features/billing/components/AccountBillingSection.tsx
Normal file
86
src/features/billing/components/AccountBillingSection.tsx
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
/**
|
||||
* Sprint 5d — Section Abonnement de la page Paramètres.
|
||||
*
|
||||
* Affiche le plan actuel + un CTA contextuel :
|
||||
* - Plan free → lien « Voir les plans » vers `/plan`.
|
||||
* - Plan payant → bouton « Gérer mon abonnement » → Stripe Customer Portal.
|
||||
*
|
||||
* Règle D : aucune comparaison `plan === 'xxx'` exposée hors d'un mapping
|
||||
* explicite (ici la branche est binaire free vs payant).
|
||||
* Règle L : tokens DA Charcoal exclusivement.
|
||||
*/
|
||||
|
||||
import { Link } from 'react-router-dom'
|
||||
import { Button } from '@/shared/ui/Button'
|
||||
import { Card } from '@/shared/ui/Card'
|
||||
import { Badge } from '@/shared/ui/Badge'
|
||||
import { usePlan } from '@/features/dashboard/hooks/usePlan'
|
||||
import { useCustomerPortal } from '../hooks/useCustomerPortal'
|
||||
|
||||
const PLAN_LABEL: Record<'free' | 'standard' | 'premium', string> = {
|
||||
free: 'Plan Découverte',
|
||||
standard: 'Plan Standard',
|
||||
premium: 'Plan Premium',
|
||||
}
|
||||
|
||||
export function AccountBillingSection() {
|
||||
const { data: planData, isLoading: isPlanLoading } = usePlan()
|
||||
const { openPortal, isLoading: isPortalLoading, error: portalError } = useCustomerPortal()
|
||||
|
||||
if (isPlanLoading || !planData) {
|
||||
return (
|
||||
<Card variant="default" className="p-6">
|
||||
<div
|
||||
className="h-24 animate-pulse rounded bg-surface-hover"
|
||||
aria-busy="true"
|
||||
aria-label="Chargement du plan…"
|
||||
/>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
const plan = planData.plan as 'free' | 'standard' | 'premium'
|
||||
const isSubscribed = plan !== 'free'
|
||||
|
||||
return (
|
||||
<Card variant="default" className="space-y-4 p-6">
|
||||
<header className="flex flex-wrap items-center justify-between gap-3">
|
||||
<h2 className="text-lg font-semibold text-ink-primary">Abonnement</h2>
|
||||
<Badge variant="plan" planValue={plan}>
|
||||
{PLAN_LABEL[plan]}
|
||||
</Badge>
|
||||
</header>
|
||||
|
||||
{isSubscribed ? (
|
||||
<>
|
||||
<p className="text-sm text-ink-secondary">
|
||||
Modifier votre plan, mettre à jour votre moyen de paiement, ou consulter vos factures.
|
||||
</p>
|
||||
<Button variant="primary" size="md" onClick={openPortal} loading={isPortalLoading}>
|
||||
Gérer mon abonnement
|
||||
</Button>
|
||||
{portalError && (
|
||||
<p
|
||||
role="alert"
|
||||
className="rounded-md border border-danger/30 bg-danger-soft px-3 py-2 text-sm text-danger"
|
||||
>
|
||||
{portalError}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<p className="text-sm text-ink-secondary">
|
||||
Vous utilisez actuellement le plan gratuit (5 simulations à vie). Découvrez les plans
|
||||
payants pour un entraînement illimité avec correction détaillée.
|
||||
</p>
|
||||
<Button variant="primary" size="md">
|
||||
<Link to="/plan" className="-m-1 p-1">
|
||||
Voir les plans
|
||||
</Link>
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
55
src/features/billing/hooks/useCustomerPortal.ts
Normal file
55
src/features/billing/hooks/useCustomerPortal.ts
Normal file
|
|
@ -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<string | null>(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,
|
||||
}
|
||||
}
|
||||
|
|
@ -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<PriceType | null>(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)
|
||||
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<Record<PriceType, string>> =
|
||||
error && lastClicked ? { [lastClicked]: error } : {}
|
||||
effectiveError && lastClicked ? { [lastClicked]: effectiveError } : {}
|
||||
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-[1100px] px-5 py-6 lg:px-9 lg:py-9">
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue