diff --git a/src/apis/auth.ts b/src/apis/auth.ts index 8244a9b4..e13efb7d 100644 --- a/src/apis/auth.ts +++ b/src/apis/auth.ts @@ -1,9 +1,12 @@ -import type { PostSigninRequestType, PostSigninResponseType } from '@/schema/auth'; +import type { PostSigninRequestType, PostSigninResponseType, PostSignUpRequestType, PostSignUpResponseType } from '@/schema/auth'; import httpClient from '.'; -const postSignin = async (request: PostSigninRequestType): Promise => { +export const postSignin = async (request: PostSigninRequestType): Promise => { const response = await httpClient.post('/auth/signIn', request); return response.data; }; -export default postSignin; +export const postSignup = async (request: PostSignUpRequestType): Promise => { + const response = await httpClient.post('/auth/signUp', request); + return response.data; +}; diff --git a/src/apis/epigramComment.ts b/src/apis/epigramComment.ts new file mode 100644 index 00000000..4368fb9a --- /dev/null +++ b/src/apis/epigramComment.ts @@ -0,0 +1,46 @@ +import httpClient from '@/apis/index'; +import { CommentRequestSchema, CommentRequestType, CommentResponseSchema, CommentResponseType } from '@/schema/comment'; +import { PostCommentRequest, PatchCommentRequest } from '@/types/epigram.types'; + +export const getEpigramComments = async (params: CommentRequestType): Promise => { + try { + // 요청 파라미터 유효성 검사 + const validatedParams = CommentRequestSchema.parse(params); + + const { id, limit, cursor } = validatedParams; + + // NOTE: URL의 쿼리 문자열을 사용 + // NOTE : cursor값이 있다면 ?limit=3&cursor=100, 없다면 ?limit=3,(숫자는 임의로 지정한 것) + const queryParams = new URLSearchParams({ + limit: limit.toString(), + ...(cursor !== undefined && { cursor: cursor.toString() }), + }); + + const response = await httpClient.get(`/epigrams/${id}/comments?${queryParams.toString()}`); + + // 응답 데이터 유효성 검사 + const validatedData = CommentResponseSchema.parse(response.data); + + return validatedData; + } catch (error) { + if (error instanceof Error) { + throw new Error(`댓글을 불러오는데 실패했습니다: ${error.message}`); + } + throw error; + } +}; + +export const postComment = async (commentData: PostCommentRequest) => { + const response = await httpClient.post('/comments', commentData); + return response.data; +}; + +export const patchComment = async (commentId: number, commentData: PatchCommentRequest) => { + const response = await httpClient.patch(`/comments/${commentId}`, commentData); + return response.data; +}; + +export const deleteComment = async (commentId: number) => { + const response = await httpClient.delete(`/comments/${commentId}`); + return response.data; +}; diff --git a/src/apis/getEmotion.ts b/src/apis/getEmotion.ts new file mode 100644 index 00000000..044ff3c3 --- /dev/null +++ b/src/apis/getEmotion.ts @@ -0,0 +1,25 @@ +import { EmotionType } from '@/types/emotion'; +import type { GetEmotionResponseType } from '@/schema/emotion'; +import { translateEmotionToKorean } from '@/utils/emotionMap'; +import httpClient from '.'; +import { getMe } from './user'; + +const getEmotion = async (): Promise => { + const user = await getMe(); + if (!user) { + throw new Error('로그인이 필요합니다.'); + } + + const response = await httpClient.get('/emotionLogs/today', { + params: { userId: user.id }, + }); + + if (response.status === 204) { + return null; // No content + } + + const koreanEmotion = translateEmotionToKorean(response.data.emotion); + return koreanEmotion; +}; + +export default getEmotion; diff --git a/src/apis/getEpigrams.ts b/src/apis/getEpigrams.ts new file mode 100644 index 00000000..9685bc60 --- /dev/null +++ b/src/apis/getEpigrams.ts @@ -0,0 +1,12 @@ +import { GetEpigramsParamsType, GetEpigramsResponseType, GetEpigramsResponse } from '@/schema/epigrams'; +import httpClient from '.'; + +const getEpigrams = async (params: GetEpigramsParamsType): Promise => { + const response = await httpClient.get(`/epigrams`, { params }); + + // 데이터 일치하는지 확인 + const parsedResponse = GetEpigramsResponse.parse(response.data); + return parsedResponse; +}; + +export default getEpigrams; diff --git a/src/apis/index.ts b/src/apis/index.ts index c9b7cd80..0a4b7625 100644 --- a/src/apis/index.ts +++ b/src/apis/index.ts @@ -50,3 +50,45 @@ httpClient.interceptors.response.use( ); export default httpClient; + +// NOTE: eslint-disable no-param-reassign 미해결로 인한 설정 +httpClient.interceptors.request.use((config) => { + const accessToken = localStorage.getItem('accessToken'); + /* eslint-disable no-param-reassign */ + if (accessToken) config.headers.Authorization = `Bearer ${accessToken}`; + /* eslint-enable no-param-reassign */ + return config; +}); + +httpClient.interceptors.response.use( + (response) => response, + + (error) => { + if (error.response && error.response.status === 401) { + const refreshToken = localStorage.getItem('refreshToken'); + + if (!refreshToken) { + window.location.href = '/auth/SignIn'; + return Promise.reject(error); + } + + return httpClient + .post('/auth/refresh-token', null, { + headers: { Authorization: `Bearer ${refreshToken}` }, + }) + .then((response) => { + const { accessToken, refreshToken: newRefreshToken } = response.data; + localStorage.setItem('accessToken', accessToken); + localStorage.setItem('refreshToken', newRefreshToken); + + const originalRequest = error.config; + return httpClient(originalRequest); + }) + .catch(() => { + window.location.href = '/auth/SignIn'; + return Promise.reject(error); + }); + } + return Promise.reject(error); + }, +); diff --git a/src/apis/oauth.ts b/src/apis/oauth.ts new file mode 100644 index 00000000..ae6bd068 --- /dev/null +++ b/src/apis/oauth.ts @@ -0,0 +1,11 @@ +import axios from 'axios'; + +const postOauth = async (code: string) => { + const response = await axios.post(`${process.env.NEXT_PUBLIC_BASE_URL}/auth/signIn/KAKAO`, { + redirectUri: process.env.NEXT_PUBLIC_REDIRECT_URI, + token: code, + }); + return response.data; +}; + +export default postOauth; diff --git a/src/apis/postEmotion.ts b/src/apis/postEmotion.ts new file mode 100644 index 00000000..98130bc7 --- /dev/null +++ b/src/apis/postEmotion.ts @@ -0,0 +1,24 @@ +import { EmotionType } from '@/types/emotion'; +import type { PostEmotionRequestType, PostEmotionResponseType } from '@/schema/emotion'; +import { translateEmotionToEnglish } from '@/utils/emotionMap'; +import httpClient from '.'; +import { getMe } from './user'; + +const postEmotion = async (emotion: EmotionType): Promise => { + const user = await getMe(); + if (!user) { + throw new Error('로그인이 필요합니다.'); + } + + const englishEmotion = translateEmotionToEnglish(emotion); + const request: PostEmotionRequestType = { emotion: englishEmotion }; + + const response = await httpClient.post('/emotionLogs/today', { + ...request, + userId: user.id, + }); + + return response.data; +}; + +export default postEmotion; diff --git a/src/apis/user.ts b/src/apis/user.ts index 551dad17..d4a5c757 100644 --- a/src/apis/user.ts +++ b/src/apis/user.ts @@ -1,19 +1,19 @@ -import type { GetUserReponseType, GetUserRequestType, PatchMeRequestType, PostPresignedUrlRequestType, PostPresignedUrlResponseType } from '@/schema/user'; +import type { GetUserResponseType, GetUserRequestType, PatchMeRequestType, PostPresignedUrlRequestType, PostPresignedUrlResponseType } from '@/schema/user'; import httpClient from '.'; -export const getMe = async (): Promise => { +export const getMe = async (): Promise => { const response = await httpClient.get('/users/me'); return response.data; }; -export const getUser = async (request: GetUserRequestType): Promise => { +export const getUser = async (request: GetUserRequestType): Promise => { const { id } = request; const response = await httpClient.get(`/users/${id}`); return response.data; }; -export const updateMe = async (request: PatchMeRequestType): Promise => { +export const updateMe = async (request: PatchMeRequestType): Promise => { const response = await httpClient.patch('/users/me', { ...request }); return response.data; }; diff --git a/src/components/Emotion/EmotionCard.tsx b/src/components/Emotion/EmotionCard.tsx index 1896f7cd..74134281 100644 --- a/src/components/Emotion/EmotionCard.tsx +++ b/src/components/Emotion/EmotionCard.tsx @@ -7,7 +7,7 @@ import React from 'react'; import cn from '@/lib/utils'; import Image from 'next/image'; -import { EmotionIconCardProps } from '@/types/EmotionTypes'; +import { EmotionIconCardProps } from '@/types/emotion'; // 아이콘 파일 경로 매핑 const iconPaths = { diff --git a/src/components/Emotion/EmotionSaveToast.tsx b/src/components/Emotion/EmotionSaveToast.tsx new file mode 100644 index 00000000..e7d24105 --- /dev/null +++ b/src/components/Emotion/EmotionSaveToast.tsx @@ -0,0 +1,34 @@ +/* + * 오늘의 감정을 선택하면 표시되는 toast입니다. + * 감정을 확인하기 위해 마이페이지로 연결됩니다. + */ + +import React, { useEffect } from 'react'; +import { useToast } from '@/components/ui/use-toast'; +import { ToastAction } from '@/components/ui/toast'; +import { useRouter } from 'next/router'; + +interface EmotionSaveToastProps { + iconType: string; +} + +function EmotionSaveToast({ iconType }: EmotionSaveToastProps) { + const { toast } = useToast(); + const router = useRouter(); + + useEffect(() => { + toast({ + title: '오늘의 감정이 저장되었습니다.', + description: `오늘의 감정: ${iconType}`, + action: ( + router.push('/mypage')}> + 확인하기 + + ), + }); + }, [iconType, toast, router]); + + return null; +} + +export default EmotionSaveToast; diff --git a/src/components/Emotion/EmotionSelector.tsx b/src/components/Emotion/EmotionSelector.tsx index 5a73639e..4375c3ca 100644 --- a/src/components/Emotion/EmotionSelector.tsx +++ b/src/components/Emotion/EmotionSelector.tsx @@ -1,19 +1,21 @@ -/* - 여러 개의 EmotionIconCard를 관리합니다. - 사용자 인터페이스에 필요한 상호 작용 로직을 포함합니다. - */ - -import React, { useState } from 'react'; -import EmotionIconCard from '@/components/Emotion/EmotionCard'; +import React, { useState, useEffect } from 'react'; import useMediaQuery from '@/hooks/useMediaQuery'; -import { EmotionType, EmotionState } from '@/types/EmotionTypes'; +import EmotionIconCard from '@/components/Emotion/EmotionCard'; +import { EmotionType, EmotionState } from '@/types/emotion'; +import usePostEmotion from '@/hooks/usePostEmotion'; +import { useGetEmotion } from '@/hooks/useGetEmotion'; +import EmotionSaveToast from './EmotionSaveToast'; -// EmotionSelector 컴포넌트 함수 선언 +/** + * EmotionSelector 컴포넌트는 여러 개의 EmotionIconCard를 관리하고 + * 사용자의 오늘의 감정을 선택하고 저장하고 출력합니다. + */ function EmotionSelector() { + // 반응형 디자인을 위한 미디어 쿼리 훅 const isTablet = useMediaQuery('(min-width: 768px) and (max-width: 1024px)'); const isMobile = useMediaQuery('(max-width: 767px)'); - // 감정 카드 상태 관리 + // 감정 카드 상태 관리를 위한 useState 훅 const [states, setStates] = useState>({ 감동: 'Default', 기쁨: 'Default', @@ -22,13 +24,37 @@ function EmotionSelector() { 분노: 'Default', }); - // 감정 카드 클릭 핸들러 - const handleCardClick = (iconType: EmotionType) => { + // 현재 선택된 감정을 관리하는 useState 훅 + const [selectedEmotion, setSelectedEmotion] = useState(null); + // 오늘의 감정을 조회하기 위한 훅 + const { data: emotion, error: getError, isLoading: isGetLoading } = useGetEmotion(); + // 감정을 저장하기 위한 훅 + const postEmotionMutation = usePostEmotion(); + + // 컴포넌트가 마운트될 때 한 번만 실행되는 useEffect 훅 + // 오늘의 감정을 조회하고 상태를 업데이트합니다. + useEffect(() => { + if (emotion) { + setStates((prevStates) => ({ + ...prevStates, + [emotion]: 'Clicked', + })); + } + }, [emotion]); + + /** + * 감정 카드 클릭 핸들러 + * 사용자가 감정 카드를 클릭했을 때 호출됩니다. + * 클릭된 감정 카드를 'Clicked' 상태로 설정하고 나머지 카드는 'Unclicked' 상태로 설정합니다. + * 감정을 서버에 저장합니다. + * @param iconType - 클릭된 감정의 타입 + */ + const handleCardClick = async (iconType: EmotionType) => { setStates((prevStates) => { const newStates = { ...prevStates }; if (prevStates[iconType] === 'Clicked') { - // 현재 클릭된 카드가 다시 클릭되면 모두 Default로 설정 + // 현재 클릭된 카드가 다시 클릭되면 모든 카드를 Default로 설정 Object.keys(newStates).forEach((key) => { newStates[key as EmotionType] = 'Default'; }); @@ -41,8 +67,20 @@ function EmotionSelector() { return newStates; }); + + // 오늘의 감정 저장 + postEmotionMutation.mutate(iconType, { + onSuccess: (_, clickedIconType) => { + setSelectedEmotion(clickedIconType); + }, + onError: (error: unknown) => { + // eslint-disable-next-line + console.error(error); + }, + }); }; + // 반응형 디자인을 위한 카드 크기 설정 let containerClass = 'w-[544px] h-[136px] gap-4'; let cardSize: 'lg' | 'md' | 'sm' = 'lg'; @@ -54,12 +92,19 @@ function EmotionSelector() { cardSize = 'sm'; } + if (isGetLoading) return

Loading...

; + if (getError) return

{getError.message}

; + return ( -
- {(['감동', '기쁨', '고민', '슬픔', '분노'] as const).map((iconType) => ( - handleCardClick(iconType)} /> - ))} -
+ <> +
+ {(['감동', '기쁨', '고민', '슬픔', '분노'] as const).map((iconType) => ( + handleCardClick(iconType)} /> + ))} +
+ {/* 감정이 선택되었을 때 토스트 메시지 표시 */} + {selectedEmotion && } + ); } diff --git a/src/hooks/useDeleteCommentHook.ts b/src/hooks/useDeleteCommentHook.ts new file mode 100644 index 00000000..006019b5 --- /dev/null +++ b/src/hooks/useDeleteCommentHook.ts @@ -0,0 +1,31 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { deleteComment } from '@/apis/epigramComment'; +import { toast } from '@/components/ui/use-toast'; + +const useDeleteCommentMutation = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (commentId: number) => deleteComment(commentId), + onSuccess: () => { + // 댓글 목록 쿼리 무효화 + queryClient.invalidateQueries({ queryKey: ['epigramComments'] }); + + // 성공 메시지 표시 + toast({ + title: '댓글 삭제 성공', + description: '댓글이 성공적으로 삭제되었습니다.', + }); + }, + onError: (error) => { + // 에러 메시지 표시 + toast({ + title: '댓글 삭제 실패', + description: `댓글 삭제 중 오류가 발생했습니다: ${error instanceof Error ? error.message : '알 수 없는 오류'}`, + variant: 'destructive', + }); + }, + }); +}; + +export default useDeleteCommentMutation; diff --git a/src/hooks/useGetEmotion.ts b/src/hooks/useGetEmotion.ts index 46fe745e..e8555101 100644 --- a/src/hooks/useGetEmotion.ts +++ b/src/hooks/useGetEmotion.ts @@ -1,7 +1,13 @@ import quries from '@/apis/queries'; +import getEmotion from '@/apis/getEmotion'; +import { EmotionType } from '@/types/emotion'; import { GetMonthlyEmotionLogsRequestType } from '@/schema/emotion'; import { useQuery } from '@tanstack/react-query'; -const useMonthlyEmotionLogs = (requset: GetMonthlyEmotionLogsRequestType) => useQuery(quries.emotion.getMonthlyEmotionLogs(requset)); +export const useMonthlyEmotionLogs = (requset: GetMonthlyEmotionLogsRequestType) => useQuery(quries.emotion.getMonthlyEmotionLogs(requset)); -export default useMonthlyEmotionLogs; +export const useGetEmotion = () => + useQuery({ + queryKey: ['emotion'], + queryFn: getEmotion, + }); diff --git a/src/hooks/useKakaoLogin.ts b/src/hooks/useKakaoLogin.ts new file mode 100644 index 00000000..14d8d81b --- /dev/null +++ b/src/hooks/useKakaoLogin.ts @@ -0,0 +1,42 @@ +import postOauth from '@/apis/oauth'; +import { toast } from '@/components/ui/use-toast'; +import { useMutation } from '@tanstack/react-query'; +import { isAxiosError } from 'axios'; +import { useRouter } from 'next/router'; + +const useKakaoLogin = () => { + const router = useRouter(); + + return useMutation({ + mutationFn: async (code: string) => { + const result = await postOauth(code); + localStorage.setItem('accessToken', result.accessToken); + localStorage.setItem('refreshToken', result.refreshToken); + return result; + }, + onSuccess: () => { + router.push('/'); + }, + onError: (error) => { + if (isAxiosError(error)) { + const status = error.response?.status; + + if (!status) return; + + if (status === 400) { + toast({ description: '잘못된 요청입니다. 요청을 확인해 주세요.', className: 'bg-state-error text-white font-semibold' }); + router.push('/auth/SignIn'); + return; + } + + if (status >= 500) { + toast({ description: '서버에 문제가 발생했습니다. 잠시 후 다시 시도해 주세요.', className: 'bg-state-error text-white font-semibold' }); + } + } + + toast({ description: '알 수 없는 에러가 발생했습니다.', className: 'bg-state-error text-white font-semibold' }); + }, + }); +}; + +export default useKakaoLogin; diff --git a/src/hooks/usePatchCommentHook.ts b/src/hooks/usePatchCommentHook.ts new file mode 100644 index 00000000..b215624d --- /dev/null +++ b/src/hooks/usePatchCommentHook.ts @@ -0,0 +1,32 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { patchComment } from '@/apis/epigramComment'; +import { PatchCommentRequest } from '@/types/epigram.types'; +import { toast } from '@/components/ui/use-toast'; + +const usePatchCommentMutation = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: ({ commentId, ...commentData }: { commentId: number } & PatchCommentRequest) => patchComment(commentId, commentData), + onSuccess: () => { + // 댓글 목록 쿼리 무효화 + queryClient.invalidateQueries({ queryKey: ['epigramComments'] }); + + // 성공 메시지 표시 + toast({ + title: '댓글 수정 성공', + description: '댓글이 성공적으로 수정되었습니다.', + }); + }, + onError: (error) => { + // 에러 메시지 표시 + toast({ + title: '댓글 수정 실패', + description: `댓글 수정 중 오류가 발생했습니다: ${error instanceof Error ? error.message : '알 수 없는 오류'}`, + variant: 'destructive', + }); + }, + }); +}; + +export default usePatchCommentMutation; diff --git a/src/hooks/usePostEmotion.ts b/src/hooks/usePostEmotion.ts new file mode 100644 index 00000000..f13540ae --- /dev/null +++ b/src/hooks/usePostEmotion.ts @@ -0,0 +1,11 @@ +import { useMutation } from '@tanstack/react-query'; +import postEmotion from '@/apis/postEmotion'; +import { EmotionType } from '@/types/emotion'; +import { PostEmotionResponseType } from '@/schema/emotion'; + +const usePostEmotion = () => + useMutation({ + mutationFn: postEmotion, + }); + +export default usePostEmotion; diff --git a/src/hooks/useRegisterMutation.ts b/src/hooks/useRegisterMutation.ts new file mode 100644 index 00000000..8cbe5fe7 --- /dev/null +++ b/src/hooks/useRegisterMutation.ts @@ -0,0 +1,77 @@ +import { postSignup } from '@/apis/auth'; +import { toast } from '@/components/ui/use-toast'; +import { useMutation } from '@tanstack/react-query'; +import { useRouter } from 'next/router'; +import { isAxiosError } from 'axios'; + +const useRegisterMutation = () => { + const router = useRouter(); + + return useMutation({ + mutationFn: postSignup, + onSuccess: (data) => { + localStorage.setItem('accessToken', data.accessToken); + localStorage.setItem('refreshToken', data.refreshToken); + router.push('/'); + }, + onError: (error) => { + if (isAxiosError(error)) { + const { status, data } = error.response || {}; + + if (!status) return; + + if (status === 400) { + const errorMessage = data?.message || '잘못된 요청입니다. 입력 값을 확인해주세요.'; + + if (errorMessage.includes('이미 사용중인 이메일')) { + toast({ + description: '이미 사용중인 이메일입니다.', + className: 'border-state-error text-state-error font-semibold', + }); + return; + } + + toast({ + description: errorMessage, + className: 'border-state-error text-state-error font-semibold', + }); + return; + } + + if (status === 500) { + const errorMessage = data?.message || '서버 오류가 발생했습니다. 잠시 후 다시 시도해주세요.'; + + // NOTE: swagger 문서에서 중복된 닉네임은 500에러와 함께 "Internal Server Error" 메시지로 응답 옴 + if (errorMessage.includes('Internal Server Error')) { + toast({ + description: '이미 존재하는 닉네임입니다.', + className: 'border-state-error text-state-error font-semibold', + }); + return; + } + + toast({ + description: errorMessage, + className: 'border-state-error text-state-error font-semibold', + }); + return; + } + + if (status >= 500) { + toast({ + description: '서버 오류가 발생했습니다. 잠시 후 다시 시도해주세요.', + className: 'border-state-error text-state-error font-semibold', + }); + return; + } + + toast({ + description: '알 수 없는 오류가 발생했습니다. 잠시 후 다시 시도해주세요.', + className: 'border-state-error text-state-error font-semibold', + }); + } + }, + }); +}; + +export default useRegisterMutation; diff --git a/src/hooks/useSignInMutation.ts b/src/hooks/useSignInMutation.ts index eaf9fd76..2f4ebb5e 100644 --- a/src/hooks/useSignInMutation.ts +++ b/src/hooks/useSignInMutation.ts @@ -1,4 +1,4 @@ -import postSignin from '@/apis/auth'; +import { postSignin } from '@/apis/auth'; import { toast } from '@/components/ui/use-toast'; import { useMutation } from '@tanstack/react-query'; import { useRouter } from 'next/router'; diff --git a/src/hooks/userQueryHooks.ts b/src/hooks/userQueryHooks.ts index f5043b15..fdad65ca 100644 --- a/src/hooks/userQueryHooks.ts +++ b/src/hooks/userQueryHooks.ts @@ -1,6 +1,8 @@ import quries from '@/apis/queries'; + import { updateMe, createPresignedUrl } from '@/apis/user'; import { GetUserRequestType, PatchMeRequestType, PostPresignedUrlRequestType, PostPresignedUrlResponseType } from '@/schema/user'; + import { MutationOptions } from '@/types/query'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; diff --git a/src/pages/auth/SignIn.tsx b/src/pages/auth/SignIn.tsx index 400d0edd..68ef07ec 100644 --- a/src/pages/auth/SignIn.tsx +++ b/src/pages/auth/SignIn.tsx @@ -85,15 +85,15 @@ export default function SignIn() {
- - - +
); diff --git a/src/pages/auth/SignUp.tsx b/src/pages/auth/SignUp.tsx new file mode 100644 index 00000000..e6c52906 --- /dev/null +++ b/src/pages/auth/SignUp.tsx @@ -0,0 +1,129 @@ +import Image from 'next/image'; +import Link from 'next/link'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { PostSignUpRequest, PostSignUpRequestType } from '@/schema/auth'; +import { useForm } from 'react-hook-form'; +import { Input } from '@/components/ui/input'; +import { Button } from '@/components/ui/button'; +import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form'; +import useRegisterMutation from '@/hooks/useRegisterMutation'; + +export default function SignUp() { + const mutationRegister = useRegisterMutation(); + + const form = useForm({ + resolver: zodResolver(PostSignUpRequest), + mode: 'onBlur', + defaultValues: { + email: '', + password: '', + passwordConfirmation: '', + nickname: '', + }, + }); + + return ( +
+
+ + logo + +
+
+
+ mutationRegister.mutate(values))} className='flex flex-col items-center w-full h-full px-6'> + ( + + 이메일 + + + + + + )} + /> + ( + + 비밀번호 + + + + + + )} + /> + ( + + + + + + + )} + /> + ( + + 닉네임 + + + + + + )} + /> + + + +
+
+ + + +
+
+ ); +} diff --git a/src/pages/auth/redirect/kakao/index.tsx b/src/pages/auth/redirect/kakao/index.tsx new file mode 100644 index 00000000..46e82db7 --- /dev/null +++ b/src/pages/auth/redirect/kakao/index.tsx @@ -0,0 +1,18 @@ +import useKakaoLogin from '@/hooks/useKakaoLogin'; +import { useSearchParams } from 'next/navigation'; +import { useEffect } from 'react'; + +export default function Kakao() { + const searchParams = useSearchParams(); + const code = searchParams.get('code'); + const { mutate: login } = useKakaoLogin(); + + useEffect(() => { + if (code) { + login(code); + } else { + /* eslint-disable no-console */ + console.log(code); // code가 없을 때 콘솔에 출력 + } + }, [code, login]); +} diff --git a/src/schema/auth.ts b/src/schema/auth.ts index 33466608..5de73332 100644 --- a/src/schema/auth.ts +++ b/src/schema/auth.ts @@ -1,5 +1,26 @@ import * as z from 'zod'; +const PWD_VALIDATION_REGEX = /^(?=.*[a-zA-Z])(?=.*[0-9])(?=.*[!@#$%^&*])[a-zA-Z0-9!@#$%^&*]{8,}$/; + +// NOTE: 회원가입 스키마 +export const PostSignUpRequest = z + .object({ + email: z.string().min(1, { message: '이메일은 필수 입력입니다.' }).email({ message: '이메일 형식으로 작성해 주세요.' }), + password: z + .string() + .min(1, { message: '비밀번호는 필수 입력입니다.' }) + .min(8, { message: '비밀번호는 최소 8자 이상입니다.' }) + .regex(PWD_VALIDATION_REGEX, { message: '비밀번호는 숫자, 영문, 특수문자로만 가능합니다.' }), + passwordConfirmation: z.string().min(1, { message: '비밀번호 확인을 입력해주세요.' }), + nickname: z.string().min(1, { message: '닉네임은 필수 입력입니다.' }).max(20, { message: '닉네임은 최대 20자까지 가능합니다.' }), + }) + .refine((data) => data.password === data.passwordConfirmation, { + message: '비밀번호가 일치하지 않습니다.', + path: ['passwordConfirmation'], + }); + +// NOTE: 로그인 스키마 + export const PostSigninRequest = z.object({ email: z.string().min(1, { message: '이메일은 필수 입력입니다.' }).email({ message: '올바른 이메일 주소가 아닙니다.' }), password: z.string().min(1, { message: '비밀번호는 필수 입력입니다.' }), @@ -15,11 +36,15 @@ const User = z.object({ image: z.string(), }); -export const PostSigninResponse = z.object({ +export const PostAuthResponse = z.object({ accessToken: z.string(), refreshToken: z.string(), user: User, }); +// NOTE: 회원가입 타입 +export type PostSignUpRequestType = z.infer; +export type PostSignUpResponseType = z.infer; +// NOTE: 로그인 타입 export type PostSigninRequestType = z.infer; -export type PostSigninResponseType = z.infer; +export type PostSigninResponseType = z.infer; diff --git a/src/schema/comment.ts b/src/schema/comment.ts new file mode 100644 index 00000000..391557df --- /dev/null +++ b/src/schema/comment.ts @@ -0,0 +1,44 @@ +import { z } from 'zod'; + +const WriterSchema = z.object({ + image: z.string().nullable(), + nickname: z.string(), + id: z.number(), +}); + +const CommentContentSchema = z.string().min(1); + +const CommentSchema = z.object({ + epigramId: z.number(), + writer: WriterSchema, + updatedAt: z.string().datetime(), + createdAt: z.string().datetime(), + isPrivate: z.boolean(), + content: CommentContentSchema, + id: z.number(), +}); + +const CommentResponseSchema = z.object({ + totalCount: z.number(), + nextCursor: z.number().nullable(), + list: z.array(CommentSchema), +}); + +const CommentRequestSchema = z.object({ + id: z.number().int().positive(), + limit: z.number().int().positive().max(100), + cursor: z.number().optional(), +}); + +const CommentFormSchema = z.object({ + content: z.string().min(1, '댓글을 입력해주세요.').max(100, '100자 이내로 입력해주세요.'), + isPrivate: z.boolean().default(true), +}); + +export type CommentFormValues = z.infer; +export type CommentRequestType = z.infer; +export type CommentResponseType = z.infer; +export type CommentType = z.infer; +export type Writer = z.infer; + +export { CommentRequestSchema, CommentResponseSchema, CommentFormSchema, CommentSchema, WriterSchema }; diff --git a/src/schema/emotion.ts b/src/schema/emotion.ts index 4fa234a9..2bc5a92e 100644 --- a/src/schema/emotion.ts +++ b/src/schema/emotion.ts @@ -17,6 +17,27 @@ const EmotionSchema = z.object({ // 감정 로그 배열 정의 export const GetMonthlyEmotionLogsResponse = z.array(EmotionSchema); - export type GetMonthlyEmotionLogsRequestType = z.infer; export type GetMonthlyEmotionLogsResponseType = z.infer; + +export const PostEmotionRequest = z.object({ + emotion: z.enum(['MOVED', 'JOY', 'WORRY', 'SADNESS', 'ANGER']), +}); + +export const PostEmotionResponse = z.object({ + createdAt: z.coerce.date(), + emotion: z.enum(['MOVED', 'JOY', 'WORRY', 'SADNESS', 'ANGER']), + userId: z.number(), + id: z.number(), +}); + +export const GetEmotionResponse = z.object({ + createdAt: z.coerce.date(), + emotion: z.enum(['MOVED', 'JOY', 'WORRY', 'SADNESS', 'ANGER']), + userId: z.number(), + id: z.number(), +}); + +export type PostEmotionRequestType = z.infer; +export type PostEmotionResponseType = z.infer; +export type GetEmotionResponseType = z.infer; diff --git a/src/schema/epigram.ts b/src/schema/epigram.ts new file mode 100644 index 00000000..f72fdec7 --- /dev/null +++ b/src/schema/epigram.ts @@ -0,0 +1,30 @@ +import { z } from 'zod'; + +// Tag 스키마 +const TagSchema = z.object({ + name: z.string().min(1).max(10), + id: z.number().int().positive(), +}); + +// GetEpigramResponseType 스키마 +const GetEpigramResponseSchema = z.object({ + id: z.number().int().positive(), + content: z.string().min(1).max(500), + author: z.string().min(1).max(30), + referenceTitle: z.string().max(100).nullable().optional(), + referenceUrl: z.string().url().nullable().optional(), + writerId: z.number().int().positive(), + tags: z.array(TagSchema), + likeCount: z.number(), + isLiked: z.boolean().optional(), +}); + +const EpigramRequestSchema = z.object({ + id: z.union([z.string(), z.number(), z.undefined()]), +}); + +export type Tag = z.infer; +export type GetEpigramResponseType = z.infer; +export type EpigramRequestType = z.infer; + +export { TagSchema, GetEpigramResponseSchema, EpigramRequestSchema }; diff --git a/src/schema/epigrams.ts b/src/schema/epigrams.ts new file mode 100644 index 00000000..46a7cb85 --- /dev/null +++ b/src/schema/epigrams.ts @@ -0,0 +1,33 @@ +import * as z from 'zod'; + +export const GetEpigramsParams = z.object({ + limit: z.number(), + cursor: z.number().optional(), + keyword: z.string().optional(), + writerId: z.number().optional(), +}); + +export const GetEpigramsResponse = z.object({ + totalCount: z.number(), + nextCursor: z.number(), + list: z.array( + z.object({ + likeCount: z.number(), + tags: z.array( + z.object({ + name: z.string(), + id: z.number(), + }), + ), + writerId: z.number(), + referenceUrl: z.string(), + referenceTitle: z.string(), + author: z.string(), + content: z.string(), + id: z.number(), + }), + ), +}); + +export type GetEpigramsParamsType = z.infer; +export type GetEpigramsResponseType = z.infer; diff --git a/src/schema/user.ts b/src/schema/user.ts index 753011d7..5c6881cb 100644 --- a/src/schema/user.ts +++ b/src/schema/user.ts @@ -30,7 +30,7 @@ export const PostPresignedUrlResponse = z.object({ url: z.string().url(), }); -export type GetUserReponseType = z.infer; +export type GetUserResponseType = z.infer; export type GetUserRequestType = z.infer; export type PatchMeRequestType = z.infer; diff --git a/src/types/EmotionTypes.ts b/src/types/EmotionTypes.ts deleted file mode 100644 index e64f3d3f..00000000 --- a/src/types/EmotionTypes.ts +++ /dev/null @@ -1,14 +0,0 @@ -export type EmotionType = '감동' | '기쁨' | '고민' | '슬픔' | '분노'; -export type EmotionState = 'Default' | 'Unclicked' | 'Clicked'; - -export interface EmotionIconCardProps { - iconType: EmotionType; // 아이콘 종류 - state: EmotionState; // 상태 - size: 'sm' | 'md' | 'lg'; // 크기 - onClick?: () => void; // 클릭 이벤트 핸들러 -} - -export interface InteractiveEmotionIconCardProps extends Omit { - state: EmotionState; - onClick: () => void; -} diff --git a/src/types/emotion.ts b/src/types/emotion.ts index a5dfb576..45b5b78c 100644 --- a/src/types/emotion.ts +++ b/src/types/emotion.ts @@ -5,11 +5,26 @@ export interface Emotion { } // 감정 로그 타입 지정 -export type EmotionType = 'MOVED' | 'HAPPY' | 'WORRIED' | 'SAD' | 'ANGRY'; +export type EmotionTypeEN = 'MOVED' | 'HAPPY' | 'WORRIED' | 'SAD' | 'ANGRY'; export interface EmotionLog { id: number; userId: number; - emotion: EmotionType; + emotion: EmotionTypeEN; createdAt: Date; } + +export type EmotionType = '감동' | '기쁨' | '고민' | '슬픔' | '분노'; +export type EmotionState = 'Default' | 'Unclicked' | 'Clicked'; + +export interface EmotionIconCardProps { + iconType: EmotionType; // 아이콘 종류 + state: EmotionState; // 상태 + size: 'sm' | 'md' | 'lg'; // 크기 + onClick?: () => void; // 클릭 이벤트 핸들러 +} + +export interface InteractiveEmotionIconCardProps extends Omit { + state: EmotionState; + onClick: () => void; +} diff --git a/src/types/epigram.types.ts b/src/types/epigram.types.ts new file mode 100644 index 00000000..226ee9ee --- /dev/null +++ b/src/types/epigram.types.ts @@ -0,0 +1,24 @@ +import { GetEpigramResponseType } from '@/schema/epigram'; +import { GetUserResponseType } from '@/schema/user'; + +export interface EpigramFigureProps { + epigram: GetEpigramResponseType; + currentUserId: GetUserResponseType['id'] | undefined; +} + +export interface EpigramCommentProps { + epigramId: number; + currentUserId: GetUserResponseType['id'] | undefined; + userImage?: string | undefined; +} + +export interface PostCommentRequest { + epigramId: number; + isPrivate: boolean; + content: string; +} + +export interface PatchCommentRequest { + isPrivate: boolean; + content: string; +} diff --git a/src/user/ui-profile/Calendar.tsx b/src/user/ui-profile/Calendar.tsx index ff4ec107..4ce6d06a 100644 --- a/src/user/ui-profile/Calendar.tsx +++ b/src/user/ui-profile/Calendar.tsx @@ -1,7 +1,7 @@ import React, { useState } from 'react'; import Image from 'next/image'; import { subMonths } from 'date-fns'; -import { EmotionLog, EmotionType } from '@/types/emotion'; +import { EmotionLog, EmotionTypeEN } from '@/types/emotion'; import useCalendar from '../../hooks/useCalendar'; import { DAY_LIST, DATE_MONTH_FIXER, iconPaths } from '../utill/constants'; import CalendarHeader from './CalendarHeader'; @@ -16,14 +16,14 @@ export default function Calendar({ currentDate, setCurrentDate, monthlyEmotionLo // 캘린더 함수 호출 const { weekCalendarList } = useCalendar(currentDate); // 감정 필터 - const [selectedEmotion, setSelectedEmotion] = useState(null); + const [selectedEmotion, setSelectedEmotion] = useState(null); // 달력에 출력할 수 있게 매핑 - const emotionMap: Record = Array.isArray(monthlyEmotionLogs) - ? monthlyEmotionLogs.reduce>((acc, log) => { + const emotionMap: Record = Array.isArray(monthlyEmotionLogs) + ? monthlyEmotionLogs.reduce>((acc, log) => { const date = new Date(log.createdAt); const dateString = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`; - acc[dateString] = log.emotion as EmotionType; + acc[dateString] = log.emotion as EmotionTypeEN; return acc; }, {}) : {}; @@ -34,7 +34,7 @@ export default function Calendar({ currentDate, setCurrentDate, monthlyEmotionLo const handleNextMonth = () => setCurrentDate((prevDate) => subMonths(prevDate, -DATE_MONTH_FIXER)); // 감정 필터 - const handleEmotionSelect = (emotion: EmotionType) => { + const handleEmotionSelect = (emotion: EmotionTypeEN) => { // 현재 선택된 감정과 같으면 초기화 if (selectedEmotion === emotion) { setSelectedEmotion(null); @@ -67,7 +67,7 @@ export default function Calendar({ currentDate, setCurrentDate, monthlyEmotionLo // 현재 날짜와 비교 const isToday = day === currentDate.getDate() && currentDate.getMonth() === new Date().getMonth() && currentDate.getFullYear() === new Date().getFullYear(); const dateString = `${currentDate.getFullYear()}-${String(currentDate.getMonth() + 1).padStart(2, '0')}-${String(day).padStart(2, '0')}`; - const emotion: EmotionType = filteredEmotionMap[dateString]; // 날짜에 해당하는 감정 가져오기 + const emotion: EmotionTypeEN = filteredEmotionMap[dateString]; // 날짜에 해당하는 감정 가져오기 const iconPath = emotion && iconPaths[emotion] ? iconPaths[emotion].path : '/icon/BW/SmileFaceBWIcon.svg'; return ( diff --git a/src/user/ui-profile/CalendarHeader.tsx b/src/user/ui-profile/CalendarHeader.tsx index c3a56d4e..2c337e1c 100644 --- a/src/user/ui-profile/CalendarHeader.tsx +++ b/src/user/ui-profile/CalendarHeader.tsx @@ -1,7 +1,7 @@ import { DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, DropdownMenuGroup, DropdownMenu } from '@/components/ui/dropdown-menu'; import { Button } from '@/components/ui/button'; import Image from 'next/image'; -import { EmotionType } from '@/types/emotion'; +import { EmotionTypeEN } from '@/types/emotion'; import ARROW_BOTTOM_ICON from '../../../public/icon/arrow-bottom-icon.svg'; import ARROW_RIGHT_ICON from '../../../public/icon/arrow-right-icon.svg'; import ARROW_LEFT_ICON from '../../../public/icon/arrow-left-icon.svg'; @@ -11,8 +11,8 @@ interface CalendarHeaderProps { currentDate: Date; onPrevMonth: () => void; onNextMonth: () => void; - onEmotionSelect: (emotion: EmotionType) => void; - selectEmotion: EmotionType | null; + onEmotionSelect: (emotion: EmotionTypeEN) => void; + selectEmotion: EmotionTypeEN | null; } export default function CalendarHeader({ currentDate, onPrevMonth, onNextMonth, onEmotionSelect, selectEmotion }: CalendarHeaderProps) { @@ -35,7 +35,7 @@ export default function CalendarHeader({ currentDate, onPrevMonth, onNextMonth, diff --git a/src/user/ui-profile/Chart.tsx b/src/user/ui-profile/Chart.tsx index c84e3276..6e89af4c 100644 --- a/src/user/ui-profile/Chart.tsx +++ b/src/user/ui-profile/Chart.tsx @@ -1,4 +1,4 @@ -import { EmotionLog, EmotionType } from '@/types/emotion'; +import { EmotionLog, EmotionTypeEN } from '@/types/emotion'; import Image from 'next/image'; import { iconPaths } from '../utill/constants'; @@ -21,7 +21,7 @@ export default function Chart({ monthlyEmotionLogs }: ChartProps) { // 감정 종류 및 총 감정 수 계산 const TOTAL_COUNT = monthlyEmotionLogs.length; - const EMOTIONS: EmotionType[] = ['MOVED', 'HAPPY', 'WORRIED', 'SAD', 'ANGRY']; + const EMOTIONS: EmotionTypeEN[] = ['MOVED', 'HAPPY', 'WORRIED', 'SAD', 'ANGRY']; const RADIUS = 90; // 원의 반지름 const CIRCUMFERENCE = 2 * Math.PI * RADIUS; diff --git a/src/user/ui-profile/EmotionMonthlyLogs.tsx b/src/user/ui-profile/EmotionMonthlyLogs.tsx index ee1f9a2c..18d4ada8 100644 --- a/src/user/ui-profile/EmotionMonthlyLogs.tsx +++ b/src/user/ui-profile/EmotionMonthlyLogs.tsx @@ -1,4 +1,4 @@ -import useMonthlyEmotionLogs from '@/hooks/useGetEmotion'; +import { useMonthlyEmotionLogs } from '@/hooks/useGetEmotion'; import { Emotion } from '@/types/emotion'; import { useEffect, useState } from 'react'; import Calendar from './Calendar'; diff --git a/src/utils/emotionMap.ts b/src/utils/emotionMap.ts new file mode 100644 index 00000000..84b5663e --- /dev/null +++ b/src/utils/emotionMap.ts @@ -0,0 +1,23 @@ +import { EmotionType } from '@/types/emotion'; + +const emotionMap: Record = { + 감동: 'MOVED', + 기쁨: 'JOY', + 고민: 'WORRY', + 슬픔: 'SADNESS', + 분노: 'ANGER', +}; + +const reverseEmotionMap: Record<'MOVED' | 'JOY' | 'WORRY' | 'SADNESS' | 'ANGER', EmotionType> = { + MOVED: '감동', + JOY: '기쁨', + WORRY: '고민', + SADNESS: '슬픔', + ANGER: '분노', +}; + +const translateEmotionToEnglish = (emotion: EmotionType): 'MOVED' | 'JOY' | 'WORRY' | 'SADNESS' | 'ANGER' => emotionMap[emotion]; + +const translateEmotionToKorean = (emotion: 'MOVED' | 'JOY' | 'WORRY' | 'SADNESS' | 'ANGER'): EmotionType => reverseEmotionMap[emotion]; + +export { translateEmotionToEnglish, translateEmotionToKorean };