diff --git a/src/components/home/ActivityFeed.tsx b/src/components/home/ActivityFeed.tsx new file mode 100644 index 00000000..c540c65d --- /dev/null +++ b/src/components/home/ActivityFeed.tsx @@ -0,0 +1,188 @@ +import { useEffect, useRef } from 'react'; +import { Link } from 'react-router'; +import { useRecentActivity } from '@/hooks/useRecentActivity'; +import { formatRelativeTime } from '@/utils/time.utils'; +import { cn } from '@/lib/utils'; + +/* ------------------------------------------------------------------ */ +/* Skeleton */ +/* ------------------------------------------------------------------ */ + +const skeletonBlockClass = + 'rounded-md bg-white/12 skeleton-shimmer motion-reduce:bg-white/18 motion-reduce:ring-1 motion-reduce:ring-white/15'; + +function ActivitySkeletonRow() { + return ( +
+
+
+
+
+
+
+
+
+ ); +} + +function ActivitySkeletonList() { + return ( +
+ Loading recent activity + {Array.from({ length: 3 }).map((_, i) => ( + + ))} +
+ ); +} + +/* ------------------------------------------------------------------ */ +/* Row */ +/* ------------------------------------------------------------------ */ + +interface ActivityRowProps { + walletAddress: string; + type: 'buy' | 'sell'; + creatorName: string; + creatorId: string; + quantity: number; + timestamp: number; +} + +function ActivityRow({ + walletAddress, + type, + creatorName, + creatorId, + quantity, + timestamp, +}: ActivityRowProps) { + const truncatedWallet = `${walletAddress.slice(0, 6)}...${walletAddress.slice(-4)}`; + const actionLabel = type === 'buy' ? 'Bought' : 'Sold'; + const actionColor = + type === 'buy' + ? 'text-emerald-600' + : 'text-red-500'; + const timeAgo = formatRelativeTime(timestamp); + + return ( +
+ {/* Wallet */} + + {truncatedWallet} + + + {/* Action */} + + {actionLabel} + + + {/* Creator Name */} + + {creatorName} + + + {/* Quantity */} + + {quantity} + + + {/* Time */} + + {timeAgo} + +
+ ); +} + +/* ------------------------------------------------------------------ */ +/* Section */ +/* ------------------------------------------------------------------ */ + +export default function ActivityFeed() { + const headingRef = useRef(null); + const listRef = useRef(null); + + const { trades, isLoading } = useRecentActivity(); + + useEffect(() => { + const observer = new IntersectionObserver( + entries => { + entries.forEach(entry => { + if (entry.isIntersecting) { + entry.target.classList.add('is-visible'); + observer.unobserve(entry.target); + } + }); + }, + { threshold: 0.1 } + ); + + if (headingRef.current) observer.observe(headingRef.current); + if (listRef.current) observer.observe(listRef.current); + + return () => observer.disconnect(); + }, []); + + if (!isLoading && (!trades || trades.length === 0)) { + return null; + } + + return ( +
+
+ {/* Header */} +
+
+ + + Live Activity + +
+ +

+ + Recent marketplace + +
+ + activity across all creators. + +

+
+ + {/* List or Skeleton */} + {isLoading ? ( +
+ +
+ ) : ( +
+ {trades?.map(trade => ( + + ))} +
+ )} +
+
+ ); +} diff --git a/src/components/marketplace/TrendingCreatorsSection.tsx b/src/components/marketplace/TrendingCreatorsSection.tsx new file mode 100644 index 00000000..a19c5149 --- /dev/null +++ b/src/components/marketplace/TrendingCreatorsSection.tsx @@ -0,0 +1,158 @@ +import { Link } from 'react-router'; +import { useTrendingCreators } from '@/hooks/useTrendingCreators'; +import { normalizeCreatorDisplayName } from '@/utils/creatorDisplayName.utils'; +import { formatNumber } from '@/utils/numberFormat.utils'; +import CreatorInitialsAvatar from '@/components/common/CreatorInitialsAvatar'; +import Change24hBadge from '@/components/common/Change24hBadge'; +import { cn } from '@/lib/utils'; +import MarketplaceSection from '@/components/common/MarketplaceSection'; +import SectionDivider from '@/components/common/SectionDivider'; + +/* ------------------------------------------------------------------ */ +/* Skeleton */ +/* ------------------------------------------------------------------ */ + +const skeletonBlockClass = + 'rounded-md bg-white/12 skeleton-shimmer motion-reduce:bg-white/18 motion-reduce:ring-1 motion-reduce:ring-white/15'; + +function TrendingSkeletonRow() { + return ( +
+
+
+
+
+
+
+
+
+ ); +} + +function TrendingSkeletonList() { + return ( +
+ Loading trending creators + {Array.from({ length: 5 }).map((_, i) => ( + + ))} +
+ ); +} + +/* ------------------------------------------------------------------ */ +/* Row */ +/* ------------------------------------------------------------------ */ + +interface TrendingRowProps { + rank: number; + name: string; + thumbnail?: string; + creatorId: string; + volume: number; + change24h: number; +} + +function TrendingRow({ + rank, + name, + thumbnail, + creatorId, + volume, + change24h, +}: TrendingRowProps) { + return ( + + {/* Rank */} + + #{rank} + + + {/* Avatar */} +
+ +
+ + {/* Name */} + + {name} + + + {/* 24h Volume */} + + {formatNumber(volume, { style: 'compact' })} XLM + + + {/* 24h Change Badge */} +
+ +
+ + ); +} + +/* ------------------------------------------------------------------ */ +/* Section */ +/* ------------------------------------------------------------------ */ + +export default function TrendingCreatorsSection() { + const { creators, isLoading } = useTrendingCreators(); + + if (!isLoading && (!creators || creators.length === 0)) { + return null; + } + + return ( + <> + + +
+

+ 24-hour momentum +

+

+ Discover creators with the highest buy volume in the last 24 + hours — early signals for momentum and opportunity. +

+
+ + + {isLoading ? ( +
+ +
+ ) : ( +
+ {creators?.map((creator, index) => ( + + ))} +
+ )} +
+ + ); +} diff --git a/src/hooks/useRecentActivity.ts b/src/hooks/useRecentActivity.ts new file mode 100644 index 00000000..6c8a3345 --- /dev/null +++ b/src/hooks/useRecentActivity.ts @@ -0,0 +1,33 @@ +import { useQuery } from '@tanstack/react-query'; +import { globalActivityService, type GlobalActivityTrade } from '@/services/globalActivity.service'; +import { queryKeys } from '@/lib/queryKeys'; + +export const RECENT_ACTIVITY_POLL_INTERVAL_MS = 60_000; // 60 seconds +export const RECENT_ACTIVITY_DISPLAY_LIMIT = 5; + +export interface UseRecentActivityResult { + trades: GlobalActivityTrade[]; + isLoading: boolean; + isError: boolean; + error: Error | null; + refetch: () => void; +} + +export function useRecentActivity( + limit = RECENT_ACTIVITY_DISPLAY_LIMIT +): UseRecentActivityResult { + const { data, isLoading, isError, error, refetch } = useQuery({ + queryKey: queryKeys.activity.recent(limit), + queryFn: () => globalActivityService.getRecentActivity(limit), + refetchInterval: RECENT_ACTIVITY_POLL_INTERVAL_MS, + staleTime: 30_000, // 30 seconds + }); + + return { + trades: data ?? [], + isLoading, + isError, + error: error instanceof Error ? error : null, + refetch, + }; +} diff --git a/src/hooks/useTrendingCreators.ts b/src/hooks/useTrendingCreators.ts new file mode 100644 index 00000000..f60edf6c --- /dev/null +++ b/src/hooks/useTrendingCreators.ts @@ -0,0 +1,33 @@ +import { useQuery } from '@tanstack/react-query'; +import { leaderboardService } from '@/services/leaderboard.service'; +import { queryKeys } from '@/lib/queryKeys'; + +/** Refresh trending creators every 5 minutes. */ +const TRENDING_CREATORS_REFETCH_INTERVAL_MS = 5 * 60 * 1_000; +/** Stale after 4 minutes 55 seconds so a background refetch is triggered before the 5-minute refresh. */ +const TRENDING_CREATORS_STALE_TIME_MS = (5 * 60 - 5) * 1_000; +/** Display only the top 5 trending creators. */ +const TRENDING_CREATORS_LIMIT = 5; + +export interface UseTrendingCreatorsResult { + creators: ReturnType extends Promise ? T : never; + isLoading: boolean; + isError: boolean; + error: Error | null; +} + +export function useTrendingCreators() { + const { data, isLoading, isError, error } = useQuery({ + queryKey: queryKeys.leaderboard.volume('24h'), + queryFn: () => leaderboardService.getVolumeLeaderboard('24h'), + staleTime: TRENDING_CREATORS_STALE_TIME_MS, + refetchInterval: TRENDING_CREATORS_REFETCH_INTERVAL_MS, + }); + + return { + creators: (data ?? []).slice(0, TRENDING_CREATORS_LIMIT), + isLoading, + isError, + error: error instanceof Error ? error : null, + }; +} diff --git a/src/lib/queryKeys.ts b/src/lib/queryKeys.ts index 409b7fb0..16fac302 100644 --- a/src/lib/queryKeys.ts +++ b/src/lib/queryKeys.ts @@ -105,6 +105,10 @@ export const queryKeys = { ['leaderboard', 'volume', window] as const, ratings: () => ['leaderboard', 'ratings'] as const, }, + activity: { + all: () => ['activity'] as const, + recent: (limit = 5) => ['activity', 'recent', limit] as const, + }, admin: { treasury: () => ['admin', 'treasury', 'balance'] as const, treasuryDistributions: () => diff --git a/src/pages/MarketplacePage.tsx b/src/pages/MarketplacePage.tsx index 4bb6f319..f64e9357 100644 --- a/src/pages/MarketplacePage.tsx +++ b/src/pages/MarketplacePage.tsx @@ -15,6 +15,7 @@ import MarketplaceSection from '@/components/common/MarketplaceSection'; import SearchBar from '@/components/common/SearchBar'; import SectionDivider from '@/components/common/SectionDivider'; import StickyFilterBar from '@/components/common/StickyFilterBar'; +import TrendingCreatorsSection from '@/components/marketplace/TrendingCreatorsSection'; import { Button } from '@/components/ui/button'; import SectionErrorBoundary from '@/components/common/SectionErrorBoundary'; import { @@ -213,6 +214,10 @@ export default function MarketplacePage() { id="marketplace-listing-main" aria-label="Creator key marketplace listing" > + + + + { + try { + const response = await this.api.get>( + '/activity/recent', + { params: { limit } } + ); + return response.data.data; + } catch (error) { + throw this.handleError(error); + } + } +} + +export const globalActivityService = new GlobalActivityService();