diff --git a/src/components/DiscoverRoadmaps/DiscoverRoadmapSorting.tsx b/src/components/DiscoverRoadmaps/DiscoverRoadmapSorting.tsx new file mode 100644 index 000000000..c78daf3ff --- /dev/null +++ b/src/components/DiscoverRoadmaps/DiscoverRoadmapSorting.tsx @@ -0,0 +1,77 @@ +import { ArrowDownWideNarrow, Check, ChevronDown } from 'lucide-react'; +import { useRef, useState } from 'react'; +import { useOutsideClick } from '../../hooks/use-outside-click'; +import type { SortByValues } from './DiscoverRoadmaps'; + +const sortingLabels: { label: string; value: SortByValues }[] = [ + { + label: 'Newest', + value: 'createdAt', + }, + { + label: 'Oldest', + value: '-createdAt', + }, + { + label: 'Highest Rated', + value: 'rating', + }, + { + label: 'Lowest Rated', + value: '-rating', + }, +]; + +type DiscoverRoadmapSortingProps = { + sortBy: SortByValues; + onSortChange: (sortBy: SortByValues) => void; +}; + +export function DiscoverRoadmapSorting(props: DiscoverRoadmapSortingProps) { + const { sortBy, onSortChange } = props; + + const [isOpen, setIsOpen] = useState(false); + const dropdownRef = useRef(null); + + const selectedValue = sortingLabels.find((item) => item.value === sortBy); + + useOutsideClick(dropdownRef, () => { + setIsOpen(false); + }); + + return ( +
+ + + {isOpen && ( +
+ {sortingLabels.map((item) => ( + + ))} +
+ )} +
+ ); +} diff --git a/src/components/DiscoverRoadmaps/DiscoverRoadmaps.tsx b/src/components/DiscoverRoadmaps/DiscoverRoadmaps.tsx index 6dcee9d10..71faaa403 100644 --- a/src/components/DiscoverRoadmaps/DiscoverRoadmaps.tsx +++ b/src/components/DiscoverRoadmaps/DiscoverRoadmaps.tsx @@ -4,29 +4,133 @@ import { Pagination } from '../Pagination/Pagination'; import { SearchRoadmap } from './SearchRoadmap'; import { EmptyDiscoverRoadmaps } from './EmptyDiscoverRoadmaps'; import { Rating } from '../Rating/Rating'; +import { useEffect, useState } from 'react'; +import { deleteUrlParam, getUrlParams, setUrlParams } from '../../lib/browser'; +import { LoadingRoadmaps } from '../ExploreAIRoadmap/LoadingRoadmaps'; +import { httpGet } from '../../lib/http'; +import { useToast } from '../../hooks/use-toast'; +import { DiscoverRoadmapSorting } from './DiscoverRoadmapSorting'; -type DiscoverRoadmapsProps = { - searchParams: string; - roadmapsResponse: ListShowcaseRoadmapResponse; +type DiscoverRoadmapsProps = {}; + +export type SortByValues = 'rating' | '-rating' | 'createdAt' | '-createdAt'; + +type QueryParams = { + q?: string; + s?: SortByValues; + p?: string; +}; + +type PageState = { + searchTerm: string; + sortBy: SortByValues; + currentPage: number; }; export function DiscoverRoadmaps(props: DiscoverRoadmapsProps) { - const { roadmapsResponse, searchParams: defaultSearchparams } = props; + const toast = useToast(); + + const [pageState, setPageState] = useState({ + searchTerm: '', + sortBy: 'createdAt', + currentPage: 0, + }); + const [isLoading, setIsLoading] = useState(true); + const [roadmapsResponse, setRoadmapsResponse] = + useState(null); + + useEffect(() => { + const queryParams = getUrlParams() as QueryParams; + + setPageState({ + searchTerm: queryParams.q || '', + sortBy: queryParams.s || 'createdAt', + currentPage: +(queryParams.p || '1'), + }); + }, []); + + useEffect(() => { + setIsLoading(true); + if (!pageState.currentPage) { + return; + } + + // only set the URL params if the user modified anything + if ( + pageState.currentPage !== 1 || + pageState.searchTerm !== '' || + pageState.sortBy !== 'createdAt' + ) { + setUrlParams({ + q: pageState.searchTerm, + s: pageState.sortBy, + p: String(pageState.currentPage), + }); + } else { + deleteUrlParam('q'); + deleteUrlParam('s'); + deleteUrlParam('p'); + } + + loadAIRoadmaps( + pageState.currentPage, + pageState.searchTerm, + pageState.sortBy, + ).finally(() => { + setIsLoading(false); + }); + }, [pageState]); + + const loadAIRoadmaps = async ( + currPage: number = 1, + searchTerm: string = '', + sortBy: SortByValues = 'createdAt', + ) => { + const { response, error } = await httpGet( + `${import.meta.env.PUBLIC_API_URL}/v1-list-showcase-roadmap`, + { + currPage, + ...(searchTerm && { term: searchTerm }), + ...(sortBy && { sortBy }), + }, + ); + + if (error || !response) { + toast.error(error?.message || 'Something went wrong'); + return; + } + + setRoadmapsResponse(response); + }; const roadmaps = roadmapsResponse?.data || []; - const searchParams = new URLSearchParams(defaultSearchparams); - const titleQuery = searchParams.get('q') || ''; + const loadingIndicator = isLoading && ; return (
- +
+ {}} + /> + + { + setPageState({ + ...pageState, + sortBy, + }); + }} + /> +
- {roadmaps.length === 0 && } - {roadmaps.length > 0 && ( + {loadingIndicator} + {roadmaps.length === 0 && !isLoading && } + {roadmaps.length > 0 && !isLoading && ( <>
    {roadmaps.map((roadmap) => { @@ -75,13 +179,10 @@ export function DiscoverRoadmaps(props: DiscoverRoadmapsProps) { perPage={roadmapsResponse?.perPage || 0} totalCount={roadmapsResponse?.totalCount || 0} onPageChange={(page) => { - const newSearchParams = new URLSearchParams(); - if (titleQuery) { - newSearchParams.set('q', titleQuery); - } - - newSearchParams.set('currPage', page.toString()); - window.location.href = `/discover?${newSearchParams.toString()}`; + setPageState({ + ...pageState, + currentPage: page, + }); }} /> diff --git a/src/components/DiscoverRoadmaps/SearchRoadmap.tsx b/src/components/DiscoverRoadmaps/SearchRoadmap.tsx index c76a397d4..89592f41c 100644 --- a/src/components/DiscoverRoadmaps/SearchRoadmap.tsx +++ b/src/components/DiscoverRoadmaps/SearchRoadmap.tsx @@ -1,18 +1,45 @@ import { Search } from 'lucide-react'; +import { useEffect, useState } from 'react'; +import { useDebounceValue } from '../../hooks/use-debounce'; +import { Spinner } from '../ReactIcons/Spinner'; type SearchRoadmapProps = { value: string; total: number; + isLoading: boolean; + onValueChange: (value: string) => void; }; export function SearchRoadmap(props: SearchRoadmapProps) { - const { total, value: defaultValue } = props; + const { total, value: defaultValue, onValueChange, isLoading } = props; + + const [term, setTerm] = useState(defaultValue); + const debouncedTerm = useDebounceValue(term, 500); + + useEffect(() => { + setTerm(defaultValue); + }, [defaultValue]); + + useEffect(() => { + if (debouncedTerm && debouncedTerm.length < 3) { + return; + } + + if (debouncedTerm === defaultValue) { + return; + } + + onValueChange(debouncedTerm); + }, [debouncedTerm]); return ( -
    +
    { + e.preventDefault(); + onValueChange(term); + }} >