Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
82 changes: 71 additions & 11 deletions src/app/profile/edit/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<PersonalityKeywordKey[]>([]);
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)
);
Expand All @@ -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 (
Expand Down Expand Up @@ -82,15 +137,20 @@ export default function ProfileEditPage() {
<EditMbti value={mbti} onChange={setMbti} />
</section>
<section className="space-y-6">
<EditKeywords value={keywords} onChange={setKeywords} />
<EditKeywords value={keywords} onChange={setKeywords} options={availableKeywordOptions} />
<EditBio value={bio} onChange={setBio} />
</section>
</div>
</div>

<div className="fixed bottom-6 left-0 right-0 mx-auto max-w-[480px] px-6 z-10 pb-[env(safe-area-inset-bottom)]">
<RetroButton onClick={handleSave} className="w-full" variant="yellow" disabled={isLoading}>
μˆ˜μ • μ™„λ£Œ
<RetroButton
onClick={handleSave}
className="w-full"
variant="yellow"
disabled={isLoading || isSaving}
>
{isSaving ? 'μˆ˜μ • 쀑...' : 'μˆ˜μ • μ™„λ£Œ'}
</RetroButton>
</div>
</main>
Expand Down
90 changes: 84 additions & 6 deletions src/features/member/api/member.api.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown> => {
if (!value || typeof value !== 'object') return {};
Expand All @@ -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<string, unknown>;
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, unknown>): 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<string, unknown>;
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<string, unknown>, keys: string[]): string | undefined => {
for (const key of keys) {
const value = toStringSafe(obj[key])?.trim();
Expand All @@ -42,20 +81,59 @@ const pickFirstNumber = (obj: Record<string, unknown>, keys: string[]): number |
return undefined;
};

export const getMyProfile = async (): Promise<MyProfileResponse> => {
const response = await apiInstance.get<ApiResponse<unknown>>('/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']) ?? '',
universityName: pickFirstString(obj, ['universityName', 'school', 'university']),
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<MyProfileResponse> => {
const response = await apiInstance.get<ApiResponse<unknown>>('/v1/members/me');
return normalizeProfileResponse(response.data.result);
};

export const getMemberProfileById = async (memberId: number): Promise<MyProfileResponse> => {
const response = await apiInstance.get<ApiResponse<unknown>>(`/v1/members/profile/${memberId}`);
return normalizeProfileResponse(response.data.result);
};

export const getPersonalityKeywordsApi = async (): Promise<PersonalityKeywordItem[]> => {
const response = await apiInstance.get<ApiResponse<unknown>>('/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<void> => {
const body = normalizeUpdatePayload(payload);
await apiInstance.patch<ApiResponse<null>>('/v1/members/me', body);
};

export const deleteMyAccountApi = async (): Promise<void> => {
await apiInstance.delete<ApiResponse<null>>('/v1/members/me');
};
15 changes: 15 additions & 0 deletions src/features/member/api/member.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
18 changes: 16 additions & 2 deletions src/widgets/chat-room/ChatSidePanel.tsx
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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<number | null>(null);

useEffect(() => {
if (isOpen) {
Expand Down Expand Up @@ -83,7 +85,13 @@ export default function ChatSidePanel({ isOpen, onClose, members, votes }: Props
🐣
</div>
<div className="flex items-center gap-1.5">
<span className="text-[14px] font-medium text-gray-800">{member.nickname}</span>
<button
type="button"
onClick={() => setSelectedMemberId(member.memberId)}
className="text-[14px] font-medium text-gray-800 underline-offset-2 hover:underline"
>
{member.nickname}
</button>
{member.isMe && (
<span className="text-[10px] font-bold text-gray-400 border border-gray-200 px-1 rounded">
λ‚˜
Expand Down Expand Up @@ -162,6 +170,12 @@ export default function ChatSidePanel({ isOpen, onClose, members, votes }: Props
</div>
</div>
</div>

<MemberProfileModal
memberId={selectedMemberId}
isOpen={selectedMemberId !== null}
onClose={() => setSelectedMemberId(null)}
/>
</>
);
}
19 changes: 13 additions & 6 deletions src/widgets/profile-edit/EditKeywords.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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]);
}
};
Expand All @@ -32,11 +39,11 @@ export function EditKeywords({ value, onChange }: Props) {
return (
<div className="flex flex-col gap-3">
<KeywordGrid
keywords={PERSONALITY_KEYWORDS.map((keyword) => keyword.label)}
keywords={keywordOptions.map((keyword) => keyword.description)}
selected={selectedLabels}
onToggle={toggleKeyword}
/>
<p className="text-xs text-right text-gray-500">μ΅œλŒ€ 5κ°œκΉŒμ§€ 선택할 수 μžˆμ–΄μš”.</p>
<p className="text-xs text-right text-gray-500">μ΅œλŒ€ 3κ°œκΉŒμ§€ 선택할 수 μžˆμ–΄μš”.</p>
</div>
);
}
Loading