Skip to content
Merged
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
27 changes: 27 additions & 0 deletions frontend/src/app/(dashboard)/bounties/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
"use client";

import React from "react";
import { BountyFilters } from "@/components/BountyFilters";
import { BountyList } from "@/components/BountyList";
import { useBountyDiscovery } from "@/hooks/useBountyDiscovery";

export default function BountiesPage() {
const { tasks, pagination, isLoading, error, filters, setFilters, resetFilters, setPage } =
useBountyDiscovery();

return (
<div className="flex flex-col gap-4 sm:gap-6 lg:gap-8 w-full max-w-full">
<h1 className="text-3xl font-bold text-white">Discover Bounties</h1>

<BountyFilters filters={filters} onFilterChange={setFilters} onReset={resetFilters} />

<BountyList
tasks={tasks}
pagination={pagination}
isLoading={isLoading}
error={error}
onPageChange={setPage}
/>
</div>
);
}
65 changes: 64 additions & 1 deletion frontend/src/app/api/tasks/route.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,64 @@
import { createTask } from "@/lib/task-workflow";
import { createTask, listTasks } from "@/lib/task-workflow";
import { buildNoStoreJson } from "@/lib/api-response";
import { checkRateLimit } from "@/lib/rate-limit";
import type { TaskDifficulty, TaskSortOrder } from "@/types/task-workflow";

export const runtime = "nodejs";
export const dynamic = "force-dynamic";

const VALID_DIFFICULTIES: TaskDifficulty[] = ["beginner", "intermediate", "advanced"];
const VALID_SORTS: TaskSortOrder[] = ["newest", "reward_desc", "deadline_asc"];

function parseOptionalNumber(value: string | null): number | undefined {
if (!value) return undefined;
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : undefined;
}

export async function GET(request: Request) {
const { response: rateLimitResponse, headers: rateLimitHeaders } =
checkRateLimit(request);
if (rateLimitResponse) {
return rateLimitResponse;
}

const { searchParams } = new URL(request.url);

const difficultyParam = searchParams.get("difficulty");
const sortParam = searchParams.get("sort");

const result = listTasks({
search: searchParams.get("search") ?? undefined,
minReward: parseOptionalNumber(searchParams.get("minReward")),
maxReward: parseOptionalNumber(searchParams.get("maxReward")),
difficulty: VALID_DIFFICULTIES.includes(difficultyParam as TaskDifficulty)
? (difficultyParam as TaskDifficulty)
: undefined,
technology: searchParams.get("technology") ?? undefined,
organization: searchParams.get("organization") ?? undefined,
sort: VALID_SORTS.includes(sortParam as TaskSortOrder)
? (sortParam as TaskSortOrder)
: undefined,
page: parseOptionalNumber(searchParams.get("page")),
pageSize: parseOptionalNumber(searchParams.get("pageSize")),
});

return buildNoStoreJson(
{
ok: true,
tasks: result.tasks,
pagination: {
page: result.page,
pageSize: result.pageSize,
total: result.total,
totalPages: result.totalPages,
},
},
200,
rateLimitHeaders,
);
}

