diff --git a/src/features/billing/__tests__/useStripeCheckout.test.tsx b/src/features/billing/__tests__/useStripeCheckout.test.tsx new file mode 100644 index 0000000..d280bbf --- /dev/null +++ b/src/features/billing/__tests__/useStripeCheckout.test.tsx @@ -0,0 +1,107 @@ +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 { createCheckoutSessionMock } = vi.hoisted(() => ({ + createCheckoutSessionMock: vi.fn(), +})) + +vi.mock('../api', () => ({ + createCheckoutSession: createCheckoutSessionMock, +})) + +import { useStripeCheckout } from '../hooks/useStripeCheckout' + +afterEach(() => { + createCheckoutSessionMock.mockReset() +}) + +function wrapper({ children }: { children: ReactNode }) { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, + }) + return {children} +} + +// ─── Tests ─────────────────────────────────────────────────────────────────── + +describe('useStripeCheckout', () => { + it('checkout(priceType) appelle createCheckoutSession avec le bon argument', async () => { + createCheckoutSessionMock.mockReturnValue(new Promise(() => {})) + const { result } = renderHook(() => useStripeCheckout(), { wrapper }) + + act(() => { + result.current.checkout('standard') + }) + + await waitFor(() => { + expect(createCheckoutSessionMock).toHaveBeenCalledTimes(1) + }) + expect(createCheckoutSessionMock.mock.calls[0]?.[0]).toBe('standard') + }) + + it('expose pendingPriceType pendant la mutation', async () => { + createCheckoutSessionMock.mockReturnValue(new Promise(() => {})) + const { result } = renderHook(() => useStripeCheckout(), { wrapper }) + + expect(result.current.pendingPriceType).toBeNull() + act(() => { + result.current.checkout('premium') + }) + expect(result.current.pendingPriceType).toBe('premium') + }) + + it("redirige window.location.href vers l'URL Stripe au succès", 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) + }, + }, + }) + + createCheckoutSessionMock.mockResolvedValue({ + url: 'https://checkout.stripe.com/pay/cs_xyz', + }) + + const { result } = renderHook(() => useStripeCheckout(), { wrapper }) + act(() => { + result.current.checkout('standard') + }) + + await waitFor(() => { + expect(hrefSetter).toHaveBeenCalledWith('https://checkout.stripe.com/pay/cs_xyz') + }) + + Object.defineProperty(window, 'location', { + configurable: true, + writable: true, + value: originalLocation, + }) + }) + + it("expose error et reset pendingPriceType en cas d'échec", async () => { + createCheckoutSessionMock.mockRejectedValue(new Error('Configuration Stripe manquante.')) + const { result } = renderHook(() => useStripeCheckout(), { wrapper }) + + act(() => { + result.current.checkout('standard') + }) + + await waitFor(() => { + expect(result.current.error).toMatch(/Configuration Stripe manquante/) + }) + expect(result.current.pendingPriceType).toBeNull() + }) +}) diff --git a/src/features/billing/hooks/useStripeCheckout.ts b/src/features/billing/hooks/useStripeCheckout.ts new file mode 100644 index 0000000..4d83c8c --- /dev/null +++ b/src/features/billing/hooks/useStripeCheckout.ts @@ -0,0 +1,59 @@ +/** + * Sprint 5c — Hook checkout Stripe. + * + * Encapsule la mutation `createCheckoutSession` + la redirection full-page + * vers Stripe Checkout. Expose `pendingPriceType` pour permettre aux pages + * (ex. PricingPage) d'afficher un loading par carte sans state local. + * + * Usage typique : + * const { checkout, pendingPriceType, error } = useStripeCheckout() + * + */ + +import { useState } from 'react' +import { useMutation } from '@tanstack/react-query' +import { createCheckoutSession, type PriceType } from '../api' + +export interface UseStripeCheckoutResult { + checkout: (priceType: PriceType) => void + isLoading: boolean + pendingPriceType: PriceType | null + error: string | null +} + +const FALLBACK_ERROR_MESSAGE = + 'Impossible de démarrer le paiement. Réessayez dans quelques instants.' + +export function useStripeCheckout(): UseStripeCheckoutResult { + const [pendingPriceType, setPendingPriceType] = useState(null) + const [error, setError] = useState(null) + + const mutation = useMutation({ + mutationFn: createCheckoutSession, + onSuccess: (data) => { + // Redirection full-page vers Stripe Checkout. L'utilisateur reviendra + // sur /dashboard?upgrade=success après paiement réussi (cf. backend + // success_url) ou /plan?upgrade=cancelled en cas d'annulation. + window.location.href = data.url + }, + onError: (err: Error) => { + setError(err.message || FALLBACK_ERROR_MESSAGE) + setPendingPriceType(null) + }, + }) + + function checkout(priceType: PriceType): void { + setError(null) + setPendingPriceType(priceType) + mutation.mutate(priceType) + } + + return { + checkout, + isLoading: mutation.isPending, + pendingPriceType, + error, + } +} diff --git a/src/features/billing/pages/PricingPage.tsx b/src/features/billing/pages/PricingPage.tsx index 103389a..973298a 100644 --- a/src/features/billing/pages/PricingPage.tsx +++ b/src/features/billing/pages/PricingPage.tsx @@ -17,9 +17,9 @@ */ import { useState } from 'react' -import { useMutation } from '@tanstack/react-query' import { usePlan } from '@/features/dashboard/hooks/usePlan' -import { createCheckoutSession, type PriceType } from '../api' +import { type PriceType } from '../api' +import { useStripeCheckout } from '../hooks/useStripeCheckout' import { PlanCard, type PlanCardCta } from '../components/PlanCard' type Plan = 'free' | 'standard' | 'premium' @@ -148,34 +148,21 @@ function buildCtaConfigs( export function PricingPage() { const { data: planData, isLoading } = usePlan() - const [pendingType, setPendingType] = useState(null) - const [errorByType, setErrorByType] = useState>>({}) - - const mutation = useMutation({ - mutationFn: createCheckoutSession, - onSuccess: (data) => { - // Redirection full-page vers Stripe Checkout. L'utilisateur reviendra - // sur /dashboard?upgrade=success après paiement (cf. backend success_url). - window.location.href = data.url - }, - onError: (err: Error, priceType) => { - setErrorByType((prev) => ({ - ...prev, - [priceType]: - err.message || 'Impossible de démarrer le paiement. Réessayez dans quelques instants.', - })) - setPendingType(null) - }, - }) + 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 [lastClicked, setLastClicked] = useState(null) function handleUpgrade(priceType: PriceType) { - setErrorByType((prev) => ({ ...prev, [priceType]: undefined })) - setPendingType(priceType) - mutation.mutate(priceType) + setLastClicked(priceType) + checkout(priceType) } const plan = (planData?.plan as Plan | undefined) ?? 'free' - const ctaConfigs = buildCtaConfigs(plan, pendingType, handleUpgrade) + const ctaConfigs = buildCtaConfigs(plan, pendingPriceType, handleUpgrade) + const errorByType: Partial> = + error && lastClicked ? { [lastClicked]: error } : {} return (
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 ( +
+
+ ) +} 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 && }
)