diff --git a/src/components/common/TransactionHistory.tsx b/src/components/common/TransactionHistory.tsx index c1e8f26..b2b3d00 100644 --- a/src/components/common/TransactionHistory.tsx +++ b/src/components/common/TransactionHistory.tsx @@ -2,11 +2,16 @@ import { useState, useEffect } from 'react'; import { Button } from '@/components/ui/button'; import { ChevronDown, ChevronUp, ArrowUpRight, ArrowDownRight, Minus } from 'lucide-react'; import { cn } from '@/lib/utils'; +import { formatRelativeTime } from '@/utils/time.utils'; +import { formatCreatorHandle } from '@/utils/handleDisplay.utils'; -interface Transaction { +export interface Transaction { id: string; type: 'buy' | 'sell'; - creator: string; + /** Raw creator identifier from the API — never shown in the UI. */ + creatorId: string; + /** Human-readable handle used for display (e.g. instructorId). */ + creatorHandle: string; amount: number; price: number; timestamp: number; @@ -14,13 +19,18 @@ interface Transaction { status: 'completed' | 'pending' | 'failed'; } +interface TransactionHistoryProps { + transactions?: Transaction[]; +} + const COMPACT_VIEW_KEY = 'accesslayer.transaction-compact-view'; const SAMPLE_TRANSACTIONS: Transaction[] = [ { id: '1', type: 'buy', - creator: 'Alex Rivers', + creatorId: '1', + creatorHandle: 'arivers', amount: 5, price: 0.05, timestamp: Date.now() - 1000 * 60 * 30, @@ -30,7 +40,8 @@ const SAMPLE_TRANSACTIONS: Transaction[] = [ { id: '2', type: 'sell', - creator: 'Sarah Chen', + creatorId: '2', + creatorHandle: 'schen_dev', amount: 3, price: 0.12, timestamp: Date.now() - 1000 * 60 * 60 * 2, @@ -40,7 +51,8 @@ const SAMPLE_TRANSACTIONS: Transaction[] = [ { id: '3', type: 'buy', - creator: 'Marcus Thorne', + creatorId: '3', + creatorHandle: 'mthorne', amount: 10, price: 0.08, timestamp: Date.now() - 1000 * 60 * 60 * 5, @@ -50,7 +62,8 @@ const SAMPLE_TRANSACTIONS: Transaction[] = [ { id: '4', type: 'buy', - creator: 'Elena Vance', + creatorId: '4', + creatorHandle: 'evance_design', amount: 2, price: 0.04, timestamp: Date.now() - 1000 * 60 * 60 * 24, @@ -60,7 +73,8 @@ const SAMPLE_TRANSACTIONS: Transaction[] = [ { id: '5', type: 'sell', - creator: 'David Kojo', + creatorId: '5', + creatorHandle: 'dkojo_beats', amount: 7, price: 0.15, timestamp: Date.now() - 1000 * 60 * 60 * 48, @@ -69,9 +83,9 @@ const SAMPLE_TRANSACTIONS: Transaction[] = [ }, ]; -import { formatRelativeTime } from '@/utils/time.utils'; - -const TransactionHistory: React.FC = () => { +const TransactionHistory: React.FC = ({ + transactions = SAMPLE_TRANSACTIONS, +}) => { const [isCompact, setIsCompact] = useState(() => { if (typeof window === 'undefined') return false; const saved = localStorage.getItem(COMPACT_VIEW_KEY); @@ -143,7 +157,8 @@ const TransactionHistory: React.FC = () => {
- {SAMPLE_TRANSACTIONS.map(tx => { + {transactions.map(tx => { + const displayHandle = formatCreatorHandle(tx.creatorHandle); const isExpanded = expandedRows.has(tx.id) || !isCompact; return (
{ {getTransactionTypeLabel(tx.type)} - {tx.creator} + + {displayHandle} +
{(!isCompact || isExpanded) && (
diff --git a/src/components/common/__tests__/TransactionHistory.creatorHandle.integration.test.tsx b/src/components/common/__tests__/TransactionHistory.creatorHandle.integration.test.tsx new file mode 100644 index 0000000..1a54d21 --- /dev/null +++ b/src/components/common/__tests__/TransactionHistory.creatorHandle.integration.test.tsx @@ -0,0 +1,76 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { render, screen, within } from '@testing-library/react'; +import TransactionHistory, { + type Transaction, +} from '@/components/common/TransactionHistory'; + +const CREATOR_A_ID = '101'; +const CREATOR_B_ID = '202'; + +const twoCreatorTrades: Transaction[] = [ + { + id: 'trade-a', + type: 'buy', + creatorId: CREATOR_A_ID, + creatorHandle: 'arivers', + amount: 2, + price: 0.05, + timestamp: Date.now() - 1000 * 60 * 15, + txHash: '0xaaaa...1111', + status: 'completed', + }, + { + id: 'trade-b', + type: 'sell', + creatorId: CREATOR_B_ID, + creatorHandle: 'schen_dev', + amount: 1, + price: 0.12, + timestamp: Date.now() - 1000 * 60 * 45, + txHash: '0xbbbb...2222', + status: 'completed', + }, +]; + +beforeEach(() => { + vi.stubEnv('NODE_ENV', 'test'); + localStorage.clear(); +}); + +describe('TransactionHistory – activity feed creator handles (integration)', () => { + it('renders the correct creator handle for each trade entry', () => { + render(); + + expect( + screen.getByTestId('activity-creator-handle-trade-a') + ).toHaveTextContent('@arivers'); + expect( + screen.getByTestId('activity-creator-handle-trade-b') + ).toHaveTextContent('@schen_dev'); + }); + + it('shows different handles for entries from different creators', () => { + render(); + + const handleA = screen.getByTestId('activity-creator-handle-trade-a'); + const handleB = screen.getByTestId('activity-creator-handle-trade-b'); + + expect(handleA.textContent).not.toBe(handleB.textContent); + expect(handleA).toHaveTextContent('@arivers'); + expect(handleB).toHaveTextContent('@schen_dev'); + }); + + it('does not expose raw creator IDs in the rendered output', () => { + const { container } = render( + + ); + + const buyRow = screen.getByTestId('activity-item-buy'); + const sellRow = screen.getByTestId('activity-item-sell'); + + expect(within(buyRow).queryByText(CREATOR_A_ID)).not.toBeInTheDocument(); + expect(within(sellRow).queryByText(CREATOR_B_ID)).not.toBeInTheDocument(); + expect(container.textContent).not.toContain(CREATOR_A_ID); + expect(container.textContent).not.toContain(CREATOR_B_ID); + }); +}); diff --git a/src/pages/LandingPage.tsx b/src/pages/LandingPage.tsx index 6944b4e..bb55e7e 100644 --- a/src/pages/LandingPage.tsx +++ b/src/pages/LandingPage.tsx @@ -262,6 +262,10 @@ const CreatorProfileLoadError: React.FC = ({ function LandingPage() { const [creators, setCreators] = useState([]); + // Creators used for wallet holdings; kept separate from the marketplace + // list so an empty API holdings response can show zero positions while + // the browse grid still falls back to demo creators. + const [holdingsCreators, setHoldingsCreators] = useState([]); // Last successful fetch timestamp (#301). `null` means we've never // resolved a load yet — the staleness helper treats that as "stale" // so the warning surfaces if the load hangs. @@ -464,8 +468,10 @@ function LandingPage() { ); if (data && data.length > 0) { setCreators(data); + setHoldingsCreators(data); } else { setCreators(DEMO_CREATORS); + setHoldingsCreators([]); } // Track the last successful fetch so the stale-data warning // has a baseline to compare against (#301). @@ -495,6 +501,7 @@ function LandingPage() { setShowRetryBanner(false); setFetchRetryAttempt(0); setCreators(DEMO_CREATORS); + setHoldingsCreators(DEMO_CREATORS); } finally { setIsLoading(false); } @@ -674,7 +681,7 @@ function LandingPage() { const heldKeyPositions = useMemo( () => - creators.map((creator, index) => ({ + holdingsCreators.map((creator, index) => ({ creatorId: creator.id, quantity: index === 0 @@ -685,7 +692,7 @@ function LandingPage() { isPriceLoading: isPriceRefreshing, isPriceStale: creatorsAreStale, })), - [creators, creatorsAreStale, featuredHoldings, isPriceRefreshing] + [holdingsCreators, creatorsAreStale, featuredHoldings, isPriceRefreshing] ); const portfolioValue = useMemo( () => calculatePortfolioValue(heldKeyPositions), @@ -1163,6 +1170,12 @@ function LandingPage() {

{portfolioValueHelperText}

+ + {displayedPortfolioValue.heldPositionCount} +
{isLoading ? ( diff --git a/src/pages/__tests__/LandingPage.debouncedSearchClear.integration.test.tsx b/src/pages/__tests__/LandingPage.debouncedSearchClear.integration.test.tsx new file mode 100644 index 0000000..e41db3b --- /dev/null +++ b/src/pages/__tests__/LandingPage.debouncedSearchClear.integration.test.tsx @@ -0,0 +1,179 @@ +/** + * Integration test for debounced search clearing (#519). + * + * Confirms that clearing the search input triggers a debounced refetch without + * a search param and restores the full unfiltered creator list. + */ +import type { ComponentProps, ReactNode } from 'react'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { MemoryRouter } from 'react-router'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import LandingPage from '@/pages/LandingPage'; +import { + courseService, + type Course, + type GetCoursesParams, +} from '@/services/course.service'; + +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); + }, + h1: ({ children, ...props }: ComponentProps<'h1'>) => + React.createElement('h1', props, children), + button: ({ children, ...props }: ComponentProps<'button'>) => + React.createElement('button', props, children), + }, + }; +}); + +const mockGetCourses = vi.mocked(courseService.getCourses); + +const creatorAlpha: Course = { + id: '1', + title: 'Creator Alpha', + description: 'Digital artist', + price: 0.5, + priceStroops: 5_000_000, + creatorShareSupply: 100, + instructorId: 'creator-alpha', + category: 'Art', + level: 'BEGINNER', + isVerified: true, +}; + +const creatorBeta: Course = { + id: '2', + title: 'Creator Beta', + description: 'Music producer', + price: 0.1, + priceStroops: 1_000_000, + creatorShareSupply: 50, + instructorId: 'creator-beta', + category: 'Music', + level: 'INTERMEDIATE', + isVerified: true, +}; + +const allCreators = [creatorAlpha, creatorBeta]; + +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(), + })), + }); +}; + +const getCreatorTitles = () => + screen.getAllByRole('article').map(node => node.textContent); + +describe('LandingPage debounced search clear integration (#519)', () => { + beforeEach(() => { + mockMatchMedia(); + window.localStorage.clear(); + window.sessionStorage.clear(); + mockGetCourses.mockReset(); + mockGetCourses.mockImplementation(async (params?: GetCoursesParams) => { + if (params?.search) return [creatorBeta]; + return allCreators; + }); + }); + + it('re-fetches without a search param and restores the full creator list after the input is cleared', async () => { + render( + + + + ); + + await waitFor(() => expect(mockGetCourses).toHaveBeenCalledTimes(1)); + expect(mockGetCourses).toHaveBeenLastCalledWith(undefined); + await waitFor(() => + expect(getCreatorTitles()).toEqual(['Creator Alpha', 'Creator Beta']) + ); + + const input = screen.getByPlaceholderText( + /search creators by name or handle/i + ); + + fireEvent.change(input, { target: { value: 'Beta' } }); + + await waitFor(() => expect(mockGetCourses).toHaveBeenCalledTimes(2)); + expect(mockGetCourses).toHaveBeenLastCalledWith({ search: 'Beta' }); + await waitFor(() => expect(getCreatorTitles()).toEqual(['Creator Beta'])); + + fireEvent.change(input, { target: { value: '' } }); + + await waitFor(() => expect(mockGetCourses).toHaveBeenCalledTimes(3)); + expect(mockGetCourses).toHaveBeenLastCalledWith(undefined); + await waitFor(() => + expect(getCreatorTitles()).toEqual(['Creator Alpha', 'Creator Beta']) + ); + }); +}); diff --git a/src/pages/__tests__/LandingPage.holdingsCount.integration.test.tsx b/src/pages/__tests__/LandingPage.holdingsCount.integration.test.tsx new file mode 100644 index 0000000..7cc0c6f --- /dev/null +++ b/src/pages/__tests__/LandingPage.holdingsCount.integration.test.tsx @@ -0,0 +1,246 @@ +/** + * Integration test for portfolio holdings header entry count (#521). + * + * Confirms the holdings overview header count matches the number of held + * creator positions returned from the holdings response, including empty and + * refreshed responses. + */ +import type { ComponentProps, ReactNode } from 'react'; +import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'; +import { MemoryRouter } from 'react-router'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import LandingPage from '@/pages/LandingPage'; +import { courseService, type Course } from '@/services/course.service'; + +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); + }, + h1: ({ children, ...props }: ComponentProps<'h1'>) => + React.createElement('h1', props, children), + button: ({ children, ...props }: ComponentProps<'button'>) => + React.createElement('button', props, children), + }, + }; +}); + +const mockGetCourses = vi.mocked(courseService.getCourses); + +const threeHoldingsCreators: Course[] = [ + { + id: 'creator-a', + title: 'Creator A', + description: 'Digital artist', + price: 0.05, + priceStroops: 500_000, + creatorShareSupply: 100, + instructorId: 'creator-a', + category: 'Art', + level: 'BEGINNER', + isVerified: true, + }, + { + id: 'creator-b', + title: 'Creator B', + description: 'Developer', + price: 0.12, + priceStroops: 1_200_000, + creatorShareSupply: 50, + instructorId: 'creator-b', + category: 'Tech', + level: 'ADVANCED', + isVerified: false, + }, + { + id: 'creator-c', + title: 'Creator C', + description: 'Strategist', + price: 0.08, + priceStroops: 800_000, + creatorShareSupply: 75, + instructorId: 'creator-c', + category: 'Finance', + level: 'INTERMEDIATE', + isVerified: true, + }, +]; + +const singleHoldingCreator = [threeHoldingsCreators[0]]; + +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(), + })), + }); +}; + +const getHoldingsOverviewSection = () => { + const heading = screen.getByRole('heading', { name: 'Total portfolio value' }); + const section = heading.closest('[aria-labelledby="holdings-overview-heading"]'); + expect(section).not.toBeNull(); + + return section as HTMLElement; +}; + +const getHoldingsHeaderEntryCount = () => + Number(screen.getByTestId('holdings-header-entry-count').textContent); + +const countHoldingsGridEntries = () => + within(getHoldingsOverviewSection()).queryAllByText(/\d+ keys ·/).length; + +const waitForHoldingsHeaderCount = async (count: number) => { + await waitFor(() => { + expect(getHoldingsHeaderEntryCount()).toBe(count); + }); +}; + +const triggerCreatorListRefresh = () => { + const shortcutEvent = new KeyboardEvent('keydown', { + key: 'r', + code: 'KeyR', + ctrlKey: true, + altKey: true, + bubbles: true, + cancelable: true, + }); + + fireEvent(window, shortcutEvent); +}; + +describe('LandingPage holdings header entry count integration (#521)', () => { + beforeEach(() => { + mockMatchMedia(); + window.localStorage.clear(); + window.sessionStorage.clear(); + mockGetCourses.mockReset(); + }); + + it('shows a header count of 3 when three holdings entries are returned', async () => { + mockGetCourses.mockResolvedValue(threeHoldingsCreators); + + render( + + + + ); + + await waitFor(() => expect(mockGetCourses).toHaveBeenCalledTimes(1)); + await waitForHoldingsHeaderCount(3); + + expect( + screen.getByText('Across 3 held creator positions.') + ).toBeInTheDocument(); + expect(countHoldingsGridEntries()).toBe(3); + }); + + it('shows a header count of 0 for an empty holdings response', async () => { + mockGetCourses.mockResolvedValue([]); + + render( + + + + ); + + await waitFor(() => expect(mockGetCourses).toHaveBeenCalledTimes(1)); + await waitForHoldingsHeaderCount(0); + + expect(screen.getByText('No held creator keys yet.')).toBeInTheDocument(); + expect( + within(getHoldingsOverviewSection()).getByText('0 XLM') + ).toBeInTheDocument(); + expect(countHoldingsGridEntries()).toBe(0); + }); + + it('updates the header count when holdings data is refreshed', async () => { + mockGetCourses + .mockResolvedValueOnce(threeHoldingsCreators) + .mockResolvedValueOnce(singleHoldingCreator); + + render( + + + + ); + + await waitFor(() => expect(mockGetCourses).toHaveBeenCalledTimes(1)); + await waitForHoldingsHeaderCount(3); + expect(countHoldingsGridEntries()).toBe(3); + + triggerCreatorListRefresh(); + + await waitFor(() => expect(mockGetCourses).toHaveBeenCalledTimes(2)); + await waitForHoldingsHeaderCount(1); + + expect( + screen.getByText('Across 1 held creator position.') + ).toBeInTheDocument(); + expect(countHoldingsGridEntries()).toBe(1); + }); +}); diff --git a/src/pages/__tests__/MarketingPage.integration.test.tsx b/src/pages/__tests__/MarketingPage.integration.test.tsx new file mode 100644 index 0000000..e4199d2 --- /dev/null +++ b/src/pages/__tests__/MarketingPage.integration.test.tsx @@ -0,0 +1,49 @@ +import { render, screen } from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import MarketingPage from '@/pages/MarketingPage'; + +describe('MarketingPage integration (#525)', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('renders without console errors or warnings and includes all major sections', () => { + const consoleErrorSpy = vi.spyOn(console, 'error'); + const consoleWarnSpy = vi.spyOn(console, 'warn'); + + render(); + + expect(consoleErrorSpy).not.toHaveBeenCalled(); + expect(consoleWarnSpy).not.toHaveBeenCalled(); + + expect( + screen.getByRole('heading', { name: /access layer/i }) + ).toBeInTheDocument(); + + expect( + screen.getByText( + /AccessLayer is an open source platform built on the Stellar blockchain/i + ) + ).toBeInTheDocument(); + + expect(screen.getByText(/how it works/i)).toBeInTheDocument(); + expect( + screen.getByText( + /You connect your Stellar wallet, browse the marketplace, and buy keys/i + ) + ).toBeInTheDocument(); + + expect(screen.getAllByText(/^built on stellar$/i)).toHaveLength(2); + expect( + screen.getByText( + /AccessLayer is built on the Stellar blockchain using Soroban smart contracts/i + ) + ).toBeInTheDocument(); + + expect(screen.getByText(/join the community/i)).toBeInTheDocument(); + expect(screen.getByRole('link', { name: /github/i })).toBeInTheDocument(); + expect(screen.getByRole('link', { name: /telegram/i })).toBeInTheDocument(); + + expect(screen.getByAltText(/access layer/i)).toBeInTheDocument(); + }); +});