diff --git a/frontend/__test__/api/user.test.ts b/frontend/__test__/api/user.test.ts index c847d6901..920cc537a 100644 --- a/frontend/__test__/api/user.test.ts +++ b/frontend/__test__/api/user.test.ts @@ -23,12 +23,12 @@ describe('서버와 통신하여 유저의 정보를 불러올 수 있어야 한 expect(data).toEqual(transformUserInfoResponse(MOCK_USER_INFO)); }); - test('클라이언트에서 사용하는 유저 정보 API 명세가 [nickname, postCount, voteCount, gender, birthYear]으로 존재해야한다', async () => { + test('클라이언트에서 사용하는 유저 정보 API 명세가 [nickname, gender, birthYear, postCount, voteCount]으로 존재해야한다', async () => { const data = await getUserInfo(isLoggedIn); const userInfoKeys = Object.keys(data ?? {}); - expect(userInfoKeys).toEqual(['nickname', 'postCount', 'gender', 'voteCount', 'birthYear']); + expect(userInfoKeys).toEqual(['nickname', 'gender', 'birthYear', 'postCount', 'voteCount']); }); test('유저의 닉네임을 수정한다', async () => { diff --git a/frontend/src/api/userInfo.ts b/frontend/src/api/userInfo.ts index b1c71291e..f3f2e1837 100644 --- a/frontend/src/api/userInfo.ts +++ b/frontend/src/api/userInfo.ts @@ -1,16 +1,21 @@ -import type { UserInfoResponse, User, ModifyNicknameRequest } from '@type/user'; +import type { + UserInfoResponse, + User, + ModifyNicknameRequest, + UpdateUserInfoRequest, +} from '@type/user'; import { deleteFetch, getFetch, patchFetch } from '@utils/fetch'; export const transformUserInfoResponse = (userInfo: UserInfoResponse): User => { - const { nickname, postCount, gender, voteCount, birthYear } = userInfo; + const { nickname, gender, birthYear, postCount, voteCount } = userInfo; return { nickname, - postCount, gender, - voteCount, birthYear, + postCount, + voteCount, }; }; @@ -31,3 +36,7 @@ export const modifyNickname = async (nickname: string) => { export const withdrawalMembership = async () => { await deleteFetch(`${BASE_URL}/members/me/delete`); }; + +export const updateUserInfo = async (userInfo: UpdateUserInfoRequest) => { + await patchFetch(`${BASE_URL}/members/me/detail`, userInfo); +}; diff --git a/frontend/src/components/PostForm/index.tsx b/frontend/src/components/PostForm/index.tsx index b40dd9e5a..314e14959 100644 --- a/frontend/src/components/PostForm/index.tsx +++ b/frontend/src/components/PostForm/index.tsx @@ -23,7 +23,7 @@ import Toast from '@components/common/Toast'; import WritingVoteOptionList from '@components/optionList/WritingVoteOptionList'; import { PATH } from '@constants/path'; -import { POST_DESCRIPTION_MAX_LENGTH, POST_TITLE_MAX_LENGTH } from '@constants/post'; +import { CATEGORY_COUNT_LIMIT, POST_CONTENT, POST_TITLE } from '@constants/post'; import { changeCategoryToOption } from '@utils/post/changeCategoryToOption'; import { checkWriter } from '@utils/post/checkWriter'; @@ -40,10 +40,6 @@ interface PostFormProps extends HTMLAttributes { mutate: UseMutateFunction; } -const MAX_TITLE_LENGTH = 100; -const MAX_CONTENT_LENGTH = 1000; -const CATEGORY_COUNT_LIMIT = 3; - export default function PostForm({ data, mutate }: PostFormProps) { const { postId, @@ -206,19 +202,21 @@ export default function PostForm({ data, mutate }: PostFormProps) { ) => - handleTitleChange(e, POST_TITLE_MAX_LENGTH) + handleTitleChange(e, POST_TITLE) } placeholder="제목을 입력해주세요" - maxLength={MAX_TITLE_LENGTH} + maxLength={POST_TITLE.MAX_LENGTH} + minLength={POST_TITLE.MIN_LENGTH} required /> ) => - handleContentChange(e, POST_DESCRIPTION_MAX_LENGTH) + handleContentChange(e, POST_CONTENT) } placeholder="내용을 입력해주세요" - maxLength={MAX_CONTENT_LENGTH} + maxLength={POST_CONTENT.MAX_LENGTH} + minLength={POST_CONTENT.MIN_LENGTH} required /> diff --git a/frontend/src/components/comment/CommentList/CommentTextForm/index.tsx b/frontend/src/components/comment/CommentList/CommentTextForm/index.tsx index 1aa84076e..3c74b7373 100644 --- a/frontend/src/components/comment/CommentList/CommentTextForm/index.tsx +++ b/frontend/src/components/comment/CommentList/CommentTextForm/index.tsx @@ -11,7 +11,7 @@ import { useToast } from '@hooks/useToast'; import SquareButton from '@components/common/SquareButton'; import Toast from '@components/common/Toast'; -import { COMMENT_MAX_LENGTH } from '@constants/comment'; +import { COMMENT } from '@constants/comment'; import * as S from './style'; interface CommentTextFormProps { @@ -73,7 +73,7 @@ export default function CommentTextForm({ ) => handleTextChange(e, COMMENT_MAX_LENGTH)} + onChange={(e: ChangeEvent) => handleTextChange(e, COMMENT)} /> {handleCancelClick && ( diff --git a/frontend/src/components/common/Dashboard/UserProfile/UserProfile.stories.tsx b/frontend/src/components/common/Dashboard/UserProfile/UserProfile.stories.tsx index 3f7b61dc9..677cf0c73 100644 --- a/frontend/src/components/common/Dashboard/UserProfile/UserProfile.stories.tsx +++ b/frontend/src/components/common/Dashboard/UserProfile/UserProfile.stories.tsx @@ -13,10 +13,10 @@ type Story = StoryObj; const MOCK_USER_INFO: User = { nickname: '우아한 코끼리', + gender: 'MALE', + birthYear: 1989, postCount: 4, voteCount: 128, - gender: 'FEMALE', - birthYear: 1997, }; export const NoBadge: Story = { diff --git a/frontend/src/constants/comment.ts b/frontend/src/constants/comment.ts index 14cc2bcb0..71172876f 100644 --- a/frontend/src/constants/comment.ts +++ b/frontend/src/constants/comment.ts @@ -1 +1,4 @@ -export const COMMENT_MAX_LENGTH = 200; +export const COMMENT = { + MAX_LENGTH: 200, + MIN_LENGTH: 1, +} as const; diff --git a/frontend/src/constants/path.ts b/frontend/src/constants/path.ts index 65a3ba611..e557f8788 100644 --- a/frontend/src/constants/path.ts +++ b/frontend/src/constants/path.ts @@ -16,4 +16,5 @@ export const PATH = { USER_POST: `${BASE_PATH.USER}/posts`, USER_VOTE: `${BASE_PATH.USER}/votes`, USER_INFO: `${BASE_PATH.USER}/myPage`, + USER_INFO_REGISTER: `${BASE_PATH.USER}/register`, }; diff --git a/frontend/src/constants/post.ts b/frontend/src/constants/post.ts index b8c47c85a..afbd9aa3b 100644 --- a/frontend/src/constants/post.ts +++ b/frontend/src/constants/post.ts @@ -44,7 +44,15 @@ export const SEARCH_KEYWORD = 'keyword'; export const MAX_FILE_SIZE = 5000000; -export const POST_TITLE_MAX_LENGTH = 100; +export const POST_TITLE = { + MAX_LENGTH: 100, + MIN_LENGTH: 2, +} as const; + +export const POST_CONTENT = { + MAX_LENGTH: 1000, + MIN_LENGTH: 2, +} as const; export const POST_DESCRIPTION_MAX_LENGTH = 1000; @@ -55,3 +63,5 @@ export const POST_LIST_MAX_LENGTH = 10; export const DEFAULT_CATEGORY_ID = 0; export const DEFAULT_KEYWORD = ''; + +export const CATEGORY_COUNT_LIMIT = 3; diff --git a/frontend/src/constants/user.ts b/frontend/src/constants/user.ts new file mode 100644 index 000000000..d509d9dfa --- /dev/null +++ b/frontend/src/constants/user.ts @@ -0,0 +1,9 @@ +export const NICKNAME = { + MAX_LENGTH: 15, + MIN_LENGTH: 2, +} as const; + +export const BIRTH_YEAR = { + MAX_LENGTH: new Date().getFullYear(), + MIN_LENGTH: 1900, +} as const; diff --git a/frontend/src/hooks/query/user/useUserInfo.ts b/frontend/src/hooks/query/user/useUserInfo.ts index 45b8e6483..97bf63cfc 100644 --- a/frontend/src/hooks/query/user/useUserInfo.ts +++ b/frontend/src/hooks/query/user/useUserInfo.ts @@ -1,4 +1,4 @@ -import { useQuery } from '@tanstack/react-query'; +import { useQuery, useQueryClient } from '@tanstack/react-query'; import { User } from '@type/user'; @@ -7,12 +7,20 @@ import { getUserInfo } from '@api/userInfo'; import { QUERY_KEY } from '@constants/queryKey'; export const useUserInfo = (isLoggedIn: boolean) => { + const queryClient = useQueryClient(); + const { data, error, isLoading, isError } = useQuery( [QUERY_KEY.USER_INFO, isLoggedIn], () => getUserInfo(isLoggedIn), { cacheTime: 60 * 60 * 1000, staleTime: 60 * 60 * 1000, + onSuccess: () => { + queryClient.invalidateQueries([QUERY_KEY.USER_INFO]); + }, + onError: error => { + window.console.log('User Info Fetch Error', error); + }, } ); diff --git a/frontend/src/hooks/useText.ts b/frontend/src/hooks/useText.ts index 5b5e3bf71..7faca87ca 100644 --- a/frontend/src/hooks/useText.ts +++ b/frontend/src/hooks/useText.ts @@ -1,16 +1,19 @@ import React, { useState } from 'react'; +export type InputLength = Record<'MAX_LENGTH' | 'MIN_LENGTH', number>; + export const useText = (originalText: string) => { const [text, setText] = useState(originalText); + const handleTextChange = ( event: React.ChangeEvent, - limit: number + limit: InputLength ) => { const { value } = event.target; const standard = value.length; - if (standard === limit) { - event.target.setCustomValidity(`선택지 내용은 ${limit}자까지 입력 가능합니다.`); + if (standard > limit.MAX_LENGTH) { + event.target.setCustomValidity(`해당 입력값은 ${limit.MAX_LENGTH}자까지 입력 가능합니다.`); event.target.reportValidity(); return; } diff --git a/frontend/src/mocks/mockData/user.ts b/frontend/src/mocks/mockData/user.ts index 1f5d76183..a7e0130df 100644 --- a/frontend/src/mocks/mockData/user.ts +++ b/frontend/src/mocks/mockData/user.ts @@ -2,8 +2,8 @@ import { UserInfoResponse } from '@type/user'; export const MOCK_USER_INFO: UserInfoResponse = { nickname: '우아한 코끼리', + gender: 'MALE', + birthYear: 1989, postCount: 4, voteCount: 128, - gender: 'FEMALE', - birthYear: 1997, }; diff --git a/frontend/src/mocks/userInfo.ts b/frontend/src/mocks/userInfo.ts index 414edf63a..529be8641 100644 --- a/frontend/src/mocks/userInfo.ts +++ b/frontend/src/mocks/userInfo.ts @@ -7,6 +7,10 @@ export const mockUserInfo = [ return res(ctx.status(200), ctx.json(MOCK_USER_INFO)); }), + rest.patch('/members/me/detail', (req, res, ctx) => { + return res(ctx.status(200), ctx.json({ ok: '개인 정보가 성공적으로 저장되었습니다!' })); + }), + rest.patch('/members/me/nickname', (req, res, ctx) => { MOCK_USER_INFO.nickname = 'wood'; diff --git a/frontend/src/pages/MyInfo/index.tsx b/frontend/src/pages/MyInfo/index.tsx index 56b3efc6e..dbbcd1a1f 100644 --- a/frontend/src/pages/MyInfo/index.tsx +++ b/frontend/src/pages/MyInfo/index.tsx @@ -1,9 +1,12 @@ -import { useContext } from 'react'; -import { useNavigate } from 'react-router-dom'; +import { useContext, ChangeEvent } from 'react'; +import { Navigate, useNavigate } from 'react-router-dom'; import { AuthContext } from '@hooks/context/auth'; +import { useText } from '@hooks/useText'; import { useToggle } from '@hooks/useToggle'; +import { modifyNickname, withdrawalMembership } from '@api/userInfo'; + import Accordion from '@components/common/Accordion'; import GuestProfile from '@components/common/Dashboard/GuestProfile'; import UserProfile from '@components/common/Dashboard/UserProfile'; @@ -13,14 +16,39 @@ import Modal from '@components/common/Modal'; import NarrowTemplateHeader from '@components/common/NarrowTemplateHeader'; import SquareButton from '@components/common/SquareButton'; +import { PATH } from '@constants/path'; +import { NICKNAME } from '@constants/user'; + +import { clearCookieToken } from '@utils/cookie'; + import * as S from './style'; export default function MyInfo() { const navigate = useNavigate(); + const { isOpen, openComponent, closeComponent } = useToggle(); + const { loggedInfo, clearLoggedInfo } = useContext(AuthContext); + const { text: newNickname, handleTextChange: handleNicknameChange } = useText( + loggedInfo.userInfo?.nickname ?? '' + ); - const { loggedInfo } = useContext(AuthContext); - const { userInfo } = loggedInfo; + if (!loggedInfo.userInfo) { + return ; + } + + const logout = () => { + clearCookieToken('accessToken'); + clearLoggedInfo(); + }; + + const handleModifyNickname = () => { + modifyNickname(newNickname); + }; + + const handleWithdrawlMembership = () => { + withdrawalMembership(); + logout(); + }; return ( @@ -36,13 +64,17 @@ export default function MyInfo() { - {userInfo ? : } + {loggedInfo.userInfo ? : } - + ) => handleNicknameChange(e, NICKNAME)} + placeholder="새로운 닉네임을 입력해주세요" + /> - + 변경 @@ -61,7 +93,11 @@ export default function MyInfo() { 탈퇴 버튼 클릭 시,

계정은 삭제되며 복구되지 않아요. - + 탈퇴 diff --git a/frontend/src/pages/user/RegisterPersonalInfo/RegisterPersonalInfo.stories.tsx b/frontend/src/pages/user/RegisterPersonalInfo/RegisterPersonalInfo.stories.tsx new file mode 100644 index 000000000..5b1dd83d4 --- /dev/null +++ b/frontend/src/pages/user/RegisterPersonalInfo/RegisterPersonalInfo.stories.tsx @@ -0,0 +1,14 @@ +import type { Meta, StoryObj } from '@storybook/react'; + +import RegisterPersonalInfo from '.'; + +const meta: Meta = { + component: RegisterPersonalInfo, +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + render: () => , +}; diff --git a/frontend/src/pages/user/RegisterPersonalInfo/index.tsx b/frontend/src/pages/user/RegisterPersonalInfo/index.tsx new file mode 100644 index 000000000..ea9f26c1c --- /dev/null +++ b/frontend/src/pages/user/RegisterPersonalInfo/index.tsx @@ -0,0 +1,150 @@ +import { ChangeEvent, FormEvent, useState } from 'react'; +import { useNavigate } from 'react-router-dom'; + +import { updateUserInfo } from '@api/userInfo'; + +import Accordion from '@components/common/Accordion'; +import IconButton from '@components/common/IconButton'; +import Layout from '@components/common/Layout'; +import NarrowTemplateHeader from '@components/common/NarrowTemplateHeader'; +import SquareButton from '@components/common/SquareButton'; + +import { BIRTH_YEAR } from '@constants/user'; + +import * as S from './style'; + +interface UserInfoForm { + gender: ''; + birthYear: string; + isTermsAgreed: boolean; +} + +export default function RegisterPersonalInfo() { + const navigate = useNavigate(); + + const [userInfoForm, setUserInfoForm] = useState({ + gender: '', + birthYear: '', + isTermsAgreed: false, + }); + + const handleFormInputChange = (e: ChangeEvent) => { + const { name, value, type, checked } = e.target; + + const newValue = type === 'checkbox' ? checked : value; + + setUserInfoForm(prev => ({ + ...prev, + [name]: newValue, + })); + }; + + const handleUserInfoFormSubmit = (e: FormEvent) => { + e.preventDefault(); + const { gender, birthYear, isTermsAgreed } = userInfoForm; + + if (!gender || !birthYear) { + alert('필수 개인 정보를 모두 입력해주세요.'); + return; + } + + if (isNaN(Number(birthYear))) { + alert('생년월일 값을 확인해주세요.'); + return; + } + + if (!isTermsAgreed) { + alert('개인 정보 약관에 동의해주세요.'); + return; + } + + const submittedUserInfo = { gender, birthYear: Number(birthYear) }; + updateUserInfo(submittedUserInfo); + + alert('개인 정보 등록 완료!'); + navigate('/'); + }; + + return ( + + + + + + + + 개인 정보 등록 + + + + +
  • • 개인정보 항목: 성별, 나이
  • +
  • • 수집 방법: 회원가입 후 개인정보 등록 페이지에서 성별, 나이 저장
  • +
  • + • 수집 목적: 투표한 이용자의 성별 및 나이에 대한 투표 통계 제공 (단, 투표 통계는 + 글 작성자에 한하여 제공됨) +
  • +
  • • 보유 근거: 정보주체 동의
  • +
  • • 보유 기간: 회원 탈퇴 시 즉시 삭제
  • +

    + * 개인 정보 수집에 대한 동의를 거부할 수 있습니다. (단, 동의가 없을 경우 일부 + 서비스 이용에 제한이 있습니다.) +

    +
    +
    + +

    성별

    + + + + 남성 + + + + 여성 + + +
    + +

    출생 연도

    + +
    + + + 개인 정보 약관에 동의합니다. + + + + 저장 + + +
    +
    +
    +
    + ); +} diff --git a/frontend/src/pages/user/RegisterPersonalInfo/style.ts b/frontend/src/pages/user/RegisterPersonalInfo/style.ts new file mode 100644 index 000000000..1ab53d4ba --- /dev/null +++ b/frontend/src/pages/user/RegisterPersonalInfo/style.ts @@ -0,0 +1,123 @@ +import { styled } from 'styled-components'; + +import { theme } from '@styles/theme'; + +export const Wrapper = styled.main` + display: flex; + flex-direction: column; + justify-content: space-between; + align-items: center; + gap: 30px; + + padding-top: 55px; + + position: relative; + + @media (min-width: 768px) { + padding-top: 20px; + } + + @media (min-width: ${theme.breakpoint.md}) { + padding-top: 20px; + } +`; + +export const HeaderWrapper = styled.div` + width: 100%; + + position: fixed; + + z-index: ${theme.zIndex.header}; + + @media (min-width: ${theme.breakpoint.md}) { + display: none; + } +`; + +export const MainWrapper = styled.section` + display: flex; + flex-direction: column; + justify-content: space-between; + align-items: center; + gap: 20px; + + width: 90%; +`; + +export const Title = styled.h1` + width: 90%; + margin-top: 20px; + + font-size: 30px; + font-weight: bold; +`; + +export const InfoForm = styled.form` + display: flex; + flex-direction: column; + justify-content: space-between; + gap: 25px; + + width: 90%; +`; + +export const TermsList = styled.ul` + display: flex; + flex-direction: column; + justify-content: center; + gap: 10px; +`; + +export const Label = styled.label` + display: flex; + justify-content: start; + align-items: center; + gap: 10px; + + font: var(--text-body); + + p { + font-weight: bold; + } + + input[type='number']::-webkit-outer-spin-button, + input[type='number']::-webkit-inner-spin-button { + -webkit-appearance: none; + margin: 0; + } +`; + +export const GenderLabel = styled.label` + display: flex; + justify-content: start; + align-items: center; + gap: 30px; + + margin-left: 20px; +`; + +export const Input = styled.input` + width: 70%; + height: 20px; + border: 1px solid #f2f2f2; + padding: 20px; +`; + +export const Radio = styled.input` + font-size: 14px; + font-weight: light; +`; + +export const Checkbox = styled.input` + width: 20px; + height: 20px; +`; + +export const ButtonWrapper = styled.div` + display: flex; + justify-content: end; + + width: 90px; + height: 50px; + margin-left: 70%; +`; diff --git a/frontend/src/routes/router.tsx b/frontend/src/routes/router.tsx index 5cf86b232..372ccbfc8 100644 --- a/frontend/src/routes/router.tsx +++ b/frontend/src/routes/router.tsx @@ -8,6 +8,7 @@ import NotFound from '@pages/NotFound'; import CreatePost from '@pages/post/CreatePost'; import EditPost from '@pages/post/EditPost'; import PostDetailPage from '@pages/post/PostDetail'; +import RegisterPersonalInfo from '@pages/user/RegisterPersonalInfo'; import VoteStatisticsPage from '@pages/VoteStatistics'; import { PATH } from '@constants/path'; @@ -75,6 +76,7 @@ const router = createBrowserRouter([ }, { path: 'posts', element: }, { path: 'votes', element: }, + { path: 'register', element: }, ], }, { diff --git a/frontend/src/types/user.ts b/frontend/src/types/user.ts index 104d4bb9a..f96897ccf 100644 --- a/frontend/src/types/user.ts +++ b/frontend/src/types/user.ts @@ -24,3 +24,8 @@ export interface LoggedInfo { id?: number; userInfo?: User; } + +export interface UpdateUserInfoRequest { + gender: 'MALE' | 'FEMALE'; + birthYear: number; +}