diff --git a/src/app/profile/edit/page.tsx b/src/app/profile/edit/page.tsx index ac1d270..3b56325 100644 --- a/src/app/profile/edit/page.tsx +++ b/src/app/profile/edit/page.tsx @@ -3,32 +3,48 @@ 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, + getPersonalityKeywordsApi, + 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'], queryFn: getMyProfile, }); + const { data: personalityKeywords } = useQuery({ + queryKey: ['member', 'personality-keywords'], + queryFn: getPersonalityKeywordsApi, + }); + + const availableKeywordOptions = + personalityKeywords && personalityKeywords.length > 0 + ? personalityKeywords + : PERSONALITY_KEYWORDS.map((keyword) => ({ code: keyword.key, description: keyword.label })); useEffect(() => { if (!data || isHydrated) return; - const validKeywordSet = new Set(PERSONALITY_KEYWORDS.map((keyword) => keyword.key)); + const validKeywordSet = new Set(availableKeywordOptions.map((keyword) => keyword.code)); const profileKeywords = data.personalityTypes.filter( (keyword): keyword is PersonalityKeywordKey => validKeywordSet.has(keyword as PersonalityKeywordKey) ); @@ -38,12 +54,51 @@ export default function ProfileEditPage() { setKeywords(profileKeywords); setBio(data.bio ?? ''); setIsHydrated(true); - }, [data, isHydrated]); + }, [availableKeywordOptions, data, isHydrated]); + + const handleSave = async () => { + if (isSaving || isLoading) return; + + const trimmedNickname = nickname.trim(); + const normalizedMbti = mbti.trim().toUpperCase(); + + if (!trimmedNickname) { + alert('닉네임을 입력해주세요.'); + return; + } - const handleSave = () => { - console.log('Saved:', { nickname, mbti, keywords, bio }); - alert('프로필이 수정되었습니다!'); - router.back(); + 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 ( @@ -82,15 +137,20 @@ 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..f84a684 100644 --- a/src/features/member/api/member.api.ts +++ b/src/features/member/api/member.api.ts @@ -1,6 +1,10 @@ import { apiInstance } from '@/shared/api/apiInstance'; import type { ApiResponse } from '@/shared/api/api.types'; -import type { MyProfileResponse } from './member.types'; +import type { + MyProfileResponse, + PersonalityKeywordItem, + UpdateMyProfileRequest, +} from './member.types'; const toObjectRecord = (value: unknown): Record => { if (!value || typeof value !== 'object') return {}; @@ -26,6 +30,41 @@ const toStringArray = (value: unknown): string[] => { .filter((item): item is string => Boolean(item)); }; +const toPersonalityKeywordArray = (value: unknown): PersonalityKeywordItem[] => { + if (!Array.isArray(value)) return []; + + return value + .map((item) => { + if (!item || typeof item !== 'object') return null; + const obj = item as Record; + const code = toStringSafe(obj.code)?.trim(); + const description = toStringSafe(obj.description)?.trim(); + if (!code || !description) return null; + return { code, description }; + }) + .filter((item): item is PersonalityKeywordItem => item !== null); +}; + +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(); @@ -42,10 +81,8 @@ const pickFirstNumber = (obj: Record, keys: string[]): number | return undefined; }; -export const getMyProfile = async (): Promise => { - const response = await apiInstance.get>('/v1/members/me'); - const obj = toObjectRecord(response.data.result); - +const normalizeProfileResponse = (raw: unknown): MyProfileResponse => { + const obj = toObjectRecord(raw); return { memberId: pickFirstNumber(obj, ['memberId', 'id']) ?? 0, nickname: pickFirstString(obj, ['nickname', 'name']) ?? '', @@ -53,9 +90,50 @@ export const getMyProfile = async (): Promise => { collegeId: pickFirstNumber(obj, ['collegeId']), collegeName: pickFirstString(obj, ['collegeName', 'department', 'major']), age: pickFirstNumber(obj, ['age']), + gender: pickFirstString(obj, ['gender']), + entryYear: pickFirstNumber(obj, ['entryYear']), mbtiCode: pickFirstString(obj, ['mbtiCode', 'mbti'])?.toUpperCase(), - personalityTypes: toStringArray(obj.personalityTypes), + mbtiDescription: pickFirstString(obj, ['mbtiDescription']), + personalityTypes: toPersonalityTypes(obj), bio: pickFirstString(obj, ['bio', 'description', 'introduction']), faceShape: pickFirstString(obj, ['faceShape', 'animalType']), }; }; + +export const getMyProfile = async (): Promise => { + const response = await apiInstance.get>('/v1/members/me'); + return normalizeProfileResponse(response.data.result); +}; + +export const getMemberProfileById = async (memberId: number): Promise => { + const response = await apiInstance.get>(`/v1/members/profile/${memberId}`); + return normalizeProfileResponse(response.data.result); +}; + +export const getPersonalityKeywordsApi = async (): Promise => { + const response = await apiInstance.get>('/v1/members/personality-keywords'); + return toPersonalityKeywordArray(response.data.result); +}; + +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); +}; + +export const deleteMyAccountApi = async (): Promise => { + await apiInstance.delete>('/v1/members/me'); +}; diff --git a/src/features/member/api/member.types.ts b/src/features/member/api/member.types.ts index 5e7834b..1341761 100644 --- a/src/features/member/api/member.types.ts +++ b/src/features/member/api/member.types.ts @@ -5,8 +5,23 @@ export interface MyProfileResponse { collegeId?: number; collegeName?: string; age?: number; + gender?: string; + entryYear?: number; mbtiCode?: string; + mbtiDescription?: string; personalityTypes: string[]; bio?: string; faceShape?: string; } + +export interface UpdateMyProfileRequest { + nickname: string; + mbti: string; + personalityTypes: string[]; + bio?: string; +} + +export interface PersonalityKeywordItem { + code: string; + description: string; +} diff --git a/src/widgets/chat-room/ChatSidePanel.tsx b/src/widgets/chat-room/ChatSidePanel.tsx index 27ecd91..8b24a82 100644 --- a/src/widgets/chat-room/ChatSidePanel.tsx +++ b/src/widgets/chat-room/ChatSidePanel.tsx @@ -1,8 +1,9 @@ 'use client'; import { X, User, Vote, Megaphone, LogOut } from 'lucide-react'; -import { useEffect } from 'react'; +import { useEffect, useState } from 'react'; import { useRouter, useParams } from 'next/navigation'; +import MemberProfileModal from '@/widgets/profile/MemberProfileModal'; export type ChatDrawerMember = { memberId: number; @@ -28,6 +29,7 @@ export default function ChatSidePanel({ isOpen, onClose, members, votes }: Props const router = useRouter(); const params = useParams(); const roomId = params.roomId as string; + const [selectedMemberId, setSelectedMemberId] = useState(null); useEffect(() => { if (isOpen) { @@ -83,7 +85,13 @@ export default function ChatSidePanel({ isOpen, onClose, members, votes }: Props 🐣
- {member.nickname} + {member.isMe && ( 나 @@ -162,6 +170,12 @@ export default function ChatSidePanel({ isOpen, onClose, members, votes }: Props
+ + setSelectedMemberId(null)} + /> ); } diff --git a/src/widgets/profile-edit/EditKeywords.tsx b/src/widgets/profile-edit/EditKeywords.tsx index e9c3efb..cce8c8f 100644 --- a/src/widgets/profile-edit/EditKeywords.tsx +++ b/src/widgets/profile-edit/EditKeywords.tsx @@ -6,23 +6,30 @@ import { PERSONALITY_KEY_TO_LABEL, type PersonalityKeywordKey, } from '@/shared/lib/personalityKeyword'; +import type { PersonalityKeywordItem } from '@/features/member/api/member.types'; type Props = { value: PersonalityKeywordKey[]; onChange: (val: PersonalityKeywordKey[]) => void; + options?: PersonalityKeywordItem[]; }; -export function EditKeywords({ value, onChange }: Props) { +export function EditKeywords({ value, onChange, options }: Props) { + const keywordOptions = options ?? PERSONALITY_KEYWORDS.map((keyword) => ({ + code: keyword.key, + description: keyword.label, + })); + const toggleKeyword = (label: string) => { - const found = PERSONALITY_KEYWORDS.find((keyword) => keyword.label === label); + const found = keywordOptions.find((keyword) => keyword.description === label); if (!found) return; - const key = found.key; + const key = found.code as PersonalityKeywordKey; if (value.includes(key)) { onChange(value.filter((selectedKey) => selectedKey !== key)); } else { - if (value.length >= 5) return; + if (value.length >= 3) return; onChange([...value, key]); } }; @@ -32,11 +39,11 @@ export function EditKeywords({ value, onChange }: Props) { return (
keyword.label)} + keywords={keywordOptions.map((keyword) => keyword.description)} selected={selectedLabels} onToggle={toggleKeyword} /> -

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

+

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

); } diff --git a/src/widgets/profile/MemberProfileModal.tsx b/src/widgets/profile/MemberProfileModal.tsx new file mode 100644 index 0000000..490bd35 --- /dev/null +++ b/src/widgets/profile/MemberProfileModal.tsx @@ -0,0 +1,125 @@ +'use client'; + +import { useEffect, useMemo } from 'react'; +import { useQuery } from '@tanstack/react-query'; +import { X } from 'lucide-react'; +import { getMemberProfileById } from '@/features/member/api/member.api'; +import { PERSONALITY_KEY_TO_LABEL, type PersonalityKeywordKey } from '@/shared/lib/personalityKeyword'; + +type Props = { + memberId: number | null; + isOpen: boolean; + onClose: () => void; +}; + +const toKeywordLabel = (keyword: string): string => { + const mapped = PERSONALITY_KEY_TO_LABEL[keyword as PersonalityKeywordKey]; + return mapped ?? keyword; +}; + +export default function MemberProfileModal({ memberId, isOpen, onClose }: Props) { + const { data, isLoading, isError } = useQuery({ + queryKey: ['member', 'profile', memberId], + queryFn: async () => getMemberProfileById(memberId!), + enabled: isOpen && !!memberId, + }); + + useEffect(() => { + if (!isOpen) return; + const prevOverflow = document.body.style.overflow; + document.body.style.overflow = 'hidden'; + + return () => { + document.body.style.overflow = prevOverflow; + }; + }, [isOpen]); + + const keywordLabels = useMemo( + () => (data?.personalityTypes ?? []).map((keyword) => toKeywordLabel(keyword)).slice(0, 3), + [data?.personalityTypes] + ); + + if (!isOpen) return null; + + const genderText = data?.gender === 'M' ? '남자' : data?.gender === 'W' ? '여자' : '-'; + const genderSymbol = data?.gender === 'M' ? '♂' : data?.gender === 'W' ? '♀' : ''; + const studentId = typeof data?.entryYear === 'number' ? data.entryYear : '-'; + const age = typeof data?.age === 'number' ? data.age : '-'; + const mbti = data?.mbtiCode ?? '-'; + const intro = data?.bio ?? '아직 등록된 자기소개가 없어요.'; + const schoolLine = [data?.universityName, data?.collegeName].filter(Boolean).join(' '); + + return ( +
+ + + {isLoading &&

프로필 불러오는 중...

} + + {isError &&

프로필을 불러오지 못했어요.

} + + {!isLoading && !isError && data && ( +
+
+ 🐣 +
+ +

+ {data.nickname} + {genderSymbol} +

+ +

{schoolLine || '학교 정보 없음'}

+ + {keywordLabels.length > 0 && ( +
+ {keywordLabels.map((keyword) => ( + + {keyword} + + ))} +
+ )} + +
+
학번
+
{studentId}
+
나이
+
{age}
+
성별
+
{genderText}
+
MBTI
+
{mbti}
+
+ +
+ {intro} +
+ + +
+ )} + +
+ + ); +} 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]; diff --git a/src/widgets/profile/ProfileMenuSection.tsx b/src/widgets/profile/ProfileMenuSection.tsx index a4a6c98..25037b1 100644 --- a/src/widgets/profile/ProfileMenuSection.tsx +++ b/src/widgets/profile/ProfileMenuSection.tsx @@ -8,11 +8,13 @@ import MenuCard from './ui/MenuCard'; import MenuItem from './ui/MenuItem'; import Divider from './ui/Divider'; import { logoutApi } from '@/features/auth/api/auth.api'; +import { deleteMyAccountApi } from '@/features/member/api/member.api'; import { tokenStore } from '@/shared/auth/tokenStore'; export default function ProfileMenuSection() { const router = useRouter(); const [isLoggingOut, setIsLoggingOut] = useState(false); + const [isDeleting, setIsDeleting] = useState(false); const handleLogout = async () => { if (isLoggingOut) return; @@ -35,6 +37,30 @@ export default function ProfileMenuSection() { } }; + const handleWithdraw = async () => { + if (isDeleting) return; + if (!confirm('정말 탈퇴하시겠어요? 탈퇴 후에는 복구할 수 없습니다.')) return; + + try { + setIsDeleting(true); + await deleteMyAccountApi(); + alert('회원 탈퇴가 완료되었습니다.'); + } catch (error) { + if (error instanceof AxiosError) { + const message = (error.response?.data as { message?: string } | undefined)?.message; + alert(message || '탈퇴 처리 중 오류가 발생했습니다.'); + return; + } + alert('탈퇴 처리 중 오류가 발생했습니다.'); + return; + } finally { + setIsDeleting(false); + } + + tokenStore.clear(); + router.replace('/splash'); + }; + return (
@@ -63,9 +89,9 @@ export default function ProfileMenuSection() { /> alert('탈퇴')} + onClick={handleWithdraw} tone="danger" /> diff --git a/src/widgets/team-detail/TeamMembersRow.tsx b/src/widgets/team-detail/TeamMembersRow.tsx index 096c3b4..a4d862b 100644 --- a/src/widgets/team-detail/TeamMembersRow.tsx +++ b/src/widgets/team-detail/TeamMembersRow.tsx @@ -1,3 +1,8 @@ +'use client'; + +import { useState } from 'react'; +import MemberProfileModal from '@/widgets/profile/MemberProfileModal'; + // 왕관 아이콘 (이모지로 대체하거나 에셋 사용 가능, 여기선 텍스트/이모지로 처리) // 실제 프로젝트에선 import CrownIcon from '@/assets/icons/crown.png'; 등을 사용 권장 @@ -15,40 +20,59 @@ type Props = { }; export function TeamMembersRow({ members }: Props) { + const [selectedMemberId, setSelectedMemberId] = useState(null); + return ( -
- {/* 가로 스크롤 가능하게 처리 (멤버가 많을 경우 대비) */} -
- {members.map((member) => ( -
- {/* Avatar Container */} -
- {member.isLeader && ( -
- 👑 + <> +
+ {/* 가로 스크롤 가능하게 처리 (멤버가 많을 경우 대비) */} +
+ {members.map((member) => ( +
- ))} -
-
+ + {/* Info */} + + {member.nickname} + + + {member.schoolName} +
+ {member.major} +
+ + ))} + +
+ + setSelectedMemberId(null)} + /> + ); }