diff --git a/app/(my)/layout.tsx b/app/(my)/layout.tsx index 76a6fae5..c4c054fd 100644 --- a/app/(my)/layout.tsx +++ b/app/(my)/layout.tsx @@ -14,7 +14,9 @@ export default function MyLayout({ return (
-
{children}
+
+ {children} +
); } diff --git a/app/global.css b/app/global.css index bcbecc55..74119091 100644 --- a/app/global.css +++ b/app/global.css @@ -185,7 +185,7 @@ https://velog.io/@oneook/tailwindcss-4.0-%EB%AC%B4%EC%97%87%EC%9D%B4-%EB%8B%AC%E --color-background-alternative: var(--color-gray-50); --color-background-default: var(--color-gray-0); - --color-background-dimmer: rgba(24, 29, 39, 80); + --color-background-dimmer: rgba(24, 29, 39, 0.8); --color-background-disabled: var(--color-gray-100); --color-background-brand-subtle: var(--color-rose-300); diff --git a/public/profile-default.jpg b/public/profile-default.jpg new file mode 100644 index 00000000..46ce5400 Binary files /dev/null and b/public/profile-default.jpg differ diff --git a/src/features/auth/api/auth.ts b/src/features/auth/api/auth.ts index a055640c..26828943 100644 --- a/src/features/auth/api/auth.ts +++ b/src/features/auth/api/auth.ts @@ -19,13 +19,10 @@ export async function uploadProfileImage( filename: string, file: FormData, ) { - console.log('uploadProfileImage 요청직전', file); - // Bug : 여기서 에러발생중 const res = await axiosInstanceForMultipart.put( `/files/members/${memberId}/profile/image/${filename}`, file, ); - console.log('auth.ts - uploadProfileImage 응답', res); return res.data; } diff --git a/src/features/auth/ui/sign-up-image-selector.tsx b/src/features/auth/ui/sign-up-image-selector.tsx index 4e7fe757..8a8f9ce0 100644 --- a/src/features/auth/ui/sign-up-image-selector.tsx +++ b/src/features/auth/ui/sign-up-image-selector.tsx @@ -14,13 +14,13 @@ export default function SignupImageSelector({ handleImageChange: (event: React.ChangeEvent) => void; }) { const setDefaultImage = () => setImage('/profile-default.svg'); - const openFileDialog = () => fileInputRef.current?.click(); + const openFileFolder = () => fileInputRef.current?.click(); return (
프로필 - next최적화안쓴프로필 + next최적화안쓴프로필
@@ -44,7 +44,7 @@ export default function SignupImageSelector({ 앨범에서 선택 @@ -53,8 +53,9 @@ export default function SignupImageSelector({
diff --git a/src/features/my-page/consts/my-page-const.ts b/src/features/my-page/consts/my-page-const.ts index 15b20a72..5f909266 100644 --- a/src/features/my-page/consts/my-page-const.ts +++ b/src/features/my-page/consts/my-page-const.ts @@ -29,3 +29,5 @@ export const MBTI_OPTIONS = [ { label: 'ENFJ', value: 'ENFJ' }, { label: 'ENTJ', value: 'ENTJ' }, ]; + +export const DEFAULT_PROFILE_IMAGE_URL = '/profile-default.svg'; diff --git a/src/features/my-page/ui/profile-edit-modal.tsx b/src/features/my-page/ui/profile-edit-modal.tsx index 02cdb8a0..9c26e4a8 100644 --- a/src/features/my-page/ui/profile-edit-modal.tsx +++ b/src/features/my-page/ui/profile-edit-modal.tsx @@ -10,57 +10,82 @@ import Button from '@/shared/ui/button'; import { Modal } from '@/shared/ui/modal'; import { FormField } from '../../../shared/ui/form/form-field'; import { UpdateUserProfileRequest } from '../api/types'; -import { updateUserProfile } from '../api/update-user-profile'; -import { DEFAULT_OPTIONS, MBTI_OPTIONS } from '../consts/my-page-const'; +import { + DEFAULT_OPTIONS, + DEFAULT_PROFILE_IMAGE_URL, + MBTI_OPTIONS, +} from '../consts/my-page-const'; +import { useUpdateUserProfileMutation } from '../model/use-update-user-profile-mutation'; interface Props { - onSubmit: (formData: UpdateUserProfileRequest) => void; memberProfile: MemberProfile; memberId: number; } export type MbtiValue = (typeof MBTI_OPTIONS)[number]['value']; -export default function ProfileEditModal({ - onSubmit, - memberProfile, - memberId, -}: Props) { - const [name, setName] = useState( - memberProfile.memberName ?? '', - ); - const [tel, setTel] = useState( - memberProfile.tel ?? '', - ); - const [githubLink, setGithubLink] = useState< - UpdateUserProfileRequest['githubLink'] - >(memberProfile.githubLink?.url ?? ''); - - const [blogOrSnsLink, setBlogOrSnsLink] = useState< - UpdateUserProfileRequest['blogOrSnsLink'] - >(memberProfile.blogOrSnsLink?.url ?? ''); +export default function ProfileEditModal({ memberProfile, memberId }: Props) { + const [isOpen, setIsOpen] = useState(false); - const [mbti, setMbti] = useState( - memberProfile.mbti ?? '', + return ( + + setIsOpen(true)} + className="rounded-100 bg-fill-brand-default-default font-designer-16b text-text-inverse w-full px-150 py-100" + > + 내 프로필 수정 + + + + + +
+ 내 프로필 수정 + + + +
+
+ setIsOpen(false)} + /> +
+
+
); +} - const [simpleIntroduction, setSimpleIntroduction] = useState< - UpdateUserProfileRequest['simpleIntroduction'] - >(memberProfile.simpleIntroduction ?? ''); - - const [interests, setInterests] = useState< - UpdateUserProfileRequest['interests'] - >(memberProfile.interests?.map((item) => item.name) ?? []); +function ProfileEditForm({ + memberProfile, + memberId, + onClose, +}: Props & { onClose: () => void }) { + const fileInputRef = useRef(null); + const [profileForm, setProfileForm] = useState({ + name: memberProfile.memberName ?? '', + tel: memberProfile.tel ?? '', + githubLink: memberProfile.githubLink?.url ?? '', + blogOrSnsLink: memberProfile.blogOrSnsLink?.url ?? '', + mbti: memberProfile.mbti ?? '', + simpleIntroduction: memberProfile.simpleIntroduction ?? '', + interests: memberProfile.interests?.map((item) => item.name) ?? [], + }); const [image, setImage] = useState( memberProfile.profileImage?.resizedImages?.[0]?.resizedImageUrl ?? - '/profile-default.svg', + DEFAULT_PROFILE_IMAGE_URL, ); - const [isOpen, setIsOpen] = useState(false); - const fileInputRef = useRef(null); - const uploadProfileImage = useUploadProfileImageMutation(); + // 이름 유효성 검사: 2~10자, 한글 또는 영문만 허용 + const isNameValid = /^[가-힣a-zA-Z]{2,10}$/.test(profileForm.name); + // 연락처 유효성 검사: "(2~3자리 지역번호)-(3~4자리 번호)-(4자리 번호)" 형식 + const isTelValid = /^\d{2,3}-\d{3,4}-\d{4}$/.test(profileForm.tel); + const queryClient = useQueryClient(); + const { mutateAsync: updateProfile } = useUpdateUserProfileMutation(memberId); + const { mutateAsync: uploadProfileImage } = useUploadProfileImageMutation(); const handleImageChange = (e: React.ChangeEvent) => { if (e.target.files && e.target.files[0]) { @@ -70,45 +95,63 @@ export default function ProfileEditModal({ }; const handleSubmit = async () => { - if (!name || !tel) { - alert('모든 필수 정보를 입력해주세요!'); - - return; - } - const file = fileInputRef.current?.files?.[0]; - - const hasImageFile = !!file; - const ext = file?.name.split('.').pop()?.toUpperCase(); - const profileImageExtension = - ext && ['JPG', 'PNG', 'GIF', 'WEBP'].includes(ext) ? ext : undefined; + const profileImageExtension = file?.name.split('.').pop(); const rawFormData: UpdateUserProfileRequest = { - name, - tel, - githubLink: githubLink.trim() || undefined, - blogOrSnsLink: blogOrSnsLink.trim() || undefined, - simpleIntroduction: simpleIntroduction.trim() || undefined, - mbti: mbti.trim() || undefined, - interests: interests.length > 0 ? interests : undefined, - profileImageExtension: hasImageFile ? profileImageExtension : undefined, + name: profileForm.name, + tel: profileForm.tel, + githubLink: profileForm.githubLink, + blogOrSnsLink: profileForm.blogOrSnsLink, + simpleIntroduction: profileForm.simpleIntroduction.trim() || undefined, + mbti: profileForm.mbti || undefined, + interests: + profileForm.interests.length > 0 ? profileForm.interests : undefined, + profileImageExtension: profileImageExtension, }; const formData = Object.fromEntries( Object.entries(rawFormData).filter(([_, v]) => v !== undefined), ) as UpdateUserProfileRequest; - const updated = await updateUserProfile(memberId, formData); + const updatedProfile = await updateProfile(formData); + + // todo: 서버 측에서 api 수정되면, 리팩토링 + // 기본 프로필 이미지를 선택할 경우, 기본 프로필 이미지 전송 -> 서버 측에서 나중에 리팩토링 예정 + // public 폴더의 profile-default.jpg 파일을 fetch해서 FormData에 추가 + // profile-default.jpg를 추가한 이유는 서버에서 프로필 이미지 확장자에 svg 파일을 고려하지 못함 -> 서버 측에서 리팩토링 예정 + if (image === DEFAULT_PROFILE_IMAGE_URL) { + try { + const defaultProfileImage = 'profile-default.jpg'; + const response = await fetch(defaultProfileImage); + const blob = await response.blob(); + const defaultFile = new File([blob], defaultProfileImage, { + type: 'image/jpeg', + }); + const imageFormData = new FormData(); + imageFormData.append('file', defaultFile); + + await uploadProfileImage({ + memberId, + filename: defaultProfileImage, + file: imageFormData, + }); + } catch (error) { + console.error('기본 프로필 이미지 업로드 실패:', error); + alert('기본 프로필 이미지 업로드에 실패했습니다.'); + } + } - if (fileInputRef.current?.files?.[0] && updated.profileImageUploadUrl) { + // 폴더에서 이미지를 선택한 경우 + if (file && updatedProfile.profileImageUploadUrl) { const imageFormData = new FormData(); - imageFormData.append('file', fileInputRef.current.files[0]); + imageFormData.append('file', file); - const filename = updated.profileImageUploadUrl.split('/').pop(); + const filename = updatedProfile.profileImageUploadUrl.split('/').pop(); if (!filename) return; try { - await uploadProfileImage.mutateAsync({ + await uploadProfileImage({ memberId, filename, file: imageFormData, @@ -118,119 +161,132 @@ export default function ProfileEditModal({ alert('이미지 업로드에 실패했습니다.'); } } - await queryClient.invalidateQueries({ queryKey: ['memberInfo'] }); - onSubmit(formData); + await queryClient.invalidateQueries({ + queryKey: ['userProfile', memberId], + }); }; return ( - - setIsOpen(true)} - className="rounded-100 bg-fill-brand-default-default font-designer-16b text-text-inverse w-full px-150 py-100" - > - 내 프로필 수정 - - - - - -
- 내 프로필 수정 - - - -
-
- -
-
-
- 이미지 설정 -
- -
- - - - - - - -
-
- -
- - -
-
-
-
-
+ <> + +
+
+
이미지 설정
+ +
+ { + // 공백 입력하지 못하도록 제한 + setProfileForm({ + ...profileForm, + name: value.replace(/\s/g, ''), + }); + }} + required + /> + { + // 숫자와 하이픈(-)만 입력 허용 + const onlyNumberAndHyphen = value.replace(/[^\d-]/g, ''); + setProfileForm({ ...profileForm, tel: onlyNumberAndHyphen }); + }} + required + /> + + setProfileForm({ + ...profileForm, + githubLink: value.replace(/\s/g, ''), + }) + } + /> + + setProfileForm({ ...profileForm, mbti: value }) + } + options={MBTI_OPTIONS} + /> + + setProfileForm({ ...profileForm, interests: value }) + } + options={DEFAULT_OPTIONS} + /> + + setProfileForm({ ...profileForm, simpleIntroduction: value }) + } + /> + + setProfileForm({ + ...profileForm, + blogOrSnsLink: value.replace(/\s/g, ''), + }) + } + /> +
+
+ +
+ + +
+
+ ); } diff --git a/src/features/my-page/ui/profile.tsx b/src/features/my-page/ui/profile.tsx index d31c1fa4..811cbc3f 100644 --- a/src/features/my-page/ui/profile.tsx +++ b/src/features/my-page/ui/profile.tsx @@ -1,12 +1,8 @@ -'use client'; - import Image from 'next/image'; import { MemberProfile } from '@/entities/user/api/types'; import ProfileEditModal from '@/features/my-page/ui/profile-edit-modal'; import UserAvatar from '@/shared/ui/avatar'; import Badge from '@/shared/ui/badge'; -import { UpdateUserProfileRequest } from '../api/types'; -import { useUpdateUserProfileMutation } from '../model/use-update-user-profile-mutation'; interface ProfileProps { memberId: number; @@ -14,12 +10,6 @@ interface ProfileProps { } export default function Profile({ memberId, memberProfile }: ProfileProps) { - const { mutate } = useUpdateUserProfileMutation(memberId); - - const handleSubmit = (formData: UpdateUserProfileRequest) => { - mutate(formData); - }; - return (
- {memberProfile.githubLink?.url ?? '깃허브 링크를 입력해주세요!'} + {memberProfile.githubLink?.url || '깃허브 링크를 입력해주세요!'}
- {memberProfile.blogOrSnsLink?.url ?? + {memberProfile.blogOrSnsLink?.url || '블로그 링크를 입력해주세요!'}
- + ); diff --git a/src/shared/lib/get-login-user.ts b/src/shared/lib/get-login-user.ts index 58110f4b..cdceb3a5 100644 --- a/src/shared/lib/get-login-user.ts +++ b/src/shared/lib/get-login-user.ts @@ -1,6 +1,6 @@ -import { getServerCookie, setServerCookie } from './server-cookie'; import axios from 'axios'; import { redirect } from 'next/navigation'; +import { getServerCookie, setServerCookie } from './server-cookie'; export async function getLoginUserId(): Promise { // memberId 쿠키 우선 확인 (기존 로직) @@ -17,7 +17,7 @@ export async function getLoginUserId(): Promise { // 1. accessToken 쿠키 확인 let accessToken = await getServerCookie('accessToken'); if (!accessToken) return null; - + // 2. accessToken으로 /auth/me 호출 try { // (SSR 이므로 useQuery 를 쓰지 않고 직접 API 호출) @@ -26,20 +26,19 @@ export async function getLoginUserId(): Promise { { headers: { Authorization: `Bearer ${accessToken}` }, withCredentials: true, - } + }, ); // 3. memberId 쿠키와 /auth/me의 memberId가 다르면 null 반환 if (Number(res.data.content) !== memberId) return null; return memberId; } catch (error: any) { - // 4. 401이면 만료된 토큰이므로 토큰 갱신 시도 if (error.response?.status === 401) { try { const refreshRes = await axios.get( `${process.env.NEXT_PUBLIC_API_BASE_URL}/api/v1/auth/access-token/refresh`, - { withCredentials: true } + { withCredentials: true }, ); accessToken = refreshRes.data.accessToken; // 5. SSR에서 최신화된 AccessToken을 쿠키등록 @@ -51,10 +50,10 @@ export async function getLoginUserId(): Promise { { headers: { Authorization: `Bearer ${accessToken}` }, withCredentials: true, - } + }, ); if (Number(res2.data.memberId) !== memberId) return null; - + return memberId; } catch (refreshError) { // 갱신 실패 → 로그인 페이지로 리다이렉트 diff --git a/src/shared/ui/form/form-field.tsx b/src/shared/ui/form/form-field.tsx index f617a5d6..737f3edf 100644 --- a/src/shared/ui/form/form-field.tsx +++ b/src/shared/ui/form/form-field.tsx @@ -21,10 +21,12 @@ interface FormFieldProps { value: T; direction?: 'horizontal' | 'vertical'; onChange: (value: T) => void; + error?: boolean; } export function FormField({ label, + error = false, description, type, required = false, @@ -42,10 +44,13 @@ export function FormField({ onChange(e.target.value as T)} /> {description && ( -
+
{description}
)}