From d1134ad5e41bc62ba142012fbd5702437230e37b Mon Sep 17 00:00:00 2001 From: meem08 Date: Mon, 28 Sep 2026 00:55:24 +0100 Subject: [PATCH 1/2] feat: per-wallet buy cooldown countdown on key detail page (#915) Adds a dedicated read for the connected wallet's buy-cooldown expiry, a shared ticking countdown hook, and a locked-out Buy button state on the creator key detail page. Scope: - KeyBuyCooldown + courseService.getKeyBuyCooldown (GET /keys/:keyId/buy-cooldown) and the useKeyBuyCooldown query hook, disabled until a wallet is connected so the read fires on key page load and again on wallet connect - useBuyCooldownCountdown, the single source of truth for remaining cooldown time, so the timer and the Buy button can never disagree; BuyCooldownCountdown now builds on it - Buy panel shows the countdown in-panel, disables the button, surfaces the remaining time on the button and in an aria-describedby hint, re-enables on the tick that crosses zero, and re-reads the cooldown at expiry - No cooldown (null/404/absent) leaves the normal Buy UI with no timer; a failed read never blocks buying Refs #915 --- .../common/BuyCooldownCountdown.tsx | 40 +-- .../useBuyCooldownCountdown.test.tsx | 123 ++++++++ .../__tests__/useKeyBuyCooldown.test.tsx | 126 ++++++++ src/hooks/useBuyCooldownCountdown.ts | 74 +++++ src/hooks/useKeyBuyCooldown.ts | 35 +++ src/lib/queryKeys.ts | 2 + src/pages/CreatorDetailPage.tsx | 86 ++++-- ...etailPage.buyCooldown.integration.test.tsx | 283 ++++++++++++++++++ src/services/course.service.ts | 52 ++++ 9 files changed, 768 insertions(+), 53 deletions(-) create mode 100644 src/hooks/__tests__/useBuyCooldownCountdown.test.tsx create mode 100644 src/hooks/__tests__/useKeyBuyCooldown.test.tsx create mode 100644 src/hooks/useBuyCooldownCountdown.ts create mode 100644 src/hooks/useKeyBuyCooldown.ts create mode 100644 src/pages/__tests__/CreatorDetailPage.buyCooldown.integration.test.tsx diff --git a/src/components/common/BuyCooldownCountdown.tsx b/src/components/common/BuyCooldownCountdown.tsx index d1ba416d..d48fe82c 100644 --- a/src/components/common/BuyCooldownCountdown.tsx +++ b/src/components/common/BuyCooldownCountdown.tsx @@ -1,10 +1,7 @@ -import React, { useEffect, useState } from 'react'; +import React from 'react'; import { Timer } from 'lucide-react'; import { cn } from '@/lib/utils'; -import { - computeRemainingCooldownSeconds, - formatCooldownDuration, -} from '@/utils/buyCooldown.utils'; +import { useBuyCooldownCountdown } from '@/hooks/useBuyCooldownCountdown'; export interface BuyCooldownCountdownProps { /** Timestamp (ms epoch, seconds epoch, or ISO string) after which the authenticated user can next buy this key. */ @@ -25,38 +22,15 @@ export const BuyCooldownCountdown: React.FC = ({ onExpire, className, }) => { - const [remainingSeconds, setRemainingSeconds] = useState(() => - computeRemainingCooldownSeconds(nextBuyAllowedAt) + const { remainingSeconds, formattedRemaining } = useBuyCooldownCountdown( + nextBuyAllowedAt, + onExpire ); - useEffect(() => { - const initialRemaining = computeRemainingCooldownSeconds(nextBuyAllowedAt); - setRemainingSeconds(initialRemaining); - - if (initialRemaining <= 0) { - onExpire?.(); - return; - } - - const intervalId = setInterval(() => { - const rem = computeRemainingCooldownSeconds(nextBuyAllowedAt); - setRemainingSeconds(rem); - - if (rem <= 0) { - clearInterval(intervalId); - onExpire?.(); - } - }, 1000); - - return () => clearInterval(intervalId); - }, [nextBuyAllowedAt, onExpire]); - if (remainingSeconds <= 0 || !nextBuyAllowedAt) { return null; } - const formattedTime = formatCooldownDuration(remainingSeconds); - return (
= ({ data-testid="buy-cooldown-countdown" role="status" aria-live="polite" - aria-label={`Next buy available in ${formattedTime}`} + aria-label={`Next buy available in ${formattedRemaining}`} > Next buy available in{' '} - {formattedTime} + {formattedRemaining}
); diff --git a/src/hooks/__tests__/useBuyCooldownCountdown.test.tsx b/src/hooks/__tests__/useBuyCooldownCountdown.test.tsx new file mode 100644 index 00000000..bdbcd2e6 --- /dev/null +++ b/src/hooks/__tests__/useBuyCooldownCountdown.test.tsx @@ -0,0 +1,123 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { act, renderHook } from '@testing-library/react'; +import { useBuyCooldownCountdown } from '@/hooks/useBuyCooldownCountdown'; + +const NOW_SEC = 1_700_000_000; + +describe('useBuyCooldownCountdown (#915)', () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(NOW_SEC * 1000); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('is inactive with no cooldown data and starts no timer', () => { + const { result } = renderHook(() => useBuyCooldownCountdown(null)); + + expect(result.current.isActive).toBe(false); + expect(result.current.remainingSeconds).toBe(0); + expect(vi.getTimerCount()).toBe(0); + }); + + it('is inactive when the cooldown has already expired and starts no timer', () => { + const { result } = renderHook(() => + useBuyCooldownCountdown(NOW_SEC - 10) + ); + + expect(result.current.isActive).toBe(false); + expect(vi.getTimerCount()).toBe(0); + }); + + it('reports the remaining time formatted for display', () => { + const { result } = renderHook(() => useBuyCooldownCountdown(NOW_SEC + 272)); + + expect(result.current.isActive).toBe(true); + expect(result.current.remainingSeconds).toBe(272); + expect(result.current.formattedRemaining).toBe('4m 32s'); + }); + + it('accepts a millisecond epoch expiry', () => { + const { result } = renderHook(() => + useBuyCooldownCountdown((NOW_SEC + 65) * 1000) + ); + + expect(result.current.remainingSeconds).toBe(65); + expect(result.current.formattedRemaining).toBe('1m 05s'); + }); + + it('deactivates on the tick that crosses zero so the buy action re-enables', () => { + const { result } = renderHook(() => useBuyCooldownCountdown(NOW_SEC + 2)); + + expect(result.current.isActive).toBe(true); + expect(result.current.formattedRemaining).toBe('2s'); + + act(() => { + vi.advanceTimersByTime(1000); + }); + expect(result.current.isActive).toBe(true); + expect(result.current.formattedRemaining).toBe('1s'); + + act(() => { + vi.advanceTimersByTime(1000); + }); + expect(result.current.isActive).toBe(false); + expect(result.current.remainingSeconds).toBe(0); + }); + + it('calls onExpire exactly once and stops ticking', () => { + const onExpire = vi.fn(); + renderHook(() => useBuyCooldownCountdown(NOW_SEC + 1, onExpire)); + + act(() => { + vi.advanceTimersByTime(5000); + }); + + expect(onExpire).toHaveBeenCalledTimes(1); + expect(vi.getTimerCount()).toBe(0); + }); + + it('does not call onExpire when the cooldown is already over on mount', () => { + const onExpire = vi.fn(); + renderHook(() => useBuyCooldownCountdown(NOW_SEC - 1, onExpire)); + + act(() => { + vi.advanceTimersByTime(5000); + }); + + expect(onExpire).not.toHaveBeenCalled(); + }); + + it('does not restart the interval when an inline onExpire callback changes identity', () => { + renderHook(() => useBuyCooldownCountdown(NOW_SEC + 10, () => {})); + + const timerCountAfterMount = vi.getTimerCount(); + act(() => { + vi.advanceTimersByTime(3000); + }); + + expect(vi.getTimerCount()).toBe(timerCountAfterMount); + }); + + it('recomputes against a new expiry when the cooldown value changes', () => { + const { result, rerender } = renderHook( + ({ nextBuyAllowedAt }: { nextBuyAllowedAt: number | null }) => + useBuyCooldownCountdown(nextBuyAllowedAt), + { wrapper: undefined, initialProps: { nextBuyAllowedAt: NOW_SEC + 10 } } + ); + + expect(result.current.remainingSeconds).toBe(10); + + rerender({ nextBuyAllowedAt: NOW_SEC + 120 }); + + expect(result.current.remainingSeconds).toBe(120); + expect(result.current.formattedRemaining).toBe('2m 00s'); + + rerender({ nextBuyAllowedAt: null }); + + expect(result.current.isActive).toBe(false); + expect(vi.getTimerCount()).toBe(0); + }); +}); diff --git a/src/hooks/__tests__/useKeyBuyCooldown.test.tsx b/src/hooks/__tests__/useKeyBuyCooldown.test.tsx new file mode 100644 index 00000000..127cbc63 --- /dev/null +++ b/src/hooks/__tests__/useKeyBuyCooldown.test.tsx @@ -0,0 +1,126 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { renderHook, waitFor } from '@testing-library/react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import type { ReactNode } from 'react'; +import { useKeyBuyCooldown } from '@/hooks/useKeyBuyCooldown'; +import { courseService } from '@/services/course.service'; +import { queryKeys } from '@/lib/queryKeys'; + +vi.mock('@/services/course.service', () => ({ + courseService: { + getKeyBuyCooldown: vi.fn(), + }, +})); + +const mockGetKeyBuyCooldown = vi.mocked(courseService.getKeyBuyCooldown); + +describe('useKeyBuyCooldown (#915)', () => { + let queryClient: QueryClient; + + beforeEach(() => { + queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + mockGetKeyBuyCooldown.mockReset(); + }); + + afterEach(() => { + queryClient.clear(); + }); + + const wrapper = ({ children }: { children: ReactNode }) => ( + {children} + ); + + it('reads the cooldown expiry for the connected wallet and the key', async () => { + const nextBuyAllowedAt = 1_700_000_272; + mockGetKeyBuyCooldown.mockResolvedValue({ + keyId: 'key-1', + wallet: 'wallet-1', + nextBuyAllowedAt, + cooldownLedgers: 12, + }); + + const { result } = renderHook( + () => useKeyBuyCooldown('key-1', 'wallet-1'), + { wrapper } + ); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + + expect(mockGetKeyBuyCooldown).toHaveBeenCalledWith('key-1', 'wallet-1'); + expect(result.current.data?.nextBuyAllowedAt).toBe(nextBuyAllowedAt); + + const query = queryClient + .getQueryCache() + .find({ queryKey: queryKeys.creators.buyCooldown('key-1', 'wallet-1') }); + expect(query).toBeDefined(); + expect(query?.options.staleTime).toBe(15_000); + }); + + it('treats a null cooldown as a successful "no cooldown" answer', async () => { + mockGetKeyBuyCooldown.mockResolvedValue(null); + + const { result } = renderHook( + () => useKeyBuyCooldown('key-1', 'wallet-1'), + { wrapper } + ); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + + expect(result.current.data).toBeNull(); + expect(result.current.isError).toBe(false); + }); + + it('does not fetch until a wallet is connected, then fetches on connect', async () => { + mockGetKeyBuyCooldown.mockResolvedValue({ nextBuyAllowedAt: null }); + + const { result, rerender } = renderHook( + ({ wallet }: { wallet: string | undefined }) => + useKeyBuyCooldown('key-1', wallet), + { wrapper, initialProps: { wallet: undefined as string | undefined } } + ); + + expect(result.current.fetchStatus).toBe('idle'); + expect(mockGetKeyBuyCooldown).not.toHaveBeenCalled(); + + // Wallet connects while the key page is open. + rerender({ wallet: 'wallet-1' }); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(mockGetKeyBuyCooldown).toHaveBeenCalledWith('key-1', 'wallet-1'); + }); + + it('reads again for a different wallet instead of reusing the previous cache entry', async () => { + mockGetKeyBuyCooldown.mockResolvedValue({ nextBuyAllowedAt: 1_700_000_272 }); + + const { result, rerender } = renderHook( + ({ wallet }: { wallet: string }) => useKeyBuyCooldown('key-1', wallet), + { wrapper, initialProps: { wallet: 'wallet-1' } } + ); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + + rerender({ wallet: 'wallet-2' }); + + await waitFor(() => + expect(mockGetKeyBuyCooldown).toHaveBeenCalledWith('key-1', 'wallet-2') + ); + expect( + queryClient + .getQueryCache() + .find({ queryKey: queryKeys.creators.buyCooldown('key-1', 'wallet-1') }) + ).toBeDefined(); + expect( + queryClient + .getQueryCache() + .find({ queryKey: queryKeys.creators.buyCooldown('key-1', 'wallet-2') }) + ).toBeDefined(); + }); + + it('does not fetch when the key id is empty', () => { + renderHook(() => useKeyBuyCooldown('', 'wallet-1'), { wrapper }); + + expect(mockGetKeyBuyCooldown).not.toHaveBeenCalled(); + }); +}); diff --git a/src/hooks/useBuyCooldownCountdown.ts b/src/hooks/useBuyCooldownCountdown.ts new file mode 100644 index 00000000..26ffa263 --- /dev/null +++ b/src/hooks/useBuyCooldownCountdown.ts @@ -0,0 +1,74 @@ +import { useEffect, useRef, useState } from 'react'; +import { + computeRemainingCooldownSeconds, + formatCooldownDuration, +} from '@/utils/buyCooldown.utils'; + +export interface UseBuyCooldownCountdownResult { + /** Seconds left until the wallet may next buy; 0 when no cooldown is active. */ + remainingSeconds: number; + /** `remainingSeconds` as a compact human string, e.g. "4m 32s". */ + formattedRemaining: string; + /** True while the wallet is locked out; flips to false the moment it expires. */ + isActive: boolean; +} + +/** + * Ticking buy-cooldown clock (#915). + * + * Owns the single source of truth for "how long until this wallet can buy + * again" so the countdown chip and the Buy button can never disagree: both + * derive their state from the same absolute `nextBuyAllowedAt`, and + * `isActive` flips to `false` on the tick that crosses zero, which is what + * re-enables the button without a refetch. + * + * The interval only runs while a cooldown is actually in effect, so a key with + * no cooldown costs no timers. `onExpire` fires once per expiry, from a ref + * so that passing an inline callback does not restart the interval. + */ +export function useBuyCooldownCountdown( + nextBuyAllowedAt?: number | string | null, + onExpire?: () => void +): UseBuyCooldownCountdownResult { + const [remainingSeconds, setRemainingSeconds] = useState(() => + computeRemainingCooldownSeconds(nextBuyAllowedAt) + ); + + const onExpireRef = useRef(onExpire); + useEffect(() => { + onExpireRef.current = onExpire; + }, [onExpire]); + + useEffect(() => { + const initial = computeRemainingCooldownSeconds(nextBuyAllowedAt); + setRemainingSeconds(initial); + + if (initial <= 0) return; + + let expired = false; + const intervalId = setInterval(() => { + const remaining = computeRemainingCooldownSeconds(nextBuyAllowedAt); + setRemainingSeconds(remaining); + + if (remaining <= 0) { + clearInterval(intervalId); + if (!expired) { + expired = true; + onExpireRef.current?.(); + } + } + }, 1000); + + return () => clearInterval(intervalId); + }, [nextBuyAllowedAt]); + + const isActive = remainingSeconds > 0; + + return { + remainingSeconds, + formattedRemaining: isActive + ? formatCooldownDuration(remainingSeconds) + : formatCooldownDuration(0), + isActive, + }; +} diff --git a/src/hooks/useKeyBuyCooldown.ts b/src/hooks/useKeyBuyCooldown.ts new file mode 100644 index 00000000..60c12893 --- /dev/null +++ b/src/hooks/useKeyBuyCooldown.ts @@ -0,0 +1,35 @@ +import { useQuery } from '@tanstack/react-query'; +import { queryKeys } from '@/lib/queryKeys'; +import { courseService } from '@/services/course.service'; + +/** + * Per-wallet buy cooldown for a creator key (#915). + * + * Reads the connected wallet's cooldown expiry for `keyId`. The query is + * disabled until both a `keyId` and a `wallet` are known, so the fetch fires + * exactly when the key page loads with a connected wallet, and again whenever + * the wallet changes (i.e. on connect/disconnect) because the key — and + * therefore the cache entry — is wallet-scoped. + * + * A `null` result is a valid "no cooldown" answer, not an error: the service + * maps 404s and empty payloads to `null`. The key detail page falls back to the + * held-position and creator-wide `nextBuyAllowedAt` values when this returns + * nothing, so the cooldown UI still works against backends that have not + * shipped the endpoint. + * + * `staleTime` is short because the countdown is a hard gate on buying: after a + * buy resolves, the previous expiry is already in the past and the next window + * must be picked up promptly rather than served from a long-lived cache entry. + */ +export function useKeyBuyCooldown( + keyId: string | undefined, + wallet: string | undefined +) { + return useQuery({ + queryKey: queryKeys.creators.buyCooldown(keyId ?? '', wallet ?? ''), + queryFn: () => courseService.getKeyBuyCooldown(keyId!, wallet!), + enabled: Boolean(keyId && wallet), + staleTime: 15_000, + retry: false, + }); +} diff --git a/src/lib/queryKeys.ts b/src/lib/queryKeys.ts index 250bae94..7dba9a87 100644 --- a/src/lib/queryKeys.ts +++ b/src/lib/queryKeys.ts @@ -38,6 +38,8 @@ export const queryKeys = { ['creators', creatorId, 'curve-migrations'] as const, buyback: (creatorId: string) => ['creators', creatorId, 'buyback'] as const, + buyCooldown: (creatorId: string, wallet: string) => + ['creators', creatorId, 'buy-cooldown', wallet] as const, keyConfig: (creatorId: string) => ['creators', creatorId, 'key-config'] as const, vesting: (creatorId: string) => diff --git a/src/pages/CreatorDetailPage.tsx b/src/pages/CreatorDetailPage.tsx index 1d187ea4..635f6b52 100644 --- a/src/pages/CreatorDetailPage.tsx +++ b/src/pages/CreatorDetailPage.tsx @@ -45,6 +45,8 @@ import { useKeyTwap } from '@/hooks/useKeyTwap'; import { useKeyStats } from '@/hooks/useKeyStats'; import { useKeyUniqueTraders } from '@/hooks/useKeyUniqueTraders'; import { useKeyConfig } from '@/hooks/useKeyConfig'; +import { useKeyBuyCooldown } from '@/hooks/useKeyBuyCooldown'; +import { useBuyCooldownCountdown } from '@/hooks/useBuyCooldownCountdown'; import KeyStatsPanel from '@/components/common/KeyStatsPanel'; import Skeleton from '@/components/ui/skeleton'; import { Tooltip } from '@/components/ui/tooltip'; @@ -105,11 +107,28 @@ function CreatorDetailPageContent() { const { data: holdings = [] } = useWalletHoldings(userAddress ?? ''); const userPosition = holdings.find(h => h.creatorId === (id || '')); const holdingsCount = userPosition?.quantity ?? 0; - // Per-user buy cooldown (#873): prefer the user's own position-level - // value; fall back to a creator-wide cooldown if the backend doesn't yet - // return a per-user one. Only shown for authenticated users. + // Per-wallet buy cooldown (#915). The dedicated read is the source of truth + // for the connected wallet; the held-position value (#873) and the + // creator-wide fallback are kept so the countdown still works for backends + // that do not return a per-wallet expiry. The query is disabled until a + // wallet is connected, so it fires on key page load and on wallet connect. + const { data: buyCooldown, refetch: refetchBuyCooldown } = useKeyBuyCooldown( + id || '', + userAddress + ); const nextBuyAllowedAt = - userPosition?.nextBuyAllowedAt ?? creator?.nextBuyAllowedAt ?? null; + buyCooldown?.nextBuyAllowedAt ?? + userPosition?.nextBuyAllowedAt ?? + creator?.nextBuyAllowedAt ?? + null; + // Single ticking clock shared by the countdown chip and the Buy button, so + // the button re-enables on the exact tick the timer hits zero. On expiry we + // re-read the cooldown because a configured cooldown means the next buy + // starts a fresh window. + const { isActive: isBuyCooldownActive, formattedRemaining: buyCooldownLabel } = + useBuyCooldownCountdown(nextBuyAllowedAt, () => { + void refetchBuyCooldown(); + }); const { data: twap, isLoading: isTwapLoading } = useKeyTwap(id || ''); const { data: keyStats, @@ -372,7 +391,9 @@ function CreatorDetailPageContent() {

{isKeyDeprecated(creator) ? 'Key is deprecated. New buys are disabled.' - : 'Purchase keys for this creator.'} + : isBuyCooldownActive + ? 'Buy cooldown active for your wallet.' + : 'Purchase keys for this creator.'}

{/* Configurable bid-ask spread between buy and sell price (#951) */} + {/* Per-wallet buy cooldown countdown (#915). Renders nothing when the wallet is not in a cooldown period. */} + {userAddress && ( + + )} + +
+ + {isBuyCooldownActive && ( +

+ Unlocks in {buyCooldownLabel} +

+ )}
- - {/* Buy Cooldown Countdown (only meaningful for authenticated users) */} - {userAddress && ( - - )} {/* Share to X Button (only visible for authenticated holders) */}
({ + useCreatorDetail: vi.fn(), + useSetCoCreator: vi.fn(() => ({ mutate: vi.fn(), isPending: false })), +})); + +vi.mock('@/hooks/useCreatorProfileStaleIndicator', () => ({ + useCreatorProfileStaleIndicator: () => ({ + shouldShowBadge: false, + handleRefetch: vi.fn(), + }), +})); + +vi.mock('@/hooks/useNavigationTiming', () => ({ + useNavigationTiming: vi.fn(), +})); + +vi.mock('@/hooks/usePurchaseConfetti', () => ({ + usePurchaseConfetti: vi.fn(), +})); + +vi.mock('@/utils/toast.util', () => ({ + default: { + message: vi.fn(), + success: vi.fn(), + error: vi.fn(), + loading: vi.fn(), + transactionSuccess: vi.fn(), + }, +})); + +const mockCreator = { + id: 'creator-1', + title: 'Alex Rivers', + description: 'Digital Artist & Illustrator', + price: 0.1, + priceStroops: 1_000_000, + instructorId: 'inst-1', + socialHandle: 'alexrivers', + isVerified: true, + thumbnail: 'https://example.com/avatar.jpg', + category: 'Art', + level: 'BEGINNER' as const, + creatorFeeBps: 0, + protocolFeeBps: 0, + creatorShareSupply: 50, + volume24h: 10_000_000, +}; + +const WALLET = demoUser.id; + +const nowSeconds = () => Math.floor(Date.now() / 1000); + +/** + * Short cooldown window for the expiry assertions. The budget has to cover the + * cooldown read resolving before the panel first paints as locked out, hence + * the generous margin over the one-second waits further down. + */ +const cooldownEndingIn = (seconds: number) => ({ + nextBuyAllowedAt: nowSeconds() + seconds, +}); + +const renderPage = () => + render( + + + + } /> + + + + ); + +describe('CreatorDetailPage per-wallet buy cooldown (#915)', () => { + let getKeyBuyCooldown: ReturnType; + + beforeEach(() => { + vi.clearAllMocks(); + useProfileStore.setState({ profile: demoUser }); + vi.mocked(useCreatorDetail).mockReturnValue({ + data: mockCreator, + isLoading: false, + error: null, + isFetching: false, + refetch: vi.fn(), + } as unknown as ReturnType); + // Only the new cooldown read is intercepted; the page's other reads are + // left on their real (failing) service calls, as in the other page suites. + getKeyBuyCooldown = vi + .spyOn(courseService, 'getKeyBuyCooldown') + .mockResolvedValue({ nextBuyAllowedAt: null }); + }); + + afterEach(() => { + cleanup(); + getKeyBuyCooldown.mockRestore(); + }); + + it('reads the cooldown for the connected wallet when the key page loads', async () => { + getKeyBuyCooldown.mockResolvedValue({ nextBuyAllowedAt: null }); + + renderPage(); + + await screen.findByTestId('key-detail-buy-button'); + + await waitFor(() => + expect(getKeyBuyCooldown).toHaveBeenCalledWith('creator-1', WALLET) + ); + }); + + it('reads the cooldown as soon as a wallet connects on the key page', async () => { + useProfileStore.setState({ profile: null }); + + renderPage(); + + await screen.findByTestId('key-detail-buy-button'); + expect(getKeyBuyCooldown).not.toHaveBeenCalled(); + + // Wallet connects while the key detail page is open. + act(() => { + useProfileStore.setState({ profile: demoUser }); + }); + + await waitFor(() => + expect(getKeyBuyCooldown).toHaveBeenCalledWith('creator-1', WALLET) + ); + }); + + it('shows the countdown and disables the buy button while the wallet is in cooldown', async () => { + getKeyBuyCooldown.mockResolvedValue({ + nextBuyAllowedAt: nowSeconds() + 272, + cooldownLedgers: 12, + }); + + renderPage(); + + const buyButton = await screen.findByTestId('key-detail-buy-button'); + await waitFor(() => expect(buyButton).toBeDisabled()); + + // Countdown sits inside the buy panel and reports the remaining time. + expect(screen.getByTestId('buy-cooldown-countdown')).toBeInTheDocument(); + expect(screen.getByTestId('buy-cooldown-text')).toHaveTextContent( + 'Next buy available in 4m 3' + ); + + // The button advertises the lockout instead of the normal CTA. + expect(buyButton).toHaveTextContent('Buy in 4m 3'); + expect(buyButton).not.toHaveTextContent('Buy Key'); + expect(screen.getByTestId('key-detail-buy-cooldown-reason')).toHaveTextContent( + 'Unlocks in 4m 3' + ); + expect(buyButton).toHaveAttribute( + 'aria-describedby', + 'key-detail-buy-cooldown-reason' + ); + + // A disabled buy button must not open the trade dialog. + fireEvent.click(buyButton); + expect(screen.queryByTestId('trade-dialog-amount')).not.toBeInTheDocument(); + }); + + it('re-enables the buy button automatically once the countdown reaches zero', async () => { + getKeyBuyCooldown.mockResolvedValue(cooldownEndingIn(2)); + + renderPage(); + + const buyButton = await screen.findByTestId('key-detail-buy-button'); + await waitFor(() => expect(buyButton).toBeDisabled()); + expect(buyButton.textContent).toMatch(/^Buy in \d+s$/); + + await waitFor(() => expect(buyButton).toBeEnabled(), { timeout: 8000 }); + + // Countdown and lockout reason are cleared along with the disabled state. + expect(buyButton).toHaveTextContent('Buy Key'); + expect( + screen.queryByTestId('buy-cooldown-countdown') + ).not.toBeInTheDocument(); + expect( + screen.queryByTestId('key-detail-buy-cooldown-reason') + ).not.toBeInTheDocument(); + expect(buyButton).not.toHaveAttribute('aria-describedby'); + + // The re-enabled button opens the buy flow again. + fireEvent.click(buyButton); + expect(await screen.findByTestId('trade-dialog-amount')).toBeInTheDocument(); + }); + + it('re-reads the cooldown at expiry so a configured cooldown cannot be bypassed', async () => { + getKeyBuyCooldown.mockResolvedValue(cooldownEndingIn(2)); + + renderPage(); + + const buyButton = await screen.findByTestId('key-detail-buy-button'); + await waitFor(() => expect(buyButton).toBeDisabled()); + expect(getKeyBuyCooldown).toHaveBeenCalledTimes(1); + + await waitFor(() => expect(getKeyBuyCooldown).toHaveBeenCalledTimes(2), { + timeout: 8000, + }); + }); + + it('shows the normal buy UI with no timer when the wallet is not in cooldown', async () => { + getKeyBuyCooldown.mockResolvedValue({ nextBuyAllowedAt: null }); + + renderPage(); + + const buyButton = await screen.findByTestId('key-detail-buy-button'); + await waitFor(() => expect(getKeyBuyCooldown).toHaveBeenCalled()); + + expect(buyButton).toBeEnabled(); + expect(buyButton).toHaveTextContent('Buy Key'); + expect(buyButton).not.toHaveAttribute('aria-describedby'); + expect( + screen.queryByTestId('buy-cooldown-countdown') + ).not.toBeInTheDocument(); + expect( + screen.queryByTestId('key-detail-buy-cooldown-reason') + ).not.toBeInTheDocument(); + + fireEvent.click(buyButton); + expect(await screen.findByTestId('trade-dialog-amount')).toBeInTheDocument(); + }); + + it('shows the normal buy UI when the wallet has no cooldown state at all', async () => { + getKeyBuyCooldown.mockResolvedValue(null); + + renderPage(); + + const buyButton = await screen.findByTestId('key-detail-buy-button'); + await waitFor(() => expect(getKeyBuyCooldown).toHaveBeenCalled()); + + expect(buyButton).toBeEnabled(); + expect(buyButton).toHaveTextContent('Buy Key'); + expect( + screen.queryByTestId('buy-cooldown-countdown') + ).not.toBeInTheDocument(); + }); + + it('leaves the buy button enabled when the cooldown read fails', async () => { + getKeyBuyCooldown.mockRejectedValue(new Error('Network error')); + + renderPage(); + + const buyButton = await screen.findByTestId('key-detail-buy-button'); + await waitFor(() => expect(getKeyBuyCooldown).toHaveBeenCalled()); + + expect(buyButton).toBeEnabled(); + expect(buyButton).toHaveTextContent('Buy Key'); + expect( + screen.queryByTestId('buy-cooldown-countdown') + ).not.toBeInTheDocument(); + }); + + it('never fetches the cooldown when no wallet is connected', async () => { + useProfileStore.setState({ profile: null }); + + renderPage(); + + const buyButton = await screen.findByTestId('key-detail-buy-button'); + expect(buyButton).toBeEnabled(); + expect(buyButton).toHaveTextContent('Buy Key'); + expect(getKeyBuyCooldown).not.toHaveBeenCalled(); + }); +}); diff --git a/src/services/course.service.ts b/src/services/course.service.ts index de6b395d..92ac47a8 100644 --- a/src/services/course.service.ts +++ b/src/services/course.service.ts @@ -190,6 +190,35 @@ export interface KeyOraclePrice { maxAgeSeconds?: number | null; } +/** + * Per-wallet buy cooldown for a creator key (#915). + * + * Returned by `GET /keys/:keyId/buy-cooldown?wallet=...`, which reads the + * creator's `set_buy_cooldown` window together with the caller's last buy + * ledger from the contract. The cooldown is per wallet, not per creator: two + * wallets can buy the same key at the same time as long as each is outside its + * own cooldown window. + * + * `null` (a 404, or a payload with no `nextBuyAllowedAt`) means the wallet is + * not in a cooldown period and can buy immediately — the same "no data, no + * cooldown" contract the rest of the key detail page follows. + */ +export interface KeyBuyCooldown { + /** Key the cooldown belongs to, when the backend echoes it back. */ + keyId?: string; + /** Wallet the cooldown is scoped to. */ + wallet?: string | null; + /** + * Timestamp after which this wallet may next buy the key. Accepts a + * seconds epoch, ms epoch, or ISO string; `null`/absent means no cooldown. + */ + nextBuyAllowedAt?: number | string | null; + /** Cooldown window in Stellar ledgers configured via `set_buy_cooldown`. */ + cooldownLedgers?: number | null; + /** Timestamp of this wallet's most recent buy of the key, when known. */ + lastBuyAt?: number | string | null; +} + export type PerformanceBondState = 'staked' | 'released' | 'forfeited'; /** @@ -646,6 +675,29 @@ class CourseService extends BaseApiService { } } + // Get the connected wallet's per-key buy cooldown - GET /keys/:keyId/buy-cooldown (#915) + async getKeyBuyCooldown( + keyId: string, + wallet: string + ): Promise { + try { + const response = await this.api.get>( + `/keys/${keyId}/buy-cooldown`, + { params: { wallet } } + ); + return response.data.data ?? null; + } catch (error: unknown) { + // A wallet that has never bought this key has no cooldown state yet, + // and a creator who never called `set_buy_cooldown` has no window to + // report. Both resolve to "no cooldown" instead of an error so the + // buy panel keeps working. + if (error instanceof ApiError && error.status === 404) { + return null; + } + throw this.handleError(error); + } + } + // Get creator performance bond status - GET /keys/:keyId/performance-bond (#975) async getPerformanceBond(keyId: string): Promise { try { From ae920e874478d60b193be28b1f909701c9983c3d Mon Sep 17 00:00:00 2001 From: meem08 Date: Fri, 9 Oct 2026 10:45:33 +0100 Subject: [PATCH 2/2] fix: resolve broken dev merge causing lint parse errors (#915) The merge of dev into this branch left two malformed sections: - CreatorDetailPage: the buy-panel JSX was duplicated by the merge (PR's cooldown-aware button plus dev's TradeCooldownButton), leaving a mangled subtitle ternary and stray closing tags. - course.service.ts: the getKeyBuyCooldown catch block was truncated and getTradeCooldownStatus was spliced into its body. Reconcile the buy panel into a single button that layers the states: deprecated -> whitelist lockout -> per-wallet buy cooldown (#915) -> TradeCooldownButton (#998). Restore the getKeyBuyCooldown method body. --- src/pages/CreatorDetailPage.tsx | 109 ++++++++++++++------------------ src/services/course.service.ts | 6 ++ 2 files changed, 55 insertions(+), 60 deletions(-) diff --git a/src/pages/CreatorDetailPage.tsx b/src/pages/CreatorDetailPage.tsx index 5ce46ed9..4c244044 100644 --- a/src/pages/CreatorDetailPage.tsx +++ b/src/pages/CreatorDetailPage.tsx @@ -20,7 +20,6 @@ import { resolveActiveTradeCooldown, } from '@/hooks/useTradeCooldownStatus'; import type { ActiveTradeCooldown } from '@/utils/tradeCooldown.utils'; -import { isActiveCooldown } from '@/utils/tradeCooldown.utils'; import KeyHolderList from '@/components/common/KeyHolderList'; import HolderConcentrationChart from '@/components/common/HolderConcentrationChart'; import StakingRewardsSection from '@/components/common/StakingRewardsSection'; @@ -218,7 +217,6 @@ function CreatorDetailPageContent() { tradeCooldownStatus, nextBuyAllowedAt ); - const isTradeCooldownActive = isActiveCooldown(tradeCooldown); const handleConfirmBuy = async ( amount: number, @@ -453,10 +451,9 @@ function CreatorDetailPageContent() { ? 'Key is deprecated. New buys are disabled.' : isBuyCooldownActive ? 'Buy cooldown active for your wallet.' - : 'Purchase keys for this creator.'} - : isLockedOut - ? 'Early access is restricted to approved whitelisted wallets.' - : 'Purchase keys for this creator.'} + : isLockedOut + ? 'Early access is restricted to approved whitelisted wallets.' + : 'Purchase keys for this creator.'}

{/* Configurable bid-ask spread between buy and sell price (#951) */}
- - {isBuyCooldownActive && ( -

- Unlocks in {buyCooldownLabel} -

+ Buy Disabled (Deprecated) + + ) : isLockedOut ? ( + + ) : isBuyCooldownActive ? ( + <> + +

+ Unlocks in {buyCooldownLabel} +

+ + ) : ( + setBuyDialogOpen(true)} + buttonProps={{ + 'data-testid': 'key-detail-buy-button', + }} + /> )}
- {isKeyDeprecated(creator) ? ( - - ) : ( - setBuyDialogOpen(true)} - buttonProps={{ - 'data-testid': 'key-detail-buy-button', - }} - /> - )} - - {/* Buy Cooldown Countdown (only meaningful for authenticated users) */} - {userAddress && !isTradeCooldownActive && ( - - )} {/* Share to X Button (only visible for authenticated holders) */}
{ try {