From 20afe6d56d38e06f20b57dde8d035b1be0854c7c Mon Sep 17 00:00:00 2001 From: Sungu Kim <108677235+haegu97@users.noreply.github.com> Date: Fri, 10 Jan 2025 10:41:08 +0900 Subject: [PATCH 01/13] =?UTF-8?q?=EC=B1=84=ED=8C=85=EC=B0=BD=20=EB=86=92?= =?UTF-8?q?=EC=9D=B4=20=EC=A7=80=EC=A0=95=20(#307)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/chat/[id]/page.tsx | 111 ++++++++++++++++++++----------------- 1 file changed, 60 insertions(+), 51 deletions(-) diff --git a/src/app/chat/[id]/page.tsx b/src/app/chat/[id]/page.tsx index 39860903..f1b2a8bf 100644 --- a/src/app/chat/[id]/page.tsx +++ b/src/app/chat/[id]/page.tsx @@ -178,61 +178,70 @@ function ChatRoomPage() { }; return ( -
-
-
-
-
- } - onClick={handleGoBack} - className="bg-gray-light-02" - /> -

채팅

- -
-
- } - onClick={() => {}} - className="bg-gray-light-02" - /> +
+
+
+
+
+
+ } + onClick={handleGoBack} + className="bg-gray-light-02" + /> +

채팅

