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}
/>