Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

FE-53 ✨ 오늘의 감정 저장, 조회 기능 구현 #60

Merged
merged 14 commits into from
Jul 23, 2024
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
25 changes: 25 additions & 0 deletions src/apis/getEmotion.ts
Original file line number Diff line number Diff line change
@@ -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<EmotionType | null> => {
const user = await getMe();
if (!user) {
throw new Error('로그인이 필요합니다.');
}

const response = await httpClient.get<GetEmotionResponseType>('/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;
24 changes: 24 additions & 0 deletions src/apis/postEmotion.ts
Original file line number Diff line number Diff line change
@@ -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<PostEmotionResponseType> => {
const user = await getMe();
if (!user) {
throw new Error('로그인이 필요합니다.');
}

const englishEmotion = translateEmotionToEnglish(emotion);
const request: PostEmotionRequestType = { emotion: englishEmotion };

const response = await httpClient.post<PostEmotionResponseType>('/emotionLogs/today', {
...request,
userId: user.id,
});

return response.data;
};

export default postEmotion;
2 changes: 1 addition & 1 deletion src/components/Emotion/EmotionCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down
34 changes: 34 additions & 0 deletions src/components/Emotion/EmotionSaveToast.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
/*
* 오늘의 감정을 선택하면 표시되는 toast입니다.
* 감정을 확인하기 위해 마이페이지로 연결됩니다.
*/
newjinlee marked this conversation as resolved.
Show resolved Hide resolved

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: (
<ToastAction altText='확인하기' onClick={() => router.push('/mypage')}>
확인하기
</ToastAction>
),
});
}, [iconType, toast, router]);

return null;
}

export default EmotionSaveToast;
81 changes: 63 additions & 18 deletions src/components/Emotion/EmotionSelector.tsx
Original file line number Diff line number Diff line change
@@ -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<Record<EmotionType, EmotionState>>({
감동: 'Default',
기쁨: 'Default',
Expand All @@ -22,13 +24,37 @@ function EmotionSelector() {
분노: 'Default',
});

// 감정 카드 클릭 핸들러
const handleCardClick = (iconType: EmotionType) => {
// 현재 선택된 감정을 관리하는 useState 훅
const [selectedEmotion, setSelectedEmotion] = useState<EmotionType | null>(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';
});
Expand All @@ -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';

Expand All @@ -54,12 +92,19 @@ function EmotionSelector() {
cardSize = 'sm';
}

if (isGetLoading) return <p>Loading...</p>;
if (getError) return <p>{getError.message}</p>;

return (
<div className={`justify-start items-start inline-flex ${containerClass}`}>
{(['감동', '기쁨', '고민', '슬픔', '분노'] as const).map((iconType) => (
<EmotionIconCard key={iconType} iconType={iconType} size={cardSize} state={states[iconType]} onClick={() => handleCardClick(iconType)} />
))}
</div>
<>
<div className={`justify-start items-start inline-flex ${containerClass}`}>
{(['감동', '기쁨', '고민', '슬픔', '분노'] as const).map((iconType) => (
<EmotionIconCard key={iconType} iconType={iconType} size={cardSize} state={states[iconType]} onClick={() => handleCardClick(iconType)} />
))}
</div>
{/* 감정이 선택되었을 때 토스트 메시지 표시 */}
{selectedEmotion && <EmotionSaveToast iconType={selectedEmotion} />}
</>
);
}

Expand Down
11 changes: 11 additions & 0 deletions src/hooks/useGetEmotion.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { useQuery } from '@tanstack/react-query';
import getEmotion from '@/apis/getEmotion';
import { EmotionType } from '@/types/emotion';

const useGetEmotion = () =>
useQuery<EmotionType | null, Error>({
queryKey: ['emotion'],
queryFn: getEmotion,
});

export default useGetEmotion;
11 changes: 11 additions & 0 deletions src/hooks/usePostEmotion.ts
Original file line number Diff line number Diff line change
@@ -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<PostEmotionResponseType, Error, EmotionType>({
mutationFn: postEmotion,
});

export default usePostEmotion;
23 changes: 23 additions & 0 deletions src/schema/emotion.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import * as z from 'zod';

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<typeof PostEmotionRequest>;
export type PostEmotionResponseType = z.infer<typeof PostEmotionResponse>;
export type GetEmotionResponseType = z.infer<typeof GetEmotionResponse>;
File renamed without changes.
23 changes: 23 additions & 0 deletions src/utils/emotionMap.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { EmotionType } from '@/types/emotion';

const emotionMap: Record<EmotionType, 'MOVED' | 'JOY' | 'WORRY' | 'SADNESS' | 'ANGER'> = {
감동: '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 };
Loading