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
73 changes: 73 additions & 0 deletions frontend/src/__tests__/responsive-polish.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import { describe, expect, it, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { BountyCard } from '../components/bounty/BountyCard';
import { Navbar } from '../components/layout/Navbar';
import { Footer } from '../components/layout/Footer';
import { HeroSection } from '../components/home/HeroSection';

vi.mock('../hooks/useStats', () => ({
useStats: () => ({ data: { open_bounties: 12, total_paid_usdc: 3400, total_contributors: 7 } }),
}));

vi.mock('../hooks/useAuth', () => ({
useAuth: () => ({ isAuthenticated: false, user: null, logout: vi.fn() }),
}));

vi.mock('../api/auth', () => ({
getGitHubAuthorizeUrl: vi.fn(),
}));

const bounty = {
id: '1',
title: 'A very long bounty title that should wrap correctly on smaller mobile screens without causing horizontal overflow or layout breakage',
description: 'desc',
status: 'open' as const,
tier: 'T1' as const,
reward_amount: 150,
reward_token: 'FNDRY' as const,
org_name: 'SolFoundry',
repo_name: 'solfoundry',
issue_number: 824,
skills: ['TypeScript', 'React', 'JavaScript'],
deadline: new Date(Date.now() + 86400000).toISOString(),
submission_count: 3,
created_at: new Date().toISOString(),
};

function withProviders(ui: React.ReactElement) {
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return render(
<MemoryRouter>
<QueryClientProvider client={queryClient}>{ui}</QueryClientProvider>
</MemoryRouter>,
);
}

describe('responsive polish', () => {
it('renders bounty card content without losing key mobile information', () => {
withProviders(<BountyCard bounty={bounty} />);
expect(screen.getByText(/A very long bounty title/)).toBeInTheDocument();
expect(screen.getByText('150 FNDRY')).toBeInTheDocument();
expect(screen.getByText('Open')).toBeInTheDocument();
});

it('renders navbar mobile and sign-in controls', () => {
withProviders(<Navbar />);
expect(screen.getByText('Sign in')).toBeInTheDocument();
expect(screen.getAllByRole('button').length).toBeGreaterThan(1);
});

it('renders hero CTAs and stats', () => {
withProviders(<HeroSection />);
expect(screen.getByText('Browse Bounties')).toBeInTheDocument();
expect(screen.getByText('Post a Bounty')).toBeInTheDocument();
});

it('renders footer token contract section without overflow-only content loss', () => {
withProviders(<Footer />);
expect(screen.getByText('$FNDRY Token')).toBeInTheDocument();
expect(screen.getByTitle('Copy contract address')).toBeInTheDocument();
});
});
53 changes: 21 additions & 32 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 @@ -61,69 +62,57 @@ 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 rounded-xl border border-border bg-forge-900 p-4 sm:p-5 cursor-pointer transition-colors duration-200 overflow-hidden group min-w-0"
>
{/* Row 1: Repo + Tier */}
<div className="flex items-center justify-between text-sm">
<div className="flex items-center gap-2 min-w-0">
<div className="flex items-start justify-between gap-2 text-sm">
<div className="flex items-center gap-2 min-w-0 flex-1">
{bounty.org_avatar_url && (
<img src={bounty.org_avatar_url} className="w-5 h-5 rounded-full flex-shrink-0" alt="" />
)}
<span className="text-text-muted font-mono text-xs truncate">
<span className="text-text-muted font-mono text-[11px] sm:text-xs truncate block min-w-0">
{orgName}/{repoName}
{issueNumber && <span className="ml-1">#{issueNumber}</span>}
</span>
</div>
<TierBadge tier={bounty.tier ?? 'T1'} />
<div className="flex-shrink-0">
<TierBadge tier={bounty.tier ?? 'T1'} />
</div>
</div>

{/* Row 2: Title */}
<h3 className="mt-3 font-sans text-base font-semibold text-text-primary leading-snug line-clamp-2">
<h3 className="mt-3 font-sans text-sm sm:text-base font-semibold text-text-primary leading-snug line-clamp-3 sm:line-clamp-2 break-words">
{bounty.title}
</h3>

{/* Row 3: Language dots */}
{skills.length > 0 && (
<div className="flex items-center gap-3 mt-3">
<div className="flex items-center gap-x-3 gap-y-2 flex-wrap mt-3 min-w-0">
{skills.map((lang) => (
<span key={lang} className="inline-flex items-center gap-1.5 text-xs text-text-muted">
<span
className="w-2.5 h-2.5 rounded-full"
style={{ backgroundColor: LANG_COLORS[lang] ?? '#888' }}
/>
{lang}
<span key={lang} className="inline-flex items-center gap-1.5 text-xs text-text-muted max-w-full">
<span className="w-2.5 h-2.5 rounded-full flex-shrink-0" style={{ backgroundColor: LANG_COLORS[lang] ?? '#888' }} />
<span className="truncate">{lang}</span>
</span>
))}
</div>
)}

{/* Separator */}
<div className="mt-4 border-t border-border/50" />

{/* Row 4: Reward + Meta */}
<div className="flex items-center justify-between mt-3">
<span className="font-mono text-lg font-semibold text-emerald">
<div className="mt-3 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<span className="font-mono text-base sm:text-lg font-semibold text-emerald truncate">
{formatCurrency(bounty.reward_amount, bounty.reward_token)}
</span>
<div className="flex items-center gap-3 text-xs text-text-muted">
<div className="flex flex-wrap items-center gap-x-3 gap-y-2 text-xs text-text-muted sm:justify-end">
<span className="inline-flex items-center gap-1">
<GitPullRequest className="w-3.5 h-3.5" />
{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>
)}
{bounty.deadline && <BountyCountdown deadline={bounty.deadline} compact />}
</div>
</div>

{/* Status badge */}
<span className={`absolute bottom-4 right-5 text-xs font-medium inline-flex items-center gap-1 ${statusColor}`}>
<div className={`mt-3 sm:mt-4 text-xs font-medium inline-flex items-center gap-1 ${statusColor}`}>
<span className={`w-1.5 h-1.5 rounded-full ${dotColor}`} />
{statusLabel}
</span>
</div>
</motion.div>
);
}
55 changes: 55 additions & 0 deletions frontend/src/components/bounty/BountyCountdown.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import React, { useEffect, useMemo, useState } from 'react';
import { Clock } from 'lucide-react';

