diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index eb0b615f2..bf29ada12 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,4 +1,4 @@ -import { lazy, Suspense } from 'react'; +import { lazy } from 'react'; import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { ThemeProvider } from 'styled-components'; @@ -15,7 +15,9 @@ import ApplicationFormPage from './pages/ApplicationFormPage/ApplicationFormPage import ClubUnionPage from './pages/ClubUnionPage/ClubUnionPage'; import IntroducePage from './pages/IntroducePage/IntroducePage'; import 'swiper/css'; +import { GlobalBoundary } from './components/common/ErrorBoundary'; import LegacyClubDetailPage from './pages/ClubDetailPage/LegacyClubDetailPage'; +import ErrorTestPage from './pages/ErrorTestPage/ErrorTestPage'; const queryClient = new QueryClient({ defaultOptions: { @@ -33,70 +35,51 @@ const AdminRoutes = lazy(() => import('@/pages/AdminPage/AdminRoutes')); const App = () => { return ( - - - - - - - - - - - } - /> - {/*기존 웹 & 안드로이드 url (android: v1.1.0)*/} - - - - } - /> - {/*웹 유저에게 신규 상세페이지 보유주기 위한 임시 url*/} - - - - } - /> - {/*새로 빌드해서 배포할 앱 주소 url*/} - - - - } - /> - } /> - } /> - - - - - - } - /> - } - /> - } /> - } /> - - - - + + + + + + + + + } /> + {/*기존 웹 & 안드로이드 url (android: v1.1.0)*/} + } /> + {/*웹 유저에게 신규 상세페이지 보유주기 위한 임시 url*/} + } /> + {/*새로 빌드해서 배포할 앱 주소 url*/} + } + /> + } /> + } /> + + + + + + } + /> + } + /> + } /> + {/* 개발 환경에서만 사용 가능한 에러 테스트 페이지 */} + {import.meta.env.DEV && ( + } /> + )} + } /> + + + + + ); }; diff --git a/frontend/src/components/common/ErrorBoundary/GlobalBoundary.tsx b/frontend/src/components/common/ErrorBoundary/GlobalBoundary.tsx new file mode 100644 index 000000000..6bf44c5b6 --- /dev/null +++ b/frontend/src/components/common/ErrorBoundary/GlobalBoundary.tsx @@ -0,0 +1,25 @@ +import { ReactNode, Suspense } from 'react'; +import * as Sentry from '@sentry/react'; +import Spinner from '../Spinner/Spinner'; +import GlobalErrorFallback from './GlobalErrorFallback'; + +interface GlobalBoundaryProps { + children: ReactNode; +} + +const GlobalBoundary = ({ children }: GlobalBoundaryProps) => { + return ( + ( + + )} + > + }>{children} + + ); +}; + +export default GlobalBoundary; diff --git a/frontend/src/components/common/ErrorBoundary/GlobalErrorFallback.styles.ts b/frontend/src/components/common/ErrorBoundary/GlobalErrorFallback.styles.ts new file mode 100644 index 000000000..2845c4618 --- /dev/null +++ b/frontend/src/components/common/ErrorBoundary/GlobalErrorFallback.styles.ts @@ -0,0 +1,129 @@ +import styled from 'styled-components'; + +export const Container = styled.div` + min-height: 100vh; + display: flex; + align-items: center; + justify-content: center; + padding: 20px; + background: linear-gradient(135deg, #fff5f0 0%, #ffffff 100%); +`; + +export const Content = styled.div` + max-width: 600px; + width: 100%; + text-align: center; + background: white; + border-radius: 16px; + padding: 48px 32px; + box-shadow: 0 4px 24px rgba(0, 0, 0, 0.08); +`; + +export const IconWrapper = styled.div` + margin-bottom: 24px; + color: #ff5414; + display: flex; + justify-content: center; + + svg { + width: 64px; + height: 64px; + } +`; + +export const Title = styled.h1` + font-size: 24px; + font-weight: 700; + color: #111111; + margin-bottom: 16px; + line-height: 1.4; +`; + +export const Message = styled.p` + font-size: 16px; + font-weight: 500; + color: #787878; + line-height: 1.6; + margin-bottom: 32px; +`; + +export const ErrorDetails = styled.div` + background: #f5f5f5; + border: 1px solid #ebebeb; + border-radius: 8px; + padding: 16px; + margin-bottom: 32px; + text-align: left; + max-height: 300px; + overflow-y: auto; +`; + +export const ErrorDetailsTitle = styled.div` + font-size: 12px; + font-weight: 600; + color: #989898; + margin-bottom: 12px; + text-transform: uppercase; + letter-spacing: 0.5px; +`; + +export const ErrorMessage = styled.div` + font-size: 14px; + font-weight: 600; + color: #ff5414; + margin-bottom: 12px; + word-break: break-word; +`; + +export const StackTrace = styled.pre` + font-size: 12px; + font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace; + color: #4b4b4b; + white-space: pre-wrap; + word-break: break-all; + line-height: 1.5; +`; + +export const ButtonGroup = styled.div` + display: flex; + gap: 12px; + justify-content: center; + flex-wrap: wrap; +`; + +const BaseButton = styled.button` + padding: 14px 32px; + font-size: 16px; + font-weight: 600; + border-radius: 8px; + border: none; + cursor: pointer; + transition: all 0.2s ease; + min-width: 140px; + font-family: 'Pretendard', sans-serif; + + &:active { + transform: scale(0.98); + } +`; + +export const PrimaryButton = styled(BaseButton)` + background: #ff5414; + color: white; + + &:hover { + background: #ff7543; + box-shadow: 0 4px 12px rgba(255, 84, 20, 0.3); + } +`; + +export const SecondaryButton = styled(BaseButton)` + background: white; + color: #4b4b4b; + border: 1px solid #dcdcdc; + + &:hover { + background: #f5f5f5; + border-color: #c5c5c5; + } +`; diff --git a/frontend/src/components/common/ErrorBoundary/GlobalErrorFallback.tsx b/frontend/src/components/common/ErrorBoundary/GlobalErrorFallback.tsx new file mode 100644 index 000000000..0fb33b525 --- /dev/null +++ b/frontend/src/components/common/ErrorBoundary/GlobalErrorFallback.tsx @@ -0,0 +1,74 @@ +import * as Styled from './GlobalErrorFallback.styles'; + +interface ErrorFallbackProps { + error: Error; + resetError: () => void; +} + +const WarningIcon = () => ( + + + +); + +const GlobalErrorFallback = ({ error, resetError }: ErrorFallbackProps) => { + const isDev = import.meta.env.DEV; + + const handleReload = () => { + window.location.href = '/'; + }; + + const handleReset = () => { + resetError(); + }; + + return ( + + + + + + + 서비스 이용에 불편을 드려 죄송합니다 + + 예상치 못한 오류가 발생하여 페이지를 표시할 수 없습니다. +
+ 잠시 후 다시 시도해 주세요. +
+ + {isDev && error && ( + + + 개발자 정보 (프로덕션에서는 표시되지 않습니다) + + {error.message} + {error.stack && ( + {error.stack} + )} + + )} + + + + 다시 시도 + + + 홈으로 이동 + + +
+
+ ); +}; + +export default GlobalErrorFallback; diff --git a/frontend/src/components/common/ErrorBoundary/index.ts b/frontend/src/components/common/ErrorBoundary/index.ts new file mode 100644 index 000000000..f7da0a3ed --- /dev/null +++ b/frontend/src/components/common/ErrorBoundary/index.ts @@ -0,0 +1,2 @@ +export * from './GlobalErrorFallback'; +export { default as GlobalBoundary } from './GlobalBoundary'; diff --git a/frontend/src/components/common/Header/Header.styles.ts b/frontend/src/components/common/Header/Header.styles.ts index 066141931..8eaac12ab 100644 --- a/frontend/src/components/common/Header/Header.styles.ts +++ b/frontend/src/components/common/Header/Header.styles.ts @@ -8,8 +8,7 @@ export const Header = styled.header<{ isScrolled: boolean }>` left: 0; right: 0; width: 100%; - height: 62px; - padding: 10px 20px; + padding: 18px 0; background-color: white; z-index: ${Z_INDEX.header}; @@ -21,6 +20,10 @@ export const Header = styled.header<{ isScrolled: boolean }>` height: 56px; padding: 10px 20px; } + + ${media.mobile} { + padding: 8px 20px; + } `; export const Container = styled.div` diff --git a/frontend/src/components/common/SearchField/SearchField.styles.ts b/frontend/src/components/common/SearchField/SearchField.styles.ts index 205dad827..ad8c7a678 100644 --- a/frontend/src/components/common/SearchField/SearchField.styles.ts +++ b/frontend/src/components/common/SearchField/SearchField.styles.ts @@ -6,7 +6,7 @@ export const SearchBoxContainer = styled.form<{ $isFocused: boolean }>` align-items: center; justify-content: center; width: 345px; - height: 36px; + height: 40px; padding: 3px 20px; border: 1px solid transparent; border-radius: 41px; diff --git a/frontend/src/pages/ClubDetailPage/components/ClubIntroContent/ClubIntroContent.styles.ts b/frontend/src/pages/ClubDetailPage/components/ClubIntroContent/ClubIntroContent.styles.ts index 655515ca5..a6de3a841 100644 --- a/frontend/src/pages/ClubDetailPage/components/ClubIntroContent/ClubIntroContent.styles.ts +++ b/frontend/src/pages/ClubDetailPage/components/ClubIntroContent/ClubIntroContent.styles.ts @@ -45,6 +45,7 @@ export const TextContainer = styled.div` padding: 20px; gap: 8px; background-color: ${colors.gray[100]}; + line-height: 160%; ${media.mobile} { padding: 16px; diff --git a/frontend/src/pages/ErrorTestPage/ErrorTestPage.styles.ts b/frontend/src/pages/ErrorTestPage/ErrorTestPage.styles.ts new file mode 100644 index 000000000..8f89d8839 --- /dev/null +++ b/frontend/src/pages/ErrorTestPage/ErrorTestPage.styles.ts @@ -0,0 +1,173 @@ +import styled from 'styled-components'; + +export const Container = styled.div` + max-width: 900px; + margin: 0 auto; + padding: 40px 20px; + min-height: 100vh; +`; + +export const Header = styled.div` + text-align: center; + margin-bottom: 48px; + padding-bottom: 24px; + border-bottom: 2px solid ${({ theme }) => theme.colors.gray[300]}; +`; + +export const Title = styled.h1` + font-size: ${({ theme }) => theme.typography.title.title1.size}; + font-weight: ${({ theme }) => theme.typography.title.title1.weight}; + color: ${({ theme }) => theme.colors.base.black}; + margin-bottom: 12px; +`; + +export const Subtitle = styled.p` + font-size: ${({ theme }) => theme.typography.paragraph.p3.size}; + color: ${({ theme }) => theme.colors.gray[700]}; + line-height: 1.6; +`; + +export const Section = styled.div` + background: white; + border: 1px solid ${({ theme }) => theme.colors.gray[300]}; + border-radius: 12px; + padding: 24px; + margin-bottom: 24px; + transition: all 0.2s ease; + + &:hover { + border-color: ${({ theme }) => theme.colors.gray[400]}; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05); + } +`; + +export const SectionTitle = styled.h3` + font-size: ${({ theme }) => theme.typography.title.title5.size}; + font-weight: ${({ theme }) => theme.typography.title.title5.weight}; + color: ${({ theme }) => theme.colors.base.black}; + margin-bottom: 8px; +`; + +export const Description = styled.p` + font-size: ${({ theme }) => theme.typography.paragraph.p5.size}; + color: ${({ theme }) => theme.colors.gray[600]}; + line-height: 1.5; + margin-bottom: 16px; +`; + +interface TestButtonProps { + $variant: 'danger' | 'warning' | 'info'; +} + +export const TestButton = styled.button` + width: 100%; + padding: 16px 24px; + font-size: ${({ theme }) => theme.typography.paragraph.p3.size}; + font-weight: 600; + border: none; + border-radius: 8px; + cursor: pointer; + transition: all 0.2s ease; + + ${({ $variant, theme }) => { + switch ($variant) { + case 'danger': + return ` + background: ${theme.colors.primary[900]}; + color: white; + &:hover { + background: ${theme.colors.primary[800]}; + box-shadow: 0 4px 12px rgba(255, 84, 20, 0.3); + } + `; + case 'warning': + return ` + background: ${theme.colors.secondary[2].main}; + color: ${theme.colors.gray[900]}; + &:hover { + background: ${theme.colors.secondary[5].main}; + box-shadow: 0 4px 12px rgba(255, 160, 77, 0.3); + } + `; + case 'info': + return ` + background: ${theme.colors.secondary[4].main}; + color: white; + &:hover { + background: ${theme.colors.accent[1][900]}; + box-shadow: 0 4px 12px rgba(61, 187, 255, 0.3); + } + `; + } + }} + + &:active { + transform: scale(0.98); + } +`; + +export const InfoBox = styled.div` + background: ${({ theme }) => theme.colors.accent[1][600]}; + border: 1px solid ${({ theme }) => theme.colors.accent[1][700]}; + border-radius: 12px; + padding: 24px; + margin-top: 32px; +`; + +export const InfoTitle = styled.h4` + font-size: ${({ theme }) => theme.typography.paragraph.p1.size}; + font-weight: 600; + color: ${({ theme }) => theme.colors.base.black}; + margin-bottom: 12px; +`; + +export const InfoList = styled.ul` + list-style: none; + padding: 0; + margin: 0; + + li { + font-size: ${({ theme }) => theme.typography.paragraph.p5.size}; + color: ${({ theme }) => theme.colors.gray[800]}; + line-height: 1.6; + margin-bottom: 8px; + padding-left: 20px; + position: relative; + + &:before { + content: '•'; + position: absolute; + left: 8px; + color: ${({ theme }) => theme.colors.primary[900]}; + font-weight: bold; + } + + strong { + color: ${({ theme }) => theme.colors.base.black}; + font-weight: 600; + } + } +`; + +export const BackButton = styled.button` + display: block; + margin: 32px auto 0; + padding: 12px 24px; + font-size: ${({ theme }) => theme.typography.paragraph.p3.size}; + font-weight: 500; + color: ${({ theme }) => theme.colors.gray[700]}; + background: transparent; + border: 1px solid ${({ theme }) => theme.colors.gray[400]}; + border-radius: 8px; + cursor: pointer; + transition: all 0.2s ease; + + &:hover { + background: ${({ theme }) => theme.colors.gray[100]}; + border-color: ${({ theme }) => theme.colors.gray[500]}; + } + + &:active { + transform: scale(0.98); + } +`; diff --git a/frontend/src/pages/ErrorTestPage/ErrorTestPage.tsx b/frontend/src/pages/ErrorTestPage/ErrorTestPage.tsx new file mode 100644 index 000000000..9c1616349 --- /dev/null +++ b/frontend/src/pages/ErrorTestPage/ErrorTestPage.tsx @@ -0,0 +1,148 @@ +import { useState } from 'react'; +import { useQuery } from '@tanstack/react-query'; +import * as Styled from './ErrorTestPage.styles'; + +/** + * 에러바운더리 테스트용 페이지 + * 개발 환경에서만 사용 + */ +const ErrorTestPage = () => { + const [shouldThrow, setShouldThrow] = useState(false); + + // 1. 동기 런타임 에러 테스트 + const throwSyncError = () => { + setShouldThrow(true); + }; + + // 2. 비동기 에러 테스트 + const throwAsyncError = async () => { + await new Promise((resolve) => setTimeout(resolve, 100)); + throw new Error('비동기 에러 테스트: Promise 내부에서 에러 발생'); + }; + + // 3. API 에러 테스트 (React Query) + const { refetch: triggerQueryError } = useQuery({ + queryKey: ['error-test'], + queryFn: async () => { + throw new Error('React Query 에러 테스트: API 호출 실패'); + }, + enabled: false, + throwOnError: true, + }); + + // 4. 타입 에러 시뮬레이션 + const throwTypeError = () => { + // @ts-ignore + const obj = null; + // @ts-ignore + console.log(obj.property.nested); + }; + + // 5. 이벤트 핸들러 에러 + const throwEventError = () => { + throw new Error('이벤트 핸들러 에러 테스트'); + }; + + if (shouldThrow) { + throw new Error('동기 런타임 에러 테스트: 렌더링 중 에러 발생'); + } + + return ( + + + 🧪 에러바운더리 테스트 페이지 + + 개발 환경에서만 사용 가능합니다. 각 버튼을 클릭하여 에러바운더리 + 동작을 테스트하세요. + + + + + + 🔥 동기 에러 (ErrorBoundary 캐치) + + + 컴포넌트 렌더링 중 발생하는 에러입니다. ErrorBoundary가 캐치합니다. + + + 동기 런타임 에러 발생 + + + + + + ⚡ 이벤트 핸들러 에러 (콘솔 에러) + + + 이벤트 핸들러 내부 에러는 ErrorBoundary가 캐치하지 않습니다. 콘솔에 + 에러가 기록됩니다. + + + 이벤트 핸들러 에러 발생 + + + + + + 🌐 React Query 에러 (ErrorBoundary 캐치) + + + throwOnError: true 설정 시 ErrorBoundary가 캐치합니다. + + triggerQueryError()} + $variant='danger' + > + React Query 에러 발생 + + + + + ⏱️ 비동기 에러 (콘솔 에러) + + Promise 내부 에러는 ErrorBoundary가 캐치하지 않습니다. try-catch나 + .catch()로 처리해야 합니다. + + + 비동기 에러 발생 + + + + + + 💥 타입 에러 (ErrorBoundary 캐치) + + + null/undefined 접근 에러입니다. 렌더링 중 발생하면 캐치됩니다. + + + 타입 에러 발생 + + + + + ℹ️ 테스트 가이드 + +
  • + ErrorBoundary 캐치: 빨간색 버튼 - 에러 폴백 UI가 + 표시됩니다 +
  • +
  • + 콘솔 에러: 노란색 버튼 - 콘솔에 에러가 기록되지만 + 앱은 정상 동작합니다 +
  • +
  • + Sentry 전송: 모든 에러는 Sentry 대시보드에 + 기록됩니다 +
  • +
    +
    + + (window.location.href = '/')}> + ← 메인 페이지로 돌아가기 + +
    + ); +}; + +export default ErrorTestPage; diff --git a/frontend/src/pages/MainPage/MainPage.styles.ts b/frontend/src/pages/MainPage/MainPage.styles.ts index b8a1a9c1f..5156dfc57 100644 --- a/frontend/src/pages/MainPage/MainPage.styles.ts +++ b/frontend/src/pages/MainPage/MainPage.styles.ts @@ -27,10 +27,10 @@ export const SectionBar = styled.div` display: flex; align-items: flex-end; justify-content: space-between; - margin: 60px 0px 24px 8px; + margin: 24px 0px 16px 8px; ${media.mobile} { - margin: 32px 4px 16px; + margin: 12px 4px 12px; } `; @@ -43,15 +43,16 @@ export const SectionTabs = styled.nav` } `; +// 현재는 중앙동아리 상태만 유지 +// 과동아리 또는 가동아리 확장성을 위해 active 속성 유지 export const Tab = styled.button<{ $active?: boolean }>` display: flex; position: relative; - font-size: 24px; - font-weight: bold; + font-size: 20px; + font-weight: 700; color: ${({ $active }) => ($active ? '#787878' : '#DCDCDC')}; border: none; background: none; - cursor: pointer; &::after { content: ''; @@ -119,3 +120,30 @@ export const EmptyResult = styled.div` font-size: 0.95rem; } `; + +export const RetryButton = styled.button` + margin-top: 24px; + padding: 12px 32px; + font-size: 16px; + font-weight: 600; + color: white; + background: ${({ theme }) => theme.colors.primary[900]}; + border: none; + border-radius: 8px; + cursor: pointer; + transition: all 0.2s ease; + + &:hover { + background: ${({ theme }) => theme.colors.primary[800]}; + box-shadow: 0 4px 12px rgba(255, 84, 20, 0.3); + } + + &:active { + transform: scale(0.98); + } + + ${media.mobile} { + padding: 10px 24px; + font-size: 14px; + } +`; diff --git a/frontend/src/pages/MainPage/MainPage.tsx b/frontend/src/pages/MainPage/MainPage.tsx index 7b88f6f7a..f09224ce6 100644 --- a/frontend/src/pages/MainPage/MainPage.tsx +++ b/frontend/src/pages/MainPage/MainPage.tsx @@ -28,7 +28,7 @@ const MainPage = () => { const [active, setActive] = useState<(typeof tabs)[number]>('부경대학교 중앙동아리'); - const { data, error, isLoading } = useGetCardList({ + const { data, error, isLoading, refetch } = useGetCardList({ keyword, recruitmentStatus, category: searchCategory, @@ -46,10 +46,6 @@ const MainPage = () => { return clubs.map((club: Club) => ); }, [clubs, hasData]); - if (error) { - return
    에러가 발생했습니다.
    ; - } - return ( <> @@ -75,10 +71,17 @@ const MainPage = () => { {`전체 ${isLoading ? 0 : totalCount}개의 동아리`} - {isLoading ? ( + ) : error ? ( + + 동아리 목록을 불러오는 중 문제가 발생했습니다. +
    + refetch()}> + 다시 시도 + +
    ) : isEmpty ? ( 앗, 조건에 맞는 동아리가 없어요. diff --git a/frontend/src/pages/MainPage/components/Banner/Banner.styles.ts b/frontend/src/pages/MainPage/components/Banner/Banner.styles.ts index 9e4fa1c7d..af38a27ed 100644 --- a/frontend/src/pages/MainPage/components/Banner/Banner.styles.ts +++ b/frontend/src/pages/MainPage/components/Banner/Banner.styles.ts @@ -8,7 +8,7 @@ export const BannerContainer = styled.div` display: flex; justify-content: center; align-items: center; - margin-top: 90px; + margin-top: 88px; position: relative; ${media.laptop} { diff --git a/frontend/src/pages/MainPage/components/CategoryButtonList/CategoryButtonList.styles.ts b/frontend/src/pages/MainPage/components/CategoryButtonList/CategoryButtonList.styles.ts index 627354b10..152e8c800 100644 --- a/frontend/src/pages/MainPage/components/CategoryButtonList/CategoryButtonList.styles.ts +++ b/frontend/src/pages/MainPage/components/CategoryButtonList/CategoryButtonList.styles.ts @@ -5,7 +5,7 @@ export const CategoryButtonContainer = styled.div` display: flex; justify-content: space-between; flex-wrap: nowrap; - margin-top: 32px; + margin-top: 24px; ${media.mobile} { background-color: white; @@ -14,7 +14,7 @@ export const CategoryButtonContainer = styled.div` z-index: 1; margin: 0px -20px; - padding: 6px 20px 12px; + padding: 16px 20px; } ${media.mini_mobile} { @@ -73,6 +73,7 @@ export const CategoryButton = styled.button` font-size: 12px; margin-top: 4px; line-height: normal; + font-weight: 600; } ${media.mini_mobile} { diff --git a/frontend/src/styles/Global.styles.ts b/frontend/src/styles/Global.styles.ts index 7427302a4..a6999b4a1 100644 --- a/frontend/src/styles/Global.styles.ts +++ b/frontend/src/styles/Global.styles.ts @@ -6,6 +6,9 @@ const GlobalStyles = createGlobalStyle` padding: 0; box-sizing: border-box; } + html { + overscroll-behavior-y: none; + } textarea, button, input, select { font-family: 'Pretendard', sans-serif; } diff --git a/frontend/src/utils/initSDK.ts b/frontend/src/utils/initSDK.ts index 4478e1c6c..2f0d113e2 100644 --- a/frontend/src/utils/initSDK.ts +++ b/frontend/src/utils/initSDK.ts @@ -51,6 +51,7 @@ export function initializeSentry() { sendDefaultPii: false, release: import.meta.env.VITE_SENTRY_RELEASE, tracesSampleRate: 0.1, + integrations: [Sentry.browserTracingIntegration()], }); }