Skip to content
Open
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
175 changes: 78 additions & 97 deletions frontend/src/components/home/ActivityFeed.tsx
Original file line number Diff line number Diff line change
@@ -1,112 +1,93 @@
import React, { useState, useEffect } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { slideInRight } from '../../lib/animations';
import { timeAgo } from '../../lib/utils';
import { motion } from 'framer-motion';
import { GitPullRequest, DollarSign, Star, Zap } from 'lucide-react';

interface ActivityEvent {
id: string;
type: 'completed' | 'submitted' | 'posted' | 'review';
username: string;
avatar_url?: string | null;
detail: string;
type: 'bounty_created' | 'bounty_funded' | 'pr_submitted' | 'bounty_completed';
user: string;
bounty_title: string;
amount?: string;
timestamp: string;
}

// Mock events for when API doesn't return activity
const MOCK_EVENTS: ActivityEvent[] = [
{
id: '1',
type: 'completed',
username: 'devbuilder',
detail: '$500 USDC from Bounty #42',
timestamp: new Date(Date.now() - 3 * 60 * 1000).toISOString(),
},
{
id: '2',
type: 'submitted',
username: 'KodeSage',
detail: 'PR to Bounty #38',
timestamp: new Date(Date.now() - 15 * 60 * 1000).toISOString(),
},
{
id: '3',
type: 'posted',
username: 'SolanaLabs',
detail: 'Bounty #145 — $3,500 USDC',
timestamp: new Date(Date.now() - 45 * 60 * 1000).toISOString(),
},
{
id: '4',
type: 'review',
username: 'AI Review',
detail: 'Bounty #42 — 8.5/10',
timestamp: new Date(Date.now() - 2 * 60 * 60 * 1000).toISOString(),
},
];
const EVENT_CONFIG = {
bounty_created: { icon: Zap, color: 'text-purple', label: 'created' },
bounty_funded: { icon: DollarSign, color: 'text-emerald', label: 'funded' },
pr_submitted: { icon: GitPullRequest, color: 'text-status-info', label: 'submitted PR for' },
bounty_completed: { icon: Star, color: 'text-magenta', label: 'completed' },
};

function getActionText(type: ActivityEvent['type']) {
switch (type) {
case 'completed': return 'earned';
case 'submitted': return 'submitted';
case 'posted': return 'posted';
case 'review': return 'AI Review passed for';
default: return 'updated';
}
}

function EventItem({ event }: { event: ActivityEvent }) {
const isMagenta = event.type === 'review';
return (
<div className="flex items-center gap-3 py-2 px-3 rounded-lg hover:bg-forge-850 transition-colors duration-150">
{event.avatar_url ? (
<img src={event.avatar_url} className="w-6 h-6 rounded-full flex-shrink-0" alt="" />
) : (
<div className="w-6 h-6 rounded-full bg-forge-700 flex-shrink-0 flex items-center justify-center">
<span className="font-mono text-xs text-text-muted">{event.username[0]?.toUpperCase()}</span>
</div>
)}
<p className="text-sm text-text-secondary flex-1 truncate">
<span className="font-medium text-text-primary">{event.username}</span>
{' '}{getActionText(event.type)}{' '}
<span className={`font-mono ${isMagenta ? 'text-magenta' : 'text-emerald'}`}>{event.detail}</span>
</p>
<span className="font-mono text-xs text-text-muted flex-shrink-0">{timeAgo(event.timestamp)}</span>
</div>
);
function timeAgo(dateStr: string): string {
const diff = Date.now() - new Date(dateStr).getTime();
const mins = Math.floor(diff / 60000);
if (mins < 60) return mins + 'm ago';
const hrs = Math.floor(mins / 60);
if (hrs < 24) return hrs + 'h ago';
return Math.floor(hrs / 24) + 'd ago';
}

export function ActivityFeed({ events }: { events?: ActivityEvent[] }) {
const displayEvents = events?.length ? events.slice(0, 4) : MOCK_EVENTS;
const [visibleEvents, setVisibleEvents] = useState<ActivityEvent[]>(displayEvents.slice(0, 4));
export function ActivityFeed() {
const [events, setEvents] = useState<ActivityEvent[]>([]);
const [loading, setLoading] = useState(true);

useEffect(() => {
setVisibleEvents(displayEvents.slice(0, 4));
}, [events]);
async function fetchEvents() {
try {
const res = await fetch('/api/activity');
if (res.ok) {
const data = await res.json();
setEvents(data.events || []);
}
} catch {
// Use mock data for demo
setEvents([
{ id: '1', type: 'bounty_created', user: 'alice', bounty_title: 'Build DAO Governance Module', timestamp: new Date(Date.now() - 300000).toISOString() },
{ id: '2', type: 'bounty_funded', user: 'bob', bounty_title: 'Add NFT Staking Contract', amount: '500 FNDRY', timestamp: new Date(Date.now() - 1800000).toISOString() },
{ id: '3', type: 'pr_submitted', user: 'charlie', bounty_title: 'Merkle Tree Implementation', timestamp: new Date(Date.now() - 3600000).toISOString() },
{ id: '4', type: 'bounty_completed', user: 'dave', bounty_title: 'Token Vesting Schedule', amount: '1000 FNDRY', timestamp: new Date(Date.now() - 7200000).toISOString() },
]);
} finally {
setLoading(false);
}
}
fetchEvents();
}, []);

return (
<section className="w-full border-y border-border bg-forge-900/50 py-4 overflow-hidden">
<div className="max-w-7xl mx-auto px-4">
<div className="flex items-center gap-3 mb-3">
<span className="w-2 h-2 rounded-full bg-emerald animate-pulse-glow" />
<span className="font-mono text-xs text-text-muted uppercase tracking-wider">Recent Activity</span>
</div>
<div className="space-y-1">
<AnimatePresence mode="popLayout">
{visibleEvents.map((event) => (
<motion.div
key={event.id}
variants={slideInRight}
initial="initial"
animate="animate"
exit={{ opacity: 0, x: -20, transition: { duration: 0.2 } }}
layout
>
<EventItem event={event} />
</motion.div>
))}
</AnimatePresence>
</div>
if (loading) {
return (
<div className="space-y-3">
{Array.from({ length: 4 }).map((_, i) => (
<div key={i} className="h-12 bg-forge-800 rounded-lg animate-pulse" />
))}
</div>
</section>
);
}

return (
<div className="space-y-2">
{events.map((event, idx) => {
const config = EVENT_CONFIG[event.type];
const Icon = config.icon;
return (
<motion.div
key={event.id}
initial={{ opacity: 0, x: -10 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: idx * 0.05 }}
className="flex items-center gap-3 py-2 px-3 rounded-lg bg-forge-900/50 hover:bg-forge-800/50 transition-colors"
>
<Icon className={'w-4 h-4 flex-shrink-0 ' + config.color} />
<div className="flex-1 min-w-0 text-sm">
<span className="text-text-primary font-medium">@{event.user}</span>
<span className="text-text-muted mx-1">{config.label}</span>
<span className="text-text-secondary truncate">{event.bounty_title}</span>
{event.amount && <span className="ml-2 text-emerald font-mono text-xs">{event.amount}</span>}
</div>
<span className="text-xs text-text-muted flex-shrink-0">{timeAgo(event.timestamp)}</span>
</motion.div>
);
})}
</div>
);
}
}
Loading