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:
parent
9edfbb3c95
commit
bda7feb196
7 changed files with 371 additions and 25 deletions
107
src/features/billing/__tests__/useStripeCheckout.test.tsx
Normal file
107
src/features/billing/__tests__/useStripeCheckout.test.tsx
Normal file
|
|
@ -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 <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 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()
|
||||||
|
})
|
||||||
|
})
|
||||||
59
src/features/billing/hooks/useStripeCheckout.ts
Normal file
59
src/features/billing/hooks/useStripeCheckout.ts
Normal file
|
|
@ -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()
|
||||||
|
* <Button loading={pendingPriceType === 'standard'} onClick={() => checkout('standard')}>
|
||||||
|
* Choisir Standard
|
||||||
|
* </Button>
|
||||||
|
*/
|
||||||
|
|
||||||
|
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<PriceType | null>(null)
|
||||||
|
const [error, setError] = useState<string | null>(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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -17,9 +17,9 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { useMutation } from '@tanstack/react-query'
|
|
||||||
import { usePlan } from '@/features/dashboard/hooks/usePlan'
|
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'
|
import { PlanCard, type PlanCardCta } from '../components/PlanCard'
|
||||||
|
|
||||||
type Plan = 'free' | 'standard' | 'premium'
|
type Plan = 'free' | 'standard' | 'premium'
|
||||||
|
|
@ -148,34 +148,21 @@ function buildCtaConfigs(
|
||||||
|
|
||||||
export function PricingPage() {
|
export function PricingPage() {
|
||||||
const { data: planData, isLoading } = usePlan()
|
const { data: planData, isLoading } = usePlan()
|
||||||
const [pendingType, setPendingType] = useState<PriceType | null>(null)
|
const { checkout, pendingPriceType, error } = useStripeCheckout()
|
||||||
const [errorByType, setErrorByType] = useState<Partial<Record<PriceType, string>>>({})
|
// Mémorise le dernier priceType cliqué pour rattacher l'erreur globale du
|
||||||
|
// hook à la bonne carte. Sprint 5c — `useStripeCheckout` n'expose qu'un
|
||||||
const mutation = useMutation({
|
// `error` global (l'utilisateur ne clique qu'un CTA à la fois).
|
||||||
mutationFn: createCheckoutSession,
|
const [lastClicked, setLastClicked] = useState<PriceType | null>(null)
|
||||||
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)
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
function handleUpgrade(priceType: PriceType) {
|
function handleUpgrade(priceType: PriceType) {
|
||||||
setErrorByType((prev) => ({ ...prev, [priceType]: undefined }))
|
setLastClicked(priceType)
|
||||||
setPendingType(priceType)
|
checkout(priceType)
|
||||||
mutation.mutate(priceType)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const plan = (planData?.plan as Plan | undefined) ?? 'free'
|
const plan = (planData?.plan as Plan | undefined) ?? 'free'
|
||||||
const ctaConfigs = buildCtaConfigs(plan, pendingType, handleUpgrade)
|
const ctaConfigs = buildCtaConfigs(plan, pendingPriceType, handleUpgrade)
|
||||||
|
const errorByType: Partial<Record<PriceType, string>> =
|
||||||
|
error && lastClicked ? { [lastClicked]: error } : {}
|
||||||
|
|
||||||
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">
|
||||||
|
|
|
||||||
41
src/features/dashboard/components/UpgradeSuccessBanner.tsx
Normal file
41
src/features/dashboard/components/UpgradeSuccessBanner.tsx
Normal 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 été 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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')
|
||||||
|
})
|
||||||
|
})
|
||||||
60
src/features/dashboard/hooks/useUpgradeSuccessHandler.ts
Normal file
60
src/features/dashboard/hooks/useUpgradeSuccessHandler.ts
Normal 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 }
|
||||||
|
}
|
||||||
|
|
@ -11,9 +11,11 @@ import { Button } from '@/shared/ui/Button'
|
||||||
import { hasAccess, canSimulate } from '@/entities/user/lib'
|
import { hasAccess, canSimulate } from '@/entities/user/lib'
|
||||||
import { useAuth } from '@/features/auth/hooks/useAuth'
|
import { useAuth } from '@/features/auth/hooks/useAuth'
|
||||||
import { usePlan, PLAN_QUERY_KEY } from '../hooks/usePlan'
|
import { usePlan, PLAN_QUERY_KEY } from '../hooks/usePlan'
|
||||||
|
import { useUpgradeSuccessHandler } from '../hooks/useUpgradeSuccessHandler'
|
||||||
import { DashboardFreeView } from '../components/DashboardFreeView'
|
import { DashboardFreeView } from '../components/DashboardFreeView'
|
||||||
import { DashboardStandardView } from '../components/DashboardStandardView'
|
import { DashboardStandardView } from '../components/DashboardStandardView'
|
||||||
import { DashboardPremiumView } from '../components/DashboardPremiumView'
|
import { DashboardPremiumView } from '../components/DashboardPremiumView'
|
||||||
|
import { UpgradeSuccessBanner } from '../components/UpgradeSuccessBanner'
|
||||||
|
|
||||||
function getDisplayName(
|
function getDisplayName(
|
||||||
user: { user_metadata?: { full_name?: string }; email?: string } | null,
|
user: { user_metadata?: { full_name?: string }; email?: string } | null,
|
||||||
|
|
@ -90,8 +92,10 @@ function DashboardContent() {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function DashboardPage() {
|
export function DashboardPage() {
|
||||||
|
const { showSuccess, dismiss } = useUpgradeSuccessHandler()
|
||||||
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">
|
||||||
|
{showSuccess && <UpgradeSuccessBanner onDismiss={dismiss} />}
|
||||||
<DashboardContent />
|
<DashboardContent />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue