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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
107 changes: 107 additions & 0 deletions src/components/common/CreatorActivityFeed.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div data-testid="creator-activity-feed-skeleton" className="space-y-2">
{Array.from({ length: SKELETON_ROW_COUNT }).map((_, index) => (
<div
key={index}
className="flex items-center gap-4 rounded-xl border border-white/10 bg-white/[0.02] p-4"
>
<div className="size-10 shrink-0 rounded-full bg-white/12 skeleton-shimmer" />
<div className="flex-1 space-y-2">
<div className="h-4 w-32 rounded bg-white/12 skeleton-shimmer" />
<div className="h-3 w-48 rounded bg-white/12 skeleton-shimmer" />
</div>
</div>
))}
</div>
);
}

/**
* 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<CreatorActivityFeedProps> = ({ creatorId }) => {
const { trades, isLoading } = useCreatorActivityFeed(creatorId, id =>
creatorActivityService.getCreatorActivity(id)
);

if (isLoading) {
return <ActivityFeedSkeletonRows />;
}

if (trades.length === 0) {
return (
<div
data-testid="creator-activity-feed-empty-state"
className="flex flex-col items-center gap-3 rounded-2xl border border-white/10 bg-white/[0.02] py-12 text-center"
>
<div className="flex size-12 items-center justify-center rounded-full border border-white/10 bg-white/5 text-white/40">
<History className="size-6" aria-hidden="true" />
</div>
<p className="text-sm text-white/50">
No activity yet — buy or sell keys to get started
</p>
</div>
);
}

return (
<div data-testid="creator-activity-feed" className="space-y-2">
{trades.map(trade => (
<div
key={trade.id}
data-testid={`creator-activity-item-${trade.id}`}
className="flex items-center gap-4 rounded-xl border border-white/10 bg-white/[0.02] p-4"
>
<div className="flex size-10 shrink-0 items-center justify-center rounded-full bg-white/5">
{trade.type === 'buy' ? (
<ArrowUpRight className="size-4 text-emerald-400" />
) : (
<ArrowDownRight className="size-4 text-rose-400" />
)}
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="font-semibold text-white">
{trade.type === 'buy' ? 'Buy' : 'Sell'}
</span>
<span className="text-white/40">•</span>
<span className="text-white/90">
{formatCreatorHandle(trade.traderHandle)}
</span>
</div>
<div className="mt-1 flex items-center gap-3 text-xs text-white/50">
<span>{trade.amount} keys</span>
<span className="text-white/30">•</span>
<span>{trade.price} XLM</span>
<span className="text-white/30">•</span>
<span>{formatRelativeTime(trade.timestamp)}</span>
</div>
</div>
</div>
))}
</div>
);
};

export default CreatorActivityFeed;
64 changes: 59 additions & 5 deletions src/components/common/CreatorMarketplaceInfiniteList.tsx
Original file line number Diff line number Diff line change
@@ -1,19 +1,29 @@
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';

export interface CreatorMarketplaceInfiniteListProps {
params?: Omit<GetCoursesParams, 'page'>;
}

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,
Expand All @@ -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<HTMLDivElement>({
enabled: !isLoadingFirstPage && !isFetchingNextPage,
hasMore: Boolean(hasMore),
Expand Down Expand Up @@ -55,11 +93,27 @@ export default function CreatorMarketplaceInfiniteList({
Refreshing…
</div>
)}
<div className="grid w-full grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3">
{creators.map(creator => (
<CreatorCard key={creator.id} creator={creator} />
))}
</div>
<SearchBar
value={searchQuery}
onChange={setSearchQuery}
placeholder="Search creators"
className="mb-6"
/>

{trimmedQuery && filteredCreators.length === 0 ? (
<p
data-testid="creator-marketplace-search-empty-state"
className="py-12 text-center text-sm text-white/50"
>
No results for &quot;{debouncedQuery.trim()}&quot;
</p>
) : (
<div className="grid w-full grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3">
{filteredCreators.map(creator => (
<CreatorCard key={creator.id} creator={creator} />
))}
</div>
)}

{isFetchingNextPage && (
<div data-testid="creator-marketplace-next-page-skeleton" className="mt-6">
Expand Down
60 changes: 60 additions & 0 deletions src/components/common/KeyHolderList.tsx
Original file line number Diff line number Diff line change
@@ -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<KeyHolderListProps> = ({ holders }) => {
const ranked = rankKeyHolders(holders);

if (ranked.length === 0) {
return (
<p className="py-8 text-center text-sm text-white/50" data-testid="key-holder-list-empty">
No holders yet.
</p>
);
}

return (
<ol className="divide-y divide-white/5" data-testid="key-holder-list">
{ranked.map(holder => (
<li
key={holder.id}
className="flex items-center justify-between gap-3 py-3"
data-testid="key-holder-row"
>
<div className="flex items-center gap-3">
<span
className="inline-flex size-7 shrink-0 items-center justify-center rounded-full border border-white/10 bg-white/[0.06] text-xs font-bold text-white/70"
data-testid="key-holder-rank"
aria-label={`Rank ${holder.rank}`}
>
{holder.rank}
</span>
<span className="text-sm font-medium text-white">{holder.displayName}</span>
</div>
<div className="flex items-center gap-3 text-right">
<span className="text-sm text-white/70" data-testid="key-holder-key-count">
{formatHolderCount(holder.keyCount)} keys
</span>
<span
className="w-16 shrink-0 text-sm font-semibold text-amber-300/90 tabular-nums"
data-testid="key-holder-share"
>
{formatPercent(holder.sharePercent, { maximumFractionDigits: 1 })}
</span>
</div>
</li>
))}
</ol>
);
};

export default KeyHolderList;
13 changes: 13 additions & 0 deletions src/components/common/TradeDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -49,9 +49,18 @@ const TradeDialog: React.FC<TradeDialogProps> = ({
const [touched, setTouched] = useState(false);
const amountInputRef = useRef<HTMLInputElement | null>(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 <DialogTrigger>. 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<HTMLElement | null>(null);

useEffect(() => {
if (open) {
triggerElementRef.current = document.activeElement as HTMLElement | null;
setAmountText('1');
setTouched(false);
pricePreviewFailureLogged.current = false;
Expand Down Expand Up @@ -147,6 +156,10 @@ const TradeDialog: React.FC<TradeDialogProps> = ({
event.preventDefault();
amountInputRef.current?.focus();
}}
onCloseAutoFocus={event => {
event.preventDefault();
triggerElementRef.current?.focus();
}}
onEscapeKeyDown={event => {
if (isSubmitting) event.preventDefault();
}}
Expand Down
Loading
Loading