Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 58 additions & 4 deletions src/hooks/__tests__/useTradeCooldownStatus.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ describe('useTradeCooldownStatus (#998)', () => {
});

const { result } = renderHook(
() => useTradeCooldownStatus('creator-1'),
() => useTradeCooldownStatus('creator-1', 'GWALLET1'),
{ wrapper }
);

Expand All @@ -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 }
);

Expand All @@ -82,7 +136,7 @@ describe('useTradeCooldownStatus (#998)', () => {
});

const { result } = renderHook(
() => useTradeCooldownStatus('creator-1'),
() => useTradeCooldownStatus('creator-1', 'GWALLET1'),
{ wrapper }
);

Expand Down
17 changes: 11 additions & 6 deletions src/hooks/useTradeCooldownStatus.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<TradeCooldownStatus | null> {
return useQuery<TradeCooldownStatus | null>({
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,
});
Expand Down
8 changes: 6 additions & 2 deletions src/lib/queryKeys.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) =>
Expand Down
5 changes: 4 additions & 1 deletion src/pages/CreatorDetailPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof import('@/hooks/useCreators')>();
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<typeof import('wagmi')>();
return {
...actual,
useAccount: vi.fn(() => ({
address: 'GBUYERWALLET',
isConnected: true,
})),
};
});

vi.mock('@/hooks/useCreatorProfileStaleIndicator', () => ({
useCreatorProfileStaleIndicator: () => ({
Expand Down
Loading