From 6da68d61926af5b529691b1253133b1ce5d68730 Mon Sep 17 00:00:00 2001 From: Lee hwan seuk Date: Tue, 28 Oct 2025 11:01:01 +0900 Subject: [PATCH 01/14] =?UTF-8?q?chore(signup):=20react-hook-form=20?= =?UTF-8?q?=EB=B0=8F=20zod=20=EC=84=A4=EC=B9=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/pages/SignUp/utils/SignupSchemas.ts | 27 +++++++++++++++++++ 1 file changed, 27 insertions(+) create mode 100644 apps/web/src/pages/SignUp/utils/SignupSchemas.ts diff --git a/apps/web/src/pages/SignUp/utils/SignupSchemas.ts b/apps/web/src/pages/SignUp/utils/SignupSchemas.ts new file mode 100644 index 0000000..a4decc3 --- /dev/null +++ b/apps/web/src/pages/SignUp/utils/SignupSchemas.ts @@ -0,0 +1,27 @@ +import { z } from 'zod'; + +export const signUpSchema = z + .object({ + email: z.string().email('유효한 이메일을 입력해주세요.'), + password: z + .string() + .min(8, '비밀번호는 8자 이상이어야 합니다.') + .max(16, '비밀번호는 16자 이하여야 합니다.') + .regex( + /^(?=.*[a-zA-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]+$/, + '영문, 숫자, 특수문자를 조합해주세요.', + ), + confirmPassword: z.string(), + name: z + .string() + .max(10, '최대 글자수를 초과했습니다.') + .refine((val) => !/\s/.test(val), { + message: '띄어쓰기 없이 붙여 작성해주세요.', + }), + }) + .refine((data) => data.password === data.confirmPassword, { + message: '비밀번호가 일치하지 않습니다.', + path: ['confirmPassword'], + }); + +export type SignUpFormData = z.infer; From c402c32f80b1acc7611f0ca37ab6855399012e5e Mon Sep 17 00:00:00 2001 From: Lee hwan seuk Date: Tue, 28 Oct 2025 13:10:07 +0900 Subject: [PATCH 02/14] =?UTF-8?q?feat(signup):=20=EC=9D=B4=EB=A9=94?= =?UTF-8?q?=EC=9D=BC=20=EC=A4=91=EB=B3=B5=ED=99=95=EC=9D=B8=20api=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/web/.env | 1 + apps/web/src/App.tsx | 7 +- apps/web/src/api/api.ts | 11 + apps/web/src/api/signup/signup.ts | 21 ++ .../src/pages/Login/components/Loginforrm.tsx | 76 +++--- .../pages/Login/components/PasswordField.tsx | 86 +++---- .../src/pages/SignUp/components/FormInput.tsx | 81 ------- .../pages/SignUp/components/SignUpform.tsx | 224 +++++++++++------- .../src/pages/SignUp/utils/SignupSchemas.ts | 1 + apps/web/src/pages/SignUp/utils/validation.ts | 14 -- ....timestamp-1761617098230-29eb2cf741d2a.mjs | 28 +++ .../src/components/InputField/TextField.tsx | 69 +++--- 12 files changed, 337 insertions(+), 282 deletions(-) create mode 100644 apps/web/.env create mode 100644 apps/web/src/api/api.ts create mode 100644 apps/web/src/api/signup/signup.ts delete mode 100644 apps/web/src/pages/SignUp/components/FormInput.tsx delete mode 100644 apps/web/src/pages/SignUp/utils/validation.ts create mode 100644 apps/web/vite.config.ts.timestamp-1761617098230-29eb2cf741d2a.mjs diff --git a/apps/web/.env b/apps/web/.env new file mode 100644 index 0000000..5afb925 --- /dev/null +++ b/apps/web/.env @@ -0,0 +1 @@ +VITE_BASE_URL =https://api.bugi.co.kr \ No newline at end of file diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 04dc50b..eb96790 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -3,8 +3,11 @@ import { useState } from 'react'; import { Alert, Button } from 'ui'; import { router } from './routers'; import { RouterProvider } from 'react-router-dom'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; function App() { + const queryClient = new QueryClient(); + const [inputData, setInputData] = useState('0.1'); // React Query hooks @@ -26,9 +29,9 @@ function App() { }; return ( - <> + - + ); } diff --git a/apps/web/src/api/api.ts b/apps/web/src/api/api.ts new file mode 100644 index 0000000..fa8ca1d --- /dev/null +++ b/apps/web/src/api/api.ts @@ -0,0 +1,11 @@ +import axios, { AxiosInstance } from 'axios'; + +const api: AxiosInstance = axios.create({ + baseURL: import.meta.env.VITE_BASE_URL as string, + withCredentials: true, + headers: { + 'Content-Type': 'application/json', + }, +}); + +export default api; diff --git a/apps/web/src/api/signup/signup.ts b/apps/web/src/api/signup/signup.ts new file mode 100644 index 0000000..6b205a7 --- /dev/null +++ b/apps/web/src/api/signup/signup.ts @@ -0,0 +1,21 @@ +import { useMutation } from '@tanstack/react-query'; +import api from '../api'; + +export interface SignupRequest { + email: string; + password: string; + name: string; + callbackUrl: string; + avatar: string; +} + +const duplicatedEmail = async (email: string) => { + const response = await api.post('/auth/check-email', { email }); + return response.data; +}; + +export const useDuplicatedEmailMutation = () => { + return useMutation({ + mutationFn: duplicatedEmail, + }); +}; diff --git a/apps/web/src/pages/Login/components/Loginforrm.tsx b/apps/web/src/pages/Login/components/Loginforrm.tsx index 3f7a574..13e39a9 100644 --- a/apps/web/src/pages/Login/components/Loginforrm.tsx +++ b/apps/web/src/pages/Login/components/Loginforrm.tsx @@ -1,9 +1,9 @@ -import { useState } from 'react'; +import { useForm } from 'react-hook-form'; import TextInput from '@ui/InputField/TextField'; import SaveIdIcon from '@assets/auth/saveid_icon.svg?react'; import LoginButton from './LoginButton'; -import { useNavigate } from 'react-router-dom'; import PasswordField from './PasswordField'; +import { useNavigate } from 'react-router-dom'; interface LoginFormData { email: string; @@ -12,60 +12,64 @@ interface LoginFormData { } const LoginForm = () => { - const [formData, setFormData] = useState({ - email: '', - password: '', - saveId: false, + const { + register, + handleSubmit, + watch, + formState: { isValid }, + } = useForm({ + mode: 'onChange', + defaultValues: { + email: '', + password: '', + saveId: false, + }, }); - const handleInputChange = ( - field: keyof LoginFormData, - value: string | boolean, - ) => { - setFormData((prev) => ({ ...prev, [field]: value })); + const navigate = useNavigate(); + + const onSubmit = (data: LoginFormData) => { + console.log('로그인 시도:', data); }; - const navigate = useNavigate(); + const password = watch('password'); return ( <> - {/* 이메일 부분 */} -
-
- handleInputChange('email', e.target.value)} - className="hbp:text-body-lg-regular aspect-[44/6]" - /> -
- {/* 비밀번호 부분 */} - handleInputChange('password', e.target.value)} + + {/* 이메일 */} + - {/* id 저장 부분 */} + + {/* 비밀번호 */} + + + {/* 아이디 저장 */}
- {/* 로그인 버튼 컴포넌트 */} - + + {/* 버튼 */} + + {/* 회원가입 / 비밀번호 찾기 */}
) => void; + hasValue?: boolean; + onChange?: (e: React.ChangeEvent) => void; placeholder?: string; className?: string; + name?: string; } -const PasswordField = ({ - value, - onChange, - placeholder = '비밀번호', - className = '', -}: PasswordFieldProps) => { - /* 비밀번호 보이기/숨기기 */ - const [isVisible, setIsVisible] = useState(false); +const PasswordField = forwardRef( + ( + { hasValue, onChange, placeholder = '비밀번호', className = '', name }, + ref, + ) => { + /* 비밀번호 보이기/숨기기 */ + const [isVisible, setIsVisible] = useState(false); - const toggleVisibility = () => setIsVisible((prev) => !prev); + const toggleVisibility = () => setIsVisible((prev) => !prev); - return ( -
- - {value && ( - - )} -
- ); -}; + return ( +
+ + + {/*hasvalue 있을때만 invisible/visible 아이콘 보이기 */} + {hasValue && ( + + )} +
+ ); + }, +); + +PasswordField.displayName = 'PasswordField'; export default PasswordField; diff --git a/apps/web/src/pages/SignUp/components/FormInput.tsx b/apps/web/src/pages/SignUp/components/FormInput.tsx deleted file mode 100644 index 7607957..0000000 --- a/apps/web/src/pages/SignUp/components/FormInput.tsx +++ /dev/null @@ -1,81 +0,0 @@ -import React from 'react'; -import TextField from '@ui/InputField/TextField'; -import SuccessIcon from '@assets/auth/success_icon.svg?react'; -import FailIcon from '@assets/auth/error_icon.svg?react'; -import PasswordField from '../../Login/components/PasswordField'; - -interface FormInputProps { - id?: string; - type?: string; - placeholder?: string; - value: string; - onChange: (e: React.ChangeEvent) => void; - error?: string; - successMessage?: string; - compareValue?: string; - className?: string; -} - -export default function FormInput({ - id, - type = 'text', - placeholder, - value, - onChange, - error, - successMessage, - compareValue, - className = '', -}: FormInputProps) { - /* 입력 필드 유효성에 따른 테두리 색상 조건부 적용 */ - const borderClass = !value - ? 'focus:border-yellow-500' - : compareValue !== undefined - ? value !== compareValue - ? 'border-red-500 ' - : 'border-green-500' - : error - ? 'border-red-500 focus:border-red-500' - : 'focus:border-yellow-500'; - - /* 회원가입 입력 필드 유효성 검사 메시지 */ - const showError = !!error && !successMessage; - const showSuccess = !!successMessage && !error; - - return ( -
- {/* password 타입일 때 PasswordField 적용 */} - {type === 'password' ? ( - - ) : ( - - )} - - {/* 회원가입 입력 필드 유효성 검사 메시지 */} - {(showError || showSuccess) && ( -
- {showSuccess ? : } -

- {showSuccess ? successMessage : error} -

-
- )} -
- ); -} diff --git a/apps/web/src/pages/SignUp/components/SignUpform.tsx b/apps/web/src/pages/SignUp/components/SignUpform.tsx index b545b53..936f0e4 100644 --- a/apps/web/src/pages/SignUp/components/SignUpform.tsx +++ b/apps/web/src/pages/SignUp/components/SignUpform.tsx @@ -1,64 +1,72 @@ -import React, { useState } from 'react'; +import { useForm } from 'react-hook-form'; +import { zodResolver } from '@hookform/resolvers/zod'; import { Button } from 'ui/Button'; import TextField from 'ui/TextField'; -import { validateName, validatePasswordMatch } from '../utils/validation'; -import FormInput from './FormInput'; import PasswordField from '../../Login/components/PasswordField'; - -interface SignUpFormData { - email: string; - password: string; - confirmPassword: string; - name: string; -} - -interface FormErrors { - email: string; - password: string; - name: string; -} +import SuccessIcon from '@assets/auth/success_icon.svg?react'; +import FailIcon from '@assets/auth/error_icon.svg?react'; +import { signUpSchema, SignUpFormData } from '../utils/SignupSchemas'; +import { useDuplicatedEmailMutation } from '../../../api/signup/signup'; +import { useState } from 'react'; const SignUpForm = () => { - const [formData, setFormData] = useState({ - email: '', - password: '', - confirmPassword: '', - name: '', - }); + const { mutate: checkDuplicateEmail } = useDuplicatedEmailMutation(); + const [duplicateMessage, setDuplicateMessage] = useState(null); + const [duplicateSuccess, setDuplicateSuccess] = useState( + null, + ); - const [errors, setErrors] = useState({ - email: '', - password: '', - name: '', + const { + register, + handleSubmit, + getValues, + watch, + formState: { errors, isValid }, + } = useForm({ + resolver: zodResolver(signUpSchema), + mode: 'onChange', + defaultValues: { + email: '', + password: '', + confirmPassword: '', + name: '', + }, }); - /* 입력 상태 변화 */ - const handleInputChange = - (field: keyof SignUpFormData) => - (e: React.ChangeEvent) => { - const value = e.target.value; - setFormData((prev) => ({ - ...prev, - [field]: value, - })); - if (field === 'name') { - setErrors((prev) => ({ ...prev, name: validateName(value) })); - } - - if (field === 'password' || field === 'confirmPassword') { - setErrors((prev) => ({ - ...prev, - password: validatePasswordMatch( - field === 'password' ? value : formData.password, - field === 'confirmPassword' ? value : formData.confirmPassword, - ), - })); - } - }; + /* 실시간 입력값 */ + const formValues = watch(); + + const handleDuplicateCheck = () => { + const email = getValues('email'); + if (!email) return; + + checkDuplicateEmail(email, { + onSuccess: (data) => { + if (data?.isDuplicate) { + setDuplicateSuccess(false); + setDuplicateMessage('이미 가입된 이메일입니다.'); + } else { + setDuplicateSuccess(true); + setDuplicateMessage('사용 가능한 이메일입니다'); + } + }, + onError: () => { + setDuplicateSuccess(false); + setDuplicateMessage('이미 가입된 이메일입니다.'); + }, + }); + }; + + const onSubmit = (data: SignUpFormData) => { + console.log('Form submitted:', data); + }; return ( -
- {/*이메일 섹션*/} + + {/* 이메일 섹션 */}
- {errors.email && ( -

{errors.email}

+ {(errors.email || duplicateMessage) && ( +
+ {errors.email || duplicateSuccess === false ? ( + + ) : ( + + )} +

+ {errors.email?.message || duplicateMessage || ''} +

+
)}
@@ -98,26 +129,48 @@ const SignUpForm = () => { 영문, 숫자, 특수문자를 조합하여 8-16글자로 입력해주세요.

- {/*비밀번호 재입력 섹션 */} - + + { + /* 비밀번호 일치 여부 및 유효성 메시지 표시 */ + formValues.confirmPassword && + (() => { + /*비밀번호 불일치 또는 유효성 조건 미충족*/ + const isError = + formValues.password !== formValues.confirmPassword || + !!errors.password; + + const colorClass = isError ? 'text-red-500' : 'text-green-500'; + const Icon = isError ? FailIcon : SuccessIcon; + const message = isError + ? errors.password?.message || '비밀번호가 일치하지 않습니다.' + : '비밀번호가 일치합니다.'; + + return ( +
+ +

{message}

+
+ ); + })() + } {/* 이름 섹션 */} @@ -131,23 +184,30 @@ const SignUpForm = () => {

최대 10글자 이내로 작성해주세요.

- + + {(formValues.name || !!errors.name) && ( +
+ {errors.name ? : } +

+ {errors.name ? errors.name.message : '사용 가능한 이름입니다.'} +

+
+ )} {/* 완료 버튼 */} -