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/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/global-error.tsx b/src/app/global-error.tsx new file mode 100644 index 00000000..14da1ffe --- /dev/null +++ b/src/app/global-error.tsx @@ -0,0 +1,42 @@ +'use client'; + +import Button from '@/components/button/Button'; +import Image from 'next/image'; + +export default function GlobalError({ + error, + reset, +}: { + error: Error & { digest?: string }; + reset: () => void; +}) { + return ( + + + +
+ 에러 이미지 +

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

+

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

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

페이지를 찾을 수 없습니다

+

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

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

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

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

{title}

+

{message || error.message}

+ + + + {children} +
+ ); +} diff --git a/src/features/profile/components/info/Info.test.tsx b/src/features/profile/components/info/Info.test.tsx index 89457776..aa7f8225 100644 --- a/src/features/profile/components/info/Info.test.tsx +++ b/src/features/profile/components/info/Info.test.tsx @@ -67,38 +67,38 @@ describe('Info 테스트', () => { it("수정하기 모달에서 닉네임을 입력하지 않고 수정하기 버튼 클릭 시 '닉네임을 입력해주세요' 팝업창 렌더링 확인", () => {}); - it('수정하기 모달에서 수정 후 수정하기 버튼 클릭 시 onSubmitEditInfo 함수 호출 확인', async () => { - render( - - - , - ); - - const editButton = screen.getByLabelText('프로필 수정'); - await userEvent.click(editButton); - - //닉네임 수정 - const nameInput = screen.getByRole('textbox', { name: 'nickname' }); - await userEvent.clear(nameInput); - await userEvent.type(nameInput, 'Edited Name'); - - //한 줄 소개 수정 - const descriptionInput = screen.getByRole('textbox', { - name: 'description', - }); - await userEvent.clear(descriptionInput); - await userEvent.type(descriptionInput, 'Edited Description'); - - //수정하기 버튼 클릭 - const confirmButton = screen.getByText('수정하기'); - await userEvent.click(confirmButton); - - //TODO:함수 호출 확인 - - // expect(mockSubmit).toHaveBeenCalledTimes(1); - // expect(mockSubmit).toHaveBeenCalledWith({ - // name: 'Edited Name', - // description: 'Edited Description', - // }); - }); + // it('수정하기 모달에서 수정 후 수정하기 버튼 클릭 시 onSubmitEditInfo 함수 호출 확인', async () => { + // render( + // + // + // , + // ); + + // const editButton = screen.getByLabelText('프로필 수정'); + // await userEvent.click(editButton); + + // //닉네임 수정 + // const nameInput = screen.getByRole('textbox', { name: 'nickname' }); + // await userEvent.clear(nameInput); + // await userEvent.type(nameInput, 'Edited Name'); + + // //한 줄 소개 수정 + // const descriptionInput = screen.getByRole('textbox', { + // name: 'description', + // }); + // await userEvent.clear(descriptionInput); + // await userEvent.type(descriptionInput, 'Edited Description'); + + // //수정하기 버튼 클릭 + // const confirmButton = screen.getByText('수정하기'); + // await userEvent.click(confirmButton); + + // //TODO:함수 호출 확인 + + // // expect(mockSubmit).toHaveBeenCalledTimes(1); + // // expect(mockSubmit).toHaveBeenCalledWith({ + // // name: 'Edited Name', + // // description: 'Edited Description', + // // }); + // }); }); diff --git a/src/features/profile/container/ClubContents.tsx b/src/features/profile/container/ClubContents.tsx index 689bd1ef..20b2fadb 100644 --- a/src/features/profile/container/ClubContents.tsx +++ b/src/features/profile/container/ClubContents.tsx @@ -13,6 +13,9 @@ import { MyWrittenReviewList, WrittenReviewList, } from '../container/index'; +import ErrorHandlingWrapper from '@/components/error/ErrorHandlingWrapper'; +import ErrorFallback from '@/components/error/ErrorFallback'; +import Loading from '@/components/loading/Loading'; export default function ClubContents({ isMyPage }: ProfilePageProps) { const [order, setOrder] = useState('DESC'); @@ -62,7 +65,14 @@ export default function ClubContents({ isMyPage }: ProfilePageProps) { /> -
{renderList(selectedList)}
+
+ } + > + {renderList(selectedList)} + +
); } diff --git a/src/lib/hooks/useGetUserByPath.ts b/src/lib/hooks/useGetUserByPath.ts index 4130acf0..caf30db4 100644 --- a/src/lib/hooks/useGetUserByPath.ts +++ b/src/lib/hooks/useGetUserByPath.ts @@ -4,15 +4,14 @@ import { usePathname } from 'next/navigation'; export function useGetUserByPath() { const pathname = usePathname(); - const userId = Number(pathname?.split('/')[2]); + const userId = pathname?.split('/')[2]; + + const isValidUserId: boolean = Boolean(userId && !isNaN(Number(userId))); - const { queryKey, queryFn } = users.userInfo(userId); const { data } = useQuery({ - queryKey, - queryFn, + ...users.userInfo(Number(userId)), + enabled: isValidUserId, }); - const user = data?.data; - - return user; + return data?.data; } diff --git a/src/lib/utils/reactQueryProvider.tsx b/src/lib/utils/reactQueryProvider.tsx index 28efda55..97aefc35 100644 --- a/src/lib/utils/reactQueryProvider.tsx +++ b/src/lib/utils/reactQueryProvider.tsx @@ -1,17 +1,47 @@ 'use client'; -import { QueryClientProvider, QueryClient } from '@tanstack/react-query'; +import { + QueryClientProvider, + QueryClient, + QueryCache, + MutationCache, +} from '@tanstack/react-query'; import { ReactQueryDevtools } from '@tanstack/react-query-devtools'; +import { showToast } from '@/components/toast/toast'; export const queryClient = new QueryClient({ + queryCache: new QueryCache({ + onError: (error: Error) => { + console.error('Query Error:', error); + showToast({ + message: '데이터를 조회하는 중 에러가 발생했습니다', + type: 'error', + }); + }, + }), + mutationCache: new MutationCache({ + onError: (error: Error, _, __, mutation) => { + if (!mutation.options.onError) { + console.error('Mutation Error:', error); + showToast({ + message: '요청 처리 중 오류가 발생했습니다', + type: 'error', + }); + } + }, + }), defaultOptions: { queries: { refetchOnWindowFocus: false, // 윈도우가 다시 포커스될 때 데이터를 다시 가져올지 여부 refetchOnMount: true, // 컴포넌트가 마운트될 때 데이터를 다시 가져올지 여부 retry: 0, // 실패한 쿼리 재시도 횟수 refetchOnReconnect: false, // 네트워크 재연결시 데이터를 다시 가져올지 여부 - retryOnMount: false, // 마운트 시 실패한 쿼리 재시도 여부 + // retryOnMount: false, // 마운트 시 실패한 쿼리 재시도 여부 staleTime: 1000 * 60 * 5, // 데이터가 'fresh'한 상태로 유지되는 시간 (5분) gcTime: 1000 * 60 * 10, // 사용하지 않는 캐시 데이터가 메모리에서 제거되기까지의 시간 (10분) + throwOnError: true, + }, + mutations: { + throwOnError: false, // TODO: mutation 에러 에러 바운더리로 던져줄지 고민 }, }, });