Skip to content
Open
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
363 changes: 317 additions & 46 deletions app/bounty/page.tsx
Original file line number Diff line number Diff line change
@@ -1,25 +1,124 @@
"use client";

import { useMemo } from "react";
import { useState } from "react";
import Link from "next/link";
import { useBounties } from "@/hooks/use-bounties";
import { useDebounce } from "@/hooks/use-debounce";
import { BountyCard } from "@/components/bounty/bounty-card";
import { BountyListSkeleton } from "@/components/bounty/bounty-card-skeleton";
import { BountyError } from "@/components/bounty/bounty-error";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { Separator } from "@/components/ui/separator";
import {
useBountyFilters,
BOUNTY_TYPES,
STATUSES,
} from "@/hooks/use-bounty-filters";
import { FiltersSidebar } from "@/components/bounty/filters-sidebar";
import { BountyToolbar } from "@/components/bounty/bounty-toolbar";
import { BountyGrid } from "@/components/bounty/bounty-grid";
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "@/components/ui/accordion";
import { Checkbox } from "@/components/ui/checkbox";
import { Label } from "@/components/ui/label";
import { Search, Filter } from "lucide-react";
import { MiniLeaderboard } from "@/components/leaderboard/mini-leaderboard";
import {
BountyStatus,
BountyType,
type BountyQueryInput,
} from "@/lib/graphql/generated";

