Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion src/api/auth/getToken.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@ export interface GetTokenResponse {
code: number;
message: string;
data: {
token: string; // 토큰
token: string; // 토큰 (임시 또는 액세스)
isNewUser: boolean; // true면 회원가입 필요 (임시 토큰), false면 액세스 토큰
};
}

Expand Down
26 changes: 19 additions & 7 deletions src/api/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 에러 시 로그인 페이지로 리다이렉트
Expand Down
35 changes: 35 additions & 0 deletions src/api/notifications/getNotifications.ts
Original file line number Diff line number Diff line change
@@ -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<GetNotificationsResponse> => {
const response = await apiClient.get<GetNotificationsResponse>('/notifications', {
params,
});
return response.data;
};
Comment on lines +31 to +35
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

null 파라미터 전송 차단: cursor=null 이 쿼리스트링에 노출될 수 있습니다

Axios가 객체를 직렬화할 때 null이 문자열 "null"로 전송될 수 있어 서버 필터링/캐싱에 악영향을 줄 수 있습니다. null/빈값은 아예 파라미터에서 제거하세요.

다음처럼 쿼리 객체를 정제해 전송하십시오:

 export const getNotifications = async (
   params?: GetNotificationsParams,
 ): Promise<GetNotificationsResponse> => {
-  const response = await apiClient.get<GetNotificationsResponse>('/notifications', {
-    params,
-  });
+  const { cursor, type } = params ?? {};
+  const query: Record<string, string> = {};
+  if (cursor) query.cursor = cursor;
+  if (type) query.type = type;
+  const response = await apiClient.get<GetNotificationsResponse>('/notifications', {
+    params: query,
+  });
   return response.data;
 };
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const response = await apiClient.get<GetNotificationsResponse>('/notifications', {
params,
});
return response.data;
};
export const getNotifications = async (
params?: GetNotificationsParams,
): Promise<GetNotificationsResponse> => {
const { cursor, type } = params ?? {};
const query: Record<string, string> = {};
if (cursor) query.cursor = cursor;
if (type) query.type = type;
const response = await apiClient.get<GetNotificationsResponse>('/notifications', {
params: query,
});
return response.data;
};
🤖 Prompt for AI Agents
In src/api/notifications/getNotifications.ts around lines 31 to 35, the current
axios call may serialize null values like cursor=null into the query string;
remove null/undefined/empty parameters before sending by creating a cleaned
params object (e.g., filter Object.entries(params) to exclude values that are
null or undefined or empty string) and pass that cleanedParams to apiClient.get
instead of the raw params so nulls are never included in the query string.

13 changes: 13 additions & 0 deletions src/api/users/deleteUsers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { apiClient } from '../index';

export interface DeleteUsersResponse {
isSuccess: boolean;
code: number;
message: string;
data: null;
}

