Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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
Original file line number Diff line number Diff line change
@@ -1,26 +1,36 @@
import { AnyFieldApi } from '@tanstack/react-form';

import { Input, Label } from '@/components/ui';
import { Hint, Input, Label } from '@/components/ui';

interface Props {
field: AnyFieldApi;
}

export const MBTIField = ({ field }: Props) => {
const isInvalid = field.state.meta.isTouched && !field.state.meta.isValid;
return (
<div className='mt-3 flex w-full flex-col gap-1'>
<Label htmlFor='post-meetup-title' required>
MBTI
</Label>
<Label htmlFor='post-meetup-title'>MBTI</Label>

<Input
className='bg-mono-white focus:border-mint-500 rounded-2xl border border-gray-300'
maxLength={4}
placeholder='MBTI를 입력해주세요'
required
type='text'
value={field.state.value}
onChange={(e) => field.handleChange(e.target.value)}
onBlur={field.handleBlur}
onChange={(e) => {
field.handleChange(e.target.value);
field.setMeta((prev) => ({
...prev,
errorMap: {
...prev.errorMap,
onBlur: undefined,
},
}));
}}
/>
{isInvalid && <Hint message={field.state.meta.errors[0].message} />}
</div>
);
};
Original file line number Diff line number Diff line change
@@ -1,26 +1,25 @@
import { AnyFieldApi } from '@tanstack/react-form';

import { Input, Label } from '@/components/ui';
import { Hint, Input, Label } from '@/components/ui';

interface Props {
field: AnyFieldApi;
}

export const MessageField = ({ field }: Props) => {
const isInvalid = field.state.meta.isTouched && !field.state.meta.isValid;
return (
<div className='mt-3 flex w-full flex-col gap-1'>
<Label htmlFor='post-meetup-title' required>
한 줄 소개
</Label>
<Label htmlFor='post-meetup-title'>한 줄 소개</Label>

<Input
className='bg-mono-white focus:border-mint-500 rounded-2xl border border-gray-300'
placeholder='한 줄 소개를 입력해주세요'
required
type='text'
value={field.state.value}
onChange={(e) => field.handleChange(e.target.value)}
/>
{isInvalid && <Hint message={field.state.meta.errors[0].message} />}
</div>
);
};
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
import { AnyFieldApi } from '@tanstack/react-form';

import { Input, Label } from '@/components/ui';
import { Hint, Input, Label } from '@/components/ui';

interface Props {
field: AnyFieldApi;
}

