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 (
);
}
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(/