diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c239d990..e2d73509 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -25,6 +25,9 @@ jobs: with: node-version: ${{ matrix.node-version }} + - name: Set environment variables + run: echo "NEXT_PUBLIC_API_URL=${{ secrets.NEXT_PUBLIC_API_URL }}" >> .env + - name: Install dependencies run: npm install @@ -35,6 +38,8 @@ jobs: run: npm test - name: Build Next.js app + env: + NEXT_PUBLIC_API_URL: ${{ secrets.NEXT_PUBLIC_API_URL }} run: npm run build - name: Build Storybook 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. diff --git a/jest.config.js b/jest.config.js index 2d756f45..970b1b93 100644 --- a/jest.config.js +++ b/jest.config.js @@ -11,6 +11,9 @@ 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/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..0e8cd82f 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, @@ -10,6 +11,7 @@ import { import { WriteReviewParams } from '../types'; import { AxiosError } from 'axios'; import { likeOnError, likeOnMutate } from './likeOptimisticUpdate'; +import { BookClubParams } from '@/types/bookclubs'; export function useBookClubCreateMutation() { const queryClient = useQueryClient(); @@ -25,7 +27,10 @@ export function useBookClubCreateMutation() { }); }, onError: () => { - showToast({ message: '뢁클럽 생성에 μ‹€νŒ¨ν–ˆμŠ΅λ‹ˆλ‹€.', type: 'error' }); + showToast({ + message: TOAST_MESSAGES.ERROR.CLUB_CREATE_FAILED, + type: 'error', + }); }, }); } @@ -74,12 +79,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', + }); }, }); } @@ -103,21 +114,22 @@ export function useCancelBookClub() { }); } -export function useLikeBookClub() { +export function useLikeBookClub(filter: BookClubParams) { const queryClient = useQueryClient(); return useMutation, number>({ mutationFn: (id: number) => bookClubLikeAPI.like(id), onMutate: async (id) => { - return likeOnMutate(queryClient, id, true); + return likeOnMutate(queryClient, id, true, filter); }, //TODO: 둜직 확인 ν›„ λ³€κ²½ ν•„μš” - onSuccess: () => { - queryClient.invalidateQueries({ - queryKey: bookClubs._def, - }); - }, + // onSuccess: () => { + // queryClient.invalidateQueries({ + // queryKey: ['bookClubs', 'list', DEFAULT_FILTERS], + // }); + // // console.log(bookClubs._def) + // }, onError: (_error, id, context) => { if (context) { @@ -127,21 +139,21 @@ export function useLikeBookClub() { }); } -export function useUnLikeBookClub() { +export function useUnLikeBookClub(filter: BookClubParams) { const queryClient = useQueryClient(); return useMutation, number>({ mutationFn: (id: number) => bookClubLikeAPI.unlike(id), onMutate: async (id) => { - return likeOnMutate(queryClient, id, false); + return likeOnMutate(queryClient, id, false, filter); }, //TODO: 둜직 확인 ν›„ λ³€κ²½ ν•„μš” - onSuccess: () => { - queryClient.invalidateQueries({ - queryKey: bookClubs._def, - }); - }, + // onSuccess: () => { + // queryClient.invalidateQueries({ + // queryKey: bookClubs._def, + // }); + // }, onError: (_error, id, context) => { if (context) { diff --git a/src/api/book-club/react-query/likeOptimisticUpdate.ts b/src/api/book-club/react-query/likeOptimisticUpdate.ts index 86a0a536..dce81855 100644 --- a/src/api/book-club/react-query/likeOptimisticUpdate.ts +++ b/src/api/book-club/react-query/likeOptimisticUpdate.ts @@ -1,5 +1,5 @@ import { QueryClient } from '@tanstack/react-query'; -import { BookClub } from '@/types/bookclubs'; +import { BookClub, BookClubParams } from '@/types/bookclubs'; import { bookClubs } from './queries'; import { DEFAULT_FILTERS } from '@/lib/constants/filters'; @@ -7,39 +7,39 @@ export const likeOnMutate = async ( queryClient: QueryClient, id: number, isLiked: boolean, + filter?: BookClubParams, ) => { - const listQueryKey = bookClubs.list(DEFAULT_FILTERS).queryKey; + const listQueryKey = ['bookClubs', 'list', filter || DEFAULT_FILTERS]; const detailQueryKey = bookClubs.detail(id).queryKey; - const previousBookClubs = queryClient.getQueryData<{ - bookClubs: BookClub[]; - }>(listQueryKey); + await queryClient.cancelQueries({ queryKey: listQueryKey }); + await queryClient.cancelQueries({ queryKey: detailQueryKey }); + + // console.log('πŸ” μˆ˜μ •λœ listQueryKey:', listQueryKey); + // console.log('πŸ” ν˜„μž¬ ν™œμ„±ν™”λœ λͺ¨λ“  쿼리킀:', queryClient.getQueriesData({})); + const previousBookClubs = queryClient.getQueryData<{ bookClubs: BookClub[] }>( + listQueryKey, + ); const previousDetail = queryClient.getQueryData(detailQueryKey); - // λͺ©λ‘ μΊμ‹œ μ—…λ°μ΄νŠΈ + // if (!previousBookClubs) { + // console.warn('⚠️ μΊμ‹œλœ 데이터가 μ—†μŠ΅λ‹ˆλ‹€. queryKey 확인 ν•„μš”:', listQueryKey); + // queryClient.invalidateQueries({ queryKey: listQueryKey }); + // } + if (previousBookClubs) { - queryClient.setQueryData(listQueryKey, { - ...previousBookClubs, - bookClubs: previousBookClubs.bookClubs.map((club) => + queryClient.setQueryData(listQueryKey, (old: any) => + old?.map((club: BookClub) => club.id === id ? { ...club, isLiked } : club, ), - }); + ); } - // 상세 μΊμ‹œ μ—…λ°μ΄νŠΈ if (previousDetail) { - queryClient.setQueryData(detailQueryKey, { - ...previousDetail, - isLiked, - }); + queryClient.setQueryData(detailQueryKey, { ...previousDetail, isLiked }); } - //TODO: 둜직 확인 ν›„ λ³€κ²½ ν•„μš” - queryClient.invalidateQueries({ - queryKey: bookClubs._def, - }); - return { previousBookClubs, previousDetail }; }; diff --git a/src/app/bookclub/page.tsx b/src/app/bookclub/page.tsx index 91208bd1..8e0d78c2 100644 --- a/src/app/bookclub/page.tsx +++ b/src/app/bookclub/page.tsx @@ -1,5 +1,23 @@ import BookClubMainPage from '@/features/bookclub/components/BookClubMainPage'; +import { DEFAULT_FILTERS } from '@/lib/constants/filters'; +import { fetchBookClubs } from '@/lib/utils/fetchBookClubs'; +import { + dehydrate, + HydrationBoundary, + QueryClient, +} from '@tanstack/react-query'; -export default function Home() { - return ; +export default async function Home() { + const queryClient = new QueryClient(); + + await queryClient.prefetchQuery({ + queryKey: ['bookClubs', 'list', DEFAULT_FILTERS], + queryFn: () => fetchBookClubs(DEFAULT_FILTERS), + }); + + return ( + + + + ); } 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} + /> + +
); 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/app/page.tsx b/src/app/page.tsx index 91208bd1..8e0d78c2 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,5 +1,23 @@ import BookClubMainPage from '@/features/bookclub/components/BookClubMainPage'; +import { DEFAULT_FILTERS } from '@/lib/constants/filters'; +import { fetchBookClubs } from '@/lib/utils/fetchBookClubs'; +import { + dehydrate, + HydrationBoundary, + QueryClient, +} from '@tanstack/react-query'; -export default function Home() { - return ; +export default async function Home() { + const queryClient = new QueryClient(); + + await queryClient.prefetchQuery({ + queryKey: ['bookClubs', 'list', DEFAULT_FILTERS], + queryFn: () => fetchBookClubs(DEFAULT_FILTERS), + }); + + return ( + + + + ); } 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/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 (
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/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/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); 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(); + }); }); }); diff --git a/src/features/bookclub/api/bookclubApi.ts b/src/features/bookclub/api/bookclubApi.ts deleted file mode 100644 index ac7a7d9a..00000000 --- a/src/features/bookclub/api/bookclubApi.ts +++ /dev/null @@ -1,7 +0,0 @@ -import apiClient from '@/lib/utils/apiClient'; -import { BookClubParams } from '@/types/bookclubs'; - -export const getBookClubs = async (params?: BookClubParams) => { - const response = await apiClient.get('/book-clubs', { params }); - return response.data.bookClubs; -}; diff --git a/src/features/bookclub/components/BookClubMainPage.tsx b/src/features/bookclub/components/BookClubMainPage.tsx index f434bbdf..cee9b1f3 100644 --- a/src/features/bookclub/components/BookClubMainPage.tsx +++ b/src/features/bookclub/components/BookClubMainPage.tsx @@ -7,14 +7,19 @@ import ClubListSection from './ClubListSection'; import Button from '@/components/button/Button'; import { useRouter } from 'next/navigation'; import Loading from '@/components/loading/Loading'; +import { useQuery } from '@tanstack/react-query'; +import { fetchBookClubs } from '@/lib/utils/fetchBookClubs'; function BookClubMainPage() { - const { clubList, isLoading, filters, updateFilters } = useBookClubList(); + const { filters, updateFilters } = useBookClubList(); + const { data, isLoading, isFetching } = useQuery({ + queryKey: ['bookClubs', 'list', filters], + queryFn: () => fetchBookClubs(filters), + staleTime: 1000 * 30, + }); const router = useRouter(); - const user = useAuthStore((state) => state.user); - const userName = user?.nickname || '뢁코'; const handleFilterChange = (newFilter: Partial) => { @@ -43,12 +48,12 @@ function BookClubMainPage() { } /> - {isLoading ? ( + {isLoading || isFetching ? (
) : ( - + )} ); diff --git a/src/features/bookclub/components/ClubListSection.tsx b/src/features/bookclub/components/ClubListSection.tsx index 95b5231e..29bcd6ad 100644 --- a/src/features/bookclub/components/ClubListSection.tsx +++ b/src/features/bookclub/components/ClubListSection.tsx @@ -6,20 +6,23 @@ import { useRouter } from 'next/navigation'; import { useMemo } from 'react'; import EmptyState from '@/components/common-layout/EmptyState'; import { clubStatus } from '@/lib/utils/clubUtils'; -import { BookClub } from '@/types/bookclubs'; +import { BookClub, BookClubParams } from '@/types/bookclubs'; import { useLikeClub, useUnLikeClub } from '@/lib/hooks'; interface ClubListSectionProps { bookClubs: BookClub[]; + filter: BookClubParams; } -function ClubListSection({ bookClubs = [] }: ClubListSectionProps) { +function ClubListSection({ bookClubs = [], filter }: ClubListSectionProps) { const router = useRouter(); - const { onConfirmUnLike } = useUnLikeClub(); - const { onConfirmLike } = useLikeClub(); + const { onConfirmUnLike } = useUnLikeClub(filter); + const { onConfirmLike } = useLikeClub(filter); const today = useMemo(() => new Date(), []); + // console.log('πŸ” ClubListSection 데이터:', bookClubs); + const handleLikeClub = (isLiked: boolean, id: number) => { isLiked ? onConfirmUnLike(id) : onConfirmLike(id); }; diff --git a/src/features/bookclub/hooks/useFetchBookClubList.test.tsx b/src/features/bookclub/hooks/useFetchBookClubList.test.tsx new file mode 100644 index 00000000..9c7d45bb --- /dev/null +++ b/src/features/bookclub/hooks/useFetchBookClubList.test.tsx @@ -0,0 +1,34 @@ +import { renderHook, act } from '@testing-library/react'; +import useBookClubList from '@/features/bookclub/hooks/useFetchBookClubList'; +import { DEFAULT_FILTERS } from '@/lib/constants/filters'; + +describe('useBookClubList', () => { + it('초기 ν•„ν„° μƒνƒœλŠ” DEFAULT_FILTERS와 동일해야 ν•œλ‹€', () => { + const { result } = renderHook(() => useBookClubList()); + + expect(result.current.filters).toEqual(DEFAULT_FILTERS); + }); + + it('updateFiltersλ₯Ό ν˜ΈμΆœν•˜λ©΄ ν•„ν„° μƒνƒœκ°€ μ—…λ°μ΄νŠΈλ˜μ–΄μ•Ό ν•œλ‹€', () => { + const { result } = renderHook(() => useBookClubList()); + + act(() => { + result.current.updateFilters({ meetingType: 'ONLINE' }); + }); + + expect(result.current.filters.meetingType).toBe('ONLINE'); + }); + + it('updateFiltersλŠ” κΈ°μ‘΄ ν•„ν„° μƒνƒœλ₯Ό μœ μ§€ν•˜λ©΄μ„œ μƒˆλ‘œμš΄ ν•„ν„° 값을 λ°˜μ˜ν•΄μ•Ό ν•œλ‹€', () => { + const { result } = renderHook(() => useBookClubList()); + + act(() => { + result.current.updateFilters({ location: 'μ„œμšΈ' }); + }); + + expect(result.current.filters.location).toBe('μ„œμšΈ'); + expect(result.current.filters.meetingType).toBe( + DEFAULT_FILTERS.meetingType, + ); + }); +}); diff --git a/src/features/bookclub/hooks/useFetchBookClubList.ts b/src/features/bookclub/hooks/useFetchBookClubList.ts index 2b70ca45..aa2940da 100644 --- a/src/features/bookclub/hooks/useFetchBookClubList.ts +++ b/src/features/bookclub/hooks/useFetchBookClubList.ts @@ -1,26 +1,15 @@ import { useState } from 'react'; import { BookClubParams } from '@/types/bookclubs'; -import { useQuery } from '@tanstack/react-query'; -import { bookClubs } from '@/api/book-club/react-query'; import { DEFAULT_FILTERS } from '@/lib/constants/filters'; const useBookClubList = () => { const [filters, setFilters] = useState(DEFAULT_FILTERS); - const { data, isLoading, error } = useQuery({ - ...bookClubs.list(filters), - }); - - const clubList = data?.bookClubs; - const updateFilters = (newFilters: Partial) => { setFilters((prevFilters) => ({ ...prevFilters, ...newFilters })); }; return { - clubList, - isLoading, - error, filters, updateFilters, }; 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', }, 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..353278ec 100644 --- a/src/lib/hooks/useLikeClub.ts +++ b/src/lib/hooks/useLikeClub.ts @@ -1,14 +1,17 @@ import { useLikeBookClub } from '@/api/book-club/react-query'; import { showToast } from '@/components/toast/toast'; +import { TOAST_MESSAGES } from '@/constants/messages/toast'; +import { BookClubParams } from '@/types/bookclubs'; +import { DEFAULT_FILTERS } from '../constants/filters'; -export const useLikeClub = () => { - const { mutate: likeClub } = useLikeBookClub(); +export const useLikeClub = (filter?: BookClubParams) => { + const { mutate: likeClub } = useLikeBookClub(filter || DEFAULT_FILTERS); const onConfirmLike = (selectedClubId: number) => { likeClub(selectedClubId, { onSuccess: () => { showToast({ - message: '찜 μ™„λ£Œ! μ°œν•œ λͺ¨μž„은 찜 λͺ©λ‘ νŽ˜μ΄μ§€μ—μ„œ ν™•μΈν•˜μ„Έμš”', + message: TOAST_MESSAGES.SUCCESS.CLUB_LIKE, type: 'success', }); }, @@ -23,7 +26,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..00bbda19 100644 --- a/src/lib/hooks/useUnLikeClub.ts +++ b/src/lib/hooks/useUnLikeClub.ts @@ -1,14 +1,17 @@ import { useUnLikeBookClub } from '@/api/book-club/react-query'; import { showToast } from '@/components/toast/toast'; +import { TOAST_MESSAGES } from '@/constants/messages/toast'; +import { BookClubParams } from '@/types/bookclubs'; +import { DEFAULT_FILTERS } from '../constants/filters'; -export const useUnLikeClub = () => { - const { mutate: unLikeClub } = useUnLikeBookClub(); +export const useUnLikeClub = (filter?: BookClubParams) => { + const { mutate: unLikeClub } = useUnLikeBookClub(filter || DEFAULT_FILTERS); const onConfirmUnLike = (selectedClubId: number) => { unLikeClub(selectedClubId, { onSuccess: () => { showToast({ - message: '찜이 μ·¨μ†Œλ˜μ—ˆμŠ΅λ‹ˆλ‹€', + message: TOAST_MESSAGES.SUCCESS.CLUB_UNLIKE, type: 'success', }); }, @@ -23,7 +26,7 @@ export const useUnLikeClub = () => { message: error instanceof Error ? error.message - : 'μ•Œ 수 μ—†λŠ” 였λ₯˜κ°€ λ°œμƒν–ˆμŠ΅λ‹ˆλ‹€. λ‹€μ‹œ μ‹œλ„ν•΄μ£Όμ„Έμš”.', + : TOAST_MESSAGES.ERROR.UNKNOWN, type: 'error', }); } diff --git a/src/lib/utils/fetchBookClub.test.ts b/src/lib/utils/fetchBookClub.test.ts new file mode 100644 index 00000000..b0f32e0e --- /dev/null +++ b/src/lib/utils/fetchBookClub.test.ts @@ -0,0 +1,55 @@ +import { mockBookClubs } from '@/mocks/mockDatas'; +import { fetchBookClubs } from './fetchBookClubs'; +import { DEFAULT_FILTERS } from '@/lib/constants/filters'; + +describe('fetchBookClubs', () => { + beforeEach(() => { + global.fetch = jest.fn(); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + it('μš”μ²­ 성곡 μ‹œ bookClubsλ₯Ό λ°˜ν™˜ν•΄μ•Ό ν•œλ‹€', async () => { + (global.fetch as jest.Mock).mockResolvedValue({ + ok: true, + json: jest.fn().mockResolvedValue({ bookClubs: mockBookClubs }), + }); + + const result = await fetchBookClubs(DEFAULT_FILTERS); + + expect(fetch).toHaveBeenCalledTimes(1); + expect(fetch).toHaveBeenCalledWith( + expect.stringContaining(`${process.env.NEXT_PUBLIC_API_URL}/book-clubs?`), + { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + }, + }, + ); + expect(result).toEqual(mockBookClubs); + }); + + it('HTTP μ—λŸ¬ λ°œμƒ μ‹œ 빈 배열을 λ°˜ν™˜ν•΄μ•Ό ν•œλ‹€', async () => { + (global.fetch as jest.Mock).mockResolvedValue({ + ok: false, + status: 500, + }); + + const result = await fetchBookClubs(DEFAULT_FILTERS); + + expect(fetch).toHaveBeenCalledTimes(1); + expect(result).toEqual([]); + }); + + it('fetch 호좜 쀑 μ—λŸ¬ λ°œμƒ μ‹œ 빈 배열을 λ°˜ν™˜ν•΄μ•Ό ν•œλ‹€', async () => { + (global.fetch as jest.Mock).mockRejectedValue(new Error('Network Error')); + + const result = await fetchBookClubs(DEFAULT_FILTERS); + + expect(fetch).toHaveBeenCalledTimes(1); + expect(result).toEqual([]); + }); +}); diff --git a/src/lib/utils/fetchBookClubs.ts b/src/lib/utils/fetchBookClubs.ts new file mode 100644 index 00000000..e19d6c46 --- /dev/null +++ b/src/lib/utils/fetchBookClubs.ts @@ -0,0 +1,34 @@ +import { BookClubParams } from '@/types/bookclubs'; + +export async function fetchBookClubs(filters: BookClubParams) { + try { + // filters 객체λ₯Ό URLSearchParams둜 λ³€ν™˜ + const queryParams = new URLSearchParams( + Object.entries(filters) + .filter(([, value]) => value !== undefined && value !== null) + .map(([key, value]) => [key, String(value)]), + ).toString(); + + const res = await fetch( + `${process.env.NEXT_PUBLIC_API_URL}/book-clubs?${queryParams}`, + { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + }, + }, + ); + + if (!res.ok) { + throw new Error(`HTTP error! status: ${res.status}`); + } + + const response = await res.json(); + return response.bookClubs; + } catch (error) { + if (process.env.NODE_ENV === 'development') { + console.error('Error:', error); // 개발 ν™˜κ²½μ—μ„œλ§Œ 둜그 좜λ ₯ + } + return []; + } +} diff --git a/src/types/bookclubs.ts b/src/types/bookclubs.ts index 81b9c090..e043a785 100644 --- a/src/types/bookclubs.ts +++ b/src/types/bookclubs.ts @@ -42,7 +42,7 @@ export interface BookClub { averageScore?: number; - clubStatus: 'pending' | 'confirmed' | 'closed'; // TODO: λ‚΄κ°€ λ§Œλ“  λͺ¨μž„μ—μ„œ 'λͺ¨μž„ μ™„λ£Œ' μƒνƒœ μΆ”κ°€ + clubStatus?: 'pending' | 'confirmed' | 'closed'; // TODO: λ‚΄κ°€ λ§Œλ“  λͺ¨μž„μ—μ„œ 'λͺ¨μž„ μ™„λ£Œ' μƒνƒœ μΆ”κ°€ isLiked: boolean; isInactive: boolean; isJoined: boolean;