diff --git a/src/api/apiWrapper.ts b/src/api/apiWrapper.ts index f5fc18b8..a9f02357 100644 --- a/src/api/apiWrapper.ts +++ b/src/api/apiWrapper.ts @@ -56,8 +56,20 @@ export async function fetchWrapper( }); if (!response.ok) { - throw new CustomError(`HTTP error! status: ${response.status}`, await response.json()); + const err = new CustomError( + `HTTP error! status: ${response.status}`, + await safeJson(response), + ); + (err as any).statusCode = response.status; + throw err; } + } else { + const err = new CustomError( + `HTTP error! status: ${response.status}`, + await safeJson(response), + ); + (err as any).statusCode = response.status; + throw err; } } @@ -69,3 +81,11 @@ export async function fetchWrapper( throw error; } } + +async function safeJson(res: Response) { + try { + return await res.json(); + } catch { + return null; + } +} diff --git a/src/api/authApi.ts b/src/api/authApi.ts index bb7e73c5..d9b8a316 100644 --- a/src/api/authApi.ts +++ b/src/api/authApi.ts @@ -27,6 +27,7 @@ export const postSignup = async (name: string, email: string, password: string) name: name, email: email, password: password, + eulaEnabled: true, }; try { diff --git a/src/app/error.tsx b/src/app/error.tsx new file mode 100644 index 00000000..b747c20a --- /dev/null +++ b/src/app/error.tsx @@ -0,0 +1,47 @@ +'use client'; + +import { useEffect, useMemo } from 'react'; + +import { useRouter } from 'next/navigation'; + +import ErrorFallback from '@/components/ui/ErrorFallback'; + +export default function GlobalError({ + error, + reset, +}: { + error: Error & { digest?: string }; + reset: () => void; +}) { + const router = useRouter(); + + const target = useMemo(() => { + const code = (error as any)?.statusCode ?? (error as any)?.status; + if (typeof code !== 'number') return null; + if (code >= 500 && code < 600) return '/error/500error'; + if (code >= 400 && code < 500) return '/error/400error'; + return null; + }, [error]); + + useEffect(() => { + if (target) router.replace(target); + }, [target, router]); + + return ( + + + {target ? null : ( + router.push('/')} + /> + )} + + + ); +} diff --git a/src/app/error/400error/page.tsx b/src/app/error/400error/page.tsx new file mode 100644 index 00000000..d7ea234c --- /dev/null +++ b/src/app/error/400error/page.tsx @@ -0,0 +1,18 @@ +'use client'; +import ErrorFallback from '@/components/ui/ErrorFallback'; + +export default function ClientErrorPage() { + return ( +
+ (window.location.href = '/')} + navigateHref="/" + /> +
+ ); +} diff --git a/src/app/error/500error/page.tsx b/src/app/error/500error/page.tsx new file mode 100644 index 00000000..3570b6e9 --- /dev/null +++ b/src/app/error/500error/page.tsx @@ -0,0 +1,18 @@ +'use client'; +import ErrorFallback from '@/components/ui/ErrorFallback'; + +export default function ServerErrorPage() { + return ( +
+ (window.location.href = '/')} + navigateHref="/" + /> +
+ ); +} diff --git a/src/app/providers/RQErrorBoundary.tsx b/src/app/providers/RQErrorBoundary.tsx new file mode 100644 index 00000000..f9054c0d --- /dev/null +++ b/src/app/providers/RQErrorBoundary.tsx @@ -0,0 +1,34 @@ +'use client'; +import React from 'react'; + +export default class RQErrorBoundary extends React.Component< + { children: React.ReactNode }, + { hasError: boolean } +> { + constructor(props: any) { + super(props); + this.state = { hasError: false }; + } + + static getDerivedStateFromError() { + return { hasError: true }; + } + + componentDidCatch(error: any) { + const status = error?.statusCode ?? error?.status ?? error?.response?.status; + + if (typeof status === 'number') { + if (status >= 500) { + window.location.replace('/error/500error'); + } else if (status >= 400) { + window.location.replace('/error/400error'); + } + } + } + + render() { + if (this.state.hasError) return null; + + return this.props.children; + } +} diff --git a/src/app/providers/ReactQueryProvider.tsx b/src/app/providers/ReactQueryProvider.tsx index 1867d36a..96754a55 100644 --- a/src/app/providers/ReactQueryProvider.tsx +++ b/src/app/providers/ReactQueryProvider.tsx @@ -2,21 +2,35 @@ import { useState } from 'react'; -import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { MutationCache, QueryCache, QueryClient, QueryClientProvider } from '@tanstack/react-query'; -const ReactQueryProvider = ({ children }: React.PropsWithChildren) => { +const handleGlobalError = (error: unknown) => { + const e = error as any; + const status = e?.statusCode ?? e?.status ?? e?.response?.status; + + const url = + e?.config?.url || e?.response?.url || e?.message?.includes('/auth/login') ? '/auth/login' : ''; + + console.log('[🧩 ReactQuery GlobalError]', status, url); + + if (url.includes('/auth/login')) return; + + if (!status) return; + if (status >= 500) window.location.replace('/error/500error'); + else if (status >= 400) window.location.replace('/error/400error'); +}; + +export default function ReactQueryProvider({ children }: React.PropsWithChildren) { const [queryClient] = useState( () => new QueryClient({ + queryCache: new QueryCache({ onError: handleGlobalError }), + mutationCache: new MutationCache({ onError: handleGlobalError }), defaultOptions: { - queries: { - staleTime: 60 * 1000, - }, + queries: { retry: 0, staleTime: 60_000 }, }, }), ); return {children}; -}; - -export default ReactQueryProvider; +} diff --git a/src/components/auth/LoginForm.tsx b/src/components/auth/LoginForm.tsx index b99847f0..6004d08e 100644 --- a/src/components/auth/LoginForm.tsx +++ b/src/components/auth/LoginForm.tsx @@ -36,15 +36,21 @@ export default function LoginForm() { const password = watch('password'); const isFormValid = email.trim() !== '' && password.trim() !== ''; - const login = useLogin({ - onError: () => { - setIsModalOpen(true); - }, - }); + const login = useLogin({ onError: () => {} }); - const onSubmit = (formData: LoginFormData) => { + const onSubmit = async (formData: LoginFormData) => { setIsModalOpen(false); - login.mutate(formData); + try { + await login.mutateAsync(formData); + } catch (err: any) { + const status = err?.statusCode ?? err?.status ?? err?.response?.status; + + if (status >= 500) { + window.location.replace('/error/500error'); + return; + } + setIsModalOpen(true); + } }; if (login.isPending || login.isSuccess) return ; diff --git a/src/components/auth/SignUpForm.tsx b/src/components/auth/SignUpForm.tsx index 9ac16667..7bd48089 100644 --- a/src/components/auth/SignUpForm.tsx +++ b/src/components/auth/SignUpForm.tsx @@ -6,6 +6,8 @@ import { zodResolver } from '@hookform/resolvers/zod'; import { useForm } from 'react-hook-form'; import z from 'zod'; +import CheckedIcon from '@/assets/icons/checkbox-checked-blue.svg'; +import UncheckedIcon from '@/assets/icons/checkbox-unchecked.svg'; import { useEmailCheck, useSignup } from '@/hooks/auth/useSignup'; import { signupSchema } from '@/interfaces/auth'; @@ -16,14 +18,20 @@ import AuthModal from './AuthModal'; import EmailInput from './EmailInput'; import NameInput from './NameInput'; import PasswordInput from './PasswordInput'; +import TermsAgreementModal from './TermsAgreementModal'; export type SignupFormData = z.infer; +// 이하 코드 동일 export default function SignUpForm() { const [emailServerError, setEmailServerError] = useState(null); const [isEmailChecked, setIsEmailChecked] = useState(false); const [isModalOpen, setIsModalOpen] = useState(false); const [isCheckOpen, setIsCheckOpen] = useState(false); + const [allAgree, setAllAgree] = useState(false); + const [requiredAgree, setRequiredAgree] = useState(false); + const [marketingAgree, setMarketingAgree] = useState(false); + const [isTermsOpen, setIsTermsOpen] = useState(false); const handleCloseModal = () => setIsModalOpen(false); const handleCloseCheck = () => setIsCheckOpen(false); @@ -77,23 +85,56 @@ export default function SignUpForm() { }; const signup = useSignup({ - onError: () => { - setIsModalOpen(true); - }, + onError: () => setIsModalOpen(true), }); + const handleToggleAll = () => { + const next = !allAgree; + setAllAgree(next); + setRequiredAgree(next); + setMarketingAgree(next); + }; + + useEffect(() => { + setAllAgree(requiredAgree && marketingAgree); + }, [requiredAgree, marketingAgree]); + + const handleAgreeFromModal = () => { + setRequiredAgree(true); + }; + const onSubmit = async (formData: SignupFormData) => { - if (isEmailChecked) { + if (isEmailChecked && requiredAgree) { signup.mutate(formData); } }; const shouldShowLoading = signup.isPending || signup.isSuccess; + const canSubmit = isFormValid && isEmailChecked && requiredAgree; if (shouldShowLoading) { return ; } + const IconCheckbox = ({ checked }: { checked: boolean }) => ( + + {checked ? ( + + ) : ( + + )} + + ); + + const REQUIRED_ID = 'required-agree'; + const ALL_ID = 'all-agree'; + const MARKETING_ID = 'marketing-agree'; + + const openTermsModal = (e: React.MouseEvent) => { + e.preventDefault(); + setIsTermsOpen(true); + }; + return (
@@ -121,15 +162,88 @@ export default function SignUpForm() { name="passwordCheck" error={errors.passwordCheck?.message} /> - + + + 에 동의합니다. + + + + setMarketingAgree(e.target.checked)} + /> + + + + + {isModalOpen && ( )} {isCheckOpen && ( )} + + setIsTermsOpen(false)} + onAgree={handleAgreeFromModal} + version="v1" + termsFile="eula_v1.html" + privacyFile="privacy_v1.html" + /> ); } diff --git a/src/components/auth/TermsAgreementModal.tsx b/src/components/auth/TermsAgreementModal.tsx new file mode 100644 index 00000000..2899557b --- /dev/null +++ b/src/components/auth/TermsAgreementModal.tsx @@ -0,0 +1,177 @@ +'use client'; + +import { useEffect, useMemo, useState } from 'react'; + +import CloseIcon from '@/assets/icons/close.svg'; +import { Button } from '@/components/ui/Button'; + +import Modal from '../ui/Modal'; + +type TermsAgreementModalProps = { + isOpen: boolean; + onClose: () => void; + onAgree: () => void; + baseUrl?: string; + version?: string; + termsFile?: string; + privacyFile?: string; +}; + +type DocState = { html: string; loading: boolean; error: string | null }; + +function extractBodyInnerHTML(raw: string) { + const withoutStyle = raw.replace(//gi, ''); + const withoutScript = withoutStyle.replace(//gi, ''); + const bodyMatch = withoutScript.match(/]*>([\s\S]*?)<\/body>/i); + if (bodyMatch && bodyMatch[1]) return bodyMatch[1].trim(); + + const withoutHead = withoutScript + .replace(/<\/?html[^>]*>/gi, '') + .replace(/<\/?head[^>]*>[\s\S]*?<\/head>/gi, '') + .replace(/<\/?body[^>]*>/gi, '') + .trim(); + + return withoutHead; +} + +function ScrollBox({ + html, + loading, + error, + emptyText, +}: { + html: string; + loading: boolean; + error: string | null; + emptyText: string; +}) { + if (loading) { + return
; + } + if (error) { + return
{emptyText}
; + } + + return ( +
+ ); +} + +export default function TermsAgreementModal({ + isOpen, + onClose, + onAgree, + baseUrl = process.env.NEXT_PUBLIC_EULA_BASE_URL || '', + version = 'v1', + termsFile = 'eula_v1.html', + privacyFile = 'privacy_v1.html', +}: TermsAgreementModalProps) { + const [terms, setTerms] = useState({ html: '', loading: true, error: null }); + const [privacy, setPrivacy] = useState({ html: '', loading: true, error: null }); + + const termsUrl = useMemo( + () => [baseUrl.replace(/\/$/, ''), 'eula', version, termsFile].join('/'), + [baseUrl, version, termsFile], + ); + const privacyUrl = useMemo( + () => [baseUrl.replace(/\/$/, ''), 'eula', version, privacyFile].join('/'), + [baseUrl, version, privacyFile], + ); + + useEffect(() => { + if (!isOpen) return; + + const load = async (url: string, setter: (s: DocState) => void) => { + setter({ html: '', loading: true, error: null }); + try { + const res = await fetch(url, { cache: 'no-store' }); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + const raw = await res.text(); + const cleaned = extractBodyInnerHTML(raw); + setter({ html: cleaned, loading: false, error: null }); + } catch (e: any) { + setter({ html: '', loading: false, error: e?.message ?? '불러오기 실패' }); + } + }; + + load(termsUrl, setTerms); + load(privacyUrl, setPrivacy); + }, [isOpen, termsUrl, privacyUrl]); + + const allLoaded = !terms.loading && !privacy.loading && !terms.error && !privacy.error; + + const handleAgree = () => { + onAgree(); + onClose(); + }; + + return ( + +
+

서비스 이용 약관

+ +
+ +
+
+

이용 약관

+ (필수) +
+ +
+ +
+
+

개인정보 처리방침

+ (필수) +
+ +
+ +
+ +
+
+ ); +} diff --git a/src/components/ui/Button.tsx b/src/components/ui/Button.tsx index a2237f7d..97d23d22 100644 --- a/src/components/ui/Button.tsx +++ b/src/components/ui/Button.tsx @@ -51,6 +51,7 @@ const buttonVariants = cva('flex cursor-pointer items-center justify-center', { tempNote: 'h-40 w-84', sideNote: 'h-48 w-260', error: 'h-44 w-200', + eula: 'h-48 w-520', }, rounded: { none: 'rounded-none', diff --git a/src/components/ui/ErrorFallback.tsx b/src/components/ui/ErrorFallback.tsx index 68322fa9..06ec5647 100644 --- a/src/components/ui/ErrorFallback.tsx +++ b/src/components/ui/ErrorFallback.tsx @@ -39,7 +39,7 @@ const ERROR_CONFIGS: Record = { Icon: GeneralErrorIcon, title: '문제가 발생했어요', subTitle: '잠시 후 다시 시도해 주세요', - iconWrapClass: 'h-100 w-100', + iconWrapClass: 'flex items-center justify-center h-200 w-200', primary: { label: '다시 시도', action: 'retry' }, secondary: { label: '홈으로', action: 'navigate' }, }, @@ -47,7 +47,7 @@ const ERROR_CONFIGS: Record = { Icon: NotFoundErrorIcon, title: '페이지를 찾을 수 없어요', subTitle: '요청하신 페이지가 존재하지 않습니다', - iconWrapClass: 'h-100 w-150', + iconWrapClass: 'flex items-center justify-center h-200 w-292', primary: { label: '홈으로', action: 'navigate' }, secondary: { label: '이전 페이지', action: 'back' }, }, diff --git a/src/components/ui/Modal.tsx b/src/components/ui/Modal.tsx index 62e09a89..0ba6e6e8 100644 --- a/src/components/ui/Modal.tsx +++ b/src/components/ui/Modal.tsx @@ -38,6 +38,7 @@ const modalContentVariants = cva( auth: 'h-256 w-402', timer: 'h-600 w-343 md:h-762 md:w-600', schedule: 'h-812 w-375 md:h-800 md:w-724', + eula: 'h-762 w-600', }, padding: { default: 'p-40', diff --git a/src/lib/queryClient.ts b/src/lib/queryClient.ts new file mode 100644 index 00000000..0ba1b552 --- /dev/null +++ b/src/lib/queryClient.ts @@ -0,0 +1,26 @@ +import { MutationCache, QueryCache, QueryClient } from '@tanstack/react-query'; + +import { routeByStatus } from '@/lib/routeByStatus'; + +export const queryClient = new QueryClient({ + queryCache: new QueryCache({ + onError: error => { + const e: any = error; + const status = e?.statusCode ?? e?.status ?? e?.response?.status; + routeByStatus(status); + }, + }), + mutationCache: new MutationCache({ + onError: error => { + const e: any = error; + const status = e?.statusCode ?? e?.status ?? e?.response?.status; + routeByStatus(status); + }, + }), + defaultOptions: { + queries: { + retry: 0, + }, + mutations: {}, + }, +}); diff --git a/src/lib/routeByStatus.ts b/src/lib/routeByStatus.ts new file mode 100644 index 00000000..a313592c --- /dev/null +++ b/src/lib/routeByStatus.ts @@ -0,0 +1,8 @@ +export function routeByStatus(status?: number) { + if (!status) return; + if (status >= 500) { + window.location.replace('/error/500error'); + } else if (status >= 400) { + window.location.replace('/error/400error'); + } +}