-
Notifications
You must be signed in to change notification settings - Fork 4
[페이지별] 회원가입
minjee edited this page Sep 23, 2023
·
5 revisions
-
회원가입
- 서버로 데이터 전송
-
약관 모달
- 모달 구현
-
약관 동의
- 전체 동의 체크
- 약관 모두 동의 후 가입 버튼 활성화
-
유효성 검사
- 비어 있는 input 검사
- 형식 검사
- 이메일/아이디/닉네임 중복 검사
- 결과물
- 전체 코드
정보 입력 후 가입 버튼을 누르면 서버로 데이터를 전송하고 로컬 스토리지 등에 정보를 저장합니다.
const handleRegister = async (e) => {
e.preventDefault();
const { password, passwordConfirm } = formState;
if (password !== passwordConfirm) {
alert('비밀번호가 일치하지 않습니다.');
return;
}
await pb.collection('user').create({
...formState,
emailVisibility: true,
});
navigate('/login');
};
- 비밀번호와 비밀번호 확인이 일치하는지 확인한 후, pb.collection('user').create() 메서드를 사용하여 서버로 데이터를 전송합니다.
- 전송된 정보에는 formState 객체의 내용과 추가적인 emailVisibility 속성이 포함됩니다.
- 회원가입이 완료되면 /login 경로로 페이지 이동을 수행합니다.
약관 보기 버튼 클릭 시 약관을 보여 주는 모달을 띄워 줍니다.
export default function Modal({ children }) {
const [isOpen, setIsOpen] = useState(false);
const openModalHandler = () => {
setIsOpen(!isOpen);
};
- 모달의 열림 상태를 관리하는 상태 변수 isOpen을 사용하여 모달의 열림/닫힘 상태를 제어합니다.
- 모달을 열고 닫는 버튼을 클릭하면 openModalHandler 함수가 실행되어 모달이 열리거나 닫힙니다.
return (
<>
<button
type="button"
className="text-sm rounded-full"
onClick={openModalHandler}
>
>
</button>
{isOpen ? (
<div
onClick={openModalHandler}
className="fixed flex justify-center items-center bg-black bg-opacity-40 rounded-lg top-0 left-0 right-0 bottom-0 z-10"
>
<div
onClick={(e) => e.stopPropagation()}
role="dialog" // 접근성 향상: 스크린 리더가 이 요소를 대화형 컨텐츠로 인식하게 합니다.
className="flex flex-col bg-white rounded-lg w-[500px]"
>
<button
onClick={openModalHandler}
className="bg-blue text-center text-white rounded-lg m-[10px] w-[30px] h-[30px] leading-none"
>
x
</button>
<div className="m-7 overflow-auto max-h-[400px]">{children}</div>
</div>
</div>
) : null}
</>
);
- isOpen 상태에 따라 모달 내용이 조건부로 렌더링됩니다. 모달이 열려있을 때는 배경 부분 클릭 시 모달이 닫히도록 처리되며, 내부 컨텐츠는 스크롤바가 생기도록 설정됩니다.
- onClick={(e) => e.stopPropagation()}: 내부 div 클릭 시 부모 div의 onClick 이벤트가 실행되지 않도록 합니다.(이벤트 버블링 방지)
전체 동의를 누를 시 모든 약관에 동의합니다. 약관을 모두 동의하지 않으면 가입 버튼이 활성화되지 않습니다. 약관 동의 부분은 Termscheck 컴포넌트로 분리하여 개별 관리합니다.
const [isAllAccepted, setIsAllAccepted] = useState(false);
const [isUseAccepted, setIsUseAccepted] = useState(false);
const [isPrivacyAccepted, setIsPrivacyAccepted] = useState(false);
const handleAllChange = (e) => {
const isChecked = e.target.checked;
setIsAllAccepted(isChecked);
setIsUseAccepted(isChecked);
setIsPrivacyAccepted(isChecked);
};
- isAllAccepted 상태 변수를 사용하여 전체 동의 체크 여부를 관리합니다.
- handleAllChange 함수는 전체 동의 체크박스가 선택되거나 해제될 때 개별 항목들도 그에 맞게 선택하거나 해제합니다.
const handleIndividualChange = (setIndividualState) => (event) => {
setIndividualState(event.target.checked);
};
useEffect(() => {
if (!isUseAccepted || !isPrivacyAccepted) {
setIsAllAccepted(false);
} else {
setIsAllAccepted(true);
}
}, [isUseAccepted, isPrivacyAccepted]);
useEffect(() => {
if (isAllAccepted) {
setIsUseAccepted(isAllAccepted);
setIsPrivacyAccepted(isAllAccepedt)
}
}, [isAllAccpetd]);
- isUseAccepted와 isPrivacyAccepted 상태 변수를 사용하여 이용 약관과 개인정보 수집 및 이용 동의 여부를 관리합니다.
- handleIndividualChange 함수는 개별 항목 체크박스가 클릭되었을 때 해당 상태 변수를 업데이트합니다.
- useEffect 훅을 사용하여 isUseAccepted, isPrivacyAccepted, 그리고 isAllAccepted 값이 변경될 때마다 필요한 로직을 수행합니다.
- 두 값 중 하나라도 false면 isAllAccepted 값을 false로 설정하고, 둘 다 true면 isAllAccepted 값을 true로 설정합니다.
- isAllAccepted 값이 true면 모든 항목을 체크 상태로 변경합니다.
return (
<>
<div className="flex flex-col gap-2 my-3">
<CheckField
id="checkAll"
name="checkAll"
placeholder="전체 약관 동의"
className="w-[400px] px-5 py-4 bg-gray-200 rounded-md"
onChange={handleAllChange}
checked={isAllAccepted}
/>
<div className="flex justify-between items-center pl-5 pr-20">
<CheckField
id="checkUse"
name="checkUse"
placeholder="이용 약관 동의"
className="pt-1"
onChange={handleIndividualChange(setIsUseAccepted)}
checked={isUseAccepted}
/>
<Modal>
<TermsOfServiceUse />
</Modal>
</div>
<div className="flex justify-between items-center pl-5 pr-20">
<CheckField
id="checkPrivacy"
name="checkPrivacy"
placeholder="개인정보 수집 및 이용 동의"
onChange={handleIndividualChange(setIsPrivacyAccepted)}
checked={isPrivacyAccepted}
/>
<Modal>
<TermsOfServicePrivacy />
</Modal>
</div>
</div>
<Button
disabled={!isUseAccepted || !isPrivacyAccepted}
bgColor={!isAllAccepted ? 'bg-sand' : 'bg-blue'}
>
가입하기
</Button>
</>
);
전체 동의하지 않을 시 가입하기 버튼을 활성화하지 않도록 해 놨습니다.
사용자가 입력한 데이터가 요구 사항에 부합하는지 확인하고 처리합니다.
if (!username) {
toast.error('아이디는 필수 입력 값입니다.');
return;
}
if (!email) {
toast.error('이메일은 필수 입력 값입니다.');
return;
}
if (!nickname) {
toast.error('닉네임은 필수 입력 값입니다.');
return;
}
if (!password) {
toast.error('비밀번호는 필수 입력 값입니다.');
return;
}
각 input 필드에 대해 formState 상태 변수를 사용하여 입력된 값이 비어 있는지 검사합니다.
//영문 3자리 이상 20 미만 아이디 정규식
export function idReg(text){
const reg = /^[a-z]+[a-z0-9]{2,19}$/g;;
return reg.test(String(text).toLowerCase())
}
//특수문자포함 최소 8자이상~16자이하 비밀번호 정규식
export function pwReg(text){
const reg = /^(?=.*[a-zA-Z])(?=.*[,~!@#$%^*+=-]).{8,16}$/;
return reg.test(String(text).toLowerCase());
}
//이메일 형식 검증 정규식
export function emailReg(text) {
const re =
/^(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/;
return re.test(String(text).toLowerCase());
}
validation.js: 아이디와 비밀번호, 이메일의 형식을 검사 유틸 함수를 사용합니다.
if (nickname.length < 2 || nickname.length > 10) {
toast.error('닉네임은 2자 이상, 10자 이하로 입력해주세요.');
return;
}
if (nickname.includes(' ')) {
toast.error('닉네임에는 공백이 포함될 수 없습니다.');
return;
}
if (!idReg(username)) {
toast.error('아이디 형식이 잘못되었습니다.');
return;
}
if (!pwReg(password)) {
toast.error('비밀번호 형식이 잘못되었습니다.');
return;
}
if (!emailReg(email)) {
toast.error('이메일 형식이 잘못되었습니다.');
return;
}
아이디, 닉네임, 비밀번호, 이메일의 형식을 정규식으로 검사합니다.
const handleNicknameDuplication = async () => {
const nickname = formState.nickname;
if (nickname === '') toast.error('입력된 값이 없습니다.');
else {
try {
const records = await pb.collection('user').getList(1, 1, {
filter: `nickname = '${nickname}'`,
});
if (records.items.length > 0) {
toast.error('닉네임이 중복됩니다.');
} else {
toast.success('사용 가능한 닉네임입니다.');
}
} catch (error) {
console.error(error);
}
}
};
- 각각의 중복 검사 함수에서 PocketBase API pb.collection("user").getList()를 사용하여 중복된 값을 확인합니다.
- 입력된 값에 대한 중복 여부에 따라 toast.success 또는 toast.error로 결과 메시지를 표시합니다.
Join.jsx 컴포넌트 코드
import pb from '@/api/pocketbase';
import InputField from '@/components/InputField';
import LinkItem from '@/components/LinkItem';
import LoginPageContent from '@/components/login/LoginPageContent';
import Logo from '@/components/Logo';
import PageHead from '@/components/PageHead';
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import Termscheck from '@/components/join/Termscheck';
import { toast } from 'react-hot-toast';
import { ClientResponseError } from 'pocketbase';
import { emailReg, idReg, pwReg } from '@/utils/validation';
import Button from '@/components/Button';
export default function Join() {
const navigate = useNavigate();
const [formState, setFormState] = useState({
username: '',
nickname: '',
email: '',
password: '',
passwordConfirm: '',
});
const [isAgreed, setIsAgreed] = useState(false);
const handleInput = (e) => {
const { name, value } = e.target;
setFormState({
...formState,
[name]: value,
});
};
const handleIdDuplication = async () => {
const id = formState.username;
if (id === '') toast.error('입력된 값이 없습니다.');
else {
try {
const records = await pb.collection('user').getList(1, 1, {
filter: `username = '${id}'`,
});
if (records.items.length > 0) {
toast.error('아이디가 중복됩니다.');
} else {
toast.success('사용 가능한 아이디입니다.');
}
} catch (error) {
console.error(error);
}
}
};
const handleEmailDuplication = async () => {
const email = formState.email;
if (email === '') toast.error('입력된 값이 없습니다.');
else {
try {
const records = await pb.collection('user').getList(1, 1, {
filter: `email = '${email}'`,
});
if (records.items.length > 0) {
toast.error('이메일이 중복됩니다.');
} else {
toast.success('사용 가능한 이메일입니다.');
}
} catch (error) {
console.error(error);
}
}
};
const handleNicknameDuplication = async () => {
const nickname = formState.nickname;
if (nickname === '') toast.error('입력된 값이 없습니다.');
else {
try {
const records = await pb.collection('user').getList(1, 1, {
filter: `nickname = '${nickname}'`,
});
if (records.items.length > 0) {
toast.error('닉네임이 중복됩니다.');
} else {
toast.success('사용 가능한 닉네임입니다.');
}
} catch (error) {
console.error(error);
}
}
};
const handleRegister = async (e) => {
e.preventDefault();
try {
const { username, nickname, email, password, passwordConfirm } =
formState;
const newUser = {
email: email,
emailVisibility: true,
username: username,
nickname: nickname,
password: password,
passwordConfirm: passwordConfirm,
};
if (!username) {
toast.error('아이디는 필수 입력 값입니다.');
return;
}
if (!email) {
toast.error('이메일은 필수 입력 값입니다.');
return;
}
if (!nickname) {
toast.error('닉네임은 필수 입력 값입니다.');
return;
}
if (!password) {
toast.error('비밀번호는 필수 입력 값입니다.');
return;
}
if (nickname.length < 2 || nickname.length > 10) {
toast.error('닉네임은 2자 이상, 10자 이하로 입력해주세요.');
return;
}
if (nickname.includes(' ')) {
toast.error('닉네임에는 공백이 포함될 수 없습니다.');
return;
}
if (!idReg(username)) {
toast.error('아이디 형식이 잘못되었습니다.');
return;
}
if (!pwReg(password)) {
toast.error('비밀번호 형식이 잘못되었습니다.');
return;
}
if (!emailReg(email)) {
toast.error('이메일 형식이 잘못되었습니다.');
return;
}
if (password !== passwordConfirm) {
toast.error('비밀번호가 일치하지 않습니다.');
return;
}
if (!isAgreed) {
toast.error('약관에 동의해주세요.');
return;
}
const record = await pb.collection('user').create(newUser);
if (record?.id) {
toast.success('회원가입이 완료됐습니다.');
navigate('/login');
} else {
toast.error('회원가입에 실패했습니다.');
}
} catch (error) {
if (!(error instanceof ClientResponseError)) {
console.error('회원가입 실패:', error);
toast.error('회원가입에 실패했습니다.');
}
}
};
return (
<>
<PageHead title="Jeju All in One - 회원가입" />
<LoginPageContent>
<Logo />
{/* 회원가입 폼 */}
<form onSubmit={handleRegister} className="flex flex-col gap-3 mb-5">
<div className="flex gap-2">
<InputField
id="id"
type="text"
name="username"
placeholder="아이디"
value={formState.username}
onChange={handleInput}
/>
<Button
onClick={handleIdDuplication}
type="button"
textSize="text-xs"
wight="w-[60px]"
>
중복 확인
</Button>
</div>
<div className="flex gap-2">
<InputField
id="nickname"
type="text"
name="nickname"
placeholder="닉네임 (공백없이 2~10자)"
value={formState.nickname}
onChange={handleInput}
/>
<Button
onClick={handleNicknameDuplication}
type="button"
textSize="text-xs"
wight="w-[60px]"
>
중복 확인
</Button>
</div>
<div className="flex gap-2">
<InputField
id="email"
type="text"
name="email"
placeholder="이메일"
value={formState.email}
onChange={handleInput}
/>
<Button
onClick={handleEmailDuplication}
type="button"
textSize="text-xs"
wight="w-[60px]"
>
중복 확인
</Button>
</div>
<InputField
id="password"
type="password"
name="password"
placeholder="비밀번호"
onChange={handleInput}
/>
<p className="text-sand">
특수문자 포함 최소 8자 이상, 16자 이하로 만들어 주세요.
</p>
<InputField
id="checkPassword"
type="password"
name="passwordConfirm"
placeholder="비밀번호 확인"
onChange={handleInput}
/>
{/* 약관 동의 */}
<Termscheck setIsAgreed={setIsAgreed} />
</form>
{/* 로그인 페이지 이동 */}
<p className="mt-3 mr-10">
이미 회원이신가요?
<LinkItem link="/login" className="font-extrabold text-blue">
로그인 하기
</LinkItem>
</p>
</LoginPageContent>
</>
);
}
Termscheck.jsx 컴포넌트 코드
import { useState } from 'react';
import Button from '@/components/Button';
import { CheckField } from '@/components/InputField';
import Modal from '@/components/join/Modal';
import {
TermsOfServicePrivacy,
TermsOfServiceUse,
} from '@/components/join/TermsOfService';
import { useEffect } from 'react';
export default function Termscheck({ setIsAgreed }) {
const [isAllAccepted, setIsAllAccepted] = useState(false);
const [isUseAccepted, setIsUseAccepted] = useState(false);
const [isPrivacyAccepted, setIsPrivacyAccepted] = useState(false);
const handleAllChange = (e) => {
const isChecked = e.target.checked;
setIsAllAccepted(isChecked);
setIsUseAccepted(isChecked);
setIsPrivacyAccepted(isChecked);
};
const handleIndividualChange = (setIndividualState) => (event) => {
setIndividualState(event.target.checked);
};
useEffect(() => {
if (!isUseAccepted || !isPrivacyAccepted) {
setIsAllAccepted(false);
setIsAgreed(false);
} else {
setIsAllAccepted(true);
setIsAgreed(true);
}
}, [isUseAccepted, isPrivacyAccepted]);
useEffect(() => {
if (isAllAccepted) {
setIsUseAccepted(isAllAccepted);
setIsPrivacyAccepted(isAllAccepted);
}
}, [isAllAccepted]);
return (
<>
<div className="flex flex-col gap-2 my-3">
<CheckField
id="checkAll"
name="checkAll"
placeholder="전체 약관 동의"
className="w-[400px] px-5 py-4 bg-gray-200 rounded-md"
onChange={handleAllChange}
checked={isAllAccepted}
/>
<div className="flex justify-between items-center pl-5 pr-20">
<CheckField
id="checkUse"
name="checkUse"
placeholder="이용 약관 동의"
className="pt-1"
onChange={handleIndividualChange(setIsUseAccepted)}
checked={isUseAccepted}
/>
<Modal>
<TermsOfServiceUse />
</Modal>
</div>
<div className="flex justify-between items-center pl-5 pr-20">
<CheckField
id="checkPrivacy"
name="checkPrivacy"
placeholder="개인정보 수집 및 이용 동의"
onChange={handleIndividualChange(setIsPrivacyAccepted)}
checked={isPrivacyAccepted}
/>
<Modal>
<TermsOfServicePrivacy />
</Modal>
</div>
</div>
<Button
// disabled={!isUseAccepted || !isPrivacyAccepted}
bgColor={!isAllAccepted ? 'bg-sand' : 'bg-blue'}
>
가입하기
</Button>
</>
);
}