import { useState, useEffect, useCallback, useRef } from "react"; import { DEF, INDEF, STRONG, WEAK, PREPS, PREPS_BY_CASE, CL, CASE_C, GENDER_C, PRONOUNS, POSSESSIVES, POSS_ENDINGS, combinePoss, LEVELS, wechselExample, createSession, bumpStreak, currentStreak, weekDots, } from './engine.js'; // ═══════════════════════════════════════════ // STORAGE (window.storage in Claude, localStorage when deployed) // ═══════════════════════════════════════════ const SKEY = 'gd-v10'; const ACCESS_KEY = 'beug-access-v1'; const Storage = { async get() { try { if (typeof window !== 'undefined' && window.storage?.get) { const r = await window.storage.get(SKEY); if (r?.value) return JSON.parse(r.value); } } catch (e) {} try { const v = localStorage.getItem(SKEY); if (v) return JSON.parse(v); } catch (e) {} return null; }, async set(state) { const json = JSON.stringify(state); try { if (typeof window !== 'undefined' && window.storage?.set) { await window.storage.set(SKEY, json); return; } } catch (e) {} try { localStorage.setItem(SKEY, json); } catch (e) {} } }; const Access = { get() { try { const v = localStorage.getItem(ACCESS_KEY); return v ? JSON.parse(v) : null; } catch (e) { return null; } }, set(plan = 'paid') { const value = { paid: true, plan, unlockedAt: new Date().toISOString() }; try { localStorage.setItem(ACCESS_KEY, JSON.stringify(value)); } catch (e) {} return value; } }; const TABS = [ {id:'prep', label:'Prepositions'}, {id:'article', label:'Articles'}, {id:'poss', label:'Possessives'}, {id:'pronoun', label:'Pronouns'}, ]; // Daily Practice pseudo-level: pinned to the top of the Prepositions tab once // the "Alle Präpositionen" lesson (id 10) is completed. Always free. const DAILY_ID = 10.5; const FREE_LEVEL_IDS = new Set([2, 3, 4, 8, 9, 10, 10.5, 18]); const CORE_ARTICLE_CASES = ['nominative', 'accusative', 'dative']; const PAYMENTS_ENABLED = import.meta.env.VITE_PAYMENTS_ENABLED === 'true'; const STRIPE_PLANS = PAYMENTS_ENABLED ? [ { id:'weekly', name:'Weekly', price:'$2.99', cadence:'per week', note:'Start here', url: import.meta.env.VITE_STRIPE_WEEKLY_URL || '', env:'VITE_STRIPE_WEEKLY_URL', }, { id:'yearly', name:'Yearly', price:'$9.99', cadence:'per year', note:'Best value', url: import.meta.env.VITE_STRIPE_YEARLY_URL || '', env:'VITE_STRIPE_YEARLY_URL', }, { id:'lifetime', name:'Lifetime', price:'$19.99', cadence:'one time', note:'Pay once', url: import.meta.env.VITE_STRIPE_LIFETIME_URL || '', env:'VITE_STRIPE_LIFETIME_URL', }, ] : []; // Audio served from /public/audio const AUDIO = { dativSung: '/audio/dativ-blue-danube-sung.m4a', blueDanube: '/audio/blue-danube.mp3', odeToJoy: '/audio/ode-to-joy.mp3', }; // Chunked study cards for the Prepositions "Lernen" section. // Mnemonics reproduced from the U-Michigan German Resources page. const LEARN_CARDS = [ { case:'accusative', label:'Akkusativ', desc:'These prepositions always take the Accusative.', words:[ {de:'durch', en:'through'}, {de:'für', en:'for'}, {de:'gegen', en:'against'}, {de:'ohne', en:'without'}, {de:'um', en:'around / at'}, {de:'bis', en:'until / up to'}, ], mnemonics:[ {label:'Acronym', text:'“O Fudge” — ohne, für, um, durch, gegen.'}, {label:'Rhyme', text:'“Durch-für-gegen-ohne-um, Deutsch zu lernen ist nicht dumm.”'}, ], audio:[], }, { case:'dative', label:'Dativ', desc:'These prepositions always take the Dative.', words:[ {de:'aus', en:'out of'}, {de:'außer', en:'except for'}, {de:'bei', en:'at / near'}, {de:'mit', en:'with'}, {de:'nach', en:'to / after'}, {de:'seit', en:'since'}, {de:'von', en:'from'}, {de:'zu', en:'to'}, {de:'gegenüber', en:'opposite'}, ], mnemonics:[ {label:'Sing it', text:'Sing “Aus-außer-bei-mit, nach-seit, von-zu” to the tune of the “Blue Danube” waltz.'}, {label:'Rhyme', text:'“Roses are red, violets are blue, aus-außer-bei-mit, nach-seit, von-zu.”'}, ], audio:[ {label:'🎤 Sung to the Blue Danube', src:AUDIO.dativSung}, {label:'🎻 The melody — The Blue Danube', src:AUDIO.blueDanube}, ], }, { case:'wechsel', label:'Wechselpräpositionen', desc:'Two-way: motion → Akkusativ (wohin?), staying put → Dativ (wo?).', words:[ {de:'an', en:'at / on'}, {de:'auf', en:'on / onto'}, {de:'hinter', en:'behind'}, {de:'neben', en:'next to'}, {de:'unter', en:'under'}, {de:'über', en:'over / above'}, {de:'in', en:'in / into'}, {de:'vor', en:'in front of'}, {de:'zwischen', en:'between'}, ], mnemonics:[ {label:'Sing it', text:'Sing “An, auf, hin-ter, ne-ben, un-ter/ü-ber, in, vor, zwi-i-schen” to the tune of “An die Freude” (“Ode to Joy”) from Beethoven’s 9th.'}, ], audio:[ {label:'🎼 The melody — Ode to Joy', src:AUDIO.odeToJoy}, ], }, { case:'genitive', label:'Genitiv', desc:'These take the Genitive — only a handful, so just learn the list.', words:[ {de:'trotz', en:'despite'}, {de:'während', en:'during'}, {de:'wegen', en:'because of'}, {de:'(an)statt', en:'instead of'}, ], mnemonics:[], note:'No song for these — there are only a few. Formal writing uses the genitive; casual speech increasingly uses the dative (e.g. „wegen dem Wetter"). Michigan also lists außerhalb / innerhalb / diesseits / jenseits, which must stay genitive.', audio:[], }, ]; // ═══════════════════════════════════════════ // MAIN APP // ═══════════════════════════════════════════ const ROUND = 10; const GREEN = '#58a700'; const RED = '#dc2626'; export default function App() { const [scr, setScr] = useState('home'); const [tab, setTab] = useState('prep'); const [lv, setLv] = useState(1); const [q, setQ] = useState(null); const [inp, setInp] = useState(''); const [rev, setRev] = useState(false); const [ok, setOk] = useState(false); const [res, setRes] = useState([]); const [chart, setChart] = useState(false); const [learnStep, setLearnStep] = useState(0); const [st, setSt] = useState({sc:{},tc:0,ta:0,streak:0,ld:null}); const [access, setAccess] = useState(null); const [payMsg, setPayMsg] = useState(''); const [loaded, setLoaded] = useState(false); const [shake, setShake] = useState(false); const iref = useRef(null); const sessionRef = useRef(null); useEffect(()=>{(async()=>{ const data = await Storage.get(); // Seed the practice-day history for users from before it was tracked. if (data?.ld && !data.days) data.days = { [data.ld]: true }; if (data) setSt(data); let nextAccess = Access.get(); const params = new URLSearchParams(window.location.search); if (params.get('checkout') === 'success' || params.get('beug_unlock') === '1') { nextAccess = Access.set(params.get('plan') || 'paid'); window.history.replaceState({}, '', window.location.pathname); setScr('menu'); } if (nextAccess?.paid) setAccess(nextAccess); setLoaded(true); })();},[]); const save = useCallback(async s => { setSt(s); await Storage.set(s); },[]); const hintHidden = k => !!st.hide?.[k]; const toggleHint = k => save({...st, hide:{...(st.hide||{}), [k]: !st.hide?.[k]}}); const hasPaidAccess = !!access?.paid; const hasAccess = !PAYMENTS_ENABLED || hasPaidAccess; const gated = id => PAYMENTS_ENABLED && !hasPaidAccess && !FREE_LEVEL_IDS.has(id); const unlocked = (id) => { return !gated(id); }; const nextLevelInTab = (id) => { const lvObj = LEVELS.find(l => l.id === id); if (!lvObj) return null; const tabLevels = LEVELS.filter(l => l.tab === lvObj.tab && !l.daily).sort((a,b) => a.id - b.id); const idx = tabLevels.findIndex(l => l.id === id); if (idx === -1 || idx === tabLevels.length - 1) return null; return tabLevels[idx + 1]; }; const startLv = id => { if (!unlocked(id)) { setPayMsg(''); setScr('paywall'); return; } sessionRef.current = createSession(id); setLv(id); setRes([]); setInp(''); setRev(false); setQ(sessionRef.current.next()); setScr('practice'); }; const openCheckout = plan => { if (!plan.url) { setPayMsg(`Stripe link missing: add ${plan.env} in the deployment environment.`); return; } window.location.href = plan.url; }; const submitRef = useRef(null); const nextRef = useRef(null); const doSubmit = useCallback((overrideInput) => { const userAns = (overrideInput ?? inp).trim(); if (!userAns) { setShake(true); setTimeout(()=>setShake(false),400); return; } const correct = userAns.toLowerCase() === q.ans.toLowerCase(); if (overrideInput !== undefined) setInp(overrideInput); // Dismiss mobile keyboard so feedback is visible try { iref.current?.blur(); } catch(e){} setOk(correct); setRev(true); const nr = [...res, correct]; setRes(nr); let ns = {...st}; ns.ta = (ns.ta||0) + 1; if (correct) ns.tc = (ns.tc||0) + 1; if (!ns.sc[lv]) ns.sc[lv] = {c:0,t:0}; ns.sc[lv] = {c: ns.sc[lv].c + (correct?1:0), t: ns.sc[lv].t + 1}; ns = bumpStreak(ns); save(ns); }, [inp, q, res, st, lv, save]); const doNext = useCallback(() => { if (res.length >= ROUND) { setScr('summary'); return; } setInp(''); setRev(false); setOk(false); if (!sessionRef.current) sessionRef.current = createSession(lv); setQ(sessionRef.current.next()); }, [res, lv]); submitRef.current = doSubmit; nextRef.current = doNext; useEffect(() => { const h = e => { if (chart) { if (e.key === 'Escape') setChart(false); return; } if (scr === 'summary' && e.key === 'Enter') { e.preventDefault(); startLv(lv); return; } if (scr !== 'practice') return; if (!rev && (q?.type === 'article' || q?.type === 'chunk') && q.choices) { const idx = ['1','2','3','4'].indexOf(e.key); if (idx >= 0 && q.choices[idx]) { e.preventDefault(); submitRef.current(q.choices[idx]); return; } } if (!rev && q?.type === 'prep') { if (e.key === '1') { e.preventDefault(); submitRef.current('accusative'); return; } if (e.key === '2') { e.preventDefault(); submitRef.current('dative'); return; } if (e.key === '3') { e.preventDefault(); submitRef.current('genitive'); return; } if (e.key === '4') { e.preventDefault(); submitRef.current('wechsel'); return; } } if (!rev && q?.type === 'wechselContext') { if (e.key === '1') { e.preventDefault(); submitRef.current('accusative'); return; } if (e.key === '2') { e.preventDefault(); submitRef.current('dative'); return; } } if (e.key === 'Enter') { e.preventDefault(); if (rev) nextRef.current(); else if (q?.type !== 'article' && q?.type !== 'chunk') submitRef.current(); } if (rev && (e.key === 'ArrowRight' || e.key === ' ')) { e.preventDefault(); nextRef.current(); } }; window.addEventListener('keydown', h); return () => window.removeEventListener('keydown', h); }); // One-tap mode: after an answer is revealed, auto-advance to the next // question. Always on in Daily Practice; opt-in (st.auto) elsewhere. Correct // answers flash briefly; wrong answers linger so the correction can register. useEffect(() => { if (scr !== 'practice' || !rev) return; const quick = lv === DAILY_ID || !!st.auto; if (!quick) return; const t = setTimeout(() => { nextRef.current?.(); }, ok ? 750 : 1650); return () => clearTimeout(t); }, [scr, rev, ok, lv, st.auto]); if (!loaded) return

