diff --git a/frontend/package.json b/frontend/package.json index f3f83792..a4812f59 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -13,6 +13,7 @@ "autoprefixer": "^10.4.27", "framer-motion": "^12.38.0", "lucide-react": "^1.7.0", + "socket.io-client": "^4.7.4", "postcss": "^8.5.8", "react": "^18.3.1", "react-dom": "^18.3.1", diff --git a/frontend/src/components/activity/ActivityFeed.tsx b/frontend/src/components/activity/ActivityFeed.tsx new file mode 100644 index 00000000..a9fa6d9e --- /dev/null +++ b/frontend/src/components/activity/ActivityFeed.tsx @@ -0,0 +1,229 @@ +import { useState, useEffect, useCallback, useRef } from 'react'; +import { activityFeed, Activity, ActivityType } from './activityFeedService'; +import { + Zap, GitPullRequest, GitMerge, CheckCircle2, + XCircle, Trophy, Bell, Filter, RefreshCw, Wifi, WifiOff +} from 'lucide-react'; + +interface ActivityFeedProps { + initialActivities?: Activity[]; + maxVisible?: number; + showFilters?: boolean; + className?: string; +} + +const ACTIVITY_CONFIG: Record = { + bounty_created: { icon: Zap, color: 'text-yellow-400', label: 'New Bounty' }, + bounty_submitted: { icon: GitPullRequest, color: 'text-blue-400', label: 'Submission' }, + bounty_merged: { icon: GitMerge, color: 'text-green-400', label: 'Merged' }, + review_completed: { icon: CheckCircle2, color: 'text-purple-400', label: 'Review' }, + leaderboard_changed: { icon: Trophy, color: 'text-amber-400', label: 'Leaderboard' }, + submission_approved: { icon: CheckCircle2, color: 'text-emerald-400', label: 'Approved' }, + submission_rejected: { icon: XCircle, color: 'text-red-400', label: 'Rejected' }, +}; + +function formatTimeAgo(timestamp: number): string { + const seconds = Math.floor((Date.now() - timestamp) / 1000); + if (seconds < 60) return `${seconds}s ago`; + const minutes = Math.floor(seconds / 60); + if (minutes < 60) return `${minutes}m ago`; + const hours = Math.floor(minutes / 60); + if (hours < 24) return `${hours}h ago`; + const days = Math.floor(hours / 24); + return `${days}d ago`; +} + +function ActivityFeedItem({ activity }: { activity: Activity }) { + const config = ACTIVITY_CONFIG[activity.type] || ACTIVITY_CONFIG.bounty_created; + const Icon = config.icon; + + return ( +
+
+ +
+
+
+ + {config.label} + + {activity.bountyId && ( + + {activity.bountyTitle || `#${activity.bountyId}`} + + )} +
+

+ {activity.description} +

+
+ + {activity.username && by {activity.username}} + + โ€ข + {formatTimeAgo(activity.timestamp)} + {activity.amount && activity.token && ( + <> + โ€ข + + {activity.amount} {activity.token} + + + )} +
+
+
+ ); +} + +export default function ActivityFeed({ + initialActivities = [], + maxVisible = 50, + showFilters = true, + className = '' +}: ActivityFeedProps) { + const [activities, setActivities] = useState(initialActivities); + const [connectionState, setConnectionState] = useState(activityFeed.getConnectionState()); + const [filter, setFilter] = useState([]); + const [showFilterMenu, setShowFilterMenu] = useState(false); + const [isFilterActive, setIsFilterActive] = useState(false); + const unsubscribeRef = useRef<(() => void) | null>(null); + + useEffect(() => { + activityFeed.connect(); + + const unsubscribe = activityFeed.subscribe((activity: Activity) => { + setActivities(prev => { + const exists = prev.some(a => a.id === activity.id); + if (exists) return prev; + return [activity, ...prev].slice(0, maxVisible); + }); + }); + unsubscribeRef.current = unsubscribe; + + // Poll connection state + const stateInterval = setInterval(() => { + setConnectionState(activityFeed.getConnectionState()); + }, 2000); + + return () => { + unsubscribe(); + clearInterval(stateInterval); + }; + }, [maxVisible]); + + const handleFilterToggle = useCallback((type: ActivityType) => { + setFilter(prev => { + const newFilter = prev.includes(type) + ? prev.filter(t => t !== type) + : [...prev, type]; + setIsFilterActive(newFilter.length > 0); + activityFeed.setFilters({ types: newFilter.length > 0 ? newFilter : undefined }); + return newFilter; + }); + }, []); + + const handleReconnect = useCallback(() => { + activityFeed.disconnect(); + setActivities([]); + activityFeed.connect(); + }, []); + + const filteredActivities = filter.length > 0 + ? activities.filter(a => filter.includes(a.type)) + : activities; + + const ConnectionIndicator = () => { + switch (connectionState) { + case 'connected': + return ; + case 'fallback': + return ; + default: + return ; + } + }; + + return ( +
+ {/* Header */} +
+
+ +

Activity Feed

+ +
+
+ {connectionState !== 'connected' && ( + + )} + {showFilters && ( +
+ + {showFilterMenu && ( +
+ {(Object.keys(ACTIVITY_CONFIG) as ActivityType[]).map(type => { + const config = ACTIVITY_CONFIG[type]; + return ( + + ); + })} +
+ )} +
+ )} +
+
+ + {/* Activity List */} +
+ {filteredActivities.length === 0 ? ( +
+ +

No activities yet

+

+ {connectionState === 'connected' + ? 'Waiting for live updates...' + : connectionState === 'fallback' + ? 'Connected via polling fallback' + : 'Reconnecting...'} +

+
+ ) : ( +
+ {filteredActivities.map(activity => ( + + ))} +
+ )} +
+
+ ); +} diff --git a/frontend/src/components/auth/OnboardingWizard.tsx b/frontend/src/components/auth/OnboardingWizard.tsx new file mode 100644 index 00000000..6939fb35 --- /dev/null +++ b/frontend/src/components/auth/OnboardingWizard.tsx @@ -0,0 +1,312 @@ +import React, { useState } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { motion, AnimatePresence } from 'framer-motion'; +import { useAuth } from '../../hooks/useAuth'; +import { fadeIn } from '../../lib/animations'; + +const SKILL_OPTIONS = [ + { id: 'react', label: 'React / Next.js' }, + { id: 'vue', label: 'Vue / Nuxt' }, + { id: 'svelte', label: 'Svelte' }, + { id: 'node', label: 'Node.js / Express' }, + { id: 'python', label: 'Python / FastAPI' }, + { id: 'rust', label: 'Rust' }, + { id: 'solidity', label: 'Solidity / EVM' }, + { id: 'solana', label: 'Solana / Anchor' }, + { id: 'ai-ml', label: 'AI / ML' }, + { id: 'devops', label: 'DevOps / Cloud' }, + { id: 'security', label: 'Security / Audit' }, + { id: 'docs', label: 'Technical Writing' }, +]; + +const LANG_OPTIONS = [ + { id: 'typescript', label: 'TypeScript' }, + { id: 'python', label: 'Python' }, + { id: 'rust', label: 'Rust' }, + { id: 'go', label: 'Go' }, + { id: 'solidity', label: 'Solidity' }, +]; + +const STEPS = [ + { id: 'profile', title: 'Profile', icon: '๐Ÿ‘ค' }, + { id: 'skills', title: 'Skills', icon: '๐Ÿ› ๏ธ' }, + { id: 'wallet', title: 'Wallet', icon: '๐Ÿ’œ' }, + { id: 'done', title: 'Done!', icon: '๐ŸŽ‰' }, +]; + +export function OnboardingWizard() { + const { user, updateUser } = useAuth(); + const navigate = useNavigate(); + const [step, setStep] = useState(0); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + // Step data + const [username, setUsername] = useState(user?.username ?? ''); + const [bio, setBio] = useState(''); + const [selectedSkills, setSelectedSkills] = useState([]); + const [selectedLangs, setSelectedLangs] = useState(['typescript']); + const [walletAddr, setWalletAddr] = useState(user?.wallet_address ?? ''); + const [walletVerified, setWalletVerified] = useState(user?.wallet_verified ?? false); + + const isLastStep = step === STEPS.length - 2; + const isDone = step === STEPS.length - 1; + + const toggleSkill = (id: string) => { + setSelectedSkills(prev => + prev.includes(id) ? prev.filter(s => s !== id) : [...prev, id] + ); + }; + + const toggleLang = (id: string) => { + setSelectedLangs(prev => + prev.includes(id) ? prev.filter(l => l !== id) : [...prev, id] + ); + }; + + const next = () => setStep(s => Math.min(s + 1, STEPS.length - 1)); + const back = () => setStep(s => Math.max(s - 1, 0)); + + const handleFinish = async () => { + setLoading(true); + setError(null); + try { + // Update user profile with all onboarding data + updateUser({ + username: username || user?.username ?? '', + wallet_address: walletAddr || undefined, + wallet_verified: walletVerified, + }); + // In a real app, would POST to /api/contributors/me with skills/langs + next(); + } catch(e: any) { + setError(e.message ?? 'Something went wrong'); + } finally { + setLoading(false); + } + }; + + const skipToHome = () => navigate('/', { replace: true }); + + const stepVariants = { + enter: (dir: number) => ({ x: dir > 0 ? 60 : -60, opacity: 0 }), + center: { x: 0, opacity: 1 }, + exit: (dir: number) => ({ x: dir < 0 ? 60 : -60, opacity: 0 }), + }; + + return ( +
+
+ {/* Header */} +
+

Welcome to SolFoundry

+

Complete your contributor profile to start earning

+
+ + {/* Progress Steps */} +
+ {STEPS.map((s, i) => ( + +
+
+ {i < step ? 'โœ“' : s.icon} +
+ {s.title} +
+ {i < STEPS.length - 1 && ( +
+ )} + + ))} +
+ + {/* Step Card */} +
+ + + {/* Step 0: Profile */} + {step === 0 && ( +
+

Set up your profile

+
+
+ + setUsername(e.target.value)} + placeholder={user?.username ?? 'your_username'} + className="w-full bg-gray-800 border border-gray-700 rounded-lg px-4 py-2.5 text-white placeholder-gray-500 focus:outline-none focus:border-purple-500" + /> +
+
+ +