interface CountdownState {
expired: boolean;
totalMs: number;
days: number;
hours: number;
minutes: number;
}

function getCountdownState(deadline: string): CountdownState {
const totalMs = new Date(deadline).getTime() - Date.now();

if (Number.isNaN(totalMs) || totalMs <= 0) {
return { expired: true, totalMs: 0, days: 0, hours: 0, minutes: 0 };
}

const totalMinutes = Math.floor(totalMs / 60000);
const days = Math.floor(totalMinutes / (60 * 24));
const hours = Math.floor((totalMinutes % (60 * 24)) / 60);
const minutes = totalMinutes % 60;

return { expired: false, totalMs, days, hours, minutes };
}

function getToneClass(state: CountdownState) {
if (state.expired) return 'text-status-error';
if (state.totalMs < 60 * 60 * 1000) return 'text-status-error';
if (state.totalMs < 24 * 60 * 60 * 1000) return 'text-status-warning';
return 'text-text-muted';
}

export function BountyCountdown({ deadline, compact = false }: { deadline: string; compact?: boolean }) {
const [now, setNow] = useState(() => Date.now());

useEffect(() => {
const timer = window.setInterval(() => setNow(Date.now()), 30000);
return () => window.clearInterval(timer);
}, []);

const state = useMemo(() => {
void now;
return getCountdownState(deadline);
}, [deadline, now]);

const label = state.expired ? 'Expired' : `${state.days}d ${state.hours}h ${state.minutes}m`;

return (
<span className={`inline-flex items-center gap-1 ${getToneClass(state)}`} aria-label={`Time remaining: ${label}`}>
<Clock className={compact ? 'w-3.5 h-3.5' : 'w-4 h-4'} />
<span className="font-mono">{label}</span>
</span>
);
}
Loading
Loading