Skip to content

Commit

Permalink
UI: #69 유저 정보 설정 페이지 UI 구현 (#78)
Browse files Browse the repository at this point in the history
* Feat: #69 유저 개인정보 설정 페이지 구현

* Refactor: #69 UserSettingPage에서 이미지, 링크 관련 코드를 컴포넌트로 분리

* Refactor: #69 SignUpPage에서 이미지, 링크 관련 코드를 컴포넌트로 분리

* Refactor: #69 이미지 전송을 위해 폼에서 이미지를 처리할 수 있도록 코드 리팩토링

* Refactor: #69 UserAuthenticatePage의 이메일 인증 로직을 훅으로 분리 및 인증 submit 버튼을 컴포넌트로 분리

* Refactor: #69 SignUpPage의 이메일 인증 로직, 인증 submit 버튼 분리

* Refactor: #69 인증코드 확인 로직 분리

* Rename: #69 유저설정 페이지 관련 컴포넌트 이름 수정 및 auth-form 폴더 구조 수정

* Feat: #69 인증버튼 라벨 props 추가

* Fix: #69 conflict 에러 해결

* UI: #69 전체적인 코드 태그 수정 및 디자인 코드 수정, 로고 수정

* Refactor: #69 인증번호관리 훅에서 타이머 visible 코드 삭제 및 타이머 visible 로직 수정

* UI: #69 일부 코드 태그 수정 및 디자인 코드 수정

* UI: #69 링크 컴포넌트 버튼 내부 아이콘 크기 조절

* Comment: #69 타이머 만료 코드 주석 수정

* UI: #69 label 태그 중첩 해결 및 input 태그 height 수정
  • Loading branch information
Yoonyesol authored Aug 23, 2024
1 parent 855f6ab commit 1db78dc
Show file tree
Hide file tree
Showing 18 changed files with 530 additions and 439 deletions.
5 changes: 2 additions & 3 deletions src/assets/auth_logo.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
78 changes: 37 additions & 41 deletions src/components/common/ValidationInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@ import { RiEyeFill, RiEyeOffFill } from 'react-icons/ri';
* ValidationInput 컴포넌트 params, 모든 params는 optional
*
* @params {string} [label] - 입력 필드의 label 텍스트
* @params {boolean} [required] - 입력 필드 필수 여부
* @params {boolean} [disabled] - 입력 필드 disabled 여부. 기본값은 'false'
* @params {boolean} [required] - 입력 필드 필수 여부. 기본값은 'true'
* @params {string} [errors] - 유효성 검증 후 오류 발생 시 표시할 오류 메시지
* @params {UseFormRegisterReturn} [register] - react-hook-form의 resister 함수.
* register('password', {required: ...})부분을 그대로 파라미터에 넣으면 됩니다.
Expand All @@ -30,6 +31,7 @@ import { RiEyeFill, RiEyeOffFill } from 'react-icons/ri';

