From a1664ab12c2e64b0ca5aafad09643aa9aeda1f13 Mon Sep 17 00:00:00 2001 From: Benjamin Admin Date: Mon, 27 Apr 2026 18:03:35 +0200 Subject: [PATCH] Redesign Match exercise: 3 columns, audio, scoring, native language Major improvements: 1. Third column shows native language translation (TR/AR/UK/RU) 2. Clicking EN word flashes native translation briefly (2s overlay) 3. German column has audio button on each word (speaker icon) 4. Native column has audio button for each translation 5. Scoring: tracks first-try correct vs retry vs errors separately 6. Full points only for error-free completion 7. "Nochmal" button always available to repeat the unit 8. Header shows live score: green/yellow/red counters 9. All buttons use translation system (t('back'), t('match'), etc.) Co-Authored-By: Claude Opus 4.6 (1M context) --- studio-v2/app/learn/[unitId]/match/page.tsx | 225 ++++++++++++++------ 1 file changed, 165 insertions(+), 60 deletions(-) diff --git a/studio-v2/app/learn/[unitId]/match/page.tsx b/studio-v2/app/learn/[unitId]/match/page.tsx index f9aa063..3efbaf6 100644 --- a/studio-v2/app/learn/[unitId]/match/page.tsx +++ b/studio-v2/app/learn/[unitId]/match/page.tsx @@ -5,8 +5,12 @@ import { useParams, useRouter } from 'next/navigation' import { useTheme } from '@/lib/ThemeContext' import { useNativeLanguage } from '@/lib/useNativeLanguage' import { StarRating, accuracyToStars } from '@/components/gamification/StarRating' +import { AudioButton } from '@/components/learn/AudioButton' -interface QAItem { id: string; question: string; answer: string } +interface QAItem { + id: string; question: string; answer: string + translations?: Record +} function getApiBase() { return '' } @@ -22,7 +26,11 @@ export default function MatchPage() { const [selectedLeft, setSelectedLeft] = useState(null) const [matched, setMatched] = useState>(new Set()) const [wrongPair, setWrongPair] = useState(null) + const [firstTryCorrect, setFirstTryCorrect] = useState(0) + const [retryCorrect, setRetryCorrect] = useState(0) const [errors, setErrors] = useState(0) + const [failedIds, setFailedIds] = useState>(new Set()) + const [flashNative, setFlashNative] = useState(null) const [isComplete, setIsComplete] = useState(false) const glassCard = isDark @@ -38,32 +46,44 @@ export default function MatchPage() { })() }, [unitId]) - // Take 6 items per round const roundItems = useMemo(() => { const start = round * 6 return allItems.slice(start, start + 6) }, [allItems, round]) - // Shuffled right column const shuffledRight = useMemo(() => { return [...roundItems].sort(() => Math.random() - 0.5) }, [roundItems]) - const handleLeftTap = useCallback((id: string) => { - if (matched.has(id)) return - setSelectedLeft(id === selectedLeft ? null : id) + const handleLeftTap = useCallback((item: QAItem) => { + if (matched.has(item.id)) return + setSelectedLeft(item.id === selectedLeft ? null : item.id) setWrongPair(null) - }, [selectedLeft, matched]) + + // Flash native translation briefly + if (isThirdLanguage && item.id !== selectedLeft) { + const native = wordInNative(item.translations) + if (native) { + setFlashNative(native) + setTimeout(() => setFlashNative(null), 2000) + } + } + }, [selectedLeft, matched, isThirdLanguage, wordInNative]) const handleRightTap = useCallback((id: string) => { if (!selectedLeft || matched.has(id)) return if (selectedLeft === id) { - // Correct match + // Correct setMatched(prev => new Set([...prev, id])) + if (failedIds.has(id)) { + setRetryCorrect(c => c + 1) + } else { + setFirstTryCorrect(c => c + 1) + } setSelectedLeft(null) + setFlashNative(null) - // Check if round complete if (matched.size + 1 >= roundItems.length) { const nextStart = (round + 1) * 6 if (nextStart >= allItems.length) { @@ -77,92 +97,177 @@ export default function MatchPage() { } } } else { - // Wrong match + // Wrong setWrongPair(id) setErrors(e => e + 1) + setFailedIds(prev => new Set([...prev, selectedLeft])) setTimeout(() => { setWrongPair(null) setSelectedLeft(null) + setFlashNative(null) }, 600) } - }, [selectedLeft, matched, roundItems, round, allItems]) + }, [selectedLeft, matched, roundItems, round, allItems, failedIds]) + + const restart = () => { + setRound(0) + setMatched(new Set()) + setFirstTryCorrect(0) + setRetryCorrect(0) + setErrors(0) + setFailedIds(new Set()) + setIsComplete(false) + setSelectedLeft(null) + } if (isLoading) { - return
+ return
} const totalPairs = allItems.length const matchedTotal = round * 6 + matched.size + const isPerfect = isComplete && errors === 0 return ( <> {/* Header */}
-
+
-

Zuordnen

- {matchedTotal}/{totalPairs} +

{t('match')}

+
+ {matchedTotal}/{totalPairs} · {firstTryCorrect}/{retryCorrect}/{errors} +
+ {/* Native flash overlay */} + {flashNative && ( +
+
+ {flashNative} +
+
+ )} +
{isComplete ? (
- -

Alle zugeordnet!

-

{errors} Fehler

+ +

+ {isPerfect ? t('well_done') : t('all_matched')} +

+
+
{firstTryCorrect}
{t('correct')}
+
{retryCorrect}
2. Versuch
+
{errors}
{t('errors')}
+
+ {!isPerfect && ( +

+ {isThirdLanguage + ? (nativeLang === 'tr' ? 'Tam puan icin hatasiz tamamlayin' : 'Fuer volle Punkte fehlerfrei abschliessen') + : 'Fuer volle Punkte fehlerfrei abschliessen'} +

+ )}
- - + +
) : ( -
- {/* Left column: English */} -
-

English

- {roundItems.map(item => ( - - ))} +
+ {/* Column headers */} +
+

{t('english')}

+

{t('german')}

+ {isThirdLanguage && ( +

{nativeLang.toUpperCase()}

+ )}
- {/* Right column: German (shuffled) */} -
-

Deutsch

- {shuffledRight.map(item => ( - - ))} + {/* Grid */} +
+ {/* Left: English (click to select, no sound) */} +
+ {roundItems.map(item => ( + + ))} +
+ + {/* Middle: German (click to match + sound) */} +
+ {shuffledRight.map(item => ( +
+ + {!matched.has(item.id) && ( + + )} +
+ ))} +
+ + {/* Right: Native language (display only + sound) */} + {isThirdLanguage && ( +
+ {roundItems.map(item => { + const native = wordInNative(item.translations) + return ( +
+ {native || '—'} + {native && !matched.has(item.id) && ( + + )} +
+ ) + })} +
+ )}
)}