Skip to content

Commit f71e636

Browse files
committed
refactor: 오늘의문제 가져오기&답안제출 기능 리팩토링
1 parent 10e7124 commit f71e636

3 files changed

Lines changed: 200 additions & 36 deletions

File tree

src/components/TodayQuizPage.tsx

Lines changed: 182 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -4,71 +4,167 @@ import { useQuery } from '@tanstack/react-query';
44
import { quizAPI } from '../utils/api';
55
import Container from './common/Container';
66
import Section from './common/Section';
7+
import { useModal } from '../hooks/useModal';
78

89
interface QuizData {
9-
quiz: string;
10+
question: string;
1011
choice1: string;
1112
choice2: string;
1213
choice3: string;
1314
choice4: string;
15+
answerNumber: string;
16+
commentary: string;
1417
}
1518

1619
interface AnswerResult {
1720
isCorrect: boolean;
1821
answer: string;
22+
commentary: string;
1923
}
2024

2125
// 임시 데이터
2226
const fakeTodayQuiz: QuizData = {
23-
quiz: "다음 중 JavaScript에서 변수를 선언하는 올바른 방법은?",
27+
question: "다음 중 JavaScript에서 변수를 선언하는 올바른 방법은?",
2428
choice1: "1. variable myVar = 10;",
2529
choice2: "2. let myVar = 10;",
2630
choice3: "3. declare myVar = 10;",
27-
choice4: "4. set myVar = 10;"
31+
choice4: "4. set myVar = 10;",
32+
answerNumber: "",
33+
commentary: ""
2834
};
2935

3036
const fakeAnswer: AnswerResult = {
3137
isCorrect: true,
32-
answer: "2. let myVar = 10;"
38+
answer: "2번. let myVar = 10;",
39+
commentary: "let은 ES6에서 도입된 블록 스코프 변수 선언 키워드입니다."
3340
};
3441

