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
2 changes: 1 addition & 1 deletion src/app/dashboard/contributor/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ export default function ContributorDashboardPage() {
const [fetchStatus, setFetchStatus] = useState<StatCardStatus>("loading");

useEffect(() => {
fetchBounties(mockBounties).then(setBounties);
fetchBounties(mockBounties).then((res) => setBounties(res.items));
}, []);

useEffect(() => {
Expand Down
3 changes: 2 additions & 1 deletion src/app/dashboard/maintainer/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
}
Expand Down
198 changes: 191 additions & 7 deletions src/app/issues/page.tsx
Original file line number Diff line number Diff line change
@@ -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, string | undefined>): 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<URLSearchParams>;
}) {
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 (
<div className="mx-auto max-w-6xl px-6 py-12">
Expand All @@ -24,11 +91,128 @@ export default async function IssuesPage() {
moment it&apos;s merged.
</p>
</div>
<div className="grid gap-4 md:grid-cols-2">
{bounties.map((bounty) => (
<BountyCard key={bounty.id} bounty={bounty} />
))}

{/* Filter bar — all links update URL for shareability */}
<div className="mb-6 flex flex-wrap items-center gap-3 rounded-xl border border-slate-200 bg-white p-4 shadow-sm dark:border-slate-800 dark:bg-slate-900">
{/* Status filter */}
<div className="flex flex-col gap-1">
<label className="text-xs font-medium text-slate-500 dark:text-slate-400">Status</label>
<div className="flex flex-wrap gap-1">
{STATUS_OPTIONS.map((opt) => {
const isActive = (filters.status ?? "") === opt.value;
const href = buildFilterUrl(sp, { status: opt.value || undefined });
return (
<Link
key={opt.value}
href={href}
className={`rounded-lg px-3 py-1.5 text-xs font-medium transition-colors ${
isActive
? "bg-indigo-600 text-white dark:bg-indigo-500"
: "bg-slate-100 text-slate-600 hover:bg-slate-200 dark:bg-slate-800 dark:text-slate-300 dark:hover:bg-slate-700"
}`}
>
{opt.label}
</Link>
);
})}
</div>
</div>

{/* Difficulty filter */}
<div className="flex flex-col gap-1">
<label className="text-xs font-medium text-slate-500 dark:text-slate-400">Difficulty</label>
<div className="flex flex-wrap gap-1">
{DIFFICULTY_OPTIONS.map((opt) => {
const isActive = (filters.difficulty ?? "") === opt.value;
const href = buildFilterUrl(sp, { difficulty: opt.value || undefined });
return (
<Link
key={opt.value}
href={href}
className={`rounded-lg px-3 py-1.5 text-xs font-medium transition-colors ${
isActive
? "bg-indigo-600 text-white dark:bg-indigo-500"
: "bg-slate-100 text-slate-600 hover:bg-slate-200 dark:bg-slate-800 dark:text-slate-300 dark:hover:bg-slate-700"
}`}
>
{opt.label}
</Link>
);
})}
</div>
</div>

{/* Sort */}
<div className="flex flex-col gap-1">
<label className="text-xs font-medium text-slate-500 dark:text-slate-400">Sort</label>
<div className="flex flex-wrap gap-1">
{SORT_OPTIONS.map((opt) => {
const isActive = (filters.sort ?? "") === opt.value;
const href = buildFilterUrl(sp, { sort: opt.value || undefined });
return (
<Link
key={opt.value}
href={href}
className={`rounded-lg px-3 py-1.5 text-xs font-medium transition-colors ${
isActive
? "bg-indigo-600 text-white dark:bg-indigo-500"
: "bg-slate-100 text-slate-600 hover:bg-slate-200 dark:bg-slate-800 dark:text-slate-300 dark:hover:bg-slate-700"
}`}
>
{opt.label}
</Link>
);
})}
</div>
</div>

{/* Result count */}
<div className="ml-auto text-xs text-slate-400 dark:text-slate-500">
{result.total} {result.total === 1 ? "bounty" : "bounties"}
</div>
</div>

{/* Results */}
{result.items.length === 0 ? (
<EmptyState
icon={SearchX}
title="No bounties match your filters"
description="Try adjusting the status, difficulty, or sort criteria above."
/>
) : (
<>
<div className="grid gap-4 md:grid-cols-2">
{result.items.map((bounty) => (
<BountyCard key={bounty.id} bounty={bounty} />
))}
</div>

{/* Pagination */}
{totalPages > 1 && (
<div className="mt-8 flex items-center justify-center gap-2">
{currentPage > 1 && (
<Link
href={buildFilterUrl(sp, { page: String(currentPage - 1) })}
className="rounded-lg border border-slate-200 bg-white px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-300 dark:hover:bg-slate-800"
>
Previous
</Link>
)}
<span className="px-3 text-sm text-slate-500 dark:text-slate-400">
Page {currentPage} of {totalPages}
</span>
{currentPage < totalPages && (
<Link
href={buildFilterUrl(sp, { page: String(currentPage + 1) })}
className="rounded-lg border border-slate-200 bg-white px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-300 dark:hover:bg-slate-800"
>
Next
</Link>
)}
</div>
)}
</>
)}
</div>
);
}
109 changes: 105 additions & 4 deletions src/lib/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,13 +96,114 @@ export function apiPost<T>(path: string, body?: unknown): Promise<T> {
* 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<Bounty[]> {
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<PaginatedBounties> {
try {
const raw = await request<RawBounty[]>("/bounties");
return raw.map(adaptBounty);
const qs = buildBountyQueryString(filters);
const raw = await request<RawBounty[]>(`/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(
Expand Down