feat(billing): useStripeCheckout hook + post-redirect upgrade success

- 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>
This commit is contained in:
Hermann_Kitio 2026-04-26 05:19:18 +03:00
parent 9edfbb3c95
commit bda7feb196
7 changed files with 371 additions and 25 deletions

View file

@ -0,0 +1,41 @@
/**
* Sprint 5c Banner affiché au retour de Stripe Checkout réussi.
*
* Présentationnel pur (Règle H). Le déclenchement et le nettoyage URL sont
* gérés par `useUpgradeSuccessHandler` côté DashboardPage.
*
* Tokens DA Charcoal exclusivement (Règle L).
*/
import { CheckCircle2, X } from 'lucide-react'
interface Props {
onDismiss: () => void
}
export function UpgradeSuccessBanner({ onDismiss }: Props) {
return (
<div
role="status"
aria-live="polite"
className="mb-6 flex items-start gap-3 rounded-[var(--radius-md)] border border-success/30 bg-success-soft p-4 text-sm"
>
<CheckCircle2 className="mt-0.5 size-5 shrink-0 text-success" aria-hidden="true" />
<div className="flex-1">
<p className="font-semibold text-ink-primary">Bienvenue ! Votre plan a é mis à jour.</p>
<p className="mt-0.5 text-xs text-ink-secondary">
Si certaines features semblent manquer, rafraîchissez la page dans quelques secondes la
confirmation Stripe peut prendre un instant.
</p>
</div>
<button
type="button"
onClick={onDismiss}
aria-label="Fermer le message"
className="shrink-0 rounded-md p-1 text-ink-tertiary transition-colors hover:bg-surface-hover hover:text-ink-secondary focus-visible:outline-none focus-visible:shadow-focus"
>
<X className="size-4" aria-hidden="true" />
</button>
</div>
)
}

View file

@ -0,0 +1,88 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { renderHook, act } from '@testing-library/react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import type { ReactNode } from 'react'
import { PLAN_QUERY_KEY } from '@/entities/user/query-keys'
import { useUpgradeSuccessHandler } from '../useUpgradeSuccessHandler'
let invalidateSpy: ReturnType<typeof vi.fn>
let queryClient: QueryClient
function wrapper({ children }: { children: ReactNode }) {
return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
}
function setLocation(search: string) {
window.history.replaceState(null, '', `/dashboard${search}`)
}
beforeEach(() => {
queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
})
invalidateSpy = vi.fn().mockResolvedValue(undefined)
// Spy sur la méthode invalidateQueries pour vérifier la clé exacte.
queryClient.invalidateQueries = invalidateSpy as unknown as typeof queryClient.invalidateQueries
})
afterEach(() => {
setLocation('')
})
describe('useUpgradeSuccessHandler', () => {
it('?upgrade=success → showSuccess=true, invalidate(PLAN_QUERY_KEY) appelé, URL nettoyée', () => {
setLocation('?upgrade=success')
const { result } = renderHook(() => useUpgradeSuccessHandler(), { wrapper })
expect(result.current.showSuccess).toBe(true)
expect(invalidateSpy).toHaveBeenCalledTimes(1)
expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: PLAN_QUERY_KEY })
// URL nettoyée : plus de `upgrade` dans la query string.
expect(window.location.search).toBe('')
})
it('absence de query param → showSuccess=false, invalidate non appelé', () => {
setLocation('')
const { result } = renderHook(() => useUpgradeSuccessHandler(), { wrapper })
expect(result.current.showSuccess).toBe(false)
expect(invalidateSpy).not.toHaveBeenCalled()
})
it("?upgrade=cancelled (autre valeur) → showSuccess=false, pas d'action", () => {
setLocation('?upgrade=cancelled')
const { result } = renderHook(() => useUpgradeSuccessHandler(), { wrapper })
expect(result.current.showSuccess).toBe(false)
expect(invalidateSpy).not.toHaveBeenCalled()
// URL conservée intacte (autre valeur, hors scope du nettoyage).
expect(window.location.search).toBe('?upgrade=cancelled')
})
it('dismiss() bascule showSuccess à false', () => {
setLocation('?upgrade=success')
const { result } = renderHook(() => useUpgradeSuccessHandler(), { wrapper })
expect(result.current.showSuccess).toBe(true)
act(() => {
result.current.dismiss()
})
expect(result.current.showSuccess).toBe(false)
})
it('conserve les autres query params (utm_*, etc.) lors du nettoyage', () => {
setLocation('?upgrade=success&utm_source=email&ref=abc')
renderHook(() => useUpgradeSuccessHandler(), { wrapper })
const params = new URLSearchParams(window.location.search)
expect(params.has('upgrade')).toBe(false)
expect(params.get('utm_source')).toBe('email')
expect(params.get('ref')).toBe('abc')
})
})