Loading...

; const rc = res.filter(Boolean).length; const cur = LEVELS.find(l=>l.id===lv); const chartSection = scr === 'menu' ? tab : (q?.type === 'wechselContext' ? 'prep' : q?.type === 'chunk' ? 'article' : (q?.type || tab)); const nextInTab = nextLevelInTab(lv); const quickActive = lv === DAILY_ID || !!st.auto; // Daily Practice unlocks once the all-31 lesson (id 10) is passed: // at least one full round with the app's 70% pass rate. const prepMastery = st.sc?.[10]; const dailyUnlocked = !!(prepMastery && prepMastery.t >= ROUND && prepMastery.c / prepMastery.t >= 0.7); const dailySc = st.sc?.[DAILY_ID]; const dailyPct = dailySc ? Math.round(dailySc.c / dailySc.t * 100) : null; return (
{scr !== 'home' && scr !== 'menu' ? :
Beug
}
{scr === 'practice' && ( )} {scr === 'practice' && } {PAYMENTS_ENABLED && hasPaidAccess && Unlocked}
{/* HOME */} {scr === 'home' && ( setScr('menu')} /> )} {/* MENU */} {scr === 'menu' && (
{TABS.map(t => ( ))}
{tab === 'prep' && dailyUnlocked && (
startLv(DAILY_ID)}>
⚡ Daily Practice
All 31 prepositions · one-tap · keep them sharp
{dailySc &&
{dailySc.c}/{dailySc.t}
}
{dailyPct !== null &&
=70?GREEN:RED}}>{dailyPct}%
}
)} {tab === 'prep' && (
{ setLearnStep(0); setScr('learn'); }}>
📚 Lernen
Chunk the lists first — Akkusativ, Dativ, Wechsel, Genitiv
)} {LEVELS.filter(l => l.tab === tab && !l.daily).sort((a,b)=>a.id-b.id).map(l => { const u = unlocked(l.id); const s = st.sc[l.id]; const p = s ? Math.round(s.c/s.t*100) : null; return (
startLv(l.id)}>
{l.name}
{l.desc}
{s &&
{s.c}/{s.t}
}
{p !== null &&
=70?GREEN:RED}}>{p}%
}
); })}
)} {/* PAYWALL */} {PAYMENTS_ENABLED && scr === 'paywall' && ( setScr('menu')} /> )} {/* LEARN: chunked study cards for prepositions */} {scr === 'learn' && (() => { const card = LEARN_CARDS[learnStep]; const isLast = learnStep === LEARN_CARDS.length - 1; return (
{LEARN_CARDS.map((c,i)=>(
))}
{card.label}
{card.desc}
{card.words.map(w=>(
{w.de} {w.en}
))}
{card.mnemonics.length > 0 && (
Merkhilfe
{card.mnemonics.map((m,i)=>(
{m.label} {m.text}
))}
)} {card.note && (
{card.note}
)} {card.audio.length > 0 && (
{card.audio.map(a=>(
{a.label}
))}
)}
{learnStep > 0 && ( )} {!isLast ? : }
); })()} {/* PRACTICE: sentence-based (article, poss, pronoun) */} {scr === 'practice' && (q?.type === 'article' || q?.type === 'chunk' || q?.type === 'poss' || q?.type === 'pronoun') && (
{q.sentence.split('_____').map((part,i,a)=>( {part} {i < a.length-1 && ( {rev ? q.ans : (inp || '     ')} )} ))}
{q.translation}
{(q.type === 'article' || q.type === 'chunk') ? ( ) : (
{q.type === 'poss' && <> {q.stem}- ({q.possMeaning}) } {q.type === 'pronoun' && <> {q.pron.nom} ({q.pron.en}) · {q.pron.tag} }
)} {!rev ? ( (q.type === 'article' || q.type === 'chunk') ? (
{q.choices.map((choice, i) => ( ))}
) : (
setInp(e.target.value)} style={S.input} placeholder={q.type==='poss' ? 'form…' : q.type==='pronoun' ? 'pronoun…' : 'article…'} autoComplete="off" autoCapitalize="off" spellCheck={false} enterKeyHint="go" inputMode="text"/>
or press Enter
) ) : ( q.type === 'pronoun' ? : q.type === 'chunk' ? : )}
)} {/* PRACTICE: preposition (which case?) */} {scr === 'practice' && q?.type === 'prep' && (
Which case does this preposition take?
{q.prepKey}
{q.prepEn}
{!rev ? (
{[ ['accusative','Akkusativ','1'],['dative','Dativ','2'], ['genitive','Genitiv','3'],['wechsel','Wechsel','4'], ].map(([val,lab,key]) => ( ))}
) : ( )}
)} {/* PRACTICE: wechsel in context */} {scr === 'practice' && q?.type === 'wechselContext' && (
{q.sentence}
{(!hintHidden('meaning') || rev) ? ( <>
{q.translation} {!rev && }
{q.prepKey} ({PREPS[q.prepKey].en})
) : ( <>
{q.prepKey}
)}
Wechselpräposition · motion or location?
{!rev ? (
{[['accusative','Akkusativ','1'],['dative','Dativ','2']].map(([val,lab,key]) => ( ))}
) : ( )}
)} {/* PRACTICE: contractions */} {scr === 'practice' && q?.type === 'contraction' && (
Contract the preposition and article
{q.prep} + {q.art} = ?
{CL[q.cas]}
{!rev ? (
setInp(e.target.value)} style={S.input} placeholder="contraction…" autoComplete="off" autoCapitalize="off" spellCheck={false} enterKeyHint="go" inputMode="text"/>
or press Enter
) : ( )}
)} {scr === 'practice' && (
{Array.from({length:ROUND},(_,i)=>(
))} {Math.min(res.length+(rev?0:1), ROUND)}/{ROUND} · {cur?.name}
)} {scr === 'practice' && rev && (
)} {/* SUMMARY */} {scr === 'summary' && (
Complete
{rc}/{ROUND}
{res.map((r,i)=>
)}
=7?GREEN:'#d97706', fontSize:15, fontWeight:600, margin:'24px 0'}}> {rc>=9?'Ausgezeichnet!':rc>=7?'Gut gemacht!':rc>=5?'Keep practicing.':'Review the charts.'}
{rc>=7 && nextInTab && unlocked(nextInTab.id) && (
{nextInTab.name} unlocked.
)} {rc>=7 && nextInTab && unlocked(nextInTab.id) && ( )}
)} {scr !== 'practice' && }
{chart && setChart(false)}/>}
); } function HomeScreen({ onStart }) { return (
der · den · dem

Learn German cases by feel.

Learn this stuff in days not months. Built by fast German learners for other fast German learners. Drill the prepositions, articles, possessives, and pronouns that slow you down, then get back to actual German.

); } function FeatureRequestFooter() { return (
Have a feature request?
Always looking to improve the tool.
Get in touch
); } // A hint line with a persistent hide/show toggle. Hidden stays hidden // (across sessions) until the user shows it again — for authentic testing. function Hideable({ hidden, onToggle, label, style, children }) { if (hidden) { return (
); } return (
{children}
); } function StreakBadge({ st }) { const dots = weekDots(st); const n = currentStreak(st); const practiced = dots.filter(d=>d.practiced).length; return ( {dots.map(d=>( ))} 🔥 {n} ); } function CoreReference() { const prepositionGroups = [ ['accusative', 'Akkusativ', PREPS_BY_CASE.accusative], ['dative', 'Dativ', PREPS_BY_CASE.dative], ['wechsel', 'Two-way', PREPS_BY_CASE.wechsel], ['genitive', 'Genitiv', PREPS_BY_CASE.genitive], ]; const genders = ['masculine','feminine','neuter','plural']; return (
Case map
Prepositions
{prepositionGroups.map(([key, label, words]) => (
{label}: {words.join(', ')}
))}
Definite Articles
{genders.map(g => )} {CORE_ARTICLE_CASES.map(c => ( {genders.map(g => )} ))}
{g.slice(0,4)}
{CL[c]}{DEF[g][c]}
); } function PaywallScreen({ message, onCheckout, onBack }) { return (
Beug Premium

Take the whole thing.

Possessives, pronouns, contractions, genitive articles, and mixed rounds. Built for people who want the case system handled, fast.

{['Possessives and pronouns', 'Mixed article rounds', 'Contractions and genitive', 'Saved progress on this device'].map(item => (
✓ {item}
))}
{STRIPE_PLANS.map(plan => ( ))}
{message &&
{message}
}
No account required for v1. Stripe redirects back here and unlocks this browser.
); } // ═══════════════════════════════════════════ // FEEDBACK COMPONENTS // ═══════════════════════════════════════════ const FB_GENDERS = ['masculine','feminine','neuter','plural']; const FB_CASES = ['nominative','accusative','dative','genitive']; const normAns = s => (s || '').trim().toLowerCase(); // A full declension grid (all 4 cases × 4 genders) for answer feedback. // The correct cell is highlighted green; when the answer was wrong, the // cell(s) matching the user's input are highlighted red — so you see both // your pick and the right one in the whole table, not just one row. // focusG (optional) keeps one gender column sharp and greys the rest. function FeedbackTable({ valueFor, isCorrect, isWrong, focusG }) { return ( {FB_GENDERS.map(g => ( ))} {FB_CASES.map(c => ( {FB_GENDERS.map(g => { const val = valueFor(g, c); const correct = isCorrect(g, c, val); const wrong = !correct && !!isWrong && isWrong(g, c, val); const bg = correct ? GREEN + '22' : wrong ? RED + '22' : 'transparent'; const color = correct ? GREEN : wrong ? RED : (focusG && g !== focusG ? '#c8c8c8' : '#555'); return ( ); })} ))}
{g.slice(0,4)}.
{CL[c]}{val}
); } function FillFeedback({ q, inp, ok }) { const valueFor = q.type === 'article' ? (g, c) => (q.artType === 'indefinite' ? INDEF : DEF)[g][c] : (g, c) => combinePoss(q.stem, POSS_ENDINGS[g][c]); const wrong = normAns(inp); return (
{ok ? '✓ Correct' : <>✗ {inp} {q.ans}}
{q.rule}
{!ok && (
{q.noun.w} is {q.noun.g}. {CL[q.cas]} + {q.noun.g} = {q.ans}
)} g === q.noun.g && c === q.cas} isWrong={ok ? null : (g, c, val) => g === q.noun.g && normAns(val) === wrong} />
); } function PrepFeedback({ q, inp, ok }) { const motEx = q.ans === 'wechsel' ? wechselExample(q.prepKey, true) : null; const locEx = q.ans === 'wechsel' ? wechselExample(q.prepKey, false) : null; return (
{ok ? <>✓ {CL[q.ans]} : <>✗ {CL[inp]||inp} {CL[q.ans]}}
„{q.prepKey}" → {q.ans === 'wechsel' ? 'Wechselpräposition (Akkusativ for motion, Dativ for location)' : `always ${CL[q.ans]}`}
{q.ans !== 'wechsel' && (
Example: {q.example} ({q.exampleFull ? q.exampleEn : `${q.prepEn} the ${q.exampleEn}`})
)} {q.ans === 'wechsel' && (
Motion: Er geht {motEx.de} (Akk) — "{motEx.en}"
Location: Er ist {locEx.de} (Dat) — "{locEx.en}"
)}
); } function WechselContextFeedback({ q, inp, ok }) { return (
{ok ? <>✓ {CL[q.ans]} : <>✗ {CL[inp]||inp} {CL[q.ans]}}
„{q.verb}" {q.isMotion ? 'implies motion (wohin?)' : 'implies location (wo?)'} → {CL[q.ans]}
Wechselpräpositionen take Akkusativ when something moves toward a destination — wohin? (gehen, fahren, legen, stellen) — and Dativ when it stays put — wo? (sein, stehen, sitzen, liegen).
); } function ChunkFeedback({ q, inp, ok }) { const article = DEF[q.noun.g][q.cas]; return (
{ok ? <>✓ {q.ans} : <>✗ {inp} {q.ans}}
{q.contracts ? <>{q.prepKey} + {article} = {q.ans} : <>„{q.prepKey}" + {CL[q.cas]}{q.ans}}
{q.contracts ? 'Preferred — these fuse, so use the contraction in speech and writing.' : <>{article} doesn’t fuse with „{q.prepKey}" — keep them as two words.}
Definite article by case — {DEF[q.noun.g].nominative} {q.noun.w}
DEF[g][c]} focusG={q.noun.g} isCorrect={(g, c) => g === q.noun.g && c === q.cas} isWrong={null} />
); } function ContractionFeedback({ q, inp, ok }) { return (
{ok ? <>✓ {q.prep} + {q.art} = {q.ans} : <>✗ {inp} {q.ans}}
{q.prep} + {q.art} = {q.ans}
{q.pref ? 'Preferred — use this contraction in both speech and writing.' : 'Informal — common when speaking, but the two-word form is more usual in writing.'}
Where „{q.art}" sits in the definite-article table
DEF[g][c]} focusG={null} isCorrect={(g, c, val) => normAns(val) === normAns(q.art)} isWrong={null} />
); } function PronounFeedback({ q, inp, ok }) { const cases = ['nominative','accusative','dative']; const labels = { nominative:'Nom', accusative:'Acc', dative:'Dat' }; const keys = { nominative:'nom', accusative:'acc', dative:'dat' }; return (
{ok ? '✓ Correct' : <>✗ {inp} {q.ans}}
{q.pron.en} ({q.pron.tag}) — {CL[q.cas]} form
{cases.map(c => )} {cases.map(c => { const val = q.pron[keys[c]]; const correct = c === q.cas; const wrong = !ok && !correct && normAns(val) === normAns(inp); return ; })}
{q.pron.nom}{labels[c]}
{q.pron.en}{val}
); } // ═══════════════════════════════════════════ // CHART MODAL // ═══════════════════════════════════════════ function ChartModal({ section, q, onClose }) { const genders = ['masculine','feminine','neuter','plural']; const cases = ['nominative','accusative','dative','genitive']; return (
e.stopPropagation()}>
{section==='article'?'Articles':section==='prep'?'Prepositions':section==='poss'?'Possessives':section==='pronoun'?'Personal Pronouns':'Charts'}
{section === 'article' && <>
All-in-One Declension Chart
Strong (S): no article carries the case. Weak (W): after a definite article.
{genders.map(g => )} {genders.map(g => ( ))} {cases.map(c => ( {genders.map(g => { const hl = q && (q.type==='article'||q.type==='poss') && c === q.cas && g === q.noun?.g; return ( ); })} ))}
{g}
S W
{CL[c]}{STRONG[g][c]} {WEAK[g][c]}
Unbestimmter Artikel (ein/eine/kein)
Bestimmter Artikel (der/die/das)
} {section === 'prep' && (
Prepositions by Case
Akkusativ: durch, für, gegen, ohne, um, bis
Dativ: aus, außer, bei, gegenüber, mit, nach, seit, von, zu
Genitiv: trotz, während, wegen, statt, anstatt, außerhalb, innerhalb
Wechsel: {PREPS_BY_CASE.wechsel.join(', ')}
Wechsel: accusative for motion (wohin?), dative for location (wo?).
)} {section === 'poss' && (
Possessive Determiners
{POSSESSIVES.map(p =>
{p.stem}- {p.en}
)}
Endings follow the ein/kein pattern. euer drops to eur- when an ending is added.
)} {section === 'pronoun' && (
Personal Pronouns
)}
); } function Frag2({ children }) { return <>{children}; } function SimpleChart({ table, highlightCase, highlightGender, active }) { const genders = ['masculine','feminine','neuter','plural']; const cases = ['nominative','accusative','dative','genitive']; return ( {genders.map(g => )} {cases.map(c => ( {genders.map(g => { const hl = active && c === highlightCase && g === highlightGender; return ; })} ))}
{g}
{CL[c]}{table[g][c]}
); } function PossessiveChart({ highlightCase, highlightGender, active, highlightStem }) { const genders = ['masculine','feminine','neuter','plural']; const cases = ['nominative','accusative','dative','genitive']; const stem = highlightStem || 'mein'; return (
{genders.map(g => )} {cases.map(c => ( {genders.map(g => { const hl = active && c === highlightCase && g === highlightGender; const display = combinePoss(stem, POSS_ENDINGS[g][c]); return ; })} ))}
{g}
{CL[c]}{display}
Showing forms for {stem}-. Same endings apply to all possessive bases.
); } function PronounsChartCombined({ highlightCase, highlightKey, active }) { const cases = ['nominative','accusative','dative']; const labels = { nominative:'Nom', accusative:'Acc', dative:'Dat' }; const keys = { nominative:'nom', accusative:'acc', dative:'dat' }; return ( {cases.map(c => )} {PRONOUNS.map((p, idx) => { const sep = idx === 6; return ( {cases.map(c => { const hl = active && c === highlightCase && p.key === highlightKey; return ; })} ); })}
english person{labels[c]}
{p.en} {p.tag}{p[keys[c]]}
); } // ═══════════════════════════════════════════ // STYLES // ═══════════════════════════════════════════ const S = { wrap: { background:'#f7f7f7', minHeight:'100vh', color:'#1a1a1a', fontFamily:'"DM Sans",system-ui,sans-serif' }, inner: { maxWidth:520, margin:'0 auto', padding:'16px 20px 100px' }, hdr: { display:'flex', justifyContent:'space-between', alignItems:'center', padding:'10px 0 8px', position:'sticky', top:0, background:'#f7f7f7', zIndex:10 }, logo: { fontSize:17, fontWeight:700, color:'#1a1a1a' }, linkBtn: { background:'none', border:'none', color:'#999', fontSize:13, cursor:'pointer', padding:0, fontWeight:500, fontFamily:'inherit' }, stat: { fontSize:13, color:'#999', fontWeight:500 }, tabs: { display:'flex', gap:0, borderBottom:'1px solid #eee', marginTop:6, marginBottom:12 }, tab: { flex:1, background:'none', border:'none', padding:'12px 8px', cursor:'pointer', color:'#999', fontSize:13, fontWeight:600, fontFamily:'inherit', borderBottomWidth:2, borderBottomStyle:'solid', borderBottomColor:'transparent', marginBottom:-1, transition:'all 0.15s' }, tabActive: { color:'#1a1a1a', borderBottomColor:GREEN }, hero: { background:'#fff', border:'1px solid #eee', borderRadius:12, padding:'24px 22px', textAlign:'left' }, heroMark: { fontSize:12, color:GREEN, fontWeight:800, textTransform:'uppercase', letterSpacing:0.8, marginBottom:10 }, heroTitle: { fontSize:38, lineHeight:1.05, margin:'0 0 12px', color:'#1a1a1a', letterSpacing:0, fontWeight:800 }, heroCopy: { fontSize:15, lineHeight:1.55, color:'#666', margin:'0 0 20px' }, heroActions: { display:'flex', gap:10, flexWrap:'wrap' }, sectionTitle: { fontSize:12, color:'#999', textTransform:'uppercase', letterSpacing:1, fontWeight:800, margin:'0 0 8px' }, coreGrid: { display:'grid', gridTemplateColumns:'1fr', gap:10 }, corePanel: { background:'#fff', border:'1px solid #eee', borderRadius:10, padding:'16px 16px' }, miniChart: { width:'100%', borderCollapse:'collapse', marginTop:10 }, miniTh: { padding:'5px 4px', fontSize:10, color:'#aaa', textAlign:'center', textTransform:'uppercase', fontWeight:700 }, miniTd: { padding:'7px 4px', fontSize:13, color:'#444', borderBottom:'1px solid #f3f3f3', textAlign:'center' }, lvRow: { display:'flex', alignItems:'center', gap:14, padding:'13px 16px', marginBottom:6, background:'#fff', borderRadius:10, border:'1px solid #eee' }, lvRowBlurred: { filter:'blur(0.8px)', opacity:0.82 }, lvName: { fontSize:14, fontWeight:700, color:'#1a1a1a' }, lvDesc: { fontSize:12, color:'#999', marginTop:1 }, lvPct: { fontSize:14, fontWeight:700 }, chartLinkBtn: { width:'100%', padding:12, marginTop:20, background:'#fff', border:'1px solid #eee', borderRadius:10, color:'#888', fontSize:13, cursor:'pointer', fontFamily:'inherit' }, sentence: { fontSize:26, fontWeight:600, color:'#1a1a1a', lineHeight:1.5, margin:'0 0 10px', letterSpacing:'-0.3px' }, blank: { display:'inline-block', minWidth:80, borderBottom:'2px solid', textAlign:'center', padding:'0 6px', margin:'0 2px', fontWeight:700, transition:'all 0.15s' }, translation: { fontSize:14, color:'#999', marginBottom:10 }, keyInfo: { fontSize:16, color:'#333', fontWeight:600, marginBottom:4 }, keyInfoMute: { color:'#aaa', fontWeight:400 }, secondaryContext: { fontSize:12, color:'#aaa', marginBottom:16, lineHeight:1.5 }, inputWrap: { display:'flex', flexDirection:'column', alignItems:'center', gap:6 }, inputRow: { display:'flex', gap:8, alignItems:'center' }, input: { width:170, padding:'12px 14px', borderRadius:8, border:'1.5px solid #ddd', background:'#fff', color:'#1a1a1a', fontSize:18, fontWeight:600, outline:'none', textAlign:'center', fontFamily:'inherit' }, checkBtn: { padding:'12px 20px', borderRadius:8, background:GREEN, color:'#fff', border:'none', fontSize:14, fontWeight:700, cursor:'pointer', fontFamily:'inherit' }, hintText: { fontSize:11, color:'#bbb' }, kbd: { background:'#f0f0f0', padding:'1px 5px', borderRadius:3, fontSize:10, color:'#888', border:'1px solid #e0e0e0' }, hintToggle: { background:'none', border:'none', padding:0, color:'#c2c2c2', fontSize:10, fontWeight:600, textTransform:'uppercase', letterSpacing:0.5, cursor:'pointer', fontFamily:'inherit', textDecoration:'underline', textDecorationStyle:'dotted', textUnderlineOffset:3 }, kbdSmall: { background:'#f0f0f0', padding:'1px 4px', borderRadius:3, fontSize:9, color:'#888', border:'1px solid #e0e0e0' }, articleGrid: { display:'grid', gridTemplateColumns:'1fr 1fr', gap:10, maxWidth:320, margin:'12px auto 0' }, articleBtn: { padding:'18px 16px', borderRadius:10, border:'1.5px solid #ddd', background:'#fff', color:'#1a1a1a', cursor:'pointer', fontFamily:'inherit', textAlign:'center', transition:'all 0.12s' }, choiceBtn: { flex:'1 1 auto', minWidth:90, padding:'14px 16px', borderRadius:10, border:'1.5px solid', background:'#fff', color:'#1a1a1a', cursor:'pointer', fontSize:14, fontFamily:'inherit', textAlign:'center', transition:'all 0.12s' }, feedback: { textAlign:'left', background:'#fff', borderRadius:10, padding:'14px 18px', margin:'0 auto', maxWidth:400, border:'1px solid #eee' }, stripTbl: { width:'100%', borderCollapse:'collapse', marginTop:10 }, stripH: { padding:'4px 6px', fontSize:10, fontWeight:600, textTransform:'uppercase', letterSpacing:0.3, color:'#bbb', textAlign:'center' }, stripD: { padding:'6px', textAlign:'center', fontSize:13 }, dotsRow: { display:'flex', gap:5, justifyContent:'center', alignItems:'center', position:'fixed', bottom:20, left:0, right:0 }, dotsLabel: { fontSize:11, color:'#aaa', marginLeft:10 }, dot: { width:7, height:7, borderRadius:4, transition:'background 0.2s' }, nextBtn: { padding:'11px 22px', borderRadius:8, border:'none', background:GREEN, color:'#fff', fontSize:14, fontWeight:700, cursor:'pointer', fontFamily:'inherit' }, paywallCard: { background:'#fff', border:'1px solid #eee', borderRadius:12, padding:'24px 20px', textAlign:'left' }, payTitle: { fontSize:30, lineHeight:1.1, margin:'0 0 10px', color:'#1a1a1a', letterSpacing:0, fontWeight:800 }, payCopy: { fontSize:14, lineHeight:1.55, color:'#666', margin:'0 0 16px' }, benefits: { display:'grid', gap:7, margin:'0 0 18px' }, benefit: { fontSize:13, color:'#444', fontWeight:600 }, planGrid: { display:'grid', gridTemplateColumns:'1fr', gap:10 }, planCard: { position:'relative', display:'grid', gridTemplateColumns:'1fr auto', alignItems:'end', gap:'2px 12px', width:'100%', padding:'15px 14px', borderRadius:10, border:'1.5px solid #e6e6e6', background:'#fff', color:'#1a1a1a', cursor:'pointer', fontFamily:'inherit', textAlign:'left' }, planFeatured: { borderColor:GREEN, background:'#f3ffe8', boxShadow:'0 0 0 3px rgba(88,167,0,0.14)' }, planNote: { gridColumn:'1 / -1', fontSize:10, fontWeight:800, letterSpacing:0.6, textTransform:'uppercase', color:GREEN, marginBottom:2 }, planName: { fontSize:14, fontWeight:800 }, planPrice: { fontSize:20, fontWeight:800, textAlign:'right' }, planCadence: { fontSize:11, color:'#999' }, payMsg: { marginTop:12, border:'1px solid #f5d0d0', background:'#fff7f7', color:'#b91c1c', borderRadius:8, padding:'10px 12px', fontSize:12, lineHeight:1.4 }, payFine: { marginTop:12, fontSize:11, color:'#aaa', lineHeight:1.4 }, footerContact: { borderTop:'1px solid #e9e9e9', marginTop:28, paddingTop:18, display:'flex', gap:12, justifyContent:'space-between', alignItems:'center', color:'#777', fontSize:12, lineHeight:1.4 }, footerMail: { flex:'0 0 auto', color:GREEN, fontSize:12, fontWeight:800, textDecoration:'none' }, overlay: { position:'fixed', inset:0, background:'rgba(0,0,0,0.35)', display:'flex', alignItems:'center', justifyContent:'center', zIndex:100, padding:16 }, modal: { background:'#fff', borderRadius:14, padding:'22px 22px', maxWidth:520, width:'100%', maxHeight:'88vh', overflow:'auto', boxShadow:'0 8px 30px rgba(0,0,0,0.12)' }, chartH: { fontSize:13, fontWeight:700, color:'#1a1a1a', marginBottom:6 }, fullChart: { width:'100%', borderCollapse:'collapse', fontSize:14 }, fcTh: { padding:'6px 4px', fontSize:11, fontWeight:600, textAlign:'center', color:'#888', textTransform:'capitalize' }, fcTd: { padding:'7px 4px', textAlign:'center', fontSize:14, borderBottom:'1px solid #f3f3f3' }, };