export const NickNameField = ({ field }: Props) => {
const isInvalid = !field.state.meta.isValid;

return (
<div className='flex w-full flex-col gap-1'>
<Label htmlFor='post-meetup-title' required>
Expand All @@ -21,6 +23,7 @@ export const NickNameField = ({ field }: Props) => {
value={field.state.value}
onChange={(e) => field.handleChange(e.target.value)}
/>
{isInvalid && <Hint message={field.state.meta.errors[0].message} />}
</div>
);
};
90 changes: 64 additions & 26 deletions src/components/pages/user/profile/profile-edit-modal/index.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
'use client';
import { useForm } from '@tanstack/react-form';

import { API } from '@/api';
import {
Button,
ImageRecord,
Expand All @@ -11,6 +12,13 @@ import {
} from '@/components/ui';
import { useUpdateUser } from '@/hooks/use-user';
import { useUserImageUpdate } from '@/hooks/use-user/use-user-image-update';
import {
mbtiOnBlurSchema,
mbtiOnChangeSchema,
nickNameOnChangeSchema,
profileImageOnChangeSchema,
profileMessageOnChangeSchema,
} from '@/lib/schema/mypage';
import { UpdateMyInfoPayloads, User } from '@/types/service/user';

import { ImageField, MBTIField, MessageField, NickNameField } from '../profile-edit-fields';
Expand All @@ -24,16 +32,8 @@ export const ProfileEditModal = ({ user }: Props) => {

const { close } = useModal();

const {
mutateAsync: updateUser,
isPending: isUserInfoPending,
error: _userInfoError,
} = useUpdateUser();
const {
mutateAsync: updateUserImage,
isPending: isUserImagePending,
error: _userImageError,
} = useUserImageUpdate();
const { mutateAsync: updateUser, error: userInfoError } = useUpdateUser();
const { mutateAsync: updateUserImage, error: userImageError } = useUserImageUpdate();

const form = useForm({
defaultValues: {
Expand All @@ -42,15 +42,30 @@ export const ProfileEditModal = ({ user }: Props) => {
profileMessage,
mbti,
},
validators: {
onSubmitAsync: async ({ value }) => {
if (value.nickName === nickName) return null;
const res = await API.userService.getNicknameAvailability({ nickName: value.nickName });
if (!res.available) {
return {
form: '입력값을 확인해주세요',
fields: {
nickName: { message: '이미 사용 중인 닉네임입니다' },
},
};
}
return null;
},
},

onSubmit: async ({ value }) => {
const { profileImage, nickName, profileMessage, mbti } = value;

const nextMbti = mbti.toUpperCase();
// 프로필 항목 업데이트 조건 체크
const nextProfileInfo: UpdateMyInfoPayloads = {
...(user.nickName !== value.nickName && { nickName }),
...(user.profileMessage !== value.profileMessage && { profileMessage }),
...(user.mbti !== value.mbti && { mbti }),
...(user.nickName !== nickName && { nickName }),
...(user.profileMessage !== profileMessage && { profileMessage }),
...(user.mbti !== nextMbti && { mbti: nextMbti }),
};

/*
Expand All @@ -68,17 +83,20 @@ export const ProfileEditModal = ({ user }: Props) => {
await updateUserImage({ file: imageFileObject });
}
close();
} catch (error) {
} catch {
/*
todo: 이미지 변경과 정보 변경 중 하나라도 실패하면 각 항목에 대한 에러메시지 보여줘야함
이미지 변경과 정보 변경 중 하나라도 실패하면 각 항목에 대한 에러메시지 alert
todo: toast로 변경
*/
console.log('요청 실패', error);
const errors = [];
if (userInfoError) errors.push('사용자 정보');
if (userImageError) errors.push('프로필 이미지');

alert(`${errors.join(', ')} 업데이트에 실패했습니다. 다시 시도해주세요`);
}
Comment on lines 86 to 88
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

에러 상태 참조 시점 오류 - 항상 빈 배열이 됨

userInfoErroruserImageError는 컴포넌트 렌더링 시점의 값을 클로저로 캡처합니다. mutateAsync가 실패해도 catch 블록 내에서는 아직 이전 상태를 참조하므로, errors 배열은 항상 빈 상태가 됩니다.

🔎 권장 수정안
      try {
        if (Object.keys(nextProfileInfo).length > 0) {
          await updateUser(nextProfileInfo);
        }
        const imageFileObject = Object.values(profileImage)[0];
        if (imageFileObject) {
          await updateUserImage({ file: imageFileObject });
        }
        close();
-      } catch {
-        const errors = [];
-        if (userInfoError) errors.push('사용자 정보');
-        if (userImageError) errors.push('프로필 이미지');
-
-        alert(`${errors.join(', ')} 업데이트에 실패했습니다. 다시 시도해주세요`);
+      } catch (error) {
+        // mutateAsync는 실패 시 에러를 throw하므로 직접 처리
+        alert('프로필 업데이트에 실패했습니다. 다시 시도해주세요');
      }

또는 각 mutation을 개별적으로 try-catch하여 어떤 작업이 실패했는지 추적:

let userUpdateFailed = false;
let imageUpdateFailed = false;

if (Object.keys(nextProfileInfo).length > 0) {
  try {
    await updateUser(nextProfileInfo);
  } catch {
    userUpdateFailed = true;
  }
}

const imageFileObject = Object.values(profileImage)[0];
if (imageFileObject) {
  try {
    await updateUserImage({ file: imageFileObject });
  } catch {
    imageUpdateFailed = true;
  }
}

if (userUpdateFailed || imageUpdateFailed) {
  const errors = [];
  if (userUpdateFailed) errors.push('사용자 정보');
  if (imageUpdateFailed) errors.push('프로필 이미지');
  alert(`${errors.join(', ')} 업데이트에 실패했습니다. 다시 시도해주세요`);
} else {
  close();
}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
} catch {
/*
todo: 이미지 변경과 정보 변경 하나라도 실패하면 항목에 대한 에러메시지 보여줘야함
이미지 변경과 정보 변경 하나라도 실패하면 항목에 대한 에러메시지 alert
todo: toast로 변경
*/
console.log('요청 실패', error);
const errors = [];
if (userInfoError) errors.push('사용자 정보');
if (userImageError) errors.push('프로필 이미지');
alert(`${errors.join(', ')} 업데이트에 실패했습니다. 다시 시도해주세요`);
}
} catch (error) {
// mutateAsync는 실패 시 에러를 throw하므로 직접 처리
alert('프로필 업데이트에 실패했습니다. 다시 시도해주세요');
}
🤖 Prompt for AI Agents
In src/components/pages/user/profile/profile-edit-modal/index.tsx around lines
86 to 96, the catch block reads userInfoError and userImageError which are stale
values captured by the render closure so the errors array is always empty;
instead, track failure locally by wrapping each mutation in its own try/catch
(or set boolean flags immediately when a mutation throws) then build the errors
array from those flags and show the alert (and call close() only when neither
failed).

},
});

const isPending = isUserInfoPending || isUserImagePending;

return (
<ModalContent className='max-w-82.5'>
<ModalTitle>프로필 수정</ModalTitle>
Expand All @@ -92,17 +110,37 @@ export const ProfileEditModal = ({ user }: Props) => {
form.handleSubmit();
}}
>
<form.Field children={(field) => <ImageField field={field} />} name='profileImage' />
<form.Field children={(field) => <NickNameField field={field} />} name='nickName' />
<form.Field children={(field) => <MessageField field={field} />} name='profileMessage' />
<form.Field children={(field) => <MBTIField field={field} />} name='mbti' />
<form.Field
validators={{ onChange: profileImageOnChangeSchema }}
children={(field) => <ImageField field={field} />}
name='profileImage'
/>
Comment on lines +105 to +109
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

ImageFieldinitialImages prop 누락

관련 코드 스니펫(src/components/pages/user/profile/profile-edit-fields/image-field/index.tsx)에서 ImageFieldinitialImages prop을 받도록 정의되어 있습니다. 현재 프로필 이미지 URL을 전달하지 않으면 초기 이미지가 표시되지 않을 수 있습니다.

🔎 권장 수정안
        <form.Field
          validators={{ onChange: profileImageOnChangeSchema }}
-          children={(field) => <ImageField field={field} />}
+          children={(field) => <ImageField field={field} initialImages={[image]} />}
          name='profileImage'
        />

Committable suggestion skipped: line range outside the PR's diff.

🤖 Prompt for AI Agents
In src/components/pages/user/profile/profile-edit-modal/index.tsx around lines
113 to 117, the ImageField component is rendered without the required
initialImages prop so the current profile image won't be displayed; pass a prop
initialImages that supplies the existing profile image URL (e.g. an array
containing the currentProfileImage or form.initialValues.profileImage) to the
ImageField component (ensure you map undefined/null to an empty array) so the
field shows the initial image.

<form.Field
validators={{ onChange: nickNameOnChangeSchema }}
children={(field) => <NickNameField field={field} />}
name='nickName'
/>
<form.Field
validators={{ onChange: profileMessageOnChangeSchema }}
children={(field) => <MessageField field={field} />}
name='profileMessage'
/>
<form.Field
validators={{ onChange: mbtiOnChangeSchema, onBlur: mbtiOnBlurSchema }}
children={(field) => <MBTIField field={field} />}
name='mbti'
/>
<div className='mt-6 flex gap-2'>
<Button variant='tertiary' onClick={close}>
취소
</Button>
<Button disabled={isPending} type='submit'>
{isPending ? '수정 중...' : '수정하기'}
</Button>
<form.Subscribe selector={(state) => [state.canSubmit, state.isSubmitting]}>
{([canSubmit, isSubmitting]) => (
<Button disabled={!canSubmit} type='submit'>
{isSubmitting ? '수정 중...' : '수정하기'}
</Button>
)}
</form.Subscribe>
</div>
</form>
</ModalContent>
Expand Down
14 changes: 14 additions & 0 deletions src/components/ui/imageinput/index.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import React, { useEffect, useRef, useState } from 'react';

import { validateImage } from '@/lib/validateImage';

export type ImageRecord = Record<string, File | null>;

export interface ImageInputProps {
Expand Down Expand Up @@ -103,6 +105,18 @@ export const ImageInput = ({

const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const files = Array.from(e.target.files || []);

for (const file of files) {
const validation = validateImage(file);
if (!validation.valid) {
// toast.error(validation.error);
alert(validation.error);
e.target.value = '';
return;
}
}

// 검증통과 하면 이미지 추가
addImages(files);
e.target.value = '';
};
Expand Down
7 changes: 7 additions & 0 deletions src/lib/constants/image.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
export const IMAGE_CONFIG = {
maxSizeBytes: 20971520, // 20MB
maxWidth: 2000,
maxHeight: 2000,
allowedTypes: ['image/jpeg', 'image/png', 'image/webp'],
allowedExtensions: ['.jpg', '.jpeg', '.png', '.webp'],
};
36 changes: 36 additions & 0 deletions src/lib/schema/mypage.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { z } from 'zod';

export const profileImageOnChangeSchema = z.union([
z.object({ blobUrl: z.instanceof(File) }),
z.object({ Url: z.null() }),
z.object({}),
]);
Comment on lines +3 to +7
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Search for ImageRecord type definition
echo "=== ImageRecord 타입 정의 ==="
rg -n "type ImageRecord|interface ImageRecord" --type ts -A 2

echo -e "\n=== profileImageOnChangeSchema 사용처 ==="
rg -n "profileImageOnChangeSchema" --type ts -A 3

echo -e "\n=== profileImageOnChangeSchema 정의 주변 전체 내용 ==="
cat -n src/lib/schema/mypage.ts | head -40

Repository: WeGo-Together/WeGo_FrontEnd

Length of output: 2854


🏁 Script executed:

#!/bin/bash
# Find and examine ImageField component
echo "=== ImageField 컴포넌트 정의 찾기 ==="
rg -n "export.*ImageField|function ImageField" --type tsx -A 20

echo -e "\n=== imageinput/index.tsx 전체 내용 ==="
cat -n src/components/ui/imageinput/index.tsx

Repository: WeGo-Together/WeGo_FrontEnd

Length of output: 4769


🏁 Script executed:

#!/bin/bash
# Search for ImageField definition
echo "=== ImageField 컴포넌트 찾기 ==="
rg -n "ImageField|export.*ImageField" --type ts -B 2 -A 15 | head -100

echo -e "\n=== profile-edit-modal 구조 ==="
ls -la src/components/pages/user/profile/profile-edit-modal/

echo -e "\n=== field 관련 코드 확인 ==="
rg -n "class.*Field|export.*Field" src/components/pages/user/profile/profile-edit-modal/ --type ts -A 5

Repository: WeGo-Together/WeGo_FrontEnd

Length of output: 6433


🏁 Script executed:

#!/bin/bash
# Get full ImageField implementation
echo "=== ImageField 전체 코드 ==="
cat -n src/components/pages/user/profile/profile-edit-fields/image-field/index.tsx

echo -e "\n=== form.Field 사용 방식 확인 ==="
rg -n "form\.Field" src/components/pages/user/profile/profile-edit-modal/index.tsx -B 2 -A 5 | head -60

Repository: WeGo-Together/WeGo_FrontEnd

Length of output: 3337


🏁 Script executed:

#!/bin/bash
# Final confirmation: Check what the validator actually receives
echo "=== Confirm flow: ImageInput onChange -> field.handleChange -> validator ==="
echo "ImageInput.onChange signature:"
rg -n "onChange\?" src/components/ui/imageinput/index.tsx | grep -A2 "ImageInputProps"

echo -e "\n=== Confirm ImageRecord structure with blob URLs ==="
rg -n "URL.createObjectURL" src/components/ui/imageinput/index.tsx -B 2 -A 2

Repository: WeGo-Together/WeGo_FrontEnd

Length of output: 390


profileImageOnChangeSchema의 스키마 구조가 실제 데이터 구조와 완전히 불일치하여 검증 실패

ImageInput 컴포넌트가 field.handleChange에 전달하는 데이터는 Record<string, File | null> 형태이며, 키는 URL.createObjectURL()로 생성된 동적인 blob URL입니다 (예: { "blob:http://...": File }).

그러나 현재 스키마는 고정된 키 이름(blobUrl, Url)을 기대하고 있으므로 실제 데이터와 일치하지 않아 모든 검증이 실패합니다. 스키마를 z.record(z.string(), z.union([z.instanceof(File), z.null()])) 형태로 수정하여 동적 키를 허용하도록 변경해야 합니다.

🤖 Prompt for AI Agents
In src/lib/schema/mypage.ts around lines 3 to 7, the profileImageOnChangeSchema
expects fixed property names but the component sends a Record with dynamic
blob-URL keys; replace the current union of fixed objects with a record schema
that allows any string key and values that are either File instances or null
(e.g., z.record(z.string(), z.union([z.instanceof(File), z.null()]))) so
validation matches the actual Record<string, File | null> shape.


export const nickNameOnChangeSchema = z
.string()
.min(2, '닉네임은 2글자 이상이어야 합니다.')
.max(20, '닉네임은 20글자 이하여야 합니다.');

export const profileMessageOnChangeSchema = z
.string()
.max(20, '소개글은 20글자까지 작성 가능합니다.');

export const mbtiOnChangeSchema = z.string().refine(
(val) => {
if (val === '') return true;
if (val.length >= 1 && !['I', 'E', 'i', 'e'].includes(val[0])) return false;
if (val.length >= 2 && !['S', 'N', 's', 'n'].includes(val[1])) return false;
if (val.length >= 3 && !['T', 'F', 't', 'f'].includes(val[2])) return false;
if (val.length === 4 && !['J', 'P', 'j', 'p'].includes(val[3])) return false;
return true;
},
{ message: '유효한 MBTI가 아닙니다' },
);

export const mbtiOnBlurSchema = z.string().refine(
(val) => {
if (val === '') return true;
return val.length === 4 && /^[IEie][SNsn][TFtf][JPjp]$/.test(val);
},
{ message: 'MBTI 4글자를 모두 입력해주세요' },
);
34 changes: 34 additions & 0 deletions src/lib/validateImage.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { IMAGE_CONFIG } from './constants/image';

export const validateImage = (file: File): { valid: boolean; error?: string } => {
// 1. 파일 크기 검증
if (file.size > IMAGE_CONFIG.maxSizeBytes) {
const currentSizeMB = (file.size / (1024 * 1024)).toFixed(0);
return {
valid: false,
error: `이미지 크기가 너무 큽니다. 최대 20MB까지 가능합니다. \n(현재: ${currentSizeMB}MB)`,
};
}

// 2. Content Type 검증
if (!IMAGE_CONFIG.allowedTypes.includes(file.type)) {
const currentFileType = file.type.split('/')[1];
return {
valid: false,
error: `${IMAGE_CONFIG.allowedExtensions.join(', ')} 형식만 업로드 가능합니다. \n(현재: ${currentFileType})`,
};
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

file.type이 빈 문자열일 경우 에러 메시지가 불명확해질 수 있습니다.

브라우저가 파일 타입을 결정하지 못하면 file.type이 빈 문자열("")이 될 수 있습니다. 이 경우 file.type.split('/')[1]undefined를 반환하여 에러 메시지에 "(현재: undefined)"가 표시됩니다.

🔎 fallback 처리 제안
  // 2. Content Type 검증
  if (!IMAGE_CONFIG.allowedTypes.includes(file.type)) {
-   const currentFileType = file.type.split('/')[1];
+   const currentFileType = file.type ? file.type.split('/')[1] : '알 수 없음';
    return {
      valid: false,
      error: `${IMAGE_CONFIG.allowedExtensions.join(', ')} 형식만 업로드 가능합니다. \n(현재: ${currentFileType})`,
    };
  }
🤖 Prompt for AI Agents
In src/lib/validateImage.ts around lines 13 to 20, file.type can be an empty
string so file.type.split('/')[1] may be undefined and produce "(현재:
undefined)"; update the code to compute a safe currentFileType by first checking
if file.type is a non-empty string containing a slash and using the part after
the slash, otherwise try to fall back to the file extension from file.name (e.g.
last segment after '.') if present, and if neither is available use a clear
placeholder like "알 수 없음" — then use that safe value in the returned error
message.


// 3. 확장자 검증
const fileName = file.name.toLowerCase();
const hasValidExtension = IMAGE_CONFIG.allowedExtensions.some((ext) => fileName.endsWith(ext));

if (!hasValidExtension) {
return {
valid: false,
error: `파일 확장자가 올바르지 않습니다. \n(${IMAGE_CONFIG.allowedExtensions.join(', ')}만 가능)`,
};
}

return { valid: true };
};