diff --git a/src/components/search/GroupSearchResult.tsx b/src/components/search/GroupSearchResult.tsx index b624a7eb..5dfea5fb 100644 --- a/src/components/search/GroupSearchResult.tsx +++ b/src/components/search/GroupSearchResult.tsx @@ -8,11 +8,16 @@ import type { SearchRoomItem } from '@/api/rooms/getSearchRooms'; const FILTER = ['마감임박순', '인기순']; const CATEGORIES = ['문학', '과학·IT', '사회과학', '인문학', '예술'] as const; +type ResultType = 'searching' | 'searched'; + interface Props { + type: ResultType; rooms: SearchRoomItem[]; isLoading: boolean; - isLast: boolean; - onLoadMore: () => void; + isLoadingMore?: boolean; + hasMore?: boolean; + lastRoomElementCallback?: (node: HTMLDivElement | null) => void; + error: string | null; selectedFilter: string; setSelectedFilter: (v: string) => void; @@ -33,10 +38,12 @@ const mapToGroupCardModel = (r: SearchRoomItem) => ({ }); const GroupSearchResult = ({ + type, rooms, isLoading, - isLast, - onLoadMore, + isLoadingMore = false, + hasMore = false, + lastRoomElementCallback, error, selectedFilter, setSelectedFilter, @@ -63,33 +70,41 @@ const GroupSearchResult = ({ ); })} + - 전체 {mapped.length} + {type === 'searched' && 전체 {mapped.length}} + {error && {error}} + {isEmpty ? ( 해당하는 모임방이 없어요 검색어를 바꿔보거나 직접 모임방을 만들어보세요. ) : ( - mapped.map(group => ( - + mapped.map((group, idx) => ( +
+ +
)) )} - - {isLoading && 불러오는 중...} - {!isLoading && !isLast && mapped.length > 0 && ( - 더 보기 - )} - + {isLoadingMore && mapped.length > 0 && 불러오는 중...} + {!hasMore && mapped.length > 0 && 더 이상 결과가 없어요}
); @@ -121,9 +136,9 @@ const Tab = styled.button<{ selected?: boolean }>` const Content = styled.div` display: flex; flex-direction: column; - gap: 20px; + gap: 12px; overflow-y: auto; - padding: 0 20px; + padding: 0 20px 24px; `; const GroupCardHeader = styled.div` @@ -136,7 +151,7 @@ const GroupCardHeader = styled.div` const GroupNum = styled.span` display: flex; align-items: center; - color: ${colors.grey[100]}; + color: ${colors.white}; font-size: ${typography.fontSize.sm}; font-weight: ${typography.fontWeight.medium}; `; @@ -164,25 +179,16 @@ const EmptySubText = styled.p` text-align: center; `; -const LoadMoreArea = styled.div` - display: flex; - justify-content: center; - padding: 12px 0 24px; -`; - -const LoadMoreButton = styled.button` - padding: 10px 16px; - border: none; - border-radius: 8px; - background: var(--color-darkgrey-main); - color: #fff; - font-size: ${typography.fontSize.sm}; - cursor: pointer; -`; - const LoadingText = styled.p` color: ${colors.grey[100]}; font-size: ${typography.fontSize.sm}; + text-align: center; +`; + +const EndText = styled.p` + color: ${colors.grey[200]}; + font-size: ${typography.fontSize.xs}; + text-align: center; `; const ErrorText = styled.p` diff --git a/src/pages/groupSearch/GroupSearch.tsx b/src/pages/groupSearch/GroupSearch.tsx index f332cee8..ffa26eb8 100644 --- a/src/pages/groupSearch/GroupSearch.tsx +++ b/src/pages/groupSearch/GroupSearch.tsx @@ -1,122 +1,232 @@ import TitleHeader from '@/components/common/TitleHeader'; import { Modal, Overlay } from '@/components/group/Modal.styles'; import leftArrow from '../../assets/common/leftArrow.svg'; -import { useNavigate } from 'react-router-dom'; import SearchBar from '@/components/search/SearchBar'; -import { useState, useEffect, useCallback } from 'react'; +import { useState, useEffect, useCallback, useRef } from 'react'; import RecentSearchTabs from '@/components/search/RecentSearchTabs'; import GroupSearchResult from '@/components/search/GroupSearchResult'; import { getRecentSearch, type RecentSearchData } from '@/api/recentsearch/getRecentSearch'; import { deleteRecentSearch } from '@/api/recentsearch/deleteRecentSearch'; - import { getSearchRooms, type SearchRoomItem } from '@/api/rooms/getSearchRooms'; type SortKey = 'deadline' | 'memberCount'; const GroupSearch = () => { - const navigate = useNavigate(); - const [searchTerm, setSearchTerm] = useState(''); const [isSearching, setIsSearching] = useState(false); - - const [recentSearches, setRecentSearches] = useState([]); - const [isLoadingRecent, setIsLoadingRecent] = useState(false); + const [isFinalized, setIsFinalized] = useState(false); const [rooms, setRooms] = useState([]); const [nextCursor, setNextCursor] = useState(null); const [isLast, setIsLast] = useState(true); - const [isLoadingList, setIsLoadingList] = useState(false); + + // 로딩 + const [isLoading, setIsLoading] = useState(false); + const [isLoadingMore, setIsLoadingMore] = useState(false); const [error, setError] = useState(null); + // 정렬/카테고리 const [selectedFilter, setSelectedFilter] = useState('마감임박순'); const toSortKey = useCallback( (f: string): SortKey => (f === '인기순' ? 'memberCount' : 'deadline'), [], ); - const [category, setCategory] = useState(''); - const [isFinalized] = useState(false); - useEffect(() => { - (async () => { - try { - setIsLoadingRecent(true); - const response = await getRecentSearch('ROOM'); - setRecentSearches(response.isSuccess ? response.data.recentSearchList : []); - } finally { - setIsLoadingRecent(false); - } - })(); - }, []); + const [recentSearches, setRecentSearches] = useState([]); + const [searchTimeoutId, setSearchTimeoutId] = useState(null); - const runSearch = useCallback( - async (keyword: string, sortKey: SortKey, cursor?: string, append = false) => { - if (!keyword.trim()) return; - try { - setIsLoadingList(true); - setError(null); + const observerRef = useRef(null); - const res = await getSearchRooms(keyword.trim(), sortKey, cursor, isFinalized, category); + const fetchRecentSearches = async () => { + try { + const response = await getRecentSearch('ROOM'); + setRecentSearches(response.isSuccess ? response.data.recentSearchList : []); + } catch { + setRecentSearches([]); + } + }; + useEffect(() => { + fetchRecentSearches(); + }, []); - if (!res.isSuccess) { - if (!append) { - setRooms([]); - setNextCursor(null); - setIsLast(true); - } - setError(res.message || '검색 실패'); - return; - } + const searchFirstPage = useCallback( + async (term: string, sortKey: SortKey, manual: boolean) => { + if (!term.trim()) return; + setIsSearching(true); + if (manual) setIsFinalized(false); - const { roomList, nextCursor: nc, isLast: last } = res.data; - setRooms(prev => (append ? [...prev, ...roomList] : roomList)); - setNextCursor(nc); - setIsLast(last); - } catch { - if (!append) { + setIsLoading(true); + setError(null); + try { + const res = await getSearchRooms(term.trim(), sortKey, undefined, isFinalized, category); + if (res.isSuccess) { + const { roomList, nextCursor: nc, isLast: last } = res.data; + setRooms(roomList); + setNextCursor(nc); + setIsLast(last); + } else { setRooms([]); setNextCursor(null); setIsLast(true); + setError(res.message || '검색 실패'); } + } catch { + setRooms([]); + setNextCursor(null); + setIsLast(true); setError('네트워크 오류가 발생했습니다.'); } finally { - setIsLoadingList(false); + setIsLoading(false); + if (manual) setIsFinalized(true); } }, [category, isFinalized], ); + const loadMore = useCallback(async () => { + if (!searchTerm.trim() || !nextCursor || isLast || isLoadingMore) return; + + try { + setIsLoadingMore(true); + const res = await getSearchRooms( + searchTerm.trim(), + toSortKey(selectedFilter), + nextCursor, + isFinalized, + category, + ); + if (res.isSuccess) { + const { roomList, nextCursor: nc, isLast: last } = res.data; + setRooms(prev => [...prev, ...roomList]); + setNextCursor(nc); + setIsLast(last); + } else { + setIsLast(true); + } + } catch { + setIsLast(true); + } finally { + setIsLoadingMore(false); + } + }, [ + searchTerm, + nextCursor, + isLast, + isLoadingMore, + selectedFilter, + toSortKey, + isFinalized, + category, + ]); + + const lastRoomElementCallback = useCallback( + (node: HTMLDivElement | null) => { + if (isLoadingMore || isLast) return; + if (observerRef.current) observerRef.current.disconnect(); + + observerRef.current = new IntersectionObserver(entries => { + if (entries[0].isIntersecting && !isLoadingMore && !isLast) { + loadMore(); + } + }); + if (node) observerRef.current.observe(node); + }, + [isLoadingMore, isLast, loadMore], + ); + + const handleChange = (value: string) => { + setSearchTerm(value); + setIsFinalized(false); + const trimmed = value.trim(); + setIsSearching(trimmed !== ''); + setNextCursor(null); + setIsLast(true); + setRooms([]); + + if (searchTimeoutId) { + clearTimeout(searchTimeoutId); + } + + if (trimmed) { + const id = setTimeout(() => { + searchFirstPage(trimmed, toSortKey(selectedFilter), false); + }, 300); + setSearchTimeoutId(id); + } else { + setError(null); + } + }; + const handleSearch = () => { - if (!searchTerm.trim()) return; - setIsSearching(true); - runSearch(searchTerm, toSortKey(selectedFilter), undefined, false); + const term = searchTerm.trim(); + if (!term) return; + if (searchTimeoutId) { + clearTimeout(searchTimeoutId); + setSearchTimeoutId(null); + } + searchFirstPage(term, toSortKey(selectedFilter), true); }; - const handleRecentSearchClick = (recentSearch: string) => { - setSearchTerm(recentSearch); + const handleRecentSearchClick = (recent: string) => { + setSearchTerm(recent); setIsSearching(true); - runSearch(recentSearch, toSortKey(selectedFilter), undefined, false); + setIsFinalized(false); + if (searchTimeoutId) { + clearTimeout(searchTimeoutId); + setSearchTimeoutId(null); + } + searchFirstPage(recent, toSortKey(selectedFilter), true); }; useEffect(() => { if (isSearching && searchTerm.trim()) { - runSearch(searchTerm, toSortKey(selectedFilter), undefined, false); + if (searchTimeoutId) { + clearTimeout(searchTimeoutId); + setSearchTimeoutId(null); + } + searchFirstPage(searchTerm.trim(), toSortKey(selectedFilter), false); } - }, [selectedFilter, isSearching, searchTerm, runSearch, toSortKey]); + }, [selectedFilter, isSearching, searchTerm, searchFirstPage, toSortKey, searchTimeoutId]); useEffect(() => { if (isSearching && searchTerm.trim()) { - runSearch(searchTerm, toSortKey(selectedFilter), undefined, false); + if (searchTimeoutId) { + clearTimeout(searchTimeoutId); + setSearchTimeoutId(null); + } + searchFirstPage(searchTerm.trim(), toSortKey(selectedFilter), false); } - }, [category, isSearching, searchTerm, runSearch, toSortKey, selectedFilter]); + }, [ + category, + isSearching, + searchTerm, + searchFirstPage, + toSortKey, + selectedFilter, + searchTimeoutId, + ]); - const handleLoadMore = () => { - if (!isLast && nextCursor && searchTerm.trim()) { - runSearch(searchTerm, toSortKey(selectedFilter), nextCursor, true); + const handleBackButton = () => { + if (searchTimeoutId) { + clearTimeout(searchTimeoutId); + setSearchTimeoutId(null); } + setSearchTerm(''); + setIsSearching(false); + setIsFinalized(false); + setRooms([]); + setNextCursor(null); + setIsLast(true); + setError(null); }; - const handleBackButton = () => navigate('/group'); + useEffect(() => { + return () => { + if (searchTimeoutId) clearTimeout(searchTimeoutId); + if (observerRef.current) observerRef.current.disconnect(); + }; + }, [searchTimeoutId]); return ( @@ -130,16 +240,19 @@ const GroupSearch = () => { {isSearching ? ( { /> ) : ( i.searchTerm)} + recentSearches={recentSearches.map(i => i.searchTerm)} handleDelete={(term: string) => { const x = recentSearches.find(i => i.searchTerm === term); - if (x) - deleteRecentSearch(x.recentSearchId, 1).then(res => { - if (res.isSuccess) { - setRecentSearches(prev => - prev.filter(it => it.recentSearchId !== x.recentSearchId), - ); - } - }); + if (!x) return; + const userId = 1; + deleteRecentSearch(x.recentSearchId, userId).then(res => { + if (res.isSuccess) { + setRecentSearches(prev => + prev.filter(it => it.recentSearchId !== x.recentSearchId), + ); + } + }); }} handleRecentSearchClick={handleRecentSearchClick} />