From b30603ba62d2ea810bea5a9ee3f6947352bf38c8 Mon Sep 17 00:00:00 2001 From: heymariam Date: Sat, 17 Oct 2026 06:58:34 +0100 Subject: [PATCH 1/2] feat: Add activity feed, trending creators, and marketplace features --- src/components/home/ActivityFeed.tsx | 188 ++++++++++++++++++ .../marketplace/TrendingCreatorsSection.tsx | 158 +++++++++++++++ src/hooks/useRecentActivity.ts | 33 +++ src/hooks/useTrendingCreators.ts | 33 +++ src/lib/queryKeys.ts | 18 +- src/pages/HomePage.tsx | 4 + src/pages/MarketplacePage.tsx | 5 + src/services/globalActivity.service.ts | 29 +++ 8 files changed, 461 insertions(+), 7 deletions(-) create mode 100644 src/components/home/ActivityFeed.tsx create mode 100644 src/components/marketplace/TrendingCreatorsSection.tsx create mode 100644 src/hooks/useRecentActivity.ts create mode 100644 src/hooks/useTrendingCreators.ts create mode 100644 src/services/globalActivity.service.ts 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 d7b8d133..daee75ac 100644 --- a/src/lib/queryKeys.ts +++ b/src/lib/queryKeys.ts @@ -75,10 +75,10 @@ export const queryKeys = { activity: (address: string) => ['wallet', address, 'activity'] as const, tradeHistory: (address: string) => ['wallet', address, 'tradeHistory'] as const, - vestingPositions: (address: string) => - ['wallet', address, 'vestingPositions'] as const, - stakingPositions: (address: string) => - ['wallet', address, 'stakingPositions'] as const, + vestingPositions: (address: string) => + ['wallet', address, 'vestingPositions'] as const, + stakingPositions: (address: string) => + ['wallet', address, 'stakingPositions'] as const, xlmBalance: (address: string) => ['wallet', address, 'xlmBalance'] as const, }, @@ -101,9 +101,14 @@ 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: () => ['admin', 'treasury', 'distributions'] as const, + treasuryDistributions: () => + ['admin', 'treasury', 'distributions'] as const, treasuryFees: () => ['admin', 'treasury', 'fees'] as const, oracleCallers: () => ['admin', 'oracle', 'callers'] as const, multiSigPending: () => ['admin', 'multisig', 'pending'] as const, @@ -147,8 +152,7 @@ export const queryKeys = { }, staking: { all: () => ['staking'] as const, - dashboard: (wallet: string) => - ['staking', wallet, 'dashboard'] as const, + dashboard: (wallet: string) => ['staking', wallet, 'dashboard'] as const, }, atomicSwap: { all: () => ['atomic-swap'] as const, diff --git a/src/pages/HomePage.tsx b/src/pages/HomePage.tsx index 11b0b11b..22b8c43a 100644 --- a/src/pages/HomePage.tsx +++ b/src/pages/HomePage.tsx @@ -7,6 +7,7 @@ import CreatorSpotlight from '../components/home/CreatorSpotlight'; import MarketOverview from '../components/home/MarketOverview'; import TrendingCreators from '../components/home/TrendingCreators'; import TrendingLeaderboard from '../components/home/TrendingLeaderboard'; +import ActivityFeed from '../components/home/ActivityFeed'; import RecentlyViewedSection from '../components/home/RecentlyViewedSection'; import { useNavigationTiming } from '../hooks/useNavigationTiming'; import { useDocumentTitle } from '../hooks/useDocumentTitle'; @@ -35,6 +36,9 @@ export default function HomePage() { + + + 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(); From 0623ff2b6e1a24a0f5f8c68aa369ab17ed142a2b Mon Sep 17 00:00:00 2001 From: heymariam Date: Sat, 17 Oct 2026 09:17:02 +0100 Subject: [PATCH 2/2] fix: remove unused ActivityFeed import from HomePage --- src/pages/HomePage.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/src/pages/HomePage.tsx b/src/pages/HomePage.tsx index 5e6eca2a..c50b052b 100644 --- a/src/pages/HomePage.tsx +++ b/src/pages/HomePage.tsx @@ -7,7 +7,6 @@ import CreatorSpotlight from '../components/home/CreatorSpotlight'; import MarketOverview from '../components/home/MarketOverview'; import TrendingCreators from '../components/home/TrendingCreators'; import TrendingLeaderboard from '../components/home/TrendingLeaderboard'; -import ActivityFeed from '../components/home/ActivityFeed'; import RecentlyViewedSection from '../components/home/RecentlyViewedSection'; import DiscoveryFeed from '../components/home/DiscoveryFeed'; import { useNavigationTiming } from '../hooks/useNavigationTiming';