diff --git a/.gitignore b/.gitignore index 36fca7e4f..eb880f89a 100644 --- a/.gitignore +++ b/.gitignore @@ -27,6 +27,8 @@ eggs/ .eggs/ lib/ lib64/ +!frontend/src/lib/ +!frontend/src/lib/** parts/ sdist/ var/ diff --git a/frontend/src/__tests__/bounty-flow-diagram.test.tsx b/frontend/src/__tests__/bounty-flow-diagram.test.tsx new file mode 100644 index 000000000..f8f44c6c5 --- /dev/null +++ b/frontend/src/__tests__/bounty-flow-diagram.test.tsx @@ -0,0 +1,33 @@ +import React from 'react'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { describe, expect, it } from 'vitest'; +import { BountyFlowDiagram } from '../components/how-it-works/BountyFlowDiagram'; + +describe('BountyFlowDiagram', () => { + it('renders the full bounty lifecycle inside an interactive SVG diagram', () => { + render(); + + expect(screen.getByRole('img', { name: /interactive bounty lifecycle flow diagram/i })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Show Post stage' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Show Claim stage' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Show Work stage' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Show Submit stage' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Show Review stage' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Show Payment stage' })).toBeInTheDocument(); + expect(screen.getByTestId('flow-stage-payment')).toBeInTheDocument(); + }); + + it('updates the explanatory tooltip when a stage is selected', async () => { + render(); + + fireEvent.click(screen.getByRole('button', { name: 'Show Review stage' })); + await waitFor(() => { + expect(screen.getByText(/Automated checks, LLM review/)).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByRole('button', { name: 'Show Payment stage' })); + await waitFor(() => { + expect(screen.getByText(/Approved work releases the bounty reward/)).toBeInTheDocument(); + }); + }); +}); diff --git a/frontend/src/components/how-it-works/BountyFlowDiagram.tsx b/frontend/src/components/how-it-works/BountyFlowDiagram.tsx new file mode 100644 index 000000000..fc9fad28e --- /dev/null +++ b/frontend/src/components/how-it-works/BountyFlowDiagram.tsx @@ -0,0 +1,223 @@ +import React, { useMemo, useState } from 'react'; +import { AnimatePresence, motion } from 'framer-motion'; + +interface FlowStage { + id: string; + label: string; + detail: string; + x: number; + y: number; + color: string; +} + +const FLOW_STAGES: FlowStage[] = [ + { + id: 'post', + label: 'Post', + detail: 'A maintainer publishes a scoped GitHub issue with reward, tier, and acceptance criteria.', + x: 80, + y: 112, + color: '#34d399', + }, + { + id: 'claim', + label: 'Claim', + detail: 'A contributor claims the bounty and confirms they are actively working on the issue.', + x: 240, + y: 112, + color: '#22d3ee', + }, + { + id: 'work', + label: 'Work', + detail: 'The fix is built in a fork or feature branch with focused code and tests.', + x: 400, + y: 112, + color: '#a78bfa', + }, + { + id: 'submit', + label: 'Submit', + detail: 'The contributor opens a pull request that links the bounty issue and documents verification.', + x: 560, + y: 112, + color: '#f472b6', + }, + { + id: 'review', + label: 'Review', + detail: 'Automated checks, LLM review, and maintainers evaluate correctness before approval.', + x: 720, + y: 112, + color: '#fbbf24', + }, + { + id: 'payment', + label: 'Payment', + detail: 'Approved work releases the bounty reward to the contributor wallet or payout account.', + x: 880, + y: 112, + color: '#fb7185', + }, +]; + +export function BountyFlowDiagram() { + const [activeStageId, setActiveStageId] = useState(FLOW_STAGES[0].id); + const activeIndex = FLOW_STAGES.findIndex((stage) => stage.id === activeStageId); + const activeStage = FLOW_STAGES[Math.max(activeIndex, 0)]; + + const progressWidth = useMemo(() => { + if (activeIndex <= 0) { + return 0; + } + + const first = FLOW_STAGES[0]; + const active = FLOW_STAGES[activeIndex]; + return active.x - first.x; + }, [activeIndex]); + + return ( +
+
+

+ Bounty Lifecycle +

+

+ Explore each handoff from issue creation to contributor payout. +

+
+ +
+ + + + + + + + + + + + + + + + + + + + + {FLOW_STAGES.map((stage, index) => { + const isActive = stage.id === activeStage.id; + const isComplete = index < activeIndex; + + return ( + setActiveStageId(stage.id)} + onFocus={() => setActiveStageId(stage.id)} + onClick={() => setActiveStageId(stage.id)} + onKeyDown={(event) => { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + setActiveStageId(stage.id); + } + }} + className="cursor-pointer outline-none" + > + + + {index + 1} + + + {stage.label} + + + ); + })} + + +
+ {FLOW_STAGES.map((stage) => ( + + ))} +
+ + + +

Current stage

+

{activeStage.label}

+

{activeStage.detail}

+
+
+
+
+ ); +} diff --git a/frontend/src/lib/animations.ts b/frontend/src/lib/animations.ts new file mode 100644 index 000000000..961456e79 --- /dev/null +++ b/frontend/src/lib/animations.ts @@ -0,0 +1,69 @@ +import type { Variants } from 'framer-motion'; + +export const fadeIn: Variants = { + initial: { opacity: 0, y: 12 }, + animate: { + opacity: 1, + y: 0, + transition: { duration: 0.28, ease: 'easeOut' }, + }, + exit: { + opacity: 0, + y: -8, + transition: { duration: 0.18, ease: 'easeIn' }, + }, +}; + +export const pageTransition: Variants = { + initial: { opacity: 0 }, + animate: { + opacity: 1, + transition: { duration: 0.24, ease: 'easeOut' }, + }, + exit: { + opacity: 0, + transition: { duration: 0.16, ease: 'easeIn' }, + }, +}; + +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.24, ease: 'easeOut' }, + }, +}; + +export const cardHover: Variants = { + rest: { y: 0 }, + hover: { + y: -3, + transition: { duration: 0.16, ease: 'easeOut' }, + }, +}; + +export const buttonHover: Variants = { + rest: { scale: 1 }, + hover: { + scale: 1.02, + transition: { duration: 0.14, ease: 'easeOut' }, + }, + tap: { scale: 0.98 }, +}; + +export const slideInRight: Variants = { + initial: { opacity: 0, x: 18 }, + animate: { + opacity: 1, + x: 0, + transition: { duration: 0.28, ease: 'easeOut' }, + }, +}; diff --git a/frontend/src/lib/utils.ts b/frontend/src/lib/utils.ts new file mode 100644 index 000000000..37ff45661 --- /dev/null +++ b/frontend/src/lib/utils.ts @@ -0,0 +1,79 @@ +export const LANG_COLORS: Record = { + TypeScript: '#3178c6', + JavaScript: '#f7df1e', + Rust: '#dea584', + Python: '#3572a5', + Go: '#00add8', + Solidity: '#aa6746', + Svelte: '#ff3e00', + React: '#61dafb', + CSS: '#563d7c', + HTML: '#e34c26', +}; + +export function formatCurrency(amount?: number | string | null, token = 'USDC') { + const numericAmount = Number(amount ?? 0); + const formattedAmount = Number.isFinite(numericAmount) + ? new Intl.NumberFormat('en-US', { maximumFractionDigits: 2 }).format(numericAmount) + : '0'; + + return `${formattedAmount} ${token}`; +} + +export function timeAgo(value?: string | number | Date | null) { + if (!value) { + return 'recently'; + } + + const timestamp = new Date(value).getTime(); + if (!Number.isFinite(timestamp)) { + return 'recently'; + } + + const seconds = Math.max(0, Math.floor((Date.now() - timestamp) / 1000)); + const units: Array<[Intl.RelativeTimeFormatUnit, number]> = [ + ['year', 31536000], + ['month', 2592000], + ['week', 604800], + ['day', 86400], + ['hour', 3600], + ['minute', 60], + ]; + + const rtf = new Intl.RelativeTimeFormat('en', { numeric: 'auto' }); + for (const [unit, unitSeconds] of units) { + if (seconds >= unitSeconds) { + return rtf.format(-Math.floor(seconds / unitSeconds), unit); + } + } + + return 'just now'; +} + +export function timeLeft(value?: string | number | Date | null) { + if (!value) { + return 'No deadline'; + } + + const timestamp = new Date(value).getTime(); + if (!Number.isFinite(timestamp)) { + return 'No deadline'; + } + + const seconds = Math.floor((timestamp - Date.now()) / 1000); + if (seconds <= 0) { + return 'Ended'; + } + + const days = Math.ceil(seconds / 86400); + if (days >= 2) { + return `${days} days left`; + } + + const hours = Math.ceil(seconds / 3600); + if (hours >= 2) { + return `${hours} hours left`; + } + + return 'Ending soon'; +} diff --git a/frontend/src/pages/HowItWorksPage.tsx b/frontend/src/pages/HowItWorksPage.tsx index 9b245eb50..17d86065c 100644 --- a/frontend/src/pages/HowItWorksPage.tsx +++ b/frontend/src/pages/HowItWorksPage.tsx @@ -1,6 +1,7 @@ import React from 'react'; import { motion } from 'framer-motion'; import { PageLayout } from '../components/layout/PageLayout'; +import { BountyFlowDiagram } from '../components/how-it-works/BountyFlowDiagram'; import { FlowTabs } from '../components/how-it-works/FlowTabs'; import { fadeIn } from '../lib/animations'; @@ -12,6 +13,7 @@ export function HowItWorksPage() {

How It Works

Two paths to earning on SolFoundry

+