Skip to content
Merged
Show file tree
Hide file tree
Changes from 13 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
71 changes: 71 additions & 0 deletions src/pages/sign-up/components/agreement-step.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import Button from '@components/button/button/button';
import Icon from '@components/icon/icon';
import CheckboxRow from '@pages/sign-up/components/checkbox-row';
import { useState } from 'react';

interface AgreementStepProps {
next: () => void;
}

const AgreementStep = ({ next }: AgreementStepProps) => {
const [terms, setTerms] = useState(false);
const [privacy, setPrivacy] = useState(false);

const isAllChecked = terms && privacy;
Comment on lines +11 to +14
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

각각 이용약관 동의, 개인정보 수집 동의 값을 저장하는 상태와, 전체 항목이 체크되었는지 검사를 위한 isAllChecked입니다. 둘다 true여야지 활성화 됩니다


const handleCheckAll = () => {
const next = !isAllChecked;
setTerms(next);
setPrivacy(next);
};

const handleCheckTerms = () => {
const next = !terms;
setTerms(next);
};

const handleCheckPrivacy = () => {
const next = !privacy;
setPrivacy(next);
};

return (
<div className="h-full flex-col justify-between pt-[4.8rem] pb-[2.4rem]">
<div className="flex-col gap-[0.8rem] px-[1.6rem]">
<h1 className="title_24_sb">서비스 이용약관</h1>
<p className="body_16_m text-gray-500">
서비스 가입을 위해 <br /> 아래 항목에 동의해주세요.
</p>
</div>

<div className="flex-col gap-[2.4rem]">
<div className="flex-col gap-[0.8rem]">
<CheckboxRow
label="약관 전체 동의"
onClick={handleCheckAll}
checked={isAllChecked}
divider
/>
<CheckboxRow
label="이용약관 동의 (필수)"
onClick={handleCheckTerms}
checked={terms}
svg={<Icon name="arrow-right-18" size={1.8} />}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

클릭시 이동할 주소는 아직 전달 못받은거져??

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

네! 여기도 페이지로 퍼블리싱 작업 들어갈 것 같아요 ㅠㅠ

/>

<CheckboxRow
label="개인정보 수집 및 이용동의 (필수)"
onClick={handleCheckPrivacy}
checked={privacy}
svg={<Icon name="arrow-right-18" size={1.8} />}
/>
</div>
<div className="px-[1.6rem]">
<Button label="다음으로" disabled={!isAllChecked} onClick={next} />
</div>
</div>
</div>
);
};

export default AgreementStep;
37 changes: 37 additions & 0 deletions src/pages/sign-up/components/checkbox-row.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import Icon from '@components/icon/icon';
import { cn } from '@libs/cn';
import type { ReactNode } from 'react';

interface CheckboxProps {
label: ReactNode;
checked: boolean;
onClick: () => void;
svg?: ReactNode;
divider?: boolean;
className?: string;
}

const CheckboxRow = ({ label, checked, onClick, svg, divider, className }: CheckboxProps) => {
return (
<button
type="button"
className={cn(
'flex w-full items-center justify-between gap-[0.8rem] p-[0.8rem] px-[1.6rem] text-left',
divider && 'border-gray-200 border-b',
className,
)}
>
<div className="flex items-center gap-[0.8rem]">
<Icon
name="check-filled"
onClick={onClick}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

아이콘이 아니라 버튼 자체에 onClick 연결하는거 어떨까요??
추가로 커서 pointer까지 설정하면 좋을 것 같아요!

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

이거 어디영역까지 클릭했을때 체크되게 할지 기디에 한번 물어보고 수정할게요!

className={checked ? 'text-main-800' : 'text-gray-300'}
/>
<span className="body_16_m">{label}</span>
</div>
{svg}
</button>
);
};

export default CheckboxRow;
39 changes: 35 additions & 4 deletions src/pages/sign-up/components/signup-step.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,17 @@ import queryClient from '@libs/query-client';
import {
BIRTHYEAR_RULE_MESSAGE,
BIRTHYEAR_SUCCESS_MESSAGE,
INFORMATION_RULE_MESSAGE,
NICKNAME_RULE_MESSAGE,
NICKNAME_SUCCESS_MESSAGE,
NICKNAME_TITLE,
} from '@pages/sign-up/constants/NOTICE';
import { BIRTH_PLACEHOLDER, NICKNAME_PLACEHOLDER } from '@pages/sign-up/constants/validation';
import {
BIRTH_PLACEHOLDER,
INFORMATION_MAX_LENGTH,
INFORMATION_PLACEHOLDER,
NICKNAME_PLACEHOLDER,
} from '@pages/sign-up/constants/validation';
import { type NicknameFormValues, NicknameSchema } from '@pages/sign-up/schema/validation-schema';
import { ROUTES } from '@routes/routes-config';
import { useMutation } from '@tanstack/react-query';
Expand All @@ -28,21 +34,25 @@ const SignupStep = () => {
} = useForm<NicknameFormValues>({
mode: 'onChange',
resolver: zodResolver(NicknameSchema),
defaultValues: { nickname: '', gender: undefined, birthYear: '' },
defaultValues: { nickname: '', gender: undefined, birthYear: '', information: '' },
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

한 줄 소개 항목이 추가가 되어서 form 에도 등록해두었습니다. 서버 api는 아직 업데이트가 안돼서 api 수정은 안되어있어용

});

