diff --git a/app/dashboard/projects/page.tsx b/app/dashboard/projects/page.tsx index 1704b38..4ef10b9 100644 --- a/app/dashboard/projects/page.tsx +++ b/app/dashboard/projects/page.tsx @@ -1,8 +1,9 @@ "use client"; -import { useEffect, useMemo, useState } from "react"; +import { useEffect, useMemo, useState, useCallback } from "react"; import Link from "next/link"; -import { Search, Filter, ChevronRight, Loader2 } from "lucide-react"; +import { useRouter, usePathname, useSearchParams } from "next/navigation"; +import { Search, Filter, ChevronRight, Loader2, X } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Badge } from "@/components/ui/badge"; @@ -13,6 +14,14 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select"; +import { + Sheet, + SheetContent, + SheetDescription, + SheetHeader, + SheetTitle, + SheetTrigger, +} from "@/components/ui/sheet"; interface Project { id: string; @@ -23,6 +32,7 @@ interface Project { milestonesCount: number; completedMilestones: number; deadline: string; + skills: string[]; } const statusConfig = { @@ -49,13 +59,69 @@ function getAuthHeaders(): Record { return token ? { Authorization: `Bearer ${token}` } : {}; } +// Debounce hook +function useDebounce(value: T, delay: number): T { + const [debouncedValue, setDebouncedValue] = useState(value); + useEffect(() => { + const handler = setTimeout(() => { + setDebouncedValue(value); + }, delay); + return () => { + clearTimeout(handler); + }; + }, [value, delay]); + return debouncedValue; +} + export default function ProjectsPage() { + const router = useRouter(); + const pathname = usePathname(); + const searchParams = useSearchParams(); + + // URL state sync + const initialSearch = searchParams.get("search") || ""; + const initialStatus = searchParams.get("status") || "all"; + const initialSort = searchParams.get("sort") || "latest"; + const initialMinBudget = searchParams.get("minBudget") || ""; + const initialMaxBudget = searchParams.get("maxBudget") || ""; + const initialSkills = searchParams.get("skills")?.split(",") || []; + const [projects, setProjects] = useState([]); const [loading, setLoading] = useState(true); - const [searchTerm, setSearchTerm] = useState(""); - const [statusFilter, setStatusFilter] = useState("all"); const [now] = useState(() => Date.now()); + // Local state for debouncing + const [localSearch, setLocalSearch] = useState(initialSearch); + const [localMinBudget, setLocalMinBudget] = useState(initialMinBudget); + const [localMaxBudget, setLocalMaxBudget] = useState(initialMaxBudget); + + // Non-debounced state (immediate changes) + const [statusFilter, setStatusFilter] = useState(initialStatus); + const [sortBy, setSortBy] = useState(initialSort); + const [selectedSkills, setSelectedSkills] = useState(initialSkills); + + const debouncedSearch = useDebounce(localSearch, 300); + const debouncedMinBudget = useDebounce(localMinBudget, 500); + const debouncedMaxBudget = useDebounce(localMaxBudget, 500); + + // Update URL parameters when filters change + const updateQueryString = useCallback(() => { + const params = new URLSearchParams(); + if (debouncedSearch) params.set("search", debouncedSearch); + if (statusFilter && statusFilter !== "all") params.set("status", statusFilter); + if (sortBy && sortBy !== "latest") params.set("sort", sortBy); + if (debouncedMinBudget) params.set("minBudget", debouncedMinBudget); + if (debouncedMaxBudget) params.set("maxBudget", debouncedMaxBudget); + if (selectedSkills.length > 0) params.set("skills", selectedSkills.join(",")); + + router.replace(`${pathname}?${params.toString()}`); + }, [debouncedSearch, statusFilter, sortBy, debouncedMinBudget, debouncedMaxBudget, selectedSkills, pathname, router]); + + useEffect(() => { + updateQueryString(); + }, [updateQueryString]); + + // Fetch projects useEffect(() => { (async () => { try { @@ -65,11 +131,7 @@ export default function ProjectsPage() { }); if (!res.ok) return; const data = await res.json(); - const mapped: Project[] = (data.projects ?? []).map((p: { - id: string; title: string; status: string; - budget_max: string | null; deadline: string | null; - milestones_count: number; completed_milestones: number; - }) => ({ + const mapped: Project[] = (data.projects ?? []).map((p: any) => ({ id: p.id, title: p.title, status: mapStatus(p.status), @@ -83,6 +145,7 @@ export default function ProjectsPage() { deadline: p.deadline ? new Date(p.deadline).toISOString().split("T")[0] : new Date(Date.now() + 30 * 86400000).toISOString().split("T")[0], + skills: p.skills || [], // fallback if skills not provided })); setProjects(mapped); } finally { @@ -91,49 +154,250 @@ export default function ProjectsPage() { })(); }, []); - const filtered = useMemo( - () => - projects.filter((p) => { - const matchSearch = p.title.toLowerCase().includes(searchTerm.toLowerCase()); - const matchStatus = statusFilter === "all" || p.status === statusFilter; - return matchSearch && matchStatus; - }), - [projects, searchTerm, statusFilter] - ); + const clearFilters = () => { + setLocalSearch(""); + setStatusFilter("all"); + setSortBy("latest"); + setLocalMinBudget(""); + setLocalMaxBudget(""); + setSelectedSkills([]); + router.replace(pathname); + }; + + const filtered = useMemo(() => { + let result = projects.filter((p) => { + const matchSearch = p.title.toLowerCase().includes(debouncedSearch.toLowerCase()); + const matchStatus = statusFilter === "all" || p.status === statusFilter; + const matchMinBudget = !debouncedMinBudget || p.budget >= parseFloat(debouncedMinBudget); + const matchMaxBudget = !debouncedMaxBudget || p.budget <= parseFloat(debouncedMaxBudget); + + const matchSkills = selectedSkills.length === 0 || + selectedSkills.every(skill => p.skills.includes(skill)); + + return matchSearch && matchStatus && matchMinBudget && matchMaxBudget && matchSkills; + }); + + result.sort((a, b) => { + switch (sortBy) { + case "budget-desc": return b.budget - a.budget; + case "budget-asc": return a.budget - b.budget; + case "deadline-desc": return new Date(b.deadline).getTime() - new Date(a.deadline).getTime(); + case "deadline-asc": return new Date(a.deadline).getTime() - new Date(b.deadline).getTime(); + case "latest": + default: + return b.id.localeCompare(a.id); + } + }); + + return result; + }, [projects, debouncedSearch, statusFilter, debouncedMinBudget, debouncedMaxBudget, selectedSkills, sortBy]); + + const toggleSkill = (skill: string) => { + setSelectedSkills(prev => + prev.includes(skill) ? prev.filter(s => s !== skill) : [...prev, skill] + ); + }; + + // Mock available skills for filtering UI since it might not be in API + const availableSkills = ["React", "Node.js", "Python", "UI/UX", "Smart Contracts", "Stellar"]; return ( -
-
+
+
-