3542
const TodayQuizPage: React.FC = () => {
3643
const [searchParams] = useSearchParams();
3744
const [selectedAnswer, setSelectedAnswer] = useState<number | null>(null);
3845
const [isSubmitted, setIsSubmitted] = useState(false);
3946
const [answerResult, setAnswerResult] = useState<AnswerResult | null>(null);
47+
const { openModal } = useModal();
4048

4149
const subscriptionId = searchParams.get('subscriptionId');
4250
const quizId = searchParams.get('quizId');
4351

44-
const { data: quiz, isLoading, error } = useQuery({
52+
const { data: question, isLoading, error } = useQuery({
4553
queryKey: ['todayQuiz', subscriptionId, quizId],
4654
queryFn: async () => {
4755
const response = await quizAPI.getTodayQuiz(subscriptionId || undefined, quizId || undefined);
48-
console.log('Quiz response:', response);
4956

50-
return response as QuizData;
57+
// 다양한 응답 구조 처리
58+
let quizData;
59+
60+
if (response && typeof response === 'object') {
61+
// Case 1: { data: { question, choice1, choice2, choice3, choice4 } }
62+
if ('data' in response && response.data) {
63+
quizData = response.data;
64+
}
65+
// Case 2: { question, choice1, choice2, choice3, choice4 } 직접
66+
else if ('question' in response) {
67+
quizData = response;
68+
}
69+
// Case 3: 기타 구조
70+
else {
71+
quizData = response;
72+
}
73+
} else {
74+
quizData = null;
75+
}
76+
77+
return quizData as QuizData;
5178
},
5279
enabled: !!(subscriptionId && quizId),
5380
});
81+
5482

5583
const handleSubmit = async (e: React.FormEvent) => {
5684
e.preventDefault();
5785

5886
if (selectedAnswer === null) {
59-
alert('선택지를 먼저 클릭해주세요!');
87+
openModal({
88+
title: '선택 필요',
89+
content: (
90+
<div className="text-center py-4">
91+
<div className="w-16 h-16 mx-auto mb-4 bg-yellow-100 rounded-full flex items-center justify-center">
92+
<svg className="w-8 h-8 text-yellow-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
93+
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L3.732 16.5c-.77.833.192 2.5 1.732 2.5z" />
94+
</svg>
95+
</div>
96+
<p className="text-gray-700 text-lg">선택지를 먼저 클릭해주세요!</p>
97+
</div>
98+
),
99+
size: 'sm'
100+
});
60101
return;
61102
}
62103

63-
if (quizId && subscriptionId) {
104+
if (quizId && subscriptionId && displayQuiz) {
64105
try {
65-
const result = await quizAPI.submitTodayQuizAnswer(quizId, selectedAnswer, subscriptionId);
66-
setAnswerResult(result as AnswerResult);
106+
// API로 답안 제출 (기록용)
107+
await quizAPI.submitTodayQuizAnswer(quizId, selectedAnswer, subscriptionId);
108+
109+
// 클라이언트에서 정답 검증
110+
const correctAnswerNumber = parseInt(displayQuiz.answerNumber);
111+
const isCorrect = selectedAnswer === correctAnswerNumber;
112+
113+
// 정답 텍스트 찾기
114+
const answerText = displayQuiz[`choice${correctAnswerNumber}` as keyof QuizData] as string;
115+
const cleanAnswerText = answerText ? answerText.replace(/^\d+\.\s*/, '') : '';
116+
117+
const result: AnswerResult = {
118+
isCorrect,
119+
answer: `${correctAnswerNumber}번. ${cleanAnswerText}`,
120+
commentary: displayQuiz.commentary
121+
};
122+
123+
setAnswerResult(result);
67124
setIsSubmitted(true);
68-
} catch (error) {
69-
console.error('Failed to submit answer:', error);
70-
// API 실패 시 fake 데이터 사용
71-
setAnswerResult(fakeAnswer);
125+
} catch (error: any) {
126+
127+
// 400 에러 체크 및 메시지 처리
128+
if (error?.status === 400) {
129+
const errorMessage = error?.message || '잘못된 요청입니다. 답안 제출에 실패했습니다.';
130+
131+
openModal({
132+
title: '알림',
133+
content: (
134+
<div className="text-center py-4">
135+
<div className="w-16 h-16 mx-auto mb-4 bg-red-100 rounded-full flex items-center justify-center">
136+
<svg className="w-8 h-8 text-red-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
137+
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
138+
</svg>
139+
</div>
140+
<p className="text-gray-700 text-lg">{errorMessage}</p>
141+
</div>
142+
),
143+
size: 'sm'
144+
});
145+
return;
146+
}
147+
148+
// 추가적인 400 에러 체크 (다른 형태일 수 있음)
149+
// if (error?.message?.includes('400') || error?.toString()?.includes('400')) {
150+
// console.log('Found 400 in message, showing alert');
151+
// alert('이미 제출한 문제입니다.');
152+
// return;
153+
// }
154+
155+
// 기타 API 실패해도 클라이언트에서 정답 검증
156+
const correctAnswerNumber = parseInt(displayQuiz.answerNumber || '2');
157+
const isCorrect = selectedAnswer === correctAnswerNumber;
158+
const answerText = displayQuiz[`choice${correctAnswerNumber}` as keyof QuizData] as string;
159+
const cleanAnswerText = answerText ? answerText.replace(/^\d+\.\s*/, '') : '';
160+
161+
const result: AnswerResult = {
162+
isCorrect,
163+
answer: `${correctAnswerNumber}번. ${cleanAnswerText}`,
164+
commentary: displayQuiz.commentary || '해설 정보가 없습니다.'
165+
};
166+
167+
setAnswerResult(result);
72168
setIsSubmitted(true);
73169
}
74170
}
@@ -94,7 +190,7 @@ const TodayQuizPage: React.FC = () => {
94190
}
95191

96192
// API 실패 시 fake 데이터 사용
97-
const displayQuiz = quiz || fakeTodayQuiz;
193+
const displayQuiz = question || fakeTodayQuiz;
98194

99195
if (isSubmitted && answerResult) {
100196
const isCorrect = answerResult.isCorrect;
@@ -115,11 +211,19 @@ const TodayQuizPage: React.FC = () => {
115211
<div className="bg-white rounded-2xl p-8 shadow-sm border border-gray-100 mb-8">
116212
<h3 className="text-xl font-bold text-gray-900 mb-4">결과</h3>
117213
<p className="text-gray-700 leading-relaxed mb-4">답안이 성공적으로 제출되었습니다.</p>
118-
<div className="p-4 bg-brand-50 rounded-xl">
214+
215+
<div className="p-4 bg-brand-50 rounded-xl mb-6">
119216
<p className="text-brand-800 font-medium">
120217
정답: {answerResult.answer}
121218
</p>
122219
</div>
220+
221+
<div className="p-4 bg-gray-50 rounded-xl">
222+
<h4 className="text-lg font-bold text-gray-900 mb-2">해설</h4>
223+
<p className="text-gray-700 leading-relaxed">
224+
{answerResult.commentary}
225+
</p>
226+
</div>
123227
</div>
124228
</div>
125229
</Container>
@@ -148,26 +252,72 @@ const TodayQuizPage: React.FC = () => {
148252
<div className="font-pretendard text-center py-8">
149253
{/* Question Box */}
150254
<div className="bg-brand-50 border border-brand-200 rounded-xl p-6 text-lg font-medium text-gray-800 mb-8 max-w-4xl mx-auto text-left">
151-
<strong>Q. {displayQuiz.quiz}</strong>
255+
<strong>Q. {displayQuiz?.question}</strong>
152256
</div>
153257

154258
{/* Quiz Form */}
155259
<form onSubmit={handleSubmit}>
156260
{/* Options List - 세로 배치로 변경 */}
157-
<div className="space-y-3 max-w-4xl mx-auto mb-8">
158-
{[displayQuiz.choice1, displayQuiz.choice2, displayQuiz.choice3, displayQuiz.choice4].map((choice, index) => (
159-
<div
160-
key={index}
161-
onClick={() => handleOptionClick(index + 1)}
162-
className={`border-2 p-4 rounded-xl cursor-pointer transition-all duration-200 hover:shadow-md text-left ${
163-
selectedAnswer === index + 1
164-
? 'border-brand-500 bg-brand-100 text-brand-800'
165-
: 'border-gray-300 bg-white text-gray-700 hover:border-brand-300'
166-
}`}
167-
>
168-
<span className="font-medium leading-relaxed break-words">{choice}</span>
169-
</div>
170-
))}
261+
<div className="space-y-4 max-w-4xl mx-auto mb-8">
262+
{[displayQuiz?.choice1, displayQuiz?.choice2, displayQuiz?.choice3, displayQuiz?.choice4].map((choice, index) => {
263+
const isSelected = selectedAnswer === index + 1;
264+
265+
// 앞의 "1. 2. 3. 4." 숫자 부분만 제거
266+
const displayChoice = choice ? choice.replace(/^\d+\.\s*/, '') : '';
267+
const optionNumber = index + 1;
268+
269+
return (
270+
<div
271+
key={index}
272+
onClick={() => handleOptionClick(index + 1)}
273+
className={`group relative p-5 rounded-2xl cursor-pointer transition-all duration-300 transform hover:scale-[1.02] hover:shadow-lg text-left border-2 ${
274+
isSelected
275+
? 'border-brand-500 bg-gradient-to-r from-brand-50 to-brand-100 shadow-md'
276+
: 'border-gray-200 bg-white hover:border-brand-300 hover:bg-gray-50'
277+
}`}
278+
>
279+
<div className="flex items-center space-x-4">
280+
{/* Option Number with modern design */}
281+
<div className={`flex-shrink-0 w-10 h-10 rounded-xl flex items-center justify-center font-bold text-lg transition-all duration-300 ${
282+
isSelected
283+
? 'bg-gradient-to-br from-brand-500 to-brand-600 text-white shadow-md'
284+
: 'bg-gradient-to-br from-gray-100 to-gray-200 text-gray-600 group-hover:from-brand-100 group-hover:to-brand-200 group-hover:text-brand-700'
285+
}`}>
286+
<span className="font-extrabold">{optionNumber}</span>
287+
</div>
288+
289+
{/* Option Text */}
290+
<div className="flex-1 min-w-0">
291+
<span className={`font-medium leading-relaxed break-words transition-colors duration-300 ${
292+
isSelected
293+
? 'text-brand-800'
294+
: 'text-gray-700 group-hover:text-gray-900'
295+
}`}>
296+
{displayChoice}
297+
</span>
298+
</div>
299+
300+
{/* Selected Indicator */}
301+
{isSelected && (
302+
<div className="flex-shrink-0">
303+
<div className="w-7 h-7 rounded-xl bg-gradient-to-br from-green-500 to-green-600 flex items-center justify-center shadow-md">
304+
<svg className="w-4 h-4 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
305+
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={3} d="M5 13l4 4L19 7" />
306+
</svg>
307+
</div>
308+
</div>
309+
)}
310+
</div>
311+
312+
{/* Hover Effect Border */}
313+
<div className={`absolute inset-0 rounded-2xl transition-opacity duration-300 ${
314+
isSelected
315+
? 'opacity-0'
316+
: 'opacity-0 group-hover:opacity-100 bg-gradient-to-r from-brand-500/5 to-navy-500/5'
317+
}`} />
318+
</div>
319+
);
320+
})}
171321
</div>
172322

173323
{/* Submit Button */}

src/components/common/EmailTemplate.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ const EmailTemplate: React.FC<EmailTemplateProps> = ({ toEmail, quizLink }) => {
4242
onClick={() => navigate('/quiz')}
4343
className="inline-block bg-gradient-to-r from-brand-500 to-brand-600 text-white px-8 py-4 rounded-full font-semibold text-lg transition-all duration-300 hover:from-brand-600 hover:to-brand-700 hover:shadow-lg"
4444
>
45-
🧠 문제 풀러 가기
45+
🧠 연습문제 풀기
4646
</button>
4747
</div>
4848

src/utils/api.ts

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,18 @@ async function apiRequest<T>(
2121
const response = await fetch(url, config);
2222

2323
if (!response.ok) {
24-
throw new Error(`HTTP error! status: ${response.status}`);
24+
// 에러 응답 본문 파싱
25+
let errorData;
26+
try {
27+
errorData = await response.json();
28+
} catch {
29+
errorData = { message: `HTTP error! status: ${response.status}` };
30+
}
31+
32+
const error = new Error(errorData.message || `HTTP error! status: ${response.status}`);
33+
(error as any).status = response.status;
34+
(error as any).data = errorData;
35+
throw error;
2536
}
2637

2738
return await response.json();
@@ -127,10 +138,13 @@ export const quizAPI = {
127138
},
128139

129140
// TodayQuiz 답안 제출
130-
submitTodayQuizAnswer: async (quizId: string, answer: number, subscriptionId: string) => {
141+
submitTodayQuizAnswer: async (quizId: string, answerNumber: number, subscriptionId: string) => {
131142
return apiRequest(`/quizzes/${quizId}`, {
132143
method: 'POST',
133-
body: JSON.stringify({ answer, subscriptionId }),
144+
body: JSON.stringify({
145+
answer: answerNumber.toString(),
146+
subscriptionId: parseInt(subscriptionId)
147+
}),
134148
});
135149
},
136150

0 commit comments

Comments
 (0)