diff --git a/frontend/src/components/bounty/BountyCard.tsx b/frontend/src/components/bounty/BountyCard.tsx index aa974a474..5d603f286 100644 --- a/frontend/src/components/bounty/BountyCard.tsx +++ b/frontend/src/components/bounty/BountyCard.tsx @@ -61,10 +61,10 @@ export function BountyCard({ bounty }: BountyCardProps) { initial="rest" whileHover="hover" onClick={() => navigate(`/bounties/${bounty.id}`)} - className="relative rounded-xl border border-border bg-forge-900 p-5 cursor-pointer transition-colors duration-200 overflow-hidden group" + className="relative flex h-full flex-col overflow-hidden rounded-xl border border-border bg-forge-900 p-4 transition-colors duration-200 group cursor-pointer sm:p-5" > {/* Row 1: Repo + Tier */} -
+
{bounty.org_avatar_url && ( @@ -84,7 +84,7 @@ export function BountyCard({ bounty }: BountyCardProps) { {/* Row 3: Language dots */} {skills.length > 0 && ( -
+
{skills.map((lang) => ( +
{/* Row 4: Reward + Meta */} -
+
{formatCurrency(bounty.reward_amount, bounty.reward_token)} -
+
{bounty.submission_count} PRs @@ -120,7 +120,7 @@ export function BountyCard({ bounty }: BountyCardProps) {
{/* Status badge */} - + {statusLabel} diff --git a/frontend/src/components/home/HeroSection.tsx b/frontend/src/components/home/HeroSection.tsx index e37307166..86fb48100 100644 --- a/frontend/src/components/home/HeroSection.tsx +++ b/frontend/src/components/home/HeroSection.tsx @@ -1,6 +1,6 @@ -import React, { useState, useEffect } from 'react'; -import { Link, useNavigate } from 'react-router-dom'; -import { motion, useInView, animate, useMotionValue } from 'framer-motion'; +import React, { useEffect, useState } from 'react'; +import { Link } from 'react-router-dom'; +import { animate, motion, useInView, useMotionValue } from 'framer-motion'; import { useStats } from '../../hooks/useStats'; import { getGitHubAuthorizeUrl } from '../../api/auth'; import { useAuth } from '../../hooks/useAuth'; @@ -26,7 +26,7 @@ function EmberParticles({ count = 5 }: { count?: number }) { {particles.map((p) => (
{ controls.stop(); unsubscribe(); }; + return () => { + controls.stop(); + unsubscribe(); + }; }, [inView, target, motionValue, prefix, suffix]); return {prefix}0{suffix}; @@ -66,15 +69,16 @@ function CountUp({ target, prefix = '', suffix = '' }: { target: number; prefix? export function HeroSection() { const { data: stats } = useStats(); const { isAuthenticated } = useAuth(); - const navigate = useNavigate(); const [typewriterDone, setTypewriterDone] = useState(false); const [resultLinesVisible, setResultLinesVisible] = useState(false); useEffect(() => { - // Typewriter takes ~3s (0.5s delay + 2.5s), then show result lines const t1 = setTimeout(() => setTypewriterDone(true), 3100); const t2 = setTimeout(() => setResultLinesVisible(true), 3400); - return () => { clearTimeout(t1); clearTimeout(t2); }; + return () => { + clearTimeout(t1); + clearTimeout(t2); + }; }, []); const handleSignIn = async () => { @@ -87,38 +91,34 @@ export function HeroSection() { }; return ( -
- {/* Background layers */} +
- {/* Terminal card */} - {/* Title bar */} -
+
- solfoundry — terminal + solfoundry terminal
- {/* Terminal body */} -
+
$ - + forge bounty --reward 100 --lang typescript --tier 2 {typewriterDone && ( - + )}
@@ -130,9 +130,9 @@ export function HeroSection() { className="mt-3 space-y-1.5" > {[ - { text: '✓ Bounty created: #142', delay: 0 }, - { text: '✓ Escrow funded: 100 USDC', delay: 0.3 }, - { text: '✓ 3 contributors notified', delay: 0.6 }, + { text: 'Bounty created: #142', delay: 0 }, + { text: 'Escrow funded: 100 USDC', delay: 0.3 }, + { text: '3 contributors notified', delay: 0.6 }, ].map((line, i) => ( + + {line.text} ))} @@ -149,37 +150,34 @@ export function HeroSection() {
- {/* Headline */} - THE AI-POWERED BOUNTY{' '} - FORGE + THE AI-POWERED BOUNTY FORGE Fund bounties. Ship code. Earn rewards. - {/* CTAs */} Browse Bounties @@ -188,7 +186,7 @@ export function HeroSection() { Post a Bounty @@ -198,7 +196,7 @@ export function HeroSection() { @@ -206,32 +204,31 @@ export function HeroSection() { )} - {/* Live stats strip */} - + - - {' '}open bounties + {' '} + open bounties - · + · - + $ - - {' '}paid + {' '} + paid - · + · - + - - {' '}builders + {' '} + builders
diff --git a/frontend/src/components/layout/Navbar.tsx b/frontend/src/components/layout/Navbar.tsx index e4ec31b03..3757acb05 100644 --- a/frontend/src/components/layout/Navbar.tsx +++ b/frontend/src/components/layout/Navbar.tsx @@ -33,6 +33,22 @@ export function Navbar() { return () => window.removeEventListener('scroll', onScroll); }, []); + useEffect(() => { + setMenuOpen(false); + setDropdownOpen(false); + }, [location.pathname]); + + useEffect(() => { + if (!menuOpen) return; + + const previousOverflow = document.body.style.overflow; + document.body.style.overflow = 'hidden'; + + return () => { + document.body.style.overflow = previousOverflow; + }; + }, [menuOpen]); + const handleGitHubSignIn = async () => { try { const url = await getGitHubAuthorizeUrl(); @@ -61,16 +77,16 @@ export function Navbar() { }`} /> -
+
{/* Left: Logo + Nav */} -
- +
+ SolFoundry - + SolFoundry @@ -100,7 +116,7 @@ export function Navbar() {
{/* Right: Live count + Auth */} -
+
{/* Live bounty count */} {stats && (
@@ -168,7 +184,9 @@ export function Navbar() { {/* Mobile hamburger */} @@ -182,19 +200,48 @@ export function Navbar() { initial={{ opacity: 0, height: 0 }} animate={{ opacity: 1, height: 'auto' }} exit={{ opacity: 0, height: 0 }} - className="md:hidden overflow-hidden bg-forge-900 border-b border-border" + className="overflow-hidden border-b border-border bg-forge-900 md:hidden" > -
+
+ {stats && ( +
+ + {stats.open_bounties} open +
+ )} {NAV_LINKS.map((link) => ( setMenuOpen(false)} - className="px-4 py-2.5 rounded-lg text-sm font-medium text-text-secondary hover:text-text-primary hover:bg-forge-850 transition-colors duration-150" + className={`rounded-lg px-4 py-2.5 text-sm font-medium transition-colors duration-150 ${ + isActive(link.to) + ? 'bg-forge-850 text-text-primary' + : 'text-text-secondary hover:bg-forge-850 hover:text-text-primary' + }`} > {link.label} ))} + + {isAuthenticated && user && ( + <> +
+ setMenuOpen(false)} + className="rounded-lg px-4 py-2.5 text-sm font-medium text-text-secondary transition-colors duration-150 hover:bg-forge-850 hover:text-text-primary" + > + Profile + + + + )}
)} diff --git a/frontend/src/lib/animations.ts b/frontend/src/lib/animations.ts new file mode 100644 index 000000000..2e8e429cb --- /dev/null +++ b/frontend/src/lib/animations.ts @@ -0,0 +1,94 @@ +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: 8, + transition: { duration: 0.2, ease: 'easeIn' }, + }, +}; + +export const pageTransition: Variants = { + initial: { opacity: 0, y: 20 }, + animate: { + opacity: 1, + y: 0, + transition: { duration: 0.4, ease: 'easeOut' }, + }, + exit: { + opacity: 0, + y: 12, + transition: { duration: 0.2, ease: 'easeIn' }, + }, +}; + +export const cardHover: Variants = { + rest: { + y: 0, + scale: 1, + transition: { duration: 0.2, ease: 'easeOut' }, + }, + hover: { + y: -4, + scale: 1.01, + transition: { duration: 0.2, ease: 'easeOut' }, + }, + tap: { + scale: 0.99, + transition: { duration: 0.12, ease: 'easeOut' }, + }, +}; + +export const buttonHover: Variants = { + rest: { + scale: 1, + transition: { duration: 0.15, ease: 'easeOut' }, + }, + hover: { + scale: 1.02, + transition: { duration: 0.15, ease: 'easeOut' }, + }, + tap: { + scale: 0.98, + transition: { duration: 0.1, ease: 'easeOut' }, + }, +}; + +export const staggerContainer: Variants = { + initial: {}, + animate: { + transition: { + staggerChildren: 0.06, + delayChildren: 0.04, + }, + }, +}; + +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: 20 }, + animate: { + opacity: 1, + x: 0, + transition: { duration: 0.25, ease: 'easeOut' }, + }, + exit: { + opacity: 0, + x: -20, + transition: { duration: 0.2, ease: 'easeIn' }, + }, +}; diff --git a/frontend/src/lib/utils.ts b/frontend/src/lib/utils.ts new file mode 100644 index 000000000..b131edec9 --- /dev/null +++ b/frontend/src/lib/utils.ts @@ -0,0 +1,86 @@ +const rtf = new Intl.RelativeTimeFormat('en', { numeric: 'auto' }); + +export const LANG_COLORS: Record = { + typescript: '#3178C6', + javascript: '#F7DF1E', + python: '#3776AB', + rust: '#CE422B', + go: '#00ADD8', + solidity: '#8A92B2', + react: '#61DAFB', + nextjs: '#F0F0F5', + tailwind: '#38BDF8', +}; + +function toDate(value?: string | number | Date | null): Date | null { + if (!value) return null; + const date = value instanceof Date ? value : new Date(value); + return Number.isNaN(date.getTime()) ? null : date; +} + +function formatRelative(target: Date): string { + const now = Date.now(); + const diffMs = target.getTime() - now; + const diffSeconds = Math.round(diffMs / 1000); + const absSeconds = Math.abs(diffSeconds); + + if (absSeconds < 60) return rtf.format(diffSeconds, 'second'); + + const diffMinutes = Math.round(diffSeconds / 60); + if (Math.abs(diffMinutes) < 60) return rtf.format(diffMinutes, 'minute'); + + const diffHours = Math.round(diffMinutes / 60); + if (Math.abs(diffHours) < 24) return rtf.format(diffHours, 'hour'); + + const diffDays = Math.round(diffHours / 24); + if (Math.abs(diffDays) < 30) return rtf.format(diffDays, 'day'); + + const diffMonths = Math.round(diffDays / 30); + if (Math.abs(diffMonths) < 12) return rtf.format(diffMonths, 'month'); + + const diffYears = Math.round(diffMonths / 12); + return rtf.format(diffYears, 'year'); +} + +export function timeAgo(value?: string | number | Date | null): string { + const date = toDate(value); + if (!date) return 'unknown'; + return formatRelative(date); +} + +export function timeLeft(value?: string | number | Date | null): string { + const date = toDate(value); + if (!date) return 'no deadline'; + + const diffMs = date.getTime() - Date.now(); + if (diffMs <= 0) return 'ended'; + + const diffMinutes = Math.floor(diffMs / 60000); + const days = Math.floor(diffMinutes / 1440); + const hours = Math.floor((diffMinutes % 1440) / 60); + const minutes = diffMinutes % 60; + + if (days > 0) return `${days}d ${hours}h left`; + if (hours > 0) return `${hours}h ${minutes}m left`; + return `${Math.max(minutes, 1)}m left`; +} + +export function formatCurrency(amount?: number | string | null, token?: string | null): string { + const numericAmount = typeof amount === 'string' ? Number(amount) : amount ?? 0; + const safeAmount = Number.isFinite(numericAmount) ? numericAmount : 0; + const upperToken = token?.trim()?.toUpperCase() || 'USD'; + + if (upperToken === 'USDC' || upperToken === 'USD') { + return new Intl.NumberFormat('en-US', { + style: 'currency', + currency: 'USD', + maximumFractionDigits: safeAmount >= 100 ? 0 : 2, + }).format(safeAmount); + } + + const formatted = new Intl.NumberFormat('en-US', { + maximumFractionDigits: safeAmount >= 100 ? 0 : 2, + }).format(safeAmount); + + return `${formatted} ${upperToken}`; +}