diff --git a/apps/web/components/Objects/Search/SearchBar.tsx b/apps/web/components/Objects/Search/SearchBar.tsx index 1edc48b4..85178f6c 100644 --- a/apps/web/components/Objects/Search/SearchBar.tsx +++ b/apps/web/components/Objects/Search/SearchBar.tsx @@ -1,5 +1,5 @@ -import React, { useState, useEffect, useRef } from 'react'; -import { Search } from 'lucide-react'; +import React, { useState, useEffect, useRef, useMemo, useCallback } from 'react'; +import { Search, ArrowRight, Sparkles, Book, GraduationCap, ArrowUpRight, TextSearch, ScanSearch } from 'lucide-react'; import { searchOrgCourses } from '@services/courses/courses'; import { useLHSession } from '@components/Contexts/LHSessionContext'; import Link from 'next/link'; @@ -14,6 +14,7 @@ interface Course { description: string; thumbnail_image: string; course_uuid: string; + tags?: string[]; authors: Array<{ first_name: string; last_name: string; @@ -27,7 +28,29 @@ interface SearchBarProps { isMobile?: boolean; } -export const SearchBar: React.FC = ({ orgslug, className = '', isMobile = false }) => { +const CourseResultsSkeleton = () => ( +
+
+
+
+
+ {[1, 2].map((i) => ( +
+
+
+
+
+
+
+ ))} +
+); + +export const SearchBar: React.FC = ({ + orgslug, + className = '', + isMobile = false, +}) => { const org = useOrg() as any; const [searchQuery, setSearchQuery] = useState(''); const [courses, setCourses] = useState([]); @@ -35,30 +58,10 @@ export const SearchBar: React.FC = ({ orgslug, className = '', i const [showResults, setShowResults] = useState(false); const searchRef = useRef(null); const session = useLHSession() as any; + const [isInitialLoad, setIsInitialLoad] = useState(true); - const debouncedSearchFunction = useDebounce(async (query: string) => { - if (query.trim().length === 0) { - setCourses([]); - return; - } - - setIsLoading(true); - try { - const results = await searchOrgCourses( - orgslug, - query, - 1, - 5, - null, - session?.data?.tokens?.access_token - ); - setCourses(results); - } catch (error) { - console.error('Error searching courses:', error); - setCourses([]); - } - setIsLoading(false); - }, 300); + // Debounce the search query value + const debouncedSearch = useDebounce(searchQuery, 300); useEffect(() => { const handleClickOutside = (event: MouseEvent) => { @@ -72,76 +75,189 @@ export const SearchBar: React.FC = ({ orgslug, className = '', i }, []); useEffect(() => { - debouncedSearchFunction(searchQuery); - }, [searchQuery, debouncedSearchFunction]); + const fetchCourses = async () => { + if (debouncedSearch.trim().length === 0) { + setCourses([]); + setIsLoading(false); + return; + } - const handleSearchFocus = () => { - if (searchQuery.trim().length > 0) { - setShowResults(true); + setIsLoading(true); + try { + const results = await searchOrgCourses( + orgslug, + debouncedSearch, + 1, + 3, + null, + session?.data?.tokens?.access_token + ); + setCourses(results); + } catch (error) { + console.error('Error searching courses:', error); + setCourses([]); + } + setIsLoading(false); + setIsInitialLoad(false); + }; + + fetchCourses(); + }, [debouncedSearch, orgslug, session?.data?.tokens?.access_token]); + + const MemoizedEmptyState = useMemo(() => { + if (!searchQuery.trim()) { + return ( +
+
+
+ +
+

+ Discover Your Next Learning Journey +

+

+ Start typing to search through available content +

+
+
+ ); } - }; + return null; + }, [searchQuery]); + + const searchTerms = useMemo(() => [ + { term: searchQuery, type: 'exact', icon: }, + { term: `${searchQuery} courses`, type: 'courses', icon: }, + { term: `${searchQuery} collections`, type: 'collections', icon: }, + ], [searchQuery]); + + const MemoizedSearchSuggestions = useMemo(() => { + if (searchQuery.trim()) { + return ( +
+
+ + Search suggestions +
+
+ {searchTerms.map(({ term, type, icon }) => ( + +
+ {icon} + {term} +
+ + + ))} +
+
+ ); + } + return null; + }, [searchQuery, searchTerms, orgslug]); + + const MemoizedCourseResults = useMemo(() => { + if (!courses.length) return null; + + return ( +
+
+ + Quick Results +
+ {courses.map((course) => ( + +
+ {course.thumbnail_image ? ( + {course.name} + ) : ( +
+ +
+ )} +
+ +
+
+
+
+

{course.name}

+ Course +
+

{course.description}

+
+ + ))} +
+ ); + }, [courses, orgslug, org?.org_uuid]); + + const handleSearchChange = useCallback((e: React.ChangeEvent) => { + setSearchQuery(e.target.value); + setShowResults(true); + }, []); return (
-
+
{ - setSearchQuery(e.target.value); - setShowResults(true); - }} - onFocus={handleSearchFocus} - placeholder="Search courses..." - className="w-full h-9 pl-10 pr-4 rounded-lg border border-gray-200 bg-white/50 focus:outline-hidden focus:ring-2 focus:ring-gray-200 focus:border-transparent text-sm placeholder:text-gray-400" + onChange={handleSearchChange} + onFocus={() => setShowResults(true)} + placeholder="Search courses, users, collections..." + className="w-full h-9 pl-11 pr-4 rounded-xl nice-shadow bg-white + focus:outline-none focus:ring-1 focus:ring-black/5 focus:border-black/20 + text-sm placeholder:text-black/40 transition-all" /> - +
+ +
- {showResults && (searchQuery.trim().length > 0 || isLoading) && ( -
- {isLoading ? ( -
-
Searching...
-
- ) : courses.length > 0 ? ( -
- {courses.map((course) => ( - setShowResults(false)} - > -
- {course.thumbnail_image && ( - {course.name} - )} -
-

{course.name}

-

{course.description}

- {course.authors && course.authors[0] && ( -

- by {course.authors[0].first_name} {course.authors[0].last_name} -

- )} -
-
- - ))} -
- ) : ( -
- No courses found -
- )} -
- )} +
+ {(!searchQuery.trim() || isInitialLoad) ? ( + MemoizedEmptyState + ) : ( + <> + {MemoizedSearchSuggestions} + {isLoading ? ( + + ) : ( + <> + {MemoizedCourseResults} + {(courses.length > 0 || searchQuery.trim()) && ( + + View all results + + + )} + + )} + + )} +
); }; \ No newline at end of file diff --git a/apps/web/hooks/useDebounce.ts b/apps/web/hooks/useDebounce.ts index d90de25a..76e95fa5 100644 --- a/apps/web/hooks/useDebounce.ts +++ b/apps/web/hooks/useDebounce.ts @@ -1,26 +1,54 @@ -import { useEffect, useRef } from 'react'; +import { useEffect, useRef, useState } from 'react'; +// Function debouncing export function useDebounce any>( callback: T, delay: number -): T { +): T; + +// Value debouncing +export function useDebounce(value: T, delay: number): T; + +// Implementation +export function useDebounce(valueOrCallback: T, delay: number): T { + const [debouncedValue, setDebouncedValue] = useState(valueOrCallback); const timeoutRef = useRef(undefined); useEffect(() => { + // If it's a function, return a debounced version of it + if (typeof valueOrCallback === 'function') { + return () => { + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + } + }; + } + + // For values, update the debounced value after the delay + timeoutRef.current = setTimeout(() => { + setDebouncedValue(valueOrCallback); + }, delay); + return () => { if (timeoutRef.current) { clearTimeout(timeoutRef.current); } }; - }, []); + }, [valueOrCallback, delay]); - return ((...args: Parameters) => { - if (timeoutRef.current) { - clearTimeout(timeoutRef.current); - } + // If it's a function, return a debounced version + if (typeof valueOrCallback === 'function') { + return ((...args: any[]) => { + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + } - timeoutRef.current = setTimeout(() => { - callback(...args); - }, delay); - }) as T; + timeoutRef.current = setTimeout(() => { + (valueOrCallback as Function)(...args); + }, delay); + }) as T; + } + + // For values, return the debounced value + return debouncedValue; } \ No newline at end of file