const navigate = useNavigate();

const nicknameValue = watch('nickname');
const birthYearValue = watch('birthYear');
const genderValue = watch('gender');
const informationValue = watch('information');

const isNicknameValid = !errors.nickname && nicknameValue.length > 0;
const isBirthYearValid = !errors.birthYear && birthYearValue.length > 0;
const isInformationValid = !errors.information && informationValue.length > 0;

const nicknameMutation = useMutation(userMutations.NICKNAME());
const userInfoMutation = useMutation(userMutations.USER_INFO());

const informationLength = informationValue.length ?? 0;

const onSubmit = (data: NicknameFormValues) => {
nicknameMutation.mutate(
{ nickname: data.nickname },
Expand Down Expand Up @@ -79,14 +89,20 @@ const SignupStep = () => {
...birthYearInputProps
} = register('birthYear');

const {
onBlur: onInformationBlur,
ref: informationRef,
...informationInputProps
} = register('information');

const handleGenderClick = (gender: '여성' | '남성') => {
setValue('gender', gender, { shouldValidate: true, shouldDirty: true });
};

return (
<form
onSubmit={handleSubmit(onSubmit)}
className="h-full w-full flex-col justify-between gap-[4rem]"
className="h-full w-full flex-col justify-between gap-[4rem] px-[1.6rem] pt-[4rem] pb-[1.6rem]"
>
<div className="w-full flex-col gap-[4rem]">
<h1 className="title_24_sb whitespace-pre-line">{NICKNAME_TITLE}</h1>
Expand All @@ -102,9 +118,24 @@ const SignupStep = () => {
ref={nicknameRef}
{...nicknameInputProps}
/>
<Input
placeholder={INFORMATION_PLACEHOLDER}
className="h-[10.4rem]"
label="한 줄 소개"
defaultMessage={INFORMATION_RULE_MESSAGE}
multiline
maxLength={INFORMATION_MAX_LENGTH}
isError={!!errors.information}
isValid={isInformationValid}
onBlur={onInformationBlur}
ref={informationRef}
length={informationLength}
hasLength
{...informationInputProps}
/>
<Input
placeholder={BIRTH_PLACEHOLDER}
label="생년"
label="출생 연도"
defaultMessage={isBirthYearValid ? BIRTHYEAR_SUCCESS_MESSAGE : BIRTHYEAR_RULE_MESSAGE}
validationMessage={errors.birthYear?.message}
isError={!!errors.birthYear}
Expand Down
2 changes: 2 additions & 0 deletions src/pages/sign-up/constants/NOTICE.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,3 +13,5 @@ export const NICKNAME_SUCCESS_MESSAGE = '사용 가능한 닉네임이에요.';
export const BIRTHYEAR_RULE_MESSAGE = '4자리 숫자만 입력 가능';

export const BIRTHYEAR_SUCCESS_MESSAGE = '올바른 입력 값이에요.';

export const INFORMATION_RULE_MESSAGE = '비방, 욕설 등 불쾌감을 줄 수 있는 내용 제한';
10 changes: 9 additions & 1 deletion src/pages/sign-up/constants/validation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,10 @@ export const NICKNAME_REGEX = {

export const NICKNAME_PLACEHOLDER = '2-6자 이내의 닉네임을 입력하세요.';

export const BIRTH_PLACEHOLDER = '생년을 YYYY 형태로 입력하세요.';
export const BIRTH_PLACEHOLDER = 'YYYY 형태로 입력하세요.';

export const INFORMATION_PLACEHOLDER =
'엘지팬입니다! 같이 응원가 떼창해요~\n잠실에서 김말국 먹으면서 직관하고 싶어요 ㅎㅎ';

export const BIRTH_ERROR_MESSAGES = {
LENGTH: '숫자로 4자리 입력만 가능해요.',
Expand All @@ -32,3 +35,8 @@ export const GENDER_ERROR_MESSAGES = {
};

export const GENDER_OPTIONS = ['남성', '여성'] as const;

export const SIGNUP_STEPS = ['AGREEMENT', 'INFORMATION'];

export const INFORMATION_MIN_LENGTH = 1;
export const INFORMATION_MAX_LENGTH = 50;
3 changes: 3 additions & 0 deletions src/pages/sign-up/schema/validation-schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ import {
BIRTH_ERROR_MESSAGES,
GENDER_ERROR_MESSAGES,
GENDER_OPTIONS,
INFORMATION_MAX_LENGTH,
INFORMATION_MIN_LENGTH,
NICKNAME_ERROR_MESSAGES,
NICKNAME_MAX_LENGTH,
NICKNAME_MIN_LENGTH,
Expand Down Expand Up @@ -37,6 +39,7 @@ export const NicknameSchema = z.object({
gender: z.enum(GENDER_OPTIONS, {
required_error: GENDER_ERROR_MESSAGES.REQUIRED,
}),
information: z.string().min(INFORMATION_MIN_LENGTH).max(INFORMATION_MAX_LENGTH),
});

export type NicknameFormValues = z.infer<typeof NicknameSchema>;
19 changes: 17 additions & 2 deletions src/pages/sign-up/sign-up.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,24 @@
import { useFunnel } from '@hooks/use-funnel';
import AgreementStep from '@pages/sign-up/components/agreement-step';
import SignupStep from '@pages/sign-up/components/signup-step';
import { SIGNUP_STEPS } from '@pages/sign-up/constants/validation';
import { ROUTES } from '@routes/routes-config';

const SignUp = () => {
const { Funnel, Step, goNext } = useFunnel(SIGNUP_STEPS, ROUTES.HOME);

return (
<div className="signup-layout h-full bg-gray-white">
<SignupStep />
<div className="h-full flex-col bg-gray-white">
<div className="flex-1">
<Funnel>
<Step name="AGREEMENT">
<AgreementStep next={goNext} />
</Step>
<Step name="INFORMATION">
<SignupStep />
</Step>
</Funnel>
</div>
</div>
);
};
Expand Down
3 changes: 3 additions & 0 deletions src/shared/assets/svgs/arrow-right-18.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 1 addition & 1 deletion src/shared/assets/svgs/check-filled.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
66 changes: 50 additions & 16 deletions src/shared/components/input/input.tsx
Original file line number Diff line number Diff line change
@@ -1,17 +1,23 @@
import Icon from '@components/icon/icon';
import { iconColorMap, inputClassMap } from '@components/input/styles/input-variants';
import { cn } from '@libs/cn';
import type React from 'react';
import type { InputHTMLAttributes } from 'react';
import { useState } from 'react';
import { defineInputState } from '@/shared/utils/define-input-state';

interface InputProps extends InputHTMLAttributes<HTMLInputElement> {
interface InputProps extends Omit<InputHTMLAttributes<HTMLInputElement>, 'onBlur'> {
label?: string;
isError?: boolean;
isValid?: boolean;
hasLength?: boolean;
defaultMessage?: string;
validationMessage?: string;
ref?: React.Ref<HTMLInputElement>;
className?: string;
multiline?: boolean;
length?: number;
onBlur?: (e: React.FocusEvent<HTMLInputElement> | React.FocusEvent<HTMLTextAreaElement>) => void;
}

const Input = ({
Expand All @@ -21,8 +27,12 @@ const Input = ({
label,
validationMessage,
defaultMessage,
length,
onBlur,
ref,
className,
hasLength = false,
multiline = false,
...props
}: InputProps) => {
const [isFocused, setIsFocused] = useState(false);
Expand All @@ -41,31 +51,55 @@ const Input = ({
)}
<div
className={cn(
'body_16_m h-[5.6rem] w-full flex-row-between rounded-[12px] bg-gray-100 p-[1.6rem]',
'body_16_m h-[5.6rem] w-full flex-row-between rounded-[12px] bg-gray-100 ',
borderClass,
className,
)}
>
<input
id={id}
type="text"
className="flex-1 text-gray-black placeholder:text-gray-500"
ref={ref}
onFocus={() => setIsFocused(true)}
onBlur={(e) => {
setIsFocused(false);
onBlur?.(e);
}}
{...props}
/>
{multiline ? (
<textarea
id={id}
ref={ref as React.Ref<HTMLTextAreaElement>}
className={cn(
'w-full bg-transparent text-gray-black outline-none placeholder:text-gray-500',
'resize-none whitespace-pre-wrap break-words',
'h-[10.4rem] p-[1.6rem]',
)}
onFocus={() => setIsFocused(true)}
onBlur={(e) => {
setIsFocused(false);
onBlur?.(e);
}}
{...(props as React.TextareaHTMLAttributes<HTMLTextAreaElement>)}
/>
) : (
Comment on lines +61 to +77
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

여러 줄 입력받을 수 있게 input 컴포넌트를 확장시켰습니다. multiline props가 true면 input이 아니라 textarea로 동작해용

<input
id={id}
type="text"
className="flex-1 p-[1.6rem] text-gray-black placeholder:text-gray-500"
ref={ref}
onFocus={() => setIsFocused(true)}
onBlur={(e) => {
setIsFocused(false);
onBlur?.(e);
}}
{...props}
/>
)}
</div>
{messageToShow && (
<div className="flex-row gap-[0.8rem]">
<Icon
name={inputState === 'valid' ? 'check-filled' : 'info-filled'}
size={2}
className={iconColorClass}
className={cn('text-gray-600', !multiline && iconColorClass)}
/>
<p className={`cap_14_m ${iconColorClass}`}>{messageToShow}</p>
<div className="flex w-full justify-between">
<p className={cn('cap_14_m text-gray-600', !multiline && iconColorClass)}>
{messageToShow}
</p>
{hasLength && <p className="cap_14_m text-gray-600">{length}/50</p>}
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

글자수를 보여주기 위해 hasLength를 추가했습니다

</div>
</div>
)}
</div>
Expand Down
Loading