diff --git a/src/hooks/__tests__/useTradeCooldownStatus.test.tsx b/src/hooks/__tests__/useTradeCooldownStatus.test.tsx index 38744abf..e9819df2 100644 --- a/src/hooks/__tests__/useTradeCooldownStatus.test.tsx +++ b/src/hooks/__tests__/useTradeCooldownStatus.test.tsx @@ -47,7 +47,7 @@ describe('useTradeCooldownStatus (#998)', () => { }); const { result } = renderHook( - () => useTradeCooldownStatus('creator-1'), + () => useTradeCooldownStatus('creator-1', 'GWALLET1'), { wrapper } ); @@ -58,16 +58,70 @@ describe('useTradeCooldownStatus (#998)', () => { }); it('is disabled for an empty key id', () => { - renderHook(() => useTradeCooldownStatus(''), { wrapper }); + renderHook(() => useTradeCooldownStatus('', 'GWALLET1'), { wrapper }); expect(mockGetTradeCooldownStatus).not.toHaveBeenCalled(); }); + it('does not fetch when no wallet is connected (#915)', () => { + renderHook(() => useTradeCooldownStatus('creator-1', undefined), { + wrapper, + }); + + expect(mockGetTradeCooldownStatus).not.toHaveBeenCalled(); + }); + + it('fetches when a wallet connects after page load (#915)', async () => { + mockGetTradeCooldownStatus.mockResolvedValue({ + keyId: 'creator-1', + nextBuyAllowedAt: Math.floor(Date.now() / 1000) + 60, + }); + + const { result, rerender } = renderHook( + ({ wallet }: { wallet?: string }) => + useTradeCooldownStatus('creator-1', wallet), + { wrapper, initialProps: { wallet: undefined } as { wallet?: string } } + ); + + expect(mockGetTradeCooldownStatus).not.toHaveBeenCalled(); + + rerender({ wallet: 'GWALLET1' }); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(mockGetTradeCooldownStatus).toHaveBeenCalledTimes(1); + }); + + it('refetches for a new wallet and never shows the previous wallet cooldown (#915)', async () => { + const future = Math.floor(Date.now() / 1000) + 120; + mockGetTradeCooldownStatus + .mockResolvedValueOnce({ keyId: 'creator-1', nextBuyAllowedAt: future }) + .mockResolvedValueOnce({ keyId: 'creator-1', nextBuyAllowedAt: null }); + + const { result, rerender } = renderHook( + ({ wallet }: { wallet: string }) => + useTradeCooldownStatus('creator-1', wallet), + { wrapper, initialProps: { wallet: 'GWALLET1' } } + ); + + await waitFor(() => + expect(result.current.data?.nextBuyAllowedAt).toBe(future) + ); + + rerender({ wallet: 'GWALLET2' }); + + // Wallet 1's cooldown must not leak into wallet 2 while it loads. + expect(result.current.data).toBeUndefined(); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(result.current.data?.nextBuyAllowedAt).toBeNull(); + expect(mockGetTradeCooldownStatus).toHaveBeenCalledTimes(2); + }); + it('treats a 404 (no cooldown endpoint) as null rather than an error', async () => { mockGetTradeCooldownStatus.mockResolvedValue(null); const { result } = renderHook( - () => useTradeCooldownStatus('creator-1'), + () => useTradeCooldownStatus('creator-1', 'GWALLET1'), { wrapper } ); @@ -82,7 +136,7 @@ describe('useTradeCooldownStatus (#998)', () => { }); const { result } = renderHook( - () => useTradeCooldownStatus('creator-1'), + () => useTradeCooldownStatus('creator-1', 'GWALLET1'), { wrapper } ); diff --git a/src/hooks/useTradeCooldownStatus.ts b/src/hooks/useTradeCooldownStatus.ts index 64165133..611d6c5d 100644 --- a/src/hooks/useTradeCooldownStatus.ts +++ b/src/hooks/useTradeCooldownStatus.ts @@ -17,26 +17,31 @@ import { isActiveCooldown } from '@/utils/tradeCooldown.utils'; export type { TradeCooldownInfo }; /** - * Fetches the trade cooldown status for a creator key (#998). + * Fetches the trade cooldown status for a creator key and the connected + * wallet (#998, #915). * - * The status is fetched on page load (React Query's default fetch-on-mount) - * and refreshed after every completed transaction: `invalidateTradeCooldownStatus` + * The cache is scoped per wallet, so the status is fetched on page load and + * again whenever a wallet connects or switches — a previous wallet's cooldown + * is never shown for the new one. With no wallet connected there is no + * per-wallet cooldown, so nothing is fetched. It is refreshed after every + * completed transaction: `invalidateTradeCooldownStatus` * is exported so trade mutations can drop the cached cooldown and force a * refetch, which is how buttons re-enable as soon as the backend reports the * cooldown has lifted. */ export function useTradeCooldownStatus( - keyId: string + keyId: string, + walletAddress?: string | null ): UseQueryResult { return useQuery({ - queryKey: queryKeys.creators.tradeCooldown(keyId), + queryKey: queryKeys.creators.tradeCooldown(keyId, walletAddress ?? ''), queryFn: async () => { // The endpoint has no per-wallet identity in the payload — the // creatorId is the key we asked about. const info = await courseService.getTradeCooldownStatus(keyId); return info ? { ...info, creatorId: keyId } : null; }, - enabled: Boolean(keyId), + enabled: Boolean(keyId) && Boolean(walletAddress), staleTime: 15_000, retry: false, }); diff --git a/src/lib/queryKeys.ts b/src/lib/queryKeys.ts index d7b8d133..b9fa31a7 100644 --- a/src/lib/queryKeys.ts +++ b/src/lib/queryKeys.ts @@ -54,8 +54,12 @@ export const queryKeys = { ['creators', creatorId, 'oracle-price'] as const, performanceBond: (creatorId: string) => ['creators', creatorId, 'performance-bond'] as const, - tradeCooldown: (creatorId: string) => - ['creators', creatorId, 'trade-cooldown'] as const, + // Optional wallet scopes the cache per connected wallet (#915); omit it + // to address every wallet's entry for the key (e.g. invalidation). + tradeCooldown: (creatorId: string, wallet?: string) => + wallet + ? (['creators', creatorId, 'trade-cooldown', wallet] as const) + : (['creators', creatorId, 'trade-cooldown'] as const), whitelist: (creatorId: string) => ['creators', creatorId, 'whitelist'] as const, publicProfile: (address: string) => diff --git a/src/pages/CreatorDetailPage.tsx b/src/pages/CreatorDetailPage.tsx index fc767eb1..63a8dd38 100644 --- a/src/pages/CreatorDetailPage.tsx +++ b/src/pages/CreatorDetailPage.tsx @@ -287,7 +287,10 @@ function CreatorDetailPageContent() { const tradeMutation = useTradeMutation(connectedWalletAddress ?? ''); - const { data: tradeCooldownStatus } = useTradeCooldownStatus(id || ''); + const { data: tradeCooldownStatus } = useTradeCooldownStatus( + id || '', + userAddress + ); const tradeCooldown: ActiveTradeCooldown | null = resolveActiveTradeCooldown( tradeCooldownStatus, diff --git a/src/pages/__tests__/CreatorDetailPage.tradeCooldown.integration.test.tsx b/src/pages/__tests__/CreatorDetailPage.tradeCooldown.integration.test.tsx index 9136b4db..e14e78a2 100644 --- a/src/pages/__tests__/CreatorDetailPage.tradeCooldown.integration.test.tsx +++ b/src/pages/__tests__/CreatorDetailPage.tradeCooldown.integration.test.tsx @@ -7,10 +7,26 @@ import { MemoryRouter, Route, Routes } from 'react-router'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import showToast from '@/utils/toast.util'; -vi.mock('@/hooks/useCreators', () => ({ - useCreatorDetail: vi.fn(), - useSetCoCreator: vi.fn(() => ({ mutate: vi.fn(), isPending: false })), -})); +vi.mock('@/hooks/useCreators', async importOriginal => { + const actual = await importOriginal(); + return { + ...actual, + useCreatorDetail: vi.fn(), + useSetCoCreator: vi.fn(() => ({ mutate: vi.fn(), isPending: false })), + }; +}); + +// A connected wallet: the cooldown is fetched per connected wallet (#915). +vi.mock('wagmi', async importOriginal => { + const actual = await importOriginal(); + return { + ...actual, + useAccount: vi.fn(() => ({ + address: 'GBUYERWALLET', + isConnected: true, + })), + }; +}); vi.mock('@/hooks/useCreatorProfileStaleIndicator', () => ({ useCreatorProfileStaleIndicator: () => ({