Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
d676cdc
🎨style: 커스텀 클래스 추가
Insung-Jo Jun 18, 2025
8700381
✨feat: PlusIcon 컴포넌트 구현
Insung-Jo Jun 18, 2025
0644bd4
✨feat: 엔드포인트 작성
Insung-Jo Jun 18, 2025
460e2ef
✨feat: 마이페이지 타입 작성
Insung-Jo Jun 18, 2025
e38aad8
✨feat: 마이페이지 사용자 정보 조회 및 수정 API 함수 추가
Insung-Jo Jun 18, 2025
437dd7a
✨feat: 엔드포인트 추가
Insung-Jo Jun 18, 2025
392fc77
✨feat: 마이페이지 타입 추가
Insung-Jo Jun 18, 2025
dab06ee
✨feat: 마이페이지 이미지 업로드 API 함수 추가
Insung-Jo Jun 18, 2025
cec5cfc
✨feat: 프로필 이미지 업로드 mutation 훅 생성
Insung-Jo Jun 18, 2025
4ae13db
✨feat: 프로필 업데이트 mutation 훅 생성
Insung-Jo Jun 18, 2025
110345d
🫧 modify: PlusIcon 타입 수정 및 반영
Insung-Jo Jun 18, 2025
041933b
✨feat: WhitePenIcon 컴포넌트 구현
Insung-Jo Jun 18, 2025
2cec219
✨feat: CloseCircleIcon 컴포넌트 구현
Insung-Jo Jun 18, 2025
666650e
✨feat: 마이페이지 스키마 작성
Insung-Jo Jun 18, 2025
a0b027d
🎨style: Input 조건부 스타일 추가
Insung-Jo Jun 18, 2025
89d13dc
✨feat: 사용자 정보 조회용 useUserQuery 훅 생성
Insung-Jo Jun 18, 2025
1cde8db
✨feat: ProfileImageUpload 컴포넌트 구현
Insung-Jo Jun 18, 2025
adb0300
✨feat: ProfileEditForm 컴포넌트 구현
Insung-Jo Jun 18, 2025
34d0251
✨feat: 페이지 적용
Insung-Jo Jun 18, 2025
118ea68
🐛fix: 토큰 안 넘어오던 문제 수정
Insung-Jo Jun 18, 2025
296bae0
♻️refactor: axios 인스턴스 네이밍을 authHttpClient로 명확하게 변경
Insung-Jo Jun 18, 2025
5489a48
🫧modify: 오타 수정
Insung-Jo Jun 18, 2025
76fadb9
🐛fix: 코드래빗 리뷰 반영
Insung-Jo Jun 18, 2025
cbdb10e
✨feat: 비밀번호 확인 훅 공통으로 분리 및 적용
Insung-Jo Jun 18, 2025
dd14548
✨feat: 비밀번호 변경 엔드 포인트 추가
Insung-Jo Jun 18, 2025
d7ab214
✨feat: 비밀번호 변경 타입 추가
Insung-Jo Jun 18, 2025
dadf4db
✨feat: 비밀번호 변경 API 작성
Insung-Jo Jun 18, 2025
2b3f632
✨feat: 비밀번호 변경 Mutation 훅 구현
Insung-Jo Jun 18, 2025
a269775
✨feat: 비밀번호 관련 스키마 작성
Insung-Jo Jun 18, 2025
60f5c41
✨feat: 새 비밀번호 중복 검증 훅 구현
Insung-Jo Jun 18, 2025
232708d
✨feat: 비밀번호 변경 컴포넌트 구현
Insung-Jo Jun 18, 2025
49049ef
✨feat: 페이지 적용
Insung-Jo Jun 18, 2025
f9c87dc
🫧modify: 일부 구조 개선
Insung-Jo Jun 18, 2025
254e3f3
🫧modify: 오타 수정
Insung-Jo Jun 18, 2025
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
12 changes: 9 additions & 3 deletions src/app/features/auth/api/authApi.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,21 @@
import api from '@/app/shared/lib/axios'
import authHttpClient from '@/app/shared/lib/axios'
import { User as SignupResponse } from '@/app/shared/types/user.type'

