diff --git a/src/components/common/BuyPriceEstimate.tsx b/src/components/common/BuyPriceEstimate.tsx new file mode 100644 index 0000000..717fdc3 --- /dev/null +++ b/src/components/common/BuyPriceEstimate.tsx @@ -0,0 +1,86 @@ +import { useEffect, useState } from 'react'; +import { + computeBuyCost, + DEFAULT_BONDING_CURVE_PARAMS, + type BondingCurveParams, +} from '@/utils/bondingCurve.utils'; +import { formatDisplayKeyPrice } from '@/utils/keyPriceDisplay.utils'; + +export interface BuyPriceEstimateProps { + /** Current key supply for the creator being priced. */ + currentSupply: number; + /** Number of keys the user intends to buy. */ + quantity: number; + /** Bonding curve parameters; defaults to the platform-wide curve. */ + params?: BondingCurveParams; + /** Called with the quantity when the buy button is pressed. */ + onBuy?: (quantity: number) => void; +} + +/** + * Debounce before recomputing the bonding-curve price preview after the + * quantity changes. computeBuyCost() itself is synchronous, but debouncing + * avoids recalculating (and re-rendering) on every keystroke while the user + * is still typing a multi-digit quantity. + */ +const PRICE_CALCULATION_DEBOUNCE_MS = 150; + +/** + * Shows the total XLM cost to buy `quantity` keys at the current bonding + * curve price, recalculating via computeBuyCost() whenever the quantity (or + * current supply) changes. Renders a buy button only for a positive + * quantity, and 0 XLM with no buy button for a zero quantity. + */ +export default function BuyPriceEstimate({ + currentSupply, + quantity, + params = DEFAULT_BONDING_CURVE_PARAMS, + onBuy, +}: BuyPriceEstimateProps) { + const [isCalculating, setIsCalculating] = useState(false); + const [totalCostStroops, setTotalCostStroops] = useState(() => + quantity > 0 ? computeBuyCost(currentSupply, quantity, params) : 0 + ); + + useEffect(() => { + if (quantity <= 0) { + setIsCalculating(false); + setTotalCostStroops(0); + return; + } + + setIsCalculating(true); + const timer = window.setTimeout(() => { + setTotalCostStroops(computeBuyCost(currentSupply, quantity, params)); + setIsCalculating(false); + }, PRICE_CALCULATION_DEBOUNCE_MS); + + return () => window.clearTimeout(timer); + }, [currentSupply, quantity, params]); + + const canBuy = quantity > 0 && !isCalculating; + + return ( +
+ {isCalculating ? ( + + Calculating price… + + ) : ( + + {formatDisplayKeyPrice(totalCostStroops)} + + )} + {quantity > 0 && ( + + )} +
+ ); +} diff --git a/src/components/common/CreatorMarketplaceInfiniteList.tsx b/src/components/common/CreatorMarketplaceInfiniteList.tsx new file mode 100644 index 0000000..e92a74f --- /dev/null +++ b/src/components/common/CreatorMarketplaceInfiniteList.tsx @@ -0,0 +1,64 @@ +import { useInfiniteCreatorMarketplace } from '@/hooks/useInfiniteCreatorMarketplace'; +import { useInfiniteScroll } from '@/hooks/useInfiniteScroll'; +import CreatorCard from '@/components/common/CreatorCard'; +import { CreatorGridSkeleton } from '@/components/common/CreatorSkeleton'; +import type { GetCoursesParams } from '@/services/course.service'; + +export interface CreatorMarketplaceInfiniteListProps { + params?: Omit; +} + +/** + * Creator key marketplace listing with IntersectionObserver-driven infinite + * scroll (#685): fetches the first page on mount, then automatically fetches + * subsequent pages via useInfiniteQuery as the user scrolls the sentinel + * element into view. Shows a skeleton row while the next page is loading and + * stops fetching once the backend reports no more pages. + */ +export default function CreatorMarketplaceInfiniteList({ + params, +}: CreatorMarketplaceInfiniteListProps) { + const { + creators, + hasMore, + isLoadingFirstPage, + isFetchingNextPage, + fetchNextPage, + } = useInfiniteCreatorMarketplace(params); + + const sentinelRef = useInfiniteScroll({ + enabled: !isLoadingFirstPage && !isFetchingNextPage, + hasMore: Boolean(hasMore), + onLoadMore: () => { + void fetchNextPage(); + }, + }); + + if (isLoadingFirstPage) { + return ( +
+ +
+ ); + } + + return ( +
+
+ {creators.map(creator => ( + + ))} +
+ + {isFetchingNextPage && ( +
+ +
+ )} + + {hasMore && ( + + ); +} diff --git a/src/components/common/__tests__/BuyPriceEstimate.test.tsx b/src/components/common/__tests__/BuyPriceEstimate.test.tsx new file mode 100644 index 0000000..3784bc9 --- /dev/null +++ b/src/components/common/__tests__/BuyPriceEstimate.test.tsx @@ -0,0 +1,132 @@ +/** + * Unit tests for BuyPriceEstimate — the bonding curve price preview + * component that renders the correct total XLM amount for buying N keys + * (#684). + */ +import { render, screen } from '@testing-library/react'; +import { act } from 'react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import BuyPriceEstimate from '@/components/common/BuyPriceEstimate'; +import * as bondingCurveUtils from '@/utils/bondingCurve.utils'; +import { formatDisplayKeyPrice } from '@/utils/keyPriceDisplay.utils'; + +describe('BuyPriceEstimate', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); + }); + + it('displays the correct XLM price for quantity 1', () => { + render(); + + act(() => { + vi.runAllTimers(); + }); + + const expectedCostStroops = bondingCurveUtils.computeBuyCost( + 0, + 1, + bondingCurveUtils.DEFAULT_BONDING_CURVE_PARAMS + ); + expect(screen.getByTestId('buy-price-total')).toHaveTextContent( + formatDisplayKeyPrice(expectedCostStroops) + ); + expect(screen.getByTestId('buy-price-estimate-buy-button')).toBeInTheDocument(); + }); + + it('displays 0 XLM and no buy button for quantity 0', () => { + render(); + + act(() => { + vi.runAllTimers(); + }); + + expect(screen.getByTestId('buy-price-total')).toHaveTextContent('0 XLM'); + expect( + screen.queryByTestId('buy-price-estimate-buy-button') + ).not.toBeInTheDocument(); + }); + + it('displays a higher total price for a large quantity than for quantity 1', () => { + const { unmount } = render(); + act(() => { + vi.runAllTimers(); + }); + const priceForOneText = screen.getByTestId('buy-price-total').textContent; + unmount(); + + render(); + act(() => { + vi.runAllTimers(); + }); + const priceForHundredText = screen.getByTestId('buy-price-total').textContent; + + const expectedForHundred = bondingCurveUtils.computeBuyCost( + 0, + 100, + bondingCurveUtils.DEFAULT_BONDING_CURVE_PARAMS + ); + const expectedForOne = bondingCurveUtils.computeBuyCost( + 0, + 1, + bondingCurveUtils.DEFAULT_BONDING_CURVE_PARAMS + ); + + expect(expectedForHundred).toBeGreaterThan(expectedForOne); + expect(priceForHundredText).not.toBe(priceForOneText); + expect(priceForHundredText).toContain('XLM'); + }); + + it('updates the displayed price when the quantity prop changes, without unmounting', () => { + const { rerender } = render(); + act(() => { + vi.runAllTimers(); + }); + const firstPrice = screen.getByTestId('buy-price-total').textContent; + + rerender(); + act(() => { + vi.runAllTimers(); + }); + const secondPrice = screen.getByTestId('buy-price-total').textContent; + + expect(secondPrice).not.toBe(firstPrice); + }); + + it('shows a loading state while the price is being (re)calculated', () => { + const { rerender } = render(); + act(() => { + vi.runAllTimers(); + }); + expect(screen.queryByTestId('buy-price-loading')).not.toBeInTheDocument(); + + rerender(); + // Before the debounce timer fires, the loading state should be visible + // and the buy button disabled (it stays mounted for layout stability, + // but must not be clickable while the price is stale/recalculating). + expect(screen.getByTestId('buy-price-loading')).toBeInTheDocument(); + expect(screen.getByTestId('buy-price-estimate-buy-button')).toBeDisabled(); + + act(() => { + vi.runAllTimers(); + }); + expect(screen.queryByTestId('buy-price-loading')).not.toBeInTheDocument(); + expect(screen.getByTestId('buy-price-estimate-buy-button')).toBeEnabled(); + }); + + it('calls the bonding curve calculation function with the correct arguments', () => { + const spy = vi.spyOn(bondingCurveUtils, 'computeBuyCost'); + const params = { basePriceStroops: 5_000_000, growthFactor: 1.02 }; + + render(); + act(() => { + vi.runAllTimers(); + }); + + expect(spy).toHaveBeenCalledWith(42, 7, params); + }); +}); diff --git a/src/components/common/__tests__/CreatorMarketplaceInfiniteList.test.tsx b/src/components/common/__tests__/CreatorMarketplaceInfiniteList.test.tsx new file mode 100644 index 0000000..6241286 --- /dev/null +++ b/src/components/common/__tests__/CreatorMarketplaceInfiniteList.test.tsx @@ -0,0 +1,142 @@ +/** + * Unit tests for CreatorMarketplaceInfiniteList — the IntersectionObserver- + * driven infinite scroll marketplace listing (#685). + */ +import { render, screen } from '@testing-library/react'; +import { describe, expect, it, vi, beforeEach } from 'vitest'; +import CreatorMarketplaceInfiniteList from '@/components/common/CreatorMarketplaceInfiniteList'; +import { useInfiniteCreatorMarketplace } from '@/hooks/useInfiniteCreatorMarketplace'; +import { useInfiniteScroll } from '@/hooks/useInfiniteScroll'; +import type { Course } from '@/services/course.service'; + +vi.mock('@/hooks/useInfiniteCreatorMarketplace'); +vi.mock('@/hooks/useInfiniteScroll'); + +vi.mock('@/components/common/CreatorCard', async () => { + const React = await import('react'); + return { + default: ({ creator }: { creator: { id: string; title: string } }) => + React.createElement('article', { 'aria-label': `Creator ${creator.title}` }, creator.title), + }; +}); + +const mockUseInfiniteCreatorMarketplace = vi.mocked(useInfiniteCreatorMarketplace); +const mockUseInfiniteScroll = vi.mocked(useInfiniteScroll); + +function makeCreator(id: string): Course { + return { + id, + title: `Creator ${id}`, + description: 'desc', + price: 0.1, + instructorId: id, + category: 'Art', + level: 'BEGINNER', + }; +} + +const baseHookReturn = { + creators: [] as Course[], + hasMore: false, + isLoadingFirstPage: false, + isFetchingNextPage: false, + fetchNextPage: vi.fn(), + error: null, +}; + +describe('CreatorMarketplaceInfiniteList', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockUseInfiniteScroll.mockReturnValue({ current: null }); + }); + + it('shows the initial skeleton while the first page is loading', () => { + mockUseInfiniteCreatorMarketplace.mockReturnValue({ + ...baseHookReturn, + isLoadingFirstPage: true, + }); + + render(); + + expect(screen.getByTestId('creator-marketplace-initial-skeleton')).toBeInTheDocument(); + expect(screen.queryByTestId('creator-marketplace-infinite-list')).not.toBeInTheDocument(); + }); + + it('renders every creator returned by the hook, with no duplicates', () => { + mockUseInfiniteCreatorMarketplace.mockReturnValue({ + ...baseHookReturn, + creators: [makeCreator('a'), makeCreator('b')], + }); + + render(); + + expect(screen.getByLabelText('Creator Creator a')).toBeInTheDocument(); + expect(screen.getByLabelText('Creator Creator b')).toBeInTheDocument(); + expect(screen.getAllByRole('article')).toHaveLength(2); + }); + + it('shows a skeleton row while the next page is fetching', () => { + mockUseInfiniteCreatorMarketplace.mockReturnValue({ + ...baseHookReturn, + creators: [makeCreator('a')], + isFetchingNextPage: true, + hasMore: true, + }); + + render(); + + expect(screen.getByTestId('creator-marketplace-next-page-skeleton')).toBeInTheDocument(); + }); + + it('does not show the next-page skeleton once no more pages remain', () => { + mockUseInfiniteCreatorMarketplace.mockReturnValue({ + ...baseHookReturn, + creators: [makeCreator('a')], + hasMore: false, + isFetchingNextPage: false, + }); + + render(); + + expect( + screen.queryByTestId('creator-marketplace-next-page-skeleton') + ).not.toBeInTheDocument(); + expect(screen.queryByTestId('creator-marketplace-sentinel')).not.toBeInTheDocument(); + }); + + it('renders the sentinel and calls fetchNextPage via useInfiniteScroll when more pages remain', () => { + const fetchNextPage = vi.fn(); + mockUseInfiniteCreatorMarketplace.mockReturnValue({ + ...baseHookReturn, + creators: [makeCreator('a')], + hasMore: true, + fetchNextPage, + }); + + render(); + + expect(screen.getByTestId('creator-marketplace-sentinel')).toBeInTheDocument(); + + // Simulate the sentinel scrolling into view by invoking the + // onLoadMore callback useInfiniteScroll was configured with. + const call = mockUseInfiniteScroll.mock.calls[0]![0]; + call.onLoadMore(); + + expect(fetchNextPage).toHaveBeenCalledTimes(1); + }); + + it('disables the scroll observer while a page is already loading (enabled: false)', () => { + mockUseInfiniteCreatorMarketplace.mockReturnValue({ + ...baseHookReturn, + creators: [makeCreator('a')], + hasMore: true, + isFetchingNextPage: true, + }); + + render(); + + const call = mockUseInfiniteScroll.mock.calls[0]![0]; + expect(call.enabled).toBe(false); + expect(call.hasMore).toBe(true); + }); +}); diff --git a/src/components/common/__tests__/TradeDialog.sellQuantityValidation.test.tsx b/src/components/common/__tests__/TradeDialog.sellQuantityValidation.test.tsx new file mode 100644 index 0000000..62f8b88 --- /dev/null +++ b/src/components/common/__tests__/TradeDialog.sellQuantityValidation.test.tsx @@ -0,0 +1,86 @@ +/** + * Unit tests for the sell quantity input rejecting values exceeding the + * wallet's current holding (#657). + */ +import { describe, expect, it, vi } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import TradeDialog from '@/components/common/TradeDialog'; + +describe('TradeDialog – sell quantity exceeds-holding validation', () => { + function renderSellDialog( + overrides: Partial> = {} + ) { + return render( + + ); + } + + it('shows an exceeds-balance validation error when the quantity exceeds the holding', () => { + renderSellDialog(); + const input = screen.getByTestId('trade-dialog-amount') as HTMLInputElement; + + fireEvent.change(input, { target: { value: '4' } }); + + const error = screen.getByTestId('trade-dialog-amount-error'); + expect(error).toBeInTheDocument(); + expect(error).toHaveTextContent("You can't sell more than your holdings (3 keys)."); + }); + + it('clears the validation error when the quantity is brought back within range', () => { + renderSellDialog(); + const input = screen.getByTestId('trade-dialog-amount') as HTMLInputElement; + + fireEvent.change(input, { target: { value: '4' } }); + expect(screen.getByTestId('trade-dialog-amount-error')).toBeInTheDocument(); + + fireEvent.change(input, { target: { value: '3' } }); + expect(screen.queryByTestId('trade-dialog-amount-error')).not.toBeInTheDocument(); + }); + + it('shows a distinct zero-quantity error, not the exceeds-balance error', () => { + renderSellDialog(); + const input = screen.getByTestId('trade-dialog-amount') as HTMLInputElement; + + fireEvent.change(input, { target: { value: '0' } }); + + const error = screen.getByTestId('trade-dialog-amount-error'); + expect(error).toBeInTheDocument(); + expect(error).toHaveTextContent('Amount must be greater than zero.'); + expect(error).not.toHaveTextContent('holdings'); + }); + + it('accepts a quantity equal to the holding without any error', () => { + renderSellDialog(); + const input = screen.getByTestId('trade-dialog-amount') as HTMLInputElement; + + fireEvent.change(input, { target: { value: '3' } }); + + expect(screen.queryByTestId('trade-dialog-amount-error')).not.toBeInTheDocument(); + }); + + it('accepts quantity 1 (well within holding) without any error', () => { + renderSellDialog(); + const input = screen.getByTestId('trade-dialog-amount') as HTMLInputElement; + + fireEvent.change(input, { target: { value: '1' } }); + + expect(screen.queryByTestId('trade-dialog-amount-error')).not.toBeInTheDocument(); + }); + + it('disables the confirm button while the exceeds-balance error is present', () => { + renderSellDialog(); + const input = screen.getByTestId('trade-dialog-amount') as HTMLInputElement; + + fireEvent.change(input, { target: { value: '4' } }); + + expect(screen.getByTestId('trade-dialog-confirm')).toBeDisabled(); + }); +}); diff --git a/src/hooks/__tests__/useInfiniteCreatorMarketplace.test.ts b/src/hooks/__tests__/useInfiniteCreatorMarketplace.test.ts new file mode 100644 index 0000000..2b5869b --- /dev/null +++ b/src/hooks/__tests__/useInfiniteCreatorMarketplace.test.ts @@ -0,0 +1,130 @@ +/** + * Unit tests for useInfiniteCreatorMarketplace — cursor-based infinite + * pagination over the creator key marketplace listing (#685). + */ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { renderHook, waitFor } from '@testing-library/react'; +import React from 'react'; +import { describe, expect, it, vi, beforeEach } from 'vitest'; +import { useInfiniteCreatorMarketplace } from '../useInfiniteCreatorMarketplace'; +import { courseService, type Course, type CoursesPage } from '@/services/course.service'; + +vi.mock('@/services/course.service', async () => { + const actual = await vi.importActual( + '@/services/course.service' + ); + return { + ...actual, + courseService: { + getCoursesPage: vi.fn(), + }, + }; +}); + +const mockGetCoursesPage = vi.mocked(courseService.getCoursesPage); + +function makeCreator(id: string): Course { + return { + id, + title: `Creator ${id}`, + description: 'desc', + price: 0.1, + instructorId: id, + category: 'Art', + level: 'BEGINNER', + }; +} + +function createWrapper() { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + return function Wrapper({ children }: { children: React.ReactNode }) { + return React.createElement(QueryClientProvider, { client: queryClient }, children); + }; +} + +describe('useInfiniteCreatorMarketplace', () => { + beforeEach(() => { + mockGetCoursesPage.mockReset(); + }); + + it('fetches only the first page on mount', async () => { + const page1: CoursesPage = { + items: [makeCreator('a'), makeCreator('b')], + page: 1, + hasMore: true, + }; + mockGetCoursesPage.mockResolvedValue(page1); + + const { result } = renderHook(() => useInfiniteCreatorMarketplace(), { + wrapper: createWrapper(), + }); + + await waitFor(() => expect(result.current.isLoadingFirstPage).toBe(false)); + + expect(mockGetCoursesPage).toHaveBeenCalledTimes(1); + expect(mockGetCoursesPage).toHaveBeenCalledWith(1, undefined); + expect(result.current.creators).toHaveLength(2); + expect(result.current.hasMore).toBe(true); + }); + + it('fetches the next page when fetchNextPage is called, appending without duplicates', async () => { + const page1: CoursesPage = { + items: [makeCreator('a'), makeCreator('b')], + page: 1, + hasMore: true, + }; + const page2: CoursesPage = { + items: [makeCreator('b'), makeCreator('c')], // 'b' repeated across pages + page: 2, + hasMore: false, + }; + mockGetCoursesPage.mockResolvedValueOnce(page1).mockResolvedValueOnce(page2); + + const { result } = renderHook(() => useInfiniteCreatorMarketplace(), { + wrapper: createWrapper(), + }); + await waitFor(() => expect(result.current.isLoadingFirstPage).toBe(false)); + + result.current.fetchNextPage(); + + await waitFor(() => expect(mockGetCoursesPage).toHaveBeenCalledTimes(2)); + expect(mockGetCoursesPage).toHaveBeenNthCalledWith(2, 2, undefined); + + await waitFor(() => + expect(result.current.creators.map(c => c.id)).toEqual(['a', 'b', 'c']) + ); + expect(result.current.hasMore).toBe(false); + }); + + it('stops fetching once the last page reports hasMore: false', async () => { + mockGetCoursesPage.mockResolvedValue({ + items: [makeCreator('a')], + page: 1, + hasMore: false, + }); + + const { result } = renderHook(() => useInfiniteCreatorMarketplace(), { + wrapper: createWrapper(), + }); + await waitFor(() => expect(result.current.isLoadingFirstPage).toBe(false)); + + expect(result.current.hasMore).toBe(false); + }); + + it('passes filter params through to every page request', async () => { + mockGetCoursesPage.mockResolvedValue({ + items: [makeCreator('a')], + page: 1, + hasMore: false, + }); + + const params = { category: 'Art', limit: 10 }; + renderHook(() => useInfiniteCreatorMarketplace(params), { + wrapper: createWrapper(), + }); + + await waitFor(() => expect(mockGetCoursesPage).toHaveBeenCalledWith(1, params)); + }); +}); diff --git a/src/hooks/useInfiniteCreatorMarketplace.ts b/src/hooks/useInfiniteCreatorMarketplace.ts new file mode 100644 index 0000000..56dcfb6 --- /dev/null +++ b/src/hooks/useInfiniteCreatorMarketplace.ts @@ -0,0 +1,49 @@ +import { useInfiniteQuery } from '@tanstack/react-query'; +import { useMemo } from 'react'; +import { courseService, type Course, type GetCoursesParams } from '@/services/course.service'; +import { queryKeys } from '@/lib/queryKeys'; + +const FIRST_PAGE = 1; + +/** + * Cursor-based (page-number) infinite pagination over the creator key + * marketplace listing, backed by React Query's useInfiniteQuery (#685). + * + * Replaces "load everything up front, reveal more client-side" with real + * paged fetches: only the first page loads initially, later pages fetch on + * demand via `fetchNextPage` (wire this to a useInfiniteScroll sentinel), + * and fetching stops once the last page reports `hasMore: false`. + */ +export function useInfiniteCreatorMarketplace(params?: Omit) { + const query = useInfiniteQuery({ + queryKey: queryKeys.creators.infiniteList(params), + queryFn: ({ pageParam }) => courseService.getCoursesPage(pageParam, params), + initialPageParam: FIRST_PAGE, + getNextPageParam: lastPage => (lastPage.hasMore ? lastPage.page + 1 : undefined), + }); + + // De-duplicate creators across pages by id -- a creator that shifts + // position between page fetches (e.g. sort order changing as data + // updates) should never be rendered twice. + const creators = useMemo(() => { + const seen = new Set(); + const result: Course[] = []; + for (const page of query.data?.pages ?? []) { + for (const creator of page.items) { + if (seen.has(creator.id)) continue; + seen.add(creator.id); + result.push(creator); + } + } + return result; + }, [query.data]); + + return { + creators, + hasMore: query.hasNextPage, + isLoadingFirstPage: query.isLoading, + isFetchingNextPage: query.isFetchingNextPage, + fetchNextPage: query.fetchNextPage, + error: query.error, + }; +} diff --git a/src/lib/queryKeys.ts b/src/lib/queryKeys.ts index 22314ac..a27c8a6 100644 --- a/src/lib/queryKeys.ts +++ b/src/lib/queryKeys.ts @@ -5,6 +5,8 @@ export const queryKeys = { all: ['creators'] as const, list: (params?: GetCoursesParams) => ['creators', 'list', params ?? null] as const, + infiniteList: (params?: Omit) => + ['creators', 'infiniteList', params ?? null] as const, detail: (id: string) => ['creators', 'detail', id] as const, holders: (creatorId: string) => ['creators', creatorId, 'holders'] as const, diff --git a/src/pages/__tests__/LandingPage.tradeShortcutUnmount.integration.test.tsx b/src/pages/__tests__/LandingPage.tradeShortcutUnmount.integration.test.tsx new file mode 100644 index 0000000..27df7d7 --- /dev/null +++ b/src/pages/__tests__/LandingPage.tradeShortcutUnmount.integration.test.tsx @@ -0,0 +1,205 @@ +/** + * Integration test for the `T` trade-shortcut keyboard listener being torn + * down when the creator profile page (LandingPage — see the "Issue 554: T + * key opens the trade panel from the creator profile page" comment on its + * keydown effect) unmounts (#654). + * + * The `useEffect` registering the listener already returns a cleanup + * function that calls `window.removeEventListener`, so this test is meant + * to confirm that wiring actually works end-to-end: + * - `T` opens the trade dialog while the page is mounted + * - Unmounting the page (simulating navigating away, e.g. to a creator + * discovery list elsewhere in the app) removes the listener, so `T` + * does nothing afterwards and produces no console errors + * - Mounting the page again re-registers the listener, so `T` opens the + * trade dialog again + */ +import type { ComponentProps, ReactNode } from 'react'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { MemoryRouter } from 'react-router'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import LandingPage from '@/pages/LandingPage'; +import { courseService, type Course } from '@/services/course.service'; + +vi.mock('@/hooks/useWallet', () => ({ + useTradeMutation: () => ({ mutateAsync: vi.fn(), isPending: false }), + useWalletHoldings: () => ({ data: [] }), +})); + +vi.mock('@/services/course.service', () => ({ + courseService: { + getCourses: vi.fn(), + }, +})); + +vi.mock('@/hooks/useNetworkMismatch', () => ({ + useNetworkMismatch: () => ({ + isMismatch: false, + expectedChainName: 'Stellar Testnet', + }), +})); + +vi.mock('@/hooks/useStaleData', () => ({ + useStaleData: () => ({ + stale: false, + ageMs: 0, + msUntilStale: 60_000, + revalidate: vi.fn(), + }), +})); + +vi.mock('@/components/common/StellarConnectionQualityBadge', async () => { + const React = await import('react'); + + return { + default: () => React.createElement('div', { role: 'status' }, 'RPC good'), + }; +}); + +vi.mock('@/components/common/CreatorCard', async () => { + const React = await import('react'); + + return { + default: ({ creator }: { creator: { title: string } }) => + React.createElement( + 'article', + { 'aria-label': `Creator ${creator.title}` }, + creator.title + ), + }; +}); + +vi.mock('framer-motion', async () => { + const React = await import('react'); + type MotionDivProps = ComponentProps<'div'> & { + layout?: boolean; + transition?: unknown; + }; + + return { + AnimatePresence: ({ children }: { children: ReactNode }) => + React.createElement(React.Fragment, null, children), + LayoutGroup: ({ children }: { children: ReactNode }) => + React.createElement(React.Fragment, null, children), + motion: { + div: ({ children, ...props }: MotionDivProps) => { + const { layout, transition, ...divProps } = props; + void layout; + void transition; + + return React.createElement('div', divProps, children); + }, + button: ({ children, ...props }: ComponentProps<'button'>) => + React.createElement('button', props, children), + }, + }; +}); + +const mockGetCourses = vi.mocked(courseService.getCourses); + +const creatorList: Course[] = [ + { + id: 'alex-rivers', + title: 'Alex Rivers', + description: 'Digital artist', + price: 0.05, + priceStroops: 500_000, + creatorShareSupply: 120, + instructorId: 'arivers', + category: 'Art', + level: 'BEGINNER', + isVerified: true, + }, +]; + +const mockMatchMedia = () => { + Object.defineProperty(window, 'matchMedia', { + writable: true, + value: vi.fn().mockImplementation((query: string) => ({ + matches: false, + media: query, + onchange: null, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + addListener: vi.fn(), + removeListener: vi.fn(), + dispatchEvent: vi.fn(), + })), + }); +}; + +/** A stand-in for "the creator discovery list page" the user navigates to. */ +function DiscoveryListPlaceholder() { + return
Creator discovery list
; +} + +function pressT() { + const event = new KeyboardEvent('keydown', { + key: 't', + code: 'KeyT', + bubbles: true, + cancelable: true, + }); + fireEvent(window, event); + return event; +} + +describe('LandingPage trade shortcut — cleanup on unmount (#654)', () => { + beforeEach(() => { + mockMatchMedia(); + window.localStorage.clear(); + window.sessionStorage.clear(); + mockGetCourses.mockReset(); + mockGetCourses.mockResolvedValue(creatorList); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('opens the trade dialog with T, stops responding after unmount, and works again after remount', async () => { + const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + + // 1. Mount the creator profile page and confirm T opens the trade dialog. + const { unmount } = render( + + + + ); + await waitFor(() => expect(mockGetCourses).toHaveBeenCalledTimes(1)); + + const firstPress = pressT(); + expect(firstPress.defaultPrevented).toBe(true); + expect(await screen.findByRole('dialog')).toBeInTheDocument(); + + // 2. Navigate away: unmount the profile page and mount a stand-in for + // the creator discovery list page in its place. + unmount(); + const { unmount: unmountDiscoveryList } = render(); + expect(screen.getByTestId('discovery-list-placeholder')).toBeInTheDocument(); + + // 3. T should now do nothing -- no dialog, no preventDefault -- because + // the listener registered by the unmounted page was cleaned up. + const secondPress = pressT(); + expect(secondPress.defaultPrevented).toBe(false); + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + expect(consoleErrorSpy).not.toHaveBeenCalled(); + + // 4. Navigate back: unmount the discovery list stand-in and mount the + // profile page again -- this re-registers the listener, so T opens + // the trade dialog once more. + unmountDiscoveryList(); + render( + + + + ); + await waitFor(() => expect(mockGetCourses).toHaveBeenCalledTimes(2)); + + const thirdPress = pressT(); + expect(thirdPress.defaultPrevented).toBe(true); + expect(await screen.findByRole('dialog')).toBeInTheDocument(); + + expect(consoleErrorSpy).not.toHaveBeenCalled(); + }); +}); diff --git a/src/services/course.service.ts b/src/services/course.service.ts index ace2eb5..460b3ca 100644 --- a/src/services/course.service.ts +++ b/src/services/course.service.ts @@ -45,6 +45,22 @@ export interface GetCoursesParams { sort?: Exclude; } +/** Raw envelope shape for a paginated /courses response. */ +interface CoursesPageEnvelope { + items?: Course[]; + data?: Course[]; + has_more?: boolean; + hasMore?: boolean; +} + +export interface CoursesPage { + items: Course[]; + /** The page number that was requested (used as this page's cursor). */ + page: number; + /** Whether another page is available after this one. */ + hasMore: boolean; +} + class CourseService extends BaseApiService { private readonly PROFILE_CACHE_TTL = 30000; // 30 seconds @@ -68,6 +84,46 @@ class CourseService extends BaseApiService { } } + /** + * Get one cursor-paginated page of courses for infinite-scroll marketplace + * browsing - GET /courses (#685). `page` is used as the cursor: pass the + * previous response's `page + 1` to fetch the next page. + * + * `hasMore` is read from the response's `has_more`/`hasMore` field when + * the backend provides it, falling back to "this page was full" (item + * count equals the requested limit) when it doesn't -- a full page means + * there could be more, an under-full page means we've reached the end. + */ + async getCoursesPage( + page: number, + params?: Omit + ): Promise { + const limit = params?.limit ?? 20; + const requestParams: GetCoursesParams = { ...params, page, limit }; + const cacheKey = `courses_page_${JSON.stringify(requestParams)}`; + const cached = cacheManager.get(cacheKey); + if (cached) return cached; + + try { + const response = await this.api.get>( + '/courses', + { params: requestParams } + ); + + const raw = response.data.data; + const items: Course[] = Array.isArray(raw) ? raw : (raw.items ?? raw.data ?? []); + const hasMore: boolean = Array.isArray(raw) + ? items.length === limit + : (raw.has_more ?? raw.hasMore ?? items.length === limit); + + const result: CoursesPage = { items, page, hasMore }; + cacheManager.set(cacheKey, result, this.PROFILE_CACHE_TTL); + return result; + } catch (error) { + throw this.handleError(error); + } + } + // Get single course - GET /courses/:id async getCourse(courseId: string): Promise { const cacheKey = `course_${courseId}`;