View file

@ -0,0 +1,60 @@
/**
* Sprint 5c Détection retour Stripe Checkout réussi.
*
* Lit `?upgrade=success` au mount de la page Dashboard, déclenche :
* 1. invalidation du cache plan (`PLAN_QUERY_KEY`) refetch automatique
* du plan mis à jour par le webhook backend `checkout.session.completed`,
* 2. affichage d'un banner de succès (consommé par DashboardPage),
* 3. nettoyage du query param via `history.replaceState` (un refresh ne
* doit pas re-déclencher le banner).
*
* Indépendant de react-router (lit `window.location.search` directement)
* pour faciliter les tests sans MemoryRouter.
*
* Race connue (Sprint 5c) : le webhook Stripe peut arriver après le
* redirect frontend (latence ~1-3 s). Si l'invalidation refetch trop tôt,
* `usePlan()` retourne encore l'ancien plan. Mitigation MVP : message
* neutre + refresh manuel résoud. Polling/retry à tracer en FTD si
* problème observé en production.
*/
import { useEffect, useState } from 'react'
import { useQueryClient } from '@tanstack/react-query'
import { PLAN_QUERY_KEY } from '@/entities/user/query-keys'
export interface UseUpgradeSuccessHandlerResult {
showSuccess: boolean
dismiss: () => void
}
const QUERY_PARAM = 'upgrade'
const SUCCESS_VALUE = 'success'
export function useUpgradeSuccessHandler(): UseUpgradeSuccessHandlerResult {
const [showSuccess, setShowSuccess] = useState(false)
const queryClient = useQueryClient()
useEffect(() => {
if (typeof window === 'undefined') return
const params = new URLSearchParams(window.location.search)
if (params.get(QUERY_PARAM) !== SUCCESS_VALUE) return
setShowSuccess(true)
void queryClient.invalidateQueries({ queryKey: PLAN_QUERY_KEY })
// Nettoyage URL : retire UNIQUEMENT le param `upgrade`, conserve les autres
// (utm_*, etc.). `replaceState` ne déclenche pas de remount React Router.
params.delete(QUERY_PARAM)
const remaining = params.toString()
const newSearch = remaining.length > 0 ? `?${remaining}` : ''
const newUrl = window.location.pathname + newSearch + window.location.hash
window.history.replaceState(null, '', newUrl)
}, [queryClient])
function dismiss(): void {
setShowSuccess(false)
}
return { showSuccess, dismiss }
}

View file

@ -11,9 +11,11 @@ import { Button } from '@/shared/ui/Button'
import { hasAccess, canSimulate } from '@/entities/user/lib'
import { useAuth } from '@/features/auth/hooks/useAuth'
import { usePlan, PLAN_QUERY_KEY } from '../hooks/usePlan'
import { useUpgradeSuccessHandler } from '../hooks/useUpgradeSuccessHandler'
import { DashboardFreeView } from '../components/DashboardFreeView'
import { DashboardStandardView } from '../components/DashboardStandardView'
import { DashboardPremiumView } from '../components/DashboardPremiumView'
import { UpgradeSuccessBanner } from '../components/UpgradeSuccessBanner'
function getDisplayName(
user: { user_metadata?: { full_name?: string }; email?: string } | null,
@ -90,8 +92,10 @@ function DashboardContent() {
}
export function DashboardPage() {
const { showSuccess, dismiss } = useUpgradeSuccessHandler()
return (
<div className="mx-auto w-full max-w-[1100px] px-5 py-6 lg:px-9 lg:py-9">
{showSuccess && <UpgradeSuccessBanner onDismiss={dismiss} />}
<DashboardContent />
</div>
)