diff --git a/.gitignore b/.gitignore index 36fca7e4f..10cc267fa 100644 --- a/.gitignore +++ b/.gitignore @@ -25,8 +25,8 @@ dist/ downloads/ eggs/ .eggs/ -lib/ -lib64/ +/lib/ +/lib64/ 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..389b85b37 --- /dev/null +++ b/frontend/src/__tests__/bounty-flow-diagram.test.tsx @@ -0,0 +1,50 @@ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { describe, expect, it } from 'vitest'; +import { BountyFlowDiagram } from '../components/how-it-works/BountyFlowDiagram'; + +describe('BountyFlowDiagram', () => { + it('renders the interactive lifecycle SVG and default stage copy', () => { + render(); + + expect(screen.getByTestId('bounty-flow-svg')).toBeInTheDocument(); + expect(screen.getByText('Bounty flow from post to payout')).toBeInTheDocument(); + expect(screen.getByText('Post bounty')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Show Payment stage' })).toHaveAttribute( + 'aria-pressed', + 'false', + ); + }); + + it('updates the tooltip when a stage is clicked', async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByRole('button', { name: 'Show Review stage' })); + + expect(screen.getByRole('button', { name: 'Show Review stage' })).toHaveAttribute( + 'aria-pressed', + 'true', + ); + expect(screen.getByTestId('stage-tooltip')).toHaveTextContent( + 'Automated and maintainer review checks quality, scope, and eligibility.', + ); + }); + + it('supports keyboard activation for SVG stages', async () => { + const user = userEvent.setup(); + render(); + + const paymentStage = screen.getByRole('button', { name: 'Show Payment stage' }); + for (let i = 0; i < 6; i += 1) { + await user.tab(); + } + expect(paymentStage).toHaveFocus(); + await user.keyboard('{Enter}'); + + expect(paymentStage).toHaveAttribute('aria-pressed', 'true'); + expect(screen.getByTestId('stage-tooltip')).toHaveTextContent( + 'Approved work is merged and paid from the bounty mechanism.', + ); + }); +}); 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..0ee0a01fd --- /dev/null +++ b/frontend/src/components/how-it-works/BountyFlowDiagram.tsx @@ -0,0 +1,262 @@ +import { useMemo, useState } from 'react'; +import { motion } from 'framer-motion'; +import { + Bot, + CircleDollarSign, + FileText, + GitPullRequest, + Hammer, + ShieldCheck, + type LucideIcon, +} from 'lucide-react'; + +type FlowStageId = 'post' | 'claim' | 'work' | 'submit' | 'review' | 'payment'; + +interface FlowStage { + id: FlowStageId; + label: string; + shortLabel: string; + description: string; + detail: string; + icon: LucideIcon; + accent: 'emerald' | 'purple' | 'magenta' | 'info' | 'warning' | 'success'; +} + +const STAGES: FlowStage[] = [ + { + id: 'post', + label: 'Post bounty', + shortLabel: 'Post', + description: 'Creator publishes a scoped task with reward, deadline, and review criteria.', + detail: 'The bounty starts as a GitHub issue or SolFoundry listing with enough detail for contributors to estimate the work.', + icon: FileText, + accent: 'emerald', + }, + { + id: 'claim', + label: 'Claim', + shortLabel: 'Claim', + description: 'Contributor chooses the bounty and signals intent to work.', + detail: 'Open races can start immediately. Gated tiers check contributor reputation before review begins.', + icon: ShieldCheck, + accent: 'purple', + }, + { + id: 'work', + label: 'Work', + shortLabel: 'Work', + description: 'Contributor implements the fix, feature, docs, or asset.', + detail: 'Good submissions stay scoped, include verification notes, and avoid unrelated churn.', + icon: Hammer, + accent: 'magenta', + }, + { + id: 'submit', + label: 'Submit PR', + shortLabel: 'Submit', + description: 'The solution is opened as a pull request referencing the bounty issue.', + detail: 'The PR body must include the linked issue, test evidence, and wallet details when required by the bounty.', + icon: GitPullRequest, + accent: 'info', + }, + { + id: 'review', + label: 'Review', + shortLabel: 'Review', + description: 'Automated and maintainer review checks quality, scope, and eligibility.', + detail: 'SolFoundry review combines CI status, bounty guards, and AI-assisted review before maintainer approval.', + icon: Bot, + accent: 'warning', + }, + { + id: 'payment', + label: 'Payment', + shortLabel: 'Pay', + description: 'Approved work is merged and paid from the bounty mechanism.', + detail: 'Rewards can be released from escrow, treasury, or direct payout depending on the bounty source.', + icon: CircleDollarSign, + accent: 'success', + }, +]; + +const ACCENTS: Record = { + emerald: { + fill: 'fill-emerald-bg', + stroke: 'stroke-emerald', + text: 'text-emerald', + glow: 'shadow-[0_0_28px_rgba(0,230,118,0.22)]', + }, + purple: { + fill: 'fill-purple-bg', + stroke: 'stroke-purple', + text: 'text-purple-light', + glow: 'shadow-[0_0_28px_rgba(124,58,237,0.22)]', + }, + magenta: { + fill: 'fill-magenta-bg', + stroke: 'stroke-magenta', + text: 'text-magenta', + glow: 'shadow-[0_0_28px_rgba(224,64,251,0.22)]', + }, + info: { + fill: 'fill-[rgba(64,196,255,0.1)]', + stroke: 'stroke-status-info', + text: 'text-status-info', + glow: 'shadow-[0_0_28px_rgba(64,196,255,0.2)]', + }, + warning: { + fill: 'fill-[rgba(255,179,0,0.1)]', + stroke: 'stroke-status-warning', + text: 'text-status-warning', + glow: 'shadow-[0_0_28px_rgba(255,179,0,0.18)]', + }, + success: { + fill: 'fill-emerald-bg', + stroke: 'stroke-status-success', + text: 'text-status-success', + glow: 'shadow-[0_0_28px_rgba(0,230,118,0.22)]', + }, +}; + +const NODE_POSITIONS = [ + { x: 80, y: 110 }, + { x: 226, y: 110 }, + { x: 372, y: 110 }, + { x: 518, y: 110 }, + { x: 664, y: 110 }, + { x: 810, y: 110 }, +]; + +export function BountyFlowDiagram() { + const [activeId, setActiveId] = useState('post'); + const activeStage = useMemo( + () => STAGES.find((stage) => stage.id === activeId) ?? STAGES[0], + [activeId], + ); + + return ( + + + + Lifecycle map + + Bounty flow from post to payout + + + + Hover, focus, or tap any stage to see what happens before the bounty moves forward. + + + + + + + + + + + {NODE_POSITIONS.slice(0, -1).map((position, index) => ( + + ))} + + {STAGES.map((stage, index) => { + const position = NODE_POSITIONS[index]; + const Icon = stage.icon; + const selected = stage.id === activeId; + const accent = ACCENTS[stage.accent]; + + return ( + setActiveId(stage.id)} + onFocus={() => setActiveId(stage.id)} + onClick={() => setActiveId(stage.id)} + onKeyDown={(event) => { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + setActiveId(stage.id); + } + }} + className="cursor-pointer outline-none" + animate={{ scale: selected ? 1.05 : 1 }} + transition={{ type: 'spring', stiffness: 320, damping: 22 }} + > + + + + + + {stage.shortLabel} + + + ); + })} + + + + + + + + + + {activeStage.label} + {activeStage.description} + {activeStage.detail} + + + + + ); +} diff --git a/frontend/src/lib/animations.ts b/frontend/src/lib/animations.ts new file mode 100644 index 000000000..91bfd9aa7 --- /dev/null +++ b/frontend/src/lib/animations.ts @@ -0,0 +1,73 @@ +import type { Variants } from 'framer-motion'; + +export const fadeIn: Variants = { + initial: { opacity: 0, y: 12 }, + animate: { + opacity: 1, + y: 0, + transition: { duration: 0.22, ease: 'easeOut' }, + }, +}; + +export const pageTransition: Variants = { + initial: { opacity: 0 }, + animate: { + opacity: 1, + transition: { duration: 0.22, ease: 'easeOut' }, + }, + exit: { + opacity: 0, + transition: { duration: 0.12, ease: 'easeInOut' }, + }, +}; + +export const staggerContainer: Variants = { + initial: {}, + animate: { + transition: { + staggerChildren: 0.06, + }, + }, +}; + +export const staggerItem: Variants = { + initial: { opacity: 0, y: 10 }, + animate: { + opacity: 1, + y: 0, + transition: { duration: 0.2, ease: 'easeOut' }, + }, +}; + +export const slideInRight: Variants = { + initial: { opacity: 0, x: 18 }, + animate: { + opacity: 1, + x: 0, + transition: { duration: 0.22, ease: 'easeOut' }, + }, +}; + +export const cardHover: Variants = { + rest: { + y: 0, + borderColor: 'var(--color-border)', + }, + hover: { + y: -3, + borderColor: 'var(--color-border-hover)', + transition: { duration: 0.16, ease: 'easeOut' }, + }, +}; + +export const buttonHover: Variants = { + rest: { scale: 1 }, + hover: { + scale: 1.02, + transition: { duration: 0.15, ease: 'easeOut' }, + }, + tap: { + scale: 0.98, + transition: { duration: 0.08, ease: 'easeOut' }, + }, +}; diff --git a/frontend/src/lib/utils.ts b/frontend/src/lib/utils.ts new file mode 100644 index 000000000..510ac1639 --- /dev/null +++ b/frontend/src/lib/utils.ts @@ -0,0 +1,72 @@ +export const LANG_COLORS: Record = { + TypeScript: '#3178c6', + JavaScript: '#f7df1e', + React: '#61dafb', + Python: '#3776ab', + Rust: '#dea584', + Solana: '#14f195', + Solidity: '#627eea', + Go: '#00add8', + 'C++': '#00599c', + CSS: '#663399', + HTML: '#e34f26', + Docs: '#a78bfa', +}; + +export function formatCurrency(amount?: number | null, token = 'USDC') { + const safeAmount = Number.isFinite(amount ?? NaN) ? Number(amount) : 0; + + if (token === 'FNDRY') { + return `${compactNumber(safeAmount)} FNDRY`; + } + + if (token === 'USDC') { + return `$${compactNumber(safeAmount)} USDC`; + } + + return `${compactNumber(safeAmount)} ${token}`; +} + +export function timeAgo(value?: string | Date | null) { + const date = toDate(value); + if (!date) return 'unknown'; + + const seconds = Math.round((Date.now() - date.getTime()) / 1000); + const suffix = seconds < 0 ? 'from now' : 'ago'; + const absolute = Math.abs(seconds); + + if (absolute < 60) return 'just now'; + if (absolute < 3600) return `${Math.round(absolute / 60)}m ${suffix}`; + if (absolute < 86400) return `${Math.round(absolute / 3600)}h ${suffix}`; + if (absolute < 2592000) return `${Math.round(absolute / 86400)}d ${suffix}`; + if (absolute < 31536000) return `${Math.round(absolute / 2592000)}mo ${suffix}`; + return `${Math.round(absolute / 31536000)}y ${suffix}`; +} + +export function timeLeft(value?: string | Date | null) { + const date = toDate(value); + if (!date) return 'No deadline'; + + const seconds = Math.round((date.getTime() - Date.now()) / 1000); + if (seconds <= 0) return 'Expired'; + if (seconds < 3600) return `${Math.ceil(seconds / 60)}m left`; + if (seconds < 86400) return `${Math.ceil(seconds / 3600)}h left`; + return `${Math.ceil(seconds / 86400)}d left`; +} + +function toDate(value?: string | Date | null) { + if (!value) return null; + const date = value instanceof Date ? value : new Date(value); + return Number.isNaN(date.getTime()) ? null : date; +} + +function compactNumber(value: number) { + const abs = Math.abs(value); + if (abs >= 1_000_000) return `${trimTrailingZero(value / 1_000_000)}M`; + if (abs >= 1_000) return `${trimTrailingZero(value / 1_000)}k`; + return new Intl.NumberFormat('en-US', { maximumFractionDigits: 2 }).format(value); +} + +function trimTrailingZero(value: number) { + return value.toFixed(value >= 10 ? 0 : 1).replace(/\.0$/, ''); +} diff --git a/frontend/src/pages/HowItWorksPage.tsx b/frontend/src/pages/HowItWorksPage.tsx index 9b245eb50..3819cd509 100644 --- a/frontend/src/pages/HowItWorksPage.tsx +++ b/frontend/src/pages/HowItWorksPage.tsx @@ -2,6 +2,7 @@ import React from 'react'; import { motion } from 'framer-motion'; import { PageLayout } from '../components/layout/PageLayout'; import { FlowTabs } from '../components/how-it-works/FlowTabs'; +import { BountyFlowDiagram } from '../components/how-it-works/BountyFlowDiagram'; import { fadeIn } from '../lib/animations'; export function HowItWorksPage() { @@ -12,6 +13,7 @@ export function HowItWorksPage() { How It Works Two paths to earning on SolFoundry +
Lifecycle map
+ Hover, focus, or tap any stage to see what happens before the bounty moves forward. +
{activeStage.description}
{activeStage.detail}
Two paths to earning on SolFoundry