Files
app-hejyou/src/components/PairWordCard.jsx
admin 6b31fddb27 PairWordCard: partial correct selection counts as correct
A subset of correct words with no wrong selections now triggers confetti and marks the card as solved.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-01 10:46:49 +02:00

215 lines
7.2 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useState, useMemo } from 'react'
import confetti from 'canvas-confetti'
import './PairCards.css'
function triggerConfetti() {
confetti({
particleCount: 90,
spread: 70,
origin: { y: 0.55 },
colors: ['#C4A85A', '#7A5C2E', '#3D7055', '#E8C9A8', '#fff'],
scalar: 0.9,
gravity: 1.1,
})
}
function SelectionOverlay({ chip }) {
const sels = chip?.selections
if (!sels?.length) return null
const maskId = `selmask-${chip.id.slice(0, 8)}`
const label = chip.label || ''
const toPoints = pts => pts.map(p => `${p.x * 100},${p.y * 100}`).join(' ')
const firstPts = sels[0].points
const xs = firstPts.map(p => p.x * 100)
const ys = firstPts.map(p => p.y * 100)
const cx = (Math.min(...xs) + Math.max(...xs)) / 2
const labelY = Math.min(Math.max(...ys) + 6, 94)
return (
<svg className="pair-bbox-svg" viewBox="0 0 100 100" preserveAspectRatio="none">
<defs>
<mask id={maskId}>
<rect width="100" height="100" fill="white" />
{sels.map((s, i) => <polygon key={i} points={toPoints(s.points)} fill="black" />)}
</mask>
</defs>
<rect width="100" height="100" fill="rgba(0,0,0,0.5)" mask={`url(#${maskId})`} />
{sels.map((s, i) => (
<polygon key={i} points={toPoints(s.points)}
fill="rgba(255,215,100,0.08)" stroke="rgba(255,215,100,0.92)"
strokeWidth="0.8" strokeLinejoin="round" />
))}
{label && (
<text x={cx} y={labelY} textAnchor="middle"
fill="white" fontSize="5.5" fontWeight="700" fontFamily="Nunito, sans-serif"
style={{ filter: 'drop-shadow(0 1px 4px rgba(0,0,0,1))' }}>
{label}
</text>
)}
</svg>
)
}
// Sentence format: {{label.w:uuid}} or {{label.o:uuid}}
function resolveSentence(sentence, placeholders, onChipClick, activeId) {
if (!sentence) return null
const parts = sentence.split(/(\{\{[^}]+\.[wo]:[0-9a-f-]{36}\}\})/)
return parts.map((part, i) => {
const m = part.match(/^\{\{([^.]+)\.(w|o):([0-9a-f-]{36})\}\}$/)
if (m) {
const label = m[1]
const type = m[2] === 'w' ? 'word' : 'object'
const id = m[3]
const entry = placeholders?.[id] || {}
return (
<span
key={i}
className={`pair-word-chip${activeId === id ? ' active' : ''}`}
onClick={e => { e.stopPropagation(); onChipClick?.(id, { label, type, ...entry }) }}
>
{label}
</span>
)
}
return part
})
}
function shuffle(arr) {
const a = [...arr]
for (let i = a.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[a[i], a[j]] = [a[j], a[i]]
}
return a
}
export default function PairWordCard({ card, onComplete }) {
const [selectedIds, setSelectedIds] = useState(new Set()) // toggled, not yet confirmed
const [confirmed, setConfirmed] = useState(false) // after Bestätigen
const [isCorrect, setIsCorrect] = useState(false)
const [activeChip, setActiveChip] = useState(null)
const lang = card.lang || 'de'
const native = lang === 'de' ? 'en' : 'de'
const isWord = activeChip && activeChip.type === 'word'
const isObject = activeChip && activeChip.type === 'object' && activeChip.selections?.length
const q = card.question
const stmt = card.positive_statement
const neg = card.negative_statement
const sentence = q?.[`sentence_${lang}`] || q?.sentence_de
const hint = q?.[`sentence_${native}`] || null
const pic = card.picture?.url
const options = useMemo(() => {
const pos = (stmt?.positive_words || []).map(w => ({ ...w, correct: true }))
const neg2 = (neg?.negative_words || []).map(w => ({ ...w, correct: false }))
return shuffle([...pos, ...neg2])
}, [stmt, neg])
function handleChipClick(id, entry) {
setActiveChip(prev => prev?.id === id ? null : { id, ...entry })
}
function handleSelect(opt) {
if (confirmed) return
setSelectedIds(prev => {
const next = new Set(prev)
next.has(opt.id) ? next.delete(opt.id) : next.add(opt.id)
return next
})
}
function handleConfirm() {
if (selectedIds.size === 0 || confirmed) return
setActiveChip(null)
const correctIds = new Set(options.filter(o => o.correct).map(o => o.id))
const noWrongSelected = [...selectedIds].every(id => correctIds.has(id))
const ok = noWrongSelected
setIsCorrect(ok)
setConfirmed(true)
if (ok) triggerConfetti()
setTimeout(() => onComplete(ok ? 'correct' : 'wrong'), 900)
}
return (
<div className="pair-card" onClick={() => setActiveChip(null)}>
{/* Image — flush to card top */}
<div className={`pair-image-wrap${isWord ? ' chip-active' : ''}`}>
{pic
? <img src={pic} alt="" className="pair-image" loading="lazy" />
: <div className="pair-image-placeholder">🖼</div>
}
{isWord && (
<div className="pair-chip-highlight">
<div className="pair-chip-highlight-badge">
<span className="pair-chip-highlight-target">{activeChip.label || '…'}</span>
</div>
</div>
)}
{isObject && <SelectionOverlay chip={activeChip} />}
</div>
<div className="pair-card-body" onClick={e => e.stopPropagation()} style={{ paddingTop: 18 }}>
<p className="pair-question">
{resolveSentence(sentence, card.placeholders, handleChipClick, activeChip?.id)}
</p>
{hint && !confirmed && (
<p className="pair-hint">
{resolveSentence(hint, card.placeholders, null, null)}
</p>
)}
<div className="pair-options">
{options.map((opt) => {
const label = opt[lang] || opt.de || '…'
const hint2 = opt[native] || null
let cls = 'pair-option-btn'
if (confirmed) {
if (selectedIds.has(opt.id)) cls += opt.correct ? ' correct' : ' wrong'
else if (opt.correct) cls += ' correct'
} else if (selectedIds.has(opt.id)) {
cls += ' selected'
}
return (
<button key={opt.id} className={cls} onClick={() => handleSelect(opt)} disabled={confirmed}>
{label}
{confirmed && hint2 && opt.correct && (
<span style={{ display: 'block', fontSize: '11px', opacity: 0.85, marginTop: 2 }}>
{hint2}
</span>
)}
</button>
)
})}
</div>
{confirmed && (
<p className={`pair-feedback ${isCorrect ? 'correct' : 'wrong'}`}>
{isCorrect
? '✓ Richtig!'
: `✗ Richtig wären: ${options.filter(o => o.correct).map(o => o[lang] || o.de).join(', ')}`
}
</p>
)}
{!confirmed && (
<div className="pair-btn-row" style={{ marginTop: 12 }}>
<button
className={`pair-btn ${selectedIds.size > 0 ? 'pair-btn-primary' : 'pair-btn-locked'}`}
onClick={handleConfirm}
disabled={selectedIds.size === 0}
>
Bestätigen
</button>
</div>
)}
</div>
</div>
)
}