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..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/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/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 00000000..328b5896 Binary files /dev/null and b/public/images/errorImage.png differ 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/app/bookclub/create/error.tsx b/src/app/bookclub/create/error.tsx new file mode 100644 index 00000000..b6e198e8 --- /dev/null +++ b/src/app/bookclub/create/error.tsx @@ -0,0 +1,20 @@ +'use client'; + +import ErrorTemplate from '@/components/error/ErrorTemplate'; + +export default function BookClubCreateError({ + error, + reset, +}: { + error: Error & { digest?: string }; + reset: () => void; +}) { + 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/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/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/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/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); 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/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/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/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/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/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() {
) : ( - +
+ +
)} ); 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/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/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} 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({ 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/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/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/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/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/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/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, }; }; 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', }); } 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 μ—λŸ¬ μ—λŸ¬ λ°”μš΄λ”λ¦¬λ‘œ λ˜μ Έμ€„μ§€ κ³ λ―Ό }, }, });