diff --git a/src/layouts/Navbar.tsx b/src/layouts/Navbar.tsx index ca257e49..8199223c 100644 --- a/src/layouts/Navbar.tsx +++ b/src/layouts/Navbar.tsx @@ -22,6 +22,7 @@ const Navbar = () => { '/feed/:id', '/sign-up', '/write-edit', + '/oauth/*', ); return ( diff --git a/src/pages/Login/KakaoRedirect.tsx b/src/pages/Login/KakaoRedirect.tsx index 36918f03..b3015184 100644 --- a/src/pages/Login/KakaoRedirect.tsx +++ b/src/pages/Login/KakaoRedirect.tsx @@ -2,6 +2,7 @@ import { useCallback, useEffect } from 'react'; import { useNavigate } from 'react-router-dom'; import { getIsMember, getLogin } from '../../api'; +import Spinner from '../../components/Spinner/Spinner'; const KakaoRedirect = () => { const navigate = useNavigate(); @@ -32,7 +33,7 @@ const KakaoRedirect = () => { getCode(code); }, [code, getCode]); - return
Loading...
; + return ; }; export default KakaoRedirect; diff --git a/src/pages/Profile/Setting.page.tsx b/src/pages/Profile/Setting.page.tsx deleted file mode 100644 index 0c7c89c2..00000000 --- a/src/pages/Profile/Setting.page.tsx +++ /dev/null @@ -1,364 +0,0 @@ -import styled from '@emotion/styled'; -import { - useCheckbox, - useField, - Button, - CheckboxContainer, - Dropdown, - Field, - Header, - Spacer, - Tag, - Text, - theme, - SVGLoginImageWrite, - useDropdown, -} from 'concept-be-design-system'; -import { useCallback, useEffect, useState } from 'react'; - -import Back from '../../layouts/Back'; -import { - filterSubOptions, - MAIN_SKILL_QUERY, - DETAIL_SKILL_QUERY, - SKILL_DEPTH_THREE_LIST, - REGION_LIST, -} from '../../modules/constants'; -import { Skill } from '../SignUp/types'; - -interface FormValueType { - nickName: string; - company: string; - intro: string; -} - -interface DropdownValue { - mainSkill: string; - skillDepthOne: string; - skillDepthTwo: string; - skillDepthThree: string; - region: string; -} - -const Setting = () => { - const { fieldValue, fieldErrorValue, onChangeField } = useField({ - nickName: '', - company: '', - intro: '', - }); - const { checkboxValue, onChangeCheckbox } = useCheckbox({ - goal: filterSubOptions, - }); - const { dropdownValue, onResetDropdown, onClickDropdown } = useDropdown({ - mainSkill: '', - skillDepthOne: '', - skillDepthTwo: '', - skillDepthThree: '', - region: '', - }); - const [selectedSkillDepths, setSelectedSkillDepths] = useState([]); - const skillDepthOneId = MAIN_SKILL_QUERY.find(({ name }) => name === dropdownValue.skillDepthOne)?.id; - - const onClickDropdownSetting = useCallback(() => { - if (!skillDepthOneId) return; - - const selectedValue = `${dropdownValue.skillDepthTwo}, ${dropdownValue.skillDepthThree}`; - const selectedId = DETAIL_SKILL_QUERY[skillDepthOneId].find(({ name }) => name === dropdownValue.skillDepthTwo)?.id; - - if (selectedId && selectedSkillDepths.length < 3) { - setSelectedSkillDepths((prev) => [...prev, { id: selectedId, name: selectedValue }]); - onResetDropdown('skillDepthOne'); - onResetDropdown('skillDepthTwo'); - onResetDropdown('skillDepthThree'); - } - }, [skillDepthOneId, selectedSkillDepths, dropdownValue, onResetDropdown]); - - const onDeleteSkill = (value: string) => { - setSelectedSkillDepths(selectedSkillDepths.filter(({ name }) => name !== value)); - }; - - const validateInput = () => { - return [ - { - validateFn: (value: string) => /[~!@#$%";'^,&*()_+|=>`?:{[\]}]/g.test(value), - errorMessage: '사용 불가한 닉네임입니다.', - }, - { - validateFn: (value: string) => /[1234567890]/g.test(value), - errorMessage: '사용 불가한 직장명입니다.', - }, - ]; - }; - - useEffect(() => { - if (dropdownValue.skillDepthThree === '' || !dropdownValue.skillDepthTwo) return; - onClickDropdownSetting(); - }, [dropdownValue, onClickDropdownSetting]); - - return ( - -
- - - - - - 프로필 설정 - - -
- - -
- - {/* */} - - - - - - - - - - - - - - - - - 스킬 (최대 3개) - - - - {MAIN_SKILL_QUERY.map(({ id, name }) => ( - { - onClickDropdown(value, 'skillDepthOne'); - }} - > - {name} - - ))} - - - {skillDepthOneId && - DETAIL_SKILL_QUERY[skillDepthOneId].map(({ id, name }) => ( - { - onClickDropdown(value, 'skillDepthTwo'); - }} - > - {name} - - ))} - - - {SKILL_DEPTH_THREE_LIST.map(({ id, name }) => ( - { - onClickDropdown(value, 'skillDepthThree'); - }} - > - {name} - - ))} - - - - {selectedSkillDepths.map((skill, idx) => { - return ( - - {skill.name} - - ); - })} - - - - - - - - - - - - 지역 - - - {REGION_LIST.map(({ id, name }) => ( - { - onClickDropdown(value, 'region'); - }} - > - {name} - - ))} - - - - - - - - - - - - - - - - - - - - - ); -}; - -const Container = styled.div` - padding-bottom: 45px; -`; - -const MainWrapper = styled.div` - background-color: ${theme.color.c1}; - height: 100%; -`; - -const MainBox = styled.section` - background-color: ${theme.color.w1}; - border-radius: 16px 16px 0 0; - padding: 0 22px; - margin-top: 107px; - position: relative; - padding-bottom: 40px; -`; - -const SettingWrapper = styled.div` - display: flex; - flex-direction: column; - gap: 13px; -`; - -const SettingBox = styled.div` - display: flex; - flex-wrap: wrap; - gap: 8px; -`; - -const TagBox = styled.div` - display: flex; - flex-wrap: wrap; - gap: 6px; -`; - -const ImageWrapper = styled.div` - position: relative; - top: -50px; - left: 0; - right: 0; - margin: auto; - width: 100px; - height: 100px; - cursor: pointer; -`; - -const ImageBox = styled.div` - border-radius: 0 150px 150px 0; - width: 100px; - height: 100px; - overflow: hidden; -`; - -const Img = styled.img` - width: 100%; - height: 100%; -`; - -const ImageWrite = styled.div` - position: absolute; - bottom: 0; - right: 0; - background: ${theme.color.w1}; - /* padding: 7px; */ - width: 32px; - height: 32px; - display: flex; - justify-content: center; - align-items: center; - border: 1px solid #e6e6e6; - border-radius: 50%; - box-shadow: rgba(100, 100, 111, 0.2) 0px 7px 29px; -`; - -const BottomWrapper = styled.div` - padding: 0 22px; - background: ${theme.color.w1}; -`; - -export default Setting; diff --git a/src/pages/Profile/components/ProfileInfoSection.tsx b/src/pages/Profile/components/ProfileInfoSection.tsx index 70ea40e1..4e083d67 100644 --- a/src/pages/Profile/components/ProfileInfoSection.tsx +++ b/src/pages/Profile/components/ProfileInfoSection.tsx @@ -6,6 +6,7 @@ import { useNavigate } from 'react-router-dom'; import Padding from '../../../components/Padding'; import { ProfileImageDefault } from '../asset'; import { useMemberInfoQuery } from '../hooks/queries/useMemberInfoQuery'; +import { memberId } from '../utils/memberId'; const ProfileInfoSection = () => { const navigate = useNavigate(); @@ -65,7 +66,7 @@ const ProfileInfoSection = () => { {renderWorkingAndLivingPlace()}
- navigate('/profile/1')}>프로필 수정 + navigate(`/profile/${memberId}`)}>프로필 수정 {introduction} @@ -75,9 +76,9 @@ const ProfileInfoSection = () => { - {skills.map((badge) => ( - - {badge} + {skills.map(({ skillId, skillName }) => ( + + {skillName} ))} diff --git a/src/pages/Profile/types/index.ts b/src/pages/Profile/types/index.ts index bbf3059d..f4a2bfb0 100644 --- a/src/pages/Profile/types/index.ts +++ b/src/pages/Profile/types/index.ts @@ -10,6 +10,12 @@ export type Idea = { skillCategories: string[]; // 목적 }; +type MemberSkills = { + skillId: number; + skillName: string; + level: string; +}; + export type Member = { profileImageUrl: string; // 프로필 이미지, nickname: string; // 닉네임 @@ -18,7 +24,7 @@ export type Member = { livingPlace: string; // 지역 workingPlace: string; // 직장명 introduction: string; // 자기소개 - skills: string[]; // 세부 스킬 + skills: MemberSkills[]; // 세부 스킬 joinPurposes: string[]; // 관심 영역 }; diff --git a/src/pages/ProfileEdit/ProfileEdit.page.tsx b/src/pages/ProfileEdit/ProfileEdit.page.tsx new file mode 100644 index 00000000..515ce690 --- /dev/null +++ b/src/pages/ProfileEdit/ProfileEdit.page.tsx @@ -0,0 +1,332 @@ +import styled from '@emotion/styled'; +import { + useCheckbox, + useField, + Button, + CheckboxContainer, + Dropdown, + Field, + Text, + theme, + Header, + Spacer, + Tag, + SVGLoginImageWrite, + useDropdown, + Flex, + Box, +} from 'concept-be-design-system'; +import { FormEvent } from 'react'; + +import useProfileQuery from './hooks/useProfileQuery.ts'; +import usePutProfileMutation from './hooks/usePutProfileMutation.ts'; +import { DropdownValue, FieldValue } from './types'; +import Back from '../../layouts/Back.tsx'; +import { memberId } from '../Profile/utils/memberId.ts'; +import useCheckDuplicateNickname from '../SignUp/hooks/useCheckDuplicateNickname.ts'; +import useSetDetailSkills from '../SignUp/hooks/useSetDetailSkills.ts'; + +interface CheckboxValue { + goal: CheckboxOption[]; +} + +interface CheckboxOption { + id: number; + name: string; + checked: boolean; +} + +const ProfileEdit = () => { + const { mainSkills, detailSkills, skillLevels, regions, purposes, my } = useProfileQuery(); + const { fieldValue, fieldErrorValue, setFieldErrorValue, onChangeField } = useField({ + nickname: my.nickname ?? '', + company: my.workingPlace ?? '', + intro: my.introduction ?? '', + }); + const { checkboxValue, onChangeCheckbox } = useCheckbox({ + goal: my.joinPurposes ?? purposes, + }); + const { dropdownValue, onResetDropdown, onClickDropdown } = useDropdown({ + mainSkill: my.mainSkill ?? '', + skillDepthOne: '', + skillDepthTwo: '', + skillDepthThree: '', + region: my.livingPlace ?? '', + }); + const { skillDepthOneId, selectedSkillDepths, onDeleteSkill } = useSetDetailSkills({ + initialValue: my.skills, + mainSkills, + detailSkills, + dropdownValue, + onResetDropdown, + }); + const { putProfile } = usePutProfileMutation(memberId, fieldValue.nickname); + + useCheckDuplicateNickname({ nickname: fieldValue.nickname, setFieldErrorValue }); + + const validateInput = () => { + return [ + { + validateFn: (input: string) => /[~!@#$%";'^,&*()_+|=>`?:{[\]}\s]/g.test(input), + errorMessage: '사용 불가한 닉네임입니다.', + }, + { + validateFn: (input: string) => input.length < 2, + errorMessage: '2글자 이상의 닉네임으로 입력해 주세요.', + }, + ]; + }; + + const onSubmit = (e: FormEvent) => { + e.preventDefault(); + + putProfile({ + nickname: fieldValue.nickname, + mainSkillId: mainSkills.find(({ name }) => dropdownValue.mainSkill === name)?.id || 0, + profileImageUrl: my.profileImageUrl || '', + skills: selectedSkillDepths.map(({ id, name }) => ({ skillId: id, level: name.split(', ')[1] })), + joinPurposes: checkboxValue.goal.filter(({ checked }) => checked).map(({ id }) => id), + livingPlace: dropdownValue.region, + workingPlace: fieldValue.company, + introduction: fieldValue.intro, + }); + }; + + return ( + +
+ + + + + + 프로필 수정 + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + 대표 스킬 + + + + {mainSkills.map(({ id, name }) => ( + { + onClickDropdown(value, 'mainSkill'); + }} + > + {name} + + ))} + + + + + + + + + 세부 스킬 (최대 3개) + + + + {mainSkills.map(({ id, name }) => ( + { + onClickDropdown(value, 'skillDepthOne'); + onResetDropdown('skillDepthTwo'); + onResetDropdown('skillDepthThree'); + }} + > + {name} + + ))} + + + {skillDepthOneId && + detailSkills[skillDepthOneId].map(({ id, name }) => ( + { + onClickDropdown(value, 'skillDepthTwo'); + }} + > + {name} + + ))} + + + {skillLevels.map(({ id, name }) => ( + { + onClickDropdown(value, 'skillDepthThree'); + }} + > + {name} + + ))} + + + {selectedSkillDepths.length > 0 && ( + + {selectedSkillDepths.map((skill, idx) => { + return ( + + {skill.name} + + ); + })} + + )} + + + + + + + + + + + + + 지역 + + + {regions.map(({ id, name }) => ( + { + onClickDropdown(value, 'region'); + }} + > + {name} + + ))} + + + + + + + + + + + + + + + + + + + +
+ ); +}; + +const MainWrapper = styled.form` + background-color: ${theme.color.c1}; + height: 100%; +`; + +const Img = styled.img` + width: 100%; + height: 100%; +`; + +export default ProfileEdit; diff --git a/src/pages/ProfileEdit/hooks/useProfileQuery.ts b/src/pages/ProfileEdit/hooks/useProfileQuery.ts new file mode 100644 index 00000000..9e04374b --- /dev/null +++ b/src/pages/ProfileEdit/hooks/useProfileQuery.ts @@ -0,0 +1,22 @@ +import { useMemberInfoQuery } from '../../Profile/hooks/queries/useMemberInfoQuery'; +import useSignUpQuery from '../../SignUp/hooks/useSignUpQuery'; +import { convertSelectedCheckbox, convertSelectedSkills } from '../service/convertProfileQuery'; + +const useProfileQuery = () => { + const { mainSkills, detailSkills, skillLevels, regions, purposes } = useSignUpQuery(); + const my = useMemberInfoQuery(); + + const mySkills = convertSelectedSkills(my.skills); + const myPurposes = convertSelectedCheckbox(my.joinPurposes, purposes); + + return { + mainSkills, + detailSkills, + skillLevels, + regions, + purposes, + my: { ...my, ['skills']: mySkills, ['joinPurposes']: myPurposes }, + }; +}; + +export default useProfileQuery; diff --git a/src/pages/ProfileEdit/hooks/usePutProfileMutation.ts b/src/pages/ProfileEdit/hooks/usePutProfileMutation.ts new file mode 100644 index 00000000..d9a3ab3e --- /dev/null +++ b/src/pages/ProfileEdit/hooks/usePutProfileMutation.ts @@ -0,0 +1,35 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { AxiosError } from 'axios'; +import { useNavigate } from 'react-router-dom'; + +import { http } from '../../../api/http'; +import { PutSignUp } from '../types'; + +const updateUserNickname = (newNickname: string) => { + const userInfo = JSON.parse(localStorage.getItem('user') ?? '{}'); + userInfo.nickname = newNickname; + localStorage.setItem('user', JSON.stringify(userInfo)); +}; + +const _putProfile = (memberId: string, payload: PutSignUp) => http.put(`/members/${memberId}`, payload); + +const usePutProfileMutation = (memberId: string, newNickname: string) => { + const queryClient = useQueryClient(); + const navigate = useNavigate(); + const { mutate: putProfile, ...rest } = useMutation({ + mutationFn: (payload: PutSignUp) => _putProfile(memberId, payload), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['members', 'detail', memberId] }); + updateUserNickname(newNickname); + navigate('/profile'); + }, + onError: (error: AxiosError<{ message: string }>) => { + // TODO: #54 머지 이후 Alert 컴포넌트 사용 + alert(error.response?.data.message ?? '필수 정보를 입력하지 않아 저장할 수 없습니다.'); + }, + }); + + return { putProfile, ...rest }; +}; + +export default usePutProfileMutation; diff --git a/src/pages/ProfileEdit/service/convertProfileQuery.ts b/src/pages/ProfileEdit/service/convertProfileQuery.ts new file mode 100644 index 00000000..c03582aa --- /dev/null +++ b/src/pages/ProfileEdit/service/convertProfileQuery.ts @@ -0,0 +1,36 @@ +import { ConvertedCheckboxOption, ProfileSkill } from '../types'; + +export const convertSelectedSkills = (selectedSkills: ProfileSkill[] | undefined) => { + if (!selectedSkills) { + return []; + } + + return selectedSkills.map((skill) => ({ id: skill.skillId, name: `${skill.skillName}, ${skill.level}` })); +}; + +export const convertSelectedCheckbox = ( + selectedPurposes: string[] | undefined, + purposes: ConvertedCheckboxOption[], +) => { + if (!selectedPurposes) { + return []; + } + + return purposes.map((purpose) => { + const isChecked = selectedPurposes.some((purposeName) => purposeName === purpose.name); + + if (isChecked) { + return { + checked: true, + id: purpose.id, + name: purpose.name, + }; + } + + return { + checked: false, + id: purpose.id, + name: purpose.name, + }; + }); +}; diff --git a/src/pages/ProfileEdit/types/index.ts b/src/pages/ProfileEdit/types/index.ts new file mode 100644 index 00000000..af46ac5c --- /dev/null +++ b/src/pages/ProfileEdit/types/index.ts @@ -0,0 +1,76 @@ +export interface DropdownValue { + mainSkill: string; + skillDepthOne: string; + skillDepthTwo: string; + skillDepthThree: string; + region: string; +} + +export type DetailSkills = Record; + +export interface PutSignUp { + nickname: string; + mainSkillId: number; + profileImageUrl: string; + skills: Omit[]; + joinPurposes: number[]; + livingPlace?: string; + workingPlace?: string; + introduction?: string; +} + +export interface ProfileSkill { + skillId: number; + skillName: string; + level: string; +} + +export interface SignUpSkill { + id: number; + name: string; +} + +export interface GetSignUp { + mainSkillResponses: MainSkillOption[]; + purposeResponses: CheckboxOption[]; +} + +export interface MainSkillOption { + id: number; + name: string; + detailSkillResponses: DetailSkillOption[]; +} + +export interface DetailSkillOption { + id: number; + name: string; +} + +export interface CheckboxOption { + id: number; + name: string; +} + +export interface ConvertedCheckboxOption { + checked: boolean; + id: number; + name: string; +} + +export interface FieldValue { + nickname: string; + company: string; + intro: string; +} + +export interface Profile { + profileImageUrl: string; + nickname: string; + isMyProfile: boolean; + mainSkill: string; + livingPlace: string; + workingPlace: string; + introduction: string; + skills: ProfileSkill[]; + joinPurposes: string[]; +} diff --git a/src/pages/SignUp/hooks/useCheckDuplicateNickname.ts b/src/pages/SignUp/hooks/useCheckDuplicateNickname.ts index c829aca6..b0cea262 100644 --- a/src/pages/SignUp/hooks/useCheckDuplicateNickname.ts +++ b/src/pages/SignUp/hooks/useCheckDuplicateNickname.ts @@ -9,9 +9,12 @@ interface Props { } const useCheckDuplicateNickname = ({ nickname, setFieldErrorValue }: Props) => { + const userInfo = JSON.parse(localStorage.getItem('user') ?? '{}'); const timerId = useRef(null); useEffect(() => { + if (userInfo.nickname === nickname) return; + if (timerId.current) { const timerIdCurrent = timerId.current; clearTimeout(timerIdCurrent); @@ -29,7 +32,7 @@ const useCheckDuplicateNickname = ({ nickname, setFieldErrorValue }: Props) => { })); } }, 300); - }, [nickname, setFieldErrorValue]); + }, [userInfo, nickname, setFieldErrorValue]); }; export default useCheckDuplicateNickname; diff --git a/src/pages/SignUp/hooks/useSetDetailSkills.ts b/src/pages/SignUp/hooks/useSetDetailSkills.ts index fcbb6e8f..0b5b1720 100644 --- a/src/pages/SignUp/hooks/useSetDetailSkills.ts +++ b/src/pages/SignUp/hooks/useSetDetailSkills.ts @@ -3,14 +3,15 @@ import { useCallback, useEffect, useState } from 'react'; import { DetailSkills, DropdownValue, MainSkillOption, Skill } from '../types'; interface Props { + initialValue?: Skill[]; mainSkills: Pick[]; detailSkills: DetailSkills; dropdownValue: DropdownValue; onResetDropdown: (value: keyof DropdownValue) => void; } -const useSetDetailSkills = ({ mainSkills, detailSkills, dropdownValue, onResetDropdown }: Props) => { - const [selectedSkillDepths, setSelectedSkillDepths] = useState([]); +const useSetDetailSkills = ({ initialValue = [], mainSkills, detailSkills, dropdownValue, onResetDropdown }: Props) => { + const [selectedSkillDepths, setSelectedSkillDepths] = useState(initialValue); const skillDepthOneId = mainSkills.find(({ name }) => name === dropdownValue.skillDepthOne)?.id; const onDeleteSkill = useCallback((value: string) => { diff --git a/src/pages/Temp.tsx b/src/pages/Temp.tsx deleted file mode 100644 index f42374fe..00000000 --- a/src/pages/Temp.tsx +++ /dev/null @@ -1,14 +0,0 @@ -import { Button } from 'concept-be-design-system'; -import { useNavigate } from 'react-router-dom'; - -const Temp = () => { - const navigate = useNavigate(); - return ( - <> - 비로그인 임시 페이지입니다. 로그인을 시도하려면 아래 버튼을 누르세요. - - - ); -}; - -export default Temp; diff --git a/src/router.tsx b/src/router.tsx index 96132331..bb459cc6 100644 --- a/src/router.tsx +++ b/src/router.tsx @@ -11,9 +11,8 @@ import NeedAuth from './pages/NeedAuth'; import NotFound from './pages/NotFound'; import More from './pages/Profile/More.page'; import Profile from './pages/Profile/Profile.page'; -import Setting from './pages/Profile/Setting.page'; +import ProfileEdit from './pages/ProfileEdit/ProfileEdit.page'; import SignUpPage from './pages/SignUp/SignUp.page'; -import Temp from './pages/Temp'; import WritePage from './pages/Write/Write.page'; import WriteEditPage from './pages/WriteEdit/WriteEdit.page'; @@ -86,7 +85,11 @@ const routes: RouteElement[] = [ }, { path: '/profile/:id', - element: , + element: ( + + + + ), withAuth: true, }, { @@ -109,11 +112,6 @@ const routes: RouteElement[] = [ ), withAuth: false, }, - { - path: '/temp', - element: , - withAuth: false, - }, ], }, ];