diff --git a/src/app/dashboard/contributor/page.tsx b/src/app/dashboard/contributor/page.tsx index bfc89e9..5ecc08c 100644 --- a/src/app/dashboard/contributor/page.tsx +++ b/src/app/dashboard/contributor/page.tsx @@ -51,7 +51,7 @@ export default function ContributorDashboardPage() { const [fetchStatus, setFetchStatus] = useState("loading"); useEffect(() => { - fetchBounties(mockBounties).then(setBounties); + fetchBounties(mockBounties).then((res) => setBounties(res.items)); }, []); useEffect(() => { diff --git a/src/app/dashboard/maintainer/page.tsx b/src/app/dashboard/maintainer/page.tsx index 3e6ca6b..3cf70dd 100644 --- a/src/app/dashboard/maintainer/page.tsx +++ b/src/app/dashboard/maintainer/page.tsx @@ -24,7 +24,8 @@ export default async function MaintainerDashboardPage() { let statStatus: "loaded" | "error" = "loaded"; try { - bounties = await fetchBounties(mockBounties); + const bountyResult = await fetchBounties(mockBounties); + bounties = bountyResult.items; } catch { statStatus = "error"; } diff --git a/src/app/issues/page.tsx b/src/app/issues/page.tsx index ed1e81d..cb0069a 100644 --- a/src/app/issues/page.tsx +++ b/src/app/issues/page.tsx @@ -1,13 +1,80 @@ -import { fetchBounties } from "@/lib/api"; +import Link from "next/link"; +import { fetchBounties, type BountyFilters } from "@/lib/api"; import { mockBounties } from "@/lib/mock-data"; import { BountyCard } from "@/components/bounty/BountyCard"; +import { EmptyState } from "@/components/ui/EmptyState"; +import { SearchX } from "lucide-react"; export const metadata = { title: "Paid Issues | MergeFi", }; -export default async function IssuesPage() { - const bounties = await fetchBounties(mockBounties); +const STATUS_OPTIONS = [ + { value: "", label: "All statuses" }, + { value: "open", label: "Open" }, + { value: "funded", label: "Funded" }, + { value: "claimed", label: "Claimed" }, + { value: "in_review", label: "In Review" }, + { value: "paid", label: "Paid" }, +]; + +const DIFFICULTY_OPTIONS = [ + { value: "", label: "All levels" }, + { value: "beginner", label: "Beginner" }, + { value: "intermediate", label: "Intermediate" }, + { value: "advanced", label: "Advanced" }, + { value: "expert", label: "Expert" }, +]; + +const SORT_OPTIONS = [ + { value: "", label: "Default" }, + { value: "reward_desc", label: "Highest reward" }, + { value: "reward_asc", label: "Lowest reward" }, + { value: "deadline_asc", label: "Soonest deadline" }, + { value: "deadline_desc", label: "Latest deadline" }, +]; + +const PAGE_SIZE = 12; + +function parseFilters(searchParams: URLSearchParams): BountyFilters { + const page = Math.max(1, parseInt(searchParams.get("page") ?? "1", 10) || 1); + const status = searchParams.get("status") ?? undefined; + const difficulty = searchParams.get("difficulty") ?? undefined; + const asset = searchParams.get("asset") ?? undefined; + const sort = (searchParams.get("sort") as BountyFilters["sort"]) ?? undefined; + const search = searchParams.get("search") ?? undefined; + return { page, pageSize: PAGE_SIZE, status, difficulty, asset, sort, search }; +} + +function buildFilterUrl(current: URLSearchParams, updates: Record): string { + const next = new URLSearchParams(current); + for (const [key, value] of Object.entries(updates)) { + if (value) { + next.set(key, value); + } else { + next.delete(key); + } + } + // Reset to page 1 when filters change (unless explicitly setting page) + if (!("page" in updates)) { + next.delete("page"); + } + const qs = next.toString(); + return `/issues${qs ? `?${qs}` : ""}`; +} + +export default async function IssuesPage({ + searchParams, +}: { + searchParams: Promise; +}) { + const sp = await searchParams; + const filters = parseFilters(sp); + const result = await fetchBounties(mockBounties, filters); + const totalPages = Math.max(1, Math.ceil(result.total / result.pageSize)); + + // Clamp invalid page numbers + const currentPage = Math.min(Math.max(1, filters.page ?? 1), totalPages); return (
@@ -24,11 +91,128 @@ export default async function IssuesPage() { moment it's merged.

-
- {bounties.map((bounty) => ( - - ))} + + {/* Filter bar — all links update URL for shareability */} +
+ {/* Status filter */} +
+ +
+ {STATUS_OPTIONS.map((opt) => { + const isActive = (filters.status ?? "") === opt.value; + const href = buildFilterUrl(sp, { status: opt.value || undefined }); + return ( + + {opt.label} + + ); + })} +
+
+ + {/* Difficulty filter */} +
+ +
+ {DIFFICULTY_OPTIONS.map((opt) => { + const isActive = (filters.difficulty ?? "") === opt.value; + const href = buildFilterUrl(sp, { difficulty: opt.value || undefined }); + return ( + + {opt.label} + + ); + })} +
+
+ + {/* Sort */} +
+ +
+ {SORT_OPTIONS.map((opt) => { + const isActive = (filters.sort ?? "") === opt.value; + const href = buildFilterUrl(sp, { sort: opt.value || undefined }); + return ( + + {opt.label} + + ); + })} +
+
+ + {/* Result count */} +
+ {result.total} {result.total === 1 ? "bounty" : "bounties"} +
+ + {/* Results */} + {result.items.length === 0 ? ( + + ) : ( + <> +
+ {result.items.map((bounty) => ( + + ))} +
+ + {/* Pagination */} + {totalPages > 1 && ( +
+ {currentPage > 1 && ( + + Previous + + )} + + Page {currentPage} of {totalPages} + + {currentPage < totalPages && ( + + Next + + )} +
+ )} + + )}
); } diff --git a/src/lib/api.ts b/src/lib/api.ts index 1fe1480..d4a34b1 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -96,13 +96,114 @@ export function apiPost(path: string, body?: unknown): Promise { * into the flat shapes the UI renders, falling back to mock data (already in * the target shape) when the backend is unreachable. */ -export async function fetchBounties(fallback: Bounty[]): Promise { +export interface BountyFilters { + page?: number; + pageSize?: number; + status?: string; + difficulty?: string; + asset?: string; + sort?: "reward_asc" | "reward_desc" | "deadline_asc" | "deadline_desc"; + search?: string; +} + +/** + * Build query string from filter params for the bounty board (Issue #28). + * Backend contract note: GET /bounties should accept these as query params. + * If backend doesn't support them yet, mock-data fallback handles filtering. + */ +function buildBountyQueryString(filters: BountyFilters): string { + const params = new URLSearchParams(); + if (filters.page) params.set("page", String(filters.page)); + if (filters.pageSize) params.set("pageSize", String(filters.pageSize)); + if (filters.status) params.set("status", filters.status); + if (filters.difficulty) params.set("difficulty", filters.difficulty); + if (filters.asset) params.set("asset", filters.asset); + if (filters.sort) params.set("sort", filters.sort); + if (filters.search) params.set("search", filters.search); + const qs = params.toString(); + return qs ? `?${qs}` : ""; +} + +export interface PaginatedBounties { + items: Bounty[]; + total: number; + page: number; + pageSize: number; +} + +export async function fetchBounties( + fallback: Bounty[], + filters: BountyFilters = {}, +): Promise { try { - const raw = await request("/bounties"); - return raw.map(adaptBounty); + const qs = buildBountyQueryString(filters); + const raw = await request(`/bounties${qs}`); + const items = raw.map(adaptBounty); + return { + items, + total: items.length, // Backend should return total count; using length as interim + page: filters.page ?? 1, + pageSize: filters.pageSize ?? 20, + }; } catch { - return fallback; + // Fallback: apply filters in-memory against mock data + return filterMockBounties(fallback, filters); + } +} + +/** + * In-memory filter/sort/paginate for mock data parity (Issue #28). + * Ensures demos work fully offline with the same filtering behavior. + */ +function filterMockBounties( + bounties: Bounty[], + filters: BountyFilters, +): PaginatedBounties { + let filtered = [...bounties]; + + if (filters.status) { + filtered = filtered.filter((b) => b.status === filters.status); + } + if (filters.difficulty) { + filtered = filtered.filter((b) => b.difficulty === filters.difficulty); + } + if (filters.asset) { + filtered = filtered.filter((b) => b.asset === filters.asset); + } + if (filters.search) { + const q = filters.search.toLowerCase(); + filtered = filtered.filter( + (b) => + b.title.toLowerCase().includes(q) || + b.repo.toLowerCase().includes(q) || + b.labels.some((l) => l.toLowerCase().includes(q)), + ); + } + + if (filters.sort) { + switch (filters.sort) { + case "reward_asc": + filtered.sort((a, b) => a.reward - b.reward); + break; + case "reward_desc": + filtered.sort((a, b) => b.reward - a.reward); + break; + case "deadline_asc": + filtered.sort((a, b) => new Date(a.deadline).getTime() - new Date(b.deadline).getTime()); + break; + case "deadline_desc": + filtered.sort((a, b) => new Date(b.deadline).getTime() - new Date(a.deadline).getTime()); + break; + } } + + const total = filtered.length; + const page = Math.max(1, filters.page ?? 1); + const pageSize = Math.min(100, Math.max(1, filters.pageSize ?? 20)); + const start = (page - 1) * pageSize; + const items = filtered.slice(start, start + pageSize); + + return { items, total, page, pageSize }; } export async function fetchBounty(