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
188 changes: 188 additions & 0 deletions src/components/home/ActivityFeed.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="flex items-center gap-3 border-b border-gray-100 px-4 py-3 last:border-b-0">
<div className={cn('h-4 w-24', skeletonBlockClass)} />
<div className={cn('h-4 w-12', skeletonBlockClass)} />
<div className="min-w-0 flex-1">
<div className={cn('h-4 w-32', skeletonBlockClass)} />
</div>
<div className={cn('h-4 w-16', skeletonBlockClass)} />
<div className={cn('h-4 w-20', skeletonBlockClass)} />
</div>
);
}

function ActivitySkeletonList() {
return (
<div
role="status"
aria-label="Loading recent activity"
className="divide-y divide-gray-100"
>
<span className="sr-only">Loading recent activity</span>
{Array.from({ length: 3 }).map((_, i) => (
<ActivitySkeletonRow key={i} />
))}
</div>
);
}

/* ------------------------------------------------------------------ */
/* 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 (
<div className="flex items-center gap-3 border-b border-gray-100 px-4 py-3 last:border-b-0">
{/* Wallet */}
<span className="shrink-0 font-mono text-xs text-gray-500">
{truncatedWallet}
</span>

{/* Action */}
<span className={cn('shrink-0 font-mono text-xs font-medium', actionColor)}>
{actionLabel}
</span>

{/* Creator Name */}
<Link
to={`/creator/${creatorId}`}
className="min-w-0 flex-1 truncate font-jakarta text-sm font-medium text-blue-600 transition-colors hover:text-blue-700"
>
{creatorName}
</Link>

{/* Quantity */}
<span className="shrink-0 font-mono text-xs tabular-nums text-gray-600">
{quantity}
</span>

{/* Time */}
<span className="shrink-0 font-mono text-xs text-gray-400">
{timeAgo}
</span>
</div>
);
}

/* ------------------------------------------------------------------ */
/* Section */
/* ------------------------------------------------------------------ */

export default function ActivityFeed() {
const headingRef = useRef<HTMLDivElement>(null);
const listRef = useRef<HTMLDivElement>(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 (
<section className="bg-white px-6 py-20">
<div className="mx-auto max-w-5xl">
{/* Header */}
<div ref={headingRef} className="scroll-reveal">
<div className="flex items-center gap-2">
<span className="size-1.5 rounded-full bg-blue-400 animate-pulse" />
<span className="font-jakarta text-sm font-medium text-blue-600">
Live Activity
</span>
</div>

<h2 className="mt-3 font-pt-serif text-[clamp(1.6rem,3.5vw,2.4rem)] font-normal leading-[1.15]">
<span className="text-gray-900">
Recent marketplace
</span>
<br />
<span className="text-gray-400">
activity across all creators.
</span>
</h2>
</div>

{/* List or Skeleton */}
{isLoading ? (
<div className="mt-8 overflow-hidden rounded-2xl border border-gray-100 bg-white">
<ActivitySkeletonList />
</div>
) : (
<div
ref={listRef}
className="scroll-reveal mt-8 overflow-hidden rounded-2xl border border-gray-100 bg-white"
style={{ animationDelay: '100ms' }}
>
{trades?.map(trade => (
<ActivityRow
key={trade.id}
walletAddress={trade.walletAddress}
type={trade.type}
creatorName={trade.creatorName}
creatorId={trade.creatorId}
quantity={trade.quantity}
timestamp={trade.timestamp}
/>
))}
</div>
)}
</div>
</section>
);
}
158 changes: 158 additions & 0 deletions src/components/marketplace/TrendingCreatorsSection.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="flex items-center gap-4 border-b border-white/10 px-4 py-4 last:border-b-0">
<div className={cn('h-5 w-6 shrink-0', skeletonBlockClass)} />
<div
className={cn('size-8 shrink-0 rounded-full', skeletonBlockClass)}
/>
<div className="min-w-0 flex-1 space-y-1.5">
<div className={cn('h-4 w-32', skeletonBlockClass)} />
</div>
<div className={cn('h-4 w-24', skeletonBlockClass)} />
<div className={cn('h-4 w-12', skeletonBlockClass)} />
</div>
);
}

function TrendingSkeletonList() {
return (
<div
role="status"
aria-label="Loading trending creators"
className="divide-y divide-white/10"
>
<span className="sr-only">Loading trending creators</span>
{Array.from({ length: 5 }).map((_, i) => (
<TrendingSkeletonRow key={i} />
))}
</div>
);
}

/* ------------------------------------------------------------------ */
/* 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 (
<Link
to={`/creator/${creatorId}`}
className="flex items-center gap-4 border-b border-white/10 px-4 py-4 transition-all hover:bg-white/5 last:border-b-0"
>
{/* Rank */}
<span className="w-6 shrink-0 text-right font-mono text-xs font-bold tabular-nums text-amber-400">
#{rank}
</span>

{/* Avatar */}
<div className="size-8 shrink-0 overflow-hidden rounded-full bg-white/10">
<CreatorInitialsAvatar
name={name}
creatorId={creatorId}
imageSrc={thumbnail}
/>
</div>

{/* Name */}
<span className="min-w-0 flex-1 truncate font-jakarta text-sm font-medium text-white">
{name}
</span>

{/* 24h Volume */}
<span className="hidden shrink-0 font-mono text-xs tabular-nums text-white/70 sm:block">
{formatNumber(volume, { style: 'compact' })} XLM
</span>

{/* 24h Change Badge */}
<div className="w-16 shrink-0">
<Change24hBadge change={change24h} />
</div>
</Link>
);
}

/* ------------------------------------------------------------------ */
/* Section */
/* ------------------------------------------------------------------ */

export default function TrendingCreatorsSection() {
const { creators, isLoading } = useTrendingCreators();

if (!isLoading && (!creators || creators.length === 0)) {
return null;
}

return (
<>
<SectionDivider title="Trending now" spacing="relaxed" />

<div className="marketplace-section space-y-3">
<h3 className="text-sm font-bold uppercase tracking-[0.2em] text-amber-400/80">
24-hour momentum
</h3>
<p className="max-w-2xl text-sm leading-relaxed text-white/55">
Discover creators with the highest buy volume in the last 24
hours — early signals for momentum and opportunity.
</p>
</div>

<MarketplaceSection spacing="compact">
{isLoading ? (
<div className="overflow-hidden rounded-2xl border border-white/10 bg-white/5 backdrop-blur-sm">
<TrendingSkeletonList />
</div>
) : (
<div className="overflow-hidden rounded-2xl border border-white/10 bg-white/5 backdrop-blur-sm">
{creators?.map((creator, index) => (
<TrendingRow
key={creator.id}
rank={index + 1}
name={
normalizeCreatorDisplayName(creator.title) ||
'Unnamed creator'
}
thumbnail={creator.thumbnail}
creatorId={creator.id}
volume={creator.totalVolume}
change24h={creator.change24h}
/>
))}
</div>
)}
</MarketplaceSection>
</>
);
}
33 changes: 33 additions & 0 deletions src/hooks/useRecentActivity.ts
Original file line number Diff line number Diff line change
@@ -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,
};
}
Loading
Loading