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 }} + > + + +