diff --git a/src/pages/common/ErrorPage/ErrorPage.styles.ts b/src/pages/common/ErrorPage/ErrorPage.styles.ts new file mode 100644 index 0000000..d1fe6fb --- /dev/null +++ b/src/pages/common/ErrorPage/ErrorPage.styles.ts @@ -0,0 +1,57 @@ +import styled from '@emotion/styled' + +import { theme } from '@/shared/styles/theme' + +export const Container = styled.div` + min-height: 100vh; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 16px; + padding: 40px 20px; + text-align: center; + background: ${theme.colors.background}; + color: ${theme.colors.textPrimary}; +` + +export const Title = styled.h1` + font-size: 28px; + font-weight: 700; + margin: 0; +` + +export const Description = styled.p` + font-size: 16px; + color: ${theme.colors.textColor3}; + margin: 0; +` + +export const ButtonRow = styled.div` + display: flex; + gap: 12px; + margin-top: 8px; +` + +const BaseButton = styled.button` + border: 0; + border-radius: 10px; + padding: 10px 16px; + font-size: 14px; + cursor: pointer; + + &:focus-visible { + outline: 2px solid ${theme.colors.primary2}; + outline-offset: 2px; + } +` + +export const PrimaryButton = styled(BaseButton)` + background: ${theme.colors.primary}; + color: ${theme.colors.white}; +` + +export const SecondaryButton = styled(BaseButton)` + background: ${theme.colors.lightGray}; + color: ${theme.colors.textPrimary}; +` diff --git a/src/pages/common/ErrorPage/ErrorPage.tsx b/src/pages/common/ErrorPage/ErrorPage.tsx new file mode 100644 index 0000000..4d06488 --- /dev/null +++ b/src/pages/common/ErrorPage/ErrorPage.tsx @@ -0,0 +1,25 @@ +import { useNavigate, useRouteError } from 'react-router-dom' + +import * as S from './ErrorPage.styles' + +export default function ErrorPage() { + const navigate = useNavigate() + const error = useRouteError() as { status?: number; statusText?: string; message?: string } | null + + const description = error?.statusText || error?.message || '요청하신 페이지를 찾을 수 없어요.' + + return ( + + 문제가 발생했어요 + {description} + + navigate(-1)}> + 뒤로가기 + + navigate('/')}> + 홈으로 + + + + ) +} diff --git a/src/routes/Router.tsx b/src/routes/Router.tsx index ae2b6d9..b1fe29d 100644 --- a/src/routes/Router.tsx +++ b/src/routes/Router.tsx @@ -1,6 +1,15 @@ import { createBrowserRouter } from 'react-router-dom' +import ErrorPage from '@/pages/common/ErrorPage/ErrorPage' + import AuthRoutes from './AuthRoutes' import MainRoutes from './MainRoutes' -export const router = createBrowserRouter([AuthRoutes, MainRoutes]) +export const router = createBrowserRouter([ + AuthRoutes, + MainRoutes, + { + path: '*', + element: , + }, +]) diff --git a/src/shared/api/axios.ts b/src/shared/api/axios.ts new file mode 100644 index 0000000..c9d3e8c --- /dev/null +++ b/src/shared/api/axios.ts @@ -0,0 +1,19 @@ +import axios from 'axios' + +export const axiosInstance = axios.create({ + baseURL: import.meta.env.VITE_SERVER_URL, + headers: { + 'Content-Type': 'application/json', + }, + withCredentials: true, +}) + +//TODO: @yujin5959 나중에 이부분 해주세용~ +axiosInstance.interceptors.response.use( + (response) => response, + (error) => { + return Promise.reject(error) + }, +) + +export default axiosInstance diff --git a/src/shared/hooks/customQuery.ts b/src/shared/hooks/customQuery.ts new file mode 100644 index 0000000..4b0c1a1 --- /dev/null +++ b/src/shared/hooks/customQuery.ts @@ -0,0 +1,142 @@ +/* eslint-disable @typescript-eslint/no-unused-vars */ +import type { + InfiniteData, + MutationFunction, + QueryFunction, + QueryKey, + UseInfiniteQueryOptions, + UseMutationOptions, + UseQueryOptions, + UseSuspenseQueryOptions, +} from '@tanstack/react-query' +import { useInfiniteQuery, useMutation, useQuery, useSuspenseQuery } from '@tanstack/react-query' + +type DefaultQueryOptions = { + staleTime?: number + retry?: number +} + +const STALE_TIME = 1000 * 60 * 3 +const RETRY = 1 +const MUTATION_RETRY = 1 + +// 기본적인 쿼리 흐름을 정리한 커스텀 훅들: +// 1) useCustomQuery: 일반 `useQuery` 래퍼로, 스테일 시간·재시도 횟수·포커스 시 리패치 제한을 기본값으로 잡아줍니다. +// 최초 키/펑션 이외 값을 옵션에 넘겨도 기본 설정을 덮어쓰지 않고 안전하게 적용합니다. +// 2) useCustomInfiniteQuery: 페이지 단위 API를 위한 `useInfiniteQuery` 래퍼로, 위와 동일한 기본값에 더해 `keepPreviousData` 없이 연속 페이지를 유지합니다. + +export const useCustomQuery = < + TQueryFnData, + TError, + TData = TQueryFnData, + TQueryKey extends QueryKey = QueryKey, +>( + queryKey: TQueryKey, + queryFn: QueryFunction, + options?: Omit, 'queryKey' | 'queryFn'> & + DefaultQueryOptions, +) => { + const safeOptions = + options ?? + ({} as Omit, 'queryKey' | 'queryFn'> & + DefaultQueryOptions) + + const { + staleTime, + retry, + // refetchOnWindowFocus: _refetchOnWindowFocus, // 제거: 사용되지 않음 + ...restOptions + } = safeOptions + + return useQuery({ + queryKey, + queryFn, + staleTime: staleTime ?? STALE_TIME, + retry: retry ?? RETRY, + refetchOnWindowFocus: false, + ...restOptions, + }) +} + +export const useCustomSuspenseQuery = < + TQueryFnData, + TError extends Error = Error, + TData = TQueryFnData, + TQueryKey extends QueryKey = QueryKey, +>( + queryKey: TQueryKey, + queryFn: QueryFunction, + options?: Omit< + UseSuspenseQueryOptions, + 'queryKey' | 'queryFn' + > & + DefaultQueryOptions, +) => { + const safeOptions = + options ?? + ({} as Omit< + UseSuspenseQueryOptions, + 'queryKey' | 'queryFn' + > & + DefaultQueryOptions) + + const { + staleTime, + retry, + // refetchOnWindowFocus: _refetchOnWindowFocus, // 제거: 사용되지 않음 + ...restOptions + } = safeOptions + + return useSuspenseQuery({ + queryKey, + queryFn, + staleTime: staleTime ?? STALE_TIME, + retry: retry ?? RETRY, + refetchOnWindowFocus: false, + ...restOptions, + }) +} + +export const useCustomInfiniteQuery = < + TQueryFnData, + TError, + TData = InfiniteData, + TQueryKey extends QueryKey = QueryKey, + TPageParam = unknown, +>( + queryKey: TQueryKey, + queryFn: QueryFunction, + infiniteOptions?: UseInfiniteQueryOptions & + DefaultQueryOptions, +) => { + const safeOptions = + infiniteOptions ?? + ({} as UseInfiniteQueryOptions & + DefaultQueryOptions) + + const { staleTime, retry, queryKey: _queryKey, queryFn: _queryFn, ...restOptions } = safeOptions + + return useInfiniteQuery({ + queryKey, + queryFn, + staleTime: staleTime ?? STALE_TIME, + retry: retry ?? RETRY, + refetchOnWindowFocus: false, + ...restOptions, + }) +} + +export const useCustomMutation = ( + mutationFn: MutationFunction, + options?: UseMutationOptions & DefaultQueryOptions, +) => { + const safeOptions = + options ?? ({} as UseMutationOptions & DefaultQueryOptions) + const { retry, ...restOptions } = safeOptions + + return useMutation({ + mutationFn, + retry: retry ?? MUTATION_RETRY, + ...restOptions, + }) +} diff --git a/src/shared/ui/common/AsyncBoundary/AsyncBoundary.tsx b/src/shared/ui/common/AsyncBoundary/AsyncBoundary.tsx new file mode 100644 index 0000000..1efd9e1 --- /dev/null +++ b/src/shared/ui/common/AsyncBoundary/AsyncBoundary.tsx @@ -0,0 +1,78 @@ +import type { ErrorInfo, ReactNode } from 'react' +import { Component, Suspense } from 'react' +import { useLocation } from 'react-router-dom' + +import AsyncBoundaryFallback from './AsyncBoundaryFallback' + +type AsyncBoundaryProps = { + children: ReactNode + fallback: ReactNode + errorFallback?: (error: Error, reset: () => void) => ReactNode + resetKeys?: Array +} + +type AsyncBoundaryState = { + error?: Error +} + +class ErrorBoundary extends Component< + { + children: ReactNode + fallback?: (error: Error, reset: () => void) => ReactNode + resetKeys?: Array + }, + AsyncBoundaryState +> { + state: AsyncBoundaryState = {} + + static getDerivedStateFromError(error: Error) { + return { error } + } + + componentDidCatch(error: Error, info: ErrorInfo) { + console.error(error, info) + } + + componentDidUpdate(prevProps: Readonly<{ resetKeys?: Array }>) { + const { error } = this.state + const { resetKeys } = this.props + if (!error || !resetKeys || !prevProps.resetKeys) return + + const hasResetKeyChanged = + resetKeys.length !== prevProps.resetKeys.length || + resetKeys.some((key, index) => !Object.is(key, prevProps.resetKeys?.[index])) + + if (hasResetKeyChanged) { + this.resetErrorBoundary() + } + } + + resetErrorBoundary = () => { + this.setState({ error: undefined }) + } + + render() { + const { error } = this.state + if (error) { + const { fallback } = this.props + if (fallback) { + return fallback(error, this.resetErrorBoundary) + } + return + } + return this.props.children + } +} + +const AsyncBoundary = ({ children, fallback, errorFallback, resetKeys }: AsyncBoundaryProps) => { + const location = useLocation() + const derivedResetKeys = resetKeys ?? [location.pathname, location.search, location.hash] + + return ( + + {children} + + ) +} + +export default AsyncBoundary diff --git a/src/shared/ui/common/AsyncBoundary/AsyncBoundaryFallback.styles.ts b/src/shared/ui/common/AsyncBoundary/AsyncBoundaryFallback.styles.ts new file mode 100644 index 0000000..11084c5 --- /dev/null +++ b/src/shared/ui/common/AsyncBoundary/AsyncBoundaryFallback.styles.ts @@ -0,0 +1,53 @@ +import styled from '@emotion/styled' + +import { theme } from '@/shared/styles/theme' + +export const Container = styled.div` + min-height: 240px; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 10px; + padding: 24px; + border-radius: 16px; + background: ${theme.colors.white}; + color: ${theme.colors.textPrimary}; + box-shadow: 0 0 10px 0 rgba(0, 0, 0, 0.08); + text-align: center; +` + +export const Title = styled.h2` + margin: 0; + font-size: 20px; + font-weight: 700; +` + +export const Description = styled.p` + margin: 0; + color: ${theme.colors.textColor3}; + font-size: 14px; +` + +export const ErrorMessage = styled.p` + margin: 0; + color: ${theme.colors.textColor2}; + font-size: 12px; + opacity: 0.8; +` + +export const Button = styled.button` + margin-top: 6px; + border: 0; + border-radius: 10px; + padding: 10px 16px; + font-size: 14px; + cursor: pointer; + background: ${theme.colors.primary}; + color: ${theme.colors.white}; + + &:focus-visible { + outline: 2px solid ${theme.colors.primary}; + outline-offset: 2px; + } +` diff --git a/src/shared/ui/common/AsyncBoundary/AsyncBoundaryFallback.tsx b/src/shared/ui/common/AsyncBoundary/AsyncBoundaryFallback.tsx new file mode 100644 index 0000000..7bb771d --- /dev/null +++ b/src/shared/ui/common/AsyncBoundary/AsyncBoundaryFallback.tsx @@ -0,0 +1,28 @@ +import type { ReactNode } from 'react' + +import * as S from './AsyncBoundaryFallback.styles' + +type AsyncBoundaryFallbackProps = { + error: Error + onReset: () => void + title?: ReactNode + description?: ReactNode +} + +const AsyncBoundaryFallback = ({ + error, + onReset, + title = '문제가 발생했어요', + description, +}: AsyncBoundaryFallbackProps) => ( + + {title} + {description ?? '요청을 처리하는 중 오류가 발생했어요.'} + {error.message} + + 다시 시도 + + +) + +export default AsyncBoundaryFallback diff --git a/src/shared/ui/common/AsyncBoundary/index.ts b/src/shared/ui/common/AsyncBoundary/index.ts new file mode 100644 index 0000000..a2e4e24 --- /dev/null +++ b/src/shared/ui/common/AsyncBoundary/index.ts @@ -0,0 +1,2 @@ +export { default as AsyncBoundary } from './AsyncBoundary' +export { default as AsyncBoundaryFallback } from './AsyncBoundaryFallback' diff --git a/src/shared/ui/common/SuspenseFallback/SuspenseFallback.tsx b/src/shared/ui/common/SuspenseFallback/SuspenseFallback.tsx new file mode 100644 index 0000000..98c57f4 --- /dev/null +++ b/src/shared/ui/common/SuspenseFallback/SuspenseFallback.tsx @@ -0,0 +1,47 @@ +import type { CSSProperties, HTMLAttributes, ReactNode } from 'react' +import { useMemo } from 'react' + +type SuspenseFallbackProps = { + label?: string + gap?: number + children?: ReactNode +} & HTMLAttributes + +//TODO: 스타일 추가 필요 +const SuspenseFallback = ({ + label = '로딩 중입니다', + gap = 8, + children, + style, + ...rest +}: SuspenseFallbackProps) => { + const combinedStyle: CSSProperties = useMemo( + () => ({ + display: 'flex', + flexDirection: 'column', + gap, + ...style, + }), + [gap, style], + ) + + const spinnerStyle: CSSProperties = { + width: 16, + height: 16, + borderRadius: '50%', + border: '2px solid #ccc', + borderTopColor: '#555', + } + + return ( +
+
+ +
{children}
+
+ ) +} + +export default SuspenseFallback diff --git a/src/shared/ui/common/SuspenseFallback/index.ts b/src/shared/ui/common/SuspenseFallback/index.ts new file mode 100644 index 0000000..aa441e8 --- /dev/null +++ b/src/shared/ui/common/SuspenseFallback/index.ts @@ -0,0 +1 @@ +export { default } from './SuspenseFallback'