From 4c87c30f0e481562fcc0b32571507926c11f4688 Mon Sep 17 00:00:00 2001 From: smosco Date: Fri, 29 Nov 2024 15:03:42 +0900 Subject: [PATCH 1/3] =?UTF-8?q?feat:=20=ED=80=B4=EC=A6=88=20=ED=9E=8C?= =?UTF-8?q?=ED=8A=B8=20api=20=EC=9A=94=EC=B2=AD=20=EC=BF=BC=EB=A6=AC,=20?= =?UTF-8?q?=ED=9B=85=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/api/hooks/useQuiz.ts | 16 ++++++++++++++-- src/api/queries/quizQueries.ts | 15 ++++++++++++++- src/types/Quiz.ts | 8 ++++++++ 3 files changed, 36 insertions(+), 3 deletions(-) diff --git a/src/api/hooks/useQuiz.ts b/src/api/hooks/useQuiz.ts index da1be85d..975c7dff 100644 --- a/src/api/hooks/useQuiz.ts +++ b/src/api/hooks/useQuiz.ts @@ -1,13 +1,14 @@ -import { useQuery, useMutation } from '@tanstack/react-query'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import useUserLoginStatus from '@/api/hooks/useUserLoginStatus'; import { CheckQuizAnswerResponse, CheckAnswerRequest, FetchQuizResponse, + ViewHintResponse, } from '@/types/Quiz'; -import { checkQuizAnswer, fetchQuiz } from '../queries/quizQueries'; +import { checkQuizAnswer, fetchQuiz, viewHint } from '../queries/quizQueries'; export const useFetchQuiz = (contentId: number, showQuiz: boolean) => { const { data: isLoginData } = useUserLoginStatus(); @@ -26,3 +27,14 @@ export const useCheckQuestionAnswer = () => { checkQuizAnswer(questionAnswer), }); }; + +export const useViewHint = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (questionId: string) => viewHint(questionId), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['currentPoints'] }); + }, + }); +}; diff --git a/src/api/queries/quizQueries.ts b/src/api/queries/quizQueries.ts index 86c3466b..1628c09f 100644 --- a/src/api/queries/quizQueries.ts +++ b/src/api/queries/quizQueries.ts @@ -1,5 +1,9 @@ import { apiClient } from '@/lib/apiClient'; -import { FetchQuizResponse, CheckQuizAnswerResponse } from '@/types/Quiz'; +import { + FetchQuizResponse, + CheckQuizAnswerResponse, + ViewHintResponse, +} from '@/types/Quiz'; // 퀴즈 조회 (GET) export const fetchQuiz = async ( @@ -20,3 +24,12 @@ export const checkQuizAnswer = async (quizAnswer: { body: JSON.stringify(quizAnswer), }); }; + +export const viewHint = async ( + questionId: string, +): Promise => { + return apiClient('/questions/hint/view', { + method: 'POST', + body: JSON.stringify({ questionId }), + }); +}; diff --git a/src/types/Quiz.ts b/src/types/Quiz.ts index 0cbb2949..6e793a74 100644 --- a/src/types/Quiz.ts +++ b/src/types/Quiz.ts @@ -23,3 +23,11 @@ export interface CheckAnswerRequest { questionId: string; answer: string; } + +export interface ViewHintResponse { + code: string; + message: string; + data: { + hint: string; + }; +} From a9545a1c2e64074335fa8a4f0bda3f03d20d9186 Mon Sep 17 00:00:00 2001 From: smosco Date: Fri, 29 Nov 2024 15:10:41 +0900 Subject: [PATCH 2/3] =?UTF-8?q?feat:=20=ED=9E=8C=ED=8A=B8=20=EB=B3=B4?= =?UTF-8?q?=EA=B8=B0=20=EA=B8=B0=EB=8A=A5=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/quiz/GeneralQuiz.tsx | 211 ++++++++++++++++++---- src/components/quiz/OrderQuiz.tsx | 266 +++++++++++++++++++--------- 2 files changed, 354 insertions(+), 123 deletions(-) diff --git a/src/components/quiz/GeneralQuiz.tsx b/src/components/quiz/GeneralQuiz.tsx index 178fd7db..91eb457e 100644 --- a/src/components/quiz/GeneralQuiz.tsx +++ b/src/components/quiz/GeneralQuiz.tsx @@ -2,17 +2,21 @@ import { useState } from 'react'; +import { LightbulbIcon } from 'lucide-react'; + +import { useFetchCurrentPoints } from '@/api/hooks/useDashboard'; import { useFetchMissionStatus, useUpdateMissionStatus, } from '@/api/hooks/useMission'; -import { useCheckQuestionAnswer } from '@/api/hooks/useQuiz'; +import { useCheckQuestionAnswer, useViewHint } from '@/api/hooks/useQuiz'; import { Button } from '@/components/ui/button'; -import { CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { useToast } from '@/hooks/use-toast'; import { QuestionState, DomainEvent } from '@/lib/quizReducer'; import { cn } from '@/lib/utils'; +import Modal from '../common/Modal'; + interface GeneralQuizProps { question: QuestionState; dispatch: React.Dispatch; @@ -31,7 +35,18 @@ export default function GeneralQuiz({ const { data: missionStatus } = useFetchMissionStatus(); const { mutate: updateMissionStatus } = useUpdateMissionStatus(); - const toast = useToast(); + const viewHintMutation = useViewHint(); + + const [hint, setHint] = useState(''); + + const [showPointModal, setShowPointModal] = useState(false); + const [showPointErrorModal, setShowPointErrorModal] = useState(false); + + const { data: pointsData } = useFetchCurrentPoints(); + const userPoints = pointsData?.data.currentPoint || 0; + const requiredPoints = 5; // 항상 5 포인트 차감 + + const { toast } = useToast(); const handleSubmitAnswer = (questionId: string, answer: number) => { const event: DomainEvent = { @@ -61,7 +76,7 @@ export default function GeneralQuiz({ onSuccess: (response) => { const ok = response.data; handleAnswerResponse(question.questionId, ok); - if (ok) toast.toast({ description: '5 포인트 획득!' }); + if (ok) toast({ description: '5 포인트 획득!' }); if (!missionStatus?.data.quiz) { updateMissionStatus({ quiz: true }); } @@ -73,49 +88,169 @@ export default function GeneralQuiz({ ); }; + const handleHintRequest = () => { + if (userPoints < requiredPoints) { + setShowPointErrorModal(true); + } else { + setShowPointModal(true); + } + }; + + const handleConfirm = async () => { + try { + const success = await viewHintMutation.mutateAsync(question.questionId); + if (success) { + setHint(success.data.hint); + toast({ + description: `${requiredPoints} 포인트가 차감되었습니다.`, + }); + } + } catch (error) { + toast({ title: '힌트를 불러오지 못했습니다.', duration: 1000 }); + } finally { + setShowPointModal(false); + } + }; + + const handleNext = () => { + if (onNext) onNext(); + setSelectedAnswer(null); + }; + return ( -
- - - {question.question} - - - - - {question.examples.map((option, index) => { - const isSelected = selectedAnswer === index; - const isCorrectAnswer = isSelected && question.status === 'correct'; - const isWrongAnswer = isSelected && question.status === 'wrong'; - - return ( +
+
+
{question.question}
+
+
+ {/* 힌트 */} +
+ {hint ? ( +
+

+ 힌트: {hint} +

+
+ ) : ( - ); - })} + )} +
+ {/* 선택지 */} +
+ {question.examples.map((option, index) => { + const isSelected = selectedAnswer === index; + const isCorrectAnswer = isSelected && question.status === 'correct'; + const isWrongAnswer = isSelected && question.status === 'wrong'; + + return ( + + ); + })} +
+ + {/* 결과 메세지 */} + {question.status !== 'ready' && ( +
+ {question.status === 'correct' ? '정답입니다! 🎉' : '오답입니다 😢'} +
+ )} + + {/* 다음 문제 넘어가기 */} {question.status !== 'ready' && ( - )} - +
+ + {/* 힌트 포인트 모달 */} + {showPointErrorModal && ( + setShowPointErrorModal(false)} + title="포인트가 부족해요" + description={`현재 포인트: ${userPoints}P / 필요 포인트: ${requiredPoints}P`} + > +
+

+ 퀴즈를 풀어서 포인트를 모아보세요! 😊 +

+ +
+
+ )} + + {showPointModal && ( + setShowPointModal(false)} + title="힌트 보기" + description={`${requiredPoints}P를 사용하시겠습니까?`} + > +
+

+ 현재 포인트: {userPoints}P → 차감 후: + {userPoints - requiredPoints}P +

+
+ + +
+
+
+ )}
); } diff --git a/src/components/quiz/OrderQuiz.tsx b/src/components/quiz/OrderQuiz.tsx index 4a704290..2650ede6 100644 --- a/src/components/quiz/OrderQuiz.tsx +++ b/src/components/quiz/OrderQuiz.tsx @@ -1,21 +1,23 @@ -/* eslint-disable jsx-a11y/no-static-element-interactions */ -/* eslint-disable jsx-a11y/click-events-have-key-events */ - 'use client'; import { useState } from 'react'; +import { LightbulbIcon } from 'lucide-react'; + +import { useFetchCurrentPoints } from '@/api/hooks/useDashboard'; import { useFetchMissionStatus, useUpdateMissionStatus, } from '@/api/hooks/useMission'; -import { useCheckQuestionAnswer } from '@/api/hooks/useQuiz'; +import { useCheckQuestionAnswer, useViewHint } from '@/api/hooks/useQuiz'; import { Button } from '@/components/ui/button'; -import { CardHeader, CardTitle } from '@/components/ui/card'; +import { CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { useToast } from '@/hooks/use-toast'; import { QuestionState, DomainEvent } from '@/lib/quizReducer'; import { cn } from '@/lib/utils'; +import Modal from '../common/Modal'; + interface OrderQuizProps { question: QuestionState; dispatch: React.Dispatch; @@ -28,33 +30,50 @@ export default function OrderQuiz({ onNext, }: OrderQuizProps) { const [selectedOrder, setSelectedOrder] = useState([]); - const { mutate: checkAnswer } = useCheckQuestionAnswer(); - const { data: missionStatus } = useFetchMissionStatus(); const { mutate: updateMissionStatus } = useUpdateMissionStatus(); - - const toast = useToast(); + const viewHintMutation = useViewHint(); + const [hint, setHint] = useState(''); + const [showPointModal, setShowPointModal] = useState(false); + const [showPointErrorModal, setShowPointErrorModal] = useState(false); + const { data: pointsData } = useFetchCurrentPoints(); + const userPoints = pointsData?.data.currentPoint || 0; + const requiredPoints = 5; + const { toast } = useToast(); const handleSubmitAnswer = (questionId: string, answer: string) => { - const event: DomainEvent = { - type: 'submit_answer', - questionId, - answer, - }; - dispatch(event); + dispatch({ type: 'submit_answer', questionId, answer }); }; const handleAnswerResponse = (questionId: string, ok: boolean) => { - const event: DomainEvent = { - type: 'response_question_result', - questionId, - ok, - }; - dispatch(event); + dispatch({ type: 'response_question_result', questionId, ok }); + }; + + const handleHintRequest = () => { + if (userPoints < requiredPoints) { + setShowPointErrorModal(true); + } else { + setShowPointModal(true); + } + }; + + const handleHintConfirm = async () => { + try { + const success = await viewHintMutation.mutateAsync(question.questionId); + if (success) { + setHint(success.data.hint); + toast({ + description: `${requiredPoints} 포인트가 차감되었습니다.`, + }); + } + } catch (error) { + toast({ title: '힌트를 불러오지 못했습니다.', duration: 1000 }); + } finally { + setShowPointModal(false); + } }; - // 사용자가 선택한 순서를 저장하는 함수 const handleSelect = (index: number) => { if (question.status !== 'ready') return; @@ -72,12 +91,10 @@ export default function OrderQuiz({ }); }; - // 정답 제출 함수 const handleSubmit = async () => { if (selectedOrder.length !== 4) return; const userAnswer = selectedOrder.join(' '); - handleSubmitAnswer(question.questionId, userAnswer); checkAnswer( @@ -87,7 +104,7 @@ export default function OrderQuiz({ const ok = response.data; handleAnswerResponse(question.questionId, ok); - if (ok) toast.toast({ description: '5 포인트 획득!' }); + if (ok) toast({ description: '5 포인트 획득!' }); if (!missionStatus?.data.quiz) { updateMissionStatus({ quiz: true }); } @@ -101,93 +118,172 @@ export default function OrderQuiz({ const handleNext = () => { if (onNext) onNext(); - // 다음 문제 버튼 클릭하면 순서 초기화 setSelectedOrder([]); }; return (
- - + + {question.question} + + {/* 힌트 */} +
+ {hint ? ( +
+

+ 힌트: {hint} +

+
+ ) : ( + + )} +
+ + {/* 선택한 순서 */} +
+ {[0, 1, 2, 3].map((index) => ( +
+ {selectedOrder[index] !== undefined + ? selectedOrder[index] + 1 + : ''} +
+ ))} +
- {/* 사용자 선택 순서를 보여주는 UI */} -
- {[0, 1, 2, 3].map((index) => ( + {/* 선택 옵션 */} +
+ {question.examples.map((option, index) => ( + + ))} +
+ + {/* 결과 메세지 */} + {question.status !== 'ready' && (
- {selectedOrder[index] !== undefined - ? selectedOrder[index] + 1 - : '-'} + {question.status === 'correct' ? '정답입니다! 🎉' : '오답입니다 😢'}
- ))} -
- - {/* 문제 선택 옵션 */} -
- {question.examples.map((option, index) => ( - - ))} + )} - {/* 제출 버튼 또는 다음 문제 버튼 */} + {/* 제출 */} + {/*
*/} {question.status === 'ready' ? ( ) : ( - )} + {/*
*/} + - {/* 정답 여부 표시 */} - {question.status !== 'ready' && ( -
- {question.status === 'correct' ? '정답입니다!' : '오답입니다!'} + {/* 힌트 포인트 모달 */} + {showPointErrorModal && ( + setShowPointErrorModal(false)} + title="포인트가 부족해요" + description={`현재 포인트: ${userPoints}P / 필요 포인트: ${requiredPoints}P`} + > +
+

+ 퀴즈를 풀어서 포인트를 모아보세요! 😊 +

+
- )} -
+ + )} + + {showPointModal && ( + setShowPointModal(false)} + title="힌트 보기" + description={`${requiredPoints}P를 사용하시겠습니까?`} + > +
+

+ 현재 포인트: {userPoints}P → 차감 후: + {userPoints - requiredPoints}P +

+
+ + +
+
+
+ )}
); } From 76f0f949cb7112092a6b2cfe60845a253f43c0c3 Mon Sep 17 00:00:00 2001 From: smosco Date: Fri, 29 Nov 2024 15:41:05 +0900 Subject: [PATCH 3/3] =?UTF-8?q?feat:=20=EB=8B=89=EB=84=A4=EC=9E=84=20?= =?UTF-8?q?=EC=9C=A0=ED=9A=A8=EC=84=B1=20=EA=B2=80=EC=A6=9D=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/(default)/mypage/profile/page.tsx | 45 +++++++++++++++++++---- 1 file changed, 37 insertions(+), 8 deletions(-) diff --git a/src/app/(default)/mypage/profile/page.tsx b/src/app/(default)/mypage/profile/page.tsx index 6d0cfb6e..8de9821a 100644 --- a/src/app/(default)/mypage/profile/page.tsx +++ b/src/app/(default)/mypage/profile/page.tsx @@ -14,7 +14,7 @@ import { useToast } from '@/hooks/use-toast'; export default function UserProfile() { const { data: userData, refetch: refetchUserInfo } = useUserInfo(); const { data: categoryData } = useFetchAllCategories(); - const toast = useToast(); + const { toast } = useToast(); // 상태 설정 const [nickname, setNickname] = useState(''); @@ -36,15 +36,38 @@ export default function UserProfile() { // 닉네임 변경 const handleNicknameChange = () => { + // 닉네임 길이 검증 + if (nickname.length < 4 || nickname.length > 12) { + toast({ + duration: 1000, + description: '닉네임은 4~12자 사이여야 합니다.', + }); + return; + } + + // 닉네임 패턴 검증 + const nicknamePattern = /^[a-zA-Z가-힣0-9]+( [a-zA-Z가-힣0-9]+)*$/; + if (!nicknamePattern.test(nickname)) { + toast({ + duration: 1000, + description: '닉네임은 영어, 한글, 숫자 및 단일 공백만 허용됩니다.', + }); + return; + } + + // 서버로 닉네임 변경 요청 updateUserInfoMutation.mutate( { nickname }, { onSuccess: () => { - toast.toast({ description: '닉네임이 성공적으로 변경되었습니다.' }); + toast({ + duration: 1000, + description: '닉네임이 성공적으로 변경되었습니다.', + }); refetchUserInfo(); }, onError: () => { - toast.toast({ description: '닉네임을 변경하지 못했어요.' }); + toast({ duration: 1000, description: '닉네임을 변경하지 못했어요.' }); setNickname(userData?.data.nickname || ''); }, }, @@ -57,11 +80,14 @@ export default function UserProfile() { { username }, { onSuccess: () => { - toast.toast({ description: '이름이 성공적으로 변경되었습니다.' }); + toast({ + duration: 1000, + description: '이름이 성공적으로 변경되었습니다.', + }); refetchUserInfo(); }, onError: () => { - toast.toast({ description: '이름을 변경하지 못했어요.' }); + toast({ duration: 1000, description: '이름을 변경하지 못했어요.' }); setUsername(userData?.data.username || ''); }, }, @@ -88,7 +114,8 @@ export default function UserProfile() { return [...prevSelected, categoryId]; } - toast.toast({ + toast({ + duration: 1000, description: '카테고리는 최대 5개까지 선택 가능합니다.', }); @@ -112,13 +139,15 @@ export default function UserProfile() { { categories: selectedCategories }, { onSuccess: () => { - toast.toast({ + toast({ + duration: 1000, description: '카테고리가 성공적으로 변경되었습니다.', }); refetchUserInfo(); }, onError: () => { - toast.toast({ + toast({ + duration: 1000, description: '카테고리를 변경하지 못했습니다.', }); setSelectedCategories(