diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 93befb5..e4bf0fb 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,4 +1,5 @@ import Header from '@/components/common/Header'; +import { ToastProvider } from '@/components/common/ToastContext'; import ReactQueryProviders from '@/hooks/useReactQuery'; import type { Metadata } from 'next'; import localFont from 'next/font/local'; @@ -26,8 +27,10 @@ export default function RootLayout({ -
-
{children}
+ +
+
{children}
+ diff --git a/src/app/login/components/EmailInput.tsx b/src/app/login/components/EmailInput.tsx new file mode 100644 index 0000000..244a78c --- /dev/null +++ b/src/app/login/components/EmailInput.tsx @@ -0,0 +1,41 @@ +'use client'; + +import { Input } from '@/components/ui/Input'; +import useDebounce from '@/hooks/useDebounde'; +import { loginEmailValidation } from '@/util/validation'; +import { useCallback } from 'react'; +import { useWatch } from 'react-hook-form'; +import { IInputProps, ILoginFormData } from 'types/auth'; + +const EmailInput = ({ + control, + register, + errors, + trigger, +}: IInputProps) => { + const email = useWatch({ control, name: 'email' }); + + useDebounce({ + value: email, + callBack: useCallback(() => { + trigger?.('email'); + }, [email]), + }); + + return ( + <> + + + + ); +}; + +export default EmailInput; diff --git a/src/app/login/components/LoginForm.tsx b/src/app/login/components/LoginForm.tsx index bc39a0c..c8913aa 100644 --- a/src/app/login/components/LoginForm.tsx +++ b/src/app/login/components/LoginForm.tsx @@ -1,13 +1,18 @@ 'use client'; import { Button } from '@/components/ui/Button'; -import { Input } from '@/components/ui/Input'; -import { useLoginForm } from '@/hooks/useLoginForm'; +import useLoginForm from '@/hooks/useLoginForm'; import Link from 'next/link'; +import { useRouter } from 'next/navigation'; + +import EmailInput from './EmailInput'; +import PasswordInput from './PasswordInput'; const LoginForm = () => { - const { register, handleSubmit, onSubmit, errors, setFocusedField } = + const router = useRouter(); + const { register, handleSubmit, onSubmit, errors, control, trigger } = useLoginForm(); + return (
{

로그인

- - 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 ( +
+ + + +
+ ); +} 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 && ( + + )} +
+ ); + }, +); + +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자 이상이어야 합니다.', + }, +};