export const deleteUsers = async (): Promise<DeleteUsersResponse> => {
const response = await apiClient.delete<DeleteUsersResponse>('/users');
return response.data;
};
21 changes: 20 additions & 1 deletion src/components/group/CompletedGroupModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<Room[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
Expand All @@ -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 {
Expand Down Expand Up @@ -84,7 +91,14 @@ const CompletedGroupModal = ({ onClose }: CompletedGroupModalProps) => {
) : error ? (
<ErrorMessage>{error}</ErrorMessage>
) : convertedGroups.length > 0 ? (
convertedGroups.map(group => <GroupCard key={group.id} group={group} type={'modal'} />)
convertedGroups.map(group => (
<GroupCard
key={group.id}
group={group}
type={'modal'}
onClick={() => handleGroupCardClick(group)}
/>
))
Comment on lines +94 to +101
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

완료된 방 라벨 오표기 가능성 — isOngoing 전달 필요

GroupCardisOngoing === true일 때 ‘종료’를, 그렇지 않으면 ‘모집 마감’을 표시합니다. 현재 prop을 전달하지 않아 완료된 방이 ‘모집 마감’으로 보일 수 있습니다. 완료된 목록에서는 isOngoing={true}를 넘겨 ‘종료’가 노출되도록 해주세요.

적용 diff:

               <GroupCard
                 key={group.id}
                 group={group}
                 type={'modal'}
-                onClick={() => handleGroupCardClick(group)}
+                isOngoing={true}
+                onClick={() => handleGroupCardClick(group)}
               />

참고: convertRoomToGroup에서 isOnGoing(O 대문자) 필드를 세팅하지만 GroupCardisOngoing(o 소문자) prop을 받습니다. 네이밍 불일치로 혼동될 수 있으니 통일을 검토해 주세요.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
convertedGroups.map(group => (
<GroupCard
key={group.id}
group={group}
type={'modal'}
onClick={() => handleGroupCardClick(group)}
/>
))
convertedGroups.map(group => (
<GroupCard
key={group.id}
group={group}
type={'modal'}
isOngoing={true}
onClick={() => handleGroupCardClick(group)}
/>
))
🤖 Prompt for AI Agents
In src/components/group/CompletedGroupModal.tsx around lines 93 to 100, the
GroupCard entries are missing the isOngoing prop so completed rooms may display
the wrong label; pass isOngoing={true} (or the appropriate boolean from the
converted object) to each GroupCard via its JSX (e.g., isOngoing={true} for
completed list) to force the “종료” label, and also align naming by checking
convertRoomToGroup which sets isOnGoing (capital G) — rename or map that field
to isOngoing (lowercase g) to avoid future confusion.

) : (
<EmptyState data-empty="true">
<EmptyTitle>완료된 모임방이 없어요</EmptyTitle>
Expand Down Expand Up @@ -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`
Expand Down
19 changes: 10 additions & 9 deletions src/components/group/GroupCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,15 +35,16 @@ export const GroupCard = forwardRef<HTMLDivElement, Props>(
<p>{group.participants}</p>
<MaximumParticipants>/ {group.maximumParticipants}명</MaximumParticipants>
</Participant>
{isOngoing === true ? (
<RecruitingDeadline isRecommend={isRecommend}>
{group.deadLine} 종료
</RecruitingDeadline>
) : (
<OngoingDeadline isRecommend={isRecommend}>
{group.deadLine} 모집 마감
</OngoingDeadline>
)}
{(type !== 'modal' || group.type !== 'expired') &&
(isOngoing === true ? (
<RecruitingDeadline isRecommend={isRecommend}>
{group.deadLine} 종료
</RecruitingDeadline>
) : (
<OngoingDeadline isRecommend={isRecommend}>
{group.deadLine} 모집 마감
</OngoingDeadline>
))}
</Bottom>
</Info>
</Card>
Expand Down
1 change: 1 addition & 0 deletions src/components/group/MyGroupBox.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ export interface Group {
genre?: string;
isOnGoing?: boolean;
isPublic?: boolean;
type?: string;
}

const convertJoinedRoomToGroup = (room: JoinedRoomItem): Group => ({
Expand Down
21 changes: 14 additions & 7 deletions src/hooks/useSocialLoginToken.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -53,7 +60,7 @@ export const useSocialLoginToken = () => {
// 토큰 발급 Promise를 저장
tokenPromise.current = handleSocialLoginToken();
}
}, [location.pathname]);
}, [location.pathname, location.search]);

// 토큰 발급 완료를 기다리는 함수 반환
const waitForToken = useCallback(async (): Promise<void> => {
Expand Down
10 changes: 9 additions & 1 deletion src/pages/feed/Feed.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,10 @@ const Feed = () => {
navigate('/feed/search');
};

const handleNoticeButton = () => {
navigate('/notice');
};

// 전체 피드 로드 함수
const loadTotalFeeds = useCallback(async (_cursor?: string) => {
try {
Expand Down Expand Up @@ -175,7 +179,11 @@ const Feed = () => {

return (
<Container>
<MainHeader type="home" leftButtonClick={handleSearchButton} />
<MainHeader
type="home"
leftButtonClick={handleSearchButton}
rightButtonClick={handleNoticeButton}
/>
<TabBar tabs={tabs} activeTab={activeTab} onTabClick={setActiveTab} />
{initialLoading || tabLoading ? (
<LoadingSpinner size="large" fullHeight={true} />
Expand Down
9 changes: 8 additions & 1 deletion src/pages/group/Group.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,9 @@ const Group = () => {
navigate('/group/search');
};

const handleNoticeButton = () => {
navigate('/notice');

const handleAllRoomsClick = () => {
navigate('/group/search', {
state: {
Expand All @@ -105,7 +108,11 @@ const Group = () => {
<Wrapper>
{isMyGroupModalOpen && <MyGroupModal onClose={closeMyGroupModal} />}
{isCompletedGroupModalOpen && <CompletedGroupModal onClose={closeCompletedGroupModal} />}
<MainHeader type="group" leftButtonClick={openCompletedGroupModal} />
<MainHeader
type="group"
leftButtonClick={openCompletedGroupModal}
rightButtonClick={handleNoticeButton}
/>
<SearchBar placeholder="모임방 참여할 사람!" onClick={handleSearchBarClick} />
<MyGroupBox onMyGroupsClick={openMyGroupModal}></MyGroupBox>
<Blank height={'10px'} margin={'32px 0'}></Blank>
Expand Down
52 changes: 41 additions & 11 deletions src/pages/mypage/WithdrawPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 = () => {
Expand All @@ -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();
Expand All @@ -50,11 +76,11 @@ const WithdrawPage = () => {
<Content>
<ContentTitle>회원탈퇴 주의사항</ContentTitle>
<ContentText>
회원탈퇴 시 계정정보는 복구 불가능하며 90일 이후 재가입이 가능합니다.
회원탈퇴 시 계정 및 활동 데이터는 <span className="danger">즉시 삭제</span>되며,
<span className="danger"> 복구가 불가능</span>합니다.
</ContentText>
<ContentText>등록된 기록 및 게시물은 삭제되지 않습니다.</ContentText>
<ContentText>등록된 기록 및 게시물은 삭제되지 않습니다.</ContentText>
<ContentText>등록된 기록 및 게시물은 삭제되지 않습니다.</ContentText>
<ContentText>백업 및 로그 역시 보안 저장 후 최대 90일 내 자동 삭제됩니다.</ContentText>
<ContentText>법령상 보존 의무가 있는 정보는 해당 기간 동안 보관됩니다.</ContentText>
</Content>
<CheckSection>
<CheckboxContainer onClick={handleCheckboxChange}>
Expand Down Expand Up @@ -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`
Expand All @@ -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`
Expand Down Expand Up @@ -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;
`;
Expand Down
Loading