+ +
+
+ } + onClick={() => {}} + className="bg-gray-light-02" + /> +
+ router.push(`/bookclub/${chatId}`), + }} + />
- router.push(`/bookclub/${chatId}`), - }} +
+ +
+ {}} />
-
-
- {}} - /> -
-
-
- - - } - aria-label="메시지 전송" - className="h-[52px] w-[52px] bg-green-light-01" - onClick={handleSubmit} - /> + +
+
+
+ +
+ } + aria-label="메시지 전송" + className="h-[52px] w-[52px] bg-green-light-01" + onClick={handleSubmit} + /> + +
); From 50180e40e2993c55706321de4a093a8bbeeb61c8 Mon Sep 17 00:00:00 2001 From: Sungu Kim <108677235+haegu97@users.noreply.github.com> Date: Fri, 10 Jan 2025 14:11:25 +0900 Subject: [PATCH 02/13] =?UTF-8?q?=ED=86=A0=EC=8A=A4=ED=8A=B8=EA=B0=80=20?= =?UTF-8?q?=EC=82=AC=EB=9D=BC=EC=A7=80=EC=A7=80=20=EC=95=8A=EB=8A=94=20?= =?UTF-8?q?=EB=B2=84=EA=B7=B8=20=EC=88=98=EC=A0=95=20#309=20(#311)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/layout.tsx | 1 - src/components/header/HeaderBar.tsx | 2 -- src/components/toast/toast.tsx | 6 +++++- src/features/auth/api/auth.ts | 1 + 4 files changed, 6 insertions(+), 4 deletions(-) diff --git a/src/app/layout.tsx b/src/app/layout.tsx index a7ff9915..48a6dc59 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -31,7 +31,6 @@ export default function RootLayout({ {children} - ); diff --git a/src/components/header/HeaderBar.tsx b/src/components/header/HeaderBar.tsx index 34a905db..3cfba709 100644 --- a/src/components/header/HeaderBar.tsx +++ b/src/components/header/HeaderBar.tsx @@ -7,7 +7,6 @@ import { usePathname, useRouter } from 'next/navigation'; import { useAuthStore } from '@/store/authStore'; import DropDown from '../drop-down/DropDown'; import { logout } from '@/features/auth/api/auth'; -import { showToast } from '../toast/toast'; function HeaderBar() { const pathname = usePathname(); @@ -18,7 +17,6 @@ function HeaderBar() { if (value === 'LOGOUT') { try { await logout(); - showToast({ message: '로그아웃 되었습니다 ', type: 'success' }); router.replace('/bookclub'); } catch (error) { console.error('로그아웃 실패:', error); diff --git a/src/components/toast/toast.tsx b/src/components/toast/toast.tsx index 5ef1b2b4..a065313c 100644 --- a/src/components/toast/toast.tsx +++ b/src/components/toast/toast.tsx @@ -14,7 +14,11 @@ const defaultOptions: ToastOptions = { }; export const showToast = ({ message, type }: ToastProps) => { - toast[type](message, defaultOptions); + if (type === 'success') { + toast.success(message, defaultOptions); + } else if (type === 'error') { + toast.error(message, defaultOptions); + } }; export const Toast = () => { diff --git a/src/features/auth/api/auth.ts b/src/features/auth/api/auth.ts index 41db7216..0a7a8d54 100644 --- a/src/features/auth/api/auth.ts +++ b/src/features/auth/api/auth.ts @@ -62,6 +62,7 @@ export const logout = async () => { const { setIsLoggedIn, setUser } = useAuthStore.getState(); setIsLoggedIn(false); setUser(null); + showToast({ message: '로그아웃 되었습니다 ', type: 'success' }); return response; } catch (error) { console.error('로그아웃 에러:', error); From 6c3d3533355e0d6527b4fa55420b46ba4b709788 Mon Sep 17 00:00:00 2001 From: Sungu Kim <108677235+haegu97@users.noreply.github.com> Date: Fri, 10 Jan 2025 14:11:43 +0900 Subject: [PATCH 03/13] =?UTF-8?q?=E2=99=BB=EF=B8=8F[Refactor]=20=ED=86=A0?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20=EB=A9=94=EC=8B=9C=EC=A7=80=20=EC=83=81?= =?UTF-8?q?=EC=88=98=EB=A1=9C=20=EA=B4=80=EB=A6=AC=20#312=20(#314)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/api/auth/react-query/customHooks.ts | 11 ++++- src/api/book-club/react-query/customHooks.ts | 16 +++++-- src/constants/messages/toast.ts | 45 +++++++++++++++++++ .../club-details/hooks/useJoinClub.ts | 5 ++- .../profile/container/MyJoinedClubList.tsx | 12 +++-- src/lib/hooks/useCancelClub.ts | 5 ++- src/lib/hooks/useLeaveClub.ts | 8 +++- src/lib/hooks/useLikeClub.ts | 5 ++- src/lib/hooks/useUnLikeClub.ts | 5 ++- 9 files changed, 93 insertions(+), 19 deletions(-) create mode 100644 src/constants/messages/toast.ts diff --git a/src/api/auth/react-query/customHooks.ts b/src/api/auth/react-query/customHooks.ts index 58834975..5b8ca5d6 100644 --- a/src/api/auth/react-query/customHooks.ts +++ b/src/api/auth/react-query/customHooks.ts @@ -1,5 +1,6 @@ import { useMutation } from '@tanstack/react-query'; import { showToast } from '@/components/toast/toast'; +import { TOAST_MESSAGES } from '@/constants/messages/toast'; import { authClientAPI } from '../authClientAPI'; import { getUserInfo } from '@/features/auth/api/auth'; @@ -9,10 +10,16 @@ export function useEditInfoMutation() { mutationFn: (formData: FormData) => authClientAPI.editInfo(formData), onSuccess: () => { getUserInfo(); - showToast({ message: '프로필 수정이 완료되었습니다.', type: 'success' }); + showToast({ + message: TOAST_MESSAGES.SUCCESS.PROFILE_EDIT, + type: 'success', + }); }, onError: (error) => { - showToast({ message: '프로필 수정을 실패하였습니다', type: 'error' }); + showToast({ + message: TOAST_MESSAGES.ERROR.PROFILE_EDIT_FAILED, + type: 'error', + }); console.error(error); }, }); diff --git a/src/api/book-club/react-query/customHooks.ts b/src/api/book-club/react-query/customHooks.ts index dcfdc23e..b5d49fbc 100644 --- a/src/api/book-club/react-query/customHooks.ts +++ b/src/api/book-club/react-query/customHooks.ts @@ -1,6 +1,7 @@ import { useMutation, useQueryClient } from '@tanstack/react-query'; import { bookClubs } from './queries'; import { showToast } from '@/components/toast/toast'; +import { TOAST_MESSAGES } from '@/constants/messages/toast'; import { bookClubLikeAPI, bookClubMainAPI, @@ -25,7 +26,10 @@ export function useBookClubCreateMutation() { }); }, onError: () => { - showToast({ message: '북클럽 생성에 실패했습니다.', type: 'error' }); + showToast({ + message: TOAST_MESSAGES.ERROR.CLUB_CREATE_FAILED, + type: 'error', + }); }, }); } @@ -74,12 +78,18 @@ export function useWriteReview() { queryClient.invalidateQueries({ queryKey: bookClubs.my()._ctx.reviews().queryKey, }); - showToast({ message: '리뷰 작성을 완료하였습니다', type: 'success' }); + showToast({ + message: TOAST_MESSAGES.SUCCESS.REVIEW_CREATE, + type: 'success', + }); }, onError: (error) => { console.error(error); - showToast({ message: '리뷰 작성을 실패하였습니다.', type: 'error' }); + showToast({ + message: TOAST_MESSAGES.ERROR.REVIEW_CREATE_FAILED, + type: 'error', + }); }, }); } diff --git a/src/constants/messages/toast.ts b/src/constants/messages/toast.ts new file mode 100644 index 00000000..5b166a5c --- /dev/null +++ b/src/constants/messages/toast.ts @@ -0,0 +1,45 @@ +export const TOAST_MESSAGES = { + SUCCESS: { + // 인증 관련 + LOGIN: '로그인에 성공했습니다.', + LOGOUT: '로그아웃되었습니다.', + SIGNUP: '회원가입이 완료되었습니다.', + + // 프로필 관련 + PROFILE_EDIT: '프로필 수정이 완료되었습니다.', + + // 북클럽 관련 + CLUB_CREATE: '북클럽이 생성되었습니다.', + CLUB_JOIN: '참여 완료! 함께하게 돼서 기뻐요🥰', + CLUB_CANCEL: '모임을 취소하였습니다.', + CLUB_LEAVE: '모임 참여를 취소하였습니다.', + CLUB_DELETE: '취소된 모임을 삭제하였습니다.', + CLUB_LIKE: '찜 완료! 찜한 모임은 찜 목록 페이지에서 확인하세요', + CLUB_UNLIKE: '찜이 취소되었습니다', + + // 리뷰 관련 + REVIEW_CREATE: '리뷰 작성을 완료하였습니다', + }, + + ERROR: { + // 인증 관련 + LOGIN_FAILED: '로그인에 실패했습니다.', + LOGOUT_FAILED: '로그아웃에 실패했습니다.', + + // 프로필 관련 + PROFILE_EDIT_FAILED: '프로필 수정을 실패하였습니다', + + // 북클럽 관련 + CLUB_CREATE_FAILED: '북클럽 생성에 실패했습니다.', + CLUB_JOIN_FAILED: '참여 요청 중 문제가 발생했습니다. 다시 시도해주세요.', + CLUB_CANCEL_FAILED: '모임 취소를 실패하였습니다.', + CLUB_LEAVE_FAILED: '모임 참여 취소를 실패하였습니다.', + + // 리뷰 관련 + REVIEW_CREATE_FAILED: '리뷰 작성을 실패하였습니다.', + REVIEW_VALIDATION: '점수와 리뷰 내용을 입력해주세요', + + // 일반 에러 + UNKNOWN: '알 수 없는 오류가 발생했습니다.', + }, +} as const; diff --git a/src/features/club-details/hooks/useJoinClub.ts b/src/features/club-details/hooks/useJoinClub.ts index 86deb3ea..09f71b05 100644 --- a/src/features/club-details/hooks/useJoinClub.ts +++ b/src/features/club-details/hooks/useJoinClub.ts @@ -1,5 +1,6 @@ import { useJoinBookClub } from '@/api/book-club/react-query'; import { showToast } from '@/components/toast/toast'; +import { TOAST_MESSAGES } from '@/constants/messages/toast'; export const useJoinClub = () => { const { mutate: joinClub } = useJoinBookClub(); @@ -8,7 +9,7 @@ export const useJoinClub = () => { joinClub(clubId, { onSuccess: () => { showToast({ - message: '참여 완료! 함께하게 돼서 기뻐요🥰', + message: TOAST_MESSAGES.SUCCESS.CLUB_JOIN, type: 'success', }); }, @@ -20,7 +21,7 @@ export const useJoinClub = () => { }); } else { showToast({ - message: '참여 요청 중 문제가 발생했습니다. 다시 시도해주세요.', + message: TOAST_MESSAGES.ERROR.CLUB_JOIN_FAILED, type: 'error', }); } diff --git a/src/features/profile/container/MyJoinedClubList.tsx b/src/features/profile/container/MyJoinedClubList.tsx index 6176b401..30e6dcf0 100644 --- a/src/features/profile/container/MyJoinedClubList.tsx +++ b/src/features/profile/container/MyJoinedClubList.tsx @@ -16,6 +16,7 @@ import { useWriteReview, } from '@/api/book-club/react-query'; import { showToast } from '@/components/toast/toast'; +import { TOAST_MESSAGES } from '@/constants/messages/toast'; import { BookClub } from '@/types/bookclubs'; import Loading from '@/components/loading/Loading'; import { useAuthStore } from '@/store/authStore'; @@ -58,7 +59,7 @@ export default function MyJoinedClubList({ order }: ClubListProps) { const res = await leaveClub(clubId); if (res) { showToast({ - message: '취소된 모임을 삭제하였습니다.', + message: TOAST_MESSAGES.SUCCESS.CLUB_DELETE, type: 'success', }); } @@ -80,7 +81,10 @@ export default function MyJoinedClubList({ order }: ClubListProps) { const onConfirmReview = (rating: number, content: string) => { //TODO: 토스트 메시지가 뜨더라도 모달이 열린 상태로 유지되도록 수정 if (!rating || !content) { - showToast({ message: '점수와 리뷰 내용을 입력해주세요', type: 'error' }); + showToast({ + message: TOAST_MESSAGES.ERROR.REVIEW_VALIDATION, + type: 'error', + }); return; } @@ -97,14 +101,14 @@ export default function MyJoinedClubList({ order }: ClubListProps) { const res = await leaveClub(selectedClubId); if (res) { showToast({ - message: '모임 참여를 취소하였습니다.', + message: TOAST_MESSAGES.SUCCESS.CLUB_LEAVE, type: 'success', }); } } } catch (error) { showToast({ - message: '모임 참여를 취소를 실패하였습니다.', + message: TOAST_MESSAGES.ERROR.CLUB_LEAVE_FAILED, type: 'error', }); console.error(error); diff --git a/src/lib/hooks/useCancelClub.ts b/src/lib/hooks/useCancelClub.ts index 68f81d4f..cd482b25 100644 --- a/src/lib/hooks/useCancelClub.ts +++ b/src/lib/hooks/useCancelClub.ts @@ -1,5 +1,6 @@ import { useCancelBookClub } from '@/api/book-club/react-query'; import { showToast } from '@/components/toast/toast'; +import { TOAST_MESSAGES } from '@/constants/messages/toast'; import { useState } from 'react'; export function useCancelClub() { @@ -29,14 +30,14 @@ export function useCancelClub() { const res = await cancelClub(popUpState.selectedClubId); if (res) { showToast({ - message: '모임을 취소하였습니다.', + message: TOAST_MESSAGES.SUCCESS.CLUB_CANCEL, type: 'success', }); } } } catch (error) { showToast({ - message: '모임 취소를 실패하였습니다.', + message: TOAST_MESSAGES.ERROR.CLUB_CANCEL_FAILED, type: 'error', }); console.error(error); diff --git a/src/lib/hooks/useLeaveClub.ts b/src/lib/hooks/useLeaveClub.ts index fe37b393..ea27d67d 100644 --- a/src/lib/hooks/useLeaveClub.ts +++ b/src/lib/hooks/useLeaveClub.ts @@ -1,5 +1,6 @@ import { useLeaveBookClub } from '@/api/book-club/react-query'; import { showToast } from '@/components/toast/toast'; +import { TOAST_MESSAGES } from '@/constants/messages/toast'; import { useState } from 'react'; export const useLeaveClub = () => { @@ -26,14 +27,17 @@ export const useLeaveClub = () => { try { if (popUpState.selectedClubId) { await leaveClub(popUpState.selectedClubId); - showToast({ message: '모임 참여를 취소하였습니다.', type: 'success' }); + showToast({ + message: TOAST_MESSAGES.SUCCESS.CLUB_LEAVE, + type: 'success', + }); } } catch (error) { if (error instanceof Error) { showToast({ message: error.message, type: 'error' }); } else { showToast({ - message: '알 수 없는 오류가 발생했습니다.', + message: TOAST_MESSAGES.ERROR.UNKNOWN, type: 'error', }); } diff --git a/src/lib/hooks/useLikeClub.ts b/src/lib/hooks/useLikeClub.ts index abab5c66..78f4368b 100644 --- a/src/lib/hooks/useLikeClub.ts +++ b/src/lib/hooks/useLikeClub.ts @@ -1,5 +1,6 @@ import { useLikeBookClub } from '@/api/book-club/react-query'; import { showToast } from '@/components/toast/toast'; +import { TOAST_MESSAGES } from '@/constants/messages/toast'; export const useLikeClub = () => { const { mutate: likeClub } = useLikeBookClub(); @@ -8,7 +9,7 @@ export const useLikeClub = () => { likeClub(selectedClubId, { onSuccess: () => { showToast({ - message: '찜 완료! 찜한 모임은 찜 목록 페이지에서 확인하세요', + message: TOAST_MESSAGES.SUCCESS.CLUB_LIKE, type: 'success', }); }, @@ -23,7 +24,7 @@ export const useLikeClub = () => { message: error instanceof Error ? error.message - : '알 수 없는 오류가 발생했습니다. 다시 시도해주세요.', + : TOAST_MESSAGES.ERROR.UNKNOWN, type: 'error', }); } diff --git a/src/lib/hooks/useUnLikeClub.ts b/src/lib/hooks/useUnLikeClub.ts index dd6227e6..d65678ef 100644 --- a/src/lib/hooks/useUnLikeClub.ts +++ b/src/lib/hooks/useUnLikeClub.ts @@ -1,5 +1,6 @@ import { useUnLikeBookClub } from '@/api/book-club/react-query'; import { showToast } from '@/components/toast/toast'; +import { TOAST_MESSAGES } from '@/constants/messages/toast'; export const useUnLikeClub = () => { const { mutate: unLikeClub } = useUnLikeBookClub(); @@ -8,7 +9,7 @@ export const useUnLikeClub = () => { unLikeClub(selectedClubId, { onSuccess: () => { showToast({ - message: '찜이 취소되었습니다', + message: TOAST_MESSAGES.SUCCESS.CLUB_UNLIKE, type: 'success', }); }, @@ -23,7 +24,7 @@ export const useUnLikeClub = () => { message: error instanceof Error ? error.message - : '알 수 없는 오류가 발생했습니다. 다시 시도해주세요.', + : TOAST_MESSAGES.ERROR.UNKNOWN, type: 'error', }); } From 0fc6d1e96af60863ac9df4c4385fedba2ab4cb79 Mon Sep 17 00:00:00 2001 From: cloud0406 <32586926+cloud0406@users.noreply.github.com> Date: Fri, 10 Jan 2025 14:33:34 +0900 Subject: [PATCH 04/13] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20[Refactor]=20?= =?UTF-8?q?=ED=94=84=EB=A1=9C=EA=B7=B8=EB=A0=88=EC=8A=A4=EB=B0=94=20?= =?UTF-8?q?=EB=84=98=EC=B9=A8=20=ED=98=84=EC=83=81=20=ED=95=B4=EA=B2=B0=20?= =?UTF-8?q?(#313)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * ♻️[Refactor] 프로그레스바 100% 안넘도록 수정 * ✅[Test] 테스트 코드 추가 --- src/components/progress-bar/ProgressBar.test.tsx | 9 +++++++++ src/components/progress-bar/ProgressBar.tsx | 6 ++++-- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/src/components/progress-bar/ProgressBar.test.tsx b/src/components/progress-bar/ProgressBar.test.tsx index 67aa3033..fb7d0079 100644 --- a/src/components/progress-bar/ProgressBar.test.tsx +++ b/src/components/progress-bar/ProgressBar.test.tsx @@ -16,4 +16,13 @@ describe('ProgressBar', () => { const fillBar = screen.getByRole('progressbar').children[0]; expect(fillBar).toHaveStyle({ width: '25%' }); }); + + it('percentage가 100을 초과할 경우 100%로 제한되는지 확인', () => { + render(); + const progressbar = screen.getByRole('progressbar'); + const fillBar = progressbar.children[0]; + + expect(progressbar).toHaveAttribute('aria-valuenow', '100'); + expect(fillBar).toHaveStyle({ width: '100%' }); + }); }); diff --git a/src/components/progress-bar/ProgressBar.tsx b/src/components/progress-bar/ProgressBar.tsx index 8041f380..3457c53f 100644 --- a/src/components/progress-bar/ProgressBar.tsx +++ b/src/components/progress-bar/ProgressBar.tsx @@ -16,10 +16,12 @@ function ProgressBar({ const fillColor = color || (isPast ? 'bg-gray-dark-02' : 'bg-green-normal-01'); + const limitedPercentage = Math.min(100, Math.max(0, percentage)); + return (
From 86b7761a096f8b9415b1a900c9c3f525ab07fa34 Mon Sep 17 00:00:00 2001 From: cloud0406 <32586926+cloud0406@users.noreply.github.com> Date: Fri, 17 Jan 2025 12:11:16 +0900 Subject: [PATCH 05/13] =?UTF-8?q?=08=F0=9F=93=9D[Docs]=20=EB=A6=AC?= =?UTF-8?q?=EB=93=9C=EB=AF=B8=20=ED=8F=AC=EB=A7=B7=20=EC=A0=81=EC=9A=A9=20?= =?UTF-8?q?(#319)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 📝[Docs] 리드미 포맷 적용 * 리드미 업데이트 --- README.md | 123 ++++++++++++++++++++++++++++++++++++++++++++---------- 1 file changed, 100 insertions(+), 23 deletions(-) diff --git a/README.md b/README.md index e215bc4c..d6c3cff8 100644 --- a/README.md +++ b/README.md @@ -1,36 +1,113 @@ -This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app). +
+image -## Getting Started +> 📖 당신의 독서 생활에 새로운 페이지를 열어보세요! +
새로운 사람들과 함께 읽고 나누는 특별한 독서 경험, **북코**가 함께합니다. +>
+
[![Bookco](https://img.shields.io/badge/BOOKCO.SITE-00a991?style=for-the-badge)](https://bookco.vercel.app/) +
+
+
-```bash -npm run dev -# or -yarn dev -# or -pnpm dev -# or -bun dev -``` +## 🎯 Bookco에서 할 수 있는 일 -Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. +- **👥 독서 모임** + + 비슷한 취향을 가진 사람들과 함께 책을 읽고 이야기를 나눌 수 있습니다. + - 정해진 책으로 독서 모임에 참여하거나, 직접 모임을 만들 수 있습니다. -You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file. +- 💬 **채팅하기** + + 다른 북코 유저들과 채팅 기능을 통해 소통할 수 있습니다. + - 모임의 호스트나 교환하고 싶은 책을 가진 유저와 대화를 나눌 수 있습니다. + +- **📚 교환하기 (추후 개발 예정..)** + + 안 보게 된 책을 등록하면, 다른 사람의 책과 바꿔 읽을 수 있습니다. + - 집에서 방치되던 책을 다른 유저와 공유할 수 있습니다. -This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel. +
+
-## Learn More +## 📚 서비스 소개 -To learn more about Next.js, take a look at the following resources: +image +image +image +image +image +image +image +image +image -- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. -- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. +
+
-You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome! +## 🛠️ 기술스택 -## Deploy on Vercel +### 💻 Core +![Next.js](https://img.shields.io/badge/Next.js-000000?style=for-the-badge&logo=next.js&logoColor=white) +![TypeScript](https://img.shields.io/badge/TypeScript-3178C6?style=for-the-badge&logo=typescript&logoColor=white) + +### 🔄 상태 관리 +![TanStack Query](https://img.shields.io/badge/TanStack_Query-FF4154?style=for-the-badge&logo=reactquery&logoColor=white) +![Zustand](https://img.shields.io/badge/Zustand-000000?style=for-the-badge) + +### 🌐 통신 +![Axios](https://img.shields.io/badge/Axios-5A29E4?style=for-the-badge&logo=axios&logoColor=white) +![SockJS](https://img.shields.io/badge/SockJS-000000?style=for-the-badge&logo=socket.io&logoColor=white) +![STOMP](https://img.shields.io/badge/STOMP-000000?style=for-the-badge) + +### 🎨 스타일링 +![Tailwind CSS](https://img.shields.io/badge/Tailwind_CSS-06B6D4?style=for-the-badge&logo=tailwindcss&logoColor=white) + +### ⚙️ 유틸리티 +![Zod](https://img.shields.io/badge/Zod-3E67B1?style=for-the-badge&logo=zod&logoColor=white) + +### 🧪 테스팅 +![Jest](https://img.shields.io/badge/Jest-C21325?style=for-the-badge&logo=jest&logoColor=white) +![React Testing Library](https://img.shields.io/badge/React_Testing_Library-E33332?style=for-the-badge&logo=testing-library&logoColor=white) +![Storybook](https://img.shields.io/badge/Storybook-FF4785?style=for-the-badge&logo=storybook&logoColor=white) + +### 📋 코드 품질 +![ESLint](https://img.shields.io/badge/ESLint-4B32C3?style=for-the-badge&logo=eslint&logoColor=white) +![Prettier](https://img.shields.io/badge/Prettier-F7B93E?style=for-the-badge&logo=prettier&logoColor=black) +![Husky](https://img.shields.io/badge/Husky-000000?style=for-the-badge) + +
+
+ +## 🤝 팀 협업 방식, 브랜치 전략 + +### ✅ **PR 리뷰 방식** +- **2명 Approve** 방식 +- PR 확인 시간 고정: `09:00`, `13:00`, `18:00` +- **Pn 룰**과 **Dn 룰** 적용 +- **데일리 스크럼** 진행 + +### ✅ **브랜치 전략** +- **GitHub Flow** 적용 + - `feature` → `develop` → `main` + - `hotfix` 는 Main에서 급하게 수정할 일 있을 때 사용 + +### ✅ **CI/CD 전략** +- **Husky**를 통한 코드 품질 관리 + - 커밋시 린트 검사 +- **디스코드 웹훅 연결**로 실시간 알림 +- PR 작성시 Lint 검사, test 코드 실행, 스토리북 빌드, 프로덕션 빌드 실행하여 검사 + +
+
+ +## 👥 팀원 구성 + +|FE|FE|FE|FE| +|:---:|:---:|:---:|:---:| +||||| +|[김선구](https://github.com/haegu97)|[김민경](https://github.com/wynter24)|[신선](https://github.com/sunnwave)|[김정호](https://github.com/cloud0406)| +
-The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. -Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details. From 3e3ff44a9412930010034a7dafd23d7516d6b9f4 Mon Sep 17 00:00:00 2001 From: Sungu Kim <108677235+haegu97@users.noreply.github.com> Date: Tue, 21 Jan 2025 13:12:16 +0900 Subject: [PATCH 06/13] =?UTF-8?q?=EB=A1=9C=EA=B7=B8=EC=9D=B8=20=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20=EC=BD=94=EB=93=9C=20=EC=B6=94=EA=B0=80=20?= =?UTF-8?q?#317=20(#318)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../container/login-form/LoginForm.test.tsx | 55 +++++++++++-------- 1 file changed, 31 insertions(+), 24 deletions(-) diff --git a/src/features/auth/container/login-form/LoginForm.test.tsx b/src/features/auth/container/login-form/LoginForm.test.tsx index 2add16e1..72f83f25 100644 --- a/src/features/auth/container/login-form/LoginForm.test.tsx +++ b/src/features/auth/container/login-form/LoginForm.test.tsx @@ -1,57 +1,64 @@ -import { render, screen } from '@testing-library/react'; +import { render, screen, waitFor } from '@testing-library/react'; import '@testing-library/jest-dom'; import userEvent from '@testing-library/user-event'; import LoginForm from './LoginForm'; -jest.mock('react-hook-form', () => ({ - useForm: () => ({ - register: () => ({}), - handleSubmit: (fn: any) => fn, - formState: { - isSubmitting: false, - errors: {}, - isValid: true, - }, - setError: jest.fn(), - reset: jest.fn(), - }), -})); - -// next/navigation mock jest.mock('next/navigation', () => ({ useRouter: () => ({ replace: jest.fn(), }), useSearchParams: () => null, })); - -describe('LoginForm', () => { - it('폼이 올바르게 렌더링되어야 한다', () => { +describe('LoginForm UI 테스트', () => { + it('로그인 폼의 모든 UI 요소가 올바르게 렌더링되어야 한다', () => { render(); - expect(screen.getByRole('heading', { name: '로그인' })).toBeInTheDocument(); expect(screen.getByLabelText('아이디')).toBeInTheDocument(); expect(screen.getByLabelText('비밀번호')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: '로그인' })).toBeInTheDocument(); + + expect(screen.getByText('회원가입')).toBeInTheDocument(); }); - it('이메일과 비밀번호를 입력할 수 있어야 한다', async () => { + it('입력 필드에 올바른 placeholder가 표시되어야 한다', () => { + render(); + + expect(screen.getByPlaceholderText('이메일')).toBeInTheDocument(); + expect(screen.getByPlaceholderText('비밀번호')).toBeInTheDocument(); + }); +}); + +describe('LoginForm', () => { + it('이메일과 비밀번호를 입력했을 때 로그인 버튼이 활성화되어야 한다', async () => { render(); const emailInput = screen.getByLabelText('아이디'); const passwordInput = screen.getByLabelText('비밀번호'); + const submitButton = screen.getByRole('button', { name: '로그인' }); + + expect(submitButton).toBeDisabled(); await userEvent.type(emailInput, 'test@example.com'); await userEvent.type(passwordInput, 'password123'); expect(emailInput).toHaveValue('test@example.com'); expect(passwordInput).toHaveValue('password123'); + + expect(submitButton).toBeEnabled(); }); - it('로그인 버튼이 제출 가능한 상태여야 한다', () => { + it('유효하지 않은 이메일 형식을 입력하면 에러 메시지가 표시되어야 한다', async () => { render(); - const submitButton = screen.getByRole('button', { name: '로그인' }); - expect(submitButton).toBeEnabled(); + const emailInput = screen.getByLabelText('아이디'); + await userEvent.type(emailInput, 'invalid-email'); + await userEvent.tab(); + + await waitFor(() => { + expect( + screen.getByText('올바른 이메일 형식이 아닙니다.'), + ).toBeInTheDocument(); + }); }); }); From 280d3472d5bb6fd5ca265c849326920c3ca8a0c6 Mon Sep 17 00:00:00 2001 From: Jeongho Kim Date: Thu, 6 Feb 2025 11:03:37 +0900 Subject: [PATCH 07/13] =?UTF-8?q?=F0=9F=90=9B[Fix]=20storybook=20router=20?= =?UTF-8?q?=EC=97=90=EB=9F=AC=20fix=20(#334)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ChatBubbleList.stories.tsx | 20 ++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/src/features/chat-room/container/chat-bubble-list/ChatBubbleList.stories.tsx b/src/features/chat-room/container/chat-bubble-list/ChatBubbleList.stories.tsx index 9eda641a..e2a6a9f2 100644 --- a/src/features/chat-room/container/chat-bubble-list/ChatBubbleList.stories.tsx +++ b/src/features/chat-room/container/chat-bubble-list/ChatBubbleList.stories.tsx @@ -4,6 +4,7 @@ import { GroupedMessage } from '@/features/chat-room/types/chatBubbleList'; import { useAuthStore } from '@/store/authStore'; import { useEffect } from 'react'; import { mockUser } from '@/mocks/mockDatas'; +import { useRouter } from 'next/navigation'; const AuthDecorator = (Story: React.ComponentType) => { useEffect(() => { @@ -16,10 +17,27 @@ const AuthDecorator = (Story: React.ComponentType) => { return ; }; +const MockNextRouter = (Story: React.ComponentType) => { + const mockRouter = { + push: () => Promise.resolve(), + replace: () => Promise.resolve(), + prefetch: () => Promise.resolve(), + back: () => Promise.resolve(), + forward: () => Promise.resolve(), + refresh: () => Promise.resolve(), + pathname: '/', + query: {}, + }; + + (useRouter as any).mockImplementation(() => mockRouter); + + return ; +}; + const meta: Meta = { title: 'Features/ChatRoom/ChatBubbleList', component: ChatBubbleList, - decorators: [AuthDecorator], + decorators: [AuthDecorator, MockNextRouter], parameters: { layout: 'centered', }, From 9d2559ad7b0939703561eff3ec3cc33f671b7df6 Mon Sep 17 00:00:00 2001 From: Jeongho Kim Date: Thu, 6 Feb 2025 11:31:38 +0900 Subject: [PATCH 08/13] =?UTF-8?q?[Test]=20=EB=AA=A8=EC=9E=84=20=EB=A7=8C?= =?UTF-8?q?=EB=93=A4=EA=B8=B0=20=ED=8E=98=EC=9D=B4=EC=A7=80=20=EB=8B=A8?= =?UTF-8?q?=EC=9C=84=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=BD=94=EB=93=9C=20?= =?UTF-8?q?=EC=9E=91=EC=84=B1=20(#327)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * ✨[Feat] jest 절대 경로 매핑 * ✅[Test] imagefiled 테스트 코드 작성 * 🚚[Rename] 경로 이동 * 🚚[Rename] radiobuttonGroup 파일 위치 변경 * ✅[Test] RadiobuttonGroup test 코드 작성 --- jest.config.js | 4 ++ .../club-create/container/FormContainer.tsx | 4 +- .../container/ImageField/ImageField.test.tsx | 40 ++++++++++++++ .../container/{ => ImageField}/ImageField.tsx | 15 ++++-- .../RadioButtonGroup.test.tsx | 52 +++++++++++++++++++ .../RadioButtonGroup.tsx | 5 +- 6 files changed, 111 insertions(+), 9 deletions(-) create mode 100644 src/features/club-create/container/ImageField/ImageField.test.tsx rename src/features/club-create/container/{ => ImageField}/ImageField.tsx (78%) create mode 100644 src/features/club-create/container/RadioButtonGroup/RadioButtonGroup.test.tsx rename src/features/club-create/container/{ => RadioButtonGroup}/RadioButtonGroup.tsx (96%) diff --git a/jest.config.js b/jest.config.js index 2d756f45..7d3102f7 100644 --- a/jest.config.js +++ b/jest.config.js @@ -11,6 +11,10 @@ const config = { coverageProvider: 'v8', testEnvironment: 'jsdom', // setupFilesAfterEnv: ['/src/setupTests.ts'], + moduleNameMapper: { + // 절대 경로 매핑 + '^@/(.*)$': '/src/$1', + }, }; // createJestConfig is exported this way to ensure that next/jest can load the Next.js config which is async diff --git a/src/features/club-create/container/FormContainer.tsx b/src/features/club-create/container/FormContainer.tsx index 681159a1..a223cac5 100644 --- a/src/features/club-create/container/FormContainer.tsx +++ b/src/features/club-create/container/FormContainer.tsx @@ -5,11 +5,11 @@ import { CreateClubFormField, InputField, } from '@/features/club-create/components'; -import ImageField from '@/features/club-create/container/ImageField'; -import RadioButtonGroup from '@/features/club-create/container/RadioButtonGroup'; +import RadioButtonGroup from '@/features/club-create/container/RadioButtonGroup/RadioButtonGroup'; import DatePickerContainer from '@/features/club-create/container/DatePickerField'; import { useBookClubForm } from '@/features/club-create/hooks'; import PopUp from '@/components/pop-up/PopUp'; +import ImageField from '@/features/club-create/container/ImageField/ImageField'; function FormContainer() { const { diff --git a/src/features/club-create/container/ImageField/ImageField.test.tsx b/src/features/club-create/container/ImageField/ImageField.test.tsx new file mode 100644 index 00000000..fe7fe395 --- /dev/null +++ b/src/features/club-create/container/ImageField/ImageField.test.tsx @@ -0,0 +1,40 @@ +import { render, screen } from '@testing-library/react'; +import ImageField from '@/features/club-create/container/ImageField/ImageField'; +import { useImageField } from '@/features/club-create/hooks'; +import '@testing-library/jest-dom'; + +jest.mock('@/features/club-create/hooks/useImageField', () => ({ + useImageField: jest.fn(() => ({ + selectedFileName: '', + handleFileChange: jest.fn(), + })), +})); + +describe('ImageField', () => { + const mockRegister = jest.fn(); + const mockSetValue = jest.fn(); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('이미지가 선택되지 않았을 때 이미지 업로드 UI를 표시한다', () => { + render(); + + expect(screen.getByTestId('camera-icon')).toBeInTheDocument(); + expect(screen.getByTestId('file-input')).toBeInTheDocument(); + }); + + it('이미지 선택 시 파일명을 표시한다', () => { + const testFileName = 'test.jpg'; + (useImageField as jest.Mock).mockImplementationOnce(() => ({ + selectedFileName: testFileName, + handleFileChange: jest.fn(), + })); + + render(); + + expect(screen.getByTestId('image-icon')).toBeInTheDocument(); + expect(screen.getByText(testFileName)).toBeInTheDocument(); + }); +}); diff --git a/src/features/club-create/container/ImageField.tsx b/src/features/club-create/container/ImageField/ImageField.tsx similarity index 78% rename from src/features/club-create/container/ImageField.tsx rename to src/features/club-create/container/ImageField/ImageField.tsx index 64a7568a..0b471efe 100644 --- a/src/features/club-create/container/ImageField.tsx +++ b/src/features/club-create/container/ImageField/ImageField.tsx @@ -1,10 +1,10 @@ 'use client'; import { UseFormRegister, UseFormSetValue } from 'react-hook-form'; -import { BookClubForm } from '../types'; -import { CreateClubFormField } from '../components'; +import { BookClubForm } from '../../types'; +import { CreateClubFormField } from '../../components'; import { useImageField } from '@/features/club-create/hooks'; -import { CameraIcon, ImageIcon } from '../../../../public/icons'; +import { CameraIcon, ImageIcon } from '../../../../../public/icons'; interface ImageUploadContainerProps { register: UseFormRegister; @@ -24,7 +24,9 @@ function ImageField({ register, setValue, error }: ImageUploadContainerProps) { {selectedFileName ? ( <>
- +
+ +
{selectedFileName} @@ -32,7 +34,9 @@ function ImageField({ register, setValue, error }: ImageUploadContainerProps) { ) : (
- +
+ +
이미지를 첨부해 주세요 (jpg, jpeg) @@ -43,6 +47,7 @@ function ImageField({ register, setValue, error }: ImageUploadContainerProps) { accept="image/*" className="absolute inset-0 cursor-pointer opacity-0" onChange={handleFileChange} + data-testid="file-input" />
diff --git a/src/features/club-create/container/RadioButtonGroup/RadioButtonGroup.test.tsx b/src/features/club-create/container/RadioButtonGroup/RadioButtonGroup.test.tsx new file mode 100644 index 00000000..b3e33cd1 --- /dev/null +++ b/src/features/club-create/container/RadioButtonGroup/RadioButtonGroup.test.tsx @@ -0,0 +1,52 @@ +import { render, screen } from '@testing-library/react'; +import RadioButtonGroup from './RadioButtonGroup'; +import '@testing-library/jest-dom'; + +jest.mock('@/features/club-create/hooks/useSelectAddress', () => ({ + useSelectAddress: jest.fn(() => ({ + handleRadioChange: jest.fn(), + })), +})); + +describe('RadioButtonGroup', () => { + const mockRegister = jest.fn(); + const mockSetValue = jest.fn(); + const mockWatch = jest.fn(); + const mockErrors = {}; + + const options = [ + { label: '오프라인', value: 'OFFLINE' }, + { label: '온라인', value: 'ONLINE' }, + ]; + + it('OFFLINE 선택 시 주소 입력 필드가 표시된다', () => { + render( + , + ); + + expect(screen.getByTestId('address-input')).toBeInTheDocument(); + }); + + it('OFFLINE이 아닌 옵션 선택 시 주소 입력 필드가 표시되지 않는다', () => { + render( + , + ); + + expect(screen.queryByTestId('address-input')).not.toBeInTheDocument(); + }); +}); diff --git a/src/features/club-create/container/RadioButtonGroup.tsx b/src/features/club-create/container/RadioButtonGroup/RadioButtonGroup.tsx similarity index 96% rename from src/features/club-create/container/RadioButtonGroup.tsx rename to src/features/club-create/container/RadioButtonGroup/RadioButtonGroup.tsx index a52556d1..370415bd 100644 --- a/src/features/club-create/container/RadioButtonGroup.tsx +++ b/src/features/club-create/container/RadioButtonGroup/RadioButtonGroup.tsx @@ -2,8 +2,8 @@ import Card from '@/components/card/Card'; import { useSelectAddress } from '@/features/club-create/hooks'; import { BookClubForm } from '@/features/club-create/types'; import { UseFormSetValue, UseFormWatch } from 'react-hook-form'; -import InputField from '../components/InputField'; -import CreateClubFormField from '../components/CreateClubFormField'; +import InputField from '../../components/InputField'; +import CreateClubFormField from '../../components/CreateClubFormField'; interface RadioButtonGroupProps { options: { label: string; value: string; description?: string }[]; @@ -114,6 +114,7 @@ function RadioButtonGroup({ From 8300169a0b8e948f00d298df97ffb4c43b7d55db Mon Sep 17 00:00:00 2001 From: Jeongho Kim Date: Thu, 6 Feb 2025 11:32:11 +0900 Subject: [PATCH 09/13] =?UTF-8?q?=E2=9C=A8=20[Feature]=20=EC=97=90?= =?UTF-8?q?=EB=9F=AC=20=EC=A0=84=EC=97=AD=20=EC=B2=98=EB=A6=AC=20(#321)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * ✨[Feat] 에러 바운더리 및 핸들링 wrapper 적용 * ♻️[Refactor] 유저 정보 훅 enabled 추가 * ✨[Feat] 내가 참여한 모임 에러바운더리 적용 * ♻️[Refactor] 에러 바운더리 전파를 위해 retryOnMount 옵션 제거 * ✨[Feat] error.tsx 템플릿 구현 * ✨[Feat] 북클럽 생성 페이지 Error.tsx 적용 * ✨[Feat] not found 적용 * ✨[Feat] 글로벌 에러 적용 * ✨[Feat] 전역 토스트 설정 * ✨[Feat] global callback 적용 * 🚚[Rename] 에러 파일들 컴포넌트로 분리 * ✨[Feat] alertIcon 추가 * 💄[Design] 에러 폴백 컴포넌트 디자인 수정 * 💄[Design] 서버사이드 에러 템플릿 디자인 변경 * 💄[Design] notfound, global 에러 페이지 이미지 추가 * 📝[Docs] Info 테스트 코드 임시 주석 처리 --- public/icons/AlertCircleIcon.tsx | 47 ++++++++++++ public/icons/index.ts | 1 + public/images/errorImage.png | Bin 0 -> 10667 bytes src/app/bookclub/create/error.tsx | 20 +++++ src/app/global-error.tsx | 42 ++++++++++ src/app/not-found.tsx | 32 ++++++++ src/components/error/ErrorBoundary.tsx | 72 ++++++++++++++++++ src/components/error/ErrorFallback.tsx | 32 ++++++++ src/components/error/ErrorHandlingWrapper.tsx | 27 +++++++ src/components/error/ErrorTemplate.tsx | 46 +++++++++++ .../profile/components/info/Info.test.tsx | 68 ++++++++--------- .../profile/container/ClubContents.tsx | 12 ++- src/lib/hooks/useGetUserByPath.ts | 13 ++-- src/lib/utils/reactQueryProvider.tsx | 34 ++++++++- 14 files changed, 402 insertions(+), 44 deletions(-) create mode 100644 public/icons/AlertCircleIcon.tsx create mode 100644 public/images/errorImage.png create mode 100644 src/app/bookclub/create/error.tsx create mode 100644 src/app/global-error.tsx create mode 100644 src/app/not-found.tsx create mode 100644 src/components/error/ErrorBoundary.tsx create mode 100644 src/components/error/ErrorFallback.tsx create mode 100644 src/components/error/ErrorHandlingWrapper.tsx create mode 100644 src/components/error/ErrorTemplate.tsx diff --git a/public/icons/AlertCircleIcon.tsx b/public/icons/AlertCircleIcon.tsx new file mode 100644 index 00000000..827efad0 --- /dev/null +++ b/public/icons/AlertCircleIcon.tsx @@ -0,0 +1,47 @@ +import { SVGProps } from 'react'; + +interface AlertCircleIconProps extends SVGProps { + width?: number; + height?: number; +} + +function AlertCircleIcon({ + width = 50, + height = 50, + ...props +}: AlertCircleIconProps) { + return ( + + + + + + ); +} + +export default AlertCircleIcon; diff --git a/public/icons/index.ts b/public/icons/index.ts index 1a23bc21..82d49b5e 100644 --- a/public/icons/index.ts +++ b/public/icons/index.ts @@ -19,3 +19,4 @@ export { default as OnlineIcon } from './OnlineIcon'; export { default as MessageIcon } from './MessageIcon'; export { default as PencilIcon } from './PencilIcon'; export { default as IcCheckOnly } from './IcCheckOnly'; +export { default as AlertCircleIcon } from './AlertCircleIcon'; diff --git a/public/images/errorImage.png b/public/images/errorImage.png new file mode 100644 index 0000000000000000000000000000000000000000..328b589698b9c0031b777b4cbe473a84c7ba6d77 GIT binary patch literal 10667 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 void; +}) { + return ( + + ); +} diff --git a/src/app/global-error.tsx b/src/app/global-error.tsx new file mode 100644 index 00000000..14da1ffe --- /dev/null +++ b/src/app/global-error.tsx @@ -0,0 +1,42 @@ +'use client'; + +import Button from '@/components/button/Button'; +import Image from 'next/image'; + +export default function GlobalError({ + error, + reset, +}: { + error: Error & { digest?: string }; + reset: () => void; +}) { + return ( + + + +
+ 에러 이미지 +

치명적인 오류가 발생했습니다

+

+ {error.message || '서비스에 문제가 발생했습니다'} +

+ +
+ + + ); +} diff --git a/src/app/not-found.tsx b/src/app/not-found.tsx new file mode 100644 index 00000000..4528a6bf --- /dev/null +++ b/src/app/not-found.tsx @@ -0,0 +1,32 @@ +import Button from '@/components/button/Button'; +import Link from 'next/link'; +import Image from 'next/image'; + +export default function NotFound() { + return ( +
+ 에러 이미지 + +
+

페이지를 찾을 수 없습니다

+

요청하신 페이지가 존재하지 않습니다

+
+ + +
+ ); +} diff --git a/src/components/error/ErrorBoundary.tsx b/src/components/error/ErrorBoundary.tsx new file mode 100644 index 00000000..99104570 --- /dev/null +++ b/src/components/error/ErrorBoundary.tsx @@ -0,0 +1,72 @@ +'use client'; + +import { Component, ReactNode, ErrorInfo, ComponentType } from 'react'; + +interface ErrorBoundaryState { + hasError: boolean; + error: Error | null; +} + +export interface FallbackProps { + error: Error | null; + resetErrorBoundary: () => void; +} + +type ErrorBoundaryProps = { + FallbackComponent: ComponentType; + onReset: () => void; + children: ReactNode; +}; + +class ErrorBoundary extends Component { + constructor(props: ErrorBoundaryProps) { + super(props); + + this.state = { + hasError: false, + error: null, + }; + + this.resetErrorBoundary = this.resetErrorBoundary.bind(this); + } + + /** 에러 상태 변경 */ + static getDerivedStateFromError(error: Error): ErrorBoundaryState { + return { hasError: true, error }; + } + + componentDidCatch(error: Error, errorInfo: ErrorInfo): void { + console.log({ error, errorInfo }); + } + + /** 에러 상태 기본 초기화 */ + resetErrorBoundary(): void { + this.props.onReset(); + + this.setState({ + hasError: false, + error: null, + }); + } + + render() { + const { state, props } = this; + + const { hasError, error } = state; + + const { FallbackComponent, children } = props; + + if (hasError && error) { + return ( + + ); + } + + return children; + } +} + +export default ErrorBoundary; diff --git a/src/components/error/ErrorFallback.tsx b/src/components/error/ErrorFallback.tsx new file mode 100644 index 00000000..77791d9d --- /dev/null +++ b/src/components/error/ErrorFallback.tsx @@ -0,0 +1,32 @@ +'use client'; + +import Button from '@/components/button/Button'; +import { FallbackProps } from './ErrorBoundary'; +import { AlertCircleIcon } from '../../../public/icons'; + +export default function ErrorFallback({ + error, + resetErrorBoundary, +}: FallbackProps) { + return ( +
+ + {error && ( +

+ 요청을 처리하는 과정에서 오류가 발생했습니다. 다시 시도해주세요. +

+ )} + + +
+ ); +} diff --git a/src/components/error/ErrorHandlingWrapper.tsx b/src/components/error/ErrorHandlingWrapper.tsx new file mode 100644 index 00000000..921df7bf --- /dev/null +++ b/src/components/error/ErrorHandlingWrapper.tsx @@ -0,0 +1,27 @@ +'use client'; + +import { QueryErrorResetBoundary } from '@tanstack/react-query'; +import { ComponentType, ReactNode, Suspense } from 'react'; +import ErrorBoundary, { FallbackProps } from './ErrorBoundary'; + +interface ErrorHandlingWrapperProps { + children: ReactNode; + fallbackComponent: ComponentType; + suspenseFallback: ReactNode; +} + +export default function ErrorHandlingWrapper({ + children, + fallbackComponent: FallbackComponent, + suspenseFallback, +}: ErrorHandlingWrapperProps) { + return ( + + {({ reset }) => ( + + {children} + + )} + + ); +} diff --git a/src/components/error/ErrorTemplate.tsx b/src/components/error/ErrorTemplate.tsx new file mode 100644 index 00000000..93720ea7 --- /dev/null +++ b/src/components/error/ErrorTemplate.tsx @@ -0,0 +1,46 @@ +'use client'; + +import Button from '@/components/button/Button'; +import Image from 'next/image'; + +interface ErrorTemplateProps { + error: Error; + reset: () => void; + title?: string; + message?: string; + children?: React.ReactNode; +} + +export default function ErrorTemplate({ + error, + reset, + title = '오류가 발생했습니다', + message, + children, +}: ErrorTemplateProps) { + return ( +
+ 에러 이미지 +

{title}

+

{message || error.message}

+ + + + {children} +
+ ); +} diff --git a/src/features/profile/components/info/Info.test.tsx b/src/features/profile/components/info/Info.test.tsx index 89457776..aa7f8225 100644 --- a/src/features/profile/components/info/Info.test.tsx +++ b/src/features/profile/components/info/Info.test.tsx @@ -67,38 +67,38 @@ describe('Info 테스트', () => { it("수정하기 모달에서 닉네임을 입력하지 않고 수정하기 버튼 클릭 시 '닉네임을 입력해주세요' 팝업창 렌더링 확인", () => {}); - it('수정하기 모달에서 수정 후 수정하기 버튼 클릭 시 onSubmitEditInfo 함수 호출 확인', async () => { - render( - - - , - ); - - const editButton = screen.getByLabelText('프로필 수정'); - await userEvent.click(editButton); - - //닉네임 수정 - const nameInput = screen.getByRole('textbox', { name: 'nickname' }); - await userEvent.clear(nameInput); - await userEvent.type(nameInput, 'Edited Name'); - - //한 줄 소개 수정 - const descriptionInput = screen.getByRole('textbox', { - name: 'description', - }); - await userEvent.clear(descriptionInput); - await userEvent.type(descriptionInput, 'Edited Description'); - - //수정하기 버튼 클릭 - const confirmButton = screen.getByText('수정하기'); - await userEvent.click(confirmButton); - - //TODO:함수 호출 확인 - - // expect(mockSubmit).toHaveBeenCalledTimes(1); - // expect(mockSubmit).toHaveBeenCalledWith({ - // name: 'Edited Name', - // description: 'Edited Description', - // }); - }); + // it('수정하기 모달에서 수정 후 수정하기 버튼 클릭 시 onSubmitEditInfo 함수 호출 확인', async () => { + // render( + // + // + // , + // ); + + // const editButton = screen.getByLabelText('프로필 수정'); + // await userEvent.click(editButton); + + // //닉네임 수정 + // const nameInput = screen.getByRole('textbox', { name: 'nickname' }); + // await userEvent.clear(nameInput); + // await userEvent.type(nameInput, 'Edited Name'); + + // //한 줄 소개 수정 + // const descriptionInput = screen.getByRole('textbox', { + // name: 'description', + // }); + // await userEvent.clear(descriptionInput); + // await userEvent.type(descriptionInput, 'Edited Description'); + + // //수정하기 버튼 클릭 + // const confirmButton = screen.getByText('수정하기'); + // await userEvent.click(confirmButton); + + // //TODO:함수 호출 확인 + + // // expect(mockSubmit).toHaveBeenCalledTimes(1); + // // expect(mockSubmit).toHaveBeenCalledWith({ + // // name: 'Edited Name', + // // description: 'Edited Description', + // // }); + // }); }); diff --git a/src/features/profile/container/ClubContents.tsx b/src/features/profile/container/ClubContents.tsx index 689bd1ef..20b2fadb 100644 --- a/src/features/profile/container/ClubContents.tsx +++ b/src/features/profile/container/ClubContents.tsx @@ -13,6 +13,9 @@ import { MyWrittenReviewList, WrittenReviewList, } from '../container/index'; +import ErrorHandlingWrapper from '@/components/error/ErrorHandlingWrapper'; +import ErrorFallback from '@/components/error/ErrorFallback'; +import Loading from '@/components/loading/Loading'; export default function ClubContents({ isMyPage }: ProfilePageProps) { const [order, setOrder] = useState('DESC'); @@ -62,7 +65,14 @@ export default function ClubContents({ isMyPage }: ProfilePageProps) { />
-
{renderList(selectedList)}
+
+ } + > + {renderList(selectedList)} + +
); } diff --git a/src/lib/hooks/useGetUserByPath.ts b/src/lib/hooks/useGetUserByPath.ts index 4130acf0..caf30db4 100644 --- a/src/lib/hooks/useGetUserByPath.ts +++ b/src/lib/hooks/useGetUserByPath.ts @@ -4,15 +4,14 @@ import { usePathname } from 'next/navigation'; export function useGetUserByPath() { const pathname = usePathname(); - const userId = Number(pathname?.split('/')[2]); + const userId = pathname?.split('/')[2]; + + const isValidUserId: boolean = Boolean(userId && !isNaN(Number(userId))); - const { queryKey, queryFn } = users.userInfo(userId); const { data } = useQuery({ - queryKey, - queryFn, + ...users.userInfo(Number(userId)), + enabled: isValidUserId, }); - const user = data?.data; - - return user; + return data?.data; } diff --git a/src/lib/utils/reactQueryProvider.tsx b/src/lib/utils/reactQueryProvider.tsx index 28efda55..97aefc35 100644 --- a/src/lib/utils/reactQueryProvider.tsx +++ b/src/lib/utils/reactQueryProvider.tsx @@ -1,17 +1,47 @@ 'use client'; -import { QueryClientProvider, QueryClient } from '@tanstack/react-query'; +import { + QueryClientProvider, + QueryClient, + QueryCache, + MutationCache, +} from '@tanstack/react-query'; import { ReactQueryDevtools } from '@tanstack/react-query-devtools'; +import { showToast } from '@/components/toast/toast'; export const queryClient = new QueryClient({ + queryCache: new QueryCache({ + onError: (error: Error) => { + console.error('Query Error:', error); + showToast({ + message: '데이터를 조회하는 중 에러가 발생했습니다', + type: 'error', + }); + }, + }), + mutationCache: new MutationCache({ + onError: (error: Error, _, __, mutation) => { + if (!mutation.options.onError) { + console.error('Mutation Error:', error); + showToast({ + message: '요청 처리 중 오류가 발생했습니다', + type: 'error', + }); + } + }, + }), defaultOptions: { queries: { refetchOnWindowFocus: false, // 윈도우가 다시 포커스될 때 데이터를 다시 가져올지 여부 refetchOnMount: true, // 컴포넌트가 마운트될 때 데이터를 다시 가져올지 여부 retry: 0, // 실패한 쿼리 재시도 횟수 refetchOnReconnect: false, // 네트워크 재연결시 데이터를 다시 가져올지 여부 - retryOnMount: false, // 마운트 시 실패한 쿼리 재시도 여부 + // retryOnMount: false, // 마운트 시 실패한 쿼리 재시도 여부 staleTime: 1000 * 60 * 5, // 데이터가 'fresh'한 상태로 유지되는 시간 (5분) gcTime: 1000 * 60 * 10, // 사용하지 않는 캐시 데이터가 메모리에서 제거되기까지의 시간 (10분) + throwOnError: true, + }, + mutations: { + throwOnError: false, // TODO: mutation 에러 에러 바운더리로 던져줄지 고민 }, }, }); From f6f6c91ff85c0887eff3908f5ad70bc553e4c525 Mon Sep 17 00:00:00 2001 From: Sungu Kim <108677235+haegu97@users.noreply.github.com> Date: Fri, 7 Feb 2025 11:06:44 +0900 Subject: [PATCH 10/13] =?UTF-8?q?=E2=9C=A8[Feat]=20=EB=AA=A8=EC=9E=84=20?= =?UTF-8?q?=EC=B0=BE=EA=B8=B0=20=EA=B2=80=EC=83=89=20debounce=20=EC=A0=81?= =?UTF-8?q?=EC=9A=A9=20=20(#322)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor: SearchSection 제거 #320 * 📦[Chore] lodash debounce 설치 #320 * feature: 모임 검색 debounce 적용 #320 * design: gap-2로 수정 --- package-lock.json | 9 ++++- package.json | 2 ++ src/components/common-layout/FilterBar.tsx | 17 ++++------ .../input/search-input/SearchInput.tsx | 33 +++++++++++++++---- .../chat-bubble-list/ChatBubbleList.tsx | 2 +- 5 files changed, 43 insertions(+), 20 deletions(-) diff --git a/package-lock.json b/package-lock.json index f6efef56..0fab6037 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,9 +14,11 @@ "@stomp/stompjs": "^7.0.0", "@tanstack/react-query": "^5.61.3", "@tanstack/react-query-devtools": "^5.61.3", + "@types/lodash": "^4.17.14", "@types/react-datepicker": "^6.2.0", "@types/sockjs-client": "^1.5.4", "axios": "^1.7.8", + "lodash": "^4.17.21", "next": "15.0.3", "react": "^18.3.1", "react-datepicker": "^7.5.0", @@ -5687,6 +5689,12 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/lodash": { + "version": "4.17.14", + "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.14.tgz", + "integrity": "sha512-jsxagdikDiDBeIRaPYtArcT8my4tN1og7MtMRquFT3XNA6axxyHDRUemqDz/taRDdOUn0GnGHRCuff4q48sW9A==", + "license": "MIT" + }, "node_modules/@types/mdx": { "version": "2.0.13", "resolved": "https://registry.npmjs.org/@types/mdx/-/mdx-2.0.13.tgz", @@ -14089,7 +14097,6 @@ "version": "4.17.21", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", - "dev": true, "license": "MIT" }, "node_modules/lodash.debounce": { diff --git a/package.json b/package.json index 1cb6f6bf..24566719 100644 --- a/package.json +++ b/package.json @@ -32,9 +32,11 @@ "@stomp/stompjs": "^7.0.0", "@tanstack/react-query": "^5.61.3", "@tanstack/react-query-devtools": "^5.61.3", + "@types/lodash": "^4.17.14", "@types/react-datepicker": "^6.2.0", "@types/sockjs-client": "^1.5.4", "axios": "^1.7.8", + "lodash": "^4.17.21", "next": "15.0.3", "react": "^18.3.1", "react-datepicker": "^7.5.0", diff --git a/src/components/common-layout/FilterBar.tsx b/src/components/common-layout/FilterBar.tsx index e992ebc1..1e4f4d4f 100644 --- a/src/components/common-layout/FilterBar.tsx +++ b/src/components/common-layout/FilterBar.tsx @@ -1,10 +1,6 @@ -import { - CategoryTabs, - SearchSection, - FilterSection, -} from '@/components/common-layout'; +import { CategoryTabs, FilterSection } from '@/components/common-layout'; import { BookClubParams } from '@/types/bookclubs'; - +import SearchInput from '@/components/input/search-input/SearchInput'; interface FilterBarProps { filters: BookClubParams; handleFilterChange: (newFilter: Partial) => void; @@ -14,11 +10,10 @@ function FilterBar({ filters, handleFilterChange }: FilterBarProps) { return (
- - handleFilterChange({ searchKeyword: value }) - } + handleFilterChange({ searchKeyword: e.target.value })} + aria-label="책 검색" />
diff --git a/src/components/input/search-input/SearchInput.tsx b/src/components/input/search-input/SearchInput.tsx index 5b47f722..f623dbc7 100644 --- a/src/components/input/search-input/SearchInput.tsx +++ b/src/components/input/search-input/SearchInput.tsx @@ -1,6 +1,7 @@ -import { ChangeEvent } from 'react'; +import { ChangeEvent, useMemo, useState } from 'react'; import Input from '../Input'; import SearchIcon from '../../../../public/icons/SearchIcon'; +import { debounce } from 'lodash'; interface SearchInputProps { value: string; @@ -8,13 +9,31 @@ interface SearchInputProps { } function SearchInput({ value, onChange }: SearchInputProps) { + const [inputValue, setInputValue] = useState(value); + + const debouncedOnChange = useMemo( + () => + debounce((value: string) => { + onChange({ target: { value } } as ChangeEvent); + }, 300), + [onChange], + ); + + const handleChange = (e: ChangeEvent) => { + const newValue = e.target.value; + setInputValue(newValue); + debouncedOnChange(newValue); + }; + return ( - } - /> +
+ } + /> +
); } diff --git a/src/features/chat-room/container/chat-bubble-list/ChatBubbleList.tsx b/src/features/chat-room/container/chat-bubble-list/ChatBubbleList.tsx index dcde2b87..fb6a85c3 100644 --- a/src/features/chat-room/container/chat-bubble-list/ChatBubbleList.tsx +++ b/src/features/chat-room/container/chat-bubble-list/ChatBubbleList.tsx @@ -47,7 +47,7 @@ function ChatBubbleList({ return (
{groupedMessages.map((group, groupIndex) => ( -
+
{group.date} From c5ff31c4576a3c35a1e30100abd97ff88771bb6d Mon Sep 17 00:00:00 2001 From: Sungu Kim <108677235+haegu97@users.noreply.github.com> Date: Fri, 7 Feb 2025 11:07:02 +0900 Subject: [PATCH 11/13] =?UTF-8?q?=F0=9F=92=84[Design]=20=EB=AA=A8=EC=9E=84?= =?UTF-8?q?=20=EC=B9=B4=EB=93=9C,=20=EB=B2=84=ED=8A=BC=20=ED=98=B8?= =?UTF-8?q?=EB=B2=84=20=ED=9A=A8=EA=B3=BC=20=EC=A0=81=EC=9A=A9=20(#324)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * design: 모임 리스트 카드 호버 애니메이션 적용 #323 * design: 카드 호버 디자인 수정 #323 * design: 모임 취소하기 버튼 색상 변경 #323 --- src/components/card/Card.tsx | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/src/components/card/Card.tsx b/src/components/card/Card.tsx index cd5dc547..9b76ba01 100644 --- a/src/components/card/Card.tsx +++ b/src/components/card/Card.tsx @@ -218,7 +218,6 @@ function Card(props: CardProps) { max, isPast, isCanceled, - // meetingType, bookClubType, clubStatus, onLikeClick, @@ -228,7 +227,7 @@ function Card(props: CardProps) { } = props as DefaultClubCard & { variant: 'defaultClub' }; return ( -
+
+
onClick?.(clubId)} @@ -328,7 +327,7 @@ function Card(props: CardProps) { e.stopPropagation(); onWriteReview(clubId); }} - className="w-full" + className="w-full hover-dim" /> ) : (
@@ -370,7 +369,7 @@ function Card(props: CardProps) { } = props as HostedClubCard & { variant: 'hostedClub' }; return ( -
+
onClick?.(clubId)} @@ -397,7 +396,7 @@ function Card(props: CardProps) { size="modal" fillType="lightSolid" themeColor="gray-dark-01" - lightColor="gray-normal-01" + lightColor="gray-normal-02" onClick={(e) => { e.stopPropagation(); onCancel(clubId); From 43a03fcaab49fdd412a540739e4e879a108bb628 Mon Sep 17 00:00:00 2001 From: Sungu Kim <108677235+haegu97@users.noreply.github.com> Date: Fri, 7 Feb 2025 11:27:19 +0900 Subject: [PATCH 12/13] =?UTF-8?q?=F0=9F=92=84[Design]=20=EB=AA=A8=EC=9E=84?= =?UTF-8?q?=20=EC=B0=BE=EA=B8=B0=20=EB=A9=94=EC=9D=B8=20=ED=8E=98=EC=9D=B4?= =?UTF-8?q?=EC=A7=80=20=ED=95=98=EB=8B=A8=20=ED=8C=A8=EB=94=A9=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20(#326)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * design: 모임 메인 페이지 하단 여백 추가 #325 * design: pb-10으로 수정 * design: pb-12으로 수정 --- src/features/bookclub/components/BookClubMainPage.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/features/bookclub/components/BookClubMainPage.tsx b/src/features/bookclub/components/BookClubMainPage.tsx index f434bbdf..148e54d3 100644 --- a/src/features/bookclub/components/BookClubMainPage.tsx +++ b/src/features/bookclub/components/BookClubMainPage.tsx @@ -48,7 +48,9 @@ function BookClubMainPage() {
) : ( - +
+ +
)} ); From b246b6964c715af3f1c5bbcd9652fc445d4c3907 Mon Sep 17 00:00:00 2001 From: Minkyung Kim <97824352+wynter24@users.noreply.github.com> Date: Fri, 7 Feb 2025 13:50:14 +0900 Subject: [PATCH 13/13] =?UTF-8?q?=E2=9C=A8[Feat]=20=EB=AA=A8=EC=9E=84=20?= =?UTF-8?q?=EC=B0=BE=EA=B8=B0=20=ED=8E=98=EC=9D=B4=EC=A7=80=EC=97=90=20?= =?UTF-8?q?=EB=B9=84=EB=A1=9C=EA=B7=B8=EC=9D=B8=20=EC=B0=9C=20=ED=81=B4?= =?UTF-8?q?=EB=A6=AD=20=EC=8B=9C=20=EB=A1=9C=EA=B7=B8=EC=9D=B8=20=EC=95=88?= =?UTF-8?q?=EB=82=B4=EC=B0=BD=20=EC=B6=94=EA=B0=80=20#336=20(#337)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../bookclub/components/ClubListSection.tsx | 41 +++++++++++++++++-- .../club-details/components/HeaderSection.tsx | 20 ++++++--- src/lib/hooks/useLikeWithAuthCheck.ts | 27 ++++-------- 3 files changed, 59 insertions(+), 29 deletions(-) diff --git a/src/features/bookclub/components/ClubListSection.tsx b/src/features/bookclub/components/ClubListSection.tsx index 95b5231e..a7769d79 100644 --- a/src/features/bookclub/components/ClubListSection.tsx +++ b/src/features/bookclub/components/ClubListSection.tsx @@ -3,11 +3,13 @@ import Card from '@/components/card/Card'; import { formatDateForUI, isPastDate } from '@/lib/utils/formatDateForUI'; import { useRouter } from 'next/navigation'; -import { useMemo } from 'react'; +import { useEffect, useMemo } from 'react'; import EmptyState from '@/components/common-layout/EmptyState'; import { clubStatus } from '@/lib/utils/clubUtils'; import { BookClub } from '@/types/bookclubs'; -import { useLikeClub, useUnLikeClub } from '@/lib/hooks'; +import { useLikeClub, useLikeWithAuthCheck, useUnLikeClub } from '@/lib/hooks'; +import { useAuthStore } from '@/store/authStore'; +import PopUp from '@/components/pop-up/PopUp'; interface ClubListSectionProps { bookClubs: BookClub[]; @@ -15,13 +17,37 @@ interface ClubListSectionProps { function ClubListSection({ bookClubs = [] }: ClubListSectionProps) { const router = useRouter(); + const { + isLikePopUpOpen, + likePopUpLabel, + onShowAuthPopUp, + onCloseCheckAuthPopup, + } = useLikeWithAuthCheck(); const { onConfirmUnLike } = useUnLikeClub(); const { onConfirmLike } = useLikeClub(); + const { isLoggedIn, checkLoginStatus, user } = useAuthStore(); + + useEffect(() => { + checkLoginStatus(); + }, [checkLoginStatus]); const today = useMemo(() => new Date(), []); const handleLikeClub = (isLiked: boolean, id: number) => { - isLiked ? onConfirmUnLike(id) : onConfirmLike(id); + if (!isLoggedIn) { + onShowAuthPopUp(); + return; + } + + if (isLiked) { + onConfirmUnLike(id); + } else { + onConfirmLike(id); + } + }; + + const handleLikePopUpConfirm = () => { + router.push('/login'); }; return ( @@ -49,6 +75,7 @@ function ClubListSection({ bookClubs = [] }: ClubListSectionProps) { club.endDate, today, )} + isHost={club.hostId === user?.id} onLikeClick={() => handleLikeClub(club.isLiked, club.id)} onClick={() => router.push(`/bookclub/${club.id}`)} /> @@ -59,6 +86,14 @@ function ClubListSection({ bookClubs = [] }: ClubListSectionProps) { subtitle="지금 바로 책 모임을 만들어보세요." /> )} + ); } diff --git a/src/features/club-details/components/HeaderSection.tsx b/src/features/club-details/components/HeaderSection.tsx index c1d43b62..c3cd030e 100644 --- a/src/features/club-details/components/HeaderSection.tsx +++ b/src/features/club-details/components/HeaderSection.tsx @@ -13,6 +13,7 @@ import { useJoinClub } from '../hooks'; import { useCancelClub, useLeaveClub, + useLikeClub, useLikeWithAuthCheck, useUnLikeClub, } from '@/lib/hooks/index'; @@ -42,9 +43,10 @@ function HeaderSection({ clubInfo, idAsNumber }: HeaderSectionProps) { const { isLikePopUpOpen, likePopUpLabel, - onCheckAuthPopUp, + onShowAuthPopUp, onCloseCheckAuthPopup, } = useLikeWithAuthCheck(); + const { onConfirmLike } = useLikeClub(); const { onConfirmUnLike } = useUnLikeClub(); const { isLoggedIn, checkLoginStatus, user } = useAuthStore(); @@ -76,10 +78,16 @@ function HeaderSection({ clubInfo, idAsNumber }: HeaderSectionProps) { handleJoin(clubInfo.id); }; - const handleLikeClub = () => { - clubInfo.isLiked - ? onConfirmUnLike(clubInfo.id) - : onCheckAuthPopUp(clubInfo.id); + const handleLikeClub = (isLiked: boolean) => { + if (!isLoggedIn) { + onShowAuthPopUp(); + return; + } + if (isLiked) { + onConfirmUnLike(clubInfo.id); + } else { + onConfirmLike(clubInfo.id); + } }; const handleLikePopUpConfirm = () => { @@ -106,7 +114,7 @@ function HeaderSection({ clubInfo, idAsNumber }: HeaderSectionProps) { clubInfo.endDate, new Date(), // TODO: new Date() 최적화 후 수정 ), - onLikeClick: handleLikeClub, + onLikeClick: () => handleLikeClub(clubInfo.isLiked), host: { id: clubInfo.hostId, name: clubInfo.hostNickname, diff --git a/src/lib/hooks/useLikeWithAuthCheck.ts b/src/lib/hooks/useLikeWithAuthCheck.ts index a0c09298..e8eaca08 100644 --- a/src/lib/hooks/useLikeWithAuthCheck.ts +++ b/src/lib/hooks/useLikeWithAuthCheck.ts @@ -1,27 +1,14 @@ -import { useEffect, useState } from 'react'; -import { useLikeClub } from './useLikeClub'; -import { useAuthStore } from '@/store/authStore'; +import { useState } from 'react'; export const useLikeWithAuthCheck = () => { - const { onConfirmLike } = useLikeClub(); const [isPopUpOpen, setIsPopUpOpen] = useState(false); const [popUpLabel, setPopUpLabel] = useState(''); - const { isLoggedIn, checkLoginStatus } = useAuthStore(); - - useEffect(() => { - checkLoginStatus(); - }, [checkLoginStatus]); - - const onCheckAuthPopUp = (clubId: number) => { - if (isLoggedIn) { - onConfirmLike(clubId); - } else { - setPopUpLabel( - `로그인 후 이용할 수 있어요.\n로그인 페이지로 이동하시겠어요?`, - ); - setIsPopUpOpen(true); - } + const onShowAuthPopUp = () => { + setPopUpLabel( + `로그인 후 이용할 수 있어요.\n로그인 페이지로 이동하시겠어요?`, + ); + setIsPopUpOpen(true); }; const onCloseCheckAuthPopup = () => { @@ -32,7 +19,7 @@ export const useLikeWithAuthCheck = () => { return { isLikePopUpOpen: isPopUpOpen, likePopUpLabel: popUpLabel, - onCheckAuthPopUp, + onShowAuthPopUp, onCloseCheckAuthPopup, }; };