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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,6 @@ export const ImageField = ({ field, initialImages }: Props) => {
return (
<div className='flex-center py-6'>
<ImageInput
accept='image/*'
initialImages={initialImages}
maxFiles={1}
mode='replace'
Expand Down
Original file line number Diff line number Diff line change
@@ -1,26 +1,37 @@
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;
const fieldId = 'profile-mbti';
return (
<div className='mt-3 flex w-full flex-col gap-1'>
<Label htmlFor='post-meetup-title' required>
MBTI
</Label>

<Label htmlFor={fieldId}>MBTI</Label>
<Input
id={fieldId}
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,26 @@
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;
const fieldId = 'profile-message';
return (
<div className='mt-3 flex w-full flex-col gap-1'>
<Label htmlFor='post-meetup-title' required>
한 줄 소개
</Label>

<Label htmlFor={fieldId}>한 줄 소개</Label>
<Input
id={fieldId}
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,26 +1,29 @@
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;
const fieldId = 'profile-nickname';
return (
<div className='flex w-full flex-col gap-1'>
<Label htmlFor='post-meetup-title' required>
<Label htmlFor={fieldId} required>
닉네임
</Label>

<Input
id={fieldId}
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>
);
};
86 changes: 58 additions & 28 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,12 @@ export const ProfileEditModal = ({ user }: Props) => {
await updateUserImage({ file: imageFileObject });
}
close();
} catch (error) {
/*
todo: 이미지 변경과 정보 변경 중 하나라도 실패하면 각 항목에 대한 에러메시지 보여줘야함
*/
console.log('요청 실패', error);
} catch {
alert(`업데이트에 실패했습니다. 잠시 후 다시 시도해주세요`);
}
},
});

const isPending = isUserInfoPending || isUserImagePending;

return (
<ModalContent className='max-w-82.5'>
<ModalTitle>프로필 수정</ModalTitle>
Expand All @@ -92,17 +102,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
19 changes: 17 additions & 2 deletions src/components/ui/imageinput/index.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
import React, { useEffect, useRef, useState } from 'react';

import { IMAGE_CONFIG } from '@/lib/constants/image';
import { validateImage } from '@/lib/validateImage';

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

export interface ImageInputProps {
Expand All @@ -22,7 +25,7 @@ export const ImageInput = ({
children,
onChange,
maxFiles = 1,
accept = 'image/*',
accept = IMAGE_CONFIG.allowedTypes.join(','),
multiple = false,
mode = 'replace',
initialImages = [],
Expand Down Expand Up @@ -101,8 +104,20 @@ export const ImageInput = ({
updateImages(newImages);
};

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

for (const file of files) {
const validation = await 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글자를 모두 입력해주세요' },
);
Loading