import { useState, useEffect, useCallback, useRef } from "react"; // ═══════════════════════════════════════════ // 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; } }; // ═══════════════════════════════════════════ // DATA // ═══════════════════════════════════════════ const DEF = { masculine:{nominative:'der',accusative:'den',dative:'dem',genitive:'des'}, feminine: {nominative:'die',accusative:'die',dative:'der',genitive:'der'}, neuter: {nominative:'das',accusative:'das',dative:'dem',genitive:'des'}, plural: {nominative:'die',accusative:'die',dative:'den',genitive:'der'}, }; const INDEF = { masculine:{nominative:'ein', accusative:'einen',dative:'einem',genitive:'eines'}, feminine: {nominative:'eine',accusative:'eine', dative:'einer',genitive:'einer'}, neuter: {nominative:'ein', accusative:'ein', dative:'einem',genitive:'eines'}, plural: {nominative:'keine',accusative:'keine',dative:'keinen',genitive:'keiner'}, }; const STRONG = { masculine:{nominative:'r',accusative:'n',dative:'m',genitive:'n'}, feminine: {nominative:'e',accusative:'e',dative:'r',genitive:'r'}, neuter: {nominative:'s',accusative:'s',dative:'m',genitive:'n'}, plural: {nominative:'e',accusative:'e',dative:'n',genitive:'r'}, }; const WEAK = { masculine:{nominative:'e',accusative:'n',dative:'n',genitive:'n'}, feminine: {nominative:'e',accusative:'e',dative:'n',genitive:'n'}, neuter: {nominative:'e',accusative:'e',dative:'n',genitive:'n'}, plural: {nominative:'n',accusative:'n',dative:'n',genitive:'n'}, }; const PREPS = { durch:{c:'accusative',en:'through'}, für:{c:'accusative',en:'for'}, gegen:{c:'accusative',en:'against'}, ohne:{c:'accusative',en:'without'}, um:{c:'accusative',en:'around'}, bis:{c:'accusative',en:'until'}, aus:{c:'dative',en:'out of'}, bei:{c:'dative',en:'at'}, mit:{c:'dative',en:'with'}, nach:{c:'dative',en:'to/after'}, seit:{c:'dative',en:'since'}, von:{c:'dative',en:'from'}, zu:{c:'dative',en:'to'}, außer:{c:'dative',en:'besides'}, gegenüber:{c:'dative',en:'opposite'}, trotz:{c:'genitive',en:'despite'}, während:{c:'genitive',en:'during'}, wegen:{c:'genitive',en:'because of'}, statt:{c:'genitive',en:'instead of'}, anstatt:{c:'genitive',en:'instead of'}, außerhalb:{c:'genitive',en:'outside'}, innerhalb:{c:'genitive',en:'inside'}, an:{c:'wechsel',en:'at/on',m:'to',l:'at'}, auf:{c:'wechsel',en:'on',m:'onto',l:'on'}, hinter:{c:'wechsel',en:'behind',m:'behind',l:'behind'}, in:{c:'wechsel',en:'in/into',m:'into',l:'in'}, neben:{c:'wechsel',en:'next to',m:'next to',l:'next to'}, über:{c:'wechsel',en:'over/above',m:'over',l:'above'}, unter:{c:'wechsel',en:'under',m:'under',l:'under'}, vor:{c:'wechsel',en:'in front of',m:'in front of',l:'in front of'}, zwischen:{c:'wechsel',en:'between',m:'between',l:'between'}, }; const PREPS_BY_CASE = { accusative:['durch','für','gegen','ohne','um','bis'], dative:['aus','bei','mit','nach','seit','von','zu','außer','gegenüber'], genitive:['trotz','während','wegen','statt','anstatt','außerhalb','innerhalb'], wechsel:['an','auf','hinter','neben','unter','über','in','vor','zwischen'], }; const COMMON_PREPS = ['mit','von','zu','in','an','auf','für','durch','ohne','um','aus','bei','nach','vor','über','wegen']; const RARE_PREPS = ['gegen','bis','seit','außer','gegenüber','hinter','neben','unter','zwischen','trotz','während','statt','anstatt','außerhalb','innerhalb']; const ALL_PREPS = [...COMMON_PREPS, ...RARE_PREPS]; const WECHSEL_EX = { an: {noun:'Tür', g:'feminine', en:'door', mot:'to', loc:'at'}, auf: {noun:'Tisch', g:'masculine', en:'table', mot:'onto', loc:'on'}, hinter: {noun:'Haus', g:'neuter', en:'house', mot:'behind', loc:'behind'}, in: {noun:'Park', g:'masculine', en:'park', mot:'into', loc:'in'}, neben: {noun:'Tisch', g:'masculine', en:'table', mot:'next to', loc:'next to'}, über: {noun:'Brücke', g:'feminine', en:'bridge', mot:'over', loc:'above'}, unter: {noun:'Brücke', g:'feminine', en:'bridge', mot:'under', loc:'under'}, vor: {noun:'Haus', g:'neuter', en:'house', mot:'in front of', loc:'in front of'}, zwischen: {noun:'Bäume', g:'plural', en:'trees', mot:'between', loc:'between', plDat:'n'}, }; function wechselExample(prepKey, isMotion) { const ex = WECHSEL_EX[prepKey]; const cas = isMotion ? 'accusative' : 'dative'; const art = DEF[ex.g][cas]; let n = ex.noun; if (ex.g === 'plural' && !isMotion && ex.plDat) n += ex.plDat; return { de: `${prepKey} ${art} ${n}`, en: `${isMotion ? ex.mot : ex.loc} the ${ex.en}` }; } const NOUNS = [ {w:'Hund',g:'masculine',en:'dog'},{w:'Mann',g:'masculine',en:'man'}, {w:'Tisch',g:'masculine',en:'table'},{w:'Park',g:'masculine',en:'park'}, {w:'Arzt',g:'masculine',en:'doctor'},{w:'Freund',g:'masculine',en:'friend'}, {w:'Baum',g:'masculine',en:'tree'},{w:'Garten',g:'masculine',en:'garden'}, {w:'Stuhl',g:'masculine',en:'chair'},{w:'Bruder',g:'masculine',en:'brother'}, {w:'Lehrer',g:'masculine',en:'teacher'},{w:'Vater',g:'masculine',en:'father'}, {w:'Frau',g:'feminine',en:'woman'},{w:'Katze',g:'feminine',en:'cat'}, {w:'Schule',g:'feminine',en:'school'},{w:'Stadt',g:'feminine',en:'city'}, {w:'Straße',g:'feminine',en:'street'},{w:'Tür',g:'feminine',en:'door'}, {w:'Schwester',g:'feminine',en:'sister'},{w:'Mutter',g:'feminine',en:'mother'}, {w:'Tochter',g:'feminine',en:'daughter'},{w:'Musik',g:'feminine',en:'music'}, {w:'Nacht',g:'feminine',en:'night'},{w:'Woche',g:'feminine',en:'week'}, {w:'Kind',g:'neuter',en:'child'},{w:'Haus',g:'neuter',en:'house'}, {w:'Buch',g:'neuter',en:'book'},{w:'Auto',g:'neuter',en:'car'}, {w:'Mädchen',g:'neuter',en:'girl'},{w:'Fenster',g:'neuter',en:'window'}, {w:'Hotel',g:'neuter',en:'hotel'},{w:'Museum',g:'neuter',en:'museum'}, {w:'Büro',g:'neuter',en:'office'},{w:'Restaurant',g:'neuter',en:'restaurant'}, ]; const PRONOUNS = [ {key:'ich', nom:'ich', acc:'mich', dat:'mir', en:'I', enObj:'me', tag:'1st person', num:'sg'}, {key:'du', nom:'du', acc:'dich', dat:'dir', en:'you', enObj:'you', tag:'2nd informal', num:'sg'}, {key:'Sie_sg', nom:'Sie', acc:'Sie', dat:'Ihnen', en:'you', enObj:'you', tag:'2nd formal', num:'sg'}, {key:'er', nom:'er', acc:'ihn', dat:'ihm', en:'he', enObj:'him', tag:'3rd masculine', num:'sg'}, {key:'sie_sg', nom:'sie', acc:'sie', dat:'ihr', en:'she', enObj:'her', tag:'3rd feminine', num:'sg'}, {key:'es', nom:'es', acc:'es', dat:'ihm', en:'it', enObj:'it', tag:'3rd neuter', num:'sg'}, {key:'wir', nom:'wir', acc:'uns', dat:'uns', en:'we', enObj:'us', tag:'1st plural', num:'pl'}, {key:'ihr', nom:'ihr', acc:'euch', dat:'euch', en:"y'all", enObj:"y'all", tag:'2nd inf plural', num:'pl'}, {key:'Sie_pl', nom:'Sie', acc:'Sie', dat:'Ihnen', en:'you (formal)', enObj:'you (formal)', tag:'2nd formal pl', num:'pl'}, {key:'sie_pl', nom:'sie', acc:'sie', dat:'ihnen', en:'they', enObj:'them', tag:'3rd plural', num:'pl'}, ]; const POSSESSIVES = [ {stem:'mein', en:'my'}, {stem:'dein', en:'your (informal)'}, {stem:'sein', en:'his / its'}, {stem:'ihr', en:'her / their'}, {stem:'unser', en:'our'}, {stem:'euer', en:"y'all's"}, ]; const POSS_ENDINGS = { masculine:{nominative:'', accusative:'en', dative:'em', genitive:'es'}, feminine: {nominative:'e', accusative:'e', dative:'er', genitive:'er'}, neuter: {nominative:'', accusative:'', dative:'em', genitive:'es'}, plural: {nominative:'e', accusative:'e', dative:'en', genitive:'er'}, }; const combinePoss = (stem, ending) => (stem === 'euer' && ending) ? 'eur' + ending : stem + ending; const CL = {nominative:'Nominativ',accusative:'Akkusativ',dative:'Dativ',genitive:'Genitiv',wechsel:'Wechsel'}; const CASE_C = {nominative:'#2563eb',accusative:'#dc2626',dative:'#ea580c',genitive:'#16a34a',wechsel:'#7c3aed'}; const GENDER_C = {masculine:'#16a34a',feminine:'#db2777',neuter:'#2563eb',plural:'#7c3aed'}; const VERBS = { sein: {de:{Er:'ist',Sie:'ist',Wir:'sind',Ich:'bin'}, en:{Er:'is',Sie:'is',Wir:'are',Ich:'am'}}, stehen: {de:{Er:'steht',Sie:'steht',Wir:'stehen',Ich:'stehe'}, en:{Er:'is standing',Sie:'is standing',Wir:'are standing',Ich:'am standing'}}, sitzen: {de:{Er:'sitzt',Sie:'sitzt',Wir:'sitzen',Ich:'sitze'}, en:{Er:'is sitting',Sie:'is sitting',Wir:'are sitting',Ich:'am sitting'}}, warten: {de:{Er:'wartet',Sie:'wartet',Wir:'warten',Ich:'warte'}, en:{Er:'waits',Sie:'waits',Wir:'wait',Ich:'wait'}}, wohnen: {de:{Er:'wohnt',Sie:'wohnt',Wir:'wohnen',Ich:'wohne'}, en:{Er:'lives',Sie:'lives',Wir:'live',Ich:'live'}}, gehen: {de:{Er:'geht',Sie:'geht',Wir:'gehen',Ich:'gehe'}, en:{Er:'walks',Sie:'walks',Wir:'walk',Ich:'walk'}}, laufen: {de:{Er:'läuft',Sie:'läuft',Wir:'laufen',Ich:'laufe'}, en:{Er:'runs',Sie:'runs',Wir:'run',Ich:'run'}}, fahren: {de:{Er:'fährt',Sie:'fährt',Wir:'fahren',Ich:'fahre'}, en:{Er:'drives',Sie:'drives',Wir:'drive',Ich:'drive'}}, kommen: {de:{Er:'kommt',Sie:'kommt',Wir:'kommen',Ich:'komme'}, en:{Er:'comes',Sie:'comes',Wir:'come',Ich:'come'}}, reisen: {de:{Er:'reist',Sie:'reist',Wir:'reisen',Ich:'reise'}, en:{Er:'travels',Sie:'travels',Wir:'travel',Ich:'travel'}}, }; const SUBJ_EN = {Er:'He',Sie:'She',Wir:'We',Ich:'I'}; const MOTION_VS = ['gehen','laufen','fahren','kommen','reisen']; const LOCATION_VS = ['sein','stehen','sitzen','warten','wohnen']; function mkv(stems) { return { ich: stems.ich, du: stems.du, er: stems.er, sie_sg: stems.er, es: stems.er, wir: stems.wir, sie_pl: stems.wir, Sie_sg: stems.wir, Sie_pl: stems.wir, ihr: stems.ihr, }; } const VC = { kommen: {de:mkv({ich:'komme',du:'kommst',er:'kommt',wir:'kommen',ihr:'kommt'}), en:mkv({ich:'come',du:'come',er:'comes',wir:'come',ihr:'come'})}, sein: {de:mkv({ich:'bin',du:'bist',er:'ist',wir:'sind',ihr:'seid'}), en:mkv({ich:'am',du:'are',er:'is',wir:'are',ihr:'are'})}, haben: {de:mkv({ich:'habe',du:'hast',er:'hat',wir:'haben',ihr:'habt'}), en:mkv({ich:'have',du:'have',er:'has',wir:'have',ihr:'have'})}, sehen: {de:mkv({ich:'sehe',du:'siehst',er:'sieht',wir:'sehen',ihr:'seht'}), en:mkv({ich:'see',du:'see',er:'sees',wir:'see',ihr:'see'})}, kennen: {de:mkv({ich:'kenne',du:'kennst',er:'kennt',wir:'kennen',ihr:'kennt'}), en:mkv({ich:'know',du:'know',er:'knows',wir:'know',ihr:'know'})}, lieben: {de:mkv({ich:'liebe',du:'liebst',er:'liebt',wir:'lieben',ihr:'liebt'}), en:mkv({ich:'love',du:'love',er:'loves',wir:'love',ihr:'love'})}, helfen: {de:mkv({ich:'helfe',du:'hilfst',er:'hilft',wir:'helfen',ihr:'helft'}), en:mkv({ich:'help',du:'help',er:'helps',wir:'help',ihr:'help'})}, geben: {de:mkv({ich:'gebe',du:'gibst',er:'gibt',wir:'geben',ihr:'gebt'}), en:mkv({ich:'give',du:'give',er:'gives',wir:'give',ihr:'give'})}, }; const SG_PRONOUN_KEYS = ['ich','du','er','sie_sg','es']; const PL_PRONOUN_KEYS = ['wir','ihr','sie_pl']; const FORMAL_PRONOUN_KEYS = ['Sie_sg','Sie_pl']; const ALL_PRONOUN_KEYS = PRONOUNS.map(p=>p.key); const SAFE_SUBJECT_KEYS = ['ich','du','er','wir','ihr']; const TABS = [ {id:'prep', label:'Prepositions'}, {id:'article', label:'Articles'}, {id:'poss', label:'Possessives'}, {id:'pronoun', label:'Pronouns'}, ]; const FREE_LEVEL_IDS = new Set([1, 2, 3, 4, 8, 9, 10, 18]); const CORE_ARTICLE_CASES = ['nominative', 'accusative', 'dative']; const PAYMENTS_ENABLED = String(import.meta.env.VITE_PAYMENTS_ENABLED || 'false').toLowerCase() === 'true'; const STRIPE_PLANS = [ { 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', }, ]; const LEVELS = [ {id:1, tab:'article', name:'Nominativ', desc:'Gender basics: der, die, or das?', cases:['nominative'], preps:[], type:'article', artType:'definite'}, {id:2, tab:'article', name:'Akkusativ', desc:'durch, für, gegen, ohne, um, bis', cases:['accusative'], preps:PREPS_BY_CASE.accusative, type:'article', artType:'definite'}, {id:3, tab:'article', name:'Dativ', desc:'mit, von, zu, aus, bei, nach, seit...', cases:['dative'], preps:PREPS_BY_CASE.dative, type:'article', artType:'definite'}, {id:4, tab:'article', name:'Wechselpräpositionen', desc:'Two-way: motion (Akk) vs location (Dat)', cases:['accusative','dative'], preps:PREPS_BY_CASE.wechsel, type:'article', artType:'definite', twoWay:true}, {id:4.5, tab:'article', name:'Kontraktionen', desc:'Fused forms in context: ins, am, zum…', cases:['accusative','dative'], type:'chunk', artType:'definite'}, {id:5, tab:'article', name:'Genitiv', desc:'trotz, während, wegen, statt...', cases:['genitive'], preps:PREPS_BY_CASE.genitive, type:'article', artType:'definite'}, {id:6, tab:'article', name:'Alles gemischt', desc:'All prepositions, definite articles', cases:['nominative','accusative','dative','genitive'], preps:[...PREPS_BY_CASE.accusative,...PREPS_BY_CASE.dative,...PREPS_BY_CASE.genitive,...PREPS_BY_CASE.wechsel], type:'article', artType:'definite', mixed:true}, {id:7, tab:'article', name:'ein-Wörter', desc:'Everything with ein/eine/einem...', cases:['nominative','accusative','dative','genitive'], preps:[...PREPS_BY_CASE.accusative,...PREPS_BY_CASE.dative,...PREPS_BY_CASE.genitive,...PREPS_BY_CASE.wechsel], type:'article', artType:'indefinite', mixed:true}, {id:8, tab:'prep', name:'Häufige Präpositionen', desc:'16 most common prepositions', preps:COMMON_PREPS, type:'prep'}, {id:9, tab:'prep', name:'Seltene Präpositionen', desc:'15 less common prepositions', preps:RARE_PREPS, type:'prep'}, {id:10, tab:'prep', name:'Alle Präpositionen', desc:'All 31 prepositions mixed', preps:ALL_PREPS, type:'prep'}, {id:18, tab:'prep', name:'Wechsel im Kontext', desc:'Real sentences: Akkusativ or Dativ?', preps:PREPS_BY_CASE.wechsel, type:'wechselContext'}, {id:19, tab:'prep', name:'Kontraktionen', desc:'Contractions: am, ins, zum, beim…', type:'contraction'}, {id:11, tab:'poss', name:'Häufige Possessive', desc:'mein · dein · sein — all cases', possessives:['mein','dein','sein'], cases:['nominative','accusative','dative'], type:'poss'}, {id:12, tab:'poss', name:'Schwierige Possessive', desc:'ihr · unser · euer — all cases', possessives:['ihr','unser','euer'], cases:['nominative','accusative','dative'], type:'poss'}, {id:13, tab:'poss', name:'Alle gemischt', desc:'mein, dein, sein, ihr, unser, euer — incl. Genitiv', possessives:['mein','dein','sein','ihr','unser','euer'], cases:['nominative','accusative','dative','genitive'], type:'poss'}, {id:14, tab:'pronoun', name:'Singular Pronomen', desc:'ich, du, er, sie, es — all cases', pronouns:SG_PRONOUN_KEYS, cases:['nominative','accusative','dative'], type:'pronoun'}, {id:15, tab:'pronoun', name:'Plural Pronomen', desc:'wir, ihr, sie — all cases', pronouns:PL_PRONOUN_KEYS, cases:['nominative','accusative','dative'], type:'pronoun'}, {id:16, tab:'pronoun', name:'Sie (formal)', desc:'Formal Sie — singular and plural', pronouns:FORMAL_PRONOUN_KEYS, cases:['nominative','accusative','dative'], type:'pronoun'}, {id:17, tab:'pronoun', name:'Alle gemischt', desc:'All 10 pronouns, all cases', pronouns:ALL_PRONOUN_KEYS, cases:['nominative','accusative','dative'], type:'pronoun'}, ]; // 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:[], }, ]; // Contractions (preposition + article). pref:true = preferred even in writing. const CONTRACTIONS = [ {prep:'an', art:'das', form:'ans', pref:true, c:'wechsel'}, {prep:'an', art:'dem', form:'am', pref:true, c:'wechsel'}, {prep:'in', art:'das', form:'ins', pref:true, c:'wechsel'}, {prep:'in', art:'dem', form:'im', pref:true, c:'wechsel'}, {prep:'auf',art:'das', form:'aufs',pref:true, c:'wechsel'}, {prep:'bei',art:'dem', form:'beim',pref:true, c:'dative'}, {prep:'von',art:'dem', form:'vom', pref:true, c:'dative'}, {prep:'zu', art:'dem', form:'zum', pref:true, c:'dative'}, {prep:'zu', art:'der', form:'zur', pref:true, c:'dative'}, {prep:'durch',art:'das',form:'durchs',pref:false,c:'accusative'}, {prep:'für',art:'das', form:'fürs',pref:false, c:'accusative'}, {prep:'um', art:'das', form:'ums', pref:false, c:'accusative'}, {prep:'über',art:'das',form:'übers',pref:false,c:'wechsel'}, {prep:'unter',art:'das',form:'unters',pref:false,c:'wechsel'}, {prep:'vor',art:'das', form:'vors',pref:false, c:'wechsel'}, {prep:'hinter',art:'das',form:'hinters',pref:false,c:'wechsel'}, ]; // Only PREFERRED contractions drive the in-context tests, so the contracted // form is unambiguously the best answer (the two-word form is the distractor). const CONTRACTABLE = {}; CONTRACTIONS.filter(c=>c.pref).forEach(c => { (CONTRACTABLE[c.prep] = CONTRACTABLE[c.prep] || {})[c.art] = c.form; }); // Prepositions that get the fused "chunk" question in mixed/dedicated lessons. const CHUNK_PREPS = new Set(Object.keys(CONTRACTABLE)); // an, in, auf, bei, von, zu const WECHSEL_SET = new Set(PREPS_BY_CASE.wechsel); const pick = a => a[Math.floor(Math.random()*a.length)]; const cap = s => s.charAt(0).toUpperCase() + s.slice(1); // ═══════════════════════════════════════════ // QUESTION GENERATORS // ═══════════════════════════════════════════ function genArticleQ(level) { const lv = LEVELS.find(l=>l.id===level); const noun = pick(NOUNS); const tbl = lv.artType === 'indefinite' ? INDEF : DEF; let cas, prepKey, tw = null, verb; if (lv.id === 1) { cas='nominative'; prepKey=null; } else if (lv.twoWay) { prepKey=pick(lv.preps); tw=Math.random()>0.5; cas=tw?'accusative':'dative'; } else if (lv.mixed) { if (Math.random()<0.12) { cas='nominative'; prepKey=null; } else { prepKey=pick(lv.preps); const pc=PREPS[prepKey].c; if (pc==='wechsel') { tw=Math.random()>0.5; cas=tw?'accusative':'dative'; } else cas=pc; } } else { prepKey=pick(lv.preps); cas=lv.cases[0]; } // Mixed definite lessons: contraction-capable prep → fused "chunk" question. if (lv.mixed && lv.artType === 'definite' && prepKey && CHUNK_PREPS.has(prepKey)) { return makeChunkQ(prepKey, noun, cas, tw); } const ans = tbl[noun.g][cas]; const _pool = lv.artType === 'indefinite' ? ['ein','eine','einen','einem','eines','einer','keine','keinen','keiner'] : ['der','die','das','den','dem','des']; const _rem = _pool.filter(a => a !== ans); const _picked = []; while (_picked.length < 3 && _rem.length) _picked.push(_rem.splice(Math.floor(Math.random()*_rem.length), 1)[0]); const choices = [ans, ..._picked].sort(() => Math.random() - 0.5); let sentence, translation, rule; if (!prepKey) { sentence = `_____ ${noun.w} ist hier.`; translation = `The ${noun.en} is here.`; rule = 'Subject of the sentence → Nominativ'; } else { const subj = pick(['Er','Sie','Wir']); if (tw===true) verb=pick(MOTION_VS); else if (tw===false) verb=pick(LOCATION_VS); else if (cas==='genitive') verb=pick(['warten','wohnen']); else verb=pick(MOTION_VS); sentence = `${subj} ${VERBS[verb].de[subj]} ${prepKey} _____ ${noun.w}.`; let pEn = PREPS[prepKey].en; if (PREPS[prepKey].c==='wechsel') pEn = tw ? PREPS[prepKey].m : PREPS[prepKey].l; translation = `${SUBJ_EN[subj]} ${VERBS[verb].en[subj]} ${pEn} the ${noun.en}.`; rule = PREPS[prepKey].c==='wechsel' ? (tw ? `„${prepKey}" + motion (wohin?) → Akkusativ` : `„${prepKey}" + location (wo?) → Dativ`) : `„${prepKey}" → always ${CL[cas]}`; } return {type:'article', noun, cas, prepKey, ans, sentence, translation, rule, tw, artType:lv.artType, choices}; } function genPrepQ(level) { const lv = LEVELS.find(l=>l.id===level); const prepKey = pick(lv.preps); const p = PREPS[prepKey]; const exampleNoun = pick(NOUNS); const exCase = p.c==='wechsel' ? 'dative' : p.c; return {type:'prep', prepKey, prepEn:p.en, ans:p.c, example:`${prepKey} ${DEF[exampleNoun.g][exCase]} ${exampleNoun.w}`, exampleEn:exampleNoun.en}; } function genWechselContextQ(level) { const lv = LEVELS.find(l=>l.id===level); const prepKey = pick(lv.preps); const isMotion = Math.random() > 0.5; const cas = isMotion ? 'accusative' : 'dative'; const subj = pick(['Er','Sie','Wir','Ich']); const verb = isMotion ? pick(MOTION_VS) : pick(LOCATION_VS); const ex = WECHSEL_EX[prepKey]; const art = DEF[ex.g][cas]; let nounStr = ex.noun; if (ex.g === 'plural' && !isMotion && ex.plDat) nounStr += ex.plDat; const sentence = `${subj} ${VERBS[verb].de[subj]} ${prepKey} ${art} ${nounStr}.`; const pEn = isMotion ? ex.mot : ex.loc; const translation = `${SUBJ_EN[subj]} ${VERBS[verb].en[subj]} ${pEn} the ${ex.en}.`; return {type:'wechselContext', prepKey, ans:cas, isMotion, verb, sentence, translation, nounEn: ex.en}; } function genPossQ(level) { const lv = LEVELS.find(l=>l.id===level); const noun = pick(NOUNS); const stem = pick(lv.possessives); const possMeaning = POSSESSIVES.find(p=>p.stem===stem).en; let cas, prepKey=null, tw=null, verb; const wantPrep = Math.random() > 0.5; if (wantPrep) { const allPreps = lv.cases.includes('genitive') ? [...PREPS_BY_CASE.accusative,...PREPS_BY_CASE.dative,...PREPS_BY_CASE.wechsel,...PREPS_BY_CASE.genitive] : [...PREPS_BY_CASE.accusative,...PREPS_BY_CASE.dative,...PREPS_BY_CASE.wechsel]; prepKey = pick(allPreps); const pc = PREPS[prepKey].c; if (pc==='wechsel') { tw=Math.random()>0.5; cas=tw?'accusative':'dative'; } else cas=pc; if (!lv.cases.includes(cas)) { cas=pick(lv.cases); prepKey=null; tw=null; } } else { cas = pick(lv.cases.filter(c => c !== 'genitive')) || lv.cases[0]; } const ans = combinePoss(stem, POSS_ENDINGS[noun.g][cas]); let sentence, translation, rule; if (!prepKey) { if (cas==='nominative') { sentence = `_____ ${noun.w} ist hier.`; translation = `${cap(possMeaning)} ${noun.en} is here.`; } else if (cas==='accusative') { const v = pick([['liebe','love'],['sehe','see'],['kenne','know'],['höre','hear']]); sentence = `Ich ${v[0]} _____ ${noun.w}.`; translation = `I ${v[1]} ${possMeaning} ${noun.en}.`; } else if (cas==='dative') { sentence = `Ich helfe _____ ${noun.w}.`; translation = `I help ${possMeaning} ${noun.en}.`; } } else { const subj='Ich'; if (tw===true) verb='gehen'; else if (tw===false) verb='sein'; else if (PREPS[prepKey].c==='genitive') verb='warten'; else verb='gehen'; sentence = `${subj} ${VERBS[verb].de[subj]} ${prepKey} _____ ${noun.w}.`; let pEn = PREPS[prepKey].en; if (PREPS[prepKey].c==='wechsel') pEn = tw ? PREPS[prepKey].m : PREPS[prepKey].l; translation = `${SUBJ_EN[subj]} ${VERBS[verb].en[subj]} ${pEn} ${possMeaning} ${noun.en}.`; } rule = `${stem}- + ${CL[cas]} + ${noun.g} → ${ans}`; return {type:'poss', noun, stem, possMeaning, cas, ans, sentence, translation, rule, prepKey, tw}; } function genPronounQ(level) { const lv = LEVELS.find(l=>l.id===level); const pronKey = pick(lv.pronouns); const pron = PRONOUNS.find(p=>p.key===pronKey); const cas = pick(lv.cases); const rawAns = pron[{nominative:'nom',accusative:'acc',dative:'dat'}[cas]]; let sentence, translation, ans; if (cas === 'nominative') { const tpl = pick([ {v:'kommen', dePart:' aus Berlin', enPart:' from Berlin'}, {v:'sein', dePart:' müde', enPart:' tired'}, {v:'haben', dePart:' einen Hund', enPart:' a dog'}, ]); const vDe = VC[tpl.v].de[pronKey]; const vEn = VC[tpl.v].en[pronKey]; sentence = `_____ ${vDe}${tpl.dePart}.`; translation = `${cap(pron.en)} ${vEn}${tpl.enPart}.`; ans = cap(rawAns); } else { const subjCandidates = SAFE_SUBJECT_KEYS.filter(k => k !== pronKey); const subjKey = pick(subjCandidates); const subj = PRONOUNS.find(p=>p.key===subjKey); let tpl; if (cas === 'accusative') { tpl = pick([{v:'sehen'}, {v:'kennen'}, {v:'lieben'}]); } else { tpl = pick([{v:'helfen'}, {v:'geben', dePost:' das Buch', enPost:' the book'}]); } const sDe = VC[tpl.v].de[subjKey]; const sEn = VC[tpl.v].en[subjKey]; const dePost = tpl.dePost || ''; const enPost = tpl.enPost || ''; sentence = `${cap(subj.nom)} ${sDe} _____${dePost}.`; translation = `${cap(subj.en)} ${sEn} ${pron.enObj}${enPost}.`; ans = rawAns; } return {type:'pronoun', pronKey, pron, cas, ans, sentence, translation}; } function genContractionQ() { const c = pick(CONTRACTIONS); return {type:'contraction', prep:c.prep, art:c.art, ans:c.form, pref:c.pref, cas:c.c}; } // Build the 4 choices for a fused prep+article question: always a BLEND of // contracted and two-word forms, so the shape never reveals whether it contracts. function buildChunkChoices(prepKey, article) { const map = CONTRACTABLE[prepKey] || {}; const contracts = !!map[article]; const correct = contracts ? map[article] : `${prepKey} ${article}`; const cand = new Set(); Object.values(map).forEach(f => cand.add(f)); // contraction shapes Object.keys(map).forEach(a => cand.add(`${prepKey} ${a}`)); // their two-word forms cand.add(`${prepKey} ${article}`); // two-word of the real article cand.delete(correct); const pool = [...cand].sort(() => Math.random() - 0.5); const choices = [correct, ...pool.slice(0, 3)]; const filler = ['das','dem','der','die','den'].map(a => `${prepKey} ${a}`); for (let i = 0; i < filler.length && choices.length < 4; i++) { if (!choices.includes(filler[i])) choices.push(filler[i]); } return { correct, contracts, choices: choices.slice(0, 4).sort(() => Math.random() - 0.5) }; } function pickChunkVerb(prepKey, tw) { if (PREPS[prepKey].c === 'wechsel') return tw ? pick(MOTION_VS) : pick(LOCATION_VS); if (prepKey === 'bei') return pick(['sein','wohnen','warten']); if (prepKey === 'von') return 'kommen'; if (prepKey === 'zu') return pick(['gehen','fahren','kommen']); return pick(['gehen','fahren']); } function makeChunkQ(prepKey, noun, cas, tw) { const article = DEF[noun.g][cas]; const { correct, contracts, choices } = buildChunkChoices(prepKey, article); const subj = pick(['Er','Sie','Wir','Ich']); const verb = pickChunkVerb(prepKey, tw); const sentence = `${subj} ${VERBS[verb].de[subj]} _____ ${noun.w}.`; let pEn = PREPS[prepKey].en; if (PREPS[prepKey].c === 'wechsel') pEn = tw ? PREPS[prepKey].m : PREPS[prepKey].l; const translation = `${SUBJ_EN[subj]} ${VERBS[verb].en[subj]} ${pEn} the ${noun.en}.`; const pref = contracts ? (CONTRACTIONS.find(x => x.prep === prepKey && x.art === article)?.pref ?? true) : null; return { type:'chunk', prepKey, noun, cas, ans:correct, choices, sentence, translation, tw, contracts, pref }; } // Dedicated "Kontraktionen" level: answer ALWAYS contracts (preferred forms only). function genContractionContextQ() { const c = pick(CONTRACTIONS.filter(x => x.pref && CHUNK_PREPS.has(x.prep))); const wechsel = WECHSEL_SET.has(c.prep); let cas, gender, tw = null; if (c.art === 'das') { cas='accusative'; gender='neuter'; tw = wechsel ? true : null; } else if (c.art === 'dem') { cas='dative'; gender=pick(['masculine','neuter']); tw = wechsel ? false : null; } else { cas='dative'; gender='feminine'; tw = wechsel ? false : null; } const noun = pick(NOUNS.filter(n => n.g === gender)); return makeChunkQ(c.prep, noun, cas, tw); } function gen(level) { const lv = LEVELS.find(l=>l.id===level); if (lv.type==='chunk') return genContractionContextQ(); if (lv.type==='contraction') return genContractionQ(); if (lv.type==='prep') return genPrepQ(level); if (lv.type==='wechselContext') return genWechselContextQ(level); if (lv.type==='poss') return genPossQ(level); if (lv.type==='pronoun') return genPronounQ(level); return genArticleQ(level); } // ═══════════════════════════════════════════ // 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); useEffect(()=>{(async()=>{ const data = await Storage.get(); 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 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).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; } setLv(id); setRes([]); setInp(''); setRev(false); setQ(gen(id)); 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); const today = new Date().toISOString().slice(0,10); const 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}; if (ns.ld !== today) { const ld = ns.ld ? new Date(ns.ld) : null; ns.streak = ld && Math.round((new Date(today)-ld)/864e5)===1 ? (ns.streak||0)+1 : 1; ns.ld = today; } save(ns); }, [inp, q, res, st, lv, save]); const doNext = useCallback(() => { if (res.length >= ROUND) { setScr('summary'); return; } setInp(''); setRev(false); setOk(false); setQ(gen(lv)); }, [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); }); 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); return (
{scr !== 'home' && scr !== 'menu' ? :
Beug
}
{scr === 'practice' && } {PAYMENTS_ENABLED && hasPaidAccess && Unlocked} 🔥 {st.streak||0}
{/* HOME */} {scr === 'home' && ( setScr('menu')} /> )} {/* MENU */} {scr === 'menu' && (
{TABS.map(t => ( ))}
{tab === 'prep' && (
{ setLearnStep(0); setScr('learn'); }}>
📚 Lernen
Chunk the lists first — Akkusativ, Dativ, Wechsel, Genitiv
)} {LEVELS.filter(l => l.tab === tab).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 || '\u00A0\u00A0\u00A0\u00A0\u00A0')} )} ))}
{q.translation}
{(q.type === 'article' || q.type === 'chunk') && <> {DEF[q.noun.g].nominative} {' '}{q.noun.w} ({q.noun.en}) } {q.type === 'poss' && <> {q.stem}- ({q.possMeaning}) } {q.type === 'pronoun' && <> {q.pron.nom} ({q.pron.en}) · {q.pron.tag} }
{CL[q.cas]} {q.prepKey && <> · {q.prepKey} ({PREPS[q.prepKey].en})} {q.tw !== null && q.tw !== undefined && <> · {q.tw ? '→ motion' : '● location'}} {q.type === 'poss' && <> · {DEF[q.noun.g].nominative} {q.noun.w}}
{!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}
{q.translation}
{q.prepKey} ({PREPS[q.prepKey].en})
Wechselpräposition · motion or location?
{!rev ? (
{[['accusative','Akkusativ','1','motion'],['dative','Dativ','2','location']].map(([val,lab,key,hint]) => ( ))}
) : ( )}
)} {/* 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) && ( )}
)}
{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? Get in touch. Always looking to improve the tool.
Write an email
); } 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 // ═══════════════════════════════════════════ function FillFeedback({ q, inp, ok }) { const genders = ['masculine','feminine','neuter','plural']; const stripCells = genders.map(g => { if (q.type === 'article') { const tbl = q.artType === 'indefinite' ? INDEF : DEF; return tbl[g][q.cas]; } return combinePoss(q.stem, POSS_ENDINGS[g][q.cas]); }); const stripLabel = q.type === 'article' ? (q.artType === 'indefinite' ? 'ein…' : 'der/die') : `${q.stem}-`; return (
{ok ? '✓ Correct' : <>✗ {inp} {q.ans}}
{q.rule}
{!ok && (
{q.noun.w} is {q.noun.g}. {CL[q.cas]} + {q.noun.g} = {q.ans}
)} {genders.map(g => )} {genders.map((g, i) => { const hl = g === q.noun.g; return ; })}
{CL[q.cas]}{g.slice(0,4)}.
{stripLabel}{stripCells[i]}
); } 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.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 with motion verbs (gehen, fahren, laufen, kommen) and Dativ with location verbs (sein, stehen, sitzen, warten).
); } 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.}
); } 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.'}
); } 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 hl = c === q.cas; return ; })}
{q.pron.nom}{labels[c]}
{q.pron.en}{q.pron[keys[c]]}
); } // ═══════════════════════════════════════════ // 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' }, 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' }, };