-
-
setFocusedField('email')}
+
-
-
setFocusedField('password')}
+
-
비밀번호를 잊으셨나요?
@@ -68,7 +45,12 @@ const LoginForm = () => {
-
diff --git a/src/app/login/components/PasswordInput.tsx b/src/app/login/components/PasswordInput.tsx
new file mode 100644
index 0000000..465085e
--- /dev/null
+++ b/src/app/login/components/PasswordInput.tsx
@@ -0,0 +1,42 @@
+'use client';
+
+import { Input } from '@/components/ui/Input';
+import useDebounce from '@/hooks/useDebounde';
+import { loginPasswordValidation } from '@/util/validation';
+import { useCallback } from 'react';
+import { useWatch } from 'react-hook-form';
+import { IInputProps, ILoginFormData } from 'types/auth';
+
+const PasswordInput = ({
+ control,
+ register,
+ errors,
+ trigger,
+}: IInputProps
) => {
+ const password = useWatch({ control, name: 'password' });
+
+ useDebounce({
+ value: password,
+ callBack: useCallback(() => {
+ trigger?.('password');
+ }, [password]),
+ });
+
+ return (
+ <>
+
+
+ >
+ );
+};
+
+export default PasswordInput;
diff --git a/src/app/preview/chip/page.tsx b/src/app/preview/chip/page.tsx
new file mode 100644
index 0000000..e28fbff
--- /dev/null
+++ b/src/app/preview/chip/page.tsx
@@ -0,0 +1,40 @@
+'use client';
+
+import Chip from '@/components/ui/Chip';
+import React, { useState } from 'react';
+
+export default function ChipPreview() {
+ const [position, setPosition] = useState('');
+
+ return (
+
+
All
+
All
+
+
+ setPosition('Frontend')}
+ >
+ 프론트엔드
+
+ setPosition('Backend')}
+ >
+ 백엔드
+
+ setPosition('Designer')}
+ >
+ 디자이너
+
+
+
+
+ );
+}
diff --git a/src/app/preview/toast/page.tsx b/src/app/preview/toast/page.tsx
new file mode 100644
index 0000000..ef2d2ef
--- /dev/null
+++ b/src/app/preview/toast/page.tsx
@@ -0,0 +1,35 @@
+'use client';
+
+import { useToast } from '@/components/common/ToastContext';
+
+export default function Page() {
+ const { showToast } = useToast();
+
+ return (
+
+ showToast('로그인 성공!', 'success', { duration: 3000 })}
+ className="bg-blue-500 rounded px-4 py-2 text-white"
+ >
+ 성공 토스트 보여주기
+
+ showToast('로그인 실패!', 'error', { duration: 3000 })}
+ className="bg-blue-500 rounded px-4 py-2 text-white"
+ >
+ 실패 토스트 보여주기
+
+
+ showToast('로그인 실패!', 'error', {
+ btnText: '재시도',
+ onClick: () => alert('버튼 클릭'),
+ })
+ }
+ className="bg-blue-500 rounded px-4 py-2 text-white"
+ >
+ 토스트 보여주기(with button)
+
+
+ );
+}
diff --git a/src/app/signup/components/EmailInput.tsx b/src/app/signup/components/EmailInput.tsx
new file mode 100644
index 0000000..fa6d77a
--- /dev/null
+++ b/src/app/signup/components/EmailInput.tsx
@@ -0,0 +1,68 @@
+'use client';
+
+import { Button } from '@/components/ui/Button';
+import { Input } from '@/components/ui/Input';
+import useDebounce from '@/hooks/useDebounde';
+import { emailValidation } from '@/util/validation';
+import { Dispatch, SetStateAction, useCallback, useEffect } from 'react';
+import { useWatch } from 'react-hook-form';
+import { IInputProps, ISignupFormData } from 'types/auth';
+
+export interface IEmailInputProps extends IInputProps {
+ isEmailCheck: boolean;
+ handleEmailCheck: () => void;
+ setIsEmailCheck: Dispatch>;
+}
+
+const EmailInput = ({
+ register,
+ errors,
+ isEmailCheck,
+ handleEmailCheck,
+ setIsEmailCheck,
+ control,
+ trigger,
+}: IEmailInputProps) => {
+ const email = useWatch({ control, name: 'email' });
+
+ // 입력이 있다면 중복확인 버튼 활성화
+ useEffect(() => {
+ setIsEmailCheck(false);
+ }, [email]);
+
+ useDebounce({
+ value: email,
+ callBack: useCallback(() => {
+ trigger?.('email');
+ }, [email]),
+ });
+
+ return (
+
+
+
+
+
+ 중복확인
+
+
+
+ );
+};
+export default EmailInput;
diff --git a/src/app/signup/components/NameInput.tsx b/src/app/signup/components/NameInput.tsx
new file mode 100644
index 0000000..692291a
--- /dev/null
+++ b/src/app/signup/components/NameInput.tsx
@@ -0,0 +1,68 @@
+'use client';
+
+import { Button } from '@/components/ui/Button';
+import { Input } from '@/components/ui/Input';
+import useDebounce from '@/hooks/useDebounde';
+import { nameValidation } from '@/util/validation';
+import { Dispatch, SetStateAction, useCallback, useEffect } from 'react';
+import { useWatch } from 'react-hook-form';
+import { IInputProps, ISignupFormData } from 'types/auth';
+
+export interface INameInputProps extends IInputProps {
+ isNameCheck: boolean;
+ handleNameCheck: () => void;
+ setIsNameCheck: Dispatch>;
+}
+
+const NameInput = ({
+ register,
+ errors,
+ isNameCheck,
+ handleNameCheck,
+ setIsNameCheck,
+ control,
+ trigger,
+}: INameInputProps) => {
+ const name = useWatch({ control, name: 'name' });
+
+ // 중복확인 로직 수행
+ useEffect(() => {
+ setIsNameCheck(false);
+ }, [name]);
+
+ useDebounce({
+ value: name,
+ callBack: useCallback(() => {
+ trigger?.('name');
+ }, [name]),
+ });
+
+ return (
+
+
+
+
+
+ 중복확인
+
+
+
+ );
+};
+export default NameInput;
diff --git a/src/app/signup/components/PasswordCheckInput.tsx b/src/app/signup/components/PasswordCheckInput.tsx
new file mode 100644
index 0000000..fa9bffa
--- /dev/null
+++ b/src/app/signup/components/PasswordCheckInput.tsx
@@ -0,0 +1,45 @@
+'use client';
+
+import { Input } from '@/components/ui/Input';
+import useDebounce from '@/hooks/useDebounde';
+import { passwordCheckValidation } from '@/util/validation';
+import { useCallback } from 'react';
+import { useWatch } from 'react-hook-form';
+
+import { IPasswordInputProps } from './PasswordInput';
+
+const PasswordCheckInput = ({
+ register,
+ dirtyFields,
+ errors,
+ control,
+ trigger,
+}: IPasswordInputProps) => {
+ const password = useWatch({ control, name: 'password' });
+ const passwordCheck = useWatch({ control, name: 'passwordCheck' });
+
+ useDebounce({
+ value: passwordCheck,
+ callBack: useCallback(() => {
+ trigger?.('passwordCheck');
+ }, [passwordCheck]),
+ });
+
+ return (
+
+
+
+
+ );
+};
+
+export default PasswordCheckInput;
diff --git a/src/app/signup/components/PasswordInput.tsx b/src/app/signup/components/PasswordInput.tsx
new file mode 100644
index 0000000..34d322d
--- /dev/null
+++ b/src/app/signup/components/PasswordInput.tsx
@@ -0,0 +1,53 @@
+'use client';
+
+import { Input } from '@/components/ui/Input';
+import useDebounce from '@/hooks/useDebounde';
+import { passwordValidation } from '@/util/validation';
+import { useCallback } from 'react';
+import { useWatch } from 'react-hook-form';
+import { IInputProps, ISignupFormData } from 'types/auth';
+
+export interface IPasswordInputProps extends IInputProps {
+ dirtyFields: Partial<
+ Readonly<{
+ name?: boolean | undefined;
+ email?: boolean | undefined;
+ position?: boolean | undefined;
+ password?: boolean | undefined;
+ passwordCheck?: boolean | undefined;
+ }>
+ >;
+}
+
+const PasswordInput = ({
+ register,
+ control,
+ trigger,
+ dirtyFields,
+ errors,
+}: IPasswordInputProps) => {
+ const password = useWatch({ control, name: 'password' });
+ useDebounce({
+ value: password,
+ callBack: useCallback(() => {
+ trigger?.('password');
+ }, [password]),
+ });
+
+ return (
+
+
+
+
+ );
+};
+export default PasswordInput;
diff --git a/src/app/signup/components/PositionInput.tsx b/src/app/signup/components/PositionInput.tsx
new file mode 100644
index 0000000..2314db2
--- /dev/null
+++ b/src/app/signup/components/PositionInput.tsx
@@ -0,0 +1,33 @@
+import { PositionSelect } from '@/components/common/PositionSelect';
+import { positionValidation } from '@/util/validation';
+import { useWatch } from 'react-hook-form';
+import { IInputProps, ISignupFormData } from 'types/auth';
+
+interface IPositionInputProps extends IInputProps {
+ handleClickPosition: (value: string) => void;
+}
+
+const PositionInput = ({
+ control,
+ handleClickPosition,
+ errors,
+ register,
+}: IPositionInputProps) => {
+ const position = useWatch({ control, name: 'position' });
+ return (
+
+
+
+ {errors.position?.message && (
+
+ {errors.position?.message}
+
+ )}
+
+
+ );
+};
+
+export default PositionInput;
diff --git a/src/app/signup/components/SignupForm.tsx b/src/app/signup/components/SignupForm.tsx
new file mode 100644
index 0000000..29be0ff
--- /dev/null
+++ b/src/app/signup/components/SignupForm.tsx
@@ -0,0 +1,100 @@
+'use client';
+
+import { Button } from '@/components/ui/Button';
+import useSignUpForm from '@/hooks/useSignupForm';
+import Link from 'next/link';
+
+import EmailInput from './EmailInput';
+import NameInput from './NameInput';
+import PasswordCheckInput from './PasswordCheckInput';
+import PasswordInput from './PasswordInput';
+import PositionInput from './PositionInput';
+
+const SignupForm = () => {
+ const {
+ handleSubmit,
+ onSubmit,
+ register,
+ errors,
+ isNameCheck,
+ handleNameCheck,
+ isEmailCheck,
+ handleEmailCheck,
+ handleClickPosition,
+ dirtyFields,
+ control,
+ setIsNameCheck,
+ setIsEmailCheck,
+ trigger,
+ } = useSignUpForm();
+
+ return (
+
+ );
+};
+
+export default SignupForm;
diff --git a/src/app/signup/page.tsx b/src/app/signup/page.tsx
index df258ab..42e9870 100644
--- a/src/app/signup/page.tsx
+++ b/src/app/signup/page.tsx
@@ -1,142 +1,24 @@
-'use client';
+import { baseURL } from '@/lib/axios/defaultConfig';
-import { Button } from '@/components/ui/Button';
-import { Input } from '@/components/ui/Input';
-import Link from 'next/link';
-import { useForm } from 'react-hook-form';
+import SignupForm from './components/SignupForm';
-interface ISignupFormData {
- id: string;
- pw: string;
-}
+export const metadata = {
+ metadataBase: new URL(`${baseURL}/signup`),
+ title: '회원가입 | Deving',
+ description: 'Deving에 가입하고 다양한 서비스를 이용하세요',
+ openGraph: {
+ title: '회원가입 | Deving',
+ description: 'Deving에 가입하고 다양한 서비스를 이용하세요',
+ url: `${baseURL}/signup`, // 추후 수정
+ siteName: 'deving',
+ type: 'website',
+ },
+};
export default function Signup() {
- const {
- register,
- handleSubmit,
- watch,
- formState: { errors },
- } = useForm({
- mode: 'onBlur',
- });
- const onSubmit = (data: ISignupFormData) => {
- console.log('로그인 데이터: ', data);
- };
return (
);
}
diff --git a/src/assets/icon/check_icon.svg b/src/assets/icon/check_icon.svg
new file mode 100644
index 0000000..770a655
--- /dev/null
+++ b/src/assets/icon/check_icon.svg
@@ -0,0 +1,4 @@
+
diff --git a/src/assets/icon/warning_icon.svg b/src/assets/icon/warning_icon.svg
new file mode 100644
index 0000000..cb1e6e2
--- /dev/null
+++ b/src/assets/icon/warning_icon.svg
@@ -0,0 +1,4 @@
+
diff --git a/src/components/common/Header.tsx b/src/components/common/Header.tsx
index b3d4bcf..eb8c691 100644
--- a/src/components/common/Header.tsx
+++ b/src/components/common/Header.tsx
@@ -2,12 +2,21 @@
import Logo from '@/assets/icon/logo.svg';
import Profile from '@/assets/icon/profile.svg';
+import { removeAccessToken } from '@/lib/serverActions';
import { Menu } from 'lucide-react';
import Link from 'next/link';
import { useRouter } from 'next/navigation';
import { useState } from 'react';
import Dropdown from './Dropdown';
+import { useToast } from './ToastContext';
+
+const navigation = [
+ { href: '/meeting/mogakco', label: '모각코' },
+ { href: '/meeting/study', label: '스터디' },
+ { href: '/meeting/side-project', label: '사이드 프로젝트' },
+ { href: '/meeting/hobby', label: '취미' },
+];
const BeforeLogin = () => {
return (
@@ -24,6 +33,7 @@ const BeforeLogin = () => {
const AfterLogin = () => {
const router = useRouter();
+ const { showToast } = useToast();
const menu = [
{
value: 'mymeeting',
@@ -38,7 +48,11 @@ const AfterLogin = () => {
{
value: 'logout',
label: '로그아웃',
- onSelect: () => console.log('로그아웃'),
+ onSelect: async () => {
+ await removeAccessToken();
+ // 로그아웃 관련 토스트바 노출
+ showToast('로그아웃 되었습니다.', 'success');
+ },
},
];
return (
@@ -118,38 +132,16 @@ const NavLinks = ({ isMobile }: { isMobile?: boolean }) => {
- -
-
- 모각코
-
-
- -
-
- 스터디
-
-
- -
-
- 사이드 프로젝트
-
-
- -
-
- 취미
-
-
+ {navigation.map((item) => (
+ -
+
+ {item.label}
+
+
+ ))}
);
};
diff --git a/src/components/common/PositionSelect.tsx b/src/components/common/PositionSelect.tsx
new file mode 100644
index 0000000..aabbe03
--- /dev/null
+++ b/src/components/common/PositionSelect.tsx
@@ -0,0 +1,35 @@
+import Chip from '@/components/ui/Chip';
+
+export const PositionSelect = ({
+ position,
+ setPosition,
+}: {
+ position: string;
+ setPosition: (value: string) => void;
+}) => {
+ return (
+
+ setPosition('Frontend')}
+ >
+ 프론트엔드
+
+ setPosition('Backend')}
+ >
+ 백엔드
+
+ setPosition('Designer')}
+ >
+ 디자이너
+
+
+ );
+};
diff --git a/src/components/common/Toast.tsx b/src/components/common/Toast.tsx
new file mode 100644
index 0000000..1504b71
--- /dev/null
+++ b/src/components/common/Toast.tsx
@@ -0,0 +1,99 @@
+import { cn } from '@/util/cn';
+import { type VariantProps, cva } from 'class-variance-authority';
+import React, { forwardRef, useEffect, useState } from 'react';
+
+import CheckIcon from '../../assets/icon/check_icon.svg';
+import WarningIcon from '../../assets/icon/warning_icon.svg';
+import { Button } from '../ui/Button';
+
+const ToastVariants = cva(
+ 'typo-head4 flex h-[64px] w-fit items-center rounded-[20px] border border-main bg-BG p-[16px] text-white transition-opacity duration-300 ease-in-out',
+ {
+ variants: {
+ variant: {
+ default: '',
+ success: '',
+ error: 'border-warning',
+ },
+ },
+ defaultVariants: {
+ variant: 'default',
+ },
+ },
+);
+
+export interface ToastProps
+ extends React.HTMLAttributes,
+ VariantProps {
+ duration?: number; // 표시 시간 (ms)
+ onDismiss?: () => void; // fade out 후 호출
+ btnText?: string;
+ onBtnClick?: () => void;
+}
+
+const Toast = forwardRef(
+ (
+ {
+ className,
+ variant = 'default',
+ children,
+ duration = 3000,
+ onDismiss,
+ btnText,
+ onBtnClick,
+ ...props
+ },
+ ref,
+ ) => {
+ const [visible, setVisible] = useState(false);
+
+ // 컴포넌트 마운트 후 fade in 효과 적용
+ useEffect(() => {
+ setVisible(true);
+ const timer = setTimeout(() => {
+ setVisible(false);
+ }, duration);
+ return () => clearTimeout(timer);
+ }, [duration]);
+
+ // fade out 애니메이션(300ms) 후 onDismiss 호출
+ useEffect(() => {
+ if (!visible) {
+ const timer = setTimeout(() => {
+ onDismiss && onDismiss();
+ }, 300);
+ return () => clearTimeout(timer);
+ }
+ }, [visible, onDismiss]);
+
+ return (
+
+ {variant === 'success' ? (
+
+ ) : variant === 'error' ? (
+
+ ) : null}
+ {children}
+ {btnText && (
+
+ {btnText}
+
+ )}
+
+ );
+ },
+);
+
+Toast.displayName = 'Toast';
+
+export { Toast, ToastVariants };
diff --git a/src/components/common/ToastContext.tsx b/src/components/common/ToastContext.tsx
new file mode 100644
index 0000000..414c3fc
--- /dev/null
+++ b/src/components/common/ToastContext.tsx
@@ -0,0 +1,80 @@
+'use client';
+
+import React, { ReactNode, createContext, useContext, useState } from 'react';
+
+import { Toast } from './Toast';
+
+interface ToastItem {
+ id: number;
+ message: string;
+ variant: 'default' | 'success' | 'error';
+ duration: number;
+ btnText?: string;
+ onClick?: () => void;
+}
+
+interface ToastContextType {
+ showToast: (
+ message: string,
+ variant?: 'default' | 'success' | 'error',
+ options?: { duration?: number; btnText?: string; onClick?: () => void },
+ ) => void;
+}
+
+const ToastContext = createContext(undefined);
+
+export const ToastProvider = ({ children }: { children: ReactNode }) => {
+ const [toasts, setToasts] = useState([]);
+
+ const showToast = (
+ message: string,
+ variant: 'default' | 'success' | 'error' = 'default',
+ options?: { duration?: number; btnText?: string; onClick?: () => void },
+ ) => {
+ const id = Date.now();
+ const duration = options?.duration ?? 3000;
+ setToasts((prev) => [
+ ...prev,
+ {
+ id,
+ message,
+ variant,
+ duration,
+ btnText: options?.btnText,
+ onClick: options?.onClick,
+ },
+ ]);
+ };
+
+ const removeToast = (id: number) => {
+ setToasts((prev) => prev.filter((toast) => toast.id !== id));
+ };
+
+ return (
+
+ {children}
+
+ {toasts.map((toast) => (
+ removeToast(toast.id)}
+ btnText={toast.btnText}
+ onClick={toast.onClick}
+ >
+ {toast.message}
+
+ ))}
+
+
+ );
+};
+
+export const useToast = () => {
+ const context = useContext(ToastContext);
+ if (!context) {
+ throw new Error('useToast must be used within a ToastProvider');
+ }
+ return context;
+};
diff --git a/src/components/ui/Chip.tsx b/src/components/ui/Chip.tsx
new file mode 100644
index 0000000..50fa4cc
--- /dev/null
+++ b/src/components/ui/Chip.tsx
@@ -0,0 +1,28 @@
+import { cn } from '@/util/cn';
+import React from 'react';
+
+interface IChipProps extends React.ComponentPropsWithRef<'div'> {
+ isActive?: boolean;
+}
+
+const Chip = React.forwardRef(
+ ({ children, className, isActive = false, ...props }, ref) => {
+ return (
+
+ {children}
+
+ );
+ },
+);
+
+Chip.displayName = 'Chip';
+
+export default Chip;
diff --git a/src/hooks/mutations/useUserMutation.ts b/src/hooks/mutations/useUserMutation.ts
index 93e8278..4f5bbf8 100644
--- a/src/hooks/mutations/useUserMutation.ts
+++ b/src/hooks/mutations/useUserMutation.ts
@@ -1,12 +1,20 @@
+import { ISignupFormData } from '@/app/signup/page';
+import { useToast } from '@/components/common/ToastContext';
import { setAccessToken } from '@/lib/serverActions';
import { useMutation } from '@tanstack/react-query';
-import { postLogin } from 'service/api/user';
+import {
+ getEmailCheck,
+ getNameCheck,
+ postLogin,
+ postSignup,
+} from 'service/api/user';
const useLoginMutation = ({
onSuccessCallback,
}: {
onSuccessCallback: () => void;
}) => {
+ const { showToast } = useToast();
return useMutation({
mutationFn: ({ email, password }: { email: string; password: string }) =>
postLogin({ email, password }),
@@ -17,13 +25,88 @@ const useLoginMutation = ({
await setAccessToken(accessToken);
}
+ showToast('로그인 성공', 'success');
// 메인페이지로 리다이렉트
onSuccessCallback();
},
onError: () => {
- console.log('로그인 에러');
+ showToast('이메일 또는 비밀번호가 틀렸습니다.', 'error');
},
});
};
-export { useLoginMutation };
+// 닉네임 중복 검사
+const useNameCheckMutation = ({
+ onSuccessCallback,
+ onErrorCallback,
+}: {
+ onSuccessCallback: () => void;
+ onErrorCallback: () => void;
+}) => {
+ return useMutation({
+ mutationFn: (name: string) => getNameCheck(name),
+ onSuccess: () => {
+ // 중복 검사 성공
+ /**
+ * TODO
+ * - 중복확인 버튼 비활성화
+ */
+ onSuccessCallback();
+ },
+ onError: () => {
+ // 중복 검사 실패
+ onErrorCallback();
+ },
+ });
+};
+
+// 이메일 중복 검사
+const useEmailCheckMutation = ({
+ onSuccessCallback,
+ onErrorCallback,
+}: {
+ onSuccessCallback: () => void;
+ onErrorCallback: () => void;
+}) => {
+ return useMutation({
+ mutationFn: (email: string) => getEmailCheck(email),
+ onSuccess: () => {
+ // 중복 검사 성공
+ /**
+ * TODO
+ * - 중복확인 버튼 비활성화
+ */
+ onSuccessCallback();
+ },
+ onError: () => {
+ // 중복 검사 실패
+ onErrorCallback();
+ },
+ });
+};
+
+// 회원가입
+const useSignupMutation = ({
+ onSuccessCallback,
+}: {
+ onSuccessCallback: () => void;
+}) => {
+ return useMutation({
+ mutationFn: (data: ISignupFormData) => postSignup(data),
+ onSuccess: () => {
+ /**
+ * TODO
+ * - 로그인 페이지로 리다이렉트
+ * - 회원가입 성공 토스트바
+ */
+ onSuccessCallback();
+ },
+ });
+};
+
+export {
+ useLoginMutation,
+ useNameCheckMutation,
+ useEmailCheckMutation,
+ useSignupMutation,
+};
diff --git a/src/hooks/useDebounde.ts b/src/hooks/useDebounde.ts
index f0f587e..1d665ef 100644
--- a/src/hooks/useDebounde.ts
+++ b/src/hooks/useDebounde.ts
@@ -1,4 +1,4 @@
-import { useEffect, useState } from 'react';
+import { useEffect } from 'react';
/**
* 특정 값이 변경된 후 지정된 시간이 지나면 콜백 함수를 실행하는 Debounce 훅
@@ -17,7 +17,7 @@ const useDebounce = ({
callBack?: () => void;
}) => {
useEffect(() => {
- if (!value) return;
+ if (value === null || value === undefined) return;
const timer = setTimeout(() => {
if (callBack) {
callBack();
diff --git a/src/hooks/useLoginForm.ts b/src/hooks/useLoginForm.ts
index f66b93b..0a309ec 100644
--- a/src/hooks/useLoginForm.ts
+++ b/src/hooks/useLoginForm.ts
@@ -1,16 +1,10 @@
import { useRouter } from 'next/navigation';
-import { useCallback, useState } from 'react';
-import { useForm, useWatch } from 'react-hook-form';
+import { useForm } from 'react-hook-form';
+import { ILoginFormData } from 'types/auth';
import { useLoginMutation } from './mutations/useUserMutation';
-import useDebounce from './useDebounde';
-interface ILoginFormData {
- email: string;
- password: string;
-}
-
-export function useLoginForm() {
+const useLoginForm = () => {
const {
register,
handleSubmit,
@@ -22,33 +16,6 @@ export function useLoginForm() {
});
const router = useRouter();
- const [focusedField, setFocusedField] = useState<'email' | 'password' | null>(
- null,
- );
-
- // `useWatch`를 사용하여 특정 필드만 감시 (렌더링 최소화)
- const email = useWatch({ control, name: 'email' });
- const password = useWatch({ control, name: 'password' });
-
- // 이메일 포커스 1초 뒤 유효성 검사
- useDebounce({
- value: email,
- callBack: useCallback(() => {
- if (focusedField === 'email') {
- trigger(focusedField);
- }
- }, [focusedField, trigger]),
- });
-
- // 비밀번호 포커스 1초 뒤 유효성 검사
- useDebounce({
- value: password,
- callBack: useCallback(() => {
- if (focusedField === 'password') {
- trigger(focusedField);
- }
- }, [focusedField, trigger]),
- });
const { mutate } = useLoginMutation({
onSuccessCallback: () => router.push('/'),
@@ -62,7 +29,10 @@ export function useLoginForm() {
register,
handleSubmit,
errors,
- setFocusedField,
onSubmit,
+ control,
+ trigger,
};
-}
+};
+
+export default useLoginForm;
diff --git a/src/hooks/useSignupForm.ts b/src/hooks/useSignupForm.ts
new file mode 100644
index 0000000..befd64f
--- /dev/null
+++ b/src/hooks/useSignupForm.ts
@@ -0,0 +1,120 @@
+import {
+ useEmailCheckMutation,
+ useNameCheckMutation,
+ useSignupMutation,
+} from '@/hooks/mutations/useUserMutation';
+import { useRouter } from 'next/navigation';
+import { useState } from 'react';
+import { useForm } from 'react-hook-form';
+import { ISignupFormData } from 'types/auth';
+
+const useSignUpForm = () => {
+ const {
+ register,
+ handleSubmit,
+ watch,
+ trigger,
+ setError,
+ setValue,
+ formState: { errors, dirtyFields },
+ control,
+ } = useForm({
+ mode: 'onBlur',
+ defaultValues: {
+ position: '',
+ },
+ });
+
+ // 처음에는 중복확인 버튼 비활성화
+ const [isNameCheck, setIsNameCheck] = useState(false);
+ const [isEmailCheck, setIsEmailCheck] = useState(false);
+
+ const router = useRouter();
+
+ // 중복확인 로직 수행
+ const { mutate: nameCheckMutate } = useNameCheckMutation({
+ onSuccessCallback: () => setIsNameCheck(true),
+ onErrorCallback: () =>
+ setError('name', {
+ type: 'checkFail',
+ message: '이미 존재하는 닉네임입니다.',
+ }),
+ });
+
+ const { mutate: emailCheckMutate } = useEmailCheckMutation({
+ onSuccessCallback: () => setIsEmailCheck(true),
+ onErrorCallback: () =>
+ setError('email', {
+ type: 'checkFail',
+ message: '이미 존재하는 이메일입니다.',
+ }),
+ });
+
+ const handleNameCheck = async () => {
+ const name = watch('name');
+ const isValid = await trigger('name');
+
+ if (isValid) {
+ nameCheckMutate(name);
+ }
+ };
+
+ const handleEmailCheck = async () => {
+ const email = watch('email');
+ const isValid = await trigger('email');
+
+ if (isValid) {
+ emailCheckMutate(email);
+ }
+ };
+
+ // 회원가입 제출
+ const { mutate: singupMutate } = useSignupMutation({
+ onSuccessCallback: () => router.push('/login'),
+ });
+
+ const onSubmit = (data: ISignupFormData) => {
+ if (!isNameCheck) {
+ setError('name', {
+ type: 'nameCheck',
+ message: '닉네임 중복확인이 필요합니다.',
+ });
+ }
+ if (!isEmailCheck) {
+ setError('email', {
+ type: 'emailCheck',
+ message: '이메일 중복확인이 필요합니다.',
+ });
+ }
+
+ if (Object.keys(errors).length) {
+ return;
+ }
+ singupMutate(data);
+ };
+
+ // 포지션 클릭
+ const handleClickPosition = (value: string) => {
+ setValue('position', value);
+ trigger('position');
+ };
+
+ return {
+ handleSubmit,
+ onSubmit,
+ register,
+ errors,
+ isNameCheck,
+ handleNameCheck,
+ isEmailCheck,
+ handleEmailCheck,
+ handleClickPosition,
+ dirtyFields,
+ control,
+ setIsNameCheck,
+ setIsEmailCheck,
+ trigger,
+ };
+};
+
+export default useSignUpForm;
diff --git a/src/service/api/user.ts b/src/service/api/user.ts
index 8836b4c..f074708 100644
--- a/src/service/api/user.ts
+++ b/src/service/api/user.ts
@@ -1,3 +1,4 @@
+import { ISignupFormData } from '@/app/signup/page';
import { basicAPI } from '@/lib/axios/basicApi';
const postLogin = async ({
@@ -11,4 +12,20 @@ const postLogin = async ({
return res;
};
-export { postLogin };
+
+const getNameCheck = async (name: string) => {
+ const res = await basicAPI.get(`/api/v1/auths/signup/name?name=${name}`);
+ return res;
+};
+
+const getEmailCheck = async (email: string) => {
+ const res = await basicAPI.get(`/api/v1/auths/signup/email?email=${email}`);
+ return res;
+};
+
+const postSignup = async (data: ISignupFormData) => {
+ const res = await basicAPI.post('/api/v1/auths/signup', data);
+ return res;
+};
+
+export { postLogin, getNameCheck, getEmailCheck, postSignup };
diff --git a/src/types/auth.ts b/src/types/auth.ts
new file mode 100644
index 0000000..4c4ce1d
--- /dev/null
+++ b/src/types/auth.ts
@@ -0,0 +1,29 @@
+import {
+ Control,
+ FieldErrors,
+ FieldValues,
+ UseFormRegister,
+ UseFormTrigger,
+} from 'react-hook-form';
+
+interface ISignupFormData {
+ name: string;
+ email: string;
+ position: string;
+ password: string;
+ passwordCheck: string;
+}
+
+interface ILoginFormData {
+ email: string;
+ password: string;
+}
+
+interface IInputProps {
+ control: Control; // ✅ control 타입 지정
+ register: UseFormRegister; // ✅ register 타입 지정
+ errors: FieldErrors; // ✅ errors 타입 지정
+ trigger?: UseFormTrigger; // ✅ trigger 타입 지정
+}
+
+export type { ISignupFormData, ILoginFormData, IInputProps };
diff --git a/src/util/validation.ts b/src/util/validation.ts
new file mode 100644
index 0000000..82a16a2
--- /dev/null
+++ b/src/util/validation.ts
@@ -0,0 +1,64 @@
+// 회원가입 유효성 검사
+export const nameValidation = {
+ required: '닉네임을 입력해주세요.',
+ minLength: {
+ value: 2,
+ message: '최소 2자 이상 입력해 주세요.',
+ },
+ maxLength: {
+ value: 10,
+ message: '최대 10자 이하로 입력해 주세요.',
+ },
+ pattern: {
+ value: /^[가-힣a-zA-Z0-9]+$/,
+ message: '한글(완성형), 영어, 숫자만 입력할 수 있습니다.',
+ },
+};
+
+export const emailValidation = {
+ required: '이메일을 입력해주세요.',
+ pattern: {
+ value: /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/,
+ message: '올바른 이메일 형식이 아닙니다.',
+ },
+};
+
+export const passwordValidation = {
+ required: '비밀번호를 입력해주세요.',
+ minLength: {
+ value: 6,
+ message: '비밀번호는 최소 8자 이상이어야 합니다.',
+ },
+ pattern: {
+ value: /^(?=.*[A-Za-z])(?=.*\d)[A-Za-z\d]{8,}$/,
+ message: '비밀번호는 영어와 숫자 포함 8자 이상이어야 합니다.',
+ },
+};
+
+export const passwordCheckValidation = (password: string) => ({
+ validate: (value: string) => {
+ if (value === '') return true;
+ return value === password || '비밀번호가 일치하지 않습니다.';
+ },
+});
+
+export const positionValidation = {
+ required: '포지션을 선택해 주세요.',
+};
+
+// 로그인 유효성 검사
+export const loginEmailValidation = {
+ required: '이메일을 입력해주세요.',
+ pattern: {
+ value: /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/,
+ message: '올바른 이메일 형식이 아닙니다.',
+ },
+};
+
+export const loginPasswordValidation = {
+ required: '비밀번호를 입력해주세요.',
+ minLength: {
+ value: 6,
+ message: '비밀번호는 최소 6자 이상이어야 합니다.',
+ },
+};