@@ -18,6 +18,9 @@ import { canonicalUrl, SITE_URL } from "../../../lib/seo.utils";
1818import api from "../../../lib/axios" ;
1919import { useAuthStore } from "../../../lib/auth.store" ;
2020import type { RoadmapListItem , RoadmapEnrollmentListItem } from "../../../lib/types" ;
21+ import { useSearchParams } from "react-router" ;
22+ import { useDebounce } from "../../../hooks/useDebounce" ;
23+ import { X } from "lucide-react" ;
2124
2225interface ListResponse {
2326 roadmaps : RoadmapListItem [ ] ;
@@ -53,21 +56,37 @@ export default function RoadmapsLandingPage() {
5356 const { isAuthenticated, user } = useAuthStore ( ) ;
5457 const isStudent = isAuthenticated && user ?. role === "STUDENT" ;
5558 const { collapsed, sidebarWidth, sidebar } = useStudentSidebar ( ) ;
59+ const [ searchParams , setSearchParams ] = useSearchParams ( ) ;
5660
5761 const [ roadmaps , setRoadmaps ] = useState < RoadmapListItem [ ] > ( [ ] ) ;
5862 const [ enrollments , setEnrollments ] = useState < RoadmapEnrollmentListItem [ ] > ( [ ] ) ;
5963 const [ loading , setLoading ] = useState ( true ) ;
6064 const [ error , setError ] = useState < string | null > ( null ) ;
61- const [ search , setSearch ] = useState ( "" ) ;
65+
66+ // Filter states from URL
67+ const [ searchInput , setSearchInput ] = useState ( searchParams . get ( "search" ) || "" ) ;
68+ const debouncedSearch = useDebounce ( searchInput , 400 ) ;
69+
70+ const level = ( searchParams . get ( "level" ) || "ALL_LEVELS" ) as any ;
71+ const tag = searchParams . get ( "tag" ) || "" ;
72+ const category = searchParams . get ( "category" ) || "" ;
6273
6374 useEffect ( ( ) => {
6475 let mounted = true ;
65- api . get < ListResponse > ( "/roadmaps" , { params : { page : 1 , limit : 50 } } )
76+ setLoading ( true ) ;
77+
78+ const params : any = { page : 1 , limit : 100 } ;
79+ if ( debouncedSearch ) params . search = debouncedSearch ;
80+ if ( level && level !== "ALL_LEVELS" ) params . level = level ;
81+ if ( tag ) params . tag = tag ;
82+ if ( category ) params . category = category ;
83+
84+ api . get < ListResponse > ( "/roadmaps" , { params } )
6685 . then ( ( res ) => mounted && setRoadmaps ( res . data . roadmaps ) )
6786 . catch ( ( ) => mounted && setError ( "Could not load roadmaps. Please try again." ) )
6887 . finally ( ( ) => mounted && setLoading ( false ) ) ;
6988 return ( ) => { mounted = false ; } ;
70- } , [ ] ) ;
89+ } , [ debouncedSearch , level , tag , category ] ) ;
7190
7291 useEffect ( ( ) => {
7392 if ( ! isStudent ) return ;
@@ -78,24 +97,68 @@ export default function RoadmapsLandingPage() {
7897 return ( ) => { mounted = false ; } ;
7998 } , [ isStudent ] ) ;
8099
100+ // Sync search input with URL when typing
101+ useEffect ( ( ) => {
102+ const newParams = new URLSearchParams ( searchParams ) ;
103+ if ( debouncedSearch ) newParams . set ( "search" , debouncedSearch ) ;
104+ else newParams . delete ( "search" ) ;
105+
106+ // Only update if it actually changed to avoid unnecessary history entries
107+ if ( newParams . toString ( ) !== searchParams . toString ( ) ) {
108+ setSearchParams ( newParams , { replace : true } ) ;
109+ }
110+ } , [ debouncedSearch , setSearchParams , searchParams ] ) ;
111+
112+ const updateFilter = ( key : string , value : string ) => {
113+ const newParams = new URLSearchParams ( searchParams ) ;
114+ if ( value && value !== "ALL_LEVELS" ) {
115+ newParams . set ( key , value ) ;
116+ } else {
117+ newParams . delete ( key ) ;
118+ }
119+ setSearchParams ( newParams , { replace : true } ) ;
120+ } ;
121+
122+ const clearFilters = ( ) => {
123+ setSearchInput ( "" ) ;
124+ setSearchParams ( { } , { replace : true } ) ;
125+ } ;
126+
81127 // Index of slugs the student is already enrolled in
82128 const enrolledBySlug = useMemo ( ( ) => {
83129 const map = new Map < string , RoadmapEnrollmentListItem > ( ) ;
84130 for ( const e of enrollments ) map . set ( e . roadmap . slug , e ) ;
85131 return map ;
86132 } , [ enrollments ] ) ;
87133
88- // Filtered + grouped: separate "in progress" from "available"
134+ // Secondary client-side filtering for ultra-smooth UX while API loads
89135 const filtered = useMemo ( ( ) => {
90- const needle = search . trim ( ) . toLowerCase ( ) ;
91- if ( ! needle ) return roadmaps ;
92- return roadmaps . filter (
93- ( r ) =>
94- r . title . toLowerCase ( ) . includes ( needle ) ||
95- r . shortDescription . toLowerCase ( ) . includes ( needle ) ||
96- r . tags . some ( ( t ) => t . toLowerCase ( ) . includes ( needle ) ) ,
97- ) ;
98- } , [ roadmaps , search ] ) ;
136+ let result = roadmaps ;
137+ const needle = debouncedSearch . trim ( ) . toLowerCase ( ) ;
138+
139+ if ( needle ) {
140+ result = result . filter (
141+ ( r ) =>
142+ r . title . toLowerCase ( ) . includes ( needle ) ||
143+ r . shortDescription . toLowerCase ( ) . includes ( needle ) ||
144+ r . tags . some ( ( t ) => t . toLowerCase ( ) . includes ( needle ) ) ,
145+ ) ;
146+ }
147+
148+ if ( level && level !== "ALL_LEVELS" ) {
149+ result = result . filter ( r => r . level === level ) ;
150+ }
151+
152+ if ( tag ) {
153+ result = result . filter ( r => r . tags . includes ( tag ) ) ;
154+ }
155+
156+ if ( category ) {
157+ result = result . filter ( r => r . tags . includes ( category ) ) ;
158+ }
159+
160+ return result ;
161+ } , [ roadmaps , debouncedSearch , level , tag , category ] ) ;
99162
100163 const inProgress = filtered . filter ( ( r ) => enrolledBySlug . has ( r . slug ) ) ;
101164 const available = filtered . filter ( ( r ) => ! enrolledBySlug . has ( r . slug ) ) ;
@@ -225,29 +288,96 @@ export default function RoadmapsLandingPage() {
225288 </ Link >
226289 </ motion . div >
227290
228- { /* Search */ }
291+ { /* Search & Filters */ }
229292 < motion . div
230293 initial = { { opacity : 0 , y : 10 } }
231294 animate = { { opacity : 1 , y : 0 } }
232295 transition = { { delay : 0.1 , duration : 0.4 } }
233- className = "mb-10"
296+ className = "mb-10 space-y-6 "
234297 >
235298 < div className = "relative" >
236299 < Search className = "absolute left-4 top-1/2 -translate-y-1/2 w-4 h-4 text-stone-400" aria-hidden />
237300 < input
238301 type = "text"
239- placeholder = "Search roadmaps..."
240- value = { search }
241- onChange = { ( e ) => setSearch ( e . target . value ) }
302+ placeholder = "Search roadmaps by title, tech, or keywords ..."
303+ value = { searchInput }
304+ onChange = { ( e ) => setSearchInput ( e . target . value ) }
242305 aria-label = "Search roadmaps"
243- className = "w-full pl-11 pr-4 py-3 bg-white dark:bg-stone-900 border border-stone-300 dark:border-white/10 rounded-md focus:outline-none focus:border-lime-400 transition-colors text-sm text-stone-900 dark:text-stone-50 placeholder-stone-400 dark:placeholder-stone-600"
306+ className = "w-full pl-11 pr-12 py-4 bg-white dark:bg-stone-900 border border-stone-200 dark:border-white/10 rounded-lg focus:outline-none focus:ring-2 focus:ring-lime-400/20 focus: border-lime-400 transition-all text-base text-stone-900 dark:text-stone-50 placeholder-stone-400 dark:placeholder-stone-600 shadow-sm "
244307 />
308+ { searchInput && (
309+ < button
310+ onClick = { ( ) => setSearchInput ( "" ) }
311+ className = "absolute right-4 top-1/2 -translate-y-1/2 p-1 text-stone-400 hover:text-stone-600 dark:hover:text-stone-200 transition-colors"
312+ >
313+ < X className = "w-4 h-4" />
314+ </ button >
315+ ) }
245316 </ div >
246- { search && (
247- < p className = "mt-2 text-[10px] font-mono uppercase tracking-widest text-stone-500" >
248- { filtered . length } match{ filtered . length === 1 ? "" : "es" }
317+
318+ < div className = "flex flex-col gap-4" >
319+ { /* Category Filters */ }
320+ < div className = "flex flex-wrap items-center gap-2" >
321+ < span className = "text-[10px] font-mono uppercase tracking-widest text-stone-400 mr-2" > Category:</ span >
322+ { [ "Frontend" , "Backend" , "Fullstack" , "AI" , "Mobile" , "DevOps" , "Blockchain" ] . map ( ( cat ) => (
323+ < FilterChip
324+ key = { cat }
325+ label = { cat }
326+ active = { category === cat }
327+ onClick = { ( ) => updateFilter ( "category" , category === cat ? "" : cat ) }
328+ />
329+ ) ) }
330+ </ div >
331+
332+ { /* Tags Filters */ }
333+ < div className = "flex flex-wrap items-center gap-2" >
334+ < span className = "text-[10px] font-mono uppercase tracking-widest text-stone-400 mr-2" > Tags:</ span >
335+ { [ "React" , "Node.js" , "Python" , "System Design" , "AWS" , "SQL" ] . map ( ( t ) => (
336+ < FilterChip
337+ key = { t }
338+ label = { t }
339+ active = { tag === t }
340+ onClick = { ( ) => updateFilter ( "tag" , tag === t ? "" : t ) }
341+ />
342+ ) ) }
343+ </ div >
344+
345+ < div className = "flex flex-wrap items-center justify-between gap-4" >
346+ { /* Level Filters */ }
347+ < div className = "flex flex-wrap items-center gap-2" >
348+ < span className = "text-[10px] font-mono uppercase tracking-widest text-stone-400 mr-2" > Level:</ span >
349+ { [ "ALL_LEVELS" , "BEGINNER" , "INTERMEDIATE" , "ADVANCED" ] . map ( ( l ) => (
350+ < FilterChip
351+ key = { l }
352+ label = { l . replace ( "_" , " " ) }
353+ active = { level === l }
354+ onClick = { ( ) => updateFilter ( "level" , l ) }
355+ />
356+ ) ) }
357+ </ div >
358+
359+ { ( searchInput || tag || category || ( level && level !== "ALL_LEVELS" ) ) && (
360+ < button
361+ onClick = { clearFilters }
362+ className = "text-[10px] font-mono uppercase tracking-widest text-red-500 hover:text-red-600 transition-colors flex items-center gap-1"
363+ >
364+ < X className = "w-3 h-3" />
365+ Clear all filters
366+ </ button >
367+ ) }
368+ </ div >
369+ </ div >
370+
371+ < div className = "flex items-center justify-between pt-2 border-t border-stone-200 dark:border-white/5" >
372+ < p className = "text-[10px] font-mono uppercase tracking-widest text-stone-500" >
373+ { loading ? "Updating results..." : (
374+ < >
375+ Showing < span className = "text-stone-900 dark:text-stone-50 font-bold" > { filtered . length } </ span > roadmap{ filtered . length === 1 ? "" : "s" }
376+ { ( searchInput || tag || category || level !== "ALL_LEVELS" ) && " matching filters" }
377+ </ >
378+ ) }
249379 </ p >
250- ) }
380+ </ div >
251381 </ motion . div >
252382
253383 { /* Sections */ }
@@ -459,3 +589,18 @@ function RoadmapCard({
459589 </ motion . div >
460590 ) ;
461591}
592+
593+ function FilterChip ( { label, active, onClick } : { label : string ; active : boolean ; onClick : ( ) => void } ) {
594+ return (
595+ < button
596+ onClick = { onClick }
597+ className = { `px-3 py-1 rounded-full text-[10px] font-mono uppercase tracking-widest transition-all ${
598+ active
599+ ? "bg-lime-400 text-stone-950 font-bold border border-lime-500 shadow-sm"
600+ : "bg-white dark:bg-stone-900 text-stone-500 hover:text-stone-900 dark:hover:text-stone-300 border border-stone-200 dark:border-white/10"
601+ } `}
602+ >
603+ { label }
604+ </ button >
605+ ) ;
606+ }
0 commit comments