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 1/3] =?UTF-8?q?[FE]=20feat:=20=ED=94=84=EB=A1=9C=ED=95=84?= =?UTF-8?q?=20=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]; From 89adea57ad37be49802b03b08317da0dc2ab2e95 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:25:56 +0900 Subject: [PATCH 2/3] =?UTF-8?q?[FE]=20feat:=20/member=EA=B4=80=EB=A0=A8=20?= =?UTF-8?q?API=20=EC=9D=BC=EA=B4=84=20=EC=97=B0=EB=8F=99=20=EB=B0=8F=20?= =?UTF-8?q?=EC=83=81=EB=8C=80=20=ED=94=84=EB=A1=9C=ED=95=84=20=ED=8E=98?= =?UTF-8?q?=EC=9D=B4=EC=A7=80=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/profile/edit/page.tsx | 21 +++- src/features/member/api/member.api.ts | 49 +++++++- src/features/member/api/member.types.ts | 8 ++ src/widgets/chat-room/ChatSidePanel.tsx | 18 ++- src/widgets/profile-edit/EditKeywords.tsx | 15 ++- src/widgets/profile/MemberProfileModal.tsx | 125 +++++++++++++++++++++ src/widgets/profile/ProfileMenuSection.tsx | 30 ++++- src/widgets/team-detail/TeamMembersRow.tsx | 88 +++++++++------ 8 files changed, 305 insertions(+), 49 deletions(-) create mode 100644 src/widgets/profile/MemberProfileModal.tsx diff --git a/src/app/profile/edit/page.tsx b/src/app/profile/edit/page.tsx index 633fb2e..3b56325 100644 --- a/src/app/profile/edit/page.tsx +++ b/src/app/profile/edit/page.tsx @@ -10,7 +10,11 @@ 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, updateMyProfile } 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() { @@ -27,11 +31,20 @@ export default function ProfileEditPage() { 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) ); @@ -41,7 +54,7 @@ export default function ProfileEditPage() { setKeywords(profileKeywords); setBio(data.bio ?? ''); setIsHydrated(true); - }, [data, isHydrated]); + }, [availableKeywordOptions, data, isHydrated]); const handleSave = async () => { if (isSaving || isLoading) return; @@ -124,7 +137,7 @@ export default function ProfileEditPage() {
- +
diff --git a/src/features/member/api/member.api.ts b/src/features/member/api/member.api.ts index e0c983e..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, UpdateMyProfileRequest } 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,21 @@ 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; @@ -62,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']) ?? '', @@ -73,13 +90,31 @@ 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(), + 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(); @@ -98,3 +133,7 @@ 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 f17998f..1341761 100644 --- a/src/features/member/api/member.types.ts +++ b/src/features/member/api/member.types.ts @@ -5,7 +5,10 @@ export interface MyProfileResponse { collegeId?: number; collegeName?: string; age?: number; + gender?: string; + entryYear?: number; mbtiCode?: string; + mbtiDescription?: string; personalityTypes: string[]; bio?: string; faceShape?: string; @@ -17,3 +20,8 @@ export interface UpdateMyProfileRequest { 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 97f25b5..cce8c8f 100644 --- a/src/widgets/profile-edit/EditKeywords.tsx +++ b/src/widgets/profile-edit/EditKeywords.tsx @@ -6,18 +6,25 @@ 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)); @@ -32,7 +39,7 @@ export function EditKeywords({ value, onChange }: Props) { return (
keyword.label)} + keywords={keywordOptions.map((keyword) => keyword.description)} selected={selectedLabels} onToggle={toggleKeyword} /> 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/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)} + /> + ); } From fbd13a651fce3d45433cc71e57b2359adae8ff9a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EB=8F=84=EC=9C=A4?= Date: Mon, 2 Mar 2026 00:04:40 +0900 Subject: [PATCH 3/3] =?UTF-8?q?[FE]=20fix:=20next&axios=20=EB=B2=84?= =?UTF-8?q?=EC=A0=84=20=EC=97=85=EB=8D=B0=EC=9D=B4=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package-lock.json | 212 +++++++++++++----- package.json | 4 +- pnpm-lock.yaml | 175 +++++++++------ src/app/(auth)/signup/step-4/page.tsx | 10 +- src/app/profile/ai/page.tsx | 164 ++++++++++++++ src/features/ai/api/ai.api.ts | 63 ++++++ src/features/ai/api/ai.types.ts | 10 + src/features/signup/model/signup.store.ts | 1 + src/features/signup/model/signup.types.ts | 1 + .../signup/ui/steps/step-4/FaceAnalyze.tsx | 99 +++++++- 10 files changed, 602 insertions(+), 137 deletions(-) create mode 100644 src/app/profile/ai/page.tsx create mode 100644 src/features/ai/api/ai.api.ts create mode 100644 src/features/ai/api/ai.types.ts diff --git a/package-lock.json b/package-lock.json index e49625e..e730ea8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,12 +8,14 @@ "name": "buting", "version": "0.1.0", "dependencies": { + "@stomp/stompjs": "^7.3.0", "@tanstack/react-query": "^5.90.12", - "axios": "^1.13.2", + "axios": "^1.13.6", "lucide-react": "^0.562.0", - "next": "15.5.2", + "next": "15.5.10", "react": "18.3.1", "react-dom": "18.3.1", + "sockjs-client": "^1.6.1", "zustand": "^5.0.9" }, "devDependencies": { @@ -23,6 +25,7 @@ "@types/node": "^20", "@types/react": "^19", "@types/react-dom": "^19", + "@types/sockjs-client": "^1.5.4", "autoprefixer": "^10.4.23", "eslint": "^9", "eslint-config-next": "15.5.2", @@ -2403,9 +2406,9 @@ } }, "node_modules/@next/env": { - "version": "15.5.2", - "resolved": "https://registry.npmjs.org/@next/env/-/env-15.5.2.tgz", - "integrity": "sha512-Qe06ew4zt12LeO6N7j8/nULSOe3fMXE4dM6xgpBQNvdzyK1sv5y4oAP3bq4LamrvGCZtmRYnW8URFCeX5nFgGg==", + "version": "15.5.10", + "resolved": "https://registry.npmjs.org/@next/env/-/env-15.5.10.tgz", + "integrity": "sha512-plg+9A/KoZcTS26fe15LHg+QxReTazrIOoKKUC3Uz4leGGeNPgLHdevVraAAOX0snnUs3WkRx3eUQpj9mreG6A==", "license": "MIT" }, "node_modules/@next/eslint-plugin-next": { @@ -2419,9 +2422,9 @@ } }, "node_modules/@next/swc-darwin-arm64": { - "version": "15.5.2", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-15.5.2.tgz", - "integrity": "sha512-8bGt577BXGSd4iqFygmzIfTYizHb0LGWqH+qgIF/2EDxS5JsSdERJKA8WgwDyNBZgTIIA4D8qUtoQHmxIIquoQ==", + "version": "15.5.7", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-15.5.7.tgz", + "integrity": "sha512-IZwtxCEpI91HVU/rAUOOobWSZv4P2DeTtNaCdHqLcTJU4wdNXgAySvKa/qJCgR5m6KI8UsKDXtO2B31jcaw1Yw==", "cpu": [ "arm64" ], @@ -2435,9 +2438,9 @@ } }, "node_modules/@next/swc-darwin-x64": { - "version": "15.5.2", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-15.5.2.tgz", - "integrity": "sha512-2DjnmR6JHK4X+dgTXt5/sOCu/7yPtqpYt8s8hLkHFK3MGkka2snTv3yRMdHvuRtJVkPwCGsvBSwmoQCHatauFQ==", + "version": "15.5.7", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-15.5.7.tgz", + "integrity": "sha512-UP6CaDBcqaCBuiq/gfCEJw7sPEoX1aIjZHnBWN9v9qYHQdMKvCKcAVs4OX1vIjeE+tC5EIuwDTVIoXpUes29lg==", "cpu": [ "x64" ], @@ -2451,9 +2454,9 @@ } }, "node_modules/@next/swc-linux-arm64-gnu": { - "version": "15.5.2", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-15.5.2.tgz", - "integrity": "sha512-3j7SWDBS2Wov/L9q0mFJtEvQ5miIqfO4l7d2m9Mo06ddsgUK8gWfHGgbjdFlCp2Ek7MmMQZSxpGFqcC8zGh2AA==", + "version": "15.5.7", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-15.5.7.tgz", + "integrity": "sha512-NCslw3GrNIw7OgmRBxHtdWFQYhexoUCq+0oS2ccjyYLtcn1SzGzeM54jpTFonIMUjNbHmpKpziXnpxhSWLcmBA==", "cpu": [ "arm64" ], @@ -2467,9 +2470,9 @@ } }, "node_modules/@next/swc-linux-arm64-musl": { - "version": "15.5.2", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-15.5.2.tgz", - "integrity": "sha512-s6N8k8dF9YGc5T01UPQ08yxsK6fUow5gG1/axWc1HVVBYQBgOjca4oUZF7s4p+kwhkB1bDSGR8QznWrFZ/Rt5g==", + "version": "15.5.7", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-15.5.7.tgz", + "integrity": "sha512-nfymt+SE5cvtTrG9u1wdoxBr9bVB7mtKTcj0ltRn6gkP/2Nu1zM5ei8rwP9qKQP0Y//umK+TtkKgNtfboBxRrw==", "cpu": [ "arm64" ], @@ -2483,9 +2486,9 @@ } }, "node_modules/@next/swc-linux-x64-gnu": { - "version": "15.5.2", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-15.5.2.tgz", - "integrity": "sha512-o1RV/KOODQh6dM6ZRJGZbc+MOAHww33Vbs5JC9Mp1gDk8cpEO+cYC/l7rweiEalkSm5/1WGa4zY7xrNwObN4+Q==", + "version": "15.5.7", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-15.5.7.tgz", + "integrity": "sha512-hvXcZvCaaEbCZcVzcY7E1uXN9xWZfFvkNHwbe/n4OkRhFWrs1J1QV+4U1BN06tXLdaS4DazEGXwgqnu/VMcmqw==", "cpu": [ "x64" ], @@ -2499,9 +2502,9 @@ } }, "node_modules/@next/swc-linux-x64-musl": { - "version": "15.5.2", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-15.5.2.tgz", - "integrity": "sha512-/VUnh7w8RElYZ0IV83nUcP/J4KJ6LLYliiBIri3p3aW2giF+PAVgZb6mk8jbQSB3WlTai8gEmCAr7kptFa1H6g==", + "version": "15.5.7", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-15.5.7.tgz", + "integrity": "sha512-4IUO539b8FmF0odY6/SqANJdgwn1xs1GkPO5doZugwZ3ETF6JUdckk7RGmsfSf7ws8Qb2YB5It33mvNL/0acqA==", "cpu": [ "x64" ], @@ -2515,9 +2518,9 @@ } }, "node_modules/@next/swc-win32-arm64-msvc": { - "version": "15.5.2", - "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-15.5.2.tgz", - "integrity": "sha512-sMPyTvRcNKXseNQ/7qRfVRLa0VhR0esmQ29DD6pqvG71+JdVnESJaHPA8t7bc67KD5spP3+DOCNLhqlEI2ZgQg==", + "version": "15.5.7", + "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-15.5.7.tgz", + "integrity": "sha512-CpJVTkYI3ZajQkC5vajM7/ApKJUOlm6uP4BknM3XKvJ7VXAvCqSjSLmM0LKdYzn6nBJVSjdclx8nYJSa3xlTgQ==", "cpu": [ "arm64" ], @@ -2548,9 +2551,9 @@ } }, "node_modules/@next/swc-win32-x64-msvc": { - "version": "15.5.2", - "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-15.5.2.tgz", - "integrity": "sha512-W5VvyZHnxG/2ukhZF/9Ikdra5fdNftxI6ybeVKYvBPDtyx7x4jPPSNduUkfH5fo3zG0JQ0bPxgy41af2JX5D4Q==", + "version": "15.5.7", + "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-15.5.7.tgz", + "integrity": "sha512-gMzgBX164I6DN+9/PGA+9dQiwmTkE4TloBNx8Kv9UiGARsr9Nba7IpcBRA1iTV9vwlYnrE3Uy6I7Aj6qLjQuqw==", "cpu": [ "x64" ], @@ -2719,6 +2722,12 @@ "dev": true, "license": "MIT" }, + "node_modules/@stomp/stompjs": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/@stomp/stompjs/-/stompjs-7.3.0.tgz", + "integrity": "sha512-nKMLoFfJhrQAqkvvKd1vLq/cVBGCMwPRCD0LqW7UT1fecRx9C3GoKEIR2CYwVuErGeZu8w0kFkl2rlhPlqHVgQ==", + "license": "Apache-2.0" + }, "node_modules/@surma/rollup-plugin-off-main-thread": { "version": "2.2.3", "resolved": "https://registry.npmjs.org/@surma/rollup-plugin-off-main-thread/-/rollup-plugin-off-main-thread-2.2.3.tgz", @@ -3214,6 +3223,13 @@ "@types/node": "*" } }, + "node_modules/@types/sockjs-client": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/@types/sockjs-client/-/sockjs-client-1.5.4.tgz", + "integrity": "sha512-zk+uFZeWyvJ5ZFkLIwoGA/DfJ+pYzcZ8eH4H/EILCm2OBZyHH6Hkdna1/UWL/CFruh5wj6ES7g75SvUB0VsH5w==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/trusted-types": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", @@ -4389,13 +4405,13 @@ } }, "node_modules/axios": { - "version": "1.13.2", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.2.tgz", - "integrity": "sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA==", + "version": "1.13.6", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.6.tgz", + "integrity": "sha512-ChTCHMouEe2kn713WHbQGcuYrr6fXTBiu460OTwWrWob16g1bXn4vtz07Ope7ewMozJAnEquLk5lWQWtBig9DQ==", "license": "MIT", "dependencies": { - "follow-redirects": "^1.15.6", - "form-data": "^4.0.4", + "follow-redirects": "^1.15.11", + "form-data": "^4.0.5", "proxy-from-env": "^1.1.0" } }, @@ -5886,6 +5902,15 @@ "node": ">=0.8.x" } }, + "node_modules/eventsource": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-2.0.2.tgz", + "integrity": "sha512-IzUmBGPR3+oUG9dUeXynyNmf91/3zUSJg1lCktzKw47OXuhco54U3r9B7O4XX+Rb1Itm9OZ2b0RkTs10bICOxA==", + "license": "MIT", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -5964,6 +5989,18 @@ "reusify": "^1.0.4" } }, + "node_modules/faye-websocket": { + "version": "0.11.4", + "resolved": "https://registry.npmjs.org/faye-websocket/-/faye-websocket-0.11.4.tgz", + "integrity": "sha512-CzbClwlXAuiRQAlUyfqPgvPoNKTckTPGfwZV4ZdAhVcP2lh9KUxJg2b5GkE7XbjKQ3YJnQ9z6D9ntLAlB+tP8g==", + "license": "Apache-2.0", + "dependencies": { + "websocket-driver": ">=0.5.1" + }, + "engines": { + "node": ">=0.8.0" + } + }, "node_modules/file-entry-cache": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", @@ -6521,6 +6558,12 @@ "node": ">= 0.4" } }, + "node_modules/http-parser-js": { + "version": "0.5.10", + "resolved": "https://registry.npmjs.org/http-parser-js/-/http-parser-js-0.5.10.tgz", + "integrity": "sha512-Pysuw9XpUq5dVc/2SMHpuTY01RFl8fttgcyunjL7eEMhGM3cI4eOmiCycJDVCo/7O7ClfQD3SaI6ftDzqOXYMA==", + "license": "MIT" + }, "node_modules/idb": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/idb/-/idb-7.1.1.tgz", @@ -6581,7 +6624,6 @@ "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", - "dev": true, "license": "ISC" }, "node_modules/internal-slot": { @@ -7606,7 +7648,6 @@ "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, "license": "MIT" }, "node_modules/mz": { @@ -7671,13 +7712,12 @@ "peer": true }, "node_modules/next": { - "version": "15.5.2", - "resolved": "https://registry.npmjs.org/next/-/next-15.5.2.tgz", - "integrity": "sha512-H8Otr7abj1glFhbGnvUt3gz++0AF1+QoCXEBmd/6aKbfdFwrn0LpA836Ed5+00va/7HQSDD+mOoVhn3tNy3e/Q==", - "deprecated": "This version has a security vulnerability. Please upgrade to a patched version. See https://nextjs.org/blog/CVE-2025-66478 for more details.", + "version": "15.5.10", + "resolved": "https://registry.npmjs.org/next/-/next-15.5.10.tgz", + "integrity": "sha512-r0X65PNwyDDyOrWNKpQoZvOatw7BcsTPRKdwEqtc9cj3wv7mbBIk9tKed4klRaFXJdX0rugpuMTHslDrAU1bBg==", "license": "MIT", "dependencies": { - "@next/env": "15.5.2", + "@next/env": "15.5.10", "@swc/helpers": "0.5.15", "caniuse-lite": "^1.0.30001579", "postcss": "8.4.31", @@ -7690,14 +7730,14 @@ "node": "^18.18.0 || ^19.8.0 || >= 20.0.0" }, "optionalDependencies": { - "@next/swc-darwin-arm64": "15.5.2", - "@next/swc-darwin-x64": "15.5.2", - "@next/swc-linux-arm64-gnu": "15.5.2", - "@next/swc-linux-arm64-musl": "15.5.2", - "@next/swc-linux-x64-gnu": "15.5.2", - "@next/swc-linux-x64-musl": "15.5.2", - "@next/swc-win32-arm64-msvc": "15.5.2", - "@next/swc-win32-x64-msvc": "15.5.2", + "@next/swc-darwin-arm64": "15.5.7", + "@next/swc-darwin-x64": "15.5.7", + "@next/swc-linux-arm64-gnu": "15.5.7", + "@next/swc-linux-arm64-musl": "15.5.7", + "@next/swc-linux-x64-gnu": "15.5.7", + "@next/swc-linux-x64-musl": "15.5.7", + "@next/swc-win32-arm64-msvc": "15.5.7", + "@next/swc-win32-x64-msvc": "15.5.7", "sharp": "^0.34.3" }, "peerDependencies": { @@ -8396,6 +8436,12 @@ "node": ">=6" } }, + "node_modules/querystringify": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz", + "integrity": "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==", + "license": "MIT" + }, "node_modules/queue-microtask": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", @@ -8604,6 +8650,12 @@ "node": ">=0.10.0" } }, + "node_modules/requires-port": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", + "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==", + "license": "MIT" + }, "node_modules/resolve": { "version": "1.22.11", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", @@ -8776,7 +8828,6 @@ "version": "5.2.1", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", - "dev": true, "funding": [ { "type": "github", @@ -9082,6 +9133,34 @@ "node": ">=8" } }, + "node_modules/sockjs-client": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/sockjs-client/-/sockjs-client-1.6.1.tgz", + "integrity": "sha512-2g0tjOR+fRs0amxENLi/q5TiJTqY+WXFOzb5UwXndlK6TO3U/mirZznpx6w34HVMoc3g7cY24yC/ZMIYnDlfkw==", + "license": "MIT", + "dependencies": { + "debug": "^3.2.7", + "eventsource": "^2.0.2", + "faye-websocket": "^0.11.4", + "inherits": "^2.0.4", + "url-parse": "^1.5.10" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://tidelift.com/funding/github/npm/sockjs-client" + } + }, + "node_modules/sockjs-client/node_modules/debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.1" + } + }, "node_modules/source-list-map": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/source-list-map/-/source-list-map-2.0.1.tgz", @@ -10090,6 +10169,16 @@ "punycode": "^2.1.0" } }, + "node_modules/url-parse": { + "version": "1.5.10", + "resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.5.10.tgz", + "integrity": "sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==", + "license": "MIT", + "dependencies": { + "querystringify": "^2.1.1", + "requires-port": "^1.0.0" + } + }, "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", @@ -10267,6 +10356,29 @@ "url": "https://opencollective.com/webpack" } }, + "node_modules/websocket-driver": { + "version": "0.7.4", + "resolved": "https://registry.npmjs.org/websocket-driver/-/websocket-driver-0.7.4.tgz", + "integrity": "sha512-b17KeDIQVjvb0ssuSDF2cYXSg2iztliJ4B9WdsuB6J952qCPKmnVq4DyW5motImXHDC1cBT/1UezrJVsKw5zjg==", + "license": "Apache-2.0", + "dependencies": { + "http-parser-js": ">=0.5.1", + "safe-buffer": ">=5.1.0", + "websocket-extensions": ">=0.1.1" + }, + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/websocket-extensions": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/websocket-extensions/-/websocket-extensions-0.1.4.tgz", + "integrity": "sha512-OqedPIGOfsDlo31UNwYbCFMSaO9m9G/0faIHj5/dZFDMFqPTcx6UwqyOy3COEaEOg/9VsGIpdqn62W5KhoKSpg==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8.0" + } + }, "node_modules/whatwg-url": { "version": "7.1.0", "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-7.1.0.tgz", diff --git a/package.json b/package.json index 97e71cb..3866d80 100644 --- a/package.json +++ b/package.json @@ -11,9 +11,9 @@ "dependencies": { "@stomp/stompjs": "^7.3.0", "@tanstack/react-query": "^5.90.12", - "axios": "^1.13.2", + "axios": "^1.13.6", "lucide-react": "^0.562.0", - "next": "15.5.2", + "next": "15.5.10", "react": "18.3.1", "react-dom": "18.3.1", "sockjs-client": "^1.6.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4060395..506cb28 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -15,14 +15,14 @@ importers: specifier: ^5.90.12 version: 5.90.12(react@18.3.1) axios: - specifier: ^1.13.2 - version: 1.13.2 + specifier: ^1.13.6 + version: 1.13.6 lucide-react: specifier: ^0.562.0 version: 0.562.0(react@18.3.1) next: - specifier: 15.5.2 - version: 15.5.2(@babel/core@7.28.5)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + specifier: 15.5.10 + version: 15.5.10(@babel/core@7.28.5)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) react: specifier: 18.3.1 version: 18.3.1 @@ -68,7 +68,7 @@ importers: version: 15.5.2(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) next-pwa: specifier: ^5.6.0 - version: 5.6.0(@babel/core@7.28.5)(next@15.5.2(@babel/core@7.28.5)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(webpack@5.104.1) + version: 5.6.0(@babel/core@7.28.5)(next@15.5.10(@babel/core@7.28.5)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(webpack@5.104.1) postcss: specifier: ^8.5.6 version: 8.5.6 @@ -595,6 +595,9 @@ packages: '@emnapi/runtime@1.7.1': resolution: {integrity: sha512-PVtJr5CmLwYAU9PZDMITZoR5iAOShYREoR45EyyLrbntV50mdePTgUn4AmOw90Ifcj+x2kRjdzr1HP3RrNiHGA==} + '@emnapi/runtime@1.8.1': + resolution: {integrity: sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg==} + '@emnapi/wasi-threads@1.1.0': resolution: {integrity: sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ==} @@ -652,8 +655,8 @@ packages: resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==} engines: {node: '>=18.18'} - '@img/colour@1.0.0': - resolution: {integrity: sha512-A5P/LfWGFSl6nsckYtjw9da+19jB8hkJ6ACTGcDfEJ0aE+l2n2El7dsVM7UVHZQ9s2lmYMWlrS21YLy2IR1LUw==} + '@img/colour@1.1.0': + resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==} engines: {node: '>=18'} '@img/sharp-darwin-arm64@0.34.5': @@ -822,8 +825,8 @@ packages: '@next/env@13.5.11': resolution: {integrity: sha512-fbb2C7HChgM7CemdCY+y3N1n8pcTKdqtQLbC7/EQtPdLvlMUT9JX/dBYl8MMZAtYG4uVMyPFHXckb68q/NRwqg==} - '@next/env@15.5.2': - resolution: {integrity: sha512-Qe06ew4zt12LeO6N7j8/nULSOe3fMXE4dM6xgpBQNvdzyK1sv5y4oAP3bq4LamrvGCZtmRYnW8URFCeX5nFgGg==} + '@next/env@15.5.10': + resolution: {integrity: sha512-plg+9A/KoZcTS26fe15LHg+QxReTazrIOoKKUC3Uz4leGGeNPgLHdevVraAAOX0snnUs3WkRx3eUQpj9mreG6A==} '@next/eslint-plugin-next@15.5.2': resolution: {integrity: sha512-lkLrRVxcftuOsJNhWatf1P2hNVfh98k/omQHrCEPPriUypR6RcS13IvLdIrEvkm9AH2Nu2YpR5vLqBuy6twH3Q==} @@ -834,8 +837,8 @@ packages: cpu: [arm64] os: [darwin] - '@next/swc-darwin-arm64@15.5.2': - resolution: {integrity: sha512-8bGt577BXGSd4iqFygmzIfTYizHb0LGWqH+qgIF/2EDxS5JsSdERJKA8WgwDyNBZgTIIA4D8qUtoQHmxIIquoQ==} + '@next/swc-darwin-arm64@15.5.7': + resolution: {integrity: sha512-IZwtxCEpI91HVU/rAUOOobWSZv4P2DeTtNaCdHqLcTJU4wdNXgAySvKa/qJCgR5m6KI8UsKDXtO2B31jcaw1Yw==} engines: {node: '>= 10'} cpu: [arm64] os: [darwin] @@ -846,8 +849,8 @@ packages: cpu: [x64] os: [darwin] - '@next/swc-darwin-x64@15.5.2': - resolution: {integrity: sha512-2DjnmR6JHK4X+dgTXt5/sOCu/7yPtqpYt8s8hLkHFK3MGkka2snTv3yRMdHvuRtJVkPwCGsvBSwmoQCHatauFQ==} + '@next/swc-darwin-x64@15.5.7': + resolution: {integrity: sha512-UP6CaDBcqaCBuiq/gfCEJw7sPEoX1aIjZHnBWN9v9qYHQdMKvCKcAVs4OX1vIjeE+tC5EIuwDTVIoXpUes29lg==} engines: {node: '>= 10'} cpu: [x64] os: [darwin] @@ -858,8 +861,8 @@ packages: cpu: [arm64] os: [linux] - '@next/swc-linux-arm64-gnu@15.5.2': - resolution: {integrity: sha512-3j7SWDBS2Wov/L9q0mFJtEvQ5miIqfO4l7d2m9Mo06ddsgUK8gWfHGgbjdFlCp2Ek7MmMQZSxpGFqcC8zGh2AA==} + '@next/swc-linux-arm64-gnu@15.5.7': + resolution: {integrity: sha512-NCslw3GrNIw7OgmRBxHtdWFQYhexoUCq+0oS2ccjyYLtcn1SzGzeM54jpTFonIMUjNbHmpKpziXnpxhSWLcmBA==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] @@ -870,8 +873,8 @@ packages: cpu: [arm64] os: [linux] - '@next/swc-linux-arm64-musl@15.5.2': - resolution: {integrity: sha512-s6N8k8dF9YGc5T01UPQ08yxsK6fUow5gG1/axWc1HVVBYQBgOjca4oUZF7s4p+kwhkB1bDSGR8QznWrFZ/Rt5g==} + '@next/swc-linux-arm64-musl@15.5.7': + resolution: {integrity: sha512-nfymt+SE5cvtTrG9u1wdoxBr9bVB7mtKTcj0ltRn6gkP/2Nu1zM5ei8rwP9qKQP0Y//umK+TtkKgNtfboBxRrw==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] @@ -882,8 +885,8 @@ packages: cpu: [x64] os: [linux] - '@next/swc-linux-x64-gnu@15.5.2': - resolution: {integrity: sha512-o1RV/KOODQh6dM6ZRJGZbc+MOAHww33Vbs5JC9Mp1gDk8cpEO+cYC/l7rweiEalkSm5/1WGa4zY7xrNwObN4+Q==} + '@next/swc-linux-x64-gnu@15.5.7': + resolution: {integrity: sha512-hvXcZvCaaEbCZcVzcY7E1uXN9xWZfFvkNHwbe/n4OkRhFWrs1J1QV+4U1BN06tXLdaS4DazEGXwgqnu/VMcmqw==} engines: {node: '>= 10'} cpu: [x64] os: [linux] @@ -894,8 +897,8 @@ packages: cpu: [x64] os: [linux] - '@next/swc-linux-x64-musl@15.5.2': - resolution: {integrity: sha512-/VUnh7w8RElYZ0IV83nUcP/J4KJ6LLYliiBIri3p3aW2giF+PAVgZb6mk8jbQSB3WlTai8gEmCAr7kptFa1H6g==} + '@next/swc-linux-x64-musl@15.5.7': + resolution: {integrity: sha512-4IUO539b8FmF0odY6/SqANJdgwn1xs1GkPO5doZugwZ3ETF6JUdckk7RGmsfSf7ws8Qb2YB5It33mvNL/0acqA==} engines: {node: '>= 10'} cpu: [x64] os: [linux] @@ -906,8 +909,8 @@ packages: cpu: [arm64] os: [win32] - '@next/swc-win32-arm64-msvc@15.5.2': - resolution: {integrity: sha512-sMPyTvRcNKXseNQ/7qRfVRLa0VhR0esmQ29DD6pqvG71+JdVnESJaHPA8t7bc67KD5spP3+DOCNLhqlEI2ZgQg==} + '@next/swc-win32-arm64-msvc@15.5.7': + resolution: {integrity: sha512-CpJVTkYI3ZajQkC5vajM7/ApKJUOlm6uP4BknM3XKvJ7VXAvCqSjSLmM0LKdYzn6nBJVSjdclx8nYJSa3xlTgQ==} engines: {node: '>= 10'} cpu: [arm64] os: [win32] @@ -924,8 +927,8 @@ packages: cpu: [x64] os: [win32] - '@next/swc-win32-x64-msvc@15.5.2': - resolution: {integrity: sha512-W5VvyZHnxG/2ukhZF/9Ikdra5fdNftxI6ybeVKYvBPDtyx7x4jPPSNduUkfH5fo3zG0JQ0bPxgy41af2JX5D4Q==} + '@next/swc-win32-x64-msvc@15.5.7': + resolution: {integrity: sha512-gMzgBX164I6DN+9/PGA+9dQiwmTkE4TloBNx8Kv9UiGARsr9Nba7IpcBRA1iTV9vwlYnrE3Uy6I7Aj6qLjQuqw==} engines: {node: '>= 10'} cpu: [x64] os: [win32] @@ -1281,6 +1284,11 @@ packages: engines: {node: '>=0.4.0'} hasBin: true + acorn@8.16.0: + resolution: {integrity: sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==} + engines: {node: '>=0.4.0'} + hasBin: true + ajv-formats@2.1.1: resolution: {integrity: sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==} peerDependencies: @@ -1402,8 +1410,8 @@ packages: resolution: {integrity: sha512-ilYanEU8vxxBexpJd8cWM4ElSQq4QctCLKih0TSfjIfCQTeyH/6zVrmIJfLPrKTKJRbiG+cfnZbQIjAlJmF1jQ==} engines: {node: '>=4'} - axios@1.13.2: - resolution: {integrity: sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA==} + axios@1.13.6: + resolution: {integrity: sha512-ChTCHMouEe2kn713WHbQGcuYrr6fXTBiu460OTwWrWob16g1bXn4vtz07Ope7ewMozJAnEquLk5lWQWtBig9DQ==} axobject-query@4.1.0: resolution: {integrity: sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==} @@ -1494,6 +1502,9 @@ packages: caniuse-lite@1.0.30001761: resolution: {integrity: sha512-JF9ptu1vP2coz98+5051jZ4PwQgd2ni8A+gYSN7EA7dPKIMf0pDlSUxhdmVOaV3/fYK5uWBkgSXJaRLr4+3A6g==} + caniuse-lite@1.0.30001775: + resolution: {integrity: sha512-s3Qv7Lht9zbVKE9XoTyRG6wVDCKdtOFIjBGg3+Yhn6JaytuNKPIjBMTMIY1AnOH3seL5mvF+x33oGAyK3hVt3A==} + chalk@4.1.2: resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} engines: {node: '>=10'} @@ -1657,8 +1668,8 @@ packages: resolution: {integrity: sha512-/kyM18EfinwXZbno9FyUGeFh87KC8HRQBQGildHZbEuRyWFOmv1U10o9BBp8XVZDVNNuQKyIGIu5ZYAAXJ0V2Q==} engines: {node: '>= 4'} - enhanced-resolve@5.18.4: - resolution: {integrity: sha512-LgQMM4WXU3QI+SYgEc2liRgznaD5ojbmY3sb8LxyguVkIg5FxdpTkvk72te2R38/TGKxH634oLxXRGY6d7AP+Q==} + enhanced-resolve@5.20.0: + resolution: {integrity: sha512-/ce7+jQ1PQ6rVXwe+jKEg5hW5ciicHwIQUagZkp6IufBoY3YDgdTTY1azVs0qoRgVmvsNB+rbjLJxDAeHHtwsQ==} engines: {node: '>=10.13.0'} es-abstract@1.24.1: @@ -2445,10 +2456,9 @@ packages: sass: optional: true - next@15.5.2: - resolution: {integrity: sha512-H8Otr7abj1glFhbGnvUt3gz++0AF1+QoCXEBmd/6aKbfdFwrn0LpA836Ed5+00va/7HQSDD+mOoVhn3tNy3e/Q==} + next@15.5.10: + resolution: {integrity: sha512-r0X65PNwyDDyOrWNKpQoZvOatw7BcsTPRKdwEqtc9cj3wv7mbBIk9tKed4klRaFXJdX0rugpuMTHslDrAU1bBg==} engines: {node: ^18.18.0 || ^19.8.0 || >= 20.0.0} - deprecated: This version has a security vulnerability. Please upgrade to a patched version. See https://nextjs.org/blog/CVE-2025-66478 for more details. hasBin: true peerDependencies: '@opentelemetry/api': ^1.1.0 @@ -2808,6 +2818,11 @@ packages: engines: {node: '>=10'} hasBin: true + semver@7.7.4: + resolution: {integrity: sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==} + engines: {node: '>=10'} + hasBin: true + serialize-javascript@4.0.0: resolution: {integrity: sha512-GaNA54380uFefWghODBWEGisLZFj00nS5ACs6yHa9nLqlLpVLO8ChDGeKRjZnV4Nh4n0Qi7nhYZD/9fCPzEqkw==} @@ -3140,8 +3155,8 @@ packages: resolution: {integrity: sha512-Lcvm7MGST/4fup+ifyKi2hjyIAwcdI4HRgtvTpIUxBRhB+RFtUh8XtDOxUfctVCnhVi+QQj49i91OyvzkJl6cg==} engines: {node: '>=10.13.0'} - watchpack@2.5.0: - resolution: {integrity: sha512-e6vZvY6xboSwLz2GD36c16+O/2Z6fKvIf4pOXptw2rY9MVwE/TXc6RGqxD3I3x0a28lwBY7DE+76uTPSsBrrCA==} + watchpack@2.5.1: + resolution: {integrity: sha512-Zn5uXdcFNIA1+1Ei5McRd+iRzfhENPCe7LeABkJtNulSxjma+l7ltNx55BWZkRlwRnpOgHqxnjyaDgJnNXnqzg==} engines: {node: '>=10.13.0'} webidl-conversions@4.0.2: @@ -3150,8 +3165,8 @@ packages: webpack-sources@1.4.3: resolution: {integrity: sha512-lgTS3Xhv1lCOKo7SA5TjKXMjpSM4sBjNV5+q2bqesbSPs5FjGmU6jjtBSkX9b4qW87vDIsCIlUPOEhbZrMdjeQ==} - webpack-sources@3.3.3: - resolution: {integrity: sha512-yd1RBzSGanHkitROoPFd6qsrxt+oFhg/129YzheDGqeustzX0vTZJZsSsQjVQC4yzBQ56K55XU8gaNCtIzOnTg==} + webpack-sources@3.3.4: + resolution: {integrity: sha512-7tP1PdV4vF+lYPnkMR0jMY5/la2ub5Fc/8VQrrU+lXkiM6C4TjVfGw7iKfyhnTQOsD+6Q/iKw0eFciziRgD58Q==} engines: {node: '>=10.13.0'} webpack@5.104.1: @@ -3966,6 +3981,11 @@ snapshots: tslib: 2.8.1 optional: true + '@emnapi/runtime@1.8.1': + dependencies: + tslib: 2.8.1 + optional: true + '@emnapi/wasi-threads@1.1.0': dependencies: tslib: 2.8.1 @@ -4028,7 +4048,7 @@ snapshots: '@humanwhocodes/retry@0.4.3': {} - '@img/colour@1.0.0': + '@img/colour@1.1.0': optional: true '@img/sharp-darwin-arm64@0.34.5': @@ -4113,7 +4133,7 @@ snapshots: '@img/sharp-wasm32@0.34.5': dependencies: - '@emnapi/runtime': 1.7.1 + '@emnapi/runtime': 1.8.1 optional: true '@img/sharp-win32-arm64@0.34.5': @@ -4164,7 +4184,7 @@ snapshots: '@next/env@13.5.11': {} - '@next/env@15.5.2': {} + '@next/env@15.5.10': {} '@next/eslint-plugin-next@15.5.2': dependencies: @@ -4173,43 +4193,43 @@ snapshots: '@next/swc-darwin-arm64@13.5.9': optional: true - '@next/swc-darwin-arm64@15.5.2': + '@next/swc-darwin-arm64@15.5.7': optional: true '@next/swc-darwin-x64@13.5.9': optional: true - '@next/swc-darwin-x64@15.5.2': + '@next/swc-darwin-x64@15.5.7': optional: true '@next/swc-linux-arm64-gnu@13.5.9': optional: true - '@next/swc-linux-arm64-gnu@15.5.2': + '@next/swc-linux-arm64-gnu@15.5.7': optional: true '@next/swc-linux-arm64-musl@13.5.9': optional: true - '@next/swc-linux-arm64-musl@15.5.2': + '@next/swc-linux-arm64-musl@15.5.7': optional: true '@next/swc-linux-x64-gnu@13.5.9': optional: true - '@next/swc-linux-x64-gnu@15.5.2': + '@next/swc-linux-x64-gnu@15.5.7': optional: true '@next/swc-linux-x64-musl@13.5.9': optional: true - '@next/swc-linux-x64-musl@15.5.2': + '@next/swc-linux-x64-musl@15.5.7': optional: true '@next/swc-win32-arm64-msvc@13.5.9': optional: true - '@next/swc-win32-arm64-msvc@15.5.2': + '@next/swc-win32-arm64-msvc@15.5.7': optional: true '@next/swc-win32-ia32-msvc@13.5.9': @@ -4218,7 +4238,7 @@ snapshots: '@next/swc-win32-x64-msvc@13.5.9': optional: true - '@next/swc-win32-x64-msvc@15.5.2': + '@next/swc-win32-x64-msvc@15.5.7': optional: true '@nodelib/fs.scandir@2.1.5': @@ -4602,9 +4622,9 @@ snapshots: '@xtuc/long@4.2.2': {} - acorn-import-phases@1.0.4(acorn@8.15.0): + acorn-import-phases@1.0.4(acorn@8.16.0): dependencies: - acorn: 8.15.0 + acorn: 8.16.0 acorn-jsx@5.3.2(acorn@8.15.0): dependencies: @@ -4612,6 +4632,8 @@ snapshots: acorn@8.15.0: {} + acorn@8.16.0: {} + ajv-formats@2.1.1(ajv@8.17.1): optionalDependencies: ajv: 8.17.1 @@ -4756,7 +4778,7 @@ snapshots: axe-core@4.11.0: {} - axios@1.13.2: + axios@1.13.6: dependencies: follow-redirects: 1.15.11 form-data: 4.0.5 @@ -4859,6 +4881,8 @@ snapshots: caniuse-lite@1.0.30001761: {} + caniuse-lite@1.0.30001775: {} + chalk@4.1.2: dependencies: ansi-styles: 4.3.0 @@ -5010,7 +5034,7 @@ snapshots: emojis-list@3.0.0: {} - enhanced-resolve@5.18.4: + enhanced-resolve@5.20.0: dependencies: graceful-fs: 4.2.11 tapable: 2.3.0 @@ -5903,12 +5927,12 @@ snapshots: neo-async@2.6.2: {} - next-pwa@5.6.0(@babel/core@7.28.5)(next@15.5.2(@babel/core@7.28.5)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(webpack@5.104.1): + next-pwa@5.6.0(@babel/core@7.28.5)(next@15.5.10(@babel/core@7.28.5)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(webpack@5.104.1): dependencies: babel-loader: 8.4.1(@babel/core@7.28.5)(webpack@5.104.1) clean-webpack-plugin: 4.0.0(webpack@5.104.1) globby: 11.1.0 - next: 15.5.2(@babel/core@7.28.5)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + next: 15.5.10(@babel/core@7.28.5)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) terser-webpack-plugin: 5.3.16(webpack@5.104.1) workbox-webpack-plugin: 6.6.0(webpack@5.104.1) workbox-window: 6.6.0 @@ -5926,7 +5950,7 @@ snapshots: '@next/env': 13.5.11 '@swc/helpers': 0.5.2 busboy: 1.6.0 - caniuse-lite: 1.0.30001761 + caniuse-lite: 1.0.30001775 postcss: 8.4.31 react: 18.3.1 react-dom: 18.3.1(react@18.3.1) @@ -5946,24 +5970,24 @@ snapshots: - '@babel/core' - babel-plugin-macros - next@15.5.2(@babel/core@7.28.5)(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + next@15.5.10(@babel/core@7.28.5)(react-dom@18.3.1(react@18.3.1))(react@18.3.1): dependencies: - '@next/env': 15.5.2 + '@next/env': 15.5.10 '@swc/helpers': 0.5.15 - caniuse-lite: 1.0.30001761 + caniuse-lite: 1.0.30001775 postcss: 8.4.31 react: 18.3.1 react-dom: 18.3.1(react@18.3.1) styled-jsx: 5.1.6(@babel/core@7.28.5)(react@18.3.1) optionalDependencies: - '@next/swc-darwin-arm64': 15.5.2 - '@next/swc-darwin-x64': 15.5.2 - '@next/swc-linux-arm64-gnu': 15.5.2 - '@next/swc-linux-arm64-musl': 15.5.2 - '@next/swc-linux-x64-gnu': 15.5.2 - '@next/swc-linux-x64-musl': 15.5.2 - '@next/swc-win32-arm64-msvc': 15.5.2 - '@next/swc-win32-x64-msvc': 15.5.2 + '@next/swc-darwin-arm64': 15.5.7 + '@next/swc-darwin-x64': 15.5.7 + '@next/swc-linux-arm64-gnu': 15.5.7 + '@next/swc-linux-arm64-musl': 15.5.7 + '@next/swc-linux-x64-gnu': 15.5.7 + '@next/swc-linux-x64-musl': 15.5.7 + '@next/swc-win32-arm64-msvc': 15.5.7 + '@next/swc-win32-x64-msvc': 15.5.7 sharp: 0.34.5 transitivePeerDependencies: - '@babel/core' @@ -6306,6 +6330,9 @@ snapshots: semver@7.7.3: {} + semver@7.7.4: + optional: true + serialize-javascript@4.0.0: dependencies: randombytes: 2.1.0 @@ -6338,9 +6365,9 @@ snapshots: sharp@0.34.5: dependencies: - '@img/colour': 1.0.0 + '@img/colour': 1.1.0 detect-libc: 2.1.2 - semver: 7.7.3 + semver: 7.7.4 optionalDependencies: '@img/sharp-darwin-arm64': 0.34.5 '@img/sharp-darwin-x64': 0.34.5 @@ -6745,7 +6772,7 @@ snapshots: glob-to-regexp: 0.4.1 graceful-fs: 4.2.11 - watchpack@2.5.0: + watchpack@2.5.1: dependencies: glob-to-regexp: 0.4.1 graceful-fs: 4.2.11 @@ -6757,7 +6784,7 @@ snapshots: source-list-map: 2.0.1 source-map: 0.6.1 - webpack-sources@3.3.3: {} + webpack-sources@3.3.4: {} webpack@5.104.1: dependencies: @@ -6767,11 +6794,11 @@ snapshots: '@webassemblyjs/ast': 1.14.1 '@webassemblyjs/wasm-edit': 1.14.1 '@webassemblyjs/wasm-parser': 1.14.1 - acorn: 8.15.0 - acorn-import-phases: 1.0.4(acorn@8.15.0) + acorn: 8.16.0 + acorn-import-phases: 1.0.4(acorn@8.16.0) browserslist: 4.28.1 chrome-trace-event: 1.0.4 - enhanced-resolve: 5.18.4 + enhanced-resolve: 5.20.0 es-module-lexer: 2.0.0 eslint-scope: 5.1.1 events: 3.3.0 @@ -6784,8 +6811,8 @@ snapshots: schema-utils: 4.3.3 tapable: 2.3.0 terser-webpack-plugin: 5.3.16(webpack@5.104.1) - watchpack: 2.5.0 - webpack-sources: 3.3.3 + watchpack: 2.5.1 + webpack-sources: 3.3.4 transitivePeerDependencies: - '@swc/core' - esbuild diff --git a/src/app/(auth)/signup/step-4/page.tsx b/src/app/(auth)/signup/step-4/page.tsx index 3f7ebc1..27c7c2b 100644 --- a/src/app/(auth)/signup/step-4/page.tsx +++ b/src/app/(auth)/signup/step-4/page.tsx @@ -29,6 +29,7 @@ export default function Step4Page() { const collegeId = signupState.collegeId; const bio = signupState.oneLiner.trim(); const keywords = signupState.keywords as PersonalityKeywordKey[]; + const faceShapeId = signupState.faceShapeId; const missingFields: string[] = []; if (!signupState.signUpToken) missingFields.push('가입 토큰'); @@ -63,6 +64,7 @@ export default function Step4Page() { personalityTypes: keywords, ...(bio ? { bio } : {}), collegeId: collegeId!, + ...(faceShapeId ? { faceShapeId } : {}), }); signupState.reset(); @@ -85,6 +87,9 @@ export default function Step4Page() { setMode('analyze'); }; + const analysisGender = + signupState.gender === 'male' ? 'M' : signupState.gender === 'female' ? 'W' : null; + return ( { + signupState.setAvatar({ faceShapeId: null }); setPickSource('manual'); setMode('picker'); }} - onComplete={() => { + gender={analysisGender} + onComplete={(result) => { + signupState.setAvatar({ faceShapeId: result.faceShapeId }); setPickSource('ai'); setMode('picker'); }} diff --git a/src/app/profile/ai/page.tsx b/src/app/profile/ai/page.tsx new file mode 100644 index 0000000..8f73ce9 --- /dev/null +++ b/src/app/profile/ai/page.tsx @@ -0,0 +1,164 @@ +'use client'; + +import { useEffect, useRef, useState } from 'react'; +import { useRouter } from 'next/navigation'; +import Image from 'next/image'; +import { AxiosError } from 'axios'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { ChevronLeft } from 'lucide-react'; +import { analyzeMyFaceApi } from '@/features/ai/api/ai.api'; +import type { AiAnalysisResult } from '@/features/ai/api/ai.types'; +import { RetroButton } from '@/shared/ui/button/RetroButton'; + +export default function ProfileAiPage() { + const router = useRouter(); + const queryClient = useQueryClient(); + const fileInputRef = useRef(null); + const [previewUrl, setPreviewUrl] = useState(null); + const [result, setResult] = useState(null); + + useEffect(() => { + return () => { + if (previewUrl) URL.revokeObjectURL(previewUrl); + }; + }, [previewUrl]); + + const analyzeMutation = useMutation({ + mutationFn: (file: File) => analyzeMyFaceApi(file), + onSuccess: async (analyzed) => { + setResult(analyzed); + await queryClient.invalidateQueries({ queryKey: ['member', 'me'] }); + alert('AI 얼굴 분석 결과가 프로필에 반영되었습니다.'); + }, + onError: (error) => { + if (error instanceof AxiosError) { + const message = (error.response?.data as { message?: string } | undefined)?.message; + alert(message || 'AI 얼굴 분석에 실패했습니다.'); + return; + } + alert('AI 얼굴 분석에 실패했습니다.'); + }, + }); + + const handlePickImage = () => { + if (analyzeMutation.isPending) return; + fileInputRef.current?.click(); + }; + + const handleFileChange = (event: React.ChangeEvent) => { + const file = event.target.files?.[0]; + event.target.value = ''; + if (!file) return; + + const nextPreviewUrl = URL.createObjectURL(file); + setPreviewUrl((prev) => { + if (prev) URL.revokeObjectURL(prev); + return nextPreviewUrl; + }); + + analyzeMutation.mutate(file); + }; + + return ( +
+
+ +
+ +
+
+ + +
+ ai analyze +
+
+ +
+
+ + +

