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) <noreply@anthropic.com>
This commit is contained in:
Benjamin Admin
2026-04-27 18:03:35 +02:00
parent 9f21bd070a
commit a1664ab12c

View File

@@ -5,8 +5,12 @@ import { useParams, useRouter } from 'next/navigation'
import { useTheme } from '@/lib/ThemeContext' import { useTheme } from '@/lib/ThemeContext'
import { useNativeLanguage } from '@/lib/useNativeLanguage' import { useNativeLanguage } from '@/lib/useNativeLanguage'
import { StarRating, accuracyToStars } from '@/components/gamification/StarRating' 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<string, any>
}
function getApiBase() { return '' } function getApiBase() { return '' }
@@ -22,7 +26,11 @@ export default function MatchPage() {
const [selectedLeft, setSelectedLeft] = useState<string | null>(null) const [selectedLeft, setSelectedLeft] = useState<string | null>(null)
const [matched, setMatched] = useState<Set<string>>(new Set()) const [matched, setMatched] = useState<Set<string>>(new Set())
const [wrongPair, setWrongPair] = useState<string | null>(null) const [wrongPair, setWrongPair] = useState<string | null>(null)
const [firstTryCorrect, setFirstTryCorrect] = useState(0)
const [retryCorrect, setRetryCorrect] = useState(0)
const [errors, setErrors] = useState(0) const [errors, setErrors] = useState(0)
const [failedIds, setFailedIds] = useState<Set<string>>(new Set())
const [flashNative, setFlashNative] = useState<string | null>(null)
const [isComplete, setIsComplete] = useState(false) const [isComplete, setIsComplete] = useState(false)
const glassCard = isDark const glassCard = isDark
@@ -38,32 +46,44 @@ export default function MatchPage() {
})() })()
}, [unitId]) }, [unitId])
// Take 6 items per round
const roundItems = useMemo(() => { const roundItems = useMemo(() => {
const start = round * 6 const start = round * 6
return allItems.slice(start, start + 6) return allItems.slice(start, start + 6)
}, [allItems, round]) }, [allItems, round])
// Shuffled right column
const shuffledRight = useMemo(() => { const shuffledRight = useMemo(() => {
return [...roundItems].sort(() => Math.random() - 0.5) return [...roundItems].sort(() => Math.random() - 0.5)
}, [roundItems]) }, [roundItems])
const handleLeftTap = useCallback((id: string) => { const handleLeftTap = useCallback((item: QAItem) => {
if (matched.has(id)) return if (matched.has(item.id)) return
setSelectedLeft(id === selectedLeft ? null : id) setSelectedLeft(item.id === selectedLeft ? null : item.id)
setWrongPair(null) 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) => { const handleRightTap = useCallback((id: string) => {
if (!selectedLeft || matched.has(id)) return if (!selectedLeft || matched.has(id)) return
if (selectedLeft === id) { if (selectedLeft === id) {
// Correct match // Correct
setMatched(prev => new Set([...prev, id])) setMatched(prev => new Set([...prev, id]))
if (failedIds.has(id)) {
setRetryCorrect(c => c + 1)
} else {
setFirstTryCorrect(c => c + 1)
}
setSelectedLeft(null) setSelectedLeft(null)
setFlashNative(null)
// Check if round complete
if (matched.size + 1 >= roundItems.length) { if (matched.size + 1 >= roundItems.length) {
const nextStart = (round + 1) * 6 const nextStart = (round + 1) * 6
if (nextStart >= allItems.length) { if (nextStart >= allItems.length) {
@@ -77,92 +97,177 @@ export default function MatchPage() {
} }
} }
} else { } else {
// Wrong match // Wrong
setWrongPair(id) setWrongPair(id)
setErrors(e => e + 1) setErrors(e => e + 1)
setFailedIds(prev => new Set([...prev, selectedLeft]))
setTimeout(() => { setTimeout(() => {
setWrongPair(null) setWrongPair(null)
setSelectedLeft(null) setSelectedLeft(null)
setFlashNative(null)
}, 600) }, 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) { if (isLoading) {
return <div className={`min-h-screen flex items-center justify-center ${isDark ? 'bg-gradient-to-br from-indigo-900 via-purple-900 to-pink-800' : 'bg-gradient-to-br from-slate-100 via-indigo-50 to-violet-100'}`}> return <div className="flex items-center justify-center py-20">
<div className="w-8 h-8 border-4 border-indigo-400 border-t-transparent rounded-full animate-spin" /> <div className="w-8 h-8 border-4 border-indigo-400 border-t-transparent rounded-full animate-spin" />
</div> </div>
} }
const totalPairs = allItems.length const totalPairs = allItems.length
const matchedTotal = round * 6 + matched.size const matchedTotal = round * 6 + matched.size
const isPerfect = isComplete && errors === 0
return ( return (
<> <>
{/* Header */} {/* Header */}
<div className={`${glassCard} border-0 border-b`}> <div className={`${glassCard} border-0 border-b`}>
<div className="max-w-2xl mx-auto px-6 py-4 flex items-center justify-between"> <div className="max-w-3xl mx-auto px-6 py-4 flex items-center justify-between">
<button onClick={() => router.push('/learn')} className={`flex items-center gap-2 text-sm ${isDark ? 'text-white/60 hover:text-white' : 'text-slate-500 hover:text-slate-900'}`}> <button onClick={() => router.push('/learn')} className={`flex items-center gap-2 text-sm ${isDark ? 'text-white/60 hover:text-white' : 'text-slate-500 hover:text-slate-900'}`}>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" /></svg> <svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" /></svg>
Zurueck {t('back')}
</button> </button>
<h1 className={`text-lg font-bold ${isDark ? 'text-white' : 'text-slate-900'}`}>Zuordnen</h1> <h1 className={`text-lg font-bold ${isDark ? 'text-white' : 'text-slate-900'}`}>{t('match')}</h1>
<span className={`text-sm ${isDark ? 'text-white/50' : 'text-slate-500'}`}>{matchedTotal}/{totalPairs}</span> <div className={`text-sm ${isDark ? 'text-white/50' : 'text-slate-500'}`}>
{matchedTotal}/{totalPairs} · <span className="text-green-400">{firstTryCorrect}</span>/<span className="text-yellow-400">{retryCorrect}</span>/<span className="text-red-400">{errors}</span>
</div>
</div> </div>
</div> </div>
{/* Native flash overlay */}
{flashNative && (
<div className="fixed top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 z-50 pointer-events-none">
<div className={`px-8 py-4 rounded-2xl text-2xl font-bold animate-pulse ${isDark ? 'bg-cyan-500/30 text-cyan-200 backdrop-blur-xl' : 'bg-cyan-100 text-cyan-800'}`}>
{flashNative}
</div>
</div>
)}
<div className="flex-1 flex items-center justify-center px-6 py-8"> <div className="flex-1 flex items-center justify-center px-6 py-8">
{isComplete ? ( {isComplete ? (
<div className={`${glassCard} rounded-3xl p-10 text-center max-w-md w-full`}> <div className={`${glassCard} rounded-3xl p-10 text-center max-w-md w-full`}>
<StarRating stars={accuracyToStars(totalPairs, totalPairs + errors)} size="lg" animated /> <StarRating stars={isPerfect ? 3 : accuracyToStars(firstTryCorrect, totalPairs)} size="lg" animated />
<h2 className={`text-2xl font-bold mt-4 mb-2 ${isDark ? 'text-white' : 'text-slate-900'}`}>Alle zugeordnet!</h2> <h2 className={`text-2xl font-bold mt-4 mb-2 ${isDark ? 'text-white' : 'text-slate-900'}`}>
<p className={`mb-6 ${isDark ? 'text-white/60' : 'text-slate-500'}`}>{errors} Fehler</p> {isPerfect ? t('well_done') : t('all_matched')}
</h2>
<div className={`flex justify-center gap-6 mb-4 text-sm ${isDark ? 'text-white/70' : 'text-slate-600'}`}>
<div><span className="text-green-400 font-bold text-lg">{firstTryCorrect}</span><br/>{t('correct')}</div>
<div><span className="text-yellow-400 font-bold text-lg">{retryCorrect}</span><br/>2. Versuch</div>
<div><span className="text-red-400 font-bold text-lg">{errors}</span><br/>{t('errors')}</div>
</div>
{!isPerfect && (
<p className={`text-xs mb-4 ${isDark ? 'text-white/40' : 'text-slate-400'}`}>
{isThirdLanguage
? (nativeLang === 'tr' ? 'Tam puan icin hatasiz tamamlayin' : 'Fuer volle Punkte fehlerfrei abschliessen')
: 'Fuer volle Punkte fehlerfrei abschliessen'}
</p>
)}
<div className="flex gap-3"> <div className="flex gap-3">
<button onClick={() => { setRound(0); setMatched(new Set()); setErrors(0); setIsComplete(false) }} className="flex-1 py-3 rounded-xl bg-gradient-to-r from-indigo-500 to-violet-500 text-white font-medium">Nochmal</button> <button onClick={restart} className="flex-1 py-3 rounded-xl bg-gradient-to-r from-indigo-500 to-violet-500 text-white font-medium">
<button onClick={() => router.push('/learn')} className={`flex-1 py-3 rounded-xl border font-medium ${isDark ? 'border-white/20 text-white/80' : 'border-slate-300 text-slate-700'}`}>Zurueck</button> {t('again')}
</button>
<button onClick={() => router.push('/learn')} className={`flex-1 py-3 rounded-xl border font-medium ${isDark ? 'border-white/20 text-white/80' : 'border-slate-300 text-slate-700'}`}>
{t('back')}
</button>
</div> </div>
</div> </div>
) : ( ) : (
<div className="w-full max-w-2xl grid grid-cols-2 gap-6"> <div className="w-full max-w-3xl">
{/* Left column: English */} {/* Column headers */}
<div className="space-y-2"> <div className={`grid ${isThirdLanguage ? 'grid-cols-3' : 'grid-cols-2'} gap-4 mb-3`}>
<p className={`text-xs font-medium text-center mb-2 ${isDark ? 'text-white/40' : 'text-slate-400'}`}>English</p> <p className={`text-xs font-medium text-center ${isDark ? 'text-white/40' : 'text-slate-400'}`}>{t('english')}</p>
{roundItems.map(item => ( <p className={`text-xs font-medium text-center ${isDark ? 'text-white/40' : 'text-slate-400'}`}>{t('german')}</p>
<button {isThirdLanguage && (
key={`l-${item.id}`} <p className={`text-xs font-medium text-center ${isDark ? 'text-cyan-300/50' : 'text-cyan-600'}`}>{nativeLang.toUpperCase()}</p>
onClick={() => handleLeftTap(item.id)} )}
disabled={matched.has(item.id)}
className={`w-full p-3 rounded-xl border-2 text-sm font-medium transition-all ${
matched.has(item.id)
? 'opacity-30 border-green-400 bg-green-500/10 cursor-default'
: selectedLeft === item.id
? (isDark ? 'border-blue-400 bg-blue-500/20 text-white' : 'border-blue-500 bg-blue-50 text-blue-900')
: (isDark ? 'border-white/20 bg-white/5 text-white hover:bg-white/10' : 'border-slate-200 bg-white text-slate-900 hover:bg-slate-50')
}`}
>
{item.question}
</button>
))}
</div> </div>
{/* Right column: German (shuffled) */} {/* Grid */}
<div className="space-y-2"> <div className={`grid ${isThirdLanguage ? 'grid-cols-3' : 'grid-cols-2'} gap-4`}>
<p className={`text-xs font-medium text-center mb-2 ${isDark ? 'text-white/40' : 'text-slate-400'}`}>Deutsch</p> {/* Left: English (click to select, no sound) */}
{shuffledRight.map(item => ( <div className="space-y-2">
<button {roundItems.map(item => (
key={`r-${item.id}`} <button
onClick={() => handleRightTap(item.id)} key={`l-${item.id}`}
disabled={matched.has(item.id) || !selectedLeft} onClick={() => handleLeftTap(item)}
className={`w-full p-3 rounded-xl border-2 text-sm font-medium transition-all ${ disabled={matched.has(item.id)}
matched.has(item.id) className={`w-full p-3 rounded-xl border-2 text-sm font-medium transition-all text-left ${
? 'opacity-30 border-green-400 bg-green-500/10 cursor-default' matched.has(item.id)
: wrongPair === item.id ? 'opacity-30 border-green-400 bg-green-500/10 cursor-default'
? (isDark ? 'border-red-400 bg-red-500/20 text-red-200 animate-pulse' : 'border-red-500 bg-red-50 text-red-800 animate-pulse') : selectedLeft === item.id
: (isDark ? 'border-white/20 bg-white/5 text-white hover:bg-white/10' : 'border-slate-200 bg-white text-slate-900 hover:bg-slate-50') ? (isDark ? 'border-blue-400 bg-blue-500/20 text-white' : 'border-blue-500 bg-blue-50 text-blue-900')
}`} : (isDark ? 'border-white/20 bg-white/5 text-white hover:bg-white/10' : 'border-slate-200 bg-white text-slate-900 hover:bg-slate-50')
> }`}
{item.answer} >
</button> {item.question}
))} </button>
))}
</div>
{/* Middle: German (click to match + sound) */}
<div className="space-y-2">
{shuffledRight.map(item => (
<div
key={`r-${item.id}`}
className={`flex items-center gap-2 p-3 rounded-xl border-2 text-sm font-medium transition-all ${
matched.has(item.id)
? 'opacity-30 border-green-400 bg-green-500/10'
: wrongPair === item.id
? (isDark ? 'border-red-400 bg-red-500/20 text-red-200 animate-pulse' : 'border-red-500 bg-red-50 text-red-800 animate-pulse')
: (isDark ? 'border-white/20 bg-white/5 text-white' : 'border-slate-200 bg-white text-slate-900')
}`}
>
<button
onClick={() => handleRightTap(item.id)}
disabled={matched.has(item.id) || !selectedLeft}
className="flex-1 text-left hover:opacity-80"
>
{item.answer}
</button>
{!matched.has(item.id) && (
<AudioButton text={item.answer} lang="de" isDark={isDark} size="sm" />
)}
</div>
))}
</div>
{/* Right: Native language (display only + sound) */}
{isThirdLanguage && (
<div className="space-y-2">
{roundItems.map(item => {
const native = wordInNative(item.translations)
return (
<div
key={`n-${item.id}`}
className={`flex items-center gap-2 p-3 rounded-xl border text-sm transition-all ${
matched.has(item.id)
? 'opacity-30 border-green-400/30 bg-green-500/5'
: selectedLeft === item.id
? (isDark ? 'border-cyan-400/50 bg-cyan-500/10 text-cyan-200' : 'border-cyan-500/50 bg-cyan-50 text-cyan-800')
: (isDark ? 'border-white/10 bg-white/3 text-white/50' : 'border-slate-100 bg-slate-50/50 text-slate-400')
}`}
>
<span className="flex-1">{native || '—'}</span>
{native && !matched.has(item.id) && (
<AudioButton text={native} lang={nativeLang as 'en' | 'de'} isDark={isDark} size="sm" />
)}
</div>
)
})}
</div>
)}
</div> </div>
</div> </div>
)} )}