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:
@@ -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<string, any>
|
||||
}
|
||||
|
||||
function getApiBase() { return '' }
|
||||
|
||||
@@ -22,7 +26,11 @@ export default function MatchPage() {
|
||||
const [selectedLeft, setSelectedLeft] = useState<string | null>(null)
|
||||
const [matched, setMatched] = useState<Set<string>>(new Set())
|
||||
const [wrongPair, setWrongPair] = useState<string | null>(null)
|
||||
const [firstTryCorrect, setFirstTryCorrect] = useState(0)
|
||||
const [retryCorrect, setRetryCorrect] = 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 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,61 +97,113 @@ 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 <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>
|
||||
}
|
||||
|
||||
const totalPairs = allItems.length
|
||||
const matchedTotal = round * 6 + matched.size
|
||||
const isPerfect = isComplete && errors === 0
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Header */}
|
||||
<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'}`}>
|
||||
<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>
|
||||
<h1 className={`text-lg font-bold ${isDark ? 'text-white' : 'text-slate-900'}`}>Zuordnen</h1>
|
||||
<span className={`text-sm ${isDark ? 'text-white/50' : 'text-slate-500'}`}>{matchedTotal}/{totalPairs}</span>
|
||||
<h1 className={`text-lg font-bold ${isDark ? 'text-white' : 'text-slate-900'}`}>{t('match')}</h1>
|
||||
<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>
|
||||
|
||||
{/* 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">
|
||||
{isComplete ? (
|
||||
<div className={`${glassCard} rounded-3xl p-10 text-center max-w-md w-full`}>
|
||||
<StarRating stars={accuracyToStars(totalPairs, totalPairs + errors)} size="lg" animated />
|
||||
<h2 className={`text-2xl font-bold mt-4 mb-2 ${isDark ? 'text-white' : 'text-slate-900'}`}>Alle zugeordnet!</h2>
|
||||
<p className={`mb-6 ${isDark ? 'text-white/60' : 'text-slate-500'}`}>{errors} Fehler</p>
|
||||
<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'}`}>
|
||||
{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">
|
||||
<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={() => 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>
|
||||
<button onClick={restart} className="flex-1 py-3 rounded-xl bg-gradient-to-r from-indigo-500 to-violet-500 text-white font-medium">
|
||||
{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 className="w-full max-w-2xl grid grid-cols-2 gap-6">
|
||||
{/* Left column: English */}
|
||||
<div className="w-full max-w-3xl">
|
||||
{/* Column headers */}
|
||||
<div className={`grid ${isThirdLanguage ? 'grid-cols-3' : 'grid-cols-2'} gap-4 mb-3`}>
|
||||
<p className={`text-xs font-medium text-center ${isDark ? 'text-white/40' : 'text-slate-400'}`}>{t('english')}</p>
|
||||
<p className={`text-xs font-medium text-center ${isDark ? 'text-white/40' : 'text-slate-400'}`}>{t('german')}</p>
|
||||
{isThirdLanguage && (
|
||||
<p className={`text-xs font-medium text-center ${isDark ? 'text-cyan-300/50' : 'text-cyan-600'}`}>{nativeLang.toUpperCase()}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Grid */}
|
||||
<div className={`grid ${isThirdLanguage ? 'grid-cols-3' : 'grid-cols-2'} gap-4`}>
|
||||
{/* Left: English (click to select, no sound) */}
|
||||
<div className="space-y-2">
|
||||
<p className={`text-xs font-medium text-center mb-2 ${isDark ? 'text-white/40' : 'text-slate-400'}`}>English</p>
|
||||
{roundItems.map(item => (
|
||||
<button
|
||||
key={`l-${item.id}`}
|
||||
onClick={() => handleLeftTap(item.id)}
|
||||
onClick={() => handleLeftTap(item)}
|
||||
disabled={matched.has(item.id)}
|
||||
className={`w-full p-3 rounded-xl border-2 text-sm font-medium transition-all ${
|
||||
className={`w-full p-3 rounded-xl border-2 text-sm font-medium transition-all text-left ${
|
||||
matched.has(item.id)
|
||||
? 'opacity-30 border-green-400 bg-green-500/10 cursor-default'
|
||||
: selectedLeft === item.id
|
||||
@@ -144,26 +216,59 @@ export default function MatchPage() {
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Right column: German (shuffled) */}
|
||||
{/* Middle: German (click to match + sound) */}
|
||||
<div className="space-y-2">
|
||||
<p className={`text-xs font-medium text-center mb-2 ${isDark ? 'text-white/40' : 'text-slate-400'}`}>Deutsch</p>
|
||||
{shuffledRight.map(item => (
|
||||
<button
|
||||
<div
|
||||
key={`r-${item.id}`}
|
||||
onClick={() => handleRightTap(item.id)}
|
||||
disabled={matched.has(item.id) || !selectedLeft}
|
||||
className={`w-full p-3 rounded-xl border-2 text-sm font-medium transition-all ${
|
||||
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 cursor-default'
|
||||
? '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 hover:bg-white/10' : 'border-slate-200 bg-white text-slate-900 hover:bg-slate-50')
|
||||
: (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>
|
||||
|
||||
Reference in New Issue
Block a user