diff --git a/src/api/rooms/getSearchRooms.ts b/src/api/rooms/getSearchRooms.ts new file mode 100644 index 00000000..b0a81245 --- /dev/null +++ b/src/api/rooms/getSearchRooms.ts @@ -0,0 +1,48 @@ +import { apiClient } from '../index'; + +export interface SearchRoomItem { + roomId: number; + bookImageUrl: string; + roomName: string; + memberCount: number; + recruitCount: number; + deadlineDate: string; + isPublic: boolean; + isFinalized?: boolean; + genre?: string; +} + +export interface SearchRoomsResponse { + isSuccess: boolean; + code: number; + message: string; + data: { + roomList: SearchRoomItem[]; + nextCursor: string | null; + isLast: boolean; + }; +} + +export const getSearchRooms = async ( + keyword: string, + sort: 'deadline' | 'memberCount', + cursor?: string, + isFinalized: boolean = false, + category: string = '', +): Promise => { + try { + const params = new URLSearchParams(); + params.append('keyword', keyword); + params.append('sort', sort); + params.append('isFinalized', String(isFinalized)); + if (cursor) params.append('cursor', cursor); + if (category) params.append('category', category); + + const url = `/rooms/search?${params.toString()}`; + const response = await apiClient.get(url); + return response.data; + } catch (error) { + console.error('방 검색 API 오류:', error); + throw error; + } +}; diff --git a/src/components/group/CompletedGroupModal.tsx b/src/components/group/CompletedGroupModal.tsx index b2708cd1..7f54418f 100644 --- a/src/components/group/CompletedGroupModal.tsx +++ b/src/components/group/CompletedGroupModal.tsx @@ -25,7 +25,7 @@ const CompletedGroupModal = ({ onClose }: CompletedGroupModalProps) => { participants: room.memberCount, maximumParticipants: room.recruitCount, coverUrl: room.bookImageUrl, - deadLine: 0, + deadLine: '', isOnGoing: false, }; }; diff --git a/src/components/group/MyGroupBox.tsx b/src/components/group/MyGroupBox.tsx index b1e297b1..1ac7eff0 100644 --- a/src/components/group/MyGroupBox.tsx +++ b/src/components/group/MyGroupBox.tsx @@ -15,7 +15,7 @@ export interface Group { userName?: string; progress?: number; coverUrl: string; - deadLine?: number; + deadLine?: string; genre?: string; isOnGoing?: boolean; } diff --git a/src/components/group/MyGroupModal.tsx b/src/components/group/MyGroupModal.tsx index 3731fd7d..f6d023c3 100644 --- a/src/components/group/MyGroupModal.tsx +++ b/src/components/group/MyGroupModal.tsx @@ -26,7 +26,7 @@ export const MyGroupModal = ({ onClose }: MyGroupModalProps) => { participants: room.memberCount, maximumParticipants: room.recruitCount, coverUrl: room.bookImageUrl, - deadLine: 0, + deadLine: '', genre: '', isOnGoing: room.type === 'playing' || room.type === 'playingAndRecruiting', }; diff --git a/src/components/search/GroupSearchResult.tsx b/src/components/search/GroupSearchResult.tsx index 1c07f27b..b624a7eb 100644 --- a/src/components/search/GroupSearchResult.tsx +++ b/src/components/search/GroupSearchResult.tsx @@ -1,139 +1,95 @@ import styled from '@emotion/styled'; -import { useState } from 'react'; -import type { Group } from '../group/MyGroupBox'; +import { useMemo } from 'react'; import { GroupCard } from '../group/GroupCard'; import { colors, typography } from '@/styles/global/global'; import { Filter } from '../common/Filter'; - -const GENRE = ['문학', '과학·IT', '사회과학', '인문학', '예술']; +import type { SearchRoomItem } from '@/api/rooms/getSearchRooms'; const FILTER = ['마감임박순', '인기순']; +const CATEGORIES = ['문학', '과학·IT', '사회과학', '인문학', '예술'] as const; + +interface Props { + rooms: SearchRoomItem[]; + isLoading: boolean; + isLast: boolean; + onLoadMore: () => void; + error: string | null; + selectedFilter: string; + setSelectedFilter: (v: string) => void; + onChangeCategory: (category: string) => void; + currentCategory: string; +} + +const mapToGroupCardModel = (r: SearchRoomItem) => ({ + id: String(r.roomId), + title: r.roomName, + userName: '', + participants: r.memberCount, + maximumParticipants: r.recruitCount, + coverUrl: r.bookImageUrl, + deadLine: r.deadlineDate, + genre: r.genre ?? '', + isOnGoing: r.isPublic, +}); -const dummyMyGroups: Group[] = [ - { - id: '1', - title: '시집만 읽는 사람들 3월', - userName: 'hoho2', - participants: 15, - maximumParticipants: 30, - coverUrl: - 'https://marketplace.canva.com/EAF9zlwqylI/1/0/1003w/canva-%EB%B2%A0%EC%9D%B4%EC%A7%80-%EC%A3%BC%ED%99%A9-%EA%B7%80%EC%97%BD%EA%B3%A0-%EB%AF%B8%EB%8B%88%EB%A9%80%ED%95%9C-%EC%9D%BC%EB%9F%AC%EC%8A%A4%ED%8A%B8-e%EB%B6%81-%EC%9C%84%EB%A1%9C-%EC%A2%8B%EC%9D%80%EA%B8%80-%EC%B1%85%ED%91%9C%EC%A7%80-zrZ6hI8_IWo.jpg', - deadLine: 1, - genre: '문학', - isOnGoing: true, - }, - { - id: '2', - title: '시집만 읽는 사람들 3월', - userName: 'hoho2', - participants: 15, - maximumParticipants: 30, - coverUrl: - 'https://marketplace.canva.com/EAF9zlwqylI/1/0/1003w/canva-%EB%B2%A0%EC%9D%B4%EC%A7%80-%EC%A3%BC%ED%99%A9-%EA%B7%80%EC%97%BD%EA%B3%A0-%EB%AF%B8%EB%8B%88%EB%A9%80%ED%95%9C-%EC%9D%BC%EB%9F%AC%EC%8A%A4%ED%8A%B8-e%EB%B6%81-%EC%9C%84%EB%A1%9C-%EC%A2%8B%EC%9D%80%EA%B8%80-%EC%B1%85%ED%91%9C%EC%A7%80-zrZ6hI8_IWo.jpg', - deadLine: 2, - genre: '문학', - isOnGoing: true, - }, - { - id: '3', - title: '시집만 읽는 사람들 3월', - userName: 'hoho2', - participants: 15, - maximumParticipants: 30, - coverUrl: - 'https://marketplace.canva.com/EAF9zlwqylI/1/0/1003w/canva-%EB%B2%A0%EC%9D%B4%EC%A7%80-%EC%A3%BC%ED%99%A9-%EA%B7%80%EC%97%BD%EA%B3%A0-%EB%AF%B8%EB%8B%88%EB%A9%80%ED%95%9C-%EC%9D%BC%EB%9F%AC%EC%8A%A4%ED%8A%B8-e%EB%B6%81-%EC%9C%84%EB%A1%9C-%EC%A2%8B%EC%9D%80%EA%B8%80-%EC%B1%85%ED%91%9C%EC%A7%80-zrZ6hI8_IWo.jpg', - deadLine: 3, - genre: '문학', - isOnGoing: true, - }, - { - id: '4', - title: '시집만 읽는 사람들 3월', - userName: 'hoho2', - participants: 15, - maximumParticipants: 30, - coverUrl: - 'https://marketplace.canva.com/EAF9zlwqylI/1/0/1003w/canva-%EB%B2%A0%EC%9D%B4%EC%A7%80-%EC%A3%BC%ED%99%A9-%EA%B7%80%EC%97%BD%EA%B3%A0-%EB%AF%B8%EB%8B%88%EB%A9%80%ED%95%9C-%EC%9D%BC%EB%9F%AC%EC%8A%A4%ED%8A%B8-e%EB%B6%81-%EC%9C%84%EB%A1%9C-%EC%A2%8B%EC%9D%80%EA%B8%80-%EC%B1%85%ED%91%9C%EC%A7%80-zrZ6hI8_IWo.jpg', - deadLine: 4, - genre: '문학', - isOnGoing: true, - }, - { - id: '5', - title: '시집만 읽는 사람들 3월', - userName: 'hoho2', - participants: 15, - maximumParticipants: 30, - coverUrl: - 'https://marketplace.canva.com/EAF9zlwqylI/1/0/1003w/canva-%EB%B2%A0%EC%9D%B4%EC%A7%80-%EC%A3%BC%ED%99%A9-%EA%B7%80%EC%97%BD%EA%B3%A0-%EB%AF%B8%EB%8B%88%EB%A9%80%ED%95%9C-%EC%9D%BC%EB%9F%AC%EC%8A%A4%ED%8A%B8-e%EB%B6%81-%EC%9C%84%EB%A1%9C-%EC%A2%8B%EC%9D%80%EA%B8%80-%EC%B1%85%ED%91%9C%EC%A7%80-zrZ6hI8_IWo.jpg', - deadLine: 4, - genre: '과학·IT', - isOnGoing: false, - }, - { - id: '6', - title: '시집만 읽는 사람들 3월', - userName: 'hoho2', - participants: 15, - maximumParticipants: 30, - coverUrl: - 'https://marketplace.canva.com/EAF9zlwqylI/1/0/1003w/canva-%EB%B2%A0%EC%9D%B4%EC%A7%80-%EC%A3%BC%ED%99%A9-%EA%B7%80%EC%97%BD%EA%B3%A0-%EB%AF%B8%EB%8B%88%EB%A9%80%ED%95%9C-%EC%9D%BC%EB%9F%AC%EC%8A%A4%ED%8A%B8-e%EB%B6%81-%EC%9C%84%EB%A1%9C-%EC%A2%8B%EC%9D%80%EA%B8%80-%EC%B1%85%ED%91%9C%EC%A7%80-zrZ6hI8_IWo.jpg', - deadLine: 4, - genre: '과학·IT', - isOnGoing: false, - }, -]; - -const GroupSearchResult = () => { - const [selected, setSelected] = useState(''); - const [showGroup] = useState(dummyMyGroups); - const [selectedFilter, setSelectedFilter] = useState('마감임박순'); - - const handleSelectTab = (tab: string) => { - if (selected === tab) { - setSelected(''); - } else setSelected(tab); - }; - - const isEmptyShowGroup = () => { - if (showGroup.length === 0) { - return true; - } else return false; - }; +const GroupSearchResult = ({ + rooms, + isLoading, + isLast, + onLoadMore, + error, + selectedFilter, + setSelectedFilter, + onChangeCategory, + currentCategory, +}: Props) => { + const mapped = useMemo(() => rooms.map(mapToGroupCardModel), [rooms]); + const isEmpty = !isLoading && mapped.length === 0; return ( <> - {GENRE.map(tab => ( - handleSelectTab(tab)}> - {tab} - - ))} + {CATEGORIES.map(tab => { + const selected = tab === currentCategory; + return ( + onChangeCategory(selected ? '' : tab)} + aria-pressed={selected} + > + {tab} + + ); + })} - 전체 {showGroup.length} + 전체 {mapped.length} + /> - {isEmptyShowGroup() ? ( + {error && {error}} + {isEmpty ? ( 해당하는 모임방이 없어요 - 직접 모임방을 만들어보세요. + 검색어를 바꿔보거나 직접 모임방을 만들어보세요. ) : ( - showGroup.map(group => ( - + mapped.map(group => ( + )) )} + + + {isLoading && 불러오는 중...} + {!isLoading && !isLast && mapped.length > 0 && ( + 더 보기 + )} + ); @@ -146,7 +102,7 @@ const TabContainer = styled.div` flex-wrap: wrap; gap: 12px; padding: 0 20px; - margin-bottom: 24px; + margin-bottom: 16px; `; const Tab = styled.button<{ selected?: boolean }>` @@ -187,7 +143,7 @@ const GroupNum = styled.span` const EmptyContent = styled.div` display: flex; - height: 100vh; + height: 60vh; flex-direction: column; justify-content: center; align-items: center; @@ -199,12 +155,38 @@ const EmptyMainText = styled.p` font-size: ${typography.fontSize.lg}; font-weight: ${typography.fontWeight.semibold}; text-align: center; - justify-self: center; `; + const EmptySubText = styled.p` color: ${colors.grey[100]}; font-size: ${typography.fontSize.sm}; font-weight: ${typography.fontWeight.regular}; text-align: center; - justify-self: 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}; +`; + +const ErrorText = styled.p` + color: #ff6b6b; + font-size: ${typography.fontSize.sm}; + text-align: center; `; diff --git a/src/pages/group/Group.tsx b/src/pages/group/Group.tsx index 415fcdf6..62cc4b34 100644 --- a/src/pages/group/Group.tsx +++ b/src/pages/group/Group.tsx @@ -23,9 +23,7 @@ const convertRoomItemToGroup = ( participants: room.memberCount, maximumParticipants: room.recruitCount, coverUrl: room.bookImageUrl, - deadLine: Math.ceil( - (new Date(room.deadlineDate).getTime() - new Date().getTime()) / (1000 * 60 * 60 * 24), - ), + deadLine: room.deadlineDate, genre: category, }); diff --git a/src/pages/groupDetail/GroupDetail.tsx b/src/pages/groupDetail/GroupDetail.tsx index cd1d1dcb..621d8ffb 100644 --- a/src/pages/groupDetail/GroupDetail.tsx +++ b/src/pages/groupDetail/GroupDetail.tsx @@ -65,7 +65,7 @@ const GroupDetail = () => { participants: room.memberCount, maximumParticipants: room.recruitCount, coverUrl: room.roomImageUrl, - deadLine: 0, + deadLine: '', genre: '', isOnGoing: true, }; diff --git a/src/pages/groupSearch/GroupSearch.tsx b/src/pages/groupSearch/GroupSearch.tsx index 1dae1663..f332cee8 100644 --- a/src/pages/groupSearch/GroupSearch.tsx +++ b/src/pages/groupSearch/GroupSearch.tsx @@ -3,78 +3,121 @@ 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 } from 'react'; +import { useState, useEffect, useCallback } 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 [isLoading, setIsLoading] = useState(false); - - const fetchRecentSearches = async () => { - try { - setIsLoading(true); - const response = await getRecentSearch('ROOM'); - - if (response.isSuccess) { - setRecentSearches(response.data.recentSearchList); - } else { - console.error('최근 검색어 조회 실패:', response.message); - setRecentSearches([]); - } - } catch (error) { - console.error('최근 검색어 조회 오류:', error); - setRecentSearches([]); - } finally { - setIsLoading(false); - } - }; + const [isLoadingRecent, setIsLoadingRecent] = useState(false); + + const [rooms, setRooms] = useState([]); + const [nextCursor, setNextCursor] = useState(null); + const [isLast, setIsLast] = useState(true); + const [isLoadingList, setIsLoadingList] = 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(() => { - fetchRecentSearches(); + (async () => { + try { + setIsLoadingRecent(true); + const response = await getRecentSearch('ROOM'); + setRecentSearches(response.isSuccess ? response.data.recentSearchList : []); + } finally { + setIsLoadingRecent(false); + } + })(); }, []); - // eslint-disable-next-line @typescript-eslint/no-unused-vars - const handleSearch = (_term: string) => { - setIsSearching(true); - // 검색 로직만 수행, 최근 검색어는 서버에서 관리 - }; + const runSearch = useCallback( + async (keyword: string, sortKey: SortKey, cursor?: string, append = false) => { + if (!keyword.trim()) return; + try { + setIsLoadingList(true); + setError(null); - const handleDelete = async (recentSearchId: number) => { - try { - const userId = 1; // 임시 userId + const res = await getSearchRooms(keyword.trim(), sortKey, cursor, isFinalized, category); - const response = await deleteRecentSearch(recentSearchId, userId); + if (!res.isSuccess) { + if (!append) { + setRooms([]); + setNextCursor(null); + setIsLast(true); + } + setError(res.message || '검색 실패'); + return; + } - if (response.isSuccess) { - setRecentSearches(prev => prev.filter(item => item.recentSearchId !== recentSearchId)); - } else { - console.error('최근 검색어 삭제 실패:', response.message); + const { roomList, nextCursor: nc, isLast: last } = res.data; + setRooms(prev => (append ? [...prev, ...roomList] : roomList)); + setNextCursor(nc); + setIsLast(last); + } catch { + if (!append) { + setRooms([]); + setNextCursor(null); + setIsLast(true); + } + setError('네트워크 오류가 발생했습니다.'); + } finally { + setIsLoadingList(false); } - } catch (error) { - console.error('최근 검색어 삭제 오류:', error); - } + }, + [category, isFinalized], + ); + + const handleSearch = () => { + if (!searchTerm.trim()) return; + setIsSearching(true); + runSearch(searchTerm, toSortKey(selectedFilter), undefined, false); }; const handleRecentSearchClick = (recentSearch: string) => { setSearchTerm(recentSearch); + setIsSearching(true); + runSearch(recentSearch, toSortKey(selectedFilter), undefined, false); }; - const handleDeleteWrapper = (searchTerm: string) => { - const recentSearchItem = recentSearches.find(item => item.searchTerm === searchTerm); - if (recentSearchItem) { - handleDelete(recentSearchItem.recentSearchId); + useEffect(() => { + if (isSearching && searchTerm.trim()) { + runSearch(searchTerm, toSortKey(selectedFilter), undefined, false); } - }; + }, [selectedFilter, isSearching, searchTerm, runSearch, toSortKey]); + + useEffect(() => { + if (isSearching && searchTerm.trim()) { + runSearch(searchTerm, toSortKey(selectedFilter), undefined, false); + } + }, [category, isSearching, searchTerm, runSearch, toSortKey, selectedFilter]); - const handleBackButton = () => { - navigate('/group'); + const handleLoadMore = () => { + if (!isLast && nextCursor && searchTerm.trim()) { + runSearch(searchTerm, toSortKey(selectedFilter), nextCursor, true); + } }; + + const handleBackButton = () => navigate('/group'); + return ( @@ -83,22 +126,42 @@ const GroupSearch = () => { leftIcon={뒤로 가기} onLeftClick={handleBackButton} /> + { - if (searchTerm.trim()) handleSearch(searchTerm.trim()); - }} + onSearch={handleSearch} /> + {isSearching ? ( - + ) : ( item.searchTerm)} - handleDelete={handleDeleteWrapper} + recentSearches={isLoadingRecent ? [] : 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), + ); + } + }); + }} handleRecentSearchClick={handleRecentSearchClick} - > + /> )} diff --git a/src/pages/searchBook/SearchBookGroup.tsx b/src/pages/searchBook/SearchBookGroup.tsx index c3aff3d3..5806d660 100644 --- a/src/pages/searchBook/SearchBookGroup.tsx +++ b/src/pages/searchBook/SearchBookGroup.tsx @@ -56,7 +56,7 @@ const SearchBookGroup = () => { title: room.roomName, participants: room.memberCount, maximumParticipants: room.recruitCount, - deadLine: 0, + deadLine: '', coverUrl: room.bookImageUrl || bookInfo?.imageUrl, }} isOngoing={true}