diff --git a/frontend/src/api/join.ts b/frontend/src/api/join.ts index 456064f50..d75ac4fb2 100644 --- a/frontend/src/api/join.ts +++ b/frontend/src/api/join.ts @@ -1,17 +1,20 @@ import { AxiosResponse } from 'axios'; import { QueryFunction } from 'react-query'; import THROW_ERROR from 'constants/throwError'; +import { QueryEmojiListSuccess } from 'types/response'; import api from './api'; interface JoinParams { + emoji: string; email: string; password: string; - organization: string; + userName: string; } interface SocialJoinParams { + emoji: string; email: string; - organization: string; + userName: string; oauthProvider: 'GITHUB' | 'GOOGLE'; } @@ -24,20 +27,24 @@ export const queryValidateEmail: QueryFunction = ({ queryKey }) => { if (typeof email !== 'string') throw new Error(THROW_ERROR.INVALID_EMAIL_FORMAT); - return api.get(`/members?email=${email}`); + return api.post(`/members/validations/email`, { email }); }; -export const postJoin = ({ email, password, organization }: JoinParams): Promise => { - return api.post('/members', { email, password, organization }); +export const queryValidateUserName: QueryFunction = ({ queryKey }) => { + const [, userName] = queryKey; + + if (typeof userName !== 'string') throw new Error(THROW_ERROR.INVALID_USER_NAME_FORMAT); + + return api.post(`/members/validations/username`, { userName }); +}; + +export const postJoin = (params: JoinParams): Promise => { + return api.post('/members', params); }; -export const postSocialJoin = ({ - email, - organization, - oauthProvider, -}: SocialJoinParams): Promise => - api.post(`/members/oauth`, { - email, - organization, - oauthProvider, - }); +export const postSocialJoin = (params: SocialJoinParams): Promise => + api.post(`/members/oauth`, params); + +export const getEmojiList: QueryFunction> = () => { + return api.get('/members/emojis'); +}; diff --git a/frontend/src/constants/manager.ts b/frontend/src/constants/manager.ts index e8b64cb8a..9509f8769 100644 --- a/frontend/src/constants/manager.ts +++ b/frontend/src/constants/manager.ts @@ -3,6 +3,10 @@ const MANAGER = { MIN_LENGTH: 8, MAX_LENGTH: 20, }, + USERNAME: { + MIN_LENGTH: 1, + MAX_LENGTH: 20, + }, ORGANIZATION: { MIN_LENGTH: 1, }, diff --git a/frontend/src/constants/message.ts b/frontend/src/constants/message.ts index c6af8a53b..9d189605a 100644 --- a/frontend/src/constants/message.ts +++ b/frontend/src/constants/message.ts @@ -7,9 +7,14 @@ const MESSAGE = { INVALID_PASSWORD: '영어와 숫자를 포함하여 8~20자로 입력해주세요.', VALID_PASSWORD_CONFIRM: '비밀번호가 일치합니다.', INVALID_PASSWORD_CONFIRM: '비밀번호가 서로 다릅니다.', + VALID_USERNAME: '사용 가능한 이름입니다.', + INVALID_USERNAME: '특수문자는 _ . , ! ? 만 허용됩니다.', VALID_ORGANIZATION: '유효한 조직명입니다.', INVALID_ORGANIZATION: '특수문자는 _ . , ! ? 만 허용됩니다.', - UNEXPECTED_ERROR: '이메일 중복 확인에 문제가 발생했습니다. 잠시 후에 다시 시도해주세요.', + CHECK_EMAIL_UNEXPECTED_ERROR: + '이메일 중복 확인에 문제가 발생했습니다. 잠시 후에 다시 시도해주세요.', + CHECK_USERNAME_UNEXPECTED_ERROR: + '이름 중복 확인에 문제가 발생했습니다. 잠시 후에 다시 시도해주세요.', }, LOGIN: { UNEXPECTED_ERROR: '로그인에 문제가 발생했습니다. 잠시 후에 다시 시도해주세요.', diff --git a/frontend/src/constants/regexp.ts b/frontend/src/constants/regexp.ts index edc70d510..9bedb0dd9 100644 --- a/frontend/src/constants/regexp.ts +++ b/frontend/src/constants/regexp.ts @@ -1,6 +1,7 @@ const REGEXP = { PASSWORD: /^(?=.*[a-zA-Z])(?=.*[0-9]).{8,20}$/, RESERVATION_PASSWORD: /^[0-9]{4}$/, + USERNAME: /^[a-zA-Z0-9ㄱ-ㅎ가-힣ㅏ-ㅣ-_!?.,\s]{1,}$/, ORGANIZATION: /^[a-zA-Z0-9ㄱ-ㅎ가-힣ㅏ-ㅣ-_!?.,\s]{1,}$/, }; diff --git a/frontend/src/constants/throwError.ts b/frontend/src/constants/throwError.ts index 52a5ad4c3..04a0b7ca3 100644 --- a/frontend/src/constants/throwError.ts +++ b/frontend/src/constants/throwError.ts @@ -1,5 +1,6 @@ const THROW_ERROR = { INVALID_EMAIL_FORMAT: '이메일은 "string" 형식이어야 합니다.', + INVALID_USER_NAME_FORMAT: '이름은 "string" 형식이어야 합니다.', INVALID_MAP_ID: '맵 ID가 올바르지 않습니다. 다시 확인해주세요.', NOT_EXIST_CONTEXT: 'context가 존재하지 않습니다.', NOT_EXIST_PRESET: '프리셋을 찾을 수 없습니다.', diff --git a/frontend/src/hooks/query/useEmojiList.ts b/frontend/src/hooks/query/useEmojiList.ts new file mode 100644 index 000000000..430cfe89b --- /dev/null +++ b/frontend/src/hooks/query/useEmojiList.ts @@ -0,0 +1,14 @@ +import { AxiosError, AxiosResponse } from 'axios'; +import { QueryKey, useQuery, UseQueryOptions, UseQueryResult } from 'react-query'; +import { getEmojiList } from 'api/join'; +import { QueryEmojiListSuccess } from 'types/response'; + +const useEmojiList = >( + options?: UseQueryOptions, AxiosError, TData, [QueryKey]> +): UseQueryResult => + useQuery(['getEmojiList'], getEmojiList, { + ...options, + refetchOnWindowFocus: false, + }); + +export default useEmojiList; diff --git a/frontend/src/pages/ManagerJoin/ManagerJoin.tsx b/frontend/src/pages/ManagerJoin/ManagerJoin.tsx index 9e2671da3..7d1c00c99 100644 --- a/frontend/src/pages/ManagerJoin/ManagerJoin.tsx +++ b/frontend/src/pages/ManagerJoin/ManagerJoin.tsx @@ -13,9 +13,10 @@ import * as Styled from './ManagerJoin.styles'; import JoinForm from './units/JoinForm'; export interface JoinParams { + emoji: string; email: string; password: string; - organization: string; + userName: string; } const ManagerJoin = (): JSX.Element => { @@ -32,10 +33,10 @@ const ManagerJoin = (): JSX.Element => { }, }); - const handleSubmit = ({ email, password, organization }: JoinParams) => { - if (!email || !password || !organization) return; + const handleSubmit = ({ emoji, email, password, userName }: JoinParams) => { + if (!emoji || !email || !password || !userName) return; - join.mutate({ email, password, organization }); + join.mutate({ emoji, email, password, userName }); }; return ( diff --git a/frontend/src/pages/ManagerJoin/units/EmojiSelector.styles.ts b/frontend/src/pages/ManagerJoin/units/EmojiSelector.styles.ts new file mode 100644 index 000000000..6e1c408d4 --- /dev/null +++ b/frontend/src/pages/ManagerJoin/units/EmojiSelector.styles.ts @@ -0,0 +1,78 @@ +import styled from 'styled-components'; + +export const EmojiSelector = styled.div` + position: relative; + padding: 0.75rem; + margin-top: 0.5rem; + margin-bottom: 2rem; + width: 100%; + border-top: 1px solid ${({ theme }) => theme.gray[500]}; + background: none; + outline: none; + display: flex; + justify-content: center; +`; + +export const LabelText = styled.span` + position: absolute; + display: inline-block; + top: -0.375rem; + left: 50%; + transform: translateX(-50%); + padding: 0 0.25rem; + font-size: 0.75rem; + background-color: white; + color: ${({ theme }) => theme.gray[500]}; +`; + +export const EmojiList = styled.div` + margin-top: 1rem; + font-size: 2.5rem; + display: grid; + grid-template-rows: repeat(2, 4rem); + grid-template-columns: repeat(5, 4rem); + gap: 1.25rem; + + @media (max-width: ${({ theme: { breakpoints } }) => breakpoints.sm}px) { + font-size: 1.5rem; + grid-template-rows: repeat(2, 3rem); + grid-template-columns: repeat(5, 3rem); + gap: 0.5rem; + } +`; + +export const EmojiItem = styled.label` + position: relative; + margin-bottom: 0; + justify-self: center; + align-self: center; +`; + +export const EmojiCode = styled.div` + cursor: pointer; + border-radius: 999px; + width: 4rem; + height: 4rem; + display: inline-flex; + justify-content: center; + align-items: center; + background-color: ${({ theme }) => theme.gray[100]}; + + input:checked + & { + background-color: ${({ theme }) => theme.primary[400]}; + } + + @media (max-width: ${({ theme: { breakpoints } }) => breakpoints.sm}px) { + width: 3rem; + height: 3rem; + } +`; + +export const Radio = styled.input` + position: absolute; + top: 0; + left: 0; + width: 0; + height: 0; + visibility: hidden; +`; diff --git a/frontend/src/pages/ManagerJoin/units/EmojiSelector.tsx b/frontend/src/pages/ManagerJoin/units/EmojiSelector.tsx new file mode 100644 index 000000000..90605130f --- /dev/null +++ b/frontend/src/pages/ManagerJoin/units/EmojiSelector.tsx @@ -0,0 +1,41 @@ +import { useMemo } from 'react'; +import useEmojiList from 'hooks/query/useEmojiList'; +import * as Styled from './EmojiSelector.styles'; + +interface EmojiSelectorProps { + onSelect?: (emoji: string) => void; +} + +const EmojiSelector = ({ onSelect }: EmojiSelectorProps): JSX.Element => { + const emojiListQuery = useEmojiList(); + + const emojiList = useMemo( + () => emojiListQuery.data?.data.emojis ?? [], + [emojiListQuery.data?.data.emojis] + ); + + const handleSelect = (emoji: string) => { + onSelect?.(emoji); + }; + + return ( + + 프로필 이모지 선택 + + {emojiList.map((emoji) => ( + + handleSelect(emoji.name)} + /> + {emoji.code} + + ))} + + + ); +}; + +export default EmojiSelector; diff --git a/frontend/src/pages/ManagerJoin/units/JoinForm.styles.ts b/frontend/src/pages/ManagerJoin/units/JoinForm.styles.ts index 32813ffb5..413d3fac3 100644 --- a/frontend/src/pages/ManagerJoin/units/JoinForm.styles.ts +++ b/frontend/src/pages/ManagerJoin/units/JoinForm.styles.ts @@ -2,8 +2,8 @@ import styled from 'styled-components'; export const Form = styled.form` margin: 3.75rem 0 1rem; +`; - label { - margin-bottom: 3rem; - } +export const InputWrapper = styled.div` + margin-bottom: 3rem; `; diff --git a/frontend/src/pages/ManagerJoin/units/JoinForm.tsx b/frontend/src/pages/ManagerJoin/units/JoinForm.tsx index 49c4eb165..dd1ed64da 100644 --- a/frontend/src/pages/ManagerJoin/units/JoinForm.tsx +++ b/frontend/src/pages/ManagerJoin/units/JoinForm.tsx @@ -1,7 +1,7 @@ import { AxiosError } from 'axios'; import React, { FormEventHandler, useEffect, useState } from 'react'; import { useQuery } from 'react-query'; -import { queryValidateEmail } from 'api/join'; +import { queryValidateEmail, queryValidateUserName } from 'api/join'; import Button from 'components/Button/Button'; import Input from 'components/Input/Input'; import MANAGER from 'constants/manager'; @@ -10,34 +10,35 @@ import REGEXP from 'constants/regexp'; import useInputs from 'hooks/useInputs'; import { ErrorResponse } from 'types/response'; import { JoinParams } from '../ManagerJoin'; +import EmojiSelector from './EmojiSelector'; import * as Styled from './JoinForm.styles'; interface Form { email: string; password: string; passwordConfirm: string; - organization: string; + userName: string; } interface Props { - onSubmit: ({ email, password, organization }: JoinParams) => void; + onSubmit: ({ emoji, email, password, userName }: JoinParams) => void; } const JoinForm = ({ onSubmit }: Props): JSX.Element => { - const [{ email, password, passwordConfirm, organization }, onChangeForm] = useInputs
({ + const [emoji, setEmoji] = useState(''); + const [{ email, password, passwordConfirm, userName }, onChangeForm] = useInputs({ email: '', password: '', passwordConfirm: '', - organization: '', + userName: '', }); const [emailMessage, setEmailMessage] = useState(''); const [passwordMessage, setPasswordMessage] = useState(''); const [passwordConfirmMessage, setPasswordConfirmMessage] = useState(''); - const [organizationMessage, setOrganizationMessage] = useState(''); + const [userNameMessage, setUserNameMessage] = useState(''); const isValidPassword = REGEXP.PASSWORD.test(password); - const isValidOrganization = REGEXP.ORGANIZATION.test(organization); const checkValidateEmail = useQuery(['checkValidateEmail', email], queryValidateEmail, { enabled: false, @@ -48,16 +49,55 @@ const JoinForm = ({ onSubmit }: Props): JSX.Element => { }, onError: (error: AxiosError) => { - setEmailMessage(error.response?.data.message ?? ''); + setEmailMessage(error.response?.data.message ?? MESSAGE.JOIN.CHECK_EMAIL_UNEXPECTED_ERROR); }, }); + const checkValidateUserName = useQuery( + ['checkValidateUserName', userName], + queryValidateUserName, + { + enabled: false, + retry: false, + + onSuccess: () => { + setUserNameMessage(MESSAGE.JOIN.VALID_USERNAME); + }, + + onError: (error: AxiosError) => { + setUserNameMessage( + error.response?.data.message ?? MESSAGE.JOIN.CHECK_USERNAME_UNEXPECTED_ERROR + ); + }, + } + ); + + const handleChangeEmail = (event: React.ChangeEvent) => { + onChangeForm(event); + setEmailMessage(''); + }; + + const handleChangeUserName = (event: React.ChangeEvent) => { + onChangeForm(event); + setUserNameMessage(''); + }; + const handleValidateEmail = () => { if (!email) return; checkValidateEmail.refetch(); }; + const handleValidateUserName = () => { + if (!userName) return; + + checkValidateUserName.refetch(); + }; + + const handleSelectEmoji = (emoji: string) => { + setEmoji(emoji); + }; + const handleSubmit: FormEventHandler = (event) => { event.preventDefault(); @@ -67,7 +107,7 @@ const JoinForm = ({ onSubmit }: Props): JSX.Element => { return; } - onSubmit({ email, password, organization }); + onSubmit({ emoji, email, password, userName }); }; useEffect(() => { @@ -88,72 +128,76 @@ const JoinForm = ({ onSubmit }: Props): JSX.Element => { ); }, [password, passwordConfirm]); - useEffect(() => { - if (!organization) { - setOrganizationMessage(''); - - return; - } - - setOrganizationMessage( - isValidOrganization ? MESSAGE.JOIN.VALID_ORGANIZATION : MESSAGE.JOIN.INVALID_ORGANIZATION - ); - }, [organization, isValidOrganization]); - return ( - - - - + + + + + + + + + + + + + + + + + + diff --git a/frontend/src/pages/ManagerSocialJoin/ManagerSocialJoin.tsx b/frontend/src/pages/ManagerSocialJoin/ManagerSocialJoin.tsx index a05d369c8..5c44afb5e 100644 --- a/frontend/src/pages/ManagerSocialJoin/ManagerSocialJoin.tsx +++ b/frontend/src/pages/ManagerSocialJoin/ManagerSocialJoin.tsx @@ -11,8 +11,9 @@ import * as Styled from './ManagerSocialJoin.styles'; import SocialJoinForm from './units/SocialJoinForm'; export interface SocialJoinParams { + emoji: string; email: string; - organization: string; + userName: string; } interface SocialJoinState { @@ -37,10 +38,10 @@ const ManagerSocialJoin = (): JSX.Element => { }, }); - const handleSubmit = ({ email, organization }: SocialJoinParams) => { - if (!email || !organization || !oauthProvider || socialJoin.isLoading) return; + const handleSubmit = ({ emoji, email, userName }: SocialJoinParams) => { + if (!emoji || !email || !userName || !oauthProvider || socialJoin.isLoading) return; - socialJoin.mutate({ email, organization, oauthProvider }); + socialJoin.mutate({ emoji, email, userName, oauthProvider }); }; if (!email || !oauthProvider) { diff --git a/frontend/src/pages/ManagerSocialJoin/units/SocialJoinForm.styles.ts b/frontend/src/pages/ManagerSocialJoin/units/SocialJoinForm.styles.ts index 32813ffb5..413d3fac3 100644 --- a/frontend/src/pages/ManagerSocialJoin/units/SocialJoinForm.styles.ts +++ b/frontend/src/pages/ManagerSocialJoin/units/SocialJoinForm.styles.ts @@ -2,8 +2,8 @@ import styled from 'styled-components'; export const Form = styled.form` margin: 3.75rem 0 1rem; +`; - label { - margin-bottom: 3rem; - } +export const InputWrapper = styled.div` + margin-bottom: 3rem; `; diff --git a/frontend/src/pages/ManagerSocialJoin/units/SocialJoinForm.tsx b/frontend/src/pages/ManagerSocialJoin/units/SocialJoinForm.tsx index 96c97c1f0..04899836e 100644 --- a/frontend/src/pages/ManagerSocialJoin/units/SocialJoinForm.tsx +++ b/frontend/src/pages/ManagerSocialJoin/units/SocialJoinForm.tsx @@ -1,67 +1,93 @@ -import { FormEventHandler, useEffect, useState } from 'react'; +import { AxiosError } from 'axios'; +import { FormEventHandler, useState } from 'react'; +import { useQuery } from 'react-query'; +import { queryValidateUserName } from 'api/join'; import Input from 'components/Input/Input'; import SocialJoinButton from 'components/SocialAuthButton/SocialJoinButton'; import MANAGER from 'constants/manager'; import MESSAGE from 'constants/message'; -import REGEXP from 'constants/regexp'; import useInput from 'hooks/useInput'; +import EmojiSelector from 'pages/ManagerJoin/units/EmojiSelector'; +import { ErrorResponse } from 'types/response'; import { SocialJoinParams } from '../ManagerSocialJoin'; import * as Styled from './SocialJoinForm.styles'; interface Props { email: string; oauthProvider: 'GITHUB' | 'GOOGLE'; - onSubmit: ({ email, organization }: SocialJoinParams) => void; + onSubmit: ({ emoji, email, userName }: SocialJoinParams) => void; } const SocialJoinForm = ({ email, oauthProvider, onSubmit }: Props): JSX.Element => { - const [organization, onChangeForm] = useInput(''); + const [emoji, setEmoji] = useState(''); + const [userName, onChangeUserName] = useInput(''); - const [organizationMessage, setOrganizationMessage] = useState(''); + const [userNameMessage, setUserNameMessage] = useState(''); - const isValidOrganization = REGEXP.ORGANIZATION.test(organization); + const checkValidateUserName = useQuery( + ['checkValidateUserName', userName], + queryValidateUserName, + { + enabled: false, + retry: false, - const handleSubmit: FormEventHandler = (event) => { - event.preventDefault(); + onSuccess: () => { + setUserNameMessage(MESSAGE.JOIN.VALID_USERNAME); + }, - onSubmit({ email, organization }); + onError: (error: AxiosError) => { + setUserNameMessage( + error.response?.data.message ?? MESSAGE.JOIN.CHECK_USERNAME_UNEXPECTED_ERROR + ); + }, + } + ); + + const handleChangeUserName = (event: React.ChangeEvent) => { + onChangeUserName(event); + setUserNameMessage(''); }; - useEffect(() => { - if (!organization) { - setOrganizationMessage(''); + const handleValidateUserName = () => { + if (!userName) return; - return; - } + checkValidateUserName.refetch(); + }; - setOrganizationMessage( - isValidOrganization ? MESSAGE.JOIN.VALID_ORGANIZATION : MESSAGE.JOIN.INVALID_ORGANIZATION - ); - }, [organization, isValidOrganization]); + const handleSelectEmoji = (emoji: string) => { + setEmoji(emoji); + }; + + const handleSubmit: FormEventHandler = (event) => { + event.preventDefault(); + + if (!emoji || !email || !userName) return; + + onSubmit({ emoji, email, userName }); + }; return ( - - + + + + + + + ); diff --git a/frontend/src/types/common.ts b/frontend/src/types/common.ts index 2cf7e6055..41b15f4d6 100644 --- a/frontend/src/types/common.ts +++ b/frontend/src/types/common.ts @@ -176,3 +176,8 @@ export interface EditorBoard { y: number; scale: number; } + +export interface Emoji { + name: string; + code: string; +} diff --git a/frontend/src/types/response.ts b/frontend/src/types/response.ts index 8654aeea8..9343aec2d 100644 --- a/frontend/src/types/response.ts +++ b/frontend/src/types/response.ts @@ -1,4 +1,12 @@ -import { MapItem, Reservation, Space, SpaceReservation, ManagerSpaceAPI, Preset } from './common'; +import { + MapItem, + Reservation, + Space, + SpaceReservation, + ManagerSpaceAPI, + Preset, + Emoji, +} from './common'; export interface MapItemResponse extends Omit { mapDrawing: string; @@ -27,6 +35,10 @@ export interface QuerySocialEmailSuccess { oauthProvider: 'GITHUB' | 'GOOGLE'; } +export interface QueryEmojiListSuccess { + emojis: Emoji[]; +} + export type QueryGuestMapSuccess = MapItemResponse; export type QueryManagerMapSuccess = MapItemResponse;