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-levels are always free: unlocking one requires // passing every lesson in its tab, so gated tabs stay gated anyway. const DAILY_IDS = LEVELS.filter(l => l.daily).map(l => l.id); const FREE_LEVEL_IDS = new Set([2, 3, 4, 8, 9, 10, 18, ...DAILY_IDS]); 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'; // Touch devices don't get keyboard hints (1–4, ↵) — but the answer buttons // keep the same footprint so tap targets stay big. const IS_TOUCH = typeof window !== 'undefined' && !!window.matchMedia?.('(pointer: coarse)').matches; 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 [rule, setRule] = useState(false); const [pickPrep, setPickPrep] = useState(null); 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; } const lvObj = LEVELS.find(l => l.id === id); if (lvObj?.tab) setTab(lvObj.tab); sessionRef.current = createSession(id); setLv(id); setRes([]); setInp(''); setRev(false); setPickPrep(null); 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 summaryRef = useRef(null); const doSubmit = useCallback((overrideInput) => { const userAns = (overrideInput ?? inp).trim(); if (!userAns) { setShake(true); setTimeout(()=>setShake(false),400); return; } // Two-part answers (contractions) grade against "prep art", not the form. const target = q.answerKey ?? q.ans; const correct = userAns.toLowerCase() === target.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); setPickPrep(null); if (!sessionRef.current) sessionRef.current = createSession(lv); setQ(sessionRef.current.next()); }, [res, lv]); submitRef.current = doSubmit; nextRef.current = doNext; useEffect(() => { const h = e => { if (rule) { if (e.key === 'Escape') setRule(false); return; } if (chart) { if (e.key === 'Escape') setChart(false); return; } if (scr === 'summary' && e.key === 'Enter') { e.preventDefault(); summaryRef.current?.(); return; } if (scr !== 'practice') return; if (!rev && q?.type === 'contraction') { const cloud = pickPrep === null ? q.prepChoices : q.artChoices; const idx = ['1','2','3','4','5','6'].indexOf(e.key); if (idx >= 0 && cloud[idx]) { e.preventDefault(); if (pickPrep === null) setPickPrep(cloud[idx]); else submitRef.current(`${pickPrep} ${cloud[idx]}`); return; } if (e.key === 'Backspace' && pickPrep !== null) { e.preventDefault(); setPickPrep(null); return; } } if (!rev && 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?.choices && q?.type !== 'prep' && q?.type !== 'wechselContext') 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: always on in Daily Practice, opt-in (st.auto) elsewhere. // A correct answer never shows the solution — just a green tick, then it // skips straight to the next question. A wrong answer shows the correction // and waits for an explicit Next, so it can actually register. useEffect(() => { if (scr !== 'practice' || !rev || !ok) return; const quick = !!LEVELS.find(l => l.id === lv)?.daily || !!st.auto; if (!quick) return; const t = setTimeout(() => { nextRef.current?.(); }, 550); 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 = !!cur?.daily || !!st.auto; const quickTick = quickActive && rev && ok; // Each tab's Daily Practice unlocks once every lesson in the tab is passed: // at least one full round each, at the app's 70% pass rate. const passedLv = id => { const s = st.sc?.[id]; return !!(s && s.t >= ROUND && s.c / s.t >= 0.7); }; const tabLessons = t => LEVELS.filter(l => l.tab === t && !l.daily); const dailyFor = t => LEVELS.find(l => l.tab === t && l.daily); const dailyUnlockedFor = t => tabLessons(t).every(l => passedLv(l.id)); // The next tab (cycling through the top tabs) with an unlocked Daily Practice. const nextDailyAfter = t => { const i = TABS.findIndex(x => x.id === t); for (let k = 1; k < TABS.length; k++) { const cand = TABS[(i + k) % TABS.length]; if (dailyUnlockedFor(cand.id)) return { tab: cand, level: dailyFor(cand.id) }; } return null; }; const nextDaily = cur?.daily ? nextDailyAfter(cur.tab) : null; summaryRef.current = () => { if (cur?.daily && nextDaily) startLv(nextDaily.level.id); else startLv(lv); }; return (
{scr !== 'home' && scr !== 'menu' ? :
Beug
}
{scr === 'practice' && ( )} {PAYMENTS_ENABLED && hasPaidAccess && Unlocked}
{/* HOME */} {scr === 'home' && ( setScr('menu')} /> )} {/* MENU */} {scr === 'menu' && (
{TABS.map(t => ( ))}
{(() => { const daily = dailyFor(tab); if (!daily) return null; const lessons = tabLessons(tab); const done = lessons.filter(l => passedLv(l.id)).length; if (!dailyUnlockedFor(tab)) return (
🔒 Daily Practice
Pass every lesson in this section to unlock · {done}/{lessons.length} done
); const sc = st.sc?.[daily.id]; const pct = sc ? Math.round(sc.c / sc.t * 100) : null; return (
startLv(daily.id)}>
⚡ Daily Practice
{daily.desc} · keep it sharp
{sc &&
{sc.c}/{sc.t}
}
{pct !== null &&
=70?GREEN:RED}}>{pct}%
}
); })()} {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.type === 'article' || q.type === 'chunk') ? ( ) : q.type === 'poss' ? ( ) : ( )} {!rev ? ( q.choices ? ( ) : (
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"/>
{!IS_TOUCH &&
or press Enter
}
) ) : quickTick ? ( ) : ( 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]) => ( ))}
) : quickTick ? ( ) : ( )}
)} {/* PRACTICE: wechsel in context */} {scr === 'practice' && q?.type === 'wechselContext' && (
{q.sentence.split('_____').map((part,i,a)=>( {part} {i < a.length-1 && ( {rev ? q.article : ' '} )} ))}
Wechselpräposition · wohin or wo?
{!rev ? (
{[['accusative','Akkusativ','1'],['dative','Dativ','2']].map(([val,lab,key]) => ( ))}
) : quickTick ? ( ) : ( setRule(true)}/> )}
)} {/* PRACTICE: contractions — shown fused, taken apart in two taps */} {scr === 'practice' && q?.type === 'contraction' && (
What is this a contraction of?
{q.ans}
{rev ? {q.prep} + {q.art} : pickPrep ? <> + ? : ? + ?}
{!rev ? (
{pickPrep ? 'Now the article' : 'Which preposition?'}
{ if (pickPrep === null) setPickPrep(w); else doSubmit(`${pickPrep} ${w}`); }} /> {pickPrep && !IS_TOUCH && (
backspace to change the preposition
)}
) : quickTick ? ( ) : ( )}
)} {scr === 'practice' && (
{Array.from({length:ROUND},(_,i)=>(
))}
{Math.min(res.length+(rev?0:1), ROUND)}/{ROUND} · {cur?.name}
)} {scr === 'practice' && rev && !quickTick && (
)} {/* 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.'}
{cur?.daily && nextDaily ? ( <>
) : ( <> {rc>=7 && nextInTab && unlocked(nextInTab.id) && (
{nextInTab.name} unlocked.
)} {rc>=7 && nextInTab && unlocked(nextInTab.id) && ( )} )}
)} {scr !== 'practice' && }
{chart && setChart(false)} onRule={()=>{ setChart(false); setRule(true); }}/>} {rule && setRule(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 // ═══════════════════════════════════════════ // Shared 2×2 answer grid. On touch devices the number-key chips disappear // but the buttons keep their footprint so tap targets stay big. function AnswerGrid({ choices, onPick }) { return (
{choices.map((choice, i) => ( ))}
); } // A loose cloud of word chips. Sizes vary a little so it reads as a cloud // rather than a list, but every chip keeps a full-height tap target. function WordCloud({ words, onPick }) { return (
{words.map((w, i) => ( ))}
); } // One-tap correct answer: no solution shown, just a tick before auto-skip. function QuickTick() { return (
✓
); } 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, onRule }) { return (
{ok ? <>✓ {CL[q.ans]} : <>✗ {CL[inp]||inp} → {CL[q.ans]}}
{q.answerSentence}
{q.contraction && (
{q.prepKey} + {q.article} fuses to {q.contraction} in real German.
)}
„{q.verb === 'ankommen' ? 'ankommen' : q.verb}" {q.isMotion ? 'puts something somewhere new (wohin?)' : q.verb === 'ankommen' ? 'keeps the Dativ even though you arrive — the an- prefix already carries the arrival' : 'just says where it all happens (wo?)'} → {CL[q.ans]}
Before this happened, was it already {PREPS[q.prepKey].en.split('/')[0]} the {q.nounEn}? {' '}{q.isMotion ? <>No — it got there because of the action. That is Akkusativ. : <>Yes — it was already there and stays. That is Dativ.}
{onRule && ( )}
); } // The wohin/wo rule, the trap, the verb pairs, and the one real exception. function WechselRuleModal({ onClose }) { const PAIRS = [ ['legen', 'lay it down', 'liegen', 'be lying'], ['stellen', 'stand it up', 'stehen', 'be standing'], ['setzen', 'sit it down', 'sitzen', 'be sitting'], ['hängen', 'hang it up (hängte)', 'hängen', 'be hanging (hing)'], ['stecken', 'stick it in', 'stecken', 'be stuck in'], ]; return (
e.stopPropagation()}>
Wohin oder wo?
Yes — there is a rule, and it is not “is something moving?”. Ask instead: does the phrase say where the thing ends up, or where the whole action happens?
Akkusativ — wohin?
The action puts something into a new place. It wasn’t there before; it is now.
Er legt das Buch auf den Tisch. — the book arrives on the table.
Dativ — wo?
The phrase just locates the scene. It was already there and stays there.
Das Buch liegt auf dem Tisch. — it was already on the table.
The test that actually works
“Before this happened, was it already there?”
Already there → Dativ. Got there because of the action → Akkusativ.
The trap: movement is not the test
Plenty of motion verbs take the Dativ, because the phrase is the arena the movement happens in, not a destination:
Er schwimmt in dem See (im See) — swimming around in the lake.
Er springt in den See — he was out, now he’s in.
Same story with tanzen, spielen, laufen, rennen.
The verb pairs — learn these cold
The Akkusativ ones are transitive: they always have a direct object being moved. If something is being put somewhere, the preposition takes Akkusativ too.
{PAIRS.map(([a, aEn, d, dEn]) => ( ))}
puts it there · Akk is already there · Dat
{a} {aEn} {d} {dEn}
hängen and stecken are the mean ones — same present tense either way, so the only clue is whether there is a direct object.
The exception worth knowing
ankommen takes the Dativ, even though arriving is the most obvious movement there is:
Wir kommen an dem Bahnhof an (am Bahnhof).
The an- prefix already carries the arrival, so the phrase only says where you ended up being. befestigen an + Dativ works the same way.
); } 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 }) { const [gotPrep, gotArt] = (inp || '').split(' '); const ending = q.ans.slice(-1); const endingRule = {s:'das', m:'dem', r:'der'}[ending]; return (
{ok ? <>✓ {q.ans} = {q.prep} + {q.art} : <>✗ {gotPrep} + {gotArt} → {q.prep} + {q.art}}
{!ok && (
{q.ans} = {q.prep} + {q.art} {gotPrep !== q.prep && <> — the preposition is {q.prep}, not {gotPrep}.}
)} {endingRule && (
Ending -{ending} always means{' '} {endingRule}. That is the whole trick: -s → das, -m → dem, -r → der.
)}
{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={ok ? null : (g, c, val) => !!gotArt && normAns(val) === normAns(gotArt)} />
); } 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, onRule }) { 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: Akkusativ when the phrase is where something ends up (wohin?), Dativ when it is where the action happens (wo?) — movement alone does not decide it. {onRule && }
)} {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, marginBottom:16 }, 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 }, pillBtn: { borderRadius:999, padding:'5px 12px', fontSize:12, fontWeight:600, cursor:'pointer', fontFamily:'inherit', lineHeight:1.2, background:'none' }, pillBtnOff: { border:'none', color:'#999' }, pillBtnOn: { border:`1.5px solid ${GREEN}`, color:GREEN, background:'#f3ffe8' }, chartsPill: { position:'absolute', right:20, bottom:2, borderRadius:999, padding:'5px 13px', fontSize:12, fontWeight:600, cursor:'pointer', fontFamily:'inherit', lineHeight:1.2, border:'1.5px solid #d5d5d5', color:'#777', background:'#fff' }, 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' }, // On touch the keyboard chips are hidden; the min-heights keep the tap // targets exactly as big as the desktop buttons with chips. 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', ...(IS_TOUCH ? {minHeight:80, display:'flex', flexDirection:'column', alignItems:'center', justifyContent:'center'} : {}) }, 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', ...(IS_TOUCH ? {minHeight:66, display:'flex', flexDirection:'column', alignItems:'center', justifyContent:'center'} : {}) }, cloud: { display:'flex', flexWrap:'wrap', gap:10, justifyContent:'center', alignItems:'center', maxWidth:360, margin:'0 auto' }, cloudChip: { position:'relative', padding:'14px 20px', borderRadius:999, border:'1.5px solid #ddd', background:'#fff', color:'#1a1a1a', fontWeight:700, cursor:'pointer', fontFamily:'inherit', minHeight:52, transition:'all 0.12s' }, cloudKbd: { position:'absolute', top:4, right:8, fontSize:9, color:'#ccc', fontWeight:600 }, cloudStep: { fontSize:11, fontWeight:700, textTransform:'uppercase', letterSpacing:0.8, color:'#bbb', marginBottom:12 }, chosenChip: { padding:'6px 16px', borderRadius:999, border:`1.5px solid ${GREEN}`, background:'#f3ffe8', color:GREEN, fontSize:20, fontWeight:700, cursor:'pointer', fontFamily:'inherit' }, ruleBtn: { marginLeft:8, background:'none', border:'1px solid #ddd', borderRadius:999, padding:'3px 10px', color:'#888', fontSize:11, fontWeight:600, cursor:'pointer', fontFamily:'inherit' }, ruleLead: { fontSize:13, color:'#444', lineHeight:1.6, marginBottom:14 }, ruleCard: { background:'#fafafa', borderRadius:8, padding:'12px 14px', marginBottom:10 }, ruleH: { fontSize:12, fontWeight:800, textTransform:'uppercase', letterSpacing:0.6, color:'#bbb', margin:'18px 0 6px' }, ruleBody: { fontSize:13, color:'#555', lineHeight:1.6 }, ruleEx: { fontSize:13, color:'#1a1a1a', lineHeight:1.7, background:'#f5f5f5', borderRadius:6, padding:'8px 10px', margin:'8px 0' }, 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 }, bottomBar: { position:'fixed', bottom:14, left:0, right:0, zIndex:5 }, bottomBarInner: { position:'relative', maxWidth:520, margin:'0 auto', padding:'0 20px' }, dotsRow: { display:'flex', gap:5, justifyContent:'center', alignItems:'center' }, dotsLabel: { fontSize:11, color:'#aaa', textAlign:'center', marginTop:6 }, 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' }, };