Skip to content

[페이지별] 회원가입

minjee edited this page Sep 23, 2023 · 5 revisions

회원가입 페이지

회원가입

정보 입력 후 가입 버튼을 누르면 서버로 데이터를 전송하고 로컬 스토리지 등에 정보를 저장합니다.

서버로 데이터 전송

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}
    >
      &#62;
    </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>
  </>
);

전체 동의하지 않을 시 가입하기 버튼을 활성화하지 않도록 해 놨습니다.

유효성 검사

사용자가 입력한 데이터가 요구 사항에 부합하는지 확인하고 처리합니다.

비어 있는 input 검사

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로 결과 메시지를 표시합니다.

결과물

화면 기록 2023-09-23 오후 10 09 31 화면 기록 2023-09-23 오후 10 12 15

전체 코드

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">
          이미 회원이신가요?&nbsp;
          <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>
    </>
  );
}