diff --git a/frontend/src/__tests__/activity-api.test.ts b/frontend/src/__tests__/activity-api.test.ts new file mode 100644 index 000000000..bbd5af225 --- /dev/null +++ b/frontend/src/__tests__/activity-api.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it } from 'vitest'; +import { normalizeActivityFeed } from '../api/activity'; + +describe('normalizeActivityFeed', () => { + it('normalizes nested API payloads and sorts newest first', () => { + const events = normalizeActivityFeed({ + events: [ + { + id: 'older', + event_type: 'bounty_posted', + actor: 'SolFoundry', + message: 'Bounty #100 is live', + created_at: '2026-05-29T11:00:00.000Z', + }, + { + id: 'newer', + type: 'payout_released', + username: 'builder', + detail: 'Bounty #99 payout released', + timestamp: '2026-05-29T12:00:00.000Z', + }, + ], + }); + + expect(events).toHaveLength(2); + expect(events[0]).toMatchObject({ + id: 'newer', + type: 'payout', + username: 'builder', + }); + expect(events[1]).toMatchObject({ + id: 'older', + type: 'posted', + username: 'SolFoundry', + }); + }); + + it('handles array payloads with alternate keys', () => { + const events = normalizeActivityFeed([ + { + transaction_signature: 'abc123', + kind: 'pull_request_submitted', + contributor_username: 'octo', + description: 'Opened PR #10', + block_time: '2026-05-29T12:30:00.000Z', + }, + ]); + + expect(events).toEqual([ + { + id: 'abc123', + type: 'submitted', + username: 'octo', + avatar_url: null, + detail: 'Opened PR #10', + timestamp: '2026-05-29T12:30:00.000Z', + }, + ]); + }); +}); diff --git a/frontend/src/__tests__/home-activity-feed.test.tsx b/frontend/src/__tests__/home-activity-feed.test.tsx new file mode 100644 index 000000000..9b104273a --- /dev/null +++ b/frontend/src/__tests__/home-activity-feed.test.tsx @@ -0,0 +1,113 @@ +import { describe, expect, it, vi, beforeEach } from 'vitest'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { render, screen, waitFor } from '@testing-library/react'; +import React from 'react'; +import { ActivityFeed } from '../components/home/ActivityFeed'; + +const mockFetch = vi.fn(); +vi.stubGlobal('fetch', mockFetch); + +function okJson(data: unknown): Response { + return { + ok: true, + status: 200, + statusText: 'OK', + json: () => Promise.resolve(data), + headers: new Headers({ 'content-type': 'application/json' }), + redirected: false, + type: 'basic' as ResponseType, + url: '', + clone() { return this; }, + body: null, + bodyUsed: false, + arrayBuffer: () => Promise.resolve(new ArrayBuffer(0)), + blob: () => Promise.resolve(new Blob()), + formData: () => Promise.resolve(new FormData()), + text: () => Promise.resolve(JSON.stringify(data)), + } as Response; +} + +function failJson(status: number): Response { + return { + ok: false, + status, + statusText: 'Error', + json: () => Promise.resolve({ message: 'error' }), + headers: new Headers({ 'content-type': 'application/json' }), + redirected: false, + type: 'basic' as ResponseType, + url: '', + clone() { return this; }, + body: null, + bodyUsed: false, + arrayBuffer: () => Promise.resolve(new ArrayBuffer(0)), + blob: () => Promise.resolve(new Blob()), + formData: () => Promise.resolve(new FormData()), + text: () => Promise.resolve('{"message":"error"}'), + } as Response; +} + +function renderWithQuery(ui: React.ReactElement) { + const client = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + gcTime: 0, + }, + }, + }); + + return render({ui}); +} + +describe('ActivityFeed', () => { + beforeEach(() => { + mockFetch.mockReset(); + }); + + it('renders API-backed events', async () => { + mockFetch.mockResolvedValue( + okJson({ + events: [ + { + id: 'evt-1', + type: 'payout_released', + username: 'octocat', + detail: 'Bounty #42', + timestamp: '2026-05-29T12:00:00.000Z', + }, + ], + }), + ); + + renderWithQuery(); + + await waitFor(() => { + expect(screen.getByText('octocat')).toBeInTheDocument(); + }); + expect(screen.getByText(/received payout for/i)).toBeInTheDocument(); + expect(screen.getByText('Bounty #42')).toBeInTheDocument(); + }); + + it('shows empty state when API returns no events', async () => { + mockFetch.mockResolvedValue(okJson({ events: [] })); + + renderWithQuery(); + + await waitFor(() => { + expect(screen.getByTestId('activity-feed-empty')).toBeInTheDocument(); + }); + expect(screen.getByText('No recent activity')).toBeInTheDocument(); + }); + + it('shows graceful fallback when API is unavailable', async () => { + mockFetch.mockResolvedValue(failJson(503)); + + renderWithQuery(); + + await waitFor(() => { + expect(screen.getByTestId('activity-feed-error')).toBeInTheDocument(); + }); + expect(screen.getByText('Activity feed unavailable right now.')).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/api/activity.ts b/frontend/src/api/activity.ts new file mode 100644 index 000000000..65541af3c --- /dev/null +++ b/frontend/src/api/activity.ts @@ -0,0 +1,130 @@ +import { apiClient } from '../services/apiClient'; +import type { ActivityEvent, ActivityEventType } from '../types/activity'; + +type UnknownRecord = Record; + +function asRecord(value: unknown): UnknownRecord | null { + return typeof value === 'object' && value !== null ? (value as UnknownRecord) : null; +} + +function asString(value: unknown): string | null { + return typeof value === 'string' && value.trim() !== '' ? value.trim() : null; +} + +function normalizeType(value: unknown): ActivityEventType { + const raw = (asString(value) ?? '').toLowerCase(); + + if (raw.includes('payout') || raw.includes('released') || raw.includes('paid')) { + return 'payout'; + } + if (raw.includes('review') || raw.includes('score')) { + return 'review'; + } + if (raw.includes('submit') || raw.includes('pull_request') || raw.includes('pr_')) { + return 'submitted'; + } + if (raw.includes('post') || raw.includes('created') || raw.includes('publish')) { + return 'posted'; + } + if (raw.includes('complete') || raw.includes('approve') || raw.includes('win')) { + return 'completed'; + } + + return 'posted'; +} + +function normalizeTimestamp(record: UnknownRecord): string { + return ( + asString(record.timestamp) ?? + asString(record.created_at) ?? + asString(record.createdAt) ?? + asString(record.block_time) ?? + asString(record.blockTime) ?? + new Date().toISOString() + ); +} + +function normalizeUsername(record: UnknownRecord): string { + return ( + asString(record.username) ?? + asString(record.user_name) ?? + asString(record.actor) ?? + asString(record.actor_username) ?? + asString(record.creator_username) ?? + asString(record.contributor_username) ?? + asString(record.org_name) ?? + 'Unknown' + ); +} + +function normalizeDetail(record: UnknownRecord): string { + return ( + asString(record.detail) ?? + asString(record.message) ?? + asString(record.description) ?? + asString(record.title) ?? + asString(record.summary) ?? + 'Recent platform activity' + ); +} + +function normalizeAvatar(record: UnknownRecord): string | null { + return ( + asString(record.avatar_url) ?? + asString(record.avatarUrl) ?? + asString(record.user_avatar) ?? + asString(record.actor_avatar_url) + ); +} + +function normalizeId(record: UnknownRecord, index: number): string { + return ( + asString(record.id) ?? + asString(record.event_id) ?? + asString(record.tx_signature) ?? + asString(record.transaction_signature) ?? + `${normalizeType(record.type ?? record.event_type)}-${normalizeTimestamp(record)}-${index}` + ); +} + +function normalizeEvent(value: unknown, index: number): ActivityEvent | null { + const record = asRecord(value); + if (!record) return null; + + return { + id: normalizeId(record, index), + type: normalizeType(record.type ?? record.event_type ?? record.kind ?? record.status), + username: normalizeUsername(record), + avatar_url: normalizeAvatar(record), + detail: normalizeDetail(record), + timestamp: normalizeTimestamp(record), + }; +} + +function extractItems(payload: unknown): unknown[] { + if (Array.isArray(payload)) return payload; + + const record = asRecord(payload); + if (!record) return []; + + const nested = + record.events ?? + record.items ?? + record.data ?? + record.results ?? + record.activity; + + return Array.isArray(nested) ? nested : []; +} + +export function normalizeActivityFeed(payload: unknown): ActivityEvent[] { + return extractItems(payload) + .map(normalizeEvent) + .filter((event): event is ActivityEvent => event !== null) + .sort((left, right) => new Date(right.timestamp).getTime() - new Date(left.timestamp).getTime()); +} + +export async function getActivityFeed(): Promise { + const response = await apiClient('/api/activity'); + return normalizeActivityFeed(response); +} diff --git a/frontend/src/components/home/ActivityFeed.tsx b/frontend/src/components/home/ActivityFeed.tsx index 8b6b4b904..8d29a2129 100644 --- a/frontend/src/components/home/ActivityFeed.tsx +++ b/frontend/src/components/home/ActivityFeed.tsx @@ -1,48 +1,9 @@ -import React, { useState, useEffect } from 'react'; +import React, { useMemo } from 'react'; import { motion, AnimatePresence } from 'framer-motion'; import { slideInRight } from '../../lib/animations'; import { timeAgo } from '../../lib/utils'; - -interface ActivityEvent { - id: string; - type: 'completed' | 'submitted' | 'posted' | 'review'; - username: string; - avatar_url?: string | null; - detail: 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(), - }, -]; +import { useActivityFeed } from '../../hooks/useActivityFeed'; +import type { ActivityEvent } from '../../types/activity'; function getActionText(type: ActivityEvent['type']) { switch (type) { @@ -50,6 +11,7 @@ function getActionText(type: ActivityEvent['type']) { case 'submitted': return 'submitted'; case 'posted': return 'posted'; case 'review': return 'AI Review passed for'; + case 'payout': return 'received payout for'; default: return 'updated'; } } @@ -75,13 +37,28 @@ function EventItem({ event }: { event: ActivityEvent }) { ); } -export function ActivityFeed({ events }: { events?: ActivityEvent[] }) { - const displayEvents = events?.length ? events.slice(0, 4) : MOCK_EVENTS; - const [visibleEvents, setVisibleEvents] = useState(displayEvents.slice(0, 4)); +function ActivitySkeleton() { + return ( + + + + + + + + ); +} - useEffect(() => { - setVisibleEvents(displayEvents.slice(0, 4)); - }, [events]); +export function ActivityFeed({ events }: { events?: ActivityEvent[] }) { + const activityQuery = useActivityFeed(); + const feedEvents = useMemo( + () => (events ?? activityQuery.data ?? []).slice(0, 4), + [events, activityQuery.data], + ); + const hasEvents = feedEvents.length > 0; + const isLoading = !events && activityQuery.isLoading && !activityQuery.data; + const hasError = !events && activityQuery.isError && !hasEvents; + const headerStatus = !events && activityQuery.isError ? 'Delayed' : 'Live'; return ( @@ -89,23 +66,48 @@ export function ActivityFeed({ events }: { events?: ActivityEvent[] }) { Recent Activity + + {headerStatus} + - - - {visibleEvents.map((event) => ( - - - + {isLoading ? ( + + {Array.from({ length: 4 }, (_, index) => ( + ))} - - + + ) : hasError ? ( + + Activity feed unavailable right now. + + ) : !hasEvents ? ( + + No recent activity + + ) : ( + + + {feedEvents.map((event) => ( + + + + ))} + + + )} ); diff --git a/frontend/src/hooks/useActivityFeed.ts b/frontend/src/hooks/useActivityFeed.ts new file mode 100644 index 000000000..b08014bf9 --- /dev/null +++ b/frontend/src/hooks/useActivityFeed.ts @@ -0,0 +1,12 @@ +import { useQuery } from '@tanstack/react-query'; +import { getActivityFeed } from '../api/activity'; + +export function useActivityFeed() { + return useQuery({ + queryKey: ['activity-feed'], + queryFn: getActivityFeed, + staleTime: 15_000, + refetchInterval: 30_000, + retry: false, + }); +} diff --git a/frontend/src/lib/animations.ts b/frontend/src/lib/animations.ts new file mode 100644 index 000000000..be9daeeb7 --- /dev/null +++ b/frontend/src/lib/animations.ts @@ -0,0 +1,74 @@ +import type { Variants } from 'framer-motion'; + +export const fadeIn: Variants = { + initial: { opacity: 0, y: 16 }, + animate: { + opacity: 1, + y: 0, + transition: { duration: 0.35, ease: 'easeOut' }, + }, + exit: { + opacity: 0, + y: -12, + transition: { duration: 0.2, ease: 'easeIn' }, + }, +}; + +export const slideInRight: Variants = { + initial: { opacity: 0, x: 20 }, + animate: { + opacity: 1, + x: 0, + transition: { duration: 0.25, ease: 'easeOut' }, + }, +}; + +export const staggerContainer: Variants = { + initial: {}, + animate: { + transition: { + staggerChildren: 0.08, + }, + }, +}; + +export const staggerItem: Variants = { + initial: { opacity: 0, y: 12 }, + animate: { + opacity: 1, + y: 0, + transition: { duration: 0.25, ease: 'easeOut' }, + }, +}; + +export const pageTransition: Variants = { + initial: { opacity: 0, y: 20 }, + animate: { + opacity: 1, + y: 0, + transition: { duration: 0.35, ease: 'easeOut' }, + }, + exit: { + opacity: 0, + y: -20, + transition: { duration: 0.2, ease: 'easeIn' }, + }, +}; + +export const cardHover: Variants = { + rest: { scale: 1, y: 0 }, + hover: { + scale: 1.01, + y: -2, + transition: { duration: 0.18, ease: 'easeOut' }, + }, +}; + +export const buttonHover: Variants = { + rest: { scale: 1 }, + hover: { + scale: 1.02, + transition: { duration: 0.18, ease: 'easeOut' }, + }, + tap: { scale: 0.98 }, +}; diff --git a/frontend/src/lib/utils.ts b/frontend/src/lib/utils.ts new file mode 100644 index 000000000..5fc41ebcb --- /dev/null +++ b/frontend/src/lib/utils.ts @@ -0,0 +1,55 @@ +const SECOND = 1_000; +const MINUTE = 60 * SECOND; +const HOUR = 60 * MINUTE; +const DAY = 24 * HOUR; + +export const LANG_COLORS: Record = { + javascript: 'bg-yellow-500/15 text-yellow-300 border-yellow-500/30', + typescript: 'bg-blue-500/15 text-blue-300 border-blue-500/30', + python: 'bg-green-500/15 text-green-300 border-green-500/30', + rust: 'bg-orange-500/15 text-orange-300 border-orange-500/30', + solidity: 'bg-slate-500/15 text-slate-300 border-slate-500/30', + react: 'bg-cyan-500/15 text-cyan-300 border-cyan-500/30', + default: 'bg-forge-800 text-text-secondary border-border', +}; + +export function timeAgo(value: string | Date): string { + const timestamp = new Date(value).getTime(); + const delta = Date.now() - timestamp; + + if (!Number.isFinite(timestamp)) return 'just now'; + if (delta < MINUTE) return 'just now'; + if (delta < HOUR) return `${Math.floor(delta / MINUTE)}m ago`; + if (delta < DAY) return `${Math.floor(delta / HOUR)}h ago`; + if (delta < 7 * DAY) return `${Math.floor(delta / DAY)}d ago`; + + return new Date(timestamp).toLocaleDateString(); +} + +export function timeLeft(value?: string | Date | null): string { + if (!value) return 'No deadline'; + + const timestamp = new Date(value).getTime(); + const delta = timestamp - Date.now(); + + if (!Number.isFinite(timestamp)) return 'No deadline'; + if (delta <= 0) return 'Expired'; + if (delta < HOUR) return `${Math.max(1, Math.floor(delta / MINUTE))}m left`; + if (delta < DAY) return `${Math.floor(delta / HOUR)}h left`; + + return `${Math.ceil(delta / DAY)}d left`; +} + +export function formatCurrency(amount: number, currency = 'USDC'): string { + if (!Number.isFinite(amount)) return `0 ${currency}`; + + if (currency === 'FNDRY') { + return `${amount.toLocaleString()} ${currency}`; + } + + return new Intl.NumberFormat('en-US', { + style: 'currency', + currency: currency === 'USDC' ? 'USD' : 'USD', + maximumFractionDigits: amount >= 100 ? 0 : 2, + }).format(amount); +} diff --git a/frontend/src/types/activity.ts b/frontend/src/types/activity.ts new file mode 100644 index 000000000..d9d5c6480 --- /dev/null +++ b/frontend/src/types/activity.ts @@ -0,0 +1,15 @@ +export type ActivityEventType = + | 'completed' + | 'submitted' + | 'posted' + | 'review' + | 'payout'; + +export interface ActivityEvent { + id: string; + type: ActivityEventType; + username: string; + avatar_url?: string | null; + detail: string; + timestamp: string; +}