All Projects

+

All Projects

View and manage all your projects

+ {/* Desktop and Mobile Controls */}
setSearchTerm(e.target.value)} + value={localSearch} + onChange={(e) => setLocalSearch(e.target.value)} className="pl-10 border-border/40" />
- + +
+ + + + + + + + Filter Projects + Refine your project search + +
+
+ + +
+ +
+ +
+ setLocalMinBudget(e.target.value)} /> + setLocalMaxBudget(e.target.value)} /> +
+
+ +
+ +
+ {availableSkills.map(skill => ( + toggleSkill(skill)} + > + {skill} + + ))} +
+
+ + +
+
+
+
+ + {/* Mobile Filter & Sort Drawer (Sheet) */} +
+ + + + + + + Sort & Filter + +
+
+ + +
+ +
+ + +
+ +
+ +
+ setLocalMinBudget(e.target.value)} /> + setLocalMaxBudget(e.target.value)} /> +
+
+ +
+ +
+ {availableSkills.map(skill => ( + toggleSkill(skill)} + > + {skill} + + ))} +
+
+ + +
+
+
+
+ {/* Active Filters Display */} + {(debouncedMinBudget || debouncedMaxBudget || statusFilter !== "all" || selectedSkills.length > 0) && ( +
+ Active Filters: + {statusFilter !== "all" && ( + + Status: {statusFilter} setStatusFilter("all")} /> + + )} + {(debouncedMinBudget || debouncedMaxBudget) && ( + + Budget: {debouncedMinBudget || "0"} - {debouncedMaxBudget || "∞"} + { setLocalMinBudget(""); setLocalMaxBudget(""); }} /> + + )} + {selectedSkills.map(skill => ( + + {skill} toggleSkill(skill)} /> + + ))} + +
+ )} + {loading ? (
@@ -217,7 +481,7 @@ export default function ProjectsPage() { : "No projects match your filters."}

{projects.length > 0 && ( - )}