diff --git a/src/features/dashboard/components/UpgradeSuccessBanner.tsx b/src/features/dashboard/components/UpgradeSuccessBanner.tsx
new file mode 100644
index 0000000..7fd8bc3
--- /dev/null
+++ b/src/features/dashboard/components/UpgradeSuccessBanner.tsx
@@ -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 (
+
+
+
+
Bienvenue ! Votre plan a été mis à jour.
+
+ Si certaines features semblent manquer, rafraîchissez la page dans quelques secondes — la
+ confirmation Stripe peut prendre un instant.
+
+
+
+
+ )
+}
diff --git a/src/features/dashboard/hooks/__tests__/useUpgradeSuccessHandler.test.tsx b/src/features/dashboard/hooks/__tests__/useUpgradeSuccessHandler.test.tsx
new file mode 100644
index 0000000..c60e517
--- /dev/null
+++ b/src/features/dashboard/hooks/__tests__/useUpgradeSuccessHandler.test.tsx
@@ -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
+let queryClient: QueryClient
+
+function wrapper({ children }: { children: ReactNode }) {
+ return {children}
+}
+
+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')
+ })
+})
diff --git a/src/features/dashboard/hooks/useUpgradeSuccessHandler.ts b/src/features/dashboard/hooks/useUpgradeSuccessHandler.ts
new file mode 100644
index 0000000..1873ece
--- /dev/null
+++ b/src/features/dashboard/hooks/useUpgradeSuccessHandler.ts
@@ -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 }
+}
diff --git a/src/features/dashboard/pages/DashboardPage.tsx b/src/features/dashboard/pages/DashboardPage.tsx
index 48e1212..cbaa59a 100644
--- a/src/features/dashboard/pages/DashboardPage.tsx
+++ b/src/features/dashboard/pages/DashboardPage.tsx
@@ -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 (
+ {showSuccess && }
)