import { LoginRequest, LoginResponse, SignupRequest } from '../types/auth.type'
import { AUTH_ENDPOINT } from './authEndpoint'

export const login = async (data: LoginRequest): Promise<LoginResponse> => {
const response = await api.post<LoginResponse>(AUTH_ENDPOINT.LOGIN, data)
const response = await authHttpClient.post<LoginResponse>(
AUTH_ENDPOINT.LOGIN,
data,
)
return response.data
}

export const signup = async (data: SignupRequest): Promise<SignupResponse> => {
const response = await api.post<SignupResponse>(AUTH_ENDPOINT.SIGNUP, data)
const response = await authHttpClient.post<SignupResponse>(
AUTH_ENDPOINT.SIGNUP,
data,
)
return response.data
}
4 changes: 2 additions & 2 deletions src/app/features/auth/components/SignupForm.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
'use client'

import Input from '@components/Input'
import { useConfirmPasswordValidation } from '@hooks/useConfirmPasswordValidation'
import { cn } from '@lib/cn'
import { useState } from 'react'
import { useForm } from 'react-hook-form'

import { useConfirmPasswordValidation } from '../hooks/useConfirmPasswordValidation'
import { useSignupMutation } from '../hooks/useSignupMutation'
import { signupValidation } from '../schemas/signupValidation'
import { SignupFormData } from '../types/auth.type'
Expand All @@ -29,7 +29,7 @@ export default function SignupForm() {

const [isChecked, setIsChecked] = useState(false)
const { mutate: signupMtate, isPending } = useSignupMutation()
const validation = useConfirmPasswordValidation(getValues)
const validation = useConfirmPasswordValidation(() => getValues('password'))

function handleAgree() {
setIsChecked((prev) => !prev)
Expand Down
11 changes: 0 additions & 11 deletions src/app/features/auth/hooks/useConfirmPasswordValidation.ts

This file was deleted.

4 changes: 2 additions & 2 deletions src/app/features/auth/hooks/useLoginMutation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,11 +22,11 @@ export function useLoginMutation() {
},
onError: (error) => {
if (axios.isAxiosError(error)) {
const severMessage = (
const serverMessage = (
error.response?.data as { message?: string } | undefined
)?.message
const fallback = error.message || '로그인 실패'
showError(severMessage ?? fallback)
showError(serverMessage ?? fallback)
} else {
showError('알 수 없는 에러 발생')
}
Expand Down
4 changes: 2 additions & 2 deletions src/app/features/auth/hooks/useSignupMutation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,11 +21,11 @@ export function useSignupMutation() {
},
onError: (error) => {
if (axios.isAxiosError(error)) {
const severMessage = (
const serverMessage = (
error.response?.data as { message?: string } | undefined
)?.message
const fallback = error.message || '로그인 실패'
showError(severMessage ?? fallback)
showError(serverMessage ?? fallback)
} else {
showError('알 수 없는 에러 발생')
}
Expand Down
43 changes: 43 additions & 0 deletions src/app/features/mypage/api/mypageApi.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import authHttpClient from '@lib/axios'

import { User as UserDataResponse } from '@/app/shared/types/user.type'

import { PasswordChangeRequest } from '../types/mypage.type'
import {
UpdateProfileRequest,
UploadProfileImageResponse,
} from '../types/mypage.type'
import { MYPAGE_ENDPOINT } from './mypageEndPoint'

export async function loadUser(): Promise<UserDataResponse> {
const response = await authHttpClient.get(MYPAGE_ENDPOINT.USER)
return response.data
}

export async function updateMyProfile(
data: UpdateProfileRequest,
): Promise<UserDataResponse> {
const response = await authHttpClient.put<UserDataResponse>(
MYPAGE_ENDPOINT.USER,
data,
)
return response.data
}

export async function uploadProfileImage(
image: File,
): Promise<UploadProfileImageResponse> {
const formData = new FormData()
formData.append('image', image)
const response = await authHttpClient.post<UploadProfileImageResponse>(
MYPAGE_ENDPOINT.IMAGE,
formData,
)
return response.data
}

export async function changePassword(
data: PasswordChangeRequest,
): Promise<void> {
await authHttpClient.put(MYPAGE_ENDPOINT.CHANGE_PASSWORD, data)
}
5 changes: 5 additions & 0 deletions src/app/features/mypage/api/mypageEndPoint.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export const MYPAGE_ENDPOINT = {
USER: `/${process.env.NEXT_PUBLIC_TEAM_ID}/users/me`,
IMAGE: `/${process.env.NEXT_PUBLIC_TEAM_ID}/users/me/image`,
CHANGE_PASSWORD: `/${process.env.NEXT_PUBLIC_TEAM_ID}/auth/password`,
}
114 changes: 114 additions & 0 deletions src/app/features/mypage/components/PasswordChangeForm.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
import Input from '@components/Input'
import { useConfirmPasswordValidation } from '@hooks/useConfirmPasswordValidation'
import { cn } from '@lib/cn'
import { useForm } from 'react-hook-form'

import { showSuccess } from '@/app/shared/lib/toast'

import { useChangePasswordMutation } from '../hook/useChangePasswordMutation'
import { useNewPasswordValidation } from '../hook/useNewPasswordValidation'
import { PasswordChangeRequest } from '../types/mypage.type'

interface PasswordChangeFormData extends PasswordChangeRequest {
confirmPassword: string
}

export default function PasswordChangeForm() {
const {
register,
handleSubmit,
trigger,
getValues,
reset,
formState: { errors, isValid },
} = useForm<PasswordChangeFormData>({
mode: 'onBlur',
defaultValues: {
password: '',
newPassword: '',
confirmPassword: '',
},
})

const { mutate: changePassword, isPending } = useChangePasswordMutation()
const newPasswordValidation = useNewPasswordValidation(() =>
getValues('password'),
)
const confirmPasswordValidation = useConfirmPasswordValidation(() =>
getValues('newPassword'),
)

function onSubmit(data: PasswordChangeFormData) {
changePassword(
{
password: data.password,
newPassword: data.newPassword,
},
{
onSuccess: () => {
reset()
showSuccess('비밀번호가 성공적으로 변경되었습니다!')
},
},
)
}

return (
<div className="BG-white flex h-auto w-full max-w-672 flex-col gap-24 rounded-8 p-24">
<h2 className="text-2xl font-bold">비밀번호 변경</h2>
<form
onSubmit={handleSubmit(onSubmit)}
className="flex flex-col justify-between gap-16"
>
<Input
labelName="현재 비밀번호"
type="password"
placeholder="비밀번호 입력"
autoComplete="current-password"
{...register('password')}
/>

<Input
labelName="새 비밀번호"
type="password"
placeholder="새 비밀번호 입력"
autoComplete="new-password"
{...register('newPassword', {
...newPasswordValidation,
onBlur: () => {
trigger('confirmPassword')
},
})}
hasError={!!errors.newPassword}
errorMessage={errors.newPassword?.message}
/>

<Input
labelName="새 비밀번호 확인"
type="password"
placeholder="새 비밀번호 입력"
autoComplete="new-password"
{...register('confirmPassword', {
...confirmPasswordValidation,
onBlur: () => {
trigger('newPassword')
},
})}
hasError={!!errors.confirmPassword}
errorMessage={errors.confirmPassword?.message}
/>

<button
type="submit"
className={cn(
'mt-8 h-50 w-full rounded-8 text-lg font-medium text-white',
isValid && !isPending ? 'BG-blue' : 'BG-blue-disabled',
)}
disabled={!isValid || isPending}
>
{isPending ? '변경 중..' : '변경'}
</button>
</form>
</div>
)
}
133 changes: 133 additions & 0 deletions src/app/features/mypage/components/ProfileEditForm.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
'use client'

import Input from '@components/Input'
import { showError, showSuccess } from '@lib/toast'
import { isAxiosError } from 'axios'
import { useRouter } from 'next/navigation'
import { useEffect, useState } from 'react'
import { Controller, useForm } from 'react-hook-form'

import { useUpdateMyProfileMutation } from '../hook/useUpdateMyProfileMutation'
import { useUploadProfileImageMutation } from '../hook/useUploadProfileImageMutation'
import { useUserQuery } from '../hook/useUserQurey'
import { mypageValidation } from '../schemas/mypageValidation'
import ProfileImageUpload from './ProfileImageUpload'

interface ProfileFormData {
profileImageUrl: string | null
nickname: string
email: string
}

export default function ProfileEditForm() {
const { data: user } = useUserQuery() // get으로 사용자 데이터 최신화
const router = useRouter() // useClientQuery를 사용 하여 해당 부분에만 렌더링을 진행하려 했으나 다른 부분도 연동되는 부분이 있기 때문에 라우터 사용
const {
control,
register,
handleSubmit,
reset,
formState: { errors },
} = useForm<ProfileFormData>({
mode: 'onChange',
defaultValues: {
profileImageUrl: user?.profileImageUrl,
nickname: user?.nickname,
email: user?.email,
},
})

const [profileImageFile, setProfileImageFile] = useState<File | null>(null)
const { mutateAsync: uploadImage } = useUploadProfileImageMutation()
const { mutateAsync: updateProfile } = useUpdateMyProfileMutation()

// 유저 정보가 비동기적으로 넘어오기 때문에 유저가 로딩된 시점에서 RHF을 초기화 하기 위함 (SSR 도입 시 변경 예정)
useEffect(() => {
if (user) {
reset({
profileImageUrl: user.profileImageUrl ?? null,
nickname: user.nickname ?? '',
email: user.email ?? '',
})
}
}, [user, reset])

async function onSubmit(data: ProfileFormData) {
try {
// 현재 이미지 URL을 초기값으로 설정 (변경이 없을 수도 있기 때문)
let imageUrl = data.profileImageUrl

// 새 이미지를 업로드한 경우 → 서버에 업로드 요청 (POST)
if (profileImageFile) {
const { profileImageUrl } = await uploadImage(profileImageFile)
imageUrl = profileImageUrl
}

// 닉네임과 이미지 URL을 포함한 사용자 프로필 정보 생성
const submitData = {
nickname: data.nickname,
profileImageUrl: imageUrl,
}

// 서버에 프로필 정보 수정 요청 (PUT)
await updateProfile(submitData)

// 사용자에게 성공 알림 + 컴포넌트 최신화
showSuccess('프로필 변경이 완료되었습니다.')
router.refresh()
} catch (error) {
if (isAxiosError(error)) {
// 서버 에러 메시지 우선 처리, 없으면 기본 메시지
const serverMessage = (
error.response?.data as { message?: string } | undefined
)?.message
const fallback = error.message || '프로필 변경에 실패하였습니다.'
showError(serverMessage ?? fallback)
} else {
showError('알 수 없는 에러 발생')
}
}
}

return (
<form
onSubmit={handleSubmit(onSubmit)}
className="BG-white flex h-auto w-full max-w-672 flex-col gap-24 rounded-8 p-24 font-medium"
>
<h2 className="text-2xl font-bold">프로필</h2>

<div className="flex justify-between gap-42 tablet:flex-col">
<Controller
name="profileImageUrl"
control={control}
render={({ field: { value, onChange } }) => (
<ProfileImageUpload
value={value} // 미리보기용 이미지 URL
onChange={onChange} // form 상태(profileImageUrl) 업데이트
onFileChange={(file) => setProfileImageFile(file)} // 서버 전송용 파일 저장
/>
)}
/>

<div className="flex flex-grow flex-col gap-16">
<Input labelName="이메일" {...register('email')} readOnly />
<Input
labelName="닉네임"
type="text"
placeholder="닉네임을 입력해 주세요"
autoComplete="off"
{...register('nickname', mypageValidation.nickname)}
hasError={!!errors.nickname}
errorMessage={errors.nickname?.message}
/>
<button
type="submit"
className="BG-blue h-50 w-full rounded-8 text-white"
>
저장
</button>
</div>
</div>
</form>
)
}
Loading