this repo has no description
1import { useEffect, useCallback, useMemo } from 'react';
2import { useParams, useNavigate } from 'react-router-dom';
3import Box from '@mui/material/Box';
4import Button from '@mui/material/Button';
5import IconButton from '@mui/material/IconButton';
6import Typography from '@mui/material/Typography';
7import CircularProgress from '@mui/material/CircularProgress';
8import CloseIcon from '@mui/icons-material/Close';
9import { useLesson } from '../hooks/useLesson';
10import { useSpeech } from '../hooks/useSpeech';
11import { getLesson } from '../content/loader';
12import { apiClient } from '../api/client';
13import { ProgressBar } from '../components/layout/ProgressBar';
14import { HeartsDisplay } from '../components/common/HeartsDisplay';
15import { MultipleChoice } from '../components/exercises/MultipleChoice';
16import { Translation, checkTranslation } from '../components/exercises/Translation';
17import { MatchingPairs } from '../components/exercises/MatchingPairs';
18import { FillInTheBlank } from '../components/exercises/FillInTheBlank';
19import { SpeakExercise } from '../components/exercises/SpeakExercise';
20import { CorrectBanner } from '../components/feedback/CorrectBanner';
21import { IncorrectBanner } from '../components/feedback/IncorrectBanner';
22import { SpeechBubble } from '../components/mascot/SpeechBubble';
23import type { Exercise } from '../types/lesson';
24
25function getCorrectAnswer(exercise: Exercise): string {
26 switch (exercise.type) {
27 case 'multiple-choice':
28 return exercise.choices[exercise.correctIndex];
29 case 'translation':
30 return exercise.acceptedAnswers[0];
31 case 'matching-pairs':
32 return exercise.pairs.map((p) => `${p.left} = ${p.right}`).join(', ');
33 case 'fill-in-the-blank':
34 return exercise.blank;
35 case 'speak':
36 return exercise.acceptedAnswers[0];
37 }
38}
39
40function isAnswerCorrect(exercise: Exercise, answer: unknown): boolean {
41 switch (exercise.type) {
42 case 'multiple-choice':
43 return answer === exercise.correctIndex;
44
45 case 'translation':
46 return checkTranslation(
47 (answer as string) || '',
48 exercise.acceptedAnswers,
49 );
50
51 case 'matching-pairs': {
52 const pairs = answer as Array<[string, string]> | null;
53 return pairs != null && pairs.length === exercise.pairs.length;
54 }
55
56 case 'fill-in-the-blank': {
57 const input = ((answer as string) || '').trim().toLowerCase();
58 return input === exercise.blank.trim().toLowerCase();
59 }
60
61 case 'speak': {
62 const spoken = ((answer as string) || '').trim().toLowerCase();
63 return exercise.acceptedAnswers.some(
64 (a) => a.trim().toLowerCase() === spoken,
65 );
66 }
67 }
68}
69
70export function LessonPage() {
71 const { topicId, lessonId } = useParams<{
72 topicId: string;
73 lessonId: string;
74 }>();
75 const navigate = useNavigate();
76 const {
77 state,
78 setExercises,
79 restoreState,
80 selectAnswer,
81 checkAnswer,
82 nextExercise,
83 } = useLesson();
84
85 const { speak } = useSpeech();
86
87 const lesson = useMemo(
88 () => (topicId && lessonId ? getLesson(topicId, lessonId) : null),
89 [topicId, lessonId],
90 );
91
92 // Load lesson exercises and restore saved state if available
93 useEffect(() => {
94 if (!lesson || !topicId || !lessonId) return;
95
96 interface SavedState {
97 current_index: number;
98 hearts: number;
99 correct_count: number;
100 }
101
102 apiClient
103 .get<SavedState | null>(`/api/lesson-state/${topicId}/${lessonId}`)
104 .then((saved) => {
105 if (saved && saved.current_index > 0) {
106 restoreState(
107 lesson.exercises,
108 saved.current_index,
109 saved.hearts,
110 saved.correct_count,
111 );
112 } else {
113 setExercises(lesson.exercises);
114 }
115 })
116 .catch(() => {
117 // If fetching saved state fails, just start fresh
118 setExercises(lesson.exercises);
119 });
120 }, [lesson, topicId, lessonId, setExercises, restoreState]);
121
122 const currentExercise: Exercise | undefined =
123 state.exercises[state.currentIndex];
124
125 // Auto-play pronunciation when a new task is loaded
126 useEffect(() => {
127 if (!currentExercise || state.isChecked) return;
128
129 let textToSpeak: string | null = null;
130
131 if (currentExercise.type === 'speak') {
132 textToSpeak = currentExercise.phrase;
133 } else if (currentExercise.promptAudio) {
134 textToSpeak = currentExercise.audioText ?? currentExercise.prompt;
135 }
136
137 if (textToSpeak) {
138 speak(textToSpeak);
139 }
140 // Only trigger when the exercise index changes, not on every re-render
141 // eslint-disable-next-line react-hooks/exhaustive-deps
142 }, [state.currentIndex, state.exercises]);
143
144 const handleCheck = useCallback(() => {
145 if (!currentExercise) return;
146 const correct = isAnswerCorrect(currentExercise, state.selectedAnswer);
147 checkAnswer(correct);
148 }, [currentExercise, state.selectedAnswer, checkAnswer]);
149
150 const handleContinue = useCallback(() => {
151 if (!topicId || !lessonId) return;
152 nextExercise();
153 // Save progress after advancing to next exercise.
154 // Use current state values which reflect the CHECK_ANSWER result.
155 const nextIndex = state.currentIndex + 1;
156 const isLast = nextIndex >= state.exercises.length;
157 if (!isLast) {
158 apiClient
159 .put('/api/lesson-state', {
160 topic_id: topicId,
161 lesson_id: lessonId,
162 current_index: nextIndex,
163 hearts: state.hearts,
164 correct_count: state.correctCount,
165 })
166 .catch(() => {
167 // Save failed silently - not critical
168 });
169 }
170 }, [nextExercise, topicId, lessonId, state.currentIndex, state.exercises.length, state.hearts, state.correctCount]);
171
172 // Navigate to review when finished
173 useEffect(() => {
174 if (state.isFinished && lesson) {
175 navigate('/review', {
176 state: {
177 correctCount: state.correctCount,
178 total: state.exercises.length,
179 xp: lesson.xpReward,
180 topicId: lesson.topicId,
181 lessonId: lesson.id,
182 hearts: state.hearts,
183 },
184 replace: true,
185 });
186 }
187 }, [state.isFinished, state.correctCount, state.exercises.length, state.hearts, lesson, navigate]);
188
189 if (!lesson) {
190 return (
191 <Box
192 sx={{
193 display: 'flex',
194 flexDirection: 'column',
195 alignItems: 'center',
196 justifyContent: 'center',
197 flex: 1,
198 py: 8,
199 }}
200 >
201 <Typography color="text.secondary">Lesson not found</Typography>
202 <Button
203 variant="contained"
204 onClick={() => navigate('/home')}
205 sx={{ mt: 2 }}
206 >
207 Go Home
208 </Button>
209 </Box>
210 );
211 }
212
213 if (state.exercises.length === 0) {
214 return (
215 <Box
216 sx={{
217 display: 'flex',
218 justifyContent: 'center',
219 alignItems: 'center',
220 flex: 1,
221 }}
222 >
223 <CircularProgress color="primary" />
224 </Box>
225 );
226 }
227
228 if (!currentExercise) return null;
229
230 const hasAnswer = state.selectedAnswer != null && state.selectedAnswer !== '';
231
232 const showLowHearts = state.hearts > 0 && state.hearts <= 2 && !state.isChecked;
233 const lowHeartMsg = state.hearts === 1 ? 'Kaya mo pa yan!' : 'Konting tiis pa!';
234
235 return (
236 <Box
237 sx={{
238 display: 'flex',
239 flexDirection: 'column',
240 flex: 1,
241 maxWidth: 600,
242 mx: 'auto',
243 width: '100%',
244 p: 2,
245 }}
246 >
247 {/* Top bar: close, progress, hearts */}
248 <Box
249 sx={{
250 display: 'flex',
251 alignItems: 'center',
252 gap: 2,
253 mb: 4,
254 }}
255 >
256 <IconButton
257 onClick={() => navigate('/home')}
258 sx={{ color: 'text.secondary' }}
259 >
260 <CloseIcon />
261 </IconButton>
262 <Box sx={{ flex: 1 }}>
263 <ProgressBar
264 current={state.currentIndex}
265 total={state.exercises.length}
266 />
267 </Box>
268 <HeartsDisplay hearts={state.hearts} />
269 </Box>
270
271 {/* Rambi encouragement when hearts are low */}
272 {showLowHearts && (
273 <Box sx={{ display: 'flex', justifyContent: 'center', mb: 2 }}>
274 <SpeechBubble
275 mood={state.hearts === 1 ? 'sad' : 'encourage'}
276 size={48}
277 message={lowHeartMsg}
278 />
279 </Box>
280 )}
281
282 {/* Exercise area */}
283 <Box
284 sx={{
285 flex: 1,
286 display: 'flex',
287 flexDirection: 'column',
288 justifyContent: 'center',
289 pb: state.isChecked ? 12 : 0,
290 }}
291 >
292 {currentExercise.type === 'multiple-choice' && (
293 <MultipleChoice
294 exercise={currentExercise}
295 selectedAnswer={
296 typeof state.selectedAnswer === 'number'
297 ? state.selectedAnswer
298 : null
299 }
300 isChecked={state.isChecked}
301 isCorrect={state.isCorrect}
302 onSelect={(index) => selectAnswer(index)}
303 />
304 )}
305
306 {currentExercise.type === 'translation' && (
307 <Translation
308 exercise={currentExercise}
309 selectedAnswer={
310 typeof state.selectedAnswer === 'string'
311 ? state.selectedAnswer
312 : null
313 }
314 isChecked={state.isChecked}
315 onSelect={(text) => selectAnswer(text)}
316 />
317 )}
318
319 {currentExercise.type === 'matching-pairs' && (
320 <MatchingPairs
321 exercise={currentExercise}
322 selectedAnswer={
323 Array.isArray(state.selectedAnswer)
324 ? (state.selectedAnswer as Array<[string, string]>)
325 : null
326 }
327 isChecked={state.isChecked}
328 onSelect={(pairs) => selectAnswer(pairs)}
329 />
330 )}
331
332 {currentExercise.type === 'fill-in-the-blank' && (
333 <FillInTheBlank
334 exercise={currentExercise}
335 selectedAnswer={
336 typeof state.selectedAnswer === 'string'
337 ? state.selectedAnswer
338 : null
339 }
340 isChecked={state.isChecked}
341 onSelect={(text) => selectAnswer(text)}
342 />
343 )}
344
345 {currentExercise.type === 'speak' && (
346 <SpeakExercise
347 exercise={currentExercise}
348 selectedAnswer={
349 typeof state.selectedAnswer === 'string'
350 ? state.selectedAnswer
351 : null
352 }
353 isChecked={state.isChecked}
354 onSelect={(text) => selectAnswer(text)}
355 />
356 )}
357 </Box>
358
359 {/* Bottom: Check button (Continue is now inside feedback banners) */}
360 {!state.isChecked && (
361 <Box sx={{ py: 2 }}>
362 <Button
363 fullWidth
364 variant="contained"
365 color="primary"
366 disabled={!hasAnswer}
367 onClick={handleCheck}
368 sx={{ py: 1.5 }}
369 >
370 Check
371 </Button>
372 </Box>
373 )}
374
375 {/* Feedback banners with embedded Continue button */}
376 {state.isChecked && state.isCorrect === true && (
377 <CorrectBanner onContinue={handleContinue} />
378 )}
379 {state.isChecked && state.isCorrect === false && (
380 <IncorrectBanner
381 correctAnswer={getCorrectAnswer(currentExercise)}
382 onContinue={handleContinue}
383 />
384 )}
385 </Box>
386 );
387}