diff --git a/src/components/pages/user/profile/profile-edit-fields/image-field/index.tsx b/src/components/pages/user/profile/profile-edit-fields/image-field/index.tsx index b2ae17f0..009363db 100644 --- a/src/components/pages/user/profile/profile-edit-fields/image-field/index.tsx +++ b/src/components/pages/user/profile/profile-edit-fields/image-field/index.tsx @@ -13,7 +13,6 @@ export const ImageField = ({ field, initialImages }: Props) => { return (
{ + const isInvalid = field.state.meta.isTouched && !field.state.meta.isValid; + const fieldId = 'profile-mbti'; return (
- - + 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 && }
); }; diff --git a/src/components/pages/user/profile/profile-edit-fields/message-field/index.tsx b/src/components/pages/user/profile/profile-edit-fields/message-field/index.tsx index 683045de..4bb8d1d5 100644 --- a/src/components/pages/user/profile/profile-edit-fields/message-field/index.tsx +++ b/src/components/pages/user/profile/profile-edit-fields/message-field/index.tsx @@ -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 (
- - + field.handleChange(e.target.value)} /> + {isInvalid && }
); }; diff --git a/src/components/pages/user/profile/profile-edit-fields/nickname-field/index.tsx b/src/components/pages/user/profile/profile-edit-fields/nickname-field/index.tsx index c1b8d326..6f3636c7 100644 --- a/src/components/pages/user/profile/profile-edit-fields/nickname-field/index.tsx +++ b/src/components/pages/user/profile/profile-edit-fields/nickname-field/index.tsx @@ -1,19 +1,21 @@ 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 (
-
); }; diff --git a/src/components/pages/user/profile/profile-edit-modal/index.tsx b/src/components/pages/user/profile/profile-edit-modal/index.tsx index 293415c4..9edc200e 100644 --- a/src/components/pages/user/profile/profile-edit-modal/index.tsx +++ b/src/components/pages/user/profile/profile-edit-modal/index.tsx @@ -1,6 +1,7 @@ 'use client'; import { useForm } from '@tanstack/react-form'; +import { API } from '@/api'; import { Button, ImageRecord, @@ -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'; @@ -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: { @@ -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 }), }; /* @@ -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 ( 프로필 수정 @@ -92,17 +102,37 @@ export const ProfileEditModal = ({ user }: Props) => { form.handleSubmit(); }} > - } name='profileImage' /> - } name='nickName' /> - } name='profileMessage' /> - } name='mbti' /> + } + name='profileImage' + /> + } + name='nickName' + /> + } + name='profileMessage' + /> + } + name='mbti' + />
- + [state.canSubmit, state.isSubmitting]}> + {([canSubmit, isSubmitting]) => ( + + )} +
diff --git a/src/components/ui/imageinput/index.tsx b/src/components/ui/imageinput/index.tsx index e2f81b98..7b63fdfa 100644 --- a/src/components/ui/imageinput/index.tsx +++ b/src/components/ui/imageinput/index.tsx @@ -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; export interface ImageInputProps { @@ -22,7 +25,7 @@ export const ImageInput = ({ children, onChange, maxFiles = 1, - accept = 'image/*', + accept = IMAGE_CONFIG.allowedTypes.join(','), multiple = false, mode = 'replace', initialImages = [], @@ -101,8 +104,20 @@ export const ImageInput = ({ updateImages(newImages); }; - const handleFileChange = (e: React.ChangeEvent) => { + const handleFileChange = async (e: React.ChangeEvent) => { 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 = ''; }; diff --git a/src/lib/constants/image.ts b/src/lib/constants/image.ts new file mode 100644 index 00000000..ac267a6b --- /dev/null +++ b/src/lib/constants/image.ts @@ -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'], +}; diff --git a/src/lib/schema/mypage.ts b/src/lib/schema/mypage.ts new file mode 100644 index 00000000..c3e245c9 --- /dev/null +++ b/src/lib/schema/mypage.ts @@ -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({}), +]); + +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글자를 모두 입력해주세요' }, +); diff --git a/src/lib/validateImage.ts b/src/lib/validateImage.ts new file mode 100644 index 00000000..e03ffa0a --- /dev/null +++ b/src/lib/validateImage.ts @@ -0,0 +1,56 @@ +import { IMAGE_CONFIG } from './constants/image'; + +export const validateImage = async (file: File): Promise<{ valid: boolean; error?: string }> => { + // 1. 확장자 검증 + 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(', ')}만 가능)`, + }; + } + + // 2. 파일 크기 검증 + if (file.size > IMAGE_CONFIG.maxSizeBytes) { + const currentSizeMB = (file.size / (1024 * 1024)).toFixed(0); + return { + valid: false, + error: `이미지 크기가 너무 큽니다. 최대 20MB까지 가능합니다. \n현재: ${currentSizeMB}MB`, + }; + } + + // 3. 파일 사이즈 검증 + const { width, height } = await getImageDimensions(file); + if (width > IMAGE_CONFIG.maxWidth || height > IMAGE_CONFIG.maxHeight) { + return { + valid: false, + error: `이미지는 ${IMAGE_CONFIG.maxWidth}x${IMAGE_CONFIG.maxHeight} 이하여야 합니다. \n현재: 너비(${width}), 높이(${height})`, + }; + } + + return { valid: true }; +}; + +const getImageDimensions = async (file: File): Promise<{ width: number; height: number }> => { + return new Promise((resolve, reject) => { + const img = new Image(); + const url = URL.createObjectURL(file); + + img.onload = () => { + URL.revokeObjectURL(url); // 메모리 해제 + resolve({ + width: img.width, + height: img.height, + }); + }; + + img.onerror = () => { + URL.revokeObjectURL(url); + reject(new Error('이미지 로드 실패')); + }; + + img.src = url; + }); +};