Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
1 change: 1 addition & 0 deletions .vscode/settings.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{}
268 changes: 268 additions & 0 deletions app/discover/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,268 @@
/* eslint-disable react-hooks/static-components */
"use client";

import { useCallback, useEffect, useState } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { FilterPanel } from "@/components/filters/filter-panel";
import { ProjectCard } from "@/components/cards/project-card";
import { BountyCard } from "@/components/cards/bounty-card";
import { Skeleton } from "@/components/ui/skeleton";
import { mockProjects, mockBounties } from "@/lib/mock-data";
import { FilterState, TabType } from "@/lib/types";
import { PackageOpen, Coins } from "lucide-react";

// Validation helpers
const isValidTab = (value: string | null): value is TabType => {
return value === "projects" || value === "bounties";
};

const isValidSort = (value: string | null): value is FilterState["sort"] => {
return (
value === "newest" ||
value === "recentlyUpdated" ||
value === "highestReward"
);
};

export default function DiscoverPage() {
const router = useRouter();
const searchParams = useSearchParams();

// Initialize state from URL params with validation
const initialTabParam = searchParams.get("tab");
const initialSortParam = searchParams.get("sort");

const [activeTab, setActiveTab] = useState<TabType>(
isValidTab(initialTabParam) ? initialTabParam : "projects",
);
const [filters, setFilters] = useState<FilterState>({
search: searchParams.get("search") || "",
tags: searchParams.get("tags")?.split(",").filter(Boolean) || [],
sort: isValidSort(initialSortParam) ? initialSortParam : "newest",
});
const [isLoading, setIsLoading] = useState(true);

// Simulate loading
useEffect(() => {
const timer = setTimeout(() => setIsLoading(false), 500);
return () => clearTimeout(timer);
}, []);

// Update URL when state changes (debounced)
useEffect(() => {
const timer = setTimeout(() => {
const params = new URLSearchParams();
params.set("tab", activeTab);
if (filters.search) params.set("search", filters.search);
if (filters.tags.length > 0) params.set("tags", filters.tags.join(","));
if (filters.sort !== "newest") params.set("sort", filters.sort);

router.replace(`/discover?${params.toString()}`, { scroll: false });
}, 300);

return () => clearTimeout(timer);
}, [activeTab, filters, router]);

// Filter and sort projects
const filteredProjects = useCallback(() => {
let result = [...mockProjects];

// Apply search filter
if (filters.search) {
const searchLower = filters.search.toLowerCase();
result = result.filter(
(p) =>
p.title.toLowerCase().includes(searchLower) ||
p.description.toLowerCase().includes(searchLower) ||
p.tags.some((tag) => tag.toLowerCase().includes(searchLower)),
);
}

// Apply tags filter
if (filters.tags.length > 0) {
result = result.filter((p) =>
filters.tags.some((tag) => p.tags.includes(tag)),
);
}

// Apply sort (only valid sorts for projects)
switch (filters.sort) {
case "newest":
result.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime());
break;
case "recentlyUpdated":
result.sort((a, b) => b.updatedAt.getTime() - a.updatedAt.getTime());
break;
default:
// Fallback to newest for unsupported sort values (e.g. "highestReward")
result.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime());
break;
}

return result;
}, [filters]);

// Filter and sort bounties
const filteredBounties = useCallback(() => {
let result = [...mockBounties];

// Apply search filter
if (filters.search) {
const searchLower = filters.search.toLowerCase();
result = result.filter(
(b) =>
b.title.toLowerCase().includes(searchLower) ||
b.description.toLowerCase().includes(searchLower) ||
b.tags.some((tag) => tag.toLowerCase().includes(searchLower)),
);
}

// Apply tags filter
if (filters.tags.length > 0) {
result = result.filter((b) =>
filters.tags.some((tag) => b.tags.includes(tag)),
);
}

// Apply sort
switch (filters.sort) {
case "newest":
result.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime());
break;
case "recentlyUpdated":
result.sort((a, b) => b.updatedAt.getTime() - a.updatedAt.getTime());
break;
case "highestReward":
result.sort((a, b) => b.reward - a.reward);
break;
}

return result;
}, [filters]);

const projects = filteredProjects();
const bounties = filteredBounties();

const EmptyState = ({ type }: { type: "projects" | "bounties" }) => (
<div className="flex flex-col items-center justify-center py-16 px-4 text-center">
<div className="mb-4 p-6 bg-primary/5 rounded-full">
{type === "projects" ? (
<PackageOpen className="h-16 w-16 text-primary/50" />
) : (
<Coins className="h-16 w-16 text-primary/50" />
)}
</div>
<h3 className="text-xl font-semibold mb-2">No {type} found</h3>
<p className="text-muted-foreground max-w-md">
Try adjusting your filters or search terms to find what you&#39;re
looking for.
</p>
</div>
);

return (
<div className="min-h-screen bg-background">
{/* Hero Section */}
<div className="border-b border-border/50 bg-gradient-to-b from-background to-background-card">
<div className="container mx-auto px-4 py-12 md:py-16">
<div className="max-w-3xl">
<h1 className="text-4xl md:text-5xl font-bold mb-4 gradient-text-3">
Discover Projects & Bounties
</h1>
<p className="text-lg text-muted-foreground">
Explore innovative projects shaping the future on Stellar. Find
bounties that match your skills and earn rewards.
</p>
</div>
</div>
</div>

{/* Main Content */}
<div className="container mx-auto px-4 py-8">
<Tabs
value={activeTab}
onValueChange={(val) => setActiveTab(val as TabType)}
>
{/* Tab Navigation */}
<div className="flex flex-col md:flex-row md:items-center md:justify-between gap-4 mb-8">
<TabsList className="bg-background-card text-gray-100 border border-border/50 p-1">
<TabsTrigger
value="projects"
className="data-[state=active]:bg-primary data-[state=active]:text-primary-foreground text-gray-100"
>
<PackageOpen className="mr-2 h-4 w-4" />
Projects
<span className="ml-2 text-xs opacity-70">
({mockProjects.length})
</span>
</TabsTrigger>
<TabsTrigger
value="bounties"
className="data-[state=active]:bg-primary data-[state=active]:text-primary-foreground text-gray-100"
>
<Coins className="mr-2 h-4 w-4" />
Bounties
<span className="ml-2 text-xs opacity-70">
({mockBounties.length})
</span>
</TabsTrigger>
</TabsList>
</div>

{/* Filter Panel */}
<div className="mb-8">
<FilterPanel
filters={filters}
onFiltersChange={setFilters}
activeTab={activeTab}
/>
</div>

{/* Projects Tab Content */}
<TabsContent value="projects" className="mt-0">
{isLoading ? (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{[...Array(6)].map((_, i) => (
<div key={i} className="space-y-4">
<Skeleton className="h-48 w-full" />
</div>
))}
</div>
) : projects.length > 0 ? (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{projects.map((project) => (
<ProjectCard key={project.id} project={project} />
))}
</div>
) : (
// eslint-disable-next-line react-hooks/static-components
<EmptyState type="projects" />
)}
</TabsContent>

{/* Bounties Tab Content */}
<TabsContent value="bounties" className="mt-0">
{isLoading ? (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{[...Array(6)].map((_, i) => (
<div key={i} className="space-y-4">
<Skeleton className="h-48 w-full" />
</div>
))}
</div>
) : bounties.length > 0 ? (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{bounties.map((bounty) => (
<BountyCard key={bounty.id} bounty={bounty} />
))}
</div>
) : (
<EmptyState type="bounties" />
)}
</TabsContent>
</Tabs>
</div>
</div>
);
}
Loading