Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 4 additions & 6 deletions frontend/src/components/bounty/BountyCard.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import React from 'react';
import { useNavigate } from 'react-router-dom';
import { motion } from 'framer-motion';
import { GitPullRequest, Clock } from 'lucide-react';
import { GitPullRequest } from 'lucide-react';
import type { Bounty } from '../../types/bounty';
import { cardHover } from '../../lib/animations';
import { timeLeft, formatCurrency, LANG_COLORS } from '../../lib/utils';
import { formatCurrency, LANG_COLORS } from '../../lib/utils';
import { BountyCountdown } from './BountyCountdown';

function TierBadge({ tier }: { tier: string }) {
const styles: Record<string, string> = {
Expand Down Expand Up @@ -111,10 +112,7 @@ export function BountyCard({ bounty }: BountyCardProps) {
{bounty.submission_count} PRs
</span>
{bounty.deadline && (
<span className="inline-flex items-center gap-1">
<Clock className="w-3.5 h-3.5" />
{timeLeft(bounty.deadline)}
</span>
<BountyCountdown deadline={bounty.deadline} compact />
)}
</div>
</div>
Expand Down
92 changes: 92 additions & 0 deletions frontend/src/components/bounty/BountyCountdown.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import React, { useState, useEffect } from 'react';
import { Clock, AlertTriangle, Zap } from 'lucide-react';
import { getTimeParts } from '../../lib/utils';

export type CountdownUrgency = 'normal' | 'warning' | 'urgent' | 'expired';

function getUrgency(expired: boolean, days: number, hours: number): CountdownUrgency {
if (expired) return 'expired';
if (days === 0 && hours < 1) return 'urgent';
if (days === 0) return 'warning';
return 'normal';
}

const urgencyStyles: Record<CountdownUrgency, { text: string; bg: string; border: string; icon: React.ReactNode }> = {
normal: {
text: 'text-text-muted',
bg: 'bg-forge-800',
border: 'border-border',
icon: <Clock className="w-3.5 h-3.5" />,
},
warning: {
text: 'text-status-warning',
bg: 'bg-status-warning/10',
border: 'border-status-warning/30',
icon: <AlertTriangle className="w-3.5 h-3.5" />,
},
urgent: {
text: 'text-status-error',
bg: 'bg-status-error/10',
border: 'border-status-error/30',
icon: <Zap className="w-3.5 h-3.5" />,
},
expired: {
text: 'text-text-muted',
bg: 'bg-forge-800',
border: 'border-border',
icon: <Clock className="w-3.5 h-3.5" />,
},
};

interface BountyCountdownProps {
deadline: string;
/** Compact: single-line layout for cards. Default: false (detailed). */
compact?: boolean;
/** Show seconds tick. Default: false. */
showSeconds?: boolean;
/** Additional CSS classes. */
className?: string;
}

export function BountyCountdown({ deadline, compact = false, showSeconds = false, className = '' }: BountyCountdownProps) {
const [parts, setParts] = useState(() => getTimeParts(deadline));

useEffect(() => {
// Update every second for real-time countdown
const interval = setInterval(() => {
setParts(getTimeParts(deadline));
}, 1000);
return () => clearInterval(interval);
}, [deadline]);

const urgency = getUrgency(parts.expired, parts.days, parts.hours);
const style = urgencyStyles[urgency];

if (compact) {
return (
<span className={`inline-flex items-center gap-1 font-mono text-xs ${style.text}`}>
{style.icon}
{parts.expired ? 'Expired' : `${parts.days}d ${parts.hours}h ${parts.minutes}m`}
</span>
);
}

return (
<div
className={`inline-flex items-center gap-2 px-3 py-2 rounded-lg border ${style.bg} ${style.border} ${className}`}
>
<span className={style.text}>{style.icon}</span>
{parts.expired ? (
<span className={`font-mono text-sm font-medium ${style.text}`}>Expired</span>
) : (
<span className={`font-mono text-sm font-medium ${style.text}`}>
{parts.days > 0 && <span>{parts.days}<span className="text-xs ml-0.5 mr-1">d</span></span>}
{parts.days > 0 && parts.hours > 0 && <span>{parts.hours}<span className="text-xs ml-0.5 mr-1">h</span></span>}
{parts.days === 0 && <span>{parts.hours}<span className="text-xs ml-0.5 mr-1">h</span></span>}
<span>{parts.minutes}<span className="text-xs ml-0.5 mr-1">m</span></span>
{showSeconds && <span>{parts.seconds}<span className="text-xs ml-0.5">s</span></span>}
</span>
)}
</div>
);
}
9 changes: 4 additions & 5 deletions frontend/src/components/bounty/BountyDetail.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import React, { useState } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import { motion } from 'framer-motion';
import { ArrowLeft, Clock, GitPullRequest, ExternalLink, Loader2, Check, Copy } from 'lucide-react';
import { ArrowLeft, GitPullRequest, ExternalLink, Loader2, Check, Copy } from 'lucide-react';
import type { Bounty } from '../../types/bounty';
import { timeLeft, timeAgo, formatCurrency, LANG_COLORS } from '../../lib/utils';
import { timeAgo, formatCurrency, LANG_COLORS } from '../../lib/utils';
import { BountyCountdown } from './BountyCountdown';
import { useAuth } from '../../hooks/useAuth';
import { SubmissionForm } from './SubmissionForm';
import { fadeIn } from '../../lib/animations';
Expand Down Expand Up @@ -138,9 +139,7 @@ export function BountyDetail({ bounty }: BountyDetailProps) {
{bounty.deadline && (
<div className="flex items-center justify-between text-sm">
<span className="text-text-muted">Deadline</span>
<span className="font-mono text-status-warning inline-flex items-center gap-1">
<Clock className="w-3.5 h-3.5" /> {timeLeft(bounty.deadline)}
</span>
<BountyCountdown deadline={bounty.deadline} />
</div>
)}
<div className="flex items-center justify-between text-sm">
Expand Down
75 changes: 68 additions & 7 deletions frontend/src/components/bounty/BountyGrid.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import React, { useState } from 'react';
import React, { useState, useMemo } from 'react';
import { Link } from 'react-router-dom';
import { motion } from 'framer-motion';
import { ChevronDown, Loader2, Plus } from 'lucide-react';
import { ChevronDown, Loader2, Plus, Search, X } from 'lucide-react';
import { BountyCard } from './BountyCard';
import { useInfiniteBounties } from '../../hooks/useBounties';
import { staggerContainer, staggerItem } from '../../lib/animations';
Expand All @@ -11,6 +11,14 @@ const FILTER_SKILLS = ['All', 'TypeScript', 'Rust', 'Solidity', 'Python', 'Go',
export function BountyGrid() {
const [activeSkill, setActiveSkill] = useState<string>('All');
const [statusFilter, setStatusFilter] = useState<string>('open');
const [searchQuery, setSearchQuery] = useState('');
const [debouncedQuery, setDebouncedQuery] = useState('');

// Debounce search input (300ms)
React.useEffect(() => {
const timer = setTimeout(() => setDebouncedQuery(searchQuery), 300);
return () => clearTimeout(timer);
}, [searchQuery]);

const params = {
status: statusFilter,
Expand All @@ -22,12 +30,48 @@ export function BountyGrid() {

const allBounties = data?.pages.flatMap((p) => p.items) ?? [];

// Client-side search filter
const filteredBounties = useMemo(() => {
if (!debouncedQuery.trim()) return allBounties;
const q = debouncedQuery.toLowerCase();
return allBounties.filter((b) => {
const titleMatch = b.title?.toLowerCase().includes(q);
const descMatch = b.description?.toLowerCase().includes(q);
const skillMatch = b.skills?.some((s) => s.toLowerCase().includes(q));
const catMatch = b.category?.toLowerCase().includes(q);
const orgMatch = b.org_name?.toLowerCase().includes(q);
const repoMatch = b.repo_name?.toLowerCase().includes(q);
return titleMatch || descMatch || skillMatch || catMatch || orgMatch || repoMatch;
});
}, [allBounties, debouncedQuery]);

const isSearching = debouncedQuery.trim().length > 0;

return (
<section id="bounties" className="py-16 md:py-24">
<div className="max-w-7xl mx-auto px-4">
{/* Header row */}
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 mb-8">
<h2 className="font-sans text-2xl font-semibold text-text-primary">Open Bounties</h2>
{/* Search bar */}
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-text-muted pointer-events-none" />
<input
type="text"
placeholder="Search bounties..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-full sm:w-64 appearance-none bg-forge-800 border border-border rounded-lg pl-9 pr-8 py-2 text-sm text-text-secondary placeholder-text-muted focus:border-emerald outline-none transition-colors duration-150"
/>
{searchQuery && (
<button
onClick={() => setSearchQuery('')}
className="absolute right-2 top-1/2 -translate-y-1/2 p-1 rounded text-text-muted hover:text-text-primary transition-colors"
>
<X className="w-3.5 h-3.5" />
</button>
)}
</div>
<div className="flex items-center gap-2">
<Link
to="/bounties/create"
Expand Down Expand Up @@ -93,25 +137,42 @@ export function BountyGrid() {
)}

{/* Empty state */}
{!isLoading && !isError && allBounties.length === 0 && (
{!isLoading && !isError && filteredBounties.length === 0 && (
<div className="text-center py-16">
<p className="text-text-muted text-lg mb-2">No bounties found</p>
<p className="text-text-muted text-lg mb-2">
{isSearching ? 'No bounties match your search' : 'No bounties found'}
</p>
<p className="text-text-muted text-sm">
{activeSkill !== 'All' ? `Try a different language filter.` : 'Check back soon for new bounties.'}
{isSearching ? (
<button onClick={() => setSearchQuery('')} className="text-emerald hover:underline">
Clear search
</button>
) : activeSkill !== 'All' ? (
'Try a different language filter.'
) : (
'Check back soon for new bounties.'
)}
</p>
</div>
)}

{/* Result count when searching */}
{isSearching && !isLoading && filteredBounties.length > 0 && (
<p className="text-sm text-text-muted mb-6">
{filteredBounties.length} result{filteredBounties.length !== 1 ? 's' : ''} for &quot;{debouncedQuery}&quot;
</p>
)}

{/* Bounty grid */}
{!isLoading && allBounties.length > 0 && (
{!isLoading && filteredBounties.length > 0 && (
<motion.div
variants={staggerContainer}
initial="initial"
whileInView="animate"
viewport={{ once: true, margin: '-50px' }}
className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-5"
>
{allBounties.map((bounty) => (
{filteredBounties.map((bounty) => (
<motion.div key={bounty.id} variants={staggerItem}>
<BountyCard bounty={bounty} />
</motion.div>
Expand Down
10 changes: 5 additions & 5 deletions frontend/src/components/home/HeroSection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -111,10 +111,10 @@ export function HeroSection() {
</div>

{/* Terminal body */}
<div className="p-5 font-mono text-sm leading-relaxed">
<div className="p-5 font-mono text-xs sm:text-sm leading-relaxed min-w-0">
<div className="overflow-hidden">
<span className="text-emerald">$ </span>
<span className="text-text-secondary overflow-hidden whitespace-nowrap inline-block animate-typewriter">
<span className="text-text-secondary overflow-hidden whitespace-nowrap inline-block animate-typewriter max-w-[calc(100%-1rem)]">
forge bounty --reward 100 --lang typescript --tier 2
</span>
{typewriterDone && (
Expand Down Expand Up @@ -211,22 +211,22 @@ export function HeroSection() {
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.8, duration: 0.5 }}
className="flex items-center justify-center gap-6 mt-8 font-mono text-sm text-text-muted"
className="flex flex-wrap items-center justify-center gap-3 sm:gap-6 mt-8 font-mono text-xs sm:text-sm text-text-muted"
>
<span>
<span className="text-text-primary font-semibold">
<CountUp target={stats?.open_bounties ?? 142} />
</span>
{' '}open bounties
</span>
<span className="text-text-muted">·</span>
<span className="text-text-muted hidden sm:inline">·</span>
<span>
<span className="text-text-primary font-semibold">
$<CountUp target={stats?.total_paid_usdc ?? 24500} />
</span>
{' '}paid
</span>
<span className="text-text-muted">·</span>
<span className="text-text-muted hidden sm:inline">·</span>
<span>
<span className="text-text-primary font-semibold">
<CountUp target={stats?.total_contributors ?? 89} />
Expand Down
20 changes: 11 additions & 9 deletions frontend/src/components/layout/Footer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -92,15 +92,17 @@ export function Footer() {
<div>
<h4 className="font-sans text-sm font-semibold text-text-primary mb-4">$FNDRY Token</h4>
<p className="text-sm text-text-muted mb-3">Contract Address:</p>
<div className="font-mono text-xs text-text-muted bg-forge-800 rounded px-3 py-2 inline-flex items-center gap-2 w-full">
<span className="truncate">{FNDRY_CA.slice(0, 8)}...{FNDRY_CA.slice(-4)}</span>
<button
onClick={copyCA}
className="flex-shrink-0 text-text-muted hover:text-text-primary transition-colors duration-150"
title="Copy contract address"
>
{copied ? <Check className="w-3.5 h-3.5 text-emerald" /> : <Copy className="w-3.5 h-3.5" />}
</button>
<div className="min-w-0 flex">
<div className="font-mono text-xs text-text-muted bg-forge-800 rounded px-3 py-2 inline-flex items-center gap-2 flex-1 min-w-0">
<span className="truncate">{FNDRY_CA.slice(0, 8)}...{FNDRY_CA.slice(-4)}</span>
<button
onClick={copyCA}
className="flex-shrink-0 text-text-muted hover:text-text-primary transition-colors duration-150"
title="Copy contract address"
>
{copied ? <Check className="w-3.5 h-3.5 text-emerald" /> : <Copy className="w-3.5 h-3.5" />}
</button>
</div>
</div>
</div>
</div>
Expand Down
2 changes: 2 additions & 0 deletions frontend/src/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,7 @@
html {
scroll-behavior: smooth;
font-size: 16px;
overflow-x: hidden;
}

body {
Expand All @@ -123,6 +124,7 @@ body {
-moz-osx-font-smoothing: grayscale;
background-color: #050505;
color: #F0F0F5;
overflow-x: hidden;
}

/* Custom scrollbar */
Expand Down
Loading