diff --git a/src/api/auth/exchangeTempToken.ts b/src/api/auth/exchangeTempToken.ts deleted file mode 100644 index 49b33a7c..00000000 --- a/src/api/auth/exchangeTempToken.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { apiClient } from '../index'; - -export type ExchangeTempTokenRequest = Record; - -export interface ExchangeTempTokenResponse { - isSuccess: boolean; - code: number; - message: string; - data: { - accessToken: string; - // 기타 필요한 응답 데이터 - }; -} - -export const exchangeTempToken = async ( - data?: ExchangeTempTokenRequest, -): Promise => { - const response = await apiClient.post( - '/auth/exchange-temp-token', - data, - ); - return response.data; -}; diff --git a/src/api/auth/getToken.ts b/src/api/auth/getToken.ts new file mode 100644 index 00000000..8f9fa37d --- /dev/null +++ b/src/api/auth/getToken.ts @@ -0,0 +1,19 @@ +import { apiClient } from '../index'; + +export interface GetTokenRequest { + loginTokenKey: string; // 인가코드 +} + +export interface GetTokenResponse { + isSuccess: boolean; + code: number; + message: string; + data: { + token: string; // 토큰 + }; +} + +export const getToken = async (data: GetTokenRequest): Promise => { + const response = await apiClient.post('/auth/token', data); + return response.data; +}; diff --git a/src/api/auth/index.ts b/src/api/auth/index.ts index 98c28316..b5dac0eb 100644 --- a/src/api/auth/index.ts +++ b/src/api/auth/index.ts @@ -1,2 +1 @@ -export { setCookie } from './setCookie'; -export { exchangeTempToken } from './exchangeTempToken'; +export { getToken } from './getToken'; diff --git a/src/api/auth/setCookie.ts b/src/api/auth/setCookie.ts deleted file mode 100644 index 5d1a2702..00000000 --- a/src/api/auth/setCookie.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { apiClient } from '../index'; - -export type SetCookieRequest = Record; - -export interface SetCookieResponse { - isSuccess: boolean; - code: number; - message: string; - data: Record; -} - -export const setCookie = async (data?: SetCookieRequest): Promise => { - const response = await apiClient.post('/auth/set-cookie', data); - return response.data; -}; diff --git a/src/api/index.ts b/src/api/index.ts index 043a2e00..79258320 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -17,25 +17,18 @@ export const apiClient = axios.create({ // const TEMP_ACCESS_TOKEN = // 'eyJhbGciOiJIUzI1NiJ9.eyJ1c2VySWQiOjEsImlhdCI6MTc1NDM4MjY1MiwiZXhwIjoxNzU2OTc0NjUyfQ.BSGuoMWlrzc0oKgSJXHEycxdzzY9-e7gD4xh-wSDemc'; -// Request 인터셉터: temp_token과 access_token 쿠키 처리 +// Request 인터셉터: localStorage의 토큰을 헤더에 자동 추가 apiClient.interceptors.request.use( config => { - // 쿠키에서 temp_token과 access_token 확인 - const cookies = document.cookie.split(';'); - const hasTempToken = cookies.some(cookie => cookie.trim().startsWith('temp_token=')); - const hasAccessToken = cookies.some(cookie => cookie.trim().startsWith('access_token=')); + // localStorage에서 토큰 확인 + const authToken = localStorage.getItem('authToken'); - if (hasAccessToken) { - // access_token이 있으면 정상 토큰 사용 - console.log('✅ access_token 쿠키가 있어서 정상 토큰을 사용합니다.'); - // access_token은 withCredentials: true로 자동 전송되므로 별도 헤더 설정 불필요 - } else if (hasTempToken) { - // temp_token이 있으면 임시 토큰 사용 - console.log('🔑 temp_token 쿠키가 있어서 임시 토큰을 사용합니다.'); - // temp_token도 withCredentials: true로 자동 전송되므로 별도 헤더 설정 불필요 + if (authToken) { + // 토큰이 있으면 Authorization 헤더에 추가 + console.log('🔑 Authorization 헤더에 토큰 추가'); + config.headers.Authorization = `Bearer ${authToken}`; } else { - // 둘 다 없으면 인증 토큰 없음 - console.log('❌ temp_token과 access_token 쿠키가 모두 없습니다.'); + console.log('❌ localStorage에 토큰이 없습니다.'); } return config; @@ -50,8 +43,7 @@ apiClient.interceptors.response.use( (response: AxiosResponse) => response, (error: AxiosError) => { if (error.response?.status === 401) { - // 인증 실패 시 로그인 페이지로 리다이렉트 - // window.location.href = '/'; + window.location.href = '/'; } return Promise.reject(error); }, 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/api/users/postSignup.ts b/src/api/users/postSignup.ts index dfc001d5..95ed94e0 100644 --- a/src/api/users/postSignup.ts +++ b/src/api/users/postSignup.ts @@ -7,11 +7,12 @@ export interface PostSignupRequest { } export interface PostSignupResponse { - success: boolean; + isSuccess: boolean; code: number; message: string; data: { userId: number; + accessToken: string; // 회원가입 완료 후 받는 access 토큰 }; } 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/hooks/useSocialLoginToken.ts b/src/hooks/useSocialLoginToken.ts index e9818796..36e8924c 100644 --- a/src/hooks/useSocialLoginToken.ts +++ b/src/hooks/useSocialLoginToken.ts @@ -1,13 +1,15 @@ -import { useEffect } from 'react'; -import { useNavigate, useLocation } from 'react-router-dom'; -import { apiClient } from '@/api/index'; +import { useEffect, useRef, useCallback } from 'react'; +import { useLocation } from 'react-router-dom'; +import { getToken } from '@/api/auth'; export const useSocialLoginToken = () => { - const navigate = useNavigate(); const location = useLocation(); + // 토큰 발급 완료를 기다리는 Promise + const tokenPromise = useRef | null>(null); + useEffect(() => { - const handleSocialLoginToken = async () => { + const handleSocialLoginToken = async (): Promise => { // URL에서 loginTokenKey 가져오기 const params = new URLSearchParams(window.location.search); const loginTokenKey = params.get('loginTokenKey'); @@ -17,53 +19,29 @@ export const useSocialLoginToken = () => { return; } - // 현재 경로가 /signup인지 확인 - const isSignupPage = location.pathname === '/signup'; - try { - if (isSignupPage) { - // 회원가입 페이지인 경우: 임시토큰 발급 요청 - console.log('🔑 회원가입 페이지: 임시토큰 발급 요청'); - console.log('📋 loginTokenKey:', loginTokenKey); + console.log('🔑 소셜 로그인 토큰 발급 요청'); + console.log('📋 loginTokenKey:', loginTokenKey); - const response = await apiClient.post( - '/auth/set-cookie', - { loginTokenKey }, - { withCredentials: true }, - ); + // /auth/token API 호출하여 토큰 발급 (임시 토큰 또는 access 토큰) + const response = await getToken({ loginTokenKey }); - if (response.data.isSuccess) { - console.log('✅ 임시토큰 발급 성공'); - // URL에서 loginTokenKey 파라미터 제거 - const newUrl = window.location.pathname; - window.history.replaceState({}, document.title, newUrl); - } else { - console.error('❌ 임시토큰 발급 실패:', response.data.message); - } - } else { - // 피드 페이지 등 다른 페이지인 경우: 엑세스토큰 발급 요청 - console.log('🔑 피드 페이지: 엑세스토큰 발급 요청'); - console.log('📋 loginTokenKey:', loginTokenKey); + if (response.isSuccess) { + const { token } = response.data; + + // 토큰을 localStorage에 저장 (request header에 사용) + localStorage.setItem('authToken', token); - const response = await apiClient.post( - '/auth/exchange-temp-token', - { loginTokenKey }, - { withCredentials: true }, - ); + console.log('✅ Access 토큰 발급 성공 (바로 홈 화면)'); - if (response.data.isSuccess) { - console.log('✅ 엑세스토큰 발급 성공'); - // URL에서 loginTokenKey 파라미터 제거 - const newUrl = window.location.pathname; - window.history.replaceState({}, document.title, newUrl); - } else { - console.error('❌ 엑세스토큰 발급 실패:', response.data.message); - navigate('/'); - } + // URL에서 loginTokenKey 파라미터 제거 + const newUrl = window.location.pathname; + window.history.replaceState({}, document.title, newUrl); + } else { + console.error('❌ 토큰 발급 실패:', response.message); } } catch (error) { console.error('💥 토큰 발급 중 오류 발생:', error); - navigate('/'); } }; @@ -72,7 +50,17 @@ export const useSocialLoginToken = () => { const isSocialLoginComplete = urlParams.get('loginTokenKey'); if (isSocialLoginComplete) { - handleSocialLoginToken(); + // 토큰 발급 Promise를 저장 + tokenPromise.current = handleSocialLoginToken(); + } + }, [location.pathname]); + + // 토큰 발급 완료를 기다리는 함수 반환 + const waitForToken = useCallback(async (): Promise => { + if (tokenPromise.current) { + await tokenPromise.current; } - }, [location.pathname, navigate]); // location.pathname과 navigate만 의존성으로 설정 + }, []); + + return { waitForToken }; }; diff --git a/src/pages/feed/Feed.tsx b/src/pages/feed/Feed.tsx index e3febc52..53b0df43 100644 --- a/src/pages/feed/Feed.tsx +++ b/src/pages/feed/Feed.tsx @@ -21,7 +21,7 @@ const Feed = () => { const [activeTab, setActiveTab] = useState(initialTabFromState ?? tabs[0]); // 소셜 로그인 토큰 발급 처리 - useSocialLoginToken(); + const { waitForToken } = useSocialLoginToken(); // 최초 마운트 시에만 history state 제거하여 이후 재방문 시 영향 없도록 처리 useEffect(() => { @@ -48,7 +48,7 @@ const Feed = () => { }; // 전체 피드 로드 함수 - const loadTotalFeeds = async (_cursor?: string) => { + const loadTotalFeeds = useCallback(async (_cursor?: string) => { try { setTotalLoading(true); @@ -74,10 +74,10 @@ const Feed = () => { } finally { setTotalLoading(false); } - }; + }, []); // 내 피드 로드 함수 - const loadMyFeeds = async (_cursor?: string) => { + const loadMyFeeds = useCallback(async (_cursor?: string) => { try { setMyLoading(true); const response = await getMyFeeds(_cursor ? { cursor: _cursor } : undefined); @@ -97,7 +97,7 @@ const Feed = () => { } finally { setMyLoading(false); } - }; + }, []); // 다음 페이지 로드 (무한 스크롤용) const loadMoreFeeds = useCallback(() => { @@ -146,12 +146,26 @@ const Feed = () => { // 탭별로 API 호출 useEffect(() => { - if (activeTab === '피드') { - loadTotalFeeds(); - } else if (activeTab === '내 피드') { - loadMyFeeds(); - } - }, [activeTab]); + const loadFeedsWithToken = async () => { + // 토큰 발급 완료 대기 + await waitForToken(); + + // localStorage에 토큰이 있는지 확인 + const authToken = localStorage.getItem('authToken'); + if (!authToken) { + console.log('❌ 토큰이 없어서 피드를 로드할 수 없습니다.'); + return; + } + + if (activeTab === '피드') { + loadTotalFeeds(); + } else if (activeTab === '내 피드') { + loadMyFeeds(); + } + }; + + loadFeedsWithToken(); + }, [activeTab, waitForToken, loadTotalFeeds, loadMyFeeds]); return ( 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/login/Login.tsx b/src/pages/login/Login.tsx index 02835ce7..04075dcd 100644 --- a/src/pages/login/Login.tsx +++ b/src/pages/login/Login.tsx @@ -1,3 +1,5 @@ +import { useEffect } from 'react'; +import { useNavigate } from 'react-router-dom'; import styled from '@emotion/styled'; import logo from '../../assets/login/logo.svg'; import KaKao from '../../assets/login/kakao.svg'; @@ -5,6 +7,17 @@ import Google from '../../assets/login/google.svg'; import { Wrapper } from '@/components/common/Wrapper'; const Login = () => { + const navigate = useNavigate(); + + // 이미 토큰이 있으면 /feed로 바로 이동 + useEffect(() => { + const authToken = localStorage.getItem('authToken'); + if (authToken) { + console.log('✅ 이미 토큰이 있어서 /feed로 바로 이동합니다.'); + navigate('/feed'); + } + }, [navigate]); + const handleKakaoLogin = () => { // 직접 카카오 로그인 URL로 리다이렉션 window.location.href = `${import.meta.env.VITE_API_BASE_URL}/oauth2/authorization/kakao`; 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} diff --git a/src/pages/signup/SignupGenre.tsx b/src/pages/signup/SignupGenre.tsx index cdee2fc6..6e9bfa16 100644 --- a/src/pages/signup/SignupGenre.tsx +++ b/src/pages/signup/SignupGenre.tsx @@ -61,25 +61,26 @@ const SignupGenre = () => { const handleNextClick = async () => { if (!selectedAlias || !nickname) return; - console.log('=== 🚀 다음 버튼 클릭 ==='); - console.log('🎭 선택된 alias:', selectedAlias); - console.log('👤 nickname:', nickname); - try { - console.log('🚀 postSignup API 호출 시작...'); - // ✅ 쿠키는 브라우저가 자동으로 전송 const result = await postSignup({ aliasName: selectedAlias.subTitle, nickname: nickname, isTokenRequired: false, }); - if (result.success) { + if (result.isSuccess) { console.log('🎉 회원가입 성공! 사용자 ID:', result.data.userId); + + // 회원가입 성공 시 새로운 access 토큰을 localStorage에 저장 + if (result.data.accessToken) { + localStorage.setItem('authToken', result.data.accessToken); + console.log('✅ 새로운 access 토큰이 localStorage에 저장되었습니다.'); + } + navigate('/signup/guide', { state: { - aliasName: selectedAlias.subTitle, nickname: nickname, + aliasName: selectedAlias.subTitle, }, }); } else { diff --git a/src/pages/signup/SignupNickname.tsx b/src/pages/signup/SignupNickname.tsx index 5a98f7b0..5066b628 100644 --- a/src/pages/signup/SignupNickname.tsx +++ b/src/pages/signup/SignupNickname.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect } from 'react'; +import { useState } from 'react'; import { useNavigate } from 'react-router-dom'; import { Container, InputBox, StyledInput, CharCount } from './Signup.styled'; import Header from '../../components/common/TitleHeader'; @@ -12,17 +12,10 @@ const SignupNickname = () => { const navigate = useNavigate(); // 소셜 로그인 토큰 발급 처리 - useSocialLoginToken(); + const { waitForToken } = useSocialLoginToken(); const isNextActive = nickname.length >= 2 && nickname.length <= maxLength; - // 페이지 로드 시 간단한 확인 - useEffect(() => { - console.log('=== 🔍 SignupNickname 페이지 로드 ==='); - console.log('📍 현재 페이지:', window.location.pathname); - console.log('✅ 토큰 발급 후 쿠키는 브라우저가 자동으로 처리합니다.'); - }, []); - const handleBackClick = () => { navigate(-1); }; @@ -35,7 +28,18 @@ const SignupNickname = () => { console.log('👤 입력된 닉네임:', nickname); try { - // ✅ 쿠키는 브라우저가 자동으로 전송 + // 토큰 발급 완료 대기 + await waitForToken(); + + // localStorage에 토큰이 있는지 확인 + const authToken = localStorage.getItem('authToken'); + if (!authToken) { + console.log('❌ 토큰이 없어서 닉네임 검증을 할 수 없습니다.'); + setError('인증 토큰이 없습니다. 다시 시도해주세요.'); + return; + } + + console.log('✅ 토큰 확인 완료, 닉네임 검증 API 호출'); const result = await postNickname(nickname); if (result.data.isVerified) {