From 2d298b30468bfac06bbfa3ae2b1caf570856be10 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EB=8F=84=EC=9C=A4?= Date: Fri, 27 Feb 2026 23:01:18 +0900 Subject: [PATCH] =?UTF-8?q?[FE]=20feat:=20=ED=94=84=EB=A1=9C=ED=95=84=20?= =?UTF-8?q?=EC=88=98=EC=A0=95=20API=20=EC=97=B0=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/profile/edit/page.tsx | 63 ++++++++++++++++++++--- src/features/member/api/member.api.ts | 43 +++++++++++++++- src/features/member/api/member.types.ts | 7 +++ src/widgets/profile-edit/EditKeywords.tsx | 4 +- src/widgets/profile/ProfileHero.tsx | 5 +- 5 files changed, 109 insertions(+), 13 deletions(-) diff --git a/src/app/profile/edit/page.tsx b/src/app/profile/edit/page.tsx index ac1d270..633fb2e 100644 --- a/src/app/profile/edit/page.tsx +++ b/src/app/profile/edit/page.tsx @@ -3,22 +3,25 @@ import { useEffect, useState } from 'react'; import { useRouter } from 'next/navigation'; import { ChevronLeft } from 'lucide-react'; -import { useQuery } from '@tanstack/react-query'; +import { AxiosError } from 'axios'; +import { useQuery, useQueryClient } from '@tanstack/react-query'; import { EditNickname } from '@/widgets/profile-edit/EditNickname'; import { EditMbti } from '@/widgets/profile-edit/EditMbti'; import { EditKeywords } from '@/widgets/profile-edit/EditKeywords'; import { EditBio } from '@/widgets/profile-edit/EditBio'; import { RetroButton } from '@/shared/ui/button/RetroButton'; -import { getMyProfile } from '@/features/member/api/member.api'; +import { getMyProfile, updateMyProfile } from '@/features/member/api/member.api'; import { PERSONALITY_KEYWORDS, type PersonalityKeywordKey } from '@/shared/lib/personalityKeyword'; export default function ProfileEditPage() { const router = useRouter(); + const queryClient = useQueryClient(); const [nickname, setNickname] = useState(''); const [mbti, setMbti] = useState(''); const [keywords, setKeywords] = useState([]); const [bio, setBio] = useState(''); const [isHydrated, setIsHydrated] = useState(false); + const [isSaving, setIsSaving] = useState(false); const { data, isLoading, isError } = useQuery({ queryKey: ['member', 'me'], @@ -40,10 +43,49 @@ export default function ProfileEditPage() { setIsHydrated(true); }, [data, isHydrated]); - const handleSave = () => { - console.log('Saved:', { nickname, mbti, keywords, bio }); - alert('프로필이 수정되었습니다!'); - router.back(); + const handleSave = async () => { + if (isSaving || isLoading) return; + + const trimmedNickname = nickname.trim(); + const normalizedMbti = mbti.trim().toUpperCase(); + + if (!trimmedNickname) { + alert('닉네임을 입력해주세요.'); + return; + } + + if (normalizedMbti.length !== 4) { + alert('MBTI 4글자를 선택해주세요.'); + return; + } + + if (keywords.length === 0) { + alert('성격 키워드를 1개 이상 선택해주세요.'); + return; + } + + try { + setIsSaving(true); + await updateMyProfile({ + nickname: trimmedNickname, + mbti: normalizedMbti, + personalityTypes: keywords, + bio, + }); + + await queryClient.invalidateQueries({ queryKey: ['member', 'me'] }); + alert('프로필이 수정되었습니다!'); + router.back(); + } catch (error) { + if (error instanceof AxiosError) { + const message = (error.response?.data as { message?: string } | undefined)?.message; + alert(message || '프로필 수정에 실패했습니다.'); + } else { + alert('프로필 수정에 실패했습니다.'); + } + } finally { + setIsSaving(false); + } }; return ( @@ -89,8 +131,13 @@ export default function ProfileEditPage() {
- - 수정 완료 + + {isSaving ? '수정 중...' : '수정 완료'}
diff --git a/src/features/member/api/member.api.ts b/src/features/member/api/member.api.ts index 57cfcc5..e0c983e 100644 --- a/src/features/member/api/member.api.ts +++ b/src/features/member/api/member.api.ts @@ -1,6 +1,6 @@ import { apiInstance } from '@/shared/api/apiInstance'; import type { ApiResponse } from '@/shared/api/api.types'; -import type { MyProfileResponse } from './member.types'; +import type { MyProfileResponse, UpdateMyProfileRequest } from './member.types'; const toObjectRecord = (value: unknown): Record => { if (!value || typeof value !== 'object') return {}; @@ -26,6 +26,26 @@ const toStringArray = (value: unknown): string[] => { .filter((item): item is string => Boolean(item)); }; +const toPersonalityTypes = (obj: Record): string[] => { + const personalityTypes = toStringArray(obj.personalityTypes); + if (personalityTypes.length > 0) return personalityTypes; + + const rawPersonalities = obj.personalities; + if (!Array.isArray(rawPersonalities)) return []; + + return rawPersonalities + .map((item) => { + if (typeof item === 'string') return item.trim(); + if (!item || typeof item !== 'object') return ''; + + const personality = item as Record; + const code = toStringSafe(personality.code)?.trim(); + const description = toStringSafe(personality.description)?.trim(); + return code || description || ''; + }) + .filter((item): item is string => Boolean(item)); +}; + const pickFirstString = (obj: Record, keys: string[]): string | undefined => { for (const key of keys) { const value = toStringSafe(obj[key])?.trim(); @@ -54,8 +74,27 @@ export const getMyProfile = async (): Promise => { collegeName: pickFirstString(obj, ['collegeName', 'department', 'major']), age: pickFirstNumber(obj, ['age']), mbtiCode: pickFirstString(obj, ['mbtiCode', 'mbti'])?.toUpperCase(), - personalityTypes: toStringArray(obj.personalityTypes), + personalityTypes: toPersonalityTypes(obj), bio: pickFirstString(obj, ['bio', 'description', 'introduction']), faceShape: pickFirstString(obj, ['faceShape', 'animalType']), }; }; + +const normalizeUpdatePayload = (payload: UpdateMyProfileRequest) => { + const nickname = payload.nickname.trim(); + const mbti = payload.mbti.trim().toUpperCase(); + const bio = payload.bio?.trim() ?? ''; + + return { + nickname, + mbti, + mbtiCode: mbti, + personalityTypes: payload.personalityTypes, + ...(bio ? { bio } : {}), + }; +}; + +export const updateMyProfile = async (payload: UpdateMyProfileRequest): Promise => { + const body = normalizeUpdatePayload(payload); + await apiInstance.patch>('/v1/members/me', body); +}; diff --git a/src/features/member/api/member.types.ts b/src/features/member/api/member.types.ts index 5e7834b..f17998f 100644 --- a/src/features/member/api/member.types.ts +++ b/src/features/member/api/member.types.ts @@ -10,3 +10,10 @@ export interface MyProfileResponse { bio?: string; faceShape?: string; } + +export interface UpdateMyProfileRequest { + nickname: string; + mbti: string; + personalityTypes: string[]; + bio?: string; +} diff --git a/src/widgets/profile-edit/EditKeywords.tsx b/src/widgets/profile-edit/EditKeywords.tsx index e9c3efb..97f25b5 100644 --- a/src/widgets/profile-edit/EditKeywords.tsx +++ b/src/widgets/profile-edit/EditKeywords.tsx @@ -22,7 +22,7 @@ export function EditKeywords({ value, onChange }: Props) { if (value.includes(key)) { onChange(value.filter((selectedKey) => selectedKey !== key)); } else { - if (value.length >= 5) return; + if (value.length >= 3) return; onChange([...value, key]); } }; @@ -36,7 +36,7 @@ export function EditKeywords({ value, onChange }: Props) { selected={selectedLabels} onToggle={toggleKeyword} /> -

최대 5개까지 선택할 수 있어요.

+

최대 3개까지 선택할 수 있어요.

); } diff --git a/src/widgets/profile/ProfileHero.tsx b/src/widgets/profile/ProfileHero.tsx index 60587c9..030f91c 100644 --- a/src/widgets/profile/ProfileHero.tsx +++ b/src/widgets/profile/ProfileHero.tsx @@ -3,7 +3,10 @@ import { useMemo } from 'react'; import { useQuery } from '@tanstack/react-query'; import { getMyProfile } from '@/features/member/api/member.api'; -import { PERSONALITY_KEY_TO_LABEL, type PersonalityKeywordKey } from '@/shared/lib/personalityKeyword'; +import { + PERSONALITY_KEY_TO_LABEL, + type PersonalityKeywordKey, +} from '@/shared/lib/personalityKeyword'; const toKeywordLabel = (keyword: string): string => { const mapped = PERSONALITY_KEY_TO_LABEL[keyword as PersonalityKeywordKey];