export default function BountiesPage() {
const { data, isLoading, isError, error, refetch } = useBounties();
const allBounties = useMemo(() => data?.data ?? [], [data?.data]);
const [searchQuery, setSearchQuery] = useState("");
const [selectedType, setSelectedType] = useState<BountyType | "all">("all");
const [statusFilter, setStatusFilter] = useState<BountyStatus | "all">(
BountyStatus.Open,
);
const [sortOption, setSortOption] = useState<string>("newest");
const [page, setPage] = useState(1);

const BOUNTY_TYPES: { value: BountyType; label: string }[] = [
{ value: BountyType.FixedPrice, label: "Fixed Price" },
{ value: BountyType.MilestoneBased, label: "Milestone Based" },
{ value: BountyType.Competition, label: "Competition" },
];

const STATUSES: { value: BountyStatus | "all"; label: string }[] = [
{ value: BountyStatus.Open, label: "Open" },
{ value: BountyStatus.InProgress, label: "In Progress" },
{ value: BountyStatus.Completed, label: "Completed" },
{ value: BountyStatus.Cancelled, label: "Cancelled" },
{ value: BountyStatus.Draft, label: "Draft" },
{ value: BountyStatus.Submitted, label: "Submitted" },
{ value: BountyStatus.UnderReview, label: "Under Review" },
{ value: BountyStatus.Disputed, label: "Disputed" },
{ value: "all", label: "All Statuses" },
];

const debouncedSearchQuery = useDebounce(searchQuery, 500);

const getSortParams = () => {
switch (sortOption) {
case "highest_reward":
return { sortBy: "rewardAmount", sortOrder: "desc" };
case "recently_updated":
return { sortBy: "updatedAt", sortOrder: "desc" };
case "newest":
default:
return { sortBy: "createdAt", sortOrder: "desc" };
}
};

const queryParams: BountyQueryInput = {
page,
limit: 20,
...(debouncedSearchQuery && { search: debouncedSearchQuery }),
...(selectedType !== "all" && { type: selectedType }),
...(statusFilter !== "all" && { status: statusFilter }),
...getSortParams(),
};
Comment on lines +63 to +84
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Clearing the search keeps the old backend filter alive for 500ms.

Line 80 reads from debouncedSearchQuery, so Line 110 and Line 167 clear the textbox immediately but leave the previous search variable in the request until the debounce expires. That leaves stale counts/results on screen after the UI already shows an empty query.

💡 One way to drop the search filter immediately on clear
   const debouncedSearchQuery = useDebounce(searchQuery, 500);
+  const effectiveSearchQuery =
+    searchQuery === "" ? "" : debouncedSearchQuery;
 
   const queryParams: BountyQueryInput = {
     page,
     limit: 20,
-    ...(debouncedSearchQuery && { search: debouncedSearchQuery }),
+    ...(effectiveSearchQuery && { search: effectiveSearchQuery }),
     ...(selectedType !== "all" && { type: selectedType }),
     ...(statusFilter !== "all" && { status: statusFilter }),
     ...getSortParams(),
   };

Also applies to: 109-110, 166-169

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/bounty/page.tsx` around lines 63 - 84, The bug is that queryParams uses
debouncedSearchQuery so clearing the textbox empties searchQuery immediately but
the backend filter lingers until useDebounce updates; update the query
construction to drop the search filter immediately when searchQuery is an empty
string (e.g. use a conditional like ...(searchQuery === "" ? {} :
debouncedSearchQuery && { search: debouncedSearchQuery })) so that clearing the
input removes the search param right away; reference the variables useDebounce,
debouncedSearchQuery, searchQuery and the queryParams object (and keep
getSortParams unchanged).


const { data, isLoading, isError, error, refetch } = useBounties(queryParams);

const bounties = data?.data ?? [];
const pagination = data?.pagination;
const totalResults = pagination?.total ?? 0;
const currentPage = pagination?.page ?? page;
const totalPages = pagination?.totalPages ?? 1;

const toggleType = (type: BountyType) => {
setSelectedType((prev) => (prev === type ? "all" : type));
setPage(1);
};

const filters = useBountyFilters(allBounties);
const handleStatusChange = (status: string) => {
setStatusFilter(status as BountyStatus | "all");
setPage(1);
};

const handleSortChange = (sort: string) => {
setSortOption(sort);
setPage(1);
};

const clearFilters = () => {
setSearchQuery("");
setSelectedType("all");
setStatusFilter(BountyStatus.Open);
setSortOption("newest");
setPage(1);
};
Comment on lines 109 to 115
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

clearFilters doesn't actually clear status.

Line 112 restores BountyStatus.Open, and Line 145 treats OPEN as the “no active filters” baseline. That means the reset flow still excludes completed/cancelled bounties. If OPEN is intentional, the action copy should say “Reset to defaults”; otherwise this should reset to "all".

Also applies to: 143-145

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/bounty/page.tsx` around lines 109 - 115, clearFilters currently sets
status back to BountyStatus.Open which leaves out completed/cancelled bounties;
either change the reset to a true "all" value (e.g., setStatusFilter("all") or a
BountyStatus.All enum) and update any filter-check logic that treats OPEN as the
no-filter baseline (references: clearFilters, setStatusFilter,
BountyStatus.Open), or if OPEN is intentional keep the code but change the
UI/action copy from "Clear filters" to "Reset to defaults" so it accurately
reflects that completed/cancelled items remain excluded; make the corresponding
change where the status baseline is evaluated (the code that checks OPEN as no
active filters) so behavior and wording remain consistent.


const hasPreviousPage = currentPage > 1;
const hasNextPage = currentPage < totalPages;

return (
<div className="min-h-screen text-foreground pb-20 relative overflow-hidden">
{/* Background ambient glow */}
<div className="fixed top-0 left-0 w-full h-125 bg-primary/5 rounded-full blur-[120px] -translate-y-1/2 pointer-events-none" />

<div className="container mx-auto px-4 py-12 relative z-10">
Expand All @@ -34,42 +133,214 @@ export default function BountiesPage() {
</header>

<div className="flex flex-col lg:flex-row gap-10">
<FiltersSidebar
searchQuery={filters.searchQuery}
onSearchChange={filters.setSearchQuery}
selectedTypes={filters.selectedTypes}
onToggleType={filters.toggleType}
bountyTypes={BOUNTY_TYPES}
selectedOrgs={filters.selectedOrgs}
onToggleOrg={filters.toggleOrg}
organizations={filters.organizations}
rewardRange={filters.rewardRange}
onRewardRangeChange={filters.setRewardRange}
statusFilter={filters.statusFilter}
onStatusChange={filters.setStatusFilter}
statuses={STATUSES}
hasActiveFilters={filters.hasActiveFilters}
onClearFilters={filters.clearFilters}
/>
<aside className="w-full lg:w-70 shrink-0 space-y-8">
<div className="lg:sticky lg:top-24 space-y-6">
<div className="p-5 rounded-xl border border-gray-800 bg-background-card backdrop-blur-xl shadow-sm">
<div className="flex items-center justify-between mb-6">
<h2 className="text-sm font-bold uppercase tracking-wider flex items-center gap-2">
<Filter className="size-4" /> Filters
</h2>
{(searchQuery ||
selectedType !== "all" ||
statusFilter !== BountyStatus.Open) && (
<Button
variant="ghost"
size="sm"
onClick={clearFilters}
className="h-6 text-[10px] text-primary hover:text-primary/80 p-0 hover:bg-transparent"
>
Reset
</Button>
)}
</div>

<div className="space-y-6">
<div className="space-y-2">
<Label className="text-xs font-medium">Search</Label>
<div className="relative group">
<Search className="absolute left-3 top-2.5 size-4 group-focus-within:text-primary transition-colors" />
<Input
placeholder="Keywords..."
className="pl-9 h-9 text-sm"
value={searchQuery}
onChange={(e) => {
setSearchQuery(e.target.value);
setPage(1);
}}
/>
</div>
</div>

<div className="space-y-2">
<Label className="text-xs font-medium text-gray-400">
Status
</Label>
<Select
value={statusFilter}
onValueChange={handleStatusChange}
>
<SelectTrigger className="w-full border-gray-700 hover:border-gray-600 focus:border-primary/50 h-9">
<SelectValue placeholder="Select status" />
</SelectTrigger>
<SelectContent className="bg-background border-px border-primary/30">
{STATUSES.map((status) => (
<SelectItem key={status.value} value={status.value}>
{status.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>

<Separator className="bg-gray-800/50" />

<Accordion
type="single"
collapsible
defaultValue="type"
className="w-full"
>
<AccordionItem value="type" className="border-none">
<AccordionTrigger className="text-xs font-medium hover:no-underline">
BOUNTY TYPE
</AccordionTrigger>
<AccordionContent>
<div className="space-y-2 pt-2">
{BOUNTY_TYPES.map((type) => (
<div
key={type.value}
className="flex items-center space-x-2.5 group"
>
<Checkbox
id={`type-${type.value}`}
checked={selectedType === type.value}
onCheckedChange={() => toggleType(type.value)}
className="border-gray-600 data-[state=checked]:bg-primary data-[state=checked]:border-primary"
/>
<Label
htmlFor={`type-${type.value}`}
className="text-sm font-normal cursor-pointer transition-colors"
>
{type.label}
</Label>
</div>
))}
</div>
</AccordionContent>
</AccordionItem>
</Accordion>

<p className="text-xs text-muted-foreground">
Organization and reward-range filters are temporarily
unavailable in this view because they are not yet supported
by the backend query.
</p>
</div>
</div>

<div className="hidden lg:block">
<MiniLeaderboard className="w-full" />
</div>
</div>
</aside>

<main className="flex-1 min-w-0">
<BountyToolbar
totalCount={filters.filteredBounties.length}
sortOption={filters.sortOption}
onSortChange={filters.setSortOption}
/>
<BountyGrid
bounties={filters.filteredBounties}
isLoading={isLoading}
isError={isError}
errorMessage={
error instanceof Error
? error.message
: "Failed to load bounties"
}
onRetry={refetch}
onClearFilters={filters.clearFilters}
/>
<div className="mb-6 flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4 backdrop-blur-sm">
<div className="text-sm ">
<span className="font-semibold ">{totalResults}</span> results
found
</div>
<div className="flex items-center gap-3">
<span className="text-sm hidden sm:inline font-medium">
Sort by:
</span>
<Select value={sortOption} onValueChange={handleSortChange}>
<SelectTrigger className="w-44 focus:border-primary/50 h-9">
<SelectValue placeholder="Sort by" />
</SelectTrigger>
<SelectContent align="end">
<SelectItem value="newest">Newest First</SelectItem>
<SelectItem value="highest_reward">
Highest Reward
</SelectItem>
<SelectItem value="recently_updated">
Recently Updated
</SelectItem>
</SelectContent>
</Select>
</div>
</div>

{isLoading ? (
<BountyListSkeleton count={6} />
) : isError ? (
<BountyError
message={
error instanceof Error
? error.message
: "Failed to load bounties"
}
onRetry={() => refetch()}
/>
) : bounties.length > 0 ? (
<>
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-6 auto-rows-fr">
{bounties.map((bounty) => (
<Link
key={bounty.id}
href={`/bounty/${bounty.id}`}
className="h-full block"
>
<BountyCard bounty={bounty} />
</Link>
))}
</div>

<div className="mt-8 flex flex-col sm:flex-row items-center justify-between gap-3">
<p className="text-sm text-muted-foreground">
Page {currentPage} of {totalPages}
</p>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
disabled={!hasPreviousPage}
onClick={() => setPage((prev) => Math.max(1, prev - 1))}
>
Previous
</Button>
<Button
variant="outline"
size="sm"
disabled={!hasNextPage}
onClick={() => setPage((prev) => prev + 1)}
>
Next
</Button>
</div>
</div>
</>
) : (
<div className="flex flex-col items-center justify-center py-24 text-center border border-dashed border-gray-800 rounded-2xl bg-background-card/30">
<div className="size-16 rounded-full bg-gray-800/50 flex items-center justify-center mb-4">
<Search className="size-8 text-gray-600" />
</div>
<h3 className="text-xl font-bold mb-2 text-gray-200">
No bounties found
</h3>
<p className="text-gray-400 max-w-md mx-auto mb-6">
We couldn&apos;t find any bounties matching your current
filters. Try adjusting your search terms or filters.
</p>
<Button
onClick={clearFilters}
variant="outline"
className="border-gray-700 hover:bg-gray-800"
>
Clear all filters
</Button>
</div>
)}
</main>
</div>
</div>
Expand Down