diff --git a/.eslintrc.cjs b/.eslintrc.cjs index 61e4af5b..b3474b60 100644 --- a/.eslintrc.cjs +++ b/.eslintrc.cjs @@ -36,6 +36,12 @@ module.exports = { 'import/extensions': 'off', 'jsx-a11y/no-noninteractive-element-interactions': 'warn', 'vitest/valid-title': 'off', + 'jsx-a11y/label-has-associated-control': [ + 2, + { + labelAttributes: ['htmlFor'], + }, + ], }, ignorePatterns: [ 'dist', diff --git a/package.json b/package.json index dcddc0f3..62bb1afe 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,7 @@ "react-hook-form": "^7.51.4", "react-icons": "^5.2.1", "react-router-dom": "^6.23.1", + "tailwind-scrollbar-hide": "^1.1.7", "zustand": "^4.5.2" }, "devDependencies": { diff --git a/src/assets/auth_logo.svg b/src/assets/auth_logo.svg new file mode 100644 index 00000000..f4dfe7bb --- /dev/null +++ b/src/assets/auth_logo.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/src/assets/social_google_icon.svg b/src/assets/social_google_icon.svg new file mode 100644 index 00000000..0b87f9a7 --- /dev/null +++ b/src/assets/social_google_icon.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/src/assets/social_kakao_icon.svg b/src/assets/social_kakao_icon.svg new file mode 100644 index 00000000..249c5857 --- /dev/null +++ b/src/assets/social_kakao_icon.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/src/components/common/ValidationInput.tsx b/src/components/common/ValidationInput.tsx new file mode 100644 index 00000000..5e7e0229 --- /dev/null +++ b/src/components/common/ValidationInput.tsx @@ -0,0 +1,106 @@ +import { useState } from 'react'; +import { UseFormRegisterReturn } from 'react-hook-form'; +import { RiEyeFill, RiEyeOffFill } from 'react-icons/ri'; + +/** + * ValidationInput 컴포넌트 params, 모든 params는 optional + * + * @params {string} [label] - 입력 필드의 label 텍스트 + * @params {boolean} [required] - 입력 필드 필수 여부 + * @params {string} [errors] - 유효성 검증 후 오류 발생 시 표시할 오류 메시지 + * @params {UseFormRegisterReturn} [register] - react-hook-form의 resister 함수. + * register('password', {required: ...})부분을 그대로 파라미터에 넣으면 됩니다. + * @params {string} [type] - 입력 필드의 유형(ex: text, password). 기본값은 'text' + * @params {string} [placeholder] - 입력 필드의 placeholder 텍스트 + * @params {boolean} [isButtonInput] - 버튼이 포함된 입력 필드인지 여부. 기본값은 'false' + * @params {React.ReactNode} [buttonLabel] - 버튼에 표시할 텍스트 또는 아이콘 + * @params {() => void} [onButtonClick] - 버튼 클릭 시 호출할 함수 + * + * 예시) + * value === watch('password') || '비밀번호가 일치하지 않습니다.', + })} + type="password" + /> + */ + +type ValidationInputProps = { + label?: string; + required?: boolean; + errors?: string; + register?: UseFormRegisterReturn; + type?: string; + placeholder?: string; + isButtonInput?: boolean; + buttonLabel?: React.ReactNode; + onButtonClick?: () => void; +}; + +export default function ValidationInput({ + label, + required = true, + errors, + register, + type = 'text', + placeholder, + isButtonInput = false, + buttonLabel, + onButtonClick, +}: ValidationInputProps) { + const [showPassword, setShowPassword] = useState(false); + + const handleTogglePassword = () => { + setShowPassword((prev) => !prev); + }; + + return ( + + + {label && ( + + {label} + {required && *} + + )} + + + + + {type === 'password' && ( + + {showPassword ? ( + + ) : ( + + )} + + )} + {isButtonInput && ( + + {buttonLabel} + + )} + + + {errors && {errors}} + + ); +} diff --git a/src/constants/formValidationRules.ts b/src/constants/formValidationRules.ts index 1f3ae126..d8c117f6 100644 --- a/src/constants/formValidationRules.ts +++ b/src/constants/formValidationRules.ts @@ -1,5 +1,6 @@ import Validator from '@utils/Validator'; import { deepFreeze } from '@utils/deepFreeze'; +import { EMAIL_REGEX, PASSWORD_REGEX, PHONE_REGEX } from './regex'; export const STATUS_VALIDATION_RULES = deepFreeze({ STATUS_NAME: (nameList: string[]) => ({ @@ -23,4 +24,44 @@ export const STATUS_VALIDATION_RULES = deepFreeze({ duplicatedName: (value: string) => !Validator.isDuplicatedName(colorList, value) || '이미 사용중인 색상입니다.', }, }), + EMAIL: () => ({ + required: '이메일 인증을 진행해 주세요.', + pattern: { + value: EMAIL_REGEX, + message: '이메일 형식에 맞지 않습니다.', + }, + }), + CERTIFICATION: () => ({ required: '인증번호를 입력해 주세요.' }), + PHONE: () => ({ + required: '휴대폰 번호 인증을 진행해 주세요.', + pattern: { + value: PHONE_REGEX, + message: '휴대폰 번호를 정확히 입력해 주세요.', + }, + }), + NICKNAME: () => ({ + required: '닉네임을 입력해 주세요.', + maxLength: { + value: 20, + message: '닉네임은 최대 20자까지 입력 가능합니다.', + }, + }), + PASSWORD: () => ({ + required: '비밀번호를 입력해 주세요.', + minLength: { + value: 8, + message: '비밀번호는 최소 8자 이상이어야 합니다.', + }, + maxLength: { + value: 16, + message: '비밀번호는 최대 16자 이하여야 합니다.', + }, + pattern: { + value: PASSWORD_REGEX, + message: '비밀번호는 영문자, 숫자, 기호를 포함해야 합니다.', + }, + }), + PASSWORD_CONFIRM: () => ({ + required: '비밀번호를 한 번 더 입력해 주세요.', + }), }); diff --git a/src/constants/regex.ts b/src/constants/regex.ts new file mode 100644 index 00000000..6863a928 --- /dev/null +++ b/src/constants/regex.ts @@ -0,0 +1,4 @@ +export const EMAIL_REGEX = /^[a-z0-9._%+-]+@[a-z0-9.-]+\.[a-z]{2,}(?:\.[a-z]{2,})?$/i; +export const PASSWORD_REGEX = + /^(?=.*[A-Za-z])(?=.*\d)(?=.*[~`!@#$%^&*()_\-+={[}\]|\\:;"'<,>.?/])[A-Za-z\d~`!@#$%^&*()_\-+={[}\]|\\:;"'<,>.?/]{8,16}$/; +export const PHONE_REGEX = /^01([0|1|6|7|8|9])-?([0-9]{3,4})-?([0-9]{4})$/; diff --git a/src/globals.css b/src/globals.css index 657b5d61..acc9a284 100644 --- a/src/globals.css +++ b/src/globals.css @@ -35,6 +35,7 @@ --color-main: #237700; --color-sub: #e1f4d9; --color-close: #be0000; + --color-error: #ff0000; --color-contents-box: #fdfdfd; --color-disable: #c2c2c2; --color-selected: #c2c2c2; @@ -57,7 +58,6 @@ --text-color-default: #2c2c2c; --text-color-emphasis: #5e5e5e; --text-color-category: #b1b1b1; - --text-color-error: #be0000; --text-color-blue: #0909e7; /* border color */ diff --git a/src/layouts/page/AuthLayout.tsx b/src/layouts/page/AuthLayout.tsx new file mode 100644 index 00000000..db28d263 --- /dev/null +++ b/src/layouts/page/AuthLayout.tsx @@ -0,0 +1,13 @@ +import { Outlet } from 'react-router-dom'; +import AuthGULogo from '@/assets/auth_logo.svg'; + +export default function AuthLayout() { + return ( + + + + + + + ); +} diff --git a/src/pages/user/SignInPage.tsx b/src/pages/user/SignInPage.tsx index 2ac27c76..d210a0af 100644 --- a/src/pages/user/SignInPage.tsx +++ b/src/pages/user/SignInPage.tsx @@ -1,3 +1,110 @@ +import { useNavigate } from 'react-router-dom'; +import { useForm } from 'react-hook-form'; +import Kakao from '@assets/social_kakao_icon.svg'; +import Google from '@assets/social_google_icon.svg'; +import { EMAIL_REGEX, PASSWORD_REGEX } from '@/constants/regex'; +import { UserSignIn } from '@/types/UserType'; + export default function SignInPage() { - return SignInPage; + const nav = useNavigate(); + const { + register, + handleSubmit, + formState: { errors, isSubmitting }, + } = useForm({ + mode: 'onChange', + defaultValues: { + email: '', + password: '', + }, + }); + + const onSubmit = (data: UserSignIn) => { + console.log(data); + }; + + return ( + + + Welcome to our site! + Grow Up your Life with us. + + + + + {errors.email && {errors.email.message}} + + + {errors.password && {errors.password.message}} + + + + 로그인 + + + + + nav('/search/id')} onKeyDown={() => nav('/search/id')}> + 아이디 찾기 + + | + nav('/search/password')} + onKeyDown={() => nav('/search/password')} + > + 비밀번호 찾기 + + + + + 회원이 아니신가요? + nav('/signup')}> + 회원가입 + + + + + + + 카카오 로그인 + + + + 구글 로그인 + + + + ); } diff --git a/src/pages/user/SignUpPage.tsx b/src/pages/user/SignUpPage.tsx index be64afeb..93dc3979 100644 --- a/src/pages/user/SignUpPage.tsx +++ b/src/pages/user/SignUpPage.tsx @@ -1,3 +1,259 @@ +import { Link } from 'react-router-dom'; +import { GoPlusCircle } from 'react-icons/go'; +import { FaRegTrashCan, FaPlus, FaMinus } from 'react-icons/fa6'; +import { ChangeEvent, useEffect, useState } from 'react'; +import { useForm } from 'react-hook-form'; +import { UserSignUp } from '@/types/UserType'; +import ValidationInput from '@/components/common/ValidationInput'; +import { STATUS_VALIDATION_RULES } from '@/constants/formValidationRules'; + export default function SignUpPage() { - return SignUpPage; + const [imagePreview, setImagePreview] = useState(''); + const [isFocused, setIsFocused] = useState(false); + const [link, setLink] = useState(''); + const [linksList, setLinksList] = useState([]); + + const { + register, + handleSubmit, + formState: { errors, isSubmitting }, + watch, + setValue, + } = useForm({ + mode: 'onChange', + defaultValues: { + image: [], // 추후 이미지 전송 폼 분리 예정 + email: '', + emailVerificationCode: '', + phone: '', + phoneVerificationCode: '', + nickname: '', + password: '', + checkPassword: '', + bio: '', + links: [''], + }, + }); + + // 이미지 미리보기 관련 코드 + const profileImage = watch('image'); + useEffect(() => { + if (profileImage && profileImage.length > 0) { + const file = profileImage[0]; + setImagePreview(URL.createObjectURL(file)); + } + }, [profileImage]); + + const handleRemoveImg = () => { + setValue('image', []); + setImagePreview(''); + }; + + // 웹사이트 링크 관련 코드 + const handleFocus = () => { + setIsFocused(true); + }; + + const handleBlur = () => { + setIsFocused(false); + }; + + const handleLinkChange = (e: ChangeEvent) => { + setLink(e.target.value); + }; + + const handleAddLink = (newLink: string) => { + if (newLink.trim() === '' || linksList.length === 3) { + // alert같은 걸로 유저에게 알려줘야 할 듯...? + return; + } + + setLinksList([...linksList, newLink.trim()]); + setValue('links', [...linksList, newLink.trim()]); + setLink(''); + }; + + const handleRemoveLink = (idx: number) => { + const filteredData = linksList.filter((_, index) => index !== idx); + setLinksList(filteredData); + setValue('links', filteredData); + }; + + // form 전송 함수 + const onSubmit = (data: UserSignUp) => { + const { emailVerificationCode, phoneVerificationCode, checkPassword, ...filteredData } = data; + console.log(filteredData); + }; + + return ( + + {/* 프로필 이미지 */} + + + {imagePreview ? ( + <> + + + + + + + > + ) : ( + + + + + )} + + + + {/* 이메일(아이디) */} + + + {/* 이메일 인증 */} + {/* 인증번호 발송이 완료되면 해당 폼으로 대체 */} + + + {/* 휴대폰 번호 */} + + + {/* 휴대폰 번호 인증 */} + + + {/* 닉네임, 중복 확인 */} + + + {/* 비밀번호 */} + + + {/* 비밀번호 확인 */} + value === watch('password') || '비밀번호가 일치하지 않습니다.', + })} + type="password" + /> + + {/* 자기소개 */} + + + 자기소개 + + + + + {/* 링크 */} + + 링크 + + {linksList && + linksList.map((item, index) => ( + // 추후 uuid를 사용해 각 링크별 고유 key를 부여할 예정 + // eslint-disable-next-line react/no-array-index-key + + + + + {item} + + + handleRemoveLink(index)} + className="flex h-20 w-20 items-center justify-center rounded-lg bg-sub px-8 font-bold shadow-md" + aria-label="삭제" + > + + + + + ))} + + + + handleAddLink(link)} + className="flex h-20 w-20 items-center justify-center rounded-lg bg-sub px-8 font-bold shadow-md" + aria-label="추가" + > + + + + + + + + {/* 회원가입 버튼 */} + + + 회원가입 + + + 로그인 페이지로 돌아가기 + + + + ); } diff --git a/src/routes/MainRouter.tsx b/src/routes/MainRouter.tsx index 76795e9d..16a8aed2 100644 --- a/src/routes/MainRouter.tsx +++ b/src/routes/MainRouter.tsx @@ -2,6 +2,7 @@ import { RouterProvider, createBrowserRouter } from 'react-router-dom'; import AfterLoginRoute from '@routes/AfterLoginRoute'; import BeforeLoginRoute from '@routes/BeforeLoginRoute'; +import AuthLayout from '@layouts/page/AuthLayout'; import TeamLayout from '@layouts/page/TeamLayout'; import DefaultLayout from '@layouts/page/DefaultLayout'; import SettingLayout from '@layouts/page/SettingLayout'; @@ -23,7 +24,11 @@ export default function MainRouter() { const router = createBrowserRouter([ { path: '/', - element: , + element: ( + + + + ), errorElement: , children: [ { path: 'signup', element: }, diff --git a/src/types/UserType.tsx b/src/types/UserType.tsx new file mode 100644 index 00000000..7c08a67e --- /dev/null +++ b/src/types/UserType.tsx @@ -0,0 +1,24 @@ +// 회원가입, 로그인 시 필요한 유저 타입 정의 +export type UserSignIn = { + email: string; + password: string; +}; + +export type UserSignUpForm = UserSignIn & { + image: File[]; + password: string; + nickname: string; + bio: string; + links: string[]; + phone: string; +}; + +export type UserSignUp = UserSignUpForm & { + emailVerificationCode: string; + phoneVerificationCode: string; + checkPassword: string; +}; + +export type UserSearchIdForm = { + phone: string; +}; diff --git a/tailwind.config.js b/tailwind.config.js index e28e3be7..f379513a 100644 --- a/tailwind.config.js +++ b/tailwind.config.js @@ -47,7 +47,6 @@ export default { default: 'var(--text-color-default)', emphasis: 'var(--text-color-emphasis)', category: 'var(--text-color-category)', - error: 'var(--text-color-error)', blue: 'var(--text-color-blue)', }, colors: { @@ -55,6 +54,7 @@ export default { sub: 'var(--color-sub)', close: 'var(--color-close)', 'contents-box': 'var(--color-contents-box)', + error: 'var(--color-error)', disable: 'var(--color-disable)', selected: 'var(--color-selected)', scroll: 'var(--color-scroll)', @@ -73,5 +73,5 @@ export default { }, }, }, - plugins: [], + plugins: [require('tailwind-scrollbar-hide')], }; diff --git a/yarn.lock b/yarn.lock index 55edd68d..8d5c5149 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3973,8 +3973,16 @@ string-argv@0.3.2: resolved "https://registry.yarnpkg.com/string-argv/-/string-argv-0.3.2.tgz#2b6d0ef24b656274d957d54e0a4bbf6153dc02b6" integrity sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q== -"string-width-cjs@npm:string-width@^4.2.0", string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: - name string-width-cjs +"string-width-cjs@npm:string-width@^4.2.0": + version "4.2.3" + resolved "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz" + integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== + dependencies: + emoji-regex "^8.0.0" + is-fullwidth-code-point "^3.0.0" + strip-ansi "^6.0.1" + +string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: version "4.2.3" resolved "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz" integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== @@ -4047,7 +4055,14 @@ string.prototype.trimstart@^1.0.8: define-properties "^1.2.1" es-object-atoms "^1.0.0" -"strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@^6.0.0, strip-ansi@^6.0.1: +"strip-ansi-cjs@npm:strip-ansi@^6.0.1": + version "6.0.1" + resolved "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz" + integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== + dependencies: + ansi-regex "^5.0.1" + +strip-ansi@^6.0.0, strip-ansi@^6.0.1: version "6.0.1" resolved "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz" integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== @@ -4127,6 +4142,11 @@ symbol-tree@^3.2.4: resolved "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz" integrity sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw== +tailwind-scrollbar-hide@^1.1.7: + version "1.1.7" + resolved "https://registry.yarnpkg.com/tailwind-scrollbar-hide/-/tailwind-scrollbar-hide-1.1.7.tgz#90b481fb2e204030e3919427416650c54f56f847" + integrity sha512-X324n9OtpTmOMqEgDUEA/RgLrNfBF/jwJdctaPZDzB3mppxJk7TLIDmOreEDm1Bq4R9LSPu4Epf8VSdovNU+iA== + tailwindcss@^3.4.3: version "3.4.3" resolved "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.3.tgz" @@ -4549,8 +4569,7 @@ word-wrap@^1.2.5: resolved "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz" integrity sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA== -"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0", wrap-ansi@^7.0.0: - name wrap-ansi-cjs +"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0": version "7.0.0" resolved "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz" integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== @@ -4568,6 +4587,15 @@ wrap-ansi@^6.2.0: string-width "^4.1.0" strip-ansi "^6.0.0" +wrap-ansi@^7.0.0: + version "7.0.0" + resolved "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz" + integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== + dependencies: + ansi-styles "^4.0.0" + string-width "^4.1.0" + strip-ansi "^6.0.0" + wrap-ansi@^8.1.0: version "8.1.0" resolved "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz"
{errors}
{errors.email.message}
{errors.password.message}
nav('/search/id')} onKeyDown={() => nav('/search/id')}> + 아이디 찾기 +
|
nav('/search/password')} + onKeyDown={() => nav('/search/password')} + > + 비밀번호 찾기 +
회원이 아니신가요?
+ +