+ {analyzeMutation.isPending ? '분석 중입니다...' : '사진을 눌러 AI 얼굴 분석하기'} +

+ + {result && ( +
+

+ {result.nickname || result.name || 'AI 분석 결과'} +

+ {result.description ? ( +

{result.description}

+ ) : null} +
+ )} +
+
+ +
+ + {analyzeMutation.isPending ? '분석 중...' : '사진 선택 후 분석'} + +
+
+ + +
+ ); +} diff --git a/src/features/ai/api/ai.api.ts b/src/features/ai/api/ai.api.ts new file mode 100644 index 0000000..c2ec82e --- /dev/null +++ b/src/features/ai/api/ai.api.ts @@ -0,0 +1,63 @@ +import { authApi, publicApi } from '@/shared/api/apiInstance'; +import type { ApiResponse } from '@/shared/api/api.types'; +import type { AiAnalysisResult, AiGender } from './ai.types'; + +const toRecord = (value: unknown): Record => { + if (!value || typeof value !== 'object') return {}; + return value as Record; +}; + +const toStringSafe = (value: unknown): string => { + if (typeof value === 'string') return value; + return ''; +}; + +const toNumberSafe = (value: unknown): number => { + if (typeof value === 'number' && Number.isFinite(value)) return value; + if (typeof value === 'string') { + const parsed = Number(value); + if (Number.isFinite(parsed)) return parsed; + } + return 0; +}; + +const normalizeAiAnalysisResult = (raw: unknown): AiAnalysisResult => { + const result = toRecord(raw); + const percentageValue = result.percentage; + const percentage = + typeof percentageValue === 'number' && Number.isFinite(percentageValue) + ? percentageValue + : undefined; + + return { + faceShapeId: toNumberSafe(result.faceShapeId), + name: toStringSafe(result.name), + nickname: toStringSafe(result.nickname), + description: toStringSafe(result.description), + image: toStringSafe(result.image), + ...(percentage !== undefined ? { percentage } : {}), + }; +}; + +const createImageFormData = (file: File): FormData => { + const formData = new FormData(); + formData.append('file', file); + return formData; +}; + +export async function analyzeFaceApi(gender: AiGender, file: File): Promise { + const response = await publicApi.post>('/v1/ai', createImageFormData(file), { + params: { gender }, + headers: { 'Content-Type': 'multipart/form-data' }, + }); + + return normalizeAiAnalysisResult(response.data.result); +} + +export async function analyzeMyFaceApi(file: File): Promise { + const response = await authApi.post>('/v1/ai/me', createImageFormData(file), { + headers: { 'Content-Type': 'multipart/form-data' }, + }); + + return normalizeAiAnalysisResult(response.data.result); +} diff --git a/src/features/ai/api/ai.types.ts b/src/features/ai/api/ai.types.ts new file mode 100644 index 0000000..c1f4e40 --- /dev/null +++ b/src/features/ai/api/ai.types.ts @@ -0,0 +1,10 @@ +export interface AiAnalysisResult { + faceShapeId: number; + name: string; + nickname: string; + description: string; + image: string; + percentage?: number; +} + +export type AiGender = 'M' | 'W'; diff --git a/src/features/signup/model/signup.store.ts b/src/features/signup/model/signup.store.ts index 4041187..eb5dc27 100644 --- a/src/features/signup/model/signup.store.ts +++ b/src/features/signup/model/signup.store.ts @@ -25,6 +25,7 @@ const initialPersonality: SignupPersonality = { const initialAvatar: SignupAvatar = { animal: null, + faceShapeId: null, }; const initialState = { diff --git a/src/features/signup/model/signup.types.ts b/src/features/signup/model/signup.types.ts index c4b65f8..5f3fb21 100644 --- a/src/features/signup/model/signup.types.ts +++ b/src/features/signup/model/signup.types.ts @@ -23,6 +23,7 @@ export type SignupPersonality = { export type SignupAvatar = { animal: string | null; + faceShapeId: number | null; }; export type SignupState = SignupProfile & diff --git a/src/features/signup/ui/steps/step-4/FaceAnalyze.tsx b/src/features/signup/ui/steps/step-4/FaceAnalyze.tsx index a75a337..0e75741 100644 --- a/src/features/signup/ui/steps/step-4/FaceAnalyze.tsx +++ b/src/features/signup/ui/steps/step-4/FaceAnalyze.tsx @@ -1,18 +1,76 @@ -import React from 'react'; +import React, { useEffect, useRef, useState } from 'react'; +import Image from 'next/image'; +import { AxiosError } from 'axios'; +import { analyzeFaceApi } from '@/features/ai/api/ai.api'; +import type { AiAnalysisResult, AiGender } from '@/features/ai/api/ai.types'; type Props = { onSkip: () => void; - onComplete: () => void; + gender: AiGender | null; + onComplete: (result: AiAnalysisResult) => void; }; -export function FaceAnalyze({ onSkip, onComplete }: Props) { - const handleAnalyze = () => { - alert('얼굴 분석 중... (Mock)'); - onComplete(); +export function FaceAnalyze({ onSkip, gender, onComplete }: Props) { + const fileInputRef = useRef(null); + const [isAnalyzing, setIsAnalyzing] = useState(false); + const [previewUrl, setPreviewUrl] = useState(null); + const [result, setResult] = useState(null); + + useEffect(() => { + return () => { + if (previewUrl) URL.revokeObjectURL(previewUrl); + }; + }, [previewUrl]); + + const handleAnalyzeClick = () => { + if (isAnalyzing) return; + fileInputRef.current?.click(); + }; + + const handleFileChange = async (event: React.ChangeEvent) => { + const file = event.target.files?.[0]; + event.target.value = ''; + + if (!file) return; + if (!gender) { + alert('성별 정보가 필요해요. 이전 단계에서 성별을 먼저 선택해주세요.'); + return; + } + + try { + setIsAnalyzing(true); + const nextPreviewUrl = URL.createObjectURL(file); + setPreviewUrl((prev) => { + if (prev) URL.revokeObjectURL(prev); + return nextPreviewUrl; + }); + + const analyzed = await analyzeFaceApi(gender, file); + setResult(analyzed); + onComplete(analyzed); + alert('AI 얼굴 분석이 완료되었습니다.'); + } catch (error) { + if (error instanceof AxiosError) { + const message = (error.response?.data as { message?: string } | undefined)?.message; + alert(message || 'AI 얼굴 분석에 실패했습니다.'); + } else { + alert('AI 얼굴 분석에 실패했습니다.'); + } + } finally { + setIsAnalyzing(false); + } }; return (
+ +

AI 얼굴 분석하기

@@ -33,9 +91,10 @@ export function FaceAnalyze({ onSkip, onComplete }: Props) {
-

사진을 눌러 얼굴 분석하기

+

+ {isAnalyzing ? '분석 중입니다...' : '사진을 눌러 얼굴 분석하기'} +

+ + {result && ( +
+

{result.nickname || result.name || '분석 결과'}

+ {result.description ?

{result.description}

: null} +
+ )}
);