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 { HistoriquePage } from '@/features/historique/pages/HistoriquePage'
|
||||||
import { ProgressionPage } from '@/features/progression/pages/ProgressionPage'
|
import { ProgressionPage } from '@/features/progression/pages/ProgressionPage'
|
||||||
import { PricingPage } from '@/features/billing/pages/PricingPage'
|
import { PricingPage } from '@/features/billing/pages/PricingPage'
|
||||||
|
import { ParametresPage } from '@/features/account/pages/ParametresPage'
|
||||||
import { SimulationFlowProvider } from '@/features/simulations/state/SimulationFlowProvider'
|
import { SimulationFlowProvider } from '@/features/simulations/state/SimulationFlowProvider'
|
||||||
import { AppLayout } from './AppLayout'
|
import { AppLayout } from './AppLayout'
|
||||||
|
|
||||||
|
|
@ -91,7 +92,7 @@ export function AppRouter() {
|
||||||
<Route path="/methodologie" element={<ComingSoon />} />
|
<Route path="/methodologie" element={<ComingSoon />} />
|
||||||
<Route path="/historique" element={<HistoriquePage />} />
|
<Route path="/historique" element={<HistoriquePage />} />
|
||||||
<Route path="/plan" element={<PricingPage />} />
|
<Route path="/plan" element={<PricingPage />} />
|
||||||
<Route path="/parametres" element={<ComingSoon />} />
|
<Route path="/parametres" element={<ParametresPage />} />
|
||||||
</Route>
|
</Route>
|
||||||
|
|
||||||
{/* ── Dev only ─────────────────────────────────────────────── */}
|
{/* ── 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 ───────────────────────────────────────────────────────────────────
|
// ─── Mocks ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const { usePlanMock, createCheckoutSessionMock } = vi.hoisted(() => ({
|
const { usePlanMock, createCheckoutSessionMock, createCustomerPortalSessionMock } = vi.hoisted(
|
||||||
usePlanMock: vi.fn(),
|
() => ({
|
||||||
createCheckoutSessionMock: vi.fn(),
|
usePlanMock: vi.fn(),
|
||||||
}))
|
createCheckoutSessionMock: vi.fn(),
|
||||||
|
createCustomerPortalSessionMock: vi.fn(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
vi.mock('@/features/dashboard/hooks/usePlan', () => ({
|
vi.mock('@/features/dashboard/hooks/usePlan', () => ({
|
||||||
usePlan: usePlanMock,
|
usePlan: usePlanMock,
|
||||||
|
|
@ -17,6 +20,7 @@ vi.mock('@/features/dashboard/hooks/usePlan', () => ({
|
||||||
|
|
||||||
vi.mock('../api', () => ({
|
vi.mock('../api', () => ({
|
||||||
createCheckoutSession: createCheckoutSessionMock,
|
createCheckoutSession: createCheckoutSessionMock,
|
||||||
|
createCustomerPortalSession: createCustomerPortalSessionMock,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
import { PricingPage } from '../pages/PricingPage'
|
import { PricingPage } from '../pages/PricingPage'
|
||||||
|
|
@ -25,6 +29,7 @@ afterEach(() => {
|
||||||
cleanup()
|
cleanup()
|
||||||
usePlanMock.mockReset()
|
usePlanMock.mockReset()
|
||||||
createCheckoutSessionMock.mockReset()
|
createCheckoutSessionMock.mockReset()
|
||||||
|
createCustomerPortalSessionMock.mockReset()
|
||||||
})
|
})
|
||||||
|
|
||||||
function renderPage() {
|
function renderPage() {
|
||||||
|
|
@ -120,6 +125,20 @@ describe('PricingPage — interaction', () => {
|
||||||
expect(createCheckoutSessionMock.mock.calls[0]?.[0]).toBe('standard')
|
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 () => {
|
it("erreur de mutation → callout d'erreur affiché", async () => {
|
||||||
const user = userEvent.setup()
|
const user = userEvent.setup()
|
||||||
mockPlan('free')
|
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 { usePlan } from '@/features/dashboard/hooks/usePlan'
|
||||||
import { type PriceType } from '../api'
|
import { type PriceType } from '../api'
|
||||||
import { useStripeCheckout } from '../hooks/useStripeCheckout'
|
import { useStripeCheckout } from '../hooks/useStripeCheckout'
|
||||||
|
import { useCustomerPortal } from '../hooks/useCustomerPortal'
|
||||||
import { PlanCard, type PlanCardCta } from '../components/PlanCard'
|
import { PlanCard, type PlanCardCta } from '../components/PlanCard'
|
||||||
|
|
||||||
type Plan = 'free' | 'standard' | 'premium'
|
type Plan = 'free' | 'standard' | 'premium'
|
||||||
|
|
@ -87,12 +88,11 @@ interface CtaConfigs {
|
||||||
|
|
||||||
function buildCtaConfigs(
|
function buildCtaConfigs(
|
||||||
plan: Plan,
|
plan: Plan,
|
||||||
pendingType: PriceType | null,
|
isStandardPending: boolean,
|
||||||
|
isPremiumPending: boolean,
|
||||||
onUpgrade: (priceType: PriceType) => void,
|
onUpgrade: (priceType: PriceType) => void,
|
||||||
): CtaConfigs {
|
): CtaConfigs {
|
||||||
const isStandardPending = pendingType === 'standard'
|
const anyPending = isStandardPending || isPremiumPending
|
||||||
const isPremiumPending = pendingType === 'premium'
|
|
||||||
const anyPending = pendingType !== null
|
|
||||||
|
|
||||||
if (plan === 'free') {
|
if (plan === 'free') {
|
||||||
return {
|
return {
|
||||||
|
|
@ -148,21 +148,33 @@ function buildCtaConfigs(
|
||||||
|
|
||||||
export function PricingPage() {
|
export function PricingPage() {
|
||||||
const { data: planData, isLoading } = usePlan()
|
const { data: planData, isLoading } = usePlan()
|
||||||
const { checkout, pendingPriceType, error } = useStripeCheckout()
|
const { checkout, pendingPriceType, error: checkoutError } = useStripeCheckout()
|
||||||
// Mémorise le dernier priceType cliqué pour rattacher l'erreur globale du
|
const { openPortal, isLoading: isPortalLoading, error: portalError } = useCustomerPortal()
|
||||||
// hook à la bonne carte. Sprint 5c — `useStripeCheckout` n'expose qu'un
|
// Mémorise le dernier priceType cliqué pour rattacher l'erreur (checkout OU
|
||||||
// `error` global (l'utilisateur ne clique qu'un CTA à la fois).
|
// portal) à la bonne carte. L'utilisateur ne clique qu'un CTA à la fois.
|
||||||
const [lastClicked, setLastClicked] = useState<PriceType | null>(null)
|
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) {
|
function handleUpgrade(priceType: PriceType) {
|
||||||
setLastClicked(priceType)
|
setLastClicked(priceType)
|
||||||
checkout(priceType)
|
if (plan === 'standard') {
|
||||||
|
openPortal()
|
||||||
|
} else {
|
||||||
|
checkout(priceType)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const plan = (planData?.plan as Plan | undefined) ?? 'free'
|
// Loading par carte : combine la source pertinente selon le plan utilisateur.
|
||||||
const ctaConfigs = buildCtaConfigs(plan, pendingPriceType, handleUpgrade)
|
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>> =
|
const errorByType: Partial<Record<PriceType, string>> =
|
||||||
error && lastClicked ? { [lastClicked]: error } : {}
|
effectiveError && lastClicked ? { [lastClicked]: effectiveError } : {}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto w-full max-w-[1100px] px-5 py-6 lg:px-9 lg:py-9">
|
<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