diff --git a/src/App.tsx b/src/App.tsx index bb3db881..2af98459 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,16 +1,15 @@ import Router from './pages'; import { Global } from '@emotion/react'; -import { CookiesProvider } from 'react-cookie'; import { globalStyles } from './styles/global/global'; import PopupContainer from './components/common/Modal/PopupContainer'; const App = () => { return ( - + <> - + ); }; diff --git a/src/api/index.ts b/src/api/index.ts index 13693080..68563e02 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -1,87 +1,26 @@ import axios, { type AxiosResponse, type AxiosError } from 'axios'; -// 하드코딩된 액세스 토큰 -const ACCESS_TOKEN = - 'Bearer eyJhbGciOiJIUzI1NiJ9.eyJ1c2VySWQiOjksImlhdCI6MTc1NDM4MjY1MiwiZXhwIjoxNzU2OTc0NjUyfQ.CCb_F6OGe02_ITYsE-tqc2_PvSkRsxd96t8NWNIa1pI'; - -// 토큰 관리 유틸리티 -export const TokenManager = { - setAccessToken: (token: string) => localStorage.setItem('accessToken', token), - getAccessToken: (): string | null => localStorage.getItem('accessToken'), - // setRefreshToken: (token: string) => localStorage.setItem('refreshToken', token), - // getRefreshToken: (): string | null => localStorage.getItem('refreshToken'), - clearTokens: () => { - localStorage.removeItem('accessToken'); - // localStorage.removeItem('refreshToken'); - }, - hasValidToken: (): boolean => !!localStorage.getItem('accessToken'), -}; - -// API 기본 설정 +// API 기본 URL const API_BASE_URL = import.meta.env.VITE_API_BASE_URL; -// 환경변수 확인용 -console.log('API_BASE_URL:', API_BASE_URL); - -// axios 인스턴스 생성 +// Axios 인스턴스 생성 export const apiClient = axios.create({ baseURL: API_BASE_URL, timeout: 10000, headers: { 'Content-Type': 'application/json', }, + withCredentials: true, // 쿠키 자동 전송 설정 }); -// 요청 인터셉터 -apiClient.interceptors.request.use( - config => { - // 로컬스토리지에서 토큰 먼저 확인 - const token = TokenManager.getAccessToken(); - if (token) { - config.headers.Authorization = `Bearer ${token}`; - } else { - // 토큰이 없으면 하드코딩된 토큰 사용 (개발용) - config.headers.Authorization = ACCESS_TOKEN; - } - return config; - }, - error => Promise.reject(error), -); - -// 응답 인터셉터 - 토큰 만료 처리 및 에러 처리 +// 응답 인터셉터 (에러 처리) apiClient.interceptors.response.use( (response: AxiosResponse) => response, (error: AxiosError) => { - const { status } = error.response || {}; - - // 에러 로깅 - console.error('API Error:', status, error.message); - - // 토큰 만료 또는 인증 실패 시 로그인 페이지로 리다이렉트 - if (status === 401) { - // alert('토큰이 만료되었거나 유효하지 않습니다. 로그인 페이지로 이동합니다.'); - - // 현재 페이지가 로그인 페이지가 아닌 경우에만 리다이렉트 - if (window.location.pathname !== '/') { - // alert('로그인이 필요합니다. 로그인 페이지로 이동합니다.'); - window.location.href = '/'; - } - } - - // 권한 없음 (403) 에러 처리 - if (status === 403) { - console.warn('접근 권한이 없습니다.'); - alert('접근 권한이 없습니다.'); + if (error.response?.status === 401) { + // 인증 실패 시 로그인 페이지로 리다이렉트 + // window.location.href = '/'; } - - // 서버 에러 (500번대) 처리 - if (status && status >= 500) { - console.error('서버 오류가 발생했습니다.'); - alert('서버에 문제가 발생했습니다. 잠시 후 다시 시도해주세요.'); - } - return Promise.reject(error); }, ); - -export default apiClient; diff --git a/src/api/recentsearch/getRecentSearch.ts b/src/api/recentsearch/getRecentSearch.ts new file mode 100644 index 00000000..3f156880 --- /dev/null +++ b/src/api/recentsearch/getRecentSearch.ts @@ -0,0 +1,33 @@ +import { apiClient } from '../index'; + +// 최근 검색어 유형 +export type SearchType = 'USER' | 'ROOM' | 'BOOK'; + +// 최근 검색어 데이터 타입 +export interface RecentSearchData { + recentSearchId: number; + searchTerm: string; +} + +// API 응답 타입 +export interface GetRecentSearchResponse { + isSuccess: boolean; + code: number; + message: string; + data: { + recentSearchList: RecentSearchData[]; + }; +} + +// 최근 검색어 조회 API 함수 +export const getRecentSearch = async (type: SearchType) => { + const response = await apiClient.get(`/recent-search?type=${type}`); + return response.data; +}; + +/* +// 사용 예시 +const recentUserSearches = await getRecentSearch('USER'); +const recentRoomSearches = await getRecentSearch('ROOM'); +const recentBookSearches = await getRecentSearch('BOOK'); +*/ diff --git a/src/api/users/getRecentFollowing.ts b/src/api/users/getRecentFollowing.ts new file mode 100644 index 00000000..db0773bd --- /dev/null +++ b/src/api/users/getRecentFollowing.ts @@ -0,0 +1,26 @@ +import { apiClient } from '../index'; + +// 최근 글 작성자 데이터 타입 +export interface RecentWriterData { + userId: number; + nickname: string; + profileImageUrl: string; +} + +// API 응답 타입 +export interface GetRecentFollowingResponse { + isSuccess: boolean; + code: number; + message: string; + data: { + recentWriters: RecentWriterData[]; + }; +} + +// 최근 글을 작성한 내 팔로우 리스트 조회 API 함수 +export const getRecentFollowing = async () => { + const response = await apiClient.get( + '/users/my-followings/recent-feeds', + ); + return response.data; +}; diff --git a/src/api/users/getUsers.ts b/src/api/users/getUsers.ts index 76324127..27419034 100644 --- a/src/api/users/getUsers.ts +++ b/src/api/users/getUsers.ts @@ -21,6 +21,7 @@ export interface GetUsersResponse { export interface GetUsersParams { keyword?: string; size?: number; + isFinalized?: boolean; } export const getUsers = async (params?: GetUsersParams) => { @@ -34,6 +35,10 @@ export const getUsers = async (params?: GetUsersParams) => { searchParams.append('size', params.size.toString()); } + if (params?.isFinalized !== undefined) { + searchParams.append('isFinalized', params.isFinalized.toString()); + } + const queryString = searchParams.toString(); const url = queryString ? `/users?${queryString}` : '/users'; diff --git a/src/components/feed/BookInfoCard.tsx b/src/components/feed/BookInfoCard.tsx index 59e10fcc..f512436c 100644 --- a/src/components/feed/BookInfoCard.tsx +++ b/src/components/feed/BookInfoCard.tsx @@ -6,6 +6,8 @@ const BookContainer = styled.div` display: flex; height: 44px; padding: 8px 4px 8px 12px; + min-width: 280px; + max-width: 500px; flex-direction: row; align-items: center; justify-content: space-between; @@ -15,7 +17,7 @@ const BookContainer = styled.div` .left { overflow: hidden; - max-width: 340px; + width: 220px; white-space: nowrap; color: var(--color-white); text-overflow: ellipsis; @@ -38,7 +40,7 @@ const BookContainer = styled.div` line-height: 24px; .name { - max-width: 100px; + width: 100px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; diff --git a/src/components/feed/FollowList.tsx b/src/components/feed/FollowList.tsx index 45f42c58..d259d57e 100644 --- a/src/components/feed/FollowList.tsx +++ b/src/components/feed/FollowList.tsx @@ -1,35 +1,47 @@ import styled from '@emotion/styled'; import { useNavigate } from 'react-router-dom'; +import { useState, useEffect } from 'react'; import rightArrow from '../../assets/feed/rightArrow.svg'; import people from '../../assets/feed/people.svg'; import character from '../../assets/feed/character.svg'; import { typography } from '@/styles/global/global'; - -const followerData = { - followers: [ - { userId: 1, src: 'https://placehold.co/36x36', username: 'user1' }, - { userId: 2, src: 'https://placehold.co/36x36', username: 'user2' }, - { userId: 3, src: 'https://placehold.co/36x36', username: 'user3' }, - { userId: 4, src: 'https://placehold.co/36x36', username: 'user4' }, - { userId: 5, src: 'https://placehold.co/36x36', username: 'user5' }, - { userId: 6, src: 'https://placehold.co/36x36', username: 'user6' }, - { userId: 7, src: 'https://placehold.co/36x36', username: 'user7' }, - { userId: 8, src: 'https://placehold.co/36x36', username: 'user8' }, - { userId: 9, src: 'https://placehold.co/36x36', username: 'user9' }, - { userId: 10, src: 'https://placehold.co/36x36', username: 'user10' }, - { userId: 11, src: 'https://placehold.co/36x36', username: 'user11' }, - { userId: 12, src: 'https://placehold.co/36x36', username: 'user12' }, - ], -}; +import { getRecentFollowing, type RecentWriterData } from '@/api/users/getRecentFollowing'; const FollowList = () => { const navigate = useNavigate(); - const { followers } = followerData; - const hasFollowers = followers.length > 0; - const visible = hasFollowers ? followers.slice(0, 10) : []; + const [recentWriters, setRecentWriters] = useState([]); + const [loading, setLoading] = useState(false); + + // API에서 최근 글 작성한 팔로우 리스트 조회 + const fetchRecentFollowing = async () => { + try { + setLoading(true); + const response = await getRecentFollowing(); + + if (response.isSuccess) { + setRecentWriters(response.data.recentWriters); + } else { + console.error('최근 팔로우 작성자 조회 실패:', response.message); + setRecentWriters([]); + } + } catch (error) { + console.error('최근 팔로우 작성자 조회 중 오류:', error); + setRecentWriters([]); + } finally { + setLoading(false); + } + }; + + // 컴포넌트 마운트 시 데이터 조회 + useEffect(() => { + fetchRecentFollowing(); + }, []); + + const hasFollowers = recentWriters.length > 0; + const visible = hasFollowers ? recentWriters.slice(0, 10) : []; const handleFindClick = () => { - navigate('/feed/usersearch'); + navigate('/feed/search'); }; const handleMoreClick = () => { @@ -46,13 +58,15 @@ const FollowList = () => {
내 띱
- {hasFollowers ? ( + {loading ? ( + <> + ) : hasFollowers ? (
- {visible.map(({ userId, src, username }) => ( -
handleProfileClick(userId)}> - -
{username}
+ {visible.map(({ userId, profileImageUrl, nickname }) => ( +
handleProfileClick(userId)}> + {nickname} +
{nickname}
))}
diff --git a/src/components/feed/UserProfileItem.tsx b/src/components/feed/UserProfileItem.tsx index a7ea0c83..d4c16639 100644 --- a/src/components/feed/UserProfileItem.tsx +++ b/src/components/feed/UserProfileItem.tsx @@ -56,7 +56,7 @@ const UserProfileItem = ({ )} {type === 'followerlist' && (
-
{followerCount}명이 띱하는 중
+
{followerCount ?? 0}명이 띱하는 중
)} diff --git a/src/hooks/useOAuthToken.ts b/src/hooks/useOAuthToken.ts new file mode 100644 index 00000000..3cd202d0 --- /dev/null +++ b/src/hooks/useOAuthToken.ts @@ -0,0 +1,37 @@ +import { useEffect, useState } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { apiClient } from '@/api/index'; + +export const useOAuthToken = () => { + const [isTokenRequested, setIsTokenRequested] = useState(false); + const navigate = useNavigate(); + + useEffect(() => { + const params = new URLSearchParams(window.location.search); + const loginTokenKey = params.get('loginTokenKey'); + + if (loginTokenKey && !isTokenRequested) { + console.log('=== 🔑 소셜 로그인 토큰 발급 요청 ==='); + console.log('📋 인가코드:', loginTokenKey); + + setIsTokenRequested(true); + + // 서버에 토큰 발급 요청 + apiClient + .post('/oauth-success', { loginTokenKey }, { withCredentials: true }) + .then(response => { + console.log('✅ 토큰 발급 성공:', response.data); + // URL에서 code 파라미터 제거 + const newUrl = window.location.pathname; + window.history.replaceState({}, document.title, newUrl); + }) + .catch(error => { + console.error('❌ 토큰 발급 실패:', error); + // 에러 발생 시 로그인 페이지로 이동 + navigate('/'); + }); + } + }, [isTokenRequested, navigate]); + + return { isTokenRequested }; +}; diff --git a/src/hooks/useUserSearch.ts b/src/hooks/useUserSearch.ts index be7e6c5d..49fad7be 100644 --- a/src/hooks/useUserSearch.ts +++ b/src/hooks/useUserSearch.ts @@ -6,9 +6,15 @@ interface UseUserSearchProps { keyword: string; size?: number; delay?: number; + isFinalized?: boolean; } -export const useUserSearch = ({ keyword, size = 10, delay = 300 }: UseUserSearchProps) => { +export const useUserSearch = ({ + keyword, + size = 10, + delay = 300, + isFinalized = false, +}: UseUserSearchProps) => { const [userList, setUserList] = useState([]); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); @@ -27,10 +33,10 @@ export const useUserSearch = ({ keyword, size = 10, delay = 300 }: UseUserSearch try { setLoading(true); setError(null); - const response = await getUsers({ keyword: searchKeyword, size, + isFinalized, }); const newUserList = response.data.userList; @@ -49,7 +55,7 @@ export const useUserSearch = ({ keyword, size = 10, delay = 300 }: UseUserSearch setLoading(false); } }, - [size], + [size, isFinalized], ); // 디바운스된 키워드가 변경될 때 검색 실행 diff --git a/src/pages/feed/Feed.tsx b/src/pages/feed/Feed.tsx index 08da8f6e..7c8e3be7 100644 --- a/src/pages/feed/Feed.tsx +++ b/src/pages/feed/Feed.tsx @@ -9,6 +9,7 @@ import writefab from '../../assets/common/writefab.svg'; import { useNavigate, useLocation } from 'react-router-dom'; import { getTotalFeeds } from '@/api/feeds/getTotalFeed'; import { getMyFeeds } from '@/api/feeds/getMyFeed'; +import { useOAuthToken } from '@/hooks/useOAuthToken'; import type { PostData } from '@/types/post'; const tabs = ['피드', '내 피드']; @@ -19,6 +20,9 @@ const Feed = () => { const initialTabFromState = (location.state as { initialTab?: string } | null)?.initialTab; const [activeTab, setActiveTab] = useState(initialTabFromState ?? tabs[0]); + // 소셜 로그인 토큰 발급 처리 + useOAuthToken(); + // 최초 마운트 시에만 history state 제거하여 이후 재방문 시 영향 없도록 처리 useEffect(() => { if (initialTabFromState) { diff --git a/src/pages/feed/UserSearch.tsx b/src/pages/feed/UserSearch.tsx index 5cbb4a68..ded4c001 100644 --- a/src/pages/feed/UserSearch.tsx +++ b/src/pages/feed/UserSearch.tsx @@ -20,6 +20,7 @@ const UserSearch = () => { keyword: searchTerm, size: 20, delay: 300, + isFinalized: isSearched, }); const [recentSearches, setRecentSearches] = useState([ diff --git a/src/pages/signup/SignupGenre.tsx b/src/pages/signup/SignupGenre.tsx index 5d4984c3..2ac65561 100644 --- a/src/pages/signup/SignupGenre.tsx +++ b/src/pages/signup/SignupGenre.tsx @@ -1,11 +1,9 @@ import { useState, useEffect } from 'react'; import { useNavigate, useLocation } from 'react-router-dom'; -import { useCookies } from 'react-cookie'; import { Container } from './Signup.styled'; import leftarrow from '../../assets/common/leftArrow.svg'; import TitleHeader from '../../components/common/TitleHeader'; import { postSignup } from '@/api/users/postSignup'; -import { apiClient } from '@/api/index'; const SignupGenre = () => { const [genres, setGenres] = useState< @@ -23,73 +21,27 @@ const SignupGenre = () => { } | null>(null); const navigate = useNavigate(); const location = useLocation(); - const [cookies] = useCookies(['Authorization']); // SignupNickname에서 넘어온 nickname 받기 const nickname = location.state?.nickname; - // react-cookie를 사용하여 Authorization 토큰 추출 - const getAuthTokenFromCookie = () => { - console.log('=== react-cookie 디버깅 ==='); - console.log('현재 페이지 URL:', window.location.href); - console.log('현재 도메인:', window.location.hostname); - console.log('react-cookie로 읽은 Authorization:', cookies.Authorization); - - if (cookies.Authorization) { - console.log('react-cookie로 Authorization 토큰 발견:', cookies.Authorization); - return cookies.Authorization; - } - - // 방법 2: 직접 쿠키 이름으로 검색 - const authCookie = document.cookie - .split(';') - .find(cookie => cookie.trim().startsWith('Authorization=')); - - if (authCookie) { - const token = authCookie.split('=')[1]; - console.log('직접 검색으로 Authorization 토큰 발견:', token); - return token; - } - - // 방법 3: 정규식으로 검색 - const cookieMatch = document.cookie.match(/Authorization=([^;]+)/); - if (cookieMatch && cookieMatch[1]) { - console.log('정규식으로 Authorization 토큰 발견:', cookieMatch[1]); - return cookieMatch[1]; - } - - // 방법 4: 모든 쿠키를 순회하며 검색 - const allCookies = document.cookie.split(';'); - for (let i = 0; i < allCookies.length; i++) { - const cookie = allCookies[i].trim(); - if (cookie.startsWith('Authorization=')) { - const token = cookie.substring('Authorization='.length); - console.log('순회 검색으로 Authorization 토큰 발견:', token); - return token; - } - } - - // 방법 5: 쿠키가 비어있는지 확인 - if (!document.cookie || document.cookie.trim() === '') { - console.log('document.cookie가 비어있습니다.'); + // 페이지 로드 시 간단한 확인 + useEffect(() => { + console.log('=== 🔍 SignupGenre 페이지 로드 ==='); + console.log('📍 현재 페이지:', window.location.pathname); + console.log('👤 받은 nickname:', nickname); + + // nickname이 없으면 이전 페이지로 돌아가기 + if (!nickname) { + console.log('❌ nickname이 전달되지 않았습니다.'); + console.log('❌ 이전 페이지로 돌아갑니다.'); + navigate(-1); + return; } - // 방법 6: 쿠키 길이 확인 - console.log('쿠키 총 길이:', document.cookie.length); - console.log('쿠키 원본 문자열:', JSON.stringify(document.cookie)); - - console.log('react-cookie로 Authorization 토큰을 찾을 수 없습니다.'); - return null; - }; - - // 토큰을 헤더에 설정 - const setAuthTokenToHeader = (token: string) => { - // localStorage에 저장 (페이지 새로고침 시에도 유지) - localStorage.setItem('authToken', token); - - // apiClient 기본 헤더에 설정 - apiClient.defaults.headers.common['Authorization'] = `Bearer ${token}`; - }; + console.log('✅ nickname이 정상적으로 전달되었습니다.'); + console.log('✅ 쿠키는 브라우저가 자동으로 처리합니다.'); + }, [nickname, navigate]); useEffect(() => { fetch('/genres.json') @@ -105,27 +57,20 @@ const SignupGenre = () => { const handleNextClick = async () => { if (!selectedAlias || !nickname) return; - // 쿠키에서 토큰 추출 - const authToken = getAuthTokenFromCookie(); - if (!authToken) { - console.log('쿠키에서 Authorization 토큰을 찾을 수 없습니다.'); - console.log('토큰이 없어 회원가입을 진행할 수 없습니다.'); - return; // 토큰이 없으면 함수 종료하여 페이지에 머무름 - } - - // 토큰을 헤더에 설정 - setAuthTokenToHeader(authToken); - console.log('Authorization 토큰을 헤더에 설정했습니다.'); + console.log('=== 🚀 다음 버튼 클릭 ==='); + console.log('🎭 선택된 alias:', selectedAlias); + console.log('👤 nickname:', nickname); try { + console.log('🚀 postSignup API 호출 시작...'); + // ✅ 쿠키는 브라우저가 자동으로 전송 const result = await postSignup({ aliasName: selectedAlias.subTitle, nickName: nickname, }); if (result.success) { - console.log('회원가입 성공! 사용자 ID:', result.data.userId); - // 회원가입 완료 페이지로 이동 + console.log('🎉 회원가입 성공! 사용자 ID:', result.data.userId); navigate('/signupdone', { state: { aliasName: selectedAlias.subTitle, @@ -133,10 +78,10 @@ const SignupGenre = () => { }, }); } else { - console.error('회원가입 실패:', result.message); + console.error('❌ 회원가입 실패:', result.message); } } catch (error) { - console.error('회원가입 중 오류 발생:', error); + console.error('💥 회원가입 중 오류 발생:', error); } }; diff --git a/src/pages/signup/SignupNickname.tsx b/src/pages/signup/SignupNickname.tsx index 63cd7dc4..49d59feb 100644 --- a/src/pages/signup/SignupNickname.tsx +++ b/src/pages/signup/SignupNickname.tsx @@ -1,8 +1,9 @@ -import { useState } from 'react'; +import { useState, useEffect } from 'react'; import { useNavigate } from 'react-router-dom'; import { Container, InputBox, StyledInput, CharCount } from './Signup.styled'; import Header from '../../components/common/TitleHeader'; import { postNickname } from '@/api/users/postNickname'; +import { useOAuthToken } from '@/hooks/useOAuthToken'; const SignupNickname = () => { const [nickname, setNickname] = useState(''); @@ -10,8 +11,18 @@ const SignupNickname = () => { const maxLength = 10; const navigate = useNavigate(); + // 소셜 로그인 토큰 발급 처리 + useOAuthToken(); + const isNextActive = nickname.length >= 2 && nickname.length <= maxLength; + // 페이지 로드 시 간단한 확인 + useEffect(() => { + console.log('=== 🔍 SignupNickname 페이지 로드 ==='); + console.log('📍 현재 페이지:', window.location.pathname); + console.log('✅ 토큰 발급 후 쿠키는 브라우저가 자동으로 처리합니다.'); + }, []); + const handleBackClick = () => { navigate(-1); }; @@ -20,18 +31,24 @@ const SignupNickname = () => { if (!isNextActive) return; setError(''); + console.log('=== 🚀 닉네임 검증 시작 ==='); + console.log('👤 입력된 닉네임:', nickname); + try { + // ✅ 쿠키는 브라우저가 자동으로 전송 const result = await postNickname(nickname); if (result.data.isVerified) { + console.log('✅ 닉네임 검증 성공!'); // 닉네임 검증 성공 - 다음 단계로 진행 navigate('/signup/genre', { state: { nickname } }); } else { + console.log('❌ 닉네임 검증 실패 - 이미 사용중'); // 닉네임 검증 실패 - 우리가 정한 에러 메시지 setError('이미 사용중인 닉네임이에요.'); } } catch (error) { - console.error('닉네임 검증 실패:', error); + console.error('💥 닉네임 검증 중 오류 발생:', error); setError('닉네임 검증 중 오류가 발생했습니다.'); } };