diff --git a/next.config.ts b/next.config.ts index 0a3dfb23..298d1f4e 100644 --- a/next.config.ts +++ b/next.config.ts @@ -21,6 +21,12 @@ const nextConfig: NextConfig = { protocol: 'https', hostname: 'plus.unsplash.com', }, + { + protocol: 'https', + hostname: 'we-go-bucket.s3.ap-northeast-2.amazonaws.com', + port: '', + pathname: '/**', + }, ], //imagesSizes, deviceSizes는 기본 설정 imageSizes: [16, 32, 48, 64, 96, 128, 256, 384], diff --git a/src/api/service/user-service/index.ts b/src/api/service/user-service/index.ts index be48952e..132aad6c 100644 --- a/src/api/service/user-service/index.ts +++ b/src/api/service/user-service/index.ts @@ -1,46 +1,67 @@ import { api } from '@/api/core'; import { - FollowParams, - GetUserParams, - UpdateMePayload, - UpdateMyImagePayload, - UpdateMyNotiParams, + Availability, + FollowPathParams, + GetEmailAvailabilityQueryParams, + GetNicknameAvailabilityQueryParams, + GetUserPathParams, + UnfollowQueryParams, + UpdateMyImagePayloads, + UpdateMyInfoPayloads, + UpdateMyNotificationQueryParams, User, } from '@/types/service/user'; export const userServiceRemote = () => ({ - // 2. 프로필 편집 - updateMe: async (payload: UpdateMePayload) => { - return api.patch('/users', payload); + // 1. 사용자 팔로우 + followUser: async (pathParams: FollowPathParams) => { + return api.post(`/users/follow`, null, { + params: { followNickname: pathParams.followNickname }, + }); }, - // 3. 프로필 이미지 편집 - updateMyImage: async (payload: UpdateMyImagePayload) => { - return api.patch(`/users/profile-image`, payload); + // 2. 유저 프로필 변경 + updateMyInfo: async (payloads: UpdateMyInfoPayloads) => { + return api.patch('/users/profile', payloads); }, - // 4. 알림 설정 변경 - updatMyNotification: async (payload: UpdateMyNotiParams) => { - return api.patch(`/users/notification/${payload.isNotificationEnabled}`); + // 3. 프로필 이미지 변경 + updateMyImage: async (payloads: UpdateMyImagePayloads) => { + const formData = new FormData(); + formData.append('file', payloads.file); + return api.patch(`/users/profile-image`, formData); }, - // 5. 사용자 단건 조회 - getUser: async (payload: GetUserParams) => { - return api.get(`/users/${payload.userId}`); + // 4. 알림 설정 변경 + updateMyNotification: async (queryParams: UpdateMyNotificationQueryParams) => { + return api.patch( + `/users/notification?isNotificationEnabled=${queryParams.isNotificationEnabled}`, + ); }, - // 1. 사용자 팔로우 - followUser: async (payload: FollowParams) => { - return api.post(`/follows/${payload.followNickname}`); + // 5. 유저 프로필 조회 + getUser: async (pathParams: GetUserPathParams) => { + return api.get(`/users/${pathParams.userId}`); }, - // 6. 사용자 언팔로우 - unfollowUser: async (payload: FollowParams) => { - return api.delete(`/follows/${payload.followNickname}`); + // 6. 닉네임 중복 검사 + getNicknameAvailability: async (queryParams: GetNicknameAvailabilityQueryParams) => { + return api.get(`/users/nickname/availability`, { + params: { nickname: queryParams.nickName }, + }); }, - // 7. 회원탈퇴 - deleteMe: async () => api.delete(`/users`), + // 7. 이메일 중복 검사 + getEmailAvailability: async (queryParams: GetEmailAvailabilityQueryParams) => { + return api.get(`/users/email/availability`, { + params: { email: queryParams.email }, + }); + }, - // 8. 사용자 프로필 이미지 변경 + // 8. 사용자 언팔로우 + unfollowUser: async (params: UnfollowQueryParams) => { + return api.delete(`/users/unfollow`, { + params: { unFollowNickname: params.unFollowNickname }, + }); + }, }); diff --git a/src/app/(user)/mypage/page.tsx b/src/app/(user)/mypage/page.tsx index f24a68ce..fe2e0103 100644 --- a/src/app/(user)/mypage/page.tsx +++ b/src/app/(user)/mypage/page.tsx @@ -9,7 +9,6 @@ import { useGetUser } from '@/hooks/use-user'; const MyPage = () => { // const [userId, setUserId] = useState(0); const [userId, setUserId] = useState(0); - // 여기서 user 정보를 확인해서 undefined이면 로그인페이지로 리다이렉트 const { data: user } = useGetUser({ userId }, { enabled: !!userId }); diff --git a/src/app/(user)/profile/[userId]/page.test.tsx b/src/app/(user)/profile/[userId]/page.test.tsx index 075956f3..fecada4d 100644 --- a/src/app/(user)/profile/[userId]/page.test.tsx +++ b/src/app/(user)/profile/[userId]/page.test.tsx @@ -73,7 +73,7 @@ describe('프로필 페이지 테스트', () => { return HttpResponse.json( createMockSuccessResponse({ ...mockUserItems[1], - isFollowing: false, + isFollow: false, }), ); }), @@ -93,7 +93,7 @@ describe('프로필 페이지 테스트', () => { return HttpResponse.json( createMockSuccessResponse({ ...mockUserItems[1], - isFollowing: true, + isFollow: true, }), ); }), diff --git a/src/components/pages/user/mypage/mypage-setting/index.tsx b/src/components/pages/user/mypage/mypage-setting/index.tsx index 6e461537..a23e2384 100644 --- a/src/components/pages/user/mypage/mypage-setting/index.tsx +++ b/src/components/pages/user/mypage/mypage-setting/index.tsx @@ -1,6 +1,5 @@ 'use client'; -import { useState } from 'react'; - +import { useUpdateMyNotification } from '@/hooks/use-user/use-user-notification'; import { User } from '@/types/service/user'; import { MyPageActionButton, MyPageToggleButton } from '../mypage-setting-button'; @@ -10,13 +9,14 @@ interface Props { } export const MyPageSetting = ({ user }: Props) => { - console.log(user); - // useState 로직은 추후 삭제 예정 - const [isOn, setIsOn] = useState(false); + const { mutate } = useUpdateMyNotification(); return (
- setIsOn((prev) => !prev)}> + mutate({ isNotificationEnabled: !user.isNotificationEnabled })} + > 알림 받기 console.log('로그아웃')}>로그아웃 diff --git a/src/components/pages/user/profile/profile-card/index.tsx b/src/components/pages/user/profile/profile-card/index.tsx index 65c70b70..a0f485db 100644 --- a/src/components/pages/user/profile/profile-card/index.tsx +++ b/src/components/pages/user/profile/profile-card/index.tsx @@ -1,5 +1,4 @@ -import Image from 'next/image'; - +import { ImageWithFallback } from '@/components/ui'; import { User } from '@/types/service/user'; interface Props { @@ -11,7 +10,13 @@ export const ProfileCard = ({ user }: Props) => { return (
- 프로필 이미지 +

{nickName}

{profileMessage}

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 b3dae99a..b2ae17f0 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 @@ -1,9 +1,7 @@ -import Image from 'next/image'; - import { AnyFieldApi } from '@tanstack/react-form'; import { Icon } from '@/components/icon'; -import { ImageInput, ImageInputProps } from '@/components/ui'; +import { ImageInput, ImageInputProps, ImageWithFallback } from '@/components/ui'; import { cn } from '@/lib/utils'; type ImageUploadPropsWithoutChildren = Omit; @@ -11,7 +9,7 @@ type ImageUploadPropsWithoutChildren = Omit; interface Props extends ImageUploadPropsWithoutChildren { field: AnyFieldApi; } -const ImageField = ({ field, initialImages }: Props) => { +export const ImageField = ({ field, initialImages }: Props) => { return (
{ value={field.state.value} onChange={field.handleChange} > - {(images, _onRemoveImageClick, onFileSelectClick) => ( - <> - {Object.entries(images).map(([url, _file]) => ( -
- 프로필 이미지 - -
- ))} - - )} + {(images, _onRemoveImageClick, onFileSelectClick) => { + const nextImages = Object.keys(images).length > 0 ? images : { '': null }; + return ( + <> + {Object.entries(nextImages).map(([url, _file]) => ( +
+ + +
+ ))} + + ); + }}
); }; - -export default ImageField; diff --git a/src/components/pages/user/profile/profile-edit-fields/index.ts b/src/components/pages/user/profile/profile-edit-fields/index.ts new file mode 100644 index 00000000..6000ce32 --- /dev/null +++ b/src/components/pages/user/profile/profile-edit-fields/index.ts @@ -0,0 +1,4 @@ +export { ImageField } from './image-field'; +export { MBTIField } from './mbti-field'; +export { MessageField } from './message-field'; +export { NickNameField } from './nickname-field'; 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 2e1ecceb..7a75ddda 100644 --- a/src/components/pages/user/profile/profile-edit-modal/index.tsx +++ b/src/components/pages/user/profile/profile-edit-modal/index.tsx @@ -9,12 +9,11 @@ import { ModalTitle, useModal, } from '@/components/ui'; -import { User } from '@/types/service/user'; +import { useUpdateUser } from '@/hooks/use-user'; +import { useUserImageUpdate } from '@/hooks/use-user/use-user-image-update'; +import { UpdateMyInfoPayloads, User } from '@/types/service/user'; -import ImageField from '../profile-edit-fields/image-field'; -import { MBTIField } from '../profile-edit-fields/mbti-field'; -import { MessageField } from '../profile-edit-fields/message-field'; -import { NickNameField } from '../profile-edit-fields/nickname-field'; +import { ImageField, MBTIField, MessageField, NickNameField } from '../profile-edit-fields'; interface Props { user: User; @@ -25,21 +24,66 @@ 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 form = useForm({ defaultValues: { - profileImage: { - [image]: null, - } as ImageRecord, + profileImage: { [image]: null } as ImageRecord, nickName, profileMessage, mbti, }, + onSubmit: async ({ value }) => { - console.log(value); - close(); + const { profileImage, nickName, profileMessage, mbti } = value; + + // 프로필 항목 업데이트 조건 체크 + const nextProfileInfo: UpdateMyInfoPayloads = { + ...(user.nickName !== value.nickName && { nickName }), + ...(user.profileMessage !== value.profileMessage && { profileMessage }), + ...(user.mbti !== value.mbti && { mbti }), + }; + + const promises = []; + + // 프로필 정보 업데이트 조건 체크 + if (Object.keys(nextProfileInfo).length > 0) { + promises.push(updateUser(nextProfileInfo)); + } + + // 프로필 이미지 업데이트 조건 체크 + const imageFileObject = Object.values(profileImage)[0]; + if (imageFileObject) { + promises.push(updateUserImage({ file: imageFileObject })); + } + + /* + Promise 체이닝 사용 시 catch를 먹어버리기 때문에 각 mutation의 error가 업데이트 되지않음 + 따라서 try catch 방식 사용 + */ + /* + todo: 이미지 변경과 정보 변경 중 하나라도 실패하면 각 항목에 대한 에러메시지 보여줘야함 + */ + try { + await Promise.all(promises); + close(); + } catch (error) { + console.log('요청 실패', error); + } }, }); + const isPending = isUserInfoPending || isUserImagePending; + return ( 프로필 수정 @@ -61,7 +105,9 @@ export const ProfileEditModal = ({ user }: Props) => { - +
diff --git a/src/components/pages/user/profile/profile-info/index.tsx b/src/components/pages/user/profile/profile-info/index.tsx index bdfa1e62..e6121901 100644 --- a/src/components/pages/user/profile/profile-info/index.tsx +++ b/src/components/pages/user/profile/profile-info/index.tsx @@ -1,5 +1,6 @@ 'use client'; import { Button } from '@/components/ui'; +import { useFollowUser, useUnfollowUser } from '@/hooks/use-user'; import { User } from '@/types/service/user'; import { ProfileCard } from '../profile-card'; @@ -11,15 +12,25 @@ interface Props { } export const ProfileInfo = ({ user }: Props) => { - const handleFollowClick = () => {}; + const { mutate: followUser } = useFollowUser(); + + const { mutate: unfollowUser } = useUnfollowUser(); + + const handleFollowClick = () => { + followUser({ followNickname: user.nickName }); + }; + + const handleUnfollowClick = () => { + unfollowUser({ unFollowNickname: user.nickName }); + }; return (
- {!user.isFollowing && } - {user.isFollowing && ( - } + {user.isFollow && ( + )} diff --git a/src/components/ui/image-with-fallback/index.tsx b/src/components/ui/image-with-fallback/index.tsx new file mode 100644 index 00000000..e1cf3021 --- /dev/null +++ b/src/components/ui/image-with-fallback/index.tsx @@ -0,0 +1,36 @@ +'use client'; + +import Image, { ImageProps } from 'next/image'; + +import { useEffect, useState } from 'react'; + +interface ImageWithFallbackProps extends Omit { + src: string; + fallbackSrc?: string; +} + +export const ImageWithFallback = ({ + src, + fallbackSrc = 'https://plus.unsplash.com/premium_photo-1738592736106-a17b897c0ab1?q=80&w=1934&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D', + ...rest +}: ImageWithFallbackProps) => { + const [error, setError] = useState(false); + + useEffect(() => { + // eslint-disable-next-line react-hooks/set-state-in-effect + setError(false); + }, [src]); + + const imgSrc = error || !src || src === 'null' ? fallbackSrc : src; + + return ( + { + e.preventDefault(); + setError(true); + }} + /> + ); +}; diff --git a/src/components/ui/index.ts b/src/components/ui/index.ts index 809ac185..4be4dc4a 100644 --- a/src/components/ui/index.ts +++ b/src/components/ui/index.ts @@ -1,5 +1,6 @@ export { Button } from './button'; export { Hint } from './hint'; +export { ImageWithFallback } from './image-with-fallback'; export type { ImageInputProps, ImageRecord } from './imageinput'; export { ImageInput } from './imageinput'; export { Input } from './input'; diff --git a/src/hooks/use-user/index.ts b/src/hooks/use-user/index.ts index 1c1909b3..6004fdb1 100644 --- a/src/hooks/use-user/index.ts +++ b/src/hooks/use-user/index.ts @@ -1,4 +1,3 @@ -export { useDeleteUser } from './use-user-delete'; export { useFollowUser } from './use-user-follow'; export { useGetUser } from './use-user-get'; export { useUnfollowUser } from './use-user-unfollow'; diff --git a/src/hooks/use-user/use-user-follow/index.ts b/src/hooks/use-user/use-user-follow/index.ts index 037a2384..4cad8381 100644 --- a/src/hooks/use-user/use-user-follow/index.ts +++ b/src/hooks/use-user/use-user-follow/index.ts @@ -2,12 +2,12 @@ import { useMutation, useQueryClient } from '@tanstack/react-query'; import { API } from '@/api'; import { userKeys } from '@/lib/query-key/query-key-user'; -import { FollowParams } from '@/types/service/user'; +import { FollowPathParams } from '@/types/service/user'; -export const useFollowUser = (payload: FollowParams) => { +export const useFollowUser = () => { const queryClient = useQueryClient(); const query = useMutation({ - mutationFn: () => API.userService.followUser(payload), + mutationFn: (params: FollowPathParams) => API.userService.followUser(params), onSuccess: (_data, _variables, _context) => { // todo: GetUser는 ID로 호출, follow는 nickname으로 진행 => querykey 타입 불일치로 인한 전체 querykey 삭제 적용 (임시) queryClient.invalidateQueries({ queryKey: userKeys.all }); diff --git a/src/hooks/use-user/use-user-get/index.ts b/src/hooks/use-user/use-user-get/index.ts index 44c4bb01..e56447c2 100644 --- a/src/hooks/use-user/use-user-get/index.ts +++ b/src/hooks/use-user/use-user-get/index.ts @@ -2,13 +2,19 @@ import { useQuery } from '@tanstack/react-query'; import { API } from '@/api'; import { userKeys } from '@/lib/query-key/query-key-user'; -import { GetUserParams } from '@/types/service/user'; +import { GetUserPathParams } from '@/types/service/user'; -export const useGetUser = ({ userId }: GetUserParams, options?: { enabled?: boolean }) => { +export const useGetUser = ({ userId }: GetUserPathParams, options?: { enabled?: boolean }) => { const query = useQuery({ queryKey: userKeys.item(userId), queryFn: () => API.userService.getUser({ userId }), ...options, + select: (data) => ({ + ...data, + profileImage: data.profileImage ?? '', + profileMessage: data.profileMessage ?? '', + mbti: data.mbti ?? '', + }), }); return query; }; diff --git a/src/hooks/use-user/use-user-delete/index.ts b/src/hooks/use-user/use-user-image-update/index.ts similarity index 63% rename from src/hooks/use-user/use-user-delete/index.ts rename to src/hooks/use-user/use-user-image-update/index.ts index bef8abf8..5b04bb49 100644 --- a/src/hooks/use-user/use-user-delete/index.ts +++ b/src/hooks/use-user/use-user-image-update/index.ts @@ -2,18 +2,16 @@ import { useMutation, useQueryClient } from '@tanstack/react-query'; import { API } from '@/api'; import { userKeys } from '@/lib/query-key/query-key-user'; +import { UpdateMyImagePayloads } from '@/types/service/user'; -export const useDeleteUser = () => { +export const useUserImageUpdate = () => { const queryClient = useQueryClient(); const query = useMutation({ - mutationFn: () => API.userService.deleteMe(), + mutationFn: (payload: UpdateMyImagePayloads) => API.userService.updateMyImage(payload), onSuccess: (data, _variables, _context) => { queryClient.invalidateQueries({ queryKey: userKeys.item(data.userId) }); - console.log('요청 성공'); - }, - onError: () => { - console.log('요청 실패'); }, + onError: () => {}, }); return query; }; diff --git a/src/hooks/use-user/use-user-notification/index.ts b/src/hooks/use-user/use-user-notification/index.ts new file mode 100644 index 00000000..d41c6f17 --- /dev/null +++ b/src/hooks/use-user/use-user-notification/index.ts @@ -0,0 +1,21 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; + +import { API } from '@/api'; +import { userKeys } from '@/lib/query-key/query-key-user'; +import { UpdateMyNotificationQueryParams } from '@/types/service/user'; + +export const useUpdateMyNotification = () => { + const queryClient = useQueryClient(); + const query = useMutation({ + mutationFn: (params: UpdateMyNotificationQueryParams) => + API.userService.updateMyNotification(params), + onSuccess: (_data, _variables, _context) => { + queryClient.invalidateQueries({ queryKey: userKeys.all }); + console.log('요청 성공'); + }, + onError: () => { + console.log('요청 실패'); + }, + }); + return query; +}; diff --git a/src/hooks/use-user/use-user-unfollow/index.ts b/src/hooks/use-user/use-user-unfollow/index.ts index 0f349b9d..9ef8b59d 100644 --- a/src/hooks/use-user/use-user-unfollow/index.ts +++ b/src/hooks/use-user/use-user-unfollow/index.ts @@ -2,12 +2,12 @@ import { useMutation, useQueryClient } from '@tanstack/react-query'; import { API } from '@/api'; import { userKeys } from '@/lib/query-key/query-key-user'; -import { FollowParams } from '@/types/service/user'; +import { UnfollowQueryParams } from '@/types/service/user'; -export const useUnfollowUser = (payload: FollowParams) => { +export const useUnfollowUser = () => { const queryClient = useQueryClient(); const query = useMutation({ - mutationFn: () => API.userService.unfollowUser(payload), + mutationFn: (params: UnfollowQueryParams) => API.userService.unfollowUser(params), onSuccess: (_data, _variables, _context) => { queryClient.invalidateQueries({ queryKey: userKeys.all }); console.log('요청 성공'); diff --git a/src/hooks/use-user/use-user-update/index.ts b/src/hooks/use-user/use-user-update/index.ts index 98e6c461..02dd2256 100644 --- a/src/hooks/use-user/use-user-update/index.ts +++ b/src/hooks/use-user/use-user-update/index.ts @@ -2,19 +2,16 @@ import { useMutation, useQueryClient } from '@tanstack/react-query'; import { API } from '@/api'; import { userKeys } from '@/lib/query-key/query-key-user'; -import { UpdateMePayload } from '@/types/service/user'; +import { UpdateMyInfoPayloads } from '@/types/service/user'; -export const useUpdateUser = (payload: UpdateMePayload) => { +export const useUpdateUser = () => { const queryClient = useQueryClient(); const query = useMutation({ - mutationFn: () => API.userService.updateMe(payload), + mutationFn: (payload: UpdateMyInfoPayloads) => API.userService.updateMyInfo(payload), onSuccess: (data, _variables, _context) => { queryClient.invalidateQueries({ queryKey: userKeys.item(data.userId) }); - console.log('요청 성공'); - }, - onError: () => { - console.log('요청 실패'); }, + onError: () => {}, }); return query; }; diff --git a/src/mock/service/user/user-mock.ts b/src/mock/service/user/user-mock.ts index 90c5f181..389cde55 100644 --- a/src/mock/service/user/user-mock.ts +++ b/src/mock/service/user/user-mock.ts @@ -15,7 +15,7 @@ export const mockUserItems: User[] = [ createdAt: '2025-12-07T17:00:00+09:00', groupJoinedCnt: 5, groupCreatedCnt: 3, - isFollowing: true, + isFollow: true, }, { userId: 2, @@ -31,7 +31,7 @@ export const mockUserItems: User[] = [ createdAt: '2025-08-03T17:00:00+09:00', groupJoinedCnt: 5, groupCreatedCnt: 3, - isFollowing: true, + isFollow: true, }, { userId: 3, @@ -47,6 +47,6 @@ export const mockUserItems: User[] = [ createdAt: '2025-11-03T17:00:00+09:00', groupJoinedCnt: 2, groupCreatedCnt: 1, - isFollowing: false, + isFollow: false, }, ]; diff --git a/src/types/service/user.ts b/src/types/service/user.ts index 8b73a9b4..71eaa520 100644 --- a/src/types/service/user.ts +++ b/src/types/service/user.ts @@ -11,27 +11,43 @@ export interface User { groupCreatedCnt: number; isNotificationEnabled: boolean; createdAt: string; - isFollowing: boolean; + isFollow: boolean; } -export interface GetUserParams { +export interface GetUserPathParams { userId: number; } -export interface UpdateMePayload { +export interface UpdateMyInfoPayloads { nickName?: string; mbti?: string; profileMessage?: string; } -export interface UpdateMyImagePayload { +export interface UpdateMyImagePayloads { file: File; } -export interface UpdateMyNotiParams { +export interface UpdateMyNotificationQueryParams { isNotificationEnabled: boolean; } -export interface FollowParams { +export interface FollowPathParams { followNickname: string; } + +export interface UnfollowQueryParams { + unFollowNickname: string; +} + +export interface Availability { + available: boolean; +} + +export interface GetNicknameAvailabilityQueryParams { + nickName: string; +} + +export interface GetEmailAvailabilityQueryParams { + email: string; +}