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 { 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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
Reference in New Issue
Block a user