diff --git a/.pnp.cjs b/.pnp.cjs index 868cf13..85cc109 100755 --- a/.pnp.cjs +++ b/.pnp.cjs @@ -37,6 +37,7 @@ const RAW_RUNTIME_STATE = ["eslint-config-prettier", "virtual:fab99ab653819c31700118e3551051ec2498dddb808f51896150d7d12ac371c264b5fa46a662ab7fea4ee02c7e8cf37d0e46b047c0fd340fb8234dee7ca27815#npm:9.1.0"],\ ["eslint-plugin-prettier", "virtual:fab99ab653819c31700118e3551051ec2498dddb808f51896150d7d12ac371c264b5fa46a662ab7fea4ee02c7e8cf37d0e46b047c0fd340fb8234dee7ca27815#npm:5.2.1"],\ ["eslint-plugin-unused-imports", "virtual:fab99ab653819c31700118e3551051ec2498dddb808f51896150d7d12ac371c264b5fa46a662ab7fea4ee02c7e8cf37d0e46b047c0fd340fb8234dee7ca27815#npm:4.1.4"],\ + ["immer", "npm:10.1.1"],\ ["next", "virtual:fab99ab653819c31700118e3551051ec2498dddb808f51896150d7d12ac371c264b5fa46a662ab7fea4ee02c7e8cf37d0e46b047c0fd340fb8234dee7ca27815#npm:14.2.9"],\ ["postcss", "npm:8.4.45"],\ ["prettier", "npm:3.3.3"],\ @@ -1582,6 +1583,7 @@ const RAW_RUNTIME_STATE = ["eslint-config-prettier", "virtual:fab99ab653819c31700118e3551051ec2498dddb808f51896150d7d12ac371c264b5fa46a662ab7fea4ee02c7e8cf37d0e46b047c0fd340fb8234dee7ca27815#npm:9.1.0"],\ ["eslint-plugin-prettier", "virtual:fab99ab653819c31700118e3551051ec2498dddb808f51896150d7d12ac371c264b5fa46a662ab7fea4ee02c7e8cf37d0e46b047c0fd340fb8234dee7ca27815#npm:5.2.1"],\ ["eslint-plugin-unused-imports", "virtual:fab99ab653819c31700118e3551051ec2498dddb808f51896150d7d12ac371c264b5fa46a662ab7fea4ee02c7e8cf37d0e46b047c0fd340fb8234dee7ca27815#npm:4.1.4"],\ + ["immer", "npm:10.1.1"],\ ["next", "virtual:fab99ab653819c31700118e3551051ec2498dddb808f51896150d7d12ac371c264b5fa46a662ab7fea4ee02c7e8cf37d0e46b047c0fd340fb8234dee7ca27815#npm:14.2.9"],\ ["postcss", "npm:8.4.45"],\ ["prettier", "npm:3.3.3"],\ @@ -2752,6 +2754,15 @@ const RAW_RUNTIME_STATE = "linkType": "HARD"\ }]\ ]],\ + ["immer", [\ + ["npm:10.1.1", {\ + "packageLocation": "../../../../.yarn/berry/cache/immer-npm-10.1.1-973ae10d09-10c0.zip/node_modules/immer/",\ + "packageDependencies": [\ + ["immer", "npm:10.1.1"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ ["import-fresh", [\ ["npm:3.3.0", {\ "packageLocation": "../../../../.yarn/berry/cache/import-fresh-npm-3.3.0-3e34265ca9-10c0.zip/node_modules/import-fresh/",\ diff --git a/package.json b/package.json index fdac1ae..07f8660 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,7 @@ "dependencies": { "@hookform/resolvers": "^3.9.0", "classnames": "^2.5.1", + "immer": "^10.1.1", "next": "14.2.9", "react": "^18", "react-dom": "^18", diff --git a/src/app/join/page.tsx b/src/app/join/page.tsx new file mode 100644 index 0000000..5c86805 --- /dev/null +++ b/src/app/join/page.tsx @@ -0,0 +1,96 @@ +'use client'; + +import { memo, useState } from 'react'; +import { produce } from 'immer'; +import TermsAgreement from '@/components/join/termsAgreement'; +import EmailJoinForm from '@/components/join/emailJoinForm'; +import PasswordJoinForm from '@/components/join/passwordJoinForm'; +import PhoneJoinForm from '@/components/join/phoneJoinForm'; +import PersonalInfoForm from '@/components/join/personalInfoForm'; +import JoinSuccess from '@/components/join/joinSuccess'; +import { StepType, UserDataType } from '@/types/join'; + +const Join = memo(function Join() { + const [step, setStep] = useState(0); + + const [userData, setUserData] = useState({ + term_marketing: false, + term_ad: false, + email: null, + password: null, + phone: null, + name: null, + gender: null, + birth: null, + }); + + console.log('userData', userData); + + const handleUserData = (id: keyof UserDataType, value: boolean | string) => { + setUserData( + produce((draft) => { + (draft[id] as typeof value) = value; + }), + ); + }; + + const StepData: StepType[] = [ + { + title: 'eqCM에 이용 약관에\n동의해 주세요', + component: ( + + ), + }, + { + title: '로그인에 사용할\n아이디를 입력해 주세요.', + component: ( + + ), + }, + { + title: '로그인에 사용할\n비밀번호를 입력해 주세요.', + component: ( + + ), + }, + { + title: '본인인증을\n진행해 주세요', + subtitle: '이미 가입한 계정이 있다면 알려드릴게요!', + component: ( + + ), + }, + { + title: '이름과 성별, 생년월일을\n입력해 주세요.', + component: ( + + ), + }, + { + title: '가입 완료!', + subtitle: '가입을 축하해요!', + component: , + }, + ]; + + return ( +
+

+ {StepData[step].title} +

+ {StepData[step].subtitle &&

{StepData[step].subtitle}

} +
{StepData[step].component}
+
+ ); +}); + +export default Join; diff --git a/src/components/common/input.tsx b/src/components/common/input.tsx index 870bbfd..363cbd1 100644 --- a/src/components/common/input.tsx +++ b/src/components/common/input.tsx @@ -1,10 +1,8 @@ -import { memo } from 'react'; +import { memo, InputHTMLAttributes } from 'react'; import { UseFormRegisterReturn } from 'react-hook-form'; import cn from 'classnames'; -type Props = { - type?: HTMLInputElement['type']; - placeholder: string; +type Props = InputHTMLAttributes & { style?: string; register: Partial; }; @@ -14,13 +12,16 @@ const Input = memo(function Input({ placeholder, style = '', register, + ...restProps }: Props) { return ( ); }); diff --git a/src/components/header/index.tsx b/src/components/header/index.tsx index 3d1a5d0..d7c2832 100644 --- a/src/components/header/index.tsx +++ b/src/components/header/index.tsx @@ -3,19 +3,20 @@ import { memo } from 'react'; import MyMenu from './myMenu'; import Menu from './menu'; import Category from './category'; +import Link from 'next/link'; const Header = memo(function Header() { return (
-
+ logo -
+
diff --git a/src/components/header/myMenu.tsx b/src/components/header/myMenu.tsx index e902f2d..e92b95c 100644 --- a/src/components/header/myMenu.tsx +++ b/src/components/header/myMenu.tsx @@ -4,7 +4,7 @@ import { DEFAULT_MENU, USER_STATUS_MENU } from '@/constants/header'; import MyMenuItem from './myMenuItem'; const MyMenu = memo(function MyMenu() { - const isLoggedIn = true; + const isLoggedIn = false; return (
    {DEFAULT_MENU.map((menu) => ( diff --git a/src/components/icons.tsx b/src/components/icons.tsx index 6515d35..d4b8fc6 100644 --- a/src/components/icons.tsx +++ b/src/components/icons.tsx @@ -78,7 +78,7 @@ export const Icons = { id="vector" fillRule="evenodd" clipRule="evenodd" - d="M8 10H19.25C19.6642 10 20 10.3358 20 10.75V21.25C20 21.6642 19.6642 22 19.25 22H4.75C4.33579 22 4 21.6642 4 21.25V10.75C4 10.3358 4.33579 10 4.75 10H6V8C6 4.68629 8.68629 2 12 2C13.9985 2 15.7688 2.97712 16.8593 4.47969L15.307 5.749C14.5869 4.69318 13.3744 4 12 4C9.79086 4 8 5.79086 8 8V10ZM12 12.5C12.5523 12.5 13 12.9477 13 13.5V15.5C13 16.0523 12.5523 16.5 12 16.5C11.4477 16.5 11 16.0523 11 15.5V13.5C11 12.9477 11.4477 12.5 12 12.5Z" + d="M12 2C8.68629 2 6 4.68629 6 8V10H4.75C4.33579 10 4 10.3358 4 10.75V21.25C4 21.6642 4.33579 22 4.75 22H19.25C19.6642 22 20 21.6642 20 21.25V10.75C20 10.3358 19.6642 10 19.25 10H18V8C18 4.68629 15.3137 2 12 2ZM16 10V8C16 5.79086 14.2091 4 12 4C9.79086 4 8 5.79086 8 8V10H16ZM13 13.5C13 12.9477 12.5523 12.5 12 12.5C11.4477 12.5 11 12.9477 11 13.5V15.5C11 16.0523 11.4477 16.5 12 16.5C12.5523 16.5 13 16.0523 13 15.5V13.5Z" fill="black" > @@ -213,9 +213,9 @@ export const Icons = { @@ -232,9 +232,9 @@ export const Icons = { @@ -251,8 +251,8 @@ export const Icons = { @@ -272,8 +272,8 @@ export const Icons = { @@ -292,8 +292,8 @@ export const Icons = { @@ -313,8 +313,8 @@ export const Icons = { @@ -333,8 +333,8 @@ export const Icons = { @@ -354,8 +354,8 @@ export const Icons = { @@ -402,4 +402,19 @@ export const Icons = { > ), + check: ({ color }: { color?: string }) => ( + + + + ), }; diff --git a/src/components/join/checkbox.tsx b/src/components/join/checkbox.tsx new file mode 100644 index 0000000..38fe480 --- /dev/null +++ b/src/components/join/checkbox.tsx @@ -0,0 +1,51 @@ +'use client'; + +import { memo } from 'react'; +import { Icons } from '../icons'; +import { AGREEMENT_CHECK_IDS_TYPE } from '@/types/join'; + +type Props = { + id: AGREEMENT_CHECK_IDS_TYPE; + text: string; + check: boolean; + detail?: string; + onClick: (id: AGREEMENT_CHECK_IDS_TYPE) => void; +}; + +const CheckBox = memo(function CheckBox({ + id, + text, + detail, + check, + onClick, +}: Props) { + return ( + + ); +}); + +export default CheckBox; diff --git a/src/components/join/emailJoinForm.tsx b/src/components/join/emailJoinForm.tsx new file mode 100644 index 0000000..6bed92e --- /dev/null +++ b/src/components/join/emailJoinForm.tsx @@ -0,0 +1,57 @@ +import { memo } from 'react'; +import { SubmitHandler, useForm } from 'react-hook-form'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { EmailJoinFormData, UserDataType } from '@/types/join'; +import { EmailJoinFormSchema } from '@/constants/join'; +import Input from '../common/input'; +import NextButton from './nextButton'; + +type Props = { + onClickNextBtn: React.Dispatch>; + onChangeData: (id: keyof UserDataType, value: boolean | string) => void; +}; + +function EmailJoinForm({ onClickNextBtn, onChangeData }: Props) { + const { + watch, + register, + handleSubmit, + formState: { errors }, + } = useForm({ + resolver: zodResolver(EmailJoinFormSchema), + }); + + const emailValue = watch('email'); + + const onSubmit: SubmitHandler = (data) => { + onChangeData('email', data.email); + onClickNextBtn(2); + }; + + return ( +
    +
    + + + {errors && ( + + {errors['email']?.message} + + )} +
    + + + + ); +} + +export default memo(EmailJoinForm); diff --git a/src/components/join/joinSuccess.tsx b/src/components/join/joinSuccess.tsx new file mode 100644 index 0000000..04d7739 --- /dev/null +++ b/src/components/join/joinSuccess.tsx @@ -0,0 +1,15 @@ +import Link from 'next/link'; +import { memo } from 'react'; +import NextButton from './nextButton'; + +function JoinSuccess() { + return ( +
    + + + +
    + ); +} + +export default memo(JoinSuccess); diff --git a/src/components/join/nextButton.tsx b/src/components/join/nextButton.tsx new file mode 100644 index 0000000..2655ff7 --- /dev/null +++ b/src/components/join/nextButton.tsx @@ -0,0 +1,21 @@ +import React, { memo } from 'react'; + +type Props = { + disabled?: boolean; + text?: string; + onClick?: () => void; +}; + +function NextButton({ disabled, text, onClick }: Props) { + return ( + + ); +} + +export default memo(NextButton); diff --git a/src/components/join/optionCheck.tsx b/src/components/join/optionCheck.tsx new file mode 100644 index 0000000..e479b89 --- /dev/null +++ b/src/components/join/optionCheck.tsx @@ -0,0 +1,27 @@ +import { memo } from 'react'; +import cn from 'classnames'; +import { Icons } from '../icons'; + +type Props = { + checkOption: boolean; + text: string; + style?: string; +}; + +function OptionCheck({ checkOption, text, style }: Props) { + return ( +
    + + {text} +
    + ); +} + +export default memo(OptionCheck); diff --git a/src/components/join/passwordJoinForm.tsx b/src/components/join/passwordJoinForm.tsx new file mode 100644 index 0000000..fb5b1c2 --- /dev/null +++ b/src/components/join/passwordJoinForm.tsx @@ -0,0 +1,88 @@ +import { memo } from 'react'; +import { SubmitHandler, useForm } from 'react-hook-form'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { PasswordJoinFormData, UserDataType } from '@/types/join'; +import Input from '../common/input'; +import { PasswordJoinFormSchema } from '@/constants/join'; +import NextButton from './nextButton'; +import OptionCheck from './optionCheck'; + +type Props = { + onClickNextBtn: React.Dispatch>; + onChangeData: (id: keyof UserDataType, value: boolean | string) => void; +}; + +function PasswordJoinForm({ onClickNextBtn, onChangeData }: Props) { + const { watch, register, handleSubmit } = useForm({ + resolver: zodResolver(PasswordJoinFormSchema), + }); + + const onSubmit: SubmitHandler = (data) => { + onChangeData('password', data.password); + onClickNextBtn(3); + }; + + const isPasswordLengthValid = + watch('password')?.length >= 8 && watch('password')?.length <= 20; + + const isPasswordComplexityValid = + /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[\W_]).{8,20}$/.test( + watch('password'), + ); + + const isPasswordMatch = + watch('password')?.length > 0 && watch('password') === watch('rePassword'); + + const isNextButtonEnabled = + isPasswordLengthValid && isPasswordComplexityValid && isPasswordMatch; + + return ( +
    +
    +
    + + +
    + {[ + { checkOption: isPasswordLengthValid, text: '8-20자 이내' }, + { + checkOption: isPasswordComplexityValid, + text: '대소문자, 숫자, 특수문자 포함', + }, + ].map(({ checkOption, text }) => ( + + ))} +
    +
    +
    + + + +
    +
    + + + + ); +} + +export default memo(PasswordJoinForm); diff --git a/src/components/join/personalInfoForm.tsx b/src/components/join/personalInfoForm.tsx new file mode 100644 index 0000000..46e343a --- /dev/null +++ b/src/components/join/personalInfoForm.tsx @@ -0,0 +1,108 @@ +import { memo } from 'react'; +import { SubmitHandler, useForm } from 'react-hook-form'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { PersonalInfoFormData, UserDataType } from '@/types/join'; +import { GenderFieldList, PersonalInfoFormSchema } from '@/constants/join'; +import Input from '../common/input'; +import NextButton from './nextButton'; + +type Props = { + onClickNextBtn: React.Dispatch>; + onChangeData: (id: keyof UserDataType, value: boolean | string) => void; +}; + +function PersonalInfoForm({ onClickNextBtn, onChangeData }: Props) { + const { + watch, + register, + handleSubmit, + formState: { errors }, + } = useForm({ + resolver: zodResolver(PersonalInfoFormSchema), + }); + + const nameValue = watch('name'); + const genderValue = watch('gender'); + const birthValue = watch('birth'); + + const onSubmit: SubmitHandler = (data) => { + onChangeData('name', data.name); + onChangeData('gender', data.gender); + onChangeData('birth', data.birth.toString()); + onClickNextBtn(5); + }; + + return ( +
    +
    + + + {errors && ( + + {errors['name']?.message} + + )} + + +
    + {GenderFieldList.map(({ title, value }) => ( + + ))} +
    + {errors && ( + + {errors['gender']?.message} + + )} + + + + {errors && ( + + {errors['birth']?.message} + + )} +
    + + + + ); +} + +export default memo(PersonalInfoForm); diff --git a/src/components/join/phoneJoinForm.tsx b/src/components/join/phoneJoinForm.tsx new file mode 100644 index 0000000..27a1351 --- /dev/null +++ b/src/components/join/phoneJoinForm.tsx @@ -0,0 +1,118 @@ +'use client'; + +import { memo, useState } from 'react'; +import { SubmitHandler, useForm } from 'react-hook-form'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { PhoneJoinFormData, UserDataType } from '@/types/join'; +import { PhoneJoinFormSchema } from '@/constants/join'; +import Input from '../common/input'; +import NextButton from './nextButton'; +import { PhoneFormSchema } from '@/constants/common'; + +type Props = { + onClickNextBtn: React.Dispatch>; + onChangeData: (id: keyof UserDataType, value: boolean | string) => void; +}; + +function PhoneJoinForm({ onClickNextBtn, onChangeData }: Props) { + const [isSendMessage, setIsSendMessage] = useState(false); + + const { + getValues, + register, + handleSubmit, + setError, + clearErrors, + formState: { errors }, + } = useForm({ + resolver: zodResolver(PhoneJoinFormSchema), + }); + + const onClickSendBtn = () => { + const phoneValue = getValues('phone'); + const result = PhoneFormSchema.safeParse(phoneValue); + if (!result.success) { + setError('phone', { + type: 'phone', + message: + result.error.errors[0]?.message || '유효하지 않은 전화번호입니다.', + }); + return; + } + clearErrors('phone'); + + // TODO: 인증번호 요청 API 전송 + setIsSendMessage(true); + }; + + const onSubmit: SubmitHandler = (data) => { + console.log('submit'); + + // TODO: 인증번호 확인 API 전송 후 응답에 따른 처리 + if (true) { + onChangeData('phone', data.phone); + onClickNextBtn(4); + } + }; + + return ( +
    +
    + +
    + + +
    + {errors && ( + + {errors['phone']?.message} + + )} + + {isSendMessage && ( + <> + + + {errors && ( + + {errors['validNumber']?.message} + + )} + + )} +
    + + setIsSendMessage(true)} + /> + + ); +} + +export default memo(PhoneJoinForm); diff --git a/src/components/join/termsAgreement.tsx b/src/components/join/termsAgreement.tsx new file mode 100644 index 0000000..ace9f2b --- /dev/null +++ b/src/components/join/termsAgreement.tsx @@ -0,0 +1,87 @@ +'use client'; + +import { useCallback, useState } from 'react'; +import { AGREEMENT_CHECK_IDS, TERMS_AGREEMENT_LIST } from '@/constants/join'; +import { UserDataType } from '@/types/join'; +import CheckBox from './checkbox'; +import NextButton from './nextButton'; + +type Props = { + onClickNextBtn: React.Dispatch>; + onChangeData: (id: keyof UserDataType, value: boolean | string) => void; +}; + +const TermsAgreement = ({ onClickNextBtn, onChangeData }: Props) => { + const [checkStatus, setCheckStatus] = useState({ + all: false, + age: false, + term: false, + privacy: false, + marketing: false, + ad: false, + }); + + const isAllRequiredTermsChecked = Object.entries(checkStatus) + .filter(([key]) => !['all', 'marketing', 'ad'].includes(key)) + .every(([, value]) => value === true); + + const handleChecked = (id: keyof typeof checkStatus) => { + setCheckStatus((state) => { + if (id === 'all') { + const newState = !state['all']; + const updatedState = Object.keys(state).reduce( + (acc, key) => { + acc[key as keyof typeof state] = newState; + return acc; + }, + {} as typeof state, + ); + return updatedState; + } else { + return { + ...state, + [id]: !state[id], + all: Object.entries(state) + .filter(([key]) => key !== 'all') + .every(([, value]) => value === true), + }; + } + }); + }; + + const handleSubmit = useCallback(() => { + onChangeData('term_marketing', checkStatus.marketing); + onChangeData('term_ad', checkStatus.ad); + onClickNextBtn(1); + }, [checkStatus.marketing, checkStatus.ad, onChangeData, onClickNextBtn]); + + return ( +
    +
    + +
    + {TERMS_AGREEMENT_LIST.map(({ id, required, text, detail }) => ( + + ))} +
    + + + ); +}; + +export default TermsAgreement; diff --git a/src/components/login/emailLoginForm.tsx b/src/components/login/emailLoginForm.tsx index 1e33d43..e91533a 100644 --- a/src/components/login/emailLoginForm.tsx +++ b/src/components/login/emailLoginForm.tsx @@ -1,3 +1,4 @@ +import Link from 'next/link'; import { memo } from 'react'; import { useForm, SubmitHandler } from 'react-hook-form'; import { zodResolver } from '@hookform/resolvers/zod'; @@ -5,7 +6,7 @@ import { EmailLoginFieldList, LoginFormSchema } from '@/constants/login'; import { EmailLoginFormData } from '@/types/login'; import Input from '../common/input'; -const EmailLoginForm = memo(function LoginForm() { +const EmailLoginForm = memo(function EmailLoginForm() { const { register, handleSubmit, @@ -48,12 +49,12 @@ const EmailLoginForm = memo(function LoginForm() { 로그인 - +
    ); diff --git a/src/constants/common.ts b/src/constants/common.ts new file mode 100644 index 0000000..4bcdb92 --- /dev/null +++ b/src/constants/common.ts @@ -0,0 +1,18 @@ +import { z } from 'zod'; + +export const EmailFormSchema = z + .string() + .min(3, { message: '이메일(아이디)를 입력해주세요.' }) + .email({ message: '이메일 형식이 올바르지 않습니다.' }); + +export const PasswordFormSchema = z + .string() + .min(1, { message: '비밀번호를 입력해주세요.' }) + .min(8, { message: '비밀번호는 8자 이상이어야 합니다.' }); + +export const PhoneFormSchema = z + .string() + .min(1, { message: '휴대폰 번호를 입력하세요.' }) + .refine((phone) => /^\d{10,11}$/.test(phone), { + message: '유효한 휴대폰 번호를 입력하세요', + }); diff --git a/src/constants/header.tsx b/src/constants/header.tsx index ea7012c..9eba6c6 100644 --- a/src/constants/header.tsx +++ b/src/constants/header.tsx @@ -42,6 +42,6 @@ export const DEFAULT_MENU: MyMenuItemType[] = [ ] as const; export const USER_STATUS_MENU: { [key: string]: MyMenuItemType } = { - LOGIN: { icon: , title: 'LOGIN', link: '' }, + LOGIN: { icon: , title: 'LOGIN', link: '/login' }, LOGOUT: { icon: , title: 'LOGOUT', link: '' }, } as const; diff --git a/src/constants/join.tsx b/src/constants/join.tsx new file mode 100644 index 0000000..a999865 --- /dev/null +++ b/src/constants/join.tsx @@ -0,0 +1,124 @@ +import { z } from 'zod'; +import { + GenderType, + PasswordJoinFormData, + TermsAgreementListType, +} from '@/types/join'; +import { InputFieldType } from '@/types/common'; +import { EmailFormSchema, PasswordFormSchema, PhoneFormSchema } from './common'; + +export const AGREEMENT_CHECK_IDS = { + all: 'all', + age: 'age', + term: 'term', + privacy: 'privacy', + marketing: 'marketing', + ad: 'ad', +} as const; + +export enum GENDER { + male = 'MALE', + female = 'FEMALE', +} + +export const TERMS_AGREEMENT_LIST: TermsAgreementListType[] = [ + { + id: AGREEMENT_CHECK_IDS.age, + required: true, + text: '만 14세 이상입니다', + }, + { + id: AGREEMENT_CHECK_IDS.term, + required: true, + text: '이용약관 동의', + detail: '보기', + }, + { + id: AGREEMENT_CHECK_IDS.privacy, + required: true, + text: '개인정보 수집 및 이용 동의', + detail: '보기', + }, + { + id: AGREEMENT_CHECK_IDS.marketing, + required: false, + text: '마케팅 목적의 개인정보 수집 및 이용 동의', + detail: '보기', + }, + { + id: AGREEMENT_CHECK_IDS.ad, + required: false, + text: '광고성 정보 수신 동의', + }, +]; + +export const EmailJoinFormSchema = z.object({ + email: EmailFormSchema, +}); + +export const PasswordJoinFieldList: InputFieldType[] = [ + { + type: 'text', + name: 'password', + title: '이메일(아이디)', + placeholder: 'abc@email.com', + }, + { + type: 'password', + name: 'rePassword', + title: '비밀번호', + placeholder: '8자 이상의 비밀번호', + }, +]; + +export const GenderFieldList: { title: string; value: GenderType }[] = [ + { title: '여성', value: GENDER.female }, + { title: '남성', value: GENDER.male }, +]; + +export const PasswordJoinFormSchema = z + .object({ + password: PasswordFormSchema, + rePassword: PasswordFormSchema, + }) + .refine((data) => data.password === data.rePassword, { + message: '비밀번호가 일치하지 않습니다.', + path: ['rePassword'], + }); + +export const PhoneValidNumberFormSchema = z + .string() + .min(6, { message: '인증번호를 입력하세요.' }) + .refine((validNumber) => /^\d{6}$/.test(validNumber), { + message: '인증번호는 6자리 숫자입니다.', + }); + +export const PhoneJoinFormSchema = z.object({ + phone: PhoneFormSchema, + validNumber: PhoneValidNumberFormSchema, +}); + +export const NameFormSchema = z + .string() + .min(2, { message: '이름을 입력하세요.' }); + +export const GenderFormSchema = z.enum(['MALE', 'FEMALE']); + +export const BirthFormSchema = z + .string() + .refine((val) => !isNaN(Date.parse(val)), { + message: '유효한 날짜를 입력하세요.', + }) + .transform((val) => new Date(val)) + .refine((date) => date >= new Date('1900-01-01'), { + message: '1900년 이후로 입력해 주세요.', + }) + .refine((date) => date < new Date(), { + message: '오늘 이후 날짜는 입력 불가능합니다.', + }); + +export const PersonalInfoFormSchema = z.object({ + name: NameFormSchema, + gender: GenderFormSchema, + birth: BirthFormSchema, +}); diff --git a/src/constants/login.tsx b/src/constants/login.tsx index c715084..fa549bc 100644 --- a/src/constants/login.tsx +++ b/src/constants/login.tsx @@ -1,11 +1,12 @@ import { Icons } from '@/components/icons'; import { z } from 'zod'; import { - EmailLoginFieldType, EmailLoginFormData, LoginButtonType, LoginMenuType, } from '@/types/login'; +import { InputFieldType } from '@/types/common'; +import { EmailFormSchema, PasswordFormSchema } from './common'; export const LOGIN_BUTTON_LIST: LoginButtonType[] = [ { @@ -38,17 +39,11 @@ export const LOGIN_HELP_MENU_LIST: LoginMenuType[] = [ ]; export const LoginFormSchema = z.object({ - email: z - .string() - .min(3, { message: '이메일(아이디)를 입력해주세요.' }) - .email({ message: '이메일 형식이 올바르지 않습니다.' }), - password: z - .string() - .min(1, { message: '비밀번호를 입력해주세요.' }) - .min(8, { message: '비밀번호는 8자 이상이어야 합니다.' }), + email: EmailFormSchema, + password: PasswordFormSchema, }); -export const EmailLoginFieldList: EmailLoginFieldType[] = [ +export const EmailLoginFieldList: InputFieldType[] = [ { type: 'text', name: 'email' as keyof EmailLoginFormData, diff --git a/src/types/common.ts b/src/types/common.ts new file mode 100644 index 0000000..6a8a339 --- /dev/null +++ b/src/types/common.ts @@ -0,0 +1,6 @@ +export type InputFieldType = { + type: string; + name: keyof T; + title: string; + placeholder: string; +}; diff --git a/src/types/join.ts b/src/types/join.ts new file mode 100644 index 0000000..31e82e4 --- /dev/null +++ b/src/types/join.ts @@ -0,0 +1,42 @@ +import { GENDER } from './../constants/join'; +import { z } from 'zod'; +import { + AGREEMENT_CHECK_IDS, + EmailJoinFormSchema, + PasswordJoinFormSchema, + PersonalInfoFormSchema, + PhoneJoinFormSchema, +} from '@/constants/join'; + +export type GenderType = GENDER.male | GENDER.female; + +export type UserDataType = { + term_marketing: boolean; + term_ad: boolean; + email: string | null; + password: string | null; + phone: string | null; + name: string | null; + gender: GenderType | null; + birth: string | null; +}; + +export type StepType = { + title: string; + subtitle?: string; + component: JSX.Element; +}; + +export type AGREEMENT_CHECK_IDS_TYPE = keyof typeof AGREEMENT_CHECK_IDS; + +export type TermsAgreementListType = { + id: AGREEMENT_CHECK_IDS_TYPE; + required: boolean; + text: string; + detail?: string; +}; + +export type EmailJoinFormData = z.infer; +export type PasswordJoinFormData = z.infer; +export type PhoneJoinFormData = z.infer; +export type PersonalInfoFormData = z.infer; diff --git a/src/types/login.ts b/src/types/login.ts index eab3069..aa5fc71 100644 --- a/src/types/login.ts +++ b/src/types/login.ts @@ -12,10 +12,3 @@ export type LoginButtonType = LoginMenuType & { }; export type EmailLoginFormData = z.infer; - -export type EmailLoginFieldType = { - type: string; - name: keyof T; - title: string; - placeholder: string; -}; diff --git a/yarn.lock b/yarn.lock index a71d5db..28056ca 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1215,6 +1215,7 @@ __metadata: eslint-config-prettier: "npm:^9.1.0" eslint-plugin-prettier: "npm:^5.2.1" eslint-plugin-unused-imports: "npm:^4.1.3" + immer: "npm:^10.1.1" next: "npm:14.2.9" postcss: "npm:^8" prettier: "npm:^3.3.3" @@ -2147,6 +2148,13 @@ __metadata: languageName: node linkType: hard +"immer@npm:^10.1.1": + version: 10.1.1 + resolution: "immer@npm:10.1.1" + checksum: 10c0/b749e10d137ccae91788f41bd57e9387f32ea6d6ea8fd7eb47b23fd7766681575efc7f86ceef7fe24c3bc9d61e38ff5d2f49c2663b2b0c056e280a4510923653 + languageName: node + linkType: hard + "import-fresh@npm:^3.2.1": version: 3.3.0 resolution: "import-fresh@npm:3.3.0"