type ValidationInputProps = {
label?: string;
disabled?: boolean;
required?: boolean;
errors?: string;
register?: UseFormRegisterReturn;
Expand All @@ -42,6 +44,7 @@ type ValidationInputProps = {

export default function ValidationInput({
label,
disabled = false,
required = true,
errors,
register,
Expand All @@ -58,49 +61,42 @@ export default function ValidationInput({
};

return (
<div>
<div className="flex flex-row gap-1">
{label && (
<label htmlFor={label} className="font-bold">
{label}
{required && <sup className="font-bold text-main">*</sup>}
</label>
)}
</div>
<section>
{label && (
<label htmlFor={label} className="font-bold">
{label}
{required && <sup className="text-main">*</sup>}
</label>
)}
<div
className={`flex h-30 items-center rounded-lg border px-6 text-sm ${
className={`flex size-full h-25 flex-row items-center rounded-lg border px-6 text-sm ${
errors ? 'border-2 border-error' : 'border-input'
}`}
} ${disabled ? 'bg-disable' : ''}`}
>
<div className="flex h-full w-full flex-row items-center gap-8">
<input
id={label}
{...register}
type={type === 'password' && showPassword ? 'text' : type}
placeholder={placeholder}
className="h-full grow bg-inherit outline-none placeholder:text-default"
/>
{type === 'password' && (
<div className="flex h-20 w-20 items-center text-gray-400">
{showPassword ? (
<RiEyeFill className="h-15 w-15 cursor-pointer" onClick={handleTogglePassword} />
) : (
<RiEyeOffFill className="h-15 w-15 cursor-pointer" onClick={handleTogglePassword} />
)}
</div>
)}
{isButtonInput && (
<button
type="button"
className="flex h-20 w-75 items-center justify-center rounded bg-sub px-8 font-bold"
onClick={onButtonClick}
>
{buttonLabel}
</button>
)}
</div>
<input
disabled={disabled}
id={label}
{...register}
type={type === 'password' && showPassword ? 'text' : type}
placeholder={placeholder}
className="grow bg-inherit outline-none placeholder:text-default"
/>
{type === 'password' && (
<div className="flex size-20 items-center text-gray-400">
{showPassword ? (
<RiEyeFill className="size-13 cursor-pointer" onClick={handleTogglePassword} />
) : (
<RiEyeOffFill className="size-13 cursor-pointer" onClick={handleTogglePassword} />
)}
</div>
)}
{isButtonInput && (
<button type="button" className="h-18 w-75 rounded bg-sub px-8 font-bold" onClick={onButtonClick}>
{buttonLabel}
</button>
)}
</div>
{errors && <p className="mt-[.5rem] text-sm text-error">{errors}</p>}
</div>
{errors && <p className="mt-2 text-sm text-error">{errors}</p>}
</section>
);
}
95 changes: 95 additions & 0 deletions src/components/user/auth-form/LinkContainer.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import { ChangeEvent, useState } from 'react';
import { FaPlus, FaMinus } from 'react-icons/fa6';
import { useFormContext } from 'react-hook-form';
import { USER_SETTINGS } from '@constants/userSettings';
import useToast from '@hooks/useToast';

type LinkContainerProps = {
initialLinks: string[];
};

export default function LinkContainer({ initialLinks }: LinkContainerProps) {
const { setValue } = useFormContext();
const [link, setLink] = useState<string>('');
const [links, setLinks] = useState<string[]>(initialLinks);
const [isFocused, setIsFocused] = useState(false);
const { toastWarn } = useToast();

const handleFocus = () => setIsFocused(true);

const handleBlur = () => setIsFocused(false);

const handleLinkChange = (e: ChangeEvent<HTMLInputElement>) => setLink(e.target.value);

const handleAddLink = (newLink: string) => {
if (newLink.trim() === '') return;
if (links.length === USER_SETTINGS.MAX_LINK_COUNT) {
setLink('');
return toastWarn(`링크는 최대 ${USER_SETTINGS.MAX_LINK_COUNT}개까지 등록할 수 있습니다.`);
}

const isIncludedLink = links.includes(newLink);
if (isIncludedLink) return toastWarn('이미 등록된 링크입니다.');

const updatedLinks = [...links, newLink.trim()];
setLinks(updatedLinks);
setValue('links', updatedLinks);
setLink('');
};

const handleRemoveLink = (removeLink: string) => {
const filteredData = links.filter((linkItem) => linkItem !== removeLink);
setLinks(filteredData);
setValue('links', filteredData);
};

return (
<section>
<label className="font-bold" htmlFor="link">
링크
</label>
<div className="space-y-4">
{links.map((linkItem) => (
<div key={linkItem} className="flex h-25 items-center space-x-8 rounded-lg border border-input px-6 text-sm">
<div className="grow overflow-hidden">
<a href={`https://${linkItem}`} target="_blank" rel="noopener noreferrer">
{linkItem}
</a>
</div>
<button
type="button"
onClick={() => handleRemoveLink(linkItem)}
className="flex size-18 items-center justify-center rounded-lg bg-sub"
aria-label="삭제"
>
<FaMinus className="size-8" />
</button>
</div>
))}
<div
className={`flex h-25 items-center space-x-8 rounded-lg border border-input px-6 text-sm ${isFocused ? 'bg-white' : 'bg-disable'}`}
>
<input
id="link"
placeholder="ex) www.github.com"
value={link}
onFocus={handleFocus}
onBlur={handleBlur}
onChange={handleLinkChange}
type="text"
// TODO: 전체적으로 인풋 관련 스타일링 수정 필요, div 전체를 input이 덮을 수 있도록 수정...
className="h-full grow bg-inherit outline-none placeholder:text-emphasis"
/>
<button
type="button"
onClick={() => handleAddLink(link)}
className="flex size-18 items-center justify-center rounded-lg bg-sub"
aria-label="추가"
>
<FaPlus className="size-8" />
</button>
</div>
</div>
</section>
);
}
60 changes: 60 additions & 0 deletions src/components/user/auth-form/ProfileImageContainer.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { GoPlusCircle } from 'react-icons/go';
import { FaRegTrashCan } from 'react-icons/fa6';
import { useFormContext } from 'react-hook-form';
import { convertBytesToString } from '@utils/converter';
import { USER_SETTINGS } from '@constants/userSettings';
import useToast from '@hooks/useToast';

type ProfileImageContainerProps = {
imageUrl: string;
setImageUrl: React.Dispatch<React.SetStateAction<string>>;
};

export default function ProfileImageContainer({ imageUrl, setImageUrl }: ProfileImageContainerProps) {
const { setValue } = useFormContext();
const { toastWarn } = useToast();

// 이미지 관련 코드
const handleChangeImg = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];

if (!file) return;
if (file.size > USER_SETTINGS.MAX_IMAGE_SIZE) {
e.target.value = '';
return toastWarn(
`최대 ${convertBytesToString(USER_SETTINGS.MAX_IMAGE_SIZE)} 이하의 이미지 파일만 업로드 가능합니다.`,
);
}

const image = URL.createObjectURL(file);
setImageUrl(image);
setValue('profileUrl', image);
};

const handleRemoveImg = () => {
setImageUrl('');
setValue('profileUrl', '');
};

return (
<section className="flex flex-col items-center">
<div className="group relative flex size-100 items-center justify-center overflow-hidden rounded-full border border-input">
{imageUrl ? (
<>
<img src={imageUrl} alt="profileImage" className="size-full object-cover" />
<div className="absolute inset-0 hidden items-center justify-center bg-black bg-opacity-50 group-hover:flex">
<p role="presentation" className="cursor-pointer" onClick={handleRemoveImg} onKeyDown={handleRemoveImg}>
<FaRegTrashCan className="size-15 text-white" />
</p>
</div>
</>
) : (
<label htmlFor="image" className="cursor-pointer">
<input id="image" type="file" className="hidden" onChange={handleChangeImg} />
<GoPlusCircle className="size-15 text-[#5E5E5E]" />
</label>
)}
</div>
</section>
);
}
36 changes: 36 additions & 0 deletions src/components/user/auth-form/VerificationButton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import Timer from '@components/common/Timer';

type VerificationButtonProps = {
isVerificationRequested: boolean;
isSubmitting: boolean;
requestCode: () => void;
expireVerificationCode: () => void;
buttonLabel: string;
};

export default function VerificationButton({
isVerificationRequested,
isSubmitting,
requestCode,
expireVerificationCode,
buttonLabel,
}: VerificationButtonProps) {
return (
<div>
{!isVerificationRequested ? (
<button type="submit" className="h-25 w-full rounded-lg bg-sub px-8 font-bold" onClick={requestCode}>
<span>인증요청</span>
</button>
) : (
<button type="submit" className="relative h-25 w-full rounded-lg bg-sub px-8 font-bold" disabled={isSubmitting}>
{isVerificationRequested && (
<div className="absolute left-10">
<Timer time={180} onTimeout={expireVerificationCode} />
</div>
)}
<span>{buttonLabel}</span>
</button>
)}
</div>
);
}
2 changes: 1 addition & 1 deletion src/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,6 @@
}

.auth-btn {
@apply flex h-30 cursor-pointer items-center justify-center rounded-lg bg-sub px-8 font-bold;
@apply flex h-25 cursor-pointer items-center justify-center rounded-lg bg-sub px-8 font-bold;
}
}
43 changes: 43 additions & 0 deletions src/hooks/useEmailVerification.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { useState } from 'react';
import { UseFormSetError } from 'react-hook-form';
import useToast from '@hooks/useToast';
import type { EmailVerificationForm, UserSignUpForm } from '@/types/UserType';

