diff --git a/frontend/src/components/bounty/BountyCard.tsx b/frontend/src/components/bounty/BountyCard.tsx index aa974a474..104b10899 100644 --- a/frontend/src/components/bounty/BountyCard.tsx +++ b/frontend/src/components/bounty/BountyCard.tsx @@ -1,9 +1,9 @@ -import React from 'react'; +import React, { useState } from 'react'; import { useNavigate } from 'react-router-dom'; -import { motion } from 'framer-motion'; -import { GitPullRequest, Clock } from 'lucide-react'; +import { motion, useMotionValue, useTransform, PanInfo } from 'framer-motion'; +import { GitPullRequest, Clock, ArrowRight } from 'lucide-react'; import type { Bounty } from '../../types/bounty'; -import { cardHover } from '../../lib/animations'; +import { mobileStaggerItem } from '../../lib/animations'; import { timeLeft, formatCurrency, LANG_COLORS } from '../../lib/utils'; function TierBadge({ tier }: { tier: string }) { @@ -13,7 +13,7 @@ function TierBadge({ tier }: { tier: string }) { T3: 'bg-tier-t3/10 text-tier-t3 border border-tier-t3/20', }; return ( - + {tier} ); @@ -21,10 +21,22 @@ function TierBadge({ tier }: { tier: string }) { interface BountyCardProps { bounty: Bounty; + index?: number; } -export function BountyCard({ bounty }: BountyCardProps) { +export function BountyCard({ bounty, index = 0 }: BountyCardProps) { const navigate = useNavigate(); + const [isPressed, setIsPressed] = useState(false); + const [showPreview, setShowPreview] = useState(false); + + // Swipe gesture handling for mobile quick actions + const x = useMotionValue(0); + const opacity = useTransform(x, [-100, 0, 100], [0.5, 1, 0.5]); + const background = useTransform( + x, + [-100, 0, 100], + ['rgba(0,230,118,0.1)', 'rgba(0,0,0,0)', 'rgba(224,64,251,0.1)'] + ); const orgName = bounty.org_name ?? bounty.github_issue_url?.split('/')[3] ?? 'unknown'; const repoName = bounty.repo_name ?? bounty.github_issue_url?.split('/')[4] ?? 'repo'; @@ -55,75 +67,132 @@ export function BountyCard({ bounty }: BountyCardProps) { cancelled: 'bg-status-error', }[bounty.status] ?? 'bg-emerald'; + const handleDragEnd = (_: MouseEvent | TouchEvent | PointerEvent, info: PanInfo) => { + // Swipe threshold for quick actions + if (Math.abs(info.offset.x) > 80) { + setShowPreview(true); + setTimeout(() => setShowPreview(false), 2000); + } + }; + + const handleClick = () => { + navigate(`/bounties/${bounty.id}`); + }; + return ( - 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" + setIsPressed(true)} + onPointerUp={() => setIsPressed(false)} + onPointerLeave={() => setIsPressed(false)} + className={`relative rounded-xl border border-border bg-forge-900 p-3 sm:p-4 lg:p-5 cursor-pointer transition-all duration-200 overflow-hidden group min-h-[180px] sm:min-h-[200px] flex flex-col tap-highlight ${isPressed ? 'scale-[0.98]' : ''}`} + role="button" + tabIndex={0} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + handleClick(); + } + }} + aria-label={`Bounty: ${bounty.title}, Reward: ${formatCurrency(bounty.reward_amount, bounty.reward_token)}, Status: ${statusLabel}`} > + {/* Quick Preview Indicator (shown on swipe) */} + {showPreview && ( + + Swipe for quick actions + + )} + {/* Row 1: Repo + Tier */} -
-
+
+
{bounty.org_avatar_url && ( - + )} - + {orgName}/{repoName} - {issueNumber && #{issueNumber}} + {issueNumber && #{issueNumber}}
{/* Row 2: Title */} -

+

{bounty.title}

{/* Row 3: Language dots */} {skills.length > 0 && ( -
+
{skills.map((lang) => ( - + - {lang} + {lang} ))}
)} {/* Separator */} -
+
- {/* Row 4: Reward + Meta */} -
- + {/* Row 4: Reward + Meta + Status */} +
+ {formatCurrency(bounty.reward_amount, bounty.reward_token)} -
- - - {bounty.submission_count} PRs + + {/* Status badge - inline on mobile instead of absolute */} + + + {statusLabel} + {statusLabel.split(' ')[0]} + +
+ + {/* Row 5: Meta info (PRs, Deadline) */} +
+ + + {bounty.submission_count} PRs + + {bounty.deadline && ( + + + {timeLeft(bounty.deadline)} - {bounty.deadline && ( - - - {timeLeft(bounty.deadline)} - - )} -
+ )}
- {/* Status badge */} - - - {statusLabel} - - + {/* Mobile Quick Action Hint */} + + ); } diff --git a/frontend/src/components/bounty/BountyGrid.tsx b/frontend/src/components/bounty/BountyGrid.tsx index 7709ab94c..466146749 100644 --- a/frontend/src/components/bounty/BountyGrid.tsx +++ b/frontend/src/components/bounty/BountyGrid.tsx @@ -1,7 +1,7 @@ import React, { useState } from 'react'; import { Link } from 'react-router-dom'; import { motion } from 'framer-motion'; -import { ChevronDown, Loader2, Plus } from 'lucide-react'; +import { ChevronDown, Loader2, Plus, Filter } from 'lucide-react'; import { BountyCard } from './BountyCard'; import { useInfiniteBounties } from '../../hooks/useBounties'; import { staggerContainer, staggerItem } from '../../lib/animations'; @@ -11,6 +11,7 @@ const FILTER_SKILLS = ['All', 'TypeScript', 'Rust', 'Solidity', 'Python', 'Go', export function BountyGrid() { const [activeSkill, setActiveSkill] = useState('All'); const [statusFilter, setStatusFilter] = useState('open'); + const [showMobileFilters, setShowMobileFilters] = useState(false); const params = { status: statusFilter, @@ -23,60 +24,75 @@ export function BountyGrid() { const allBounties = data?.pages.flatMap((p) => p.items) ?? []; return ( -
-
+
+
{/* Header row */} -
-

Open Bounties

-
- - - Post a Bounty - - {/* Status filter */} -
- setStatusFilter(e.target.value)} + className="appearance-none bg-forge-800 border border-border rounded-lg px-3 py-2 sm:py-1.5 pr-8 text-xs sm:text-sm text-text-secondary font-medium focus:border-emerald outline-none transition-colors duration-150 cursor-pointer min-h-[36px] sm:min-h-0" + > + + + + + + +
+ {/* Mobile filter toggle */} +
- {/* Filter pills */} -
- {FILTER_SKILLS.map((skill) => ( - - ))} + {/* Filter pills - horizontal scroll on mobile */} +
+
+ {FILTER_SKILLS.map((skill) => ( + + ))} +
{/* Loading state */} {isLoading && ( -
+
{Array.from({ length: 6 }).map((_, i) => (
@@ -86,17 +102,17 @@ export function BountyGrid() { {/* Error state */} {isError && !isLoading && ( -
-

Could not load bounties. Backend may be offline.

-

Running in demo mode — no bounties to display.

+
+

Could not load bounties. Backend may be offline.

+

Running in demo mode — no bounties to display.

)} {/* Empty state */} {!isLoading && !isError && allBounties.length === 0 && ( -
-

No bounties found

-

+

+

No bounties found

+

{activeSkill !== 'All' ? `Try a different language filter.` : 'Check back soon for new bounties.'}

@@ -109,10 +125,10 @@ export function BountyGrid() { initial="initial" whileInView="animate" viewport={{ once: true, margin: '-50px' }} - className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-5" + className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3 sm:gap-4 lg:gap-5" > {allBounties.map((bounty) => ( - + ))} @@ -121,11 +137,11 @@ export function BountyGrid() { {/* Load more */} {hasNextPage && ( -
+