export async function POST(request: Request) {
const { response: rateLimitResponse, headers: rateLimitHeaders } =
checkRateLimit(request);
Expand Down Expand Up @@ -40,13 +94,22 @@ export async function POST(request: Request) {
}

const payload = body as Record<string, unknown>;
const difficulty = VALID_DIFFICULTIES.includes(payload.difficulty as TaskDifficulty)
? (payload.difficulty as TaskDifficulty)
: undefined;

const result = createTask({
poster: String(payload.poster ?? ""),
title: String(payload.title ?? ""),
description: String(payload.description ?? ""),
reward: Number(payload.reward),
deadline: Number(payload.deadline),
maxSubmissions: Number(payload.maxSubmissions),
difficulty,
technologies: Array.isArray(payload.technologies)
? payload.technologies.map((tech) => String(tech))
: undefined,
organization: payload.organization ? String(payload.organization) : undefined,
});

if (!result.ok) {
Expand Down
178 changes: 178 additions & 0 deletions frontend/src/components/BountyFilters.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
"use client";

import React from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import type { BountyFilters as BountyFiltersState } from "@/hooks/useBountyDiscovery";
import type { TaskDifficulty, TaskSortOrder } from "@/types/task-workflow";

const DIFFICULTY_OPTIONS: Array<{ value: TaskDifficulty; label: string }> = [
{ value: "beginner", label: "Beginner" },
{ value: "intermediate", label: "Intermediate" },
{ value: "advanced", label: "Advanced" },
];

const SORT_OPTIONS: Array<{ value: TaskSortOrder; label: string }> = [
{ value: "newest", label: "Newest" },
{ value: "reward_desc", label: "Highest reward" },
{ value: "deadline_asc", label: "Deadline (soonest)" },
];

/** Sentinel used by Select items that map back to "no filter" (empty string). */
const ANY_VALUE = "any";

interface BountyFiltersProps {
filters: BountyFiltersState;
onFilterChange: (partial: Partial<BountyFiltersState>) => void;
onReset: () => void;
}

export function BountyFilters({ filters, onFilterChange, onReset }: BountyFiltersProps) {
return (
<div className="bg-[#0A0B0F]/40 backdrop-blur-xl rounded-xl sm:rounded-2xl lg:rounded-3xl border border-white/10 shadow-2xl p-4 sm:p-6 lg:p-8 w-full">
<div className="flex flex-col gap-6">
<h2 className="text-2xl font-bold text-white">Filter Bounties</h2>

{/* Keyword search */}
<div className="space-y-2">
<label htmlFor="bounty-search" className="text-sm font-medium text-muted-foreground">
Search
</label>
<Input
id="bounty-search"
type="search"
placeholder="Search by title or description"
value={filters.search}
onChange={(e) => onFilterChange({ search: e.target.value })}
/>
</div>

<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
{/* Min Reward */}
<div className="space-y-2">
<label htmlFor="bounty-min-reward" className="text-sm font-medium text-muted-foreground">
Min Reward
</label>
<Input
id="bounty-min-reward"
type="number"
placeholder="0"
value={filters.minReward}
onChange={(e) =>
onFilterChange({ minReward: e.target.value ? Number(e.target.value) : "" })
}
/>
</div>

{/* Max Reward */}
<div className="space-y-2">
<label htmlFor="bounty-max-reward" className="text-sm font-medium text-muted-foreground">
Max Reward
</label>
<Input
id="bounty-max-reward"
type="number"
placeholder="1000000000"
value={filters.maxReward}
onChange={(e) =>
onFilterChange({ maxReward: e.target.value ? Number(e.target.value) : "" })
}
/>
</div>

{/* Difficulty */}
<div className="space-y-2">
<label htmlFor="bounty-difficulty" className="text-sm font-medium text-muted-foreground">
Difficulty
</label>
<Select
value={filters.difficulty || ANY_VALUE}
onValueChange={(value) =>
onFilterChange({
difficulty: value === ANY_VALUE ? "" : (value as TaskDifficulty),
})
}
>
<SelectTrigger id="bounty-difficulty">
<SelectValue placeholder="Any difficulty" />
</SelectTrigger>
<SelectContent>
<SelectItem value={ANY_VALUE}>Any difficulty</SelectItem>
{DIFFICULTY_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>

{/* Sort */}
<div className="space-y-2">
<label htmlFor="bounty-sort" className="text-sm font-medium text-muted-foreground">
Sort by
</label>
<Select
value={filters.sort}
onValueChange={(value) => onFilterChange({ sort: value as TaskSortOrder })}
>
<SelectTrigger id="bounty-sort">
<SelectValue placeholder="Sort by" />
</SelectTrigger>
<SelectContent>
{SORT_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>

<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{/* Technology */}
<div className="space-y-2">
<label htmlFor="bounty-technology" className="text-sm font-medium text-muted-foreground">
Technology
</label>
<Input
id="bounty-technology"
type="text"
placeholder="e.g. Rust, Soroban"
value={filters.technology}
onChange={(e) => onFilterChange({ technology: e.target.value })}
/>
</div>

{/* Organization */}
<div className="space-y-2">
<label htmlFor="bounty-organization" className="text-sm font-medium text-muted-foreground">
Organization
</label>
<Input
id="bounty-organization"
type="text"
placeholder="e.g. Stellar Development Foundation"
value={filters.organization}
onChange={(e) => onFilterChange({ organization: e.target.value })}
/>
</div>
</div>

<div className="flex flex-wrap gap-3">
<Button onClick={onReset} variant="outline">
Reset Filters
</Button>
</div>
</div>
</div>
);
}
Loading
Loading