diff --git a/src/api/auth/getToken.ts b/src/api/auth/getToken.ts index 8f9fa37d..4239e680 100644 --- a/src/api/auth/getToken.ts +++ b/src/api/auth/getToken.ts @@ -9,7 +9,8 @@ export interface GetTokenResponse { code: number; message: string; data: { - token: string; // 토큰 + token: string; // 토큰 (임시 또는 액세스) + isNewUser: boolean; // true면 회원가입 필요 (임시 토큰), false면 액세스 토큰 }; } diff --git a/src/api/index.ts b/src/api/index.ts index 99a41c10..f4efc05e 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -11,23 +11,35 @@ export const apiClient = axios.create({ 'Content-Type': 'application/json', }, }); -// Request 인터셉터: localStorage의 토큰을 헤더에 자동 추가 +// Request 인터셉터: 토큰 부재 시 비공개 API 요청을 선제 차단(리다이렉트 + 요청 취소) apiClient.interceptors.request.use( config => { - // localStorage에서 토큰 확인 const authToken = localStorage.getItem('authToken'); + const preAuthToken = localStorage.getItem('preAuthToken'); + // 공개 API(완전 공개) + const publicPaths = ['/auth/token']; + // 회원가입 진행 중 필요한 경로(임시 토큰 허용) + const signupPaths = ['/users/nickname', '/users/signup']; + const isPublic = publicPaths.some(path => config.url?.startsWith(path)); + const isSignupPath = signupPaths.some(path => config.url?.startsWith(path)); + + if (!authToken && !isPublic && !(preAuthToken && isSignupPath)) { + console.log('❌ 토큰 없음: 요청을 취소하고 홈으로 이동합니다.'); + window.location.href = '/'; + // 요청 자체를 취소하여 불필요한 네트워크 왕복 방지 + return Promise.reject(new Error('Request cancelled: missing auth token')); + } if (authToken) { config.headers.Authorization = `Bearer ${authToken}`; - } else { - console.log('❌ localStorage에 토큰이 없습니다.'); + } else if (preAuthToken && isSignupPath) { + // 회원가입 경로에서는 임시 토큰을 사용 + config.headers.Authorization = `Bearer ${preAuthToken}`; } return config; }, - error => { - return Promise.reject(error); - }, + error => Promise.reject(error), ); // Response 인터셉터: 401 에러 시 로그인 페이지로 리다이렉트 diff --git a/src/api/notifications/getNotifications.ts b/src/api/notifications/getNotifications.ts new file mode 100644 index 00000000..7b0b1eb5 --- /dev/null +++ b/src/api/notifications/getNotifications.ts @@ -0,0 +1,35 @@ +import { apiClient } from '../index'; + +export interface NotificationItem { + notificationId: number; + title: string; + content: string; + isChecked: boolean; + notificationType: string; + postDate: string; +} + +export interface GetNotificationsResponse { + isSuccess: boolean; + code: number; + message: string; + data: { + notifications: NotificationItem[]; + nextCursor: string; + isLast: boolean; + }; +} + +export interface GetNotificationsParams { + cursor?: string | null; + type?: 'feed' | 'room'; +} + +export const getNotifications = async ( + params?: GetNotificationsParams, +): Promise => { + const response = await apiClient.get('/notifications', { + params, + }); + return response.data; +}; diff --git a/src/api/users/deleteUsers.ts b/src/api/users/deleteUsers.ts new file mode 100644 index 00000000..cef623cc --- /dev/null +++ b/src/api/users/deleteUsers.ts @@ -0,0 +1,13 @@ +import { apiClient } from '../index'; + +export interface DeleteUsersResponse { + isSuccess: boolean; + code: number; + message: string; + data: null; +} + +export const deleteUsers = async (): Promise => { + const response = await apiClient.delete('/users'); + return response.data; +}; diff --git a/src/components/group/CompletedGroupModal.tsx b/src/components/group/CompletedGroupModal.tsx index afa624ab..68da2567 100644 --- a/src/components/group/CompletedGroupModal.tsx +++ b/src/components/group/CompletedGroupModal.tsx @@ -8,12 +8,14 @@ import { Modal, Overlay } from './Modal.styles'; import { getMyRooms, type Room } from '@/api/rooms/getMyRooms'; import { getMyProfile } from '@/api/users/getMyProfile'; import { colors, typography } from '@/styles/global/global'; +import { useNavigate } from 'react-router-dom'; interface CompletedGroupModalProps { onClose: () => void; } const CompletedGroupModal = ({ onClose }: CompletedGroupModalProps) => { + const navigate = useNavigate(); const [rooms, setRooms] = useState([]); const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(null); @@ -29,9 +31,14 @@ const CompletedGroupModal = ({ onClose }: CompletedGroupModalProps) => { coverUrl: room.bookImageUrl, deadLine: '', isOnGoing: false, + type: room.type, }; }; + const handleGroupCardClick = (group: Group) => { + navigate(`/group/detail/joined/${group.id}`); + }; + useEffect(() => { const fetchCompletedRooms = async () => { try { @@ -84,7 +91,14 @@ const CompletedGroupModal = ({ onClose }: CompletedGroupModalProps) => { ) : error ? ( {error} ) : convertedGroups.length > 0 ? ( - convertedGroups.map(group => ) + convertedGroups.map(group => ( + handleGroupCardClick(group)} + /> + )) ) : ( 완료된 모임방이 없어요 @@ -116,6 +130,11 @@ const Content = styled.div<{ isEmpty?: boolean }>` @media (min-width: 584px) { grid-template-columns: 1fr 1fr; } + + //항목이 하나일 때는 전체 열을 사용하여 2열 그리드처럼 보이지 않도록 처리 + & > *:only-child { + grid-column: 1 / -1; + } `; const LoadingMessage = styled.div` diff --git a/src/components/group/GroupCard.tsx b/src/components/group/GroupCard.tsx index 65a935ee..3b8f4299 100644 --- a/src/components/group/GroupCard.tsx +++ b/src/components/group/GroupCard.tsx @@ -35,15 +35,16 @@ export const GroupCard = forwardRef(

{group.participants}

/ {group.maximumParticipants}명 - {isOngoing === true ? ( - - {group.deadLine} 종료 - - ) : ( - - {group.deadLine} 모집 마감 - - )} + {(type !== 'modal' || group.type !== 'expired') && + (isOngoing === true ? ( + + {group.deadLine} 종료 + + ) : ( + + {group.deadLine} 모집 마감 + + ))} diff --git a/src/components/group/MyGroupBox.tsx b/src/components/group/MyGroupBox.tsx index 5dc9659e..986f7f68 100644 --- a/src/components/group/MyGroupBox.tsx +++ b/src/components/group/MyGroupBox.tsx @@ -22,6 +22,7 @@ export interface Group { genre?: string; isOnGoing?: boolean; isPublic?: boolean; + type?: string; } const convertJoinedRoomToGroup = (room: JoinedRoomItem): Group => ({ diff --git a/src/hooks/useSocialLoginToken.ts b/src/hooks/useSocialLoginToken.ts index 36e8924c..0d02cbbc 100644 --- a/src/hooks/useSocialLoginToken.ts +++ b/src/hooks/useSocialLoginToken.ts @@ -23,16 +23,23 @@ export const useSocialLoginToken = () => { console.log('🔑 소셜 로그인 토큰 발급 요청'); console.log('📋 loginTokenKey:', loginTokenKey); - // /auth/token API 호출하여 토큰 발급 (임시 토큰 또는 access 토큰) + // /auth/token API 호출하여 토큰 발급 (임시 토큰) const response = await getToken({ loginTokenKey }); if (response.isSuccess) { - const { token } = response.data; + const { token, isNewUser } = response.data; - // 토큰을 localStorage에 저장 (request header에 사용) - localStorage.setItem('authToken', token); - - console.log('✅ Access 토큰 발급 성공 (바로 홈 화면)'); + if (isNewUser) { + // 회원가입 진행용 임시 토큰 저장 + localStorage.setItem('preAuthToken', token); + localStorage.removeItem('authToken'); + console.log('✅ 신규 사용자: 임시 토큰 저장 (회원가입 진행)'); + } else { + // 기존 사용자: 액세스 토큰 저장 + localStorage.setItem('authToken', token); + localStorage.removeItem('preAuthToken'); + console.log('✅ 기존 사용자: 액세스 토큰 저장'); + } // URL에서 loginTokenKey 파라미터 제거 const newUrl = window.location.pathname; @@ -53,7 +60,7 @@ export const useSocialLoginToken = () => { // 토큰 발급 Promise를 저장 tokenPromise.current = handleSocialLoginToken(); } - }, [location.pathname]); + }, [location.pathname, location.search]); // 토큰 발급 완료를 기다리는 함수 반환 const waitForToken = useCallback(async (): Promise => { diff --git a/src/pages/feed/Feed.tsx b/src/pages/feed/Feed.tsx index 5fa26438..192e19f7 100644 --- a/src/pages/feed/Feed.tsx +++ b/src/pages/feed/Feed.tsx @@ -53,6 +53,10 @@ const Feed = () => { navigate('/feed/search'); }; + const handleNoticeButton = () => { + navigate('/notice'); + }; + // 전체 피드 로드 함수 const loadTotalFeeds = useCallback(async (_cursor?: string) => { try { @@ -175,7 +179,11 @@ const Feed = () => { return ( - + {initialLoading || tabLoading ? ( diff --git a/src/pages/group/Group.tsx b/src/pages/group/Group.tsx index 21d1b4bd..9c43a6f8 100644 --- a/src/pages/group/Group.tsx +++ b/src/pages/group/Group.tsx @@ -93,6 +93,10 @@ const Group = () => { navigate('/group/search'); }; + const handleNoticeButton = () => { + navigate('/notice'); + }; + const handleAllRoomsClick = () => { navigate('/group/search', { state: { @@ -105,7 +109,11 @@ const Group = () => { {isMyGroupModalOpen && } {isCompletedGroupModalOpen && } - + diff --git a/src/pages/mypage/WithdrawPage.tsx b/src/pages/mypage/WithdrawPage.tsx index c1c57169..ad8fe06f 100644 --- a/src/pages/mypage/WithdrawPage.tsx +++ b/src/pages/mypage/WithdrawPage.tsx @@ -7,10 +7,11 @@ import { colors, typography } from '@/styles/global/global'; import leftArrow from '../../assets/common/leftArrow.svg'; import withdraw from '@/assets/mypage/withdraw.svg'; import check from '@/assets/mypage/check.svg'; +import { deleteUsers } from '@/api/users/deleteUsers'; const WithdrawPage = () => { const navigate = useNavigate(); - const { openConfirm, closePopup } = usePopupActions(); + const { openConfirm, closePopup, openSnackbar } = usePopupActions(); const [isChecked, setIsChecked] = useState(false); const handleBack = () => { @@ -27,10 +28,35 @@ const WithdrawPage = () => { title: '정말 탈퇴하시겠어요?', disc: "'예'를 누르면 Thip에서의 모든 기록이 사라져요", onConfirm: () => { - // 회원탈퇴 API 호출 부분 (비워둠) - console.log('회원탈퇴 API 호출'); - closePopup(); - navigate('/mypage/withdraw/done'); + void (async () => { + try { + const response = await deleteUsers(); + if (response.isSuccess) { + closePopup(); + navigate('/mypage/withdraw/done'); + localStorage.removeItem('authToken'); + } else { + closePopup(); + openSnackbar({ + message: response.message, + variant: 'top', + onClose: () => {}, + }); + } + } catch (error) { + let serverMessage = '요청 처리 중 오류가 발생했어요.'; + if (error && typeof error === 'object' && 'response' in error) { + const axiosError = error as { response?: { data?: { message?: string } } }; + serverMessage = axiosError.response?.data?.message || serverMessage; + } + closePopup(); + openSnackbar({ + message: serverMessage, + variant: 'top', + onClose: () => {}, + }); + } + })(); }, onClose: () => { closePopup(); @@ -50,11 +76,11 @@ const WithdrawPage = () => { 회원탈퇴 주의사항 - 회원탈퇴 시 계정정보는 복구 불가능하며 90일 이후 재가입이 가능합니다. + 회원탈퇴 시 계정 및 활동 데이터는 즉시 삭제되며, + 복구가 불가능합니다. - 등록된 기록 및 게시물은 삭제되지 않습니다. - 등록된 기록 및 게시물은 삭제되지 않습니다. - 등록된 기록 및 게시물은 삭제되지 않습니다. + 백업 및 로그 역시 보안 저장 후 최대 90일 내 자동 삭제됩니다. + 법령상 보존 의무가 있는 정보는 해당 기간 동안 보관됩니다. @@ -91,7 +117,7 @@ const Container = styled.div` min-width: 320px; max-width: 540px; gap: 30px; - padding: 40px 0px 105px 0px; + padding: 40px 20px 105px 20px; `; const Content = styled.div` @@ -115,6 +141,10 @@ const ContentText = styled.div` font-size: ${typography.fontSize.sm}; font-weight: ${typography.fontWeight.regular}; line-height: 20px; + + .danger { + color: #ff9496; + } `; const CheckSection = styled.div` @@ -146,7 +176,7 @@ const Checkbox = styled.div<{ checked: boolean }>` const CheckLabel = styled.div` color: ${colors.white}; - font-size: ${typography.fontSize.base}; + font-size: ${typography.fontSize.sm}; font-weight: ${typography.fontWeight.regular}; line-height: 24px; `; diff --git a/src/pages/notice/Notice.tsx b/src/pages/notice/Notice.tsx index f3e77db7..730257ad 100644 --- a/src/pages/notice/Notice.tsx +++ b/src/pages/notice/Notice.tsx @@ -1,14 +1,19 @@ -import { useState } from 'react'; +import { useCallback, useEffect, useRef, useState } from 'react'; import { useNavigate } from 'react-router-dom'; import styled from '@emotion/styled'; import TitleHeader from '@/components/common/TitleHeader'; import leftArrow from '../../assets/common/leftArrow.svg'; import { colors, typography } from '@/styles/global/global'; -import { mockNotifications } from '@/mocks/notification.mock'; +import { getNotifications, type NotificationItem } from '@/api/notifications/getNotifications'; const Notice = () => { const [selected, setSelected] = useState(''); - const [notifications, setNotifications] = useState(mockNotifications); + const [notifications, setNotifications] = useState([]); + const [nextCursor, setNextCursor] = useState(null); + const [isLast, setIsLast] = useState(true); + const [isLoading, setIsLoading] = useState(false); + const isLoadingRef = useRef(false); + const sentinelRef = useRef(null); const navigate = useNavigate(); const handleBackButton = () => { @@ -19,14 +24,67 @@ const Notice = () => { setSelected(prev => (prev === tab ? '' : tab)); }; - const handleReadNotification = (index: number) => { - setNotifications(prev => - prev.map((item, idx) => (idx === index ? { ...item, read: true } : item)), + const loadNotifications = useCallback( + async (cursor?: string | null) => { + try { + if (isLoadingRef.current) return; + isLoadingRef.current = true; + setIsLoading(true); + const params: { cursor?: string | null; type?: 'feed' | 'room' } = { cursor }; + if (selected === '피드') params.type = 'feed'; + if (selected === '모임') params.type = 'room'; + + const res = await getNotifications(params); + if (res.isSuccess) { + setNotifications(prev => + cursor ? [...prev, ...res.data.notifications] : res.data.notifications, + ); + setNextCursor(res.data.nextCursor || null); + setIsLast(res.data.isLast); + } + } finally { + setIsLoading(false); + isLoadingRef.current = false; + } + }, + [selected], + ); + + useEffect(() => { + // 탭 변경 시 첫 페이지부터 다시 로드 + setNotifications([]); + setNextCursor(null); + setIsLast(false); + void loadNotifications(null); + }, [selected, loadNotifications]); + + useEffect(() => { + if (!sentinelRef.current) return; + const el = sentinelRef.current; + const observer = new IntersectionObserver( + entries => { + const entry = entries[0]; + if (entry.isIntersecting && !isLoading && !isLast && nextCursor !== null) { + void loadNotifications(nextCursor); + } + }, + { root: null, rootMargin: '0px', threshold: 0.1 }, ); - }; - const filteredNotifications = - selected === '' ? notifications : notifications.filter(notif => notif.category === selected); + observer.observe(el); + return () => { + observer.unobserve(el); + observer.disconnect(); + }; + }, [isLoading, isLast, nextCursor, loadNotifications]); + + // const handleReadNotification = (index: number) => { + // setNotifications(prev => + // prev.map((item, idx) => (idx === index ? { ...item, isChecked: true } : item)), + // ); + // }; + + const filteredNotifications = notifications; const tabs = ['피드', '모임']; @@ -52,21 +110,24 @@ const Notice = () => { ) : ( filteredNotifications.map((notif, idx) => ( handleReadNotification(idx)} + key={notif.notificationId ?? idx} + read={notif.isChecked} + // onClick={() => handleReadNotification(idx)} > - {!notif.read && } + {!notif.isChecked && } - {notif.category} + {notif.notificationType} {notif.title} - + - {notif.description} + {notif.content} )) )} + + {/* 무한 스크롤 감지용 센티넬 */} + ); }; @@ -120,7 +181,7 @@ const NotificationCard = styled.div<{ read: boolean }>` padding: 16px; color: ${colors.grey[300]}; position: relative; - width: ${({ read }) => (read ? '100%' : '90%')}; + width: 100%; opacity: ${({ read }) => (read ? '0.5' : '1')}; cursor: pointer; `; @@ -173,12 +234,13 @@ const Description = styled.div` `; const EmptyState = styled.div` - margin-top: 50%; - font-size: ${typography.fontSize.sm}; + margin-top: 300px; + font-size: ${typography.fontSize.lg}; font-weight: ${typography.fontWeight.semibold}; - padding: 40px; + line-height: 24px; + padding: 40px 0px; text-align: center; - color: #e0e0e0; + color: ${colors.white}; `; const UnreadDot = styled.div` @@ -190,3 +252,8 @@ const UnreadDot = styled.div` background-color: #ff9496; border-radius: 50%; `; + +const Sentinel = styled.div` + width: 100%; + height: 1px; +`; diff --git a/src/pages/signup/SignupGenre.tsx b/src/pages/signup/SignupGenre.tsx index 76e6e805..5364939c 100644 --- a/src/pages/signup/SignupGenre.tsx +++ b/src/pages/signup/SignupGenre.tsx @@ -66,6 +66,8 @@ const SignupGenre = () => { if (result.data.accessToken) { localStorage.setItem('authToken', result.data.accessToken); console.log('✅ 새로운 access 토큰이 localStorage에 저장되었습니다.'); + // 임시 토큰 제거 + localStorage.removeItem('preAuthToken'); } navigate('/signup/guide', { diff --git a/src/pages/signup/SignupNickname.tsx b/src/pages/signup/SignupNickname.tsx index 5066b628..19e82b3e 100644 --- a/src/pages/signup/SignupNickname.tsx +++ b/src/pages/signup/SignupNickname.tsx @@ -31,15 +31,15 @@ const SignupNickname = () => { // 토큰 발급 완료 대기 await waitForToken(); - // localStorage에 토큰이 있는지 확인 - const authToken = localStorage.getItem('authToken'); - if (!authToken) { - console.log('❌ 토큰이 없어서 닉네임 검증을 할 수 없습니다.'); + // 임시 토큰 존재 여부 확인 (회원가입 진행 중) + const preAuthToken = localStorage.getItem('preAuthToken'); + if (!preAuthToken) { + console.log('❌ 임시 토큰이 없어 닉네임 검증을 할 수 없습니다.'); setError('인증 토큰이 없습니다. 다시 시도해주세요.'); return; } - console.log('✅ 토큰 확인 완료, 닉네임 검증 API 호출'); + console.log('✅ 임시 토큰 확인 완료, 닉네임 검증 API 호출'); const result = await postNickname(nickname); if (result.data.isVerified) {