export default function useEmailVerification() {
const [isVerificationRequested, setIsVerificationRequested] = useState(false);
const { toastSuccess, toastError } = useToast();

// 이메일 인증번호 요청 함수
const requestVerificationCode = () => {
if (!isVerificationRequested) {
setIsVerificationRequested(true);
toastSuccess('인증번호가 발송되었습니다. 이메일을 확인해 주세요.');
}
};

// 인증번호 확인 함수
const verifyCode = (verificationCode: string, setError: UseFormSetError<UserSignUpForm | EmailVerificationForm>) => {
if (verificationCode === '1234') return true;

// 인증번호 불일치
setError('code', {
type: 'manual',
message: '인증번호가 일치하지 않습니다.',
});
toastError('인증번호가 유효하지 않습니다. 다시 시도해 주세요.');
return false;
};

// 인증 코드 만료
const expireVerificationCode = () => {
setIsVerificationRequested(false);
toastError('인증 시간이 만료되었습니다. 다시 시도해 주세요.');
};

return {
isVerificationRequested,
requestVerificationCode,
verifyCode,
expireVerificationCode,
};
}
Original file line number Diff line number Diff line change
@@ -1,20 +1,20 @@
import { FormEvent, ReactNode } from 'react';

type AuthFormProps = {
type AuthFormLayoutProps = {
children: ReactNode;
onSubmit: (e: FormEvent<HTMLFormElement>) => void;
marginTop: 'mt-34.9' | 'mt-40';
};

export default function AuthForm({ children, onSubmit, marginTop }: AuthFormProps) {
export default function AuthFormLayout({ children, onSubmit, marginTop }: AuthFormLayoutProps) {
return (
<>
<section className="mt-40 text-large text-main">
Welcome to our site!
<br />
Grow Up your Life with us.
</section>
<form onSubmit={onSubmit} className={`${marginTop} flex h-screen w-300 flex-col justify-center gap-8 py-30`}>
<form onSubmit={onSubmit} className={`flex h-screen w-300 flex-col justify-center gap-8 py-30 ${marginTop}`}>
{children}
</form>
</>
Expand Down
2 changes: 1 addition & 1 deletion src/layouts/page/AuthLayout.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { Outlet } from 'react-router-dom';
import AuthGULogo from '@/assets/auth_logo.svg';
import AuthGULogo from '@assets/auth_logo.svg';

export default function AuthLayout() {
return (
Expand Down
Loading

0 comments on commit 1db78dc

Please sign in to comment.