diff --git a/src/components/common/CreatorActivityFeed.tsx b/src/components/common/CreatorActivityFeed.tsx
new file mode 100644
index 0000000..600f34b
--- /dev/null
+++ b/src/components/common/CreatorActivityFeed.tsx
@@ -0,0 +1,107 @@
+import { ArrowDownRight, ArrowUpRight, History } from 'lucide-react';
+import { useCreatorActivityFeed } from '@/hooks/useCreatorActivityFeed';
+import { creatorActivityService } from '@/services/creatorActivity.service';
+import { formatRelativeTime } from '@/utils/time.utils';
+import { formatCreatorHandle } from '@/utils/handleDisplay.utils';
+
+export interface CreatorActivityFeedProps {
+ creatorId: string;
+}
+
+const SKELETON_ROW_COUNT = 3;
+
+function ActivityFeedSkeletonRows() {
+ return (
+
+ {Array.from({ length: SKELETON_ROW_COUNT }).map((_, index) => (
+
+ ))}
+
+ );
+}
+
+/**
+ * Creator profile activity feed (#698): shows the creator's recent public
+ * trade activity, with a proper empty state when there is none.
+ *
+ * State machine:
+ * loading -> skeleton rows (never the empty state, even if `trades`
+ * happens to still be `[]` from a previous query).
+ * settled, trades.length === 0 -> empty state with the exact copy from
+ * the issue spec.
+ * settled, trades.length > 0 -> real rows.
+ */
+const CreatorActivityFeed: React.FC = ({ creatorId }) => {
+ const { trades, isLoading } = useCreatorActivityFeed(creatorId, id =>
+ creatorActivityService.getCreatorActivity(id)
+ );
+
+ if (isLoading) {
+ return ;
+ }
+
+ if (trades.length === 0) {
+ return (
+
+
+
+
+
+ No activity yet — buy or sell keys to get started
+
+
+ );
+ }
+
+ return (
+
+ {trades.map(trade => (
+
+
+ {trade.type === 'buy' ? (
+
+ ) : (
+
+ )}
+
+
+
+
+ {trade.type === 'buy' ? 'Buy' : 'Sell'}
+
+ •
+
+ {formatCreatorHandle(trade.traderHandle)}
+
+
+
+ {trade.amount} keys
+ •
+ {trade.price} XLM
+ •
+ {formatRelativeTime(trade.timestamp)}
+
+
+
+ ))}
+
+ );
+};
+
+export default CreatorActivityFeed;
diff --git a/src/components/common/CreatorMarketplaceInfiniteList.tsx b/src/components/common/CreatorMarketplaceInfiniteList.tsx
index 9de6aac..27774a7 100644
--- a/src/components/common/CreatorMarketplaceInfiniteList.tsx
+++ b/src/components/common/CreatorMarketplaceInfiniteList.tsx
@@ -1,6 +1,8 @@
+import { useEffect, useMemo, useState } from 'react';
import { useInfiniteCreatorMarketplace } from '@/hooks/useInfiniteCreatorMarketplace';
import { useInfiniteScroll } from '@/hooks/useInfiniteScroll';
import CreatorCard from '@/components/common/CreatorCard';
+import SearchBar from '@/components/common/SearchBar';
import { CreatorGridSkeleton } from '@/components/common/CreatorSkeleton';
import type { GetCoursesParams } from '@/services/course.service';
@@ -8,12 +10,20 @@ export interface CreatorMarketplaceInfiniteListProps {
params?: Omit;
}
+const SEARCH_DEBOUNCE_MS = 300;
+
/**
* 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.
+ *
+ * A search bar (#699) filters the currently-loaded creators client-side by
+ * display name (case-insensitive substring match), debounced by 300ms. This
+ * filters within the page(s) already fetched rather than issuing a new
+ * server request per keystroke — the cursor-based pagination in
+ * useInfiniteCreatorMarketplace has no server-side name filter today.
*/
export default function CreatorMarketplaceInfiniteList({
params,
@@ -27,6 +37,34 @@ export default function CreatorMarketplaceInfiniteList({
fetchNextPage,
} = useInfiniteCreatorMarketplace(params);
+ const [searchQuery, setSearchQuery] = useState('');
+ const [debouncedQuery, setDebouncedQuery] = useState('');
+
+ useEffect(() => {
+ const timer = setTimeout(() => setDebouncedQuery(searchQuery), SEARCH_DEBOUNCE_MS);
+ return () => clearTimeout(timer);
+ }, [searchQuery]);
+
+ // Reset the filter whenever the loaded creator set changes identity due
+ // to a new cursor/page landing, per the issue's "filter resets when the
+ // page changes" acceptance criterion — a stale query shouldn't silently
+ // keep hiding newly-loaded creators from a previous page's context. Both
+ // the live input value and the debounced value used for filtering are
+ // cleared together so there's no window where they disagree.
+ const creatorsKey = creators.map(creator => creator.id).join(',');
+ const [lastCreatorsKey, setLastCreatorsKey] = useState(creatorsKey);
+ if (creatorsKey !== lastCreatorsKey) {
+ setLastCreatorsKey(creatorsKey);
+ if (searchQuery) setSearchQuery('');
+ if (debouncedQuery) setDebouncedQuery('');
+ }
+
+ const trimmedQuery = debouncedQuery.trim().toLowerCase();
+ const filteredCreators = useMemo(() => {
+ if (!trimmedQuery) return creators;
+ return creators.filter(creator => creator.title.toLowerCase().includes(trimmedQuery));
+ }, [creators, trimmedQuery]);
+
const sentinelRef = useInfiniteScroll({
enabled: !isLoadingFirstPage && !isFetchingNextPage,
hasMore: Boolean(hasMore),
@@ -55,11 +93,27 @@ export default function CreatorMarketplaceInfiniteList({
Refreshing…
)}
-
- {creators.map(creator => (
-
- ))}
-
+
+
+ {trimmedQuery && filteredCreators.length === 0 ? (
+
+ No results for "{debouncedQuery.trim()}"
+
+ ) : (
+
+ {filteredCreators.map(creator => (
+
+ ))}
+
+ )}
{isFetchingNextPage && (
diff --git a/src/components/common/KeyHolderList.tsx b/src/components/common/KeyHolderList.tsx
new file mode 100644
index 0000000..0c30f10
--- /dev/null
+++ b/src/components/common/KeyHolderList.tsx
@@ -0,0 +1,60 @@
+import { formatHolderCount, formatPercent } from '@/utils/numberFormat.utils';
+import { rankKeyHolders, type KeyHolder } from '@/utils/keyHolderRanking.utils';
+
+export interface KeyHolderListProps {
+ holders: KeyHolder[];
+}
+
+/**
+ * Ranks investors by key count and shows each holder's share of the total
+ * supply held across the list (#697). Sorting and rank/share math live in
+ * `rankKeyHolders` (utils/keyHolderRanking.utils.ts) so they're covered by
+ * pure-function unit tests independent of rendering.
+ */
+const KeyHolderList: React.FC
= ({ holders }) => {
+ const ranked = rankKeyHolders(holders);
+
+ if (ranked.length === 0) {
+ return (
+
+ No holders yet.
+
+ );
+ }
+
+ return (
+
+ {ranked.map(holder => (
+
+
+
+ {holder.rank}
+
+ {holder.displayName}
+
+
+
+ {formatHolderCount(holder.keyCount)} keys
+
+
+ {formatPercent(holder.sharePercent, { maximumFractionDigits: 1 })}
+
+
+
+ ))}
+
+ );
+};
+
+export default KeyHolderList;
diff --git a/src/components/common/TradeDialog.tsx b/src/components/common/TradeDialog.tsx
index b695f98..e423bd3 100644
--- a/src/components/common/TradeDialog.tsx
+++ b/src/components/common/TradeDialog.tsx
@@ -49,9 +49,18 @@ const TradeDialog: React.FC = ({
const [touched, setTouched] = useState(false);
const amountInputRef = useRef(null);
const pricePreviewFailureLogged = useRef(false);
+ // TradeDialog is opened via `open`/`onOpenChange` props from several
+ // different external trigger buttons (see LandingPage.tsx), never via
+ // Radix's own . That means Radix's built-in
+ // focus-return-to-trigger (which targets its own internal triggerRef)
+ // is always a no-op here — there is no triggerRef to return to. We
+ // capture whatever had focus right before the dialog opened ourselves
+ // and restore it in onCloseAutoFocus instead.
+ const triggerElementRef = useRef(null);
useEffect(() => {
if (open) {
+ triggerElementRef.current = document.activeElement as HTMLElement | null;
setAmountText('1');
setTouched(false);
pricePreviewFailureLogged.current = false;
@@ -147,6 +156,10 @@ const TradeDialog: React.FC = ({
event.preventDefault();
amountInputRef.current?.focus();
}}
+ onCloseAutoFocus={event => {
+ event.preventDefault();
+ triggerElementRef.current?.focus();
+ }}
onEscapeKeyDown={event => {
if (isSubmitting) event.preventDefault();
}}
diff --git a/src/components/common/__tests__/CreatorActivityFeed.test.tsx b/src/components/common/__tests__/CreatorActivityFeed.test.tsx
new file mode 100644
index 0000000..ababee8
--- /dev/null
+++ b/src/components/common/__tests__/CreatorActivityFeed.test.tsx
@@ -0,0 +1,110 @@
+import { describe, expect, it, vi } from 'vitest';
+import { render, screen } from '@testing-library/react';
+import CreatorActivityFeed from '@/components/common/CreatorActivityFeed';
+import { useCreatorActivityFeed } from '@/hooks/useCreatorActivityFeed';
+import type { CreatorActivityTrade } from '@/services/creatorActivity.service';
+
+vi.mock('@/hooks/useCreatorActivityFeed');
+vi.mock('@/services/creatorActivity.service', () => ({
+ creatorActivityService: { getCreatorActivity: vi.fn() },
+}));
+
+const mockUseCreatorActivityFeed = vi.mocked(useCreatorActivityFeed);
+
+function makeTrade(id: string, overrides: Partial = {}): CreatorActivityTrade {
+ return {
+ id,
+ type: 'buy',
+ traderHandle: `trader-${id}`,
+ amount: 2,
+ price: 0.05,
+ timestamp: Date.now(),
+ txHash: `0x${id}`,
+ status: 'completed',
+ ...overrides,
+ };
+}
+
+describe('CreatorActivityFeed', () => {
+ it('shows skeleton rows while the query is loading', () => {
+ mockUseCreatorActivityFeed.mockReturnValue({
+ trades: [],
+ isLoading: true,
+ isError: false,
+ });
+
+ render( );
+
+ expect(screen.getByTestId('creator-activity-feed-skeleton')).toBeInTheDocument();
+ expect(screen.queryByTestId('creator-activity-feed-empty-state')).not.toBeInTheDocument();
+ });
+
+ it('does not show the empty state while loading, even though trades is an empty array', () => {
+ mockUseCreatorActivityFeed.mockReturnValue({
+ trades: [],
+ isLoading: true,
+ isError: false,
+ });
+
+ render( );
+
+ expect(screen.queryByText('No activity yet — buy or sell keys to get started')).not.toBeInTheDocument();
+ });
+
+ it('shows the empty state once the query has settled with no transactions', () => {
+ mockUseCreatorActivityFeed.mockReturnValue({
+ trades: [],
+ isLoading: false,
+ isError: false,
+ });
+
+ render( );
+
+ expect(screen.getByTestId('creator-activity-feed-empty-state')).toBeInTheDocument();
+ expect(
+ screen.getByText('No activity yet — buy or sell keys to get started')
+ ).toBeInTheDocument();
+ expect(screen.queryByTestId('creator-activity-feed-skeleton')).not.toBeInTheDocument();
+ });
+
+ it('renders real activity rows in place of the empty state as soon as data arrives', () => {
+ mockUseCreatorActivityFeed.mockReturnValue({
+ trades: [makeTrade('a'), makeTrade('b', { type: 'sell' })],
+ isLoading: false,
+ isError: false,
+ });
+
+ render( );
+
+ expect(screen.queryByTestId('creator-activity-feed-empty-state')).not.toBeInTheDocument();
+ expect(screen.getByTestId('creator-activity-item-a')).toBeInTheDocument();
+ expect(screen.getByTestId('creator-activity-item-b')).toBeInTheDocument();
+ });
+
+ it('renders the exact empty-state message copy from the design spec', () => {
+ mockUseCreatorActivityFeed.mockReturnValue({
+ trades: [],
+ isLoading: false,
+ isError: false,
+ });
+
+ render( );
+
+ expect(
+ screen.getByText('No activity yet — buy or sell keys to get started')
+ ).toBeInTheDocument();
+ });
+
+ it('distinguishes buy and sell trade types in the rendered rows', () => {
+ mockUseCreatorActivityFeed.mockReturnValue({
+ trades: [makeTrade('a', { type: 'buy' }), makeTrade('b', { type: 'sell' })],
+ isLoading: false,
+ isError: false,
+ });
+
+ render( );
+
+ expect(screen.getByTestId('creator-activity-item-a')).toHaveTextContent('Buy');
+ expect(screen.getByTestId('creator-activity-item-b')).toHaveTextContent('Sell');
+ });
+});
diff --git a/src/components/common/__tests__/CreatorMarketplaceInfiniteList.search.test.tsx b/src/components/common/__tests__/CreatorMarketplaceInfiniteList.search.test.tsx
new file mode 100644
index 0000000..a4a80fc
--- /dev/null
+++ b/src/components/common/__tests__/CreatorMarketplaceInfiniteList.search.test.tsx
@@ -0,0 +1,168 @@
+/**
+ * Unit tests for the marketplace search bar (#699): filters the currently
+ * loaded creators client-side by display name, debounced by 300ms.
+ */
+import { render, screen, fireEvent, act } from '@testing-library/react';
+import { describe, expect, it, vi, beforeEach, afterEach } 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, title: string): Course {
+ return {
+ id,
+ title,
+ 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 search', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ vi.useFakeTimers();
+ mockUseInfiniteScroll.mockReturnValue({ current: null });
+ mockUseInfiniteCreatorMarketplace.mockReturnValue({
+ ...baseHookReturn,
+ creators: [
+ makeCreator('a', 'Alice Wonderland'),
+ makeCreator('b', 'Bob Builder'),
+ makeCreator('c', 'ALICIA Keys'),
+ ],
+ });
+ });
+
+ afterEach(() => {
+ vi.useRealTimers();
+ });
+
+ it('renders the search input above the key list', () => {
+ render( );
+ expect(screen.getByPlaceholderText('Search creators')).toBeInTheDocument();
+ });
+
+ it('filters the list to matching creators within 300ms of the user stopping typing', () => {
+ render( );
+
+ fireEvent.change(screen.getByPlaceholderText('Search creators'), {
+ target: { value: 'bob' },
+ });
+
+ // Before the debounce window elapses, the full list is still shown.
+ expect(screen.getByLabelText('Creator Alice Wonderland')).toBeInTheDocument();
+
+ act(() => {
+ vi.advanceTimersByTime(300);
+ });
+
+ expect(screen.queryByLabelText('Creator Alice Wonderland')).not.toBeInTheDocument();
+ expect(screen.getByLabelText('Creator Bob Builder')).toBeInTheDocument();
+ });
+
+ it('matches case-insensitively', () => {
+ render( );
+
+ // "alic" is a substring of both "Alice" and "ALICIA" regardless of case.
+ fireEvent.change(screen.getByPlaceholderText('Search creators'), {
+ target: { value: 'alic' },
+ });
+ act(() => {
+ vi.advanceTimersByTime(300);
+ });
+
+ expect(screen.getByLabelText('Creator Alice Wonderland')).toBeInTheDocument();
+ expect(screen.getByLabelText('Creator ALICIA Keys')).toBeInTheDocument();
+ expect(screen.queryByLabelText('Creator Bob Builder')).not.toBeInTheDocument();
+ });
+
+ it('shows a "No results" empty state when no creators match the query', () => {
+ render( );
+
+ fireEvent.change(screen.getByPlaceholderText('Search creators'), {
+ target: { value: 'zzz-no-match' },
+ });
+ act(() => {
+ vi.advanceTimersByTime(300);
+ });
+
+ expect(screen.getByTestId('creator-marketplace-search-empty-state')).toHaveTextContent(
+ 'No results for "zzz-no-match"'
+ );
+ expect(screen.queryByRole('article')).not.toBeInTheDocument();
+ });
+
+ it('restores the full list when the input is cleared', () => {
+ render( );
+
+ const input = screen.getByPlaceholderText('Search creators');
+ fireEvent.change(input, { target: { value: 'bob' } });
+ act(() => {
+ vi.advanceTimersByTime(300);
+ });
+ expect(screen.queryByLabelText('Creator Alice Wonderland')).not.toBeInTheDocument();
+
+ fireEvent.change(input, { target: { value: '' } });
+ act(() => {
+ vi.advanceTimersByTime(300);
+ });
+
+ expect(screen.getByLabelText('Creator Alice Wonderland')).toBeInTheDocument();
+ expect(screen.getByLabelText('Creator Bob Builder')).toBeInTheDocument();
+ expect(screen.getByLabelText('Creator ALICIA Keys')).toBeInTheDocument();
+ });
+
+ it('resets the filter when the loaded creator set changes (new cursor/page)', () => {
+ const { rerender } = render( );
+
+ fireEvent.change(screen.getByPlaceholderText('Search creators'), {
+ target: { value: 'bob' },
+ });
+ act(() => {
+ vi.advanceTimersByTime(300);
+ });
+ expect(screen.queryByLabelText('Creator Alice Wonderland')).not.toBeInTheDocument();
+
+ // Simulate a new page landing, changing the identity of the creators array.
+ mockUseInfiniteCreatorMarketplace.mockReturnValue({
+ ...baseHookReturn,
+ creators: [
+ makeCreator('a', 'Alice Wonderland'),
+ makeCreator('b', 'Bob Builder'),
+ makeCreator('c', 'ALICIA Keys'),
+ makeCreator('d', 'Dave Newcomer'),
+ ],
+ });
+ rerender( );
+
+ expect(screen.getByPlaceholderText('Search creators')).toHaveValue('');
+ expect(screen.getByLabelText('Creator Alice Wonderland')).toBeInTheDocument();
+ expect(screen.getByLabelText('Creator Dave Newcomer')).toBeInTheDocument();
+ });
+});
diff --git a/src/components/common/__tests__/KeyHolderList.test.tsx b/src/components/common/__tests__/KeyHolderList.test.tsx
new file mode 100644
index 0000000..a17f837
--- /dev/null
+++ b/src/components/common/__tests__/KeyHolderList.test.tsx
@@ -0,0 +1,83 @@
+import { describe, expect, it } from 'vitest';
+import { render, screen, within } from '@testing-library/react';
+import KeyHolderList from '@/components/common/KeyHolderList';
+import type { KeyHolder } from '@/utils/keyHolderRanking.utils';
+
+function holder(id: string, displayName: string, keyCount: number): KeyHolder {
+ return { id, displayName, keyCount };
+}
+
+describe('KeyHolderList', () => {
+ it('renders holders sorted descending by key count', () => {
+ render(
+
+ );
+
+ const rows = screen.getAllByTestId('key-holder-row');
+ expect(rows.map(row => within(row).getByText(/^(Alice|Bob|Cara)$/).textContent)).toEqual([
+ 'Bob',
+ 'Cara',
+ 'Alice',
+ ]);
+ });
+
+ it('renders sequential rank numbers 1, 2, 3', () => {
+ render(
+
+ );
+
+ const ranks = screen.getAllByTestId('key-holder-rank').map(el => el.textContent);
+ expect(ranks).toEqual(['1', '2', '3']);
+ });
+
+ it('shows a single holder with all keys at 100% share', () => {
+ render( );
+
+ expect(screen.getByTestId('key-holder-share')).toHaveTextContent('100%');
+ });
+
+ it('renders tied holders at the same rank', () => {
+ render(
+
+ );
+
+ const ranks = screen.getAllByTestId('key-holder-rank').map(el => el.textContent);
+ expect(ranks).toEqual(['1', '1', '3']);
+ });
+
+ it('renders share percentages for each holder that are internally consistent with key counts', () => {
+ render(
+
+ );
+
+ const shares = screen.getAllByTestId('key-holder-share').map(el => el.textContent);
+ expect(shares).toEqual(['70%', '30%']);
+ });
+
+ it('shows an empty state when there are no holders', () => {
+ render( );
+
+ expect(screen.getByTestId('key-holder-list-empty')).toBeInTheDocument();
+ expect(screen.queryByTestId('key-holder-list')).not.toBeInTheDocument();
+ });
+
+ it('sets an accessible label on each rank badge', () => {
+ render( );
+
+ expect(screen.getByLabelText('Rank 1')).toBeInTheDocument();
+ });
+});
diff --git a/src/components/common/__tests__/TradeDialog.a11y.test.tsx b/src/components/common/__tests__/TradeDialog.a11y.test.tsx
new file mode 100644
index 0000000..d8d30ff
--- /dev/null
+++ b/src/components/common/__tests__/TradeDialog.a11y.test.tsx
@@ -0,0 +1,179 @@
+import { describe, expect, it, vi } from 'vitest';
+import { render, screen, waitFor } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { useState } from 'react';
+import TradeDialog from '@/components/common/TradeDialog';
+
+/**
+ * Keyboard accessibility for the buy key modal (#700). TradeDialog is built
+ * on Radix's Dialog primitive, which natively provides focus trapping,
+ * Escape-to-close, and focus-return-to-trigger-on-close. These tests verify
+ * that contract holds for THIS dialog's actual controls (amount input,
+ * Cancel, Confirm) rather than assuming the primitive is wired correctly,
+ * since `onOpenAutoFocus`/`onEscapeKeyDown` overrides in TradeDialog.tsx
+ * could silently break any of these if changed carelessly.
+ */
+describe('TradeDialog keyboard accessibility', () => {
+ /**
+ * Renders a real trigger button + TradeDialog together (mirroring how
+ * LandingPage.tsx wires them: a button's onClick opens the dialog), so
+ * focus-return-to-trigger can be verified against the actual element
+ * that had focus when the dialog opened, not a synthetic stand-in.
+ */
+ function DialogWithTrigger(
+ overrides: Partial> = {}
+ ) {
+ const [open, setOpen] = useState(false);
+ return (
+ <>
+ setOpen(true)}>
+ Open buy dialog
+
+
+ >
+ );
+ }
+
+ it('sets initial focus on the quantity input when the modal opens', async () => {
+ const user = userEvent.setup();
+ render( );
+
+ await user.click(screen.getByRole('button', { name: 'Open buy dialog' }));
+
+ await waitFor(() => {
+ expect(screen.getByTestId('trade-dialog-amount')).toHaveFocus();
+ });
+ });
+
+ it('traps focus inside the modal: Tab from Confirm (the last control) cycles back into the dialog', async () => {
+ const user = userEvent.setup();
+ render( );
+
+ // Capture the trigger element while it's still queryable — Radix
+ // marks background content aria-hidden once the dialog is open, so
+ // it can no longer be found via getByRole afterwards (correct
+ // behavior: background content should be invisible to assistive
+ // tech while a modal is open).
+ const trigger = screen.getByRole('button', { name: 'Open buy dialog' });
+ await user.click(trigger);
+ await waitFor(() => {
+ expect(screen.getByTestId('trade-dialog-amount')).toHaveFocus();
+ });
+
+ screen.getByTestId('trade-dialog-confirm').focus();
+ expect(screen.getByTestId('trade-dialog-confirm')).toHaveFocus();
+
+ await user.tab();
+
+ // Focus must land back inside the dialog (Radix's focus guard sends it
+ // to the first focusable element), never escape to the trigger button
+ // behind the modal — which would be reachable if the trap were broken.
+ expect(trigger).not.toHaveFocus();
+ expect(document.activeElement).not.toBe(document.body);
+ expect(document.activeElement).not.toBe(document.documentElement);
+ });
+
+ it('pressing Escape closes the modal', async () => {
+ const user = userEvent.setup();
+ const onOpenChange = vi.fn();
+ render( );
+
+ await waitFor(() => {
+ expect(screen.getByTestId('trade-dialog-amount')).toHaveFocus();
+ });
+
+ await user.keyboard('{Escape}');
+
+ expect(onOpenChange).toHaveBeenCalledWith(false);
+ });
+
+ it('does not close on Escape while a submission is in flight', async () => {
+ const user = userEvent.setup();
+ const onOpenChange = vi.fn();
+ render( );
+
+ await user.keyboard('{Escape}');
+
+ expect(onOpenChange).not.toHaveBeenCalled();
+ });
+
+ it('activates the confirm button via Enter when focused', async () => {
+ const user = userEvent.setup();
+ const onConfirm = vi.fn();
+ render( );
+
+ screen.getByTestId('trade-dialog-confirm').focus();
+ await user.keyboard('{Enter}');
+
+ expect(onConfirm).toHaveBeenCalledTimes(1);
+ });
+
+ it('activates the confirm button via Space when focused', async () => {
+ const user = userEvent.setup();
+ const onConfirm = vi.fn();
+ render( );
+
+ screen.getByTestId('trade-dialog-confirm').focus();
+ await user.keyboard(' ');
+
+ expect(onConfirm).toHaveBeenCalledTimes(1);
+ });
+
+ it('is not activatable via keyboard while disabled by an invalid amount', async () => {
+ const user = userEvent.setup();
+ const onConfirm = vi.fn();
+ render( );
+
+ const amountInput = screen.getByTestId('trade-dialog-amount');
+ await user.clear(amountInput);
+ amountInput.blur();
+
+ screen.getByTestId('trade-dialog-confirm').focus();
+ await user.keyboard('{Enter}');
+
+ expect(onConfirm).not.toHaveBeenCalled();
+ });
+
+ it('returns focus to the trigger button when the modal closes', async () => {
+ const user = userEvent.setup();
+ render( );
+
+ const trigger = screen.getByRole('button', { name: 'Open buy dialog' });
+ await user.click(trigger);
+
+ await waitFor(() => {
+ expect(screen.getByTestId('trade-dialog-amount')).toHaveFocus();
+ });
+
+ await user.keyboard('{Escape}');
+
+ await waitFor(() => {
+ expect(trigger).toHaveFocus();
+ });
+ });
+
+ it('returns focus to the trigger when closed via the Cancel button', async () => {
+ const user = userEvent.setup();
+ render( );
+
+ const trigger = screen.getByRole('button', { name: 'Open buy dialog' });
+ await user.click(trigger);
+ await waitFor(() => {
+ expect(screen.getByTestId('trade-dialog-amount')).toHaveFocus();
+ });
+
+ await user.click(screen.getByTestId('trade-dialog-cancel'));
+
+ await waitFor(() => {
+ expect(trigger).toHaveFocus();
+ });
+ });
+});
diff --git a/src/hooks/useCreatorActivityFeed.ts b/src/hooks/useCreatorActivityFeed.ts
new file mode 100644
index 0000000..26025cb
--- /dev/null
+++ b/src/hooks/useCreatorActivityFeed.ts
@@ -0,0 +1,34 @@
+import { useQuery } from '@tanstack/react-query';
+import { queryKeys } from '@/lib/queryKeys';
+import type { CreatorActivityTrade } from '@/services/creatorActivity.service';
+
+export interface CreatorActivityFeedResult {
+ trades: CreatorActivityTrade[];
+ isLoading: boolean;
+ isError: boolean;
+}
+
+/**
+ * Fetches a creator's public activity feed via React Query.
+ * Query key: ['creators', creatorId, 'activity']
+ *
+ * The queryFn is injected as a parameter (matching useCreatorHolderCount's
+ * convention) so tests can supply a mock without module-level vi.mock()
+ * patching.
+ */
+export function useCreatorActivityFeed(
+ creatorId: string,
+ fetchCreatorActivity: (id: string) => Promise
+): CreatorActivityFeedResult {
+ const { data, isLoading, isError } = useQuery({
+ queryKey: queryKeys.creators.activity(creatorId),
+ queryFn: () => fetchCreatorActivity(creatorId),
+ enabled: !!creatorId,
+ });
+
+ return {
+ trades: data ?? [],
+ isLoading,
+ isError,
+ };
+}
diff --git a/src/lib/queryKeys.ts b/src/lib/queryKeys.ts
index a27c8a6..e075af2 100644
--- a/src/lib/queryKeys.ts
+++ b/src/lib/queryKeys.ts
@@ -10,6 +10,8 @@ export const queryKeys = {
detail: (id: string) => ['creators', 'detail', id] as const,
holders: (creatorId: string) =>
['creators', creatorId, 'holders'] as const,
+ activity: (creatorId: string) =>
+ ['creators', creatorId, 'activity'] as const,
},
wallet: {
holdings: (address: string) => ['wallet', address, 'holdings'] as const,
diff --git a/src/pages/CreatorDetailPage.tsx b/src/pages/CreatorDetailPage.tsx
index d550687..5e36c52 100644
--- a/src/pages/CreatorDetailPage.tsx
+++ b/src/pages/CreatorDetailPage.tsx
@@ -3,6 +3,7 @@ import { useCreatorDetail } from '@/hooks/useCreators';
import CreatorBreadcrumb from '@/components/common/CreatorBreadcrumb';
import CreatorProfileHeader from '@/components/common/CreatorProfileHeader';
import CreatorProfileInfoGrid from '@/components/common/CreatorProfileInfoGrid';
+import CreatorActivityFeed from '@/components/common/CreatorActivityFeed';
import { CreatorProfileHeaderSkeleton } from '@/components/common/CreatorSkeleton';
import { bpsToPercent } from '@/utils/numberFormat.utils';
import CreatorPageErrorBoundary from '@/components/common/CreatorPageErrorBoundary';
@@ -67,6 +68,12 @@ function CreatorDetailPageContent() {
+
+
+ Activity
+
+
+
);
diff --git a/src/services/creatorActivity.service.ts b/src/services/creatorActivity.service.ts
new file mode 100644
index 0000000..7b30ffc
--- /dev/null
+++ b/src/services/creatorActivity.service.ts
@@ -0,0 +1,34 @@
+// src/services/creatorActivity.service.ts
+import { BaseApiService, type APIResponse } from './api.service';
+
+/**
+ * A single trade entry on a creator's public activity feed.
+ *
+ * Mirrors the shape consumed by the existing `TransactionHistory`
+ * component so the feed can pass entries straight through.
+ */
+export interface CreatorActivityTrade {
+ id: string;
+ type: 'buy' | 'sell';
+ traderHandle: string;
+ amount: number;
+ price: number;
+ timestamp: number;
+ txHash: string;
+ status: 'completed' | 'pending' | 'failed';
+}
+
+class CreatorActivityService extends BaseApiService {
+ async getCreatorActivity(creatorId: string): Promise {
+ try {
+ const response = await this.api.get>(
+ `/creators/${creatorId}/activity`
+ );
+ return response.data.data;
+ } catch (error) {
+ throw this.handleError(error);
+ }
+ }
+}
+
+export const creatorActivityService = new CreatorActivityService();
diff --git a/src/utils/__tests__/keyHolderRanking.utils.test.ts b/src/utils/__tests__/keyHolderRanking.utils.test.ts
new file mode 100644
index 0000000..6384b69
--- /dev/null
+++ b/src/utils/__tests__/keyHolderRanking.utils.test.ts
@@ -0,0 +1,71 @@
+import { describe, expect, it } from 'vitest';
+import { rankKeyHolders, type KeyHolder } from '@/utils/keyHolderRanking.utils';
+
+function holder(id: string, keyCount: number): KeyHolder {
+ return { id, displayName: `Holder ${id}`, keyCount };
+}
+
+describe('rankKeyHolders', () => {
+ it('sorts a list of three holders descending by key count', () => {
+ const ranked = rankKeyHolders([holder('a', 5), holder('b', 20), holder('c', 10)]);
+ expect(ranked.map(h => h.id)).toEqual(['b', 'c', 'a']);
+ });
+
+ it('assigns sequential ranks 1, 2, 3 in the sorted order', () => {
+ const ranked = rankKeyHolders([holder('a', 5), holder('b', 20), holder('c', 10)]);
+ expect(ranked.map(h => h.rank)).toEqual([1, 2, 3]);
+ });
+
+ it('computes share percentages that sum to 100% across all holders', () => {
+ const ranked = rankKeyHolders([holder('a', 30), holder('b', 50), holder('c', 20)]);
+ const total = ranked.reduce((sum, h) => sum + h.sharePercent, 0);
+ expect(total).toBeCloseTo(100, 10);
+ });
+
+ it('gives a single holder with all keys a 100% share', () => {
+ const ranked = rankKeyHolders([holder('a', 42)]);
+ expect(ranked).toHaveLength(1);
+ expect(ranked[0]!.sharePercent).toBe(100);
+ expect(ranked[0]!.rank).toBe(1);
+ });
+
+ it('renders a tie in key count at the same rank', () => {
+ const ranked = rankKeyHolders([holder('a', 10), holder('b', 10), holder('c', 5)]);
+ const [first, second, third] = ranked;
+
+ expect(first!.keyCount).toBe(10);
+ expect(second!.keyCount).toBe(10);
+ expect(first!.rank).toBe(1);
+ expect(second!.rank).toBe(1);
+ // Competition ranking: the next distinct count resumes at its
+ // 1-indexed position (3rd), not incrementing by 1 (2nd).
+ expect(third!.rank).toBe(3);
+ });
+
+ it('splits share percentage evenly between exactly tied holders', () => {
+ const ranked = rankKeyHolders([holder('a', 10), holder('b', 10)]);
+ expect(ranked[0]!.sharePercent).toBe(50);
+ expect(ranked[1]!.sharePercent).toBe(50);
+ });
+
+ it('handles three-way ties by giving the following holder rank 4', () => {
+ const ranked = rankKeyHolders([
+ holder('a', 10),
+ holder('b', 10),
+ holder('c', 10),
+ holder('d', 5),
+ ]);
+ expect(ranked.map(h => h.rank)).toEqual([1, 1, 1, 4]);
+ });
+
+ it('returns an empty array for an empty holder list', () => {
+ expect(rankKeyHolders([])).toEqual([]);
+ });
+
+ it('does not mutate the input array', () => {
+ const input = [holder('a', 5), holder('b', 20)];
+ const inputCopy = [...input];
+ rankKeyHolders(input);
+ expect(input).toEqual(inputCopy);
+ });
+});
diff --git a/src/utils/keyHolderRanking.utils.ts b/src/utils/keyHolderRanking.utils.ts
new file mode 100644
index 0000000..ac95a84
--- /dev/null
+++ b/src/utils/keyHolderRanking.utils.ts
@@ -0,0 +1,40 @@
+export interface KeyHolder {
+ id: string;
+ displayName: string;
+ keyCount: number;
+}
+
+export interface RankedKeyHolder extends KeyHolder {
+ rank: number;
+ sharePercent: number;
+}
+
+/**
+ * Ranks holders descending by key count and computes each holder's share of
+ * the total supply held across the list.
+ *
+ * Ranks are competition-style ("1224"): holders tied on key count share the
+ * same rank, and the next distinct count resumes at its 1-indexed position
+ * rather than incrementing by 1 — e.g. two holders tied for 1st both get
+ * rank 1, and the following holder gets rank 3, not rank 2.
+ */
+export function rankKeyHolders(holders: KeyHolder[]): RankedKeyHolder[] {
+ const sorted = [...holders].sort((a, b) => b.keyCount - a.keyCount);
+ const totalKeys = sorted.reduce((sum, holder) => sum + holder.keyCount, 0);
+
+ let currentRank = 0;
+ let previousKeyCount: number | null = null;
+
+ return sorted.map((holder, index) => {
+ if (holder.keyCount !== previousKeyCount) {
+ currentRank = index + 1;
+ previousKeyCount = holder.keyCount;
+ }
+
+ return {
+ ...holder,
+ rank: currentRank,
+ sharePercent: totalKeys > 0 ? (holder.keyCount / totalKeys) * 100 : 0,
+ };
+ });
+}