Files
Beug/src/App.jsx
T
2026-07-05 12:48:27 +02:00

1702 lines
86 KiB
React
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 Beethovens 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 <div style={S.wrap}><p style={{color:'#999',textAlign:'center',paddingTop:120}}>Loading...</p></div>;
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 (
<div style={S.wrap}>
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;600;700&display=swap" rel="stylesheet"/>
<div style={S.inner}>
<div style={S.hdr}>
{scr !== 'home' && scr !== 'menu'
? <button style={S.linkBtn} onClick={()=>setScr('menu')}> back</button>
: <div style={S.logo}>Beug</div>}
<div style={{display:'flex',gap:14,alignItems:'center'}}>
{scr === 'practice' && <button style={S.linkBtn} onClick={()=>setChart(true)}>charts</button>}
{PAYMENTS_ENABLED && hasPaidAccess && <span style={S.stat}>Unlocked</span>}
<span style={S.stat}>🔥 {st.streak||0}</span>
</div>
</div>
{/* HOME */}
{scr === 'home' && (
<HomeScreen
hasAccess={hasAccess}
onStart={()=>setScr('menu')}
/>
)}
{/* MENU */}
{scr === 'menu' && (
<div>
<div style={S.tabs}>
{TABS.map(t => (
<button key={t.id}
style={{...S.tab, ...(tab===t.id ? S.tabActive : {})}}
onClick={()=>setTab(t.id)}>
{t.label}
</button>
))}
</div>
<div style={{marginTop:10}}>
{tab === 'prep' && (
<div style={{...S.lvRow, cursor:'pointer'}}
onClick={()=>{ setLearnStep(0); setScr('learn'); }}>
<div style={{flex:1, minWidth:0}}>
<div style={S.lvName}>📚 Lernen</div>
<div style={S.lvDesc}>Chunk the lists first Akkusativ, Dativ, Wechsel, Genitiv</div>
</div>
</div>
)}
{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 (
<div key={l.id}
style={{...S.lvRow, ...(u ? {} : S.lvRowBlurred), cursor:'pointer'}}
onClick={()=>startLv(l.id)}>
<div style={{flex:1, minWidth:0}}>
<div style={S.lvName}>{l.name}</div>
<div style={S.lvDesc}>{l.desc}</div>
{s && <div style={{fontSize:11,color:'#bbb',marginTop:3}}>{s.c}/{s.t}</div>}
</div>
{p !== null && <div style={{...S.lvPct, color:p>=70?GREEN:RED}}>{p}%</div>}
</div>
);
})}
</div>
<button style={S.chartLinkBtn} onClick={()=>setChart(true)}>
View {TABS.find(t=>t.id===tab).label.toLowerCase()} charts
</button>
</div>
)}
{/* PAYWALL */}
{PAYMENTS_ENABLED && scr === 'paywall' && (
<PaywallScreen
message={payMsg}
onCheckout={openCheckout}
onBack={()=>setScr('menu')}
/>
)}
{/* LEARN: chunked study cards for prepositions */}
{scr === 'learn' && (() => {
const card = LEARN_CARDS[learnStep];
const isLast = learnStep === LEARN_CARDS.length - 1;
return (
<div style={{paddingTop:8}}>
<div style={{display:'flex', gap:6, justifyContent:'center', marginBottom:18}}>
{LEARN_CARDS.map((c,i)=>(
<div key={c.case} style={{height:4, flex:1, maxWidth:64, borderRadius:2,
background: i<=learnStep ? CASE_C[c.case] : '#e5e5e5'}}/>
))}
</div>
<div style={{background:'#fff', border:'1px solid #eee', borderRadius:12, padding:'20px 18px'}}>
<div style={{fontSize:12, fontWeight:700, textTransform:'uppercase', letterSpacing:1, color:CASE_C[card.case]}}>{card.label}</div>
<div style={{fontSize:13, color:'#999', marginTop:4, marginBottom:16, lineHeight:1.5}}>{card.desc}</div>
<div style={{display:'flex', flexWrap:'wrap', gap:8, marginBottom:16}}>
{card.words.map(w=>(
<div key={w.de} style={{background:'#f7f7f7', borderRadius:8, padding:'8px 12px'}}>
<span style={{fontSize:16, fontWeight:700, color:'#1a1a1a'}}>{w.de}</span>
<span style={{fontSize:12, color:'#aaa', marginLeft:6}}>{w.en}</span>
</div>
))}
</div>
{card.mnemonics.length > 0 && (
<div style={{borderTop:'1px solid #f0f0f0', paddingTop:14}}>
<div style={{fontSize:11, fontWeight:700, textTransform:'uppercase', letterSpacing:0.5, color:'#bbb', marginBottom:8}}>Merkhilfe</div>
{card.mnemonics.map((m,i)=>(
<div key={i} style={{fontSize:13, color:'#444', lineHeight:1.55, marginBottom:8}}>
<span style={{fontSize:10, fontWeight:700, color:CASE_C[card.case], textTransform:'uppercase', letterSpacing:0.5, marginRight:6}}>{m.label}</span>
{m.text}
</div>
))}
</div>
)}
{card.note && (
<div style={{borderTop:'1px solid #f0f0f0', paddingTop:14, fontSize:12, color:'#888', lineHeight:1.55}}>
{card.note}
</div>
)}
{card.audio.length > 0 && (
<div style={{marginTop:8, display:'flex', flexDirection:'column', gap:10}}>
{card.audio.map(a=>(
<div key={a.src}>
<div style={{fontSize:11, color:'#999', marginBottom:4}}>{a.label}</div>
<audio controls preload="none" src={a.src} style={{width:'100%', height:36}}/>
</div>
))}
</div>
)}
</div>
<div style={{display:'flex', gap:10, justifyContent:'center', marginTop:18}}>
{learnStep > 0 && (
<button style={{...S.nextBtn, background:'#fff', color:'#666', border:'1px solid #ddd'}}
onClick={()=>setLearnStep(s=>s-1)}> Zurück</button>
)}
{!isLast
? <button style={S.nextBtn} onClick={()=>setLearnStep(s=>s+1)}>Weiter </button>
: <button style={S.nextBtn} onClick={()=>setScr('menu')}>Fertig </button>}
</div>
</div>
);
})()}
{/* PRACTICE: sentence-based (article, poss, pronoun) */}
{scr === 'practice' && (q?.type === 'article' || q?.type === 'chunk' || q?.type === 'poss' || q?.type === 'pronoun') && (
<div style={{textAlign:'center', paddingTop:10}}>
<div style={{...S.sentence, animation:shake?'shake 0.3s':'none'}}>
{q.sentence.split('_____').map((part,i,a)=>(
<span key={i}>
{part}
{i < a.length-1 && (
<span style={{...S.blank,
borderBottomColor: rev?(ok?GREEN:RED):'#bbb',
color: rev?(ok?GREEN:RED):'#1a1a1a',
minWidth: q.type==='poss' ? 110 : 80,
}}>
{rev ? q.ans : (inp || '\u00A0\u00A0\u00A0\u00A0\u00A0')}
</span>
)}
</span>
))}
</div>
<div style={S.translation}>{q.translation}</div>
<div style={S.keyInfo}>
{(q.type === 'article' || q.type === 'chunk') && <>
<span style={{color: GENDER_C[q.noun.g], fontWeight:700}}>{DEF[q.noun.g].nominative}</span>
{' '}{q.noun.w}
<span style={S.keyInfoMute}> ({q.noun.en})</span>
</>}
{q.type === 'poss' && <>
<span style={{fontWeight:700}}>{q.stem}-</span>
<span style={S.keyInfoMute}> ({q.possMeaning})</span>
</>}
{q.type === 'pronoun' && <>
<span style={{fontWeight:700}}>{q.pron.nom}</span>
<span style={S.keyInfoMute}> ({q.pron.en})</span>
<span style={S.keyInfoMute}> · {q.pron.tag}</span>
</>}
</div>
<div style={S.secondaryContext}>
<span style={{color: CASE_C[q.cas], fontWeight:600}}>{CL[q.cas]}</span>
{q.prepKey && <> · {q.prepKey} ({PREPS[q.prepKey].en})</>}
{q.tw !== null && q.tw !== undefined && <> · {q.tw ? '→ motion' : '● location'}</>}
{q.type === 'poss' && <> · <span style={{color:GENDER_C[q.noun.g]}}>{DEF[q.noun.g].nominative}</span> {q.noun.w}</>}
</div>
{!rev ? (
(q.type === 'article' || q.type === 'chunk') ? (
<div style={S.articleGrid}>
{q.choices.map((choice, i) => (
<button key={choice} style={S.articleBtn} onClick={()=>doSubmit(choice)}>
<div style={{fontSize: choice.includes(' ') ? 17 : 22, fontWeight:700}}>{choice}</div>
<div style={{fontSize:11,color:'#999',marginTop:4}}><kbd style={S.kbdSmall}>{i+1}</kbd></div>
</button>
))}
</div>
) : (
<div style={S.inputWrap}>
<div style={S.inputRow}>
<input ref={iref} value={inp} onChange={e=>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"/>
<button style={S.checkBtn} onClick={()=>doSubmit()}>Check</button>
</div>
<div style={S.hintText}>or press <kbd style={S.kbd}>Enter</kbd></div>
</div>
)
) : (
q.type === 'pronoun'
? <PronounFeedback q={q} inp={inp} ok={ok}/>
: q.type === 'chunk'
? <ChunkFeedback q={q} inp={inp} ok={ok}/>
: <FillFeedback q={q} inp={inp} ok={ok}/>
)}
</div>
)}
{/* PRACTICE: preposition (which case?) */}
{scr === 'practice' && q?.type === 'prep' && (
<div style={{textAlign:'center', paddingTop:36}}>
<div style={{fontSize:13,color:'#999',marginBottom:8}}>Which case does this preposition take?</div>
<div style={{fontSize:48, fontWeight:700, color:'#1a1a1a', margin:'8px 0 4px'}}>{q.prepKey}</div>
<div style={{fontSize:15, color:'#999', marginBottom:28}}>{q.prepEn}</div>
{!rev ? (
<div style={{display:'flex',gap:8,flexWrap:'wrap',justifyContent:'center',maxWidth:420,margin:'0 auto'}}>
{[
['accusative','Akkusativ','1'],['dative','Dativ','2'],
['genitive','Genitiv','3'],['wechsel','Wechsel','4'],
].map(([val,lab,key]) => (
<button key={val} style={{...S.choiceBtn, borderColor: CASE_C[val]+'40'}}
onClick={()=>doSubmit(val)}>
<div style={{fontWeight:700, color: CASE_C[val]}}>{lab}</div>
<div style={{fontSize:11,color:'#999',marginTop:2}}><kbd style={S.kbdSmall}>{key}</kbd></div>
</button>
))}
</div>
) : (
<PrepFeedback q={q} inp={inp} ok={ok}/>
)}
</div>
)}
{/* PRACTICE: wechsel in context */}
{scr === 'practice' && q?.type === 'wechselContext' && (
<div style={{textAlign:'center', paddingTop:18}}>
<div style={{...S.sentence, animation:shake?'shake 0.3s':'none'}}>{q.sentence}</div>
<div style={S.translation}>{q.translation}</div>
<div style={S.keyInfo}>
<span style={{fontWeight:700}}>{q.prepKey}</span>
<span style={S.keyInfoMute}> ({PREPS[q.prepKey].en})</span>
</div>
<div style={S.secondaryContext}>Wechselpräposition · motion or location?</div>
{!rev ? (
<div style={{display:'flex',gap:10,justifyContent:'center',maxWidth:340,margin:'8px auto 0'}}>
{[['accusative','Akkusativ','1','motion'],['dative','Dativ','2','location']].map(([val,lab,key,hint]) => (
<button key={val} style={{...S.choiceBtn, borderColor: CASE_C[val]+'40', minWidth:130}}
onClick={()=>doSubmit(val)}>
<div style={{fontWeight:700, color: CASE_C[val]}}>{lab}</div>
<div style={{fontSize:11,color:'#999',marginTop:2}}>{hint} · <kbd style={S.kbdSmall}>{key}</kbd></div>
</button>
))}
</div>
) : (
<WechselContextFeedback q={q} inp={inp} ok={ok}/>
)}
</div>
)}
{/* PRACTICE: contractions */}
{scr === 'practice' && q?.type === 'contraction' && (
<div style={{textAlign:'center', paddingTop:30}}>
<div style={{fontSize:13,color:'#999',marginBottom:12}}>Contract the preposition and article</div>
<div style={{fontSize:34, fontWeight:700, color:'#1a1a1a', margin:'4px 0 6px'}}>
{q.prep} <span style={{color:'#ccc'}}>+</span> {q.art} <span style={{color:'#ccc'}}>=</span> <span style={{color:'#ccc'}}>?</span>
</div>
<div style={{fontSize:12, color:'#aaa', marginBottom:22}}>
<span style={{color:CASE_C[q.cas], fontWeight:600}}>{CL[q.cas]}</span>
</div>
{!rev ? (
<div style={S.inputWrap}>
<div style={S.inputRow}>
<input ref={iref} value={inp} onChange={e=>setInp(e.target.value)}
style={S.input} placeholder="contraction…"
autoComplete="off" autoCapitalize="off" spellCheck={false}
enterKeyHint="go" inputMode="text"/>
<button style={S.checkBtn} onClick={()=>doSubmit()}>Check</button>
</div>
<div style={S.hintText}>or press <kbd style={S.kbd}>Enter</kbd></div>
</div>
) : (
<ContractionFeedback q={q} inp={inp} ok={ok}/>
)}
</div>
)}
{scr === 'practice' && (
<div style={S.dotsRow}>
{Array.from({length:ROUND},(_,i)=>(
<div key={i} style={{...S.dot,
background: i<res.length?(res[i]?GREEN:RED):i===res.length?'#333':'#ddd',
}}/>
))}
<span style={S.dotsLabel}>{Math.min(res.length+(rev?0:1), ROUND)}/{ROUND} · {cur?.name}</span>
</div>
)}
{scr === 'practice' && rev && (
<div style={{textAlign:'center', marginTop:14}}>
<button style={S.nextBtn} onClick={doNext}>
{res.length >= ROUND ? 'See results' : 'Next'} <span style={{opacity:0.5,fontSize:12,marginLeft:4}}></span>
</button>
</div>
)}
{/* SUMMARY */}
{scr === 'summary' && (
<div style={{textAlign:'center', paddingTop:60}}>
<div style={{fontSize:12,color:'#999',textTransform:'uppercase',letterSpacing:2,fontWeight:600}}>Complete</div>
<div style={{fontSize:56, fontWeight:700, color:'#1a1a1a', margin:'10px 0'}}>
{rc}<span style={{fontSize:24, color:'#bbb'}}>/{ROUND}</span>
</div>
<div style={{...S.dotsRow, position:'static', marginTop:14}}>
{res.map((r,i)=><div key={i} style={{...S.dot,width:11,height:11,borderRadius:6,background:r?GREEN:RED}}/>)}
</div>
<div style={{color:rc>=7?GREEN:'#d97706', fontSize:15, fontWeight:600, margin:'24px 0'}}>
{rc>=9?'Ausgezeichnet!':rc>=7?'Gut gemacht!':rc>=5?'Keep practicing.':'Review the charts.'}
</div>
{rc>=7 && nextInTab && unlocked(nextInTab.id) && (
<div style={{color:GREEN, fontSize:13, marginBottom:16}}>{nextInTab.name} unlocked.</div>
)}
<button style={S.nextBtn} onClick={()=>startLv(lv)}>Play again</button>
{rc>=7 && nextInTab && unlocked(nextInTab.id) && (
<button style={{...S.nextBtn, background:'#fff', color:GREEN, border:`1.5px solid ${GREEN}`, marginTop:10, marginLeft:8}}
onClick={()=>startLv(nextInTab.id)}>
Next: {nextInTab.name}
</button>
)}
<div style={{marginTop:16}}>
<button style={S.linkBtn} onClick={()=>setScr('menu')}>back to menu</button>
</div>
</div>
)}
<FeatureRequestFooter />
</div>
{chart && <ChartModal section={chartSection} q={q} onClose={()=>setChart(false)}/>}
<style>{`
@keyframes shake{0%,100%{transform:translateX(0)}25%{transform:translateX(-6px)}75%{transform:translateX(6px)}}
@keyframes fadeUp{from{opacity:0;transform:translateY(4px)}to{opacity:1;transform:translateY(0)}}
input::placeholder{color:#bbb}
*{box-sizing:border-box}
html,body{margin:0;background:#f7f7f7}
#root{min-height:100%}
input{scroll-margin-top:60px;scroll-margin-bottom:80px}
`}</style>
</div>
);
}
function HomeScreen({ onStart }) {
return (
<div style={{paddingTop:18}}>
<section style={S.hero}>
<div style={S.heroMark}>der · den · dem</div>
<h1 style={S.heroTitle}>Learn German cases by feel.</h1>
<p style={S.heroCopy}>
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.
</p>
<div style={S.heroActions}>
<button style={S.nextBtn} onClick={onStart}>Get started</button>
</div>
</section>
<CoreReference />
</div>
);
}
function FeatureRequestFooter() {
return (
<footer style={S.footerContact}>
<div>Have a feature request? Get in touch. Always looking to improve the tool.</div>
<a
style={S.footerMail}
href={`mailto:?subject=${encodeURIComponent('Beug feature request')}&body=${encodeURIComponent('Hey, I have a feature request for Beug:\n\n')}`}
>
Write an email
</a>
</footer>
);
}
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 (
<div style={{marginTop:20}}>
<div style={S.sectionTitle}>Case map</div>
<div style={S.coreGrid}>
<div style={S.corePanel}>
<div style={S.chartH}>Prepositions</div>
{prepositionGroups.map(([key, label, words]) => (
<div key={key} style={{fontSize:12, color:'#555', lineHeight:1.65, marginTop:8}}>
<strong style={{color:CASE_C[key]}}>{label}:</strong> {words.join(', ')}
</div>
))}
</div>
<div style={S.corePanel}>
<div style={S.chartH}>Definite Articles</div>
<table style={S.miniChart}>
<thead>
<tr>
<th style={S.miniTh}></th>
{genders.map(g => <th key={g} style={{...S.miniTh, color:GENDER_C[g]}}>{g.slice(0,4)}</th>)}
</tr>
</thead>
<tbody>
{CORE_ARTICLE_CASES.map(c => (
<tr key={c}>
<td style={{...S.miniTd, textAlign:'left', color:CASE_C[c], fontWeight:700}}>{CL[c]}</td>
{genders.map(g => <td key={g} style={S.miniTd}>{DEF[g][c]}</td>)}
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
);
}
function PaywallScreen({ message, onCheckout, onBack }) {
return (
<div style={{paddingTop:18}}>
<div style={S.paywallCard}>
<div style={S.heroMark}>Beug Premium</div>
<h2 style={S.payTitle}>Take the whole thing.</h2>
<p style={S.payCopy}>
Possessives, pronouns, contractions, genitive articles, and mixed rounds. Built for people who want the case system handled, fast.
</p>
<div style={S.benefits}>
{['Possessives and pronouns', 'Mixed article rounds', 'Contractions and genitive', 'Saved progress on this device'].map(item => (
<div key={item} style={S.benefit}> {item}</div>
))}
</div>
<div style={S.planGrid}>
{STRIPE_PLANS.map(plan => (
<button key={plan.id} style={{...S.planCard, ...(plan.id === 'yearly' ? S.planFeatured : {})}} onClick={()=>onCheckout(plan)}>
<span style={S.planNote}>{plan.note}</span>
<span style={S.planName}>{plan.name}</span>
<span style={S.planPrice}>{plan.price}</span>
<span style={S.planCadence}>{plan.cadence}</span>
</button>
))}
</div>
{message && <div style={S.payMsg}>{message}</div>}
<div style={S.payFine}>No account required for v1. Stripe redirects back here and unlocks this browser.</div>
<button style={{...S.linkBtn, marginTop:16}} onClick={onBack}>Maybe later</button>
</div>
</div>
);
}
// ═══════════════════════════════════════════
// 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 (
<div style={{...S.feedback, animation:'fadeUp 0.2s ease-out'}}>
<div style={{fontSize:15, fontWeight:600, marginBottom:6, color:ok?GREEN:'#1a1a1a'}}>
{ok ? '✓ Correct' : <> <span style={{textDecoration:'line-through', color:'#999', fontWeight:400}}>{inp}</span> <span style={{color:'#999', fontWeight:400}}></span> <span style={{color:GREEN}}>{q.ans}</span></>}
</div>
<div style={{fontSize:13, color:'#666', marginBottom:!ok?4:10, lineHeight:1.5}}>{q.rule}</div>
{!ok && (
<div style={{fontSize:12, color:'#999', marginBottom:10, lineHeight:1.5}}>
{q.noun.w} is <span style={{color:GENDER_C[q.noun.g]}}>{q.noun.g}</span>. <span style={{color:CASE_C[q.cas]}}>{CL[q.cas]}</span> + {q.noun.g} = <strong style={{color:'#1a1a1a'}}>{q.ans}</strong>
</div>
)}
<table style={S.stripTbl}>
<thead><tr>
<th style={{...S.stripH, textAlign:'left', fontWeight:600, color: CASE_C[q.cas]}}>{CL[q.cas]}</th>
{genders.map(g => <th key={g} style={{...S.stripH, color:GENDER_C[g]}}>{g.slice(0,4)}.</th>)}
</tr></thead>
<tbody><tr>
<td style={{...S.stripD, textAlign:'left', color:'#aaa', fontSize:11}}>{stripLabel}</td>
{genders.map((g, i) => {
const hl = g === q.noun.g;
return <td key={g} style={{
...S.stripD, fontWeight: hl ? 700 : 400,
color: hl ? '#1a1a1a' : '#bbb',
background: hl ? '#f3f3f3' : 'transparent',
borderRadius: 4, fontSize: hl ? 15 : 13,
}}>{stripCells[i]}</td>;
})}
</tr></tbody>
</table>
</div>
);
}
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 (
<div style={{...S.feedback, animation:'fadeUp 0.2s ease-out', maxWidth:420}}>
<div style={{fontSize:15, fontWeight:600, marginBottom:6, color:ok?GREEN:'#1a1a1a'}}>
{ok ? <> <span style={{color:CASE_C[q.ans]}}>{CL[q.ans]}</span></> : <> <span style={{textDecoration:'line-through',color:'#999',fontWeight:400}}>{CL[inp]||inp}</span> <span style={{color:'#999',fontWeight:400}}></span> <span style={{color:CASE_C[q.ans]}}>{CL[q.ans]}</span></>}
</div>
<div style={{fontSize:13, color:'#666', marginBottom:8, lineHeight:1.5}}>
{q.prepKey}" → {q.ans === 'wechsel' ? 'Wechselpräposition (Akkusativ for motion, Dativ for location)' : `always ${CL[q.ans]}`}
</div>
{q.ans !== 'wechsel' && (
<div style={{fontSize:13, color:'#999', fontStyle:'italic', marginTop:4}}>
Example: {q.example} <span style={{color:'#bbb'}}>({q.prepEn} the {q.exampleEn})</span>
</div>
)}
{q.ans === 'wechsel' && (
<div style={{fontSize:12, color:'#999', marginTop:4, lineHeight:1.7}}>
<div>Motion: <em>Er geht <strong>{motEx.de}</strong></em> (<span style={{color:CASE_C.accusative}}>Akk</span>) — "{motEx.en}"</div>
<div>Location: <em>Er ist <strong>{locEx.de}</strong></em> (<span style={{color:CASE_C.dative}}>Dat</span>) — "{locEx.en}"</div>
</div>
)}
</div>
);
}
function WechselContextFeedback({ q, inp, ok }) {
return (
<div style={{...S.feedback, animation:'fadeUp 0.2s ease-out', maxWidth:420}}>
<div style={{fontSize:15, fontWeight:600, marginBottom:6, color:ok?GREEN:'#1a1a1a'}}>
{ok ? <>✓ <span style={{color:CASE_C[q.ans]}}>{CL[q.ans]}</span></> : <>✗ <span style={{textDecoration:'line-through',color:'#999',fontWeight:400}}>{CL[inp]||inp}</span> <span style={{color:'#999',fontWeight:400}}>→</span> <span style={{color:CASE_C[q.ans]}}>{CL[q.ans]}</span></>}
</div>
<div style={{fontSize:13, color:'#666', marginBottom:6, lineHeight:1.5}}>
„{q.verb}" {q.isMotion ? 'implies motion (wohin?)' : 'implies location (wo?)'} <span style={{color:CASE_C[q.ans], fontWeight:600}}>{CL[q.ans]}</span>
</div>
<div style={{fontSize:12, color:'#999', marginTop:4, lineHeight:1.6}}>
Wechselpräpositionen take <span style={{color:CASE_C.accusative}}>Akkusativ</span> with motion verbs (gehen, fahren, laufen, kommen) and <span style={{color:CASE_C.dative}}>Dativ</span> with location verbs (sein, stehen, sitzen, warten).
</div>
</div>
);
}
function ChunkFeedback({ q, inp, ok }) {
const article = DEF[q.noun.g][q.cas];
return (
<div style={{...S.feedback, animation:'fadeUp 0.2s ease-out', maxWidth:420}}>
<div style={{fontSize:15, fontWeight:600, marginBottom:6, color:ok?GREEN:'#1a1a1a'}}>
{ok
? <> <span style={{color:GREEN}}>{q.ans}</span></>
: <> <span style={{textDecoration:'line-through', color:'#999', fontWeight:400}}>{inp}</span> <span style={{color:'#999', fontWeight:400}}></span> <span style={{color:GREEN}}>{q.ans}</span></>}
</div>
<div style={{fontSize:13, color:'#666', marginBottom:6, lineHeight:1.5}}>
{q.contracts
? <><strong>{q.prepKey}</strong> + <strong>{article}</strong> = <strong style={{color:'#1a1a1a'}}>{q.ans}</strong></>
: <>{q.prepKey}" + <span style={{color:CASE_C[q.cas]}}>{CL[q.cas]}</span> → <strong style={{color:'#1a1a1a'}}>{q.ans}</strong></>}
</div>
<div style={{fontSize:12, lineHeight:1.5, color: q.contracts ? GREEN : '#999'}}>
{q.contracts
? 'Preferred — these fuse, so use the contraction in speech and writing.'
: <><strong>{article}</strong> doesnt fuse with „{q.prepKey}" keep them as two words.</>}
</div>
</div>
);
}
function ContractionFeedback({ q, inp, ok }) {
return (
<div style={{...S.feedback, animation:'fadeUp 0.2s ease-out', maxWidth:420}}>
<div style={{fontSize:15, fontWeight:600, marginBottom:8, color:ok?GREEN:'#1a1a1a'}}>
{ok
? <> {q.prep} + {q.art} = <span style={{color:GREEN}}>{q.ans}</span></>
: <> <span style={{textDecoration:'line-through', color:'#999', fontWeight:400}}>{inp}</span> <span style={{color:'#999', fontWeight:400}}></span> <span style={{color:GREEN}}>{q.ans}</span></>}
</div>
<div style={{fontSize:13, color:'#666', marginBottom:6, lineHeight:1.5}}>
{q.prep} + {q.art} = <strong style={{color:'#1a1a1a'}}>{q.ans}</strong>
</div>
<div style={{fontSize:12, color: q.pref ? GREEN : '#d97706', lineHeight:1.5}}>
{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.'}
</div>
</div>
);
}
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 (
<div style={{...S.feedback, animation:'fadeUp 0.2s ease-out', maxWidth:420}}>
<div style={{fontSize:15, fontWeight:600, marginBottom:8, color:ok?GREEN:'#1a1a1a'}}>
{ok ? '✓ Correct' : <> <span style={{textDecoration:'line-through', color:'#999', fontWeight:400}}>{inp}</span> <span style={{color:'#999', fontWeight:400}}></span> <span style={{color:GREEN}}>{q.ans}</span></>}
</div>
<div style={{fontSize:12, color:'#999', marginBottom:10, lineHeight:1.5}}>
{q.pron.en} ({q.pron.tag}) <span style={{color: CASE_C[q.cas]}}>{CL[q.cas]}</span> form
</div>
<table style={S.stripTbl}>
<thead><tr>
<th style={{...S.stripH, textAlign:'left', fontWeight:600}}>{q.pron.nom}</th>
{cases.map(c => <th key={c} style={{...S.stripH, color: CASE_C[c]}}>{labels[c]}</th>)}
</tr></thead>
<tbody><tr>
<td style={{...S.stripD, textAlign:'left', color:'#aaa', fontSize:11}}>{q.pron.en}</td>
{cases.map(c => {
const hl = c === q.cas;
return <td key={c} style={{
...S.stripD, fontWeight: hl ? 700 : 400,
color: hl ? '#1a1a1a' : '#bbb',
background: hl ? '#f3f3f3' : 'transparent',
borderRadius: 4, fontSize: hl ? 15 : 13,
}}>{q.pron[keys[c]]}</td>;
})}
</tr></tbody>
</table>
</div>
);
}
// ═══════════════════════════════════════════
// CHART MODAL
// ═══════════════════════════════════════════
function ChartModal({ section, q, onClose }) {
const genders = ['masculine','feminine','neuter','plural'];
const cases = ['nominative','accusative','dative','genitive'];
return (
<div style={S.overlay} onClick={onClose}>
<div style={S.modal} onClick={e=>e.stopPropagation()}>
<div style={{display:'flex',justifyContent:'space-between',alignItems:'center',marginBottom:14}}>
<div style={{fontSize:17,fontWeight:700}}>
{section==='article'?'Articles':section==='prep'?'Prepositions':section==='poss'?'Possessives':section==='pronoun'?'Personal Pronouns':'Charts'}
</div>
<button onClick={onClose} style={{background:'none',border:'none',fontSize:18,color:'#999',cursor:'pointer'}}></button>
</div>
{section === 'article' && <>
<div style={{marginBottom:22}}>
<div style={S.chartH}>All-in-One Declension Chart</div>
<div style={{fontSize:11, color:'#999', marginBottom:8}}>Strong (S): no article carries the case. Weak (W): after a definite article.</div>
<table style={S.fullChart}>
<thead>
<tr>
<th rowSpan={2} style={{...S.fcTh, textAlign:'left'}}></th>
{genders.map(g => <th key={g} colSpan={2} style={{...S.fcTh, color: GENDER_C[g], borderBottom:'1px solid #eee'}}>{g}</th>)}
</tr>
<tr>
{genders.map(g => (
<Frag2 key={g}>
<th style={{...S.fcTh, fontSize:10, color:'#aaa', fontWeight:600}}>S</th>
<th style={{...S.fcTh, fontSize:10, color:'#aaa', fontWeight:600}}>W</th>
</Frag2>
))}
</tr>
</thead>
<tbody>
{cases.map(c => (
<tr key={c}>
<td style={{...S.fcTd, textAlign:'left', fontWeight:700, fontSize:11, color:CASE_C[c], textTransform:'uppercase', letterSpacing:0.3}}>{CL[c]}</td>
{genders.map(g => {
const hl = q && (q.type==='article'||q.type==='poss') && c === q.cas && g === q.noun?.g;
return (
<Frag2 key={g}>
<td style={{...S.fcTd, fontWeight: hl?700:400, color: hl?GREEN:'#444', background: hl?GREEN+'15':'transparent', borderRadius: 3}}>{STRONG[g][c]}</td>
<td style={{...S.fcTd, fontWeight: hl?700:400, color: hl?GREEN:'#999', background: hl?GREEN+'15':'transparent', borderRadius: 3}}>{WEAK[g][c]}</td>
</Frag2>
);
})}
</tr>
))}
</tbody>
</table>
</div>
<div style={{marginBottom:22}}>
<div style={S.chartH}>Unbestimmter Artikel (ein/eine/kein)</div>
<SimpleChart table={INDEF} highlightCase={q?.cas} highlightGender={q?.noun?.g} active={q?.type==='article' && q?.artType==='indefinite'}/>
</div>
<div style={{marginBottom:18}}>
<div style={S.chartH}>Bestimmter Artikel (der/die/das)</div>
<SimpleChart table={DEF} highlightCase={q?.cas} highlightGender={q?.noun?.g} active={q?.type==='article' && q?.artType==='definite'}/>
</div>
</>}
{section === 'prep' && (
<div style={{marginBottom:18}}>
<div style={S.chartH}>Prepositions by Case</div>
<div style={{fontSize:13, lineHeight:2.2, color:'#444'}}>
<div><strong style={{color:CASE_C.accusative}}>Akkusativ:</strong> durch, für, gegen, ohne, um, bis</div>
<div><strong style={{color:CASE_C.dative}}>Dativ:</strong> aus, außer, bei, gegenüber, mit, nach, seit, von, zu</div>
<div><strong style={{color:CASE_C.genitive}}>Genitiv:</strong> trotz, während, wegen, statt, anstatt, außerhalb, innerhalb</div>
<div><strong style={{color:CASE_C.wechsel}}>Wechsel:</strong> {PREPS_BY_CASE.wechsel.join(', ')}</div>
</div>
<div style={{fontSize:12, color:'#999', marginTop:14, lineHeight:1.6}}>
<strong>Wechsel</strong>: accusative for motion (wohin?), dative for location (wo?).
</div>
</div>
)}
{section === 'poss' && (
<div style={{marginBottom:18}}>
<div style={S.chartH}>Possessive Determiners</div>
<div style={{fontSize:12, color:'#888', marginBottom:10}}>
{POSSESSIVES.map(p => <div key={p.stem} style={{marginBottom:2}}><strong style={{color:'#444'}}>{p.stem}-</strong> <span style={{color:'#999'}}>{p.en}</span></div>)}
</div>
<div style={{fontSize:11, color:'#999', marginBottom:10}}>Endings follow the ein/kein pattern. <em>euer</em> drops to <em>eur-</em> when an ending is added.</div>
<PossessiveChart highlightCase={q?.cas} highlightGender={q?.noun?.g} active={q?.type==='poss'} highlightStem={q?.stem}/>
</div>
)}
{section === 'pronoun' && (
<div style={{marginBottom:18}}>
<div style={S.chartH}>Personal Pronouns</div>
<PronounsChartCombined highlightCase={q?.cas} highlightKey={q?.pronKey} active={q?.type==='pronoun'}/>
</div>
)}
<button style={{...S.nextBtn, width:'100%', marginTop:10}} onClick={onClose}>
Close <span style={{opacity:0.5,fontSize:12,marginLeft:4}}>esc</span>
</button>
</div>
</div>
);
}
function Frag2({ children }) { return <>{children}</>; }
function SimpleChart({ table, highlightCase, highlightGender, active }) {
const genders = ['masculine','feminine','neuter','plural'];
const cases = ['nominative','accusative','dative','genitive'];
return (
<table style={S.fullChart}>
<thead><tr><th style={S.fcTh}></th>{genders.map(g => <th key={g} style={{...S.fcTh, color:GENDER_C[g]}}>{g}</th>)}</tr></thead>
<tbody>
{cases.map(c => (
<tr key={c}>
<td style={{...S.fcTd, textAlign:'left', fontWeight:700, fontSize:11, color:CASE_C[c], textTransform:'uppercase', letterSpacing:0.3}}>{CL[c]}</td>
{genders.map(g => {
const hl = active && c === highlightCase && g === highlightGender;
return <td key={g} style={{...S.fcTd, fontSize:14, fontWeight: hl?700:400, color: hl?GREEN:'#555', background: hl?GREEN+'15':'transparent', borderRadius: 3}}>{table[g][c]}</td>;
})}
</tr>
))}
</tbody>
</table>
);
}
function PossessiveChart({ highlightCase, highlightGender, active, highlightStem }) {
const genders = ['masculine','feminine','neuter','plural'];
const cases = ['nominative','accusative','dative','genitive'];
const stem = highlightStem || 'mein';
return (
<div>
<table style={S.fullChart}>
<thead><tr>
<th style={S.fcTh}></th>
{genders.map(g => <th key={g} style={{...S.fcTh, color:GENDER_C[g]}}>{g}</th>)}
</tr></thead>
<tbody>
{cases.map(c => (
<tr key={c}>
<td style={{...S.fcTd, textAlign:'left', fontWeight:700, fontSize:11, color:CASE_C[c], textTransform:'uppercase', letterSpacing:0.3}}>{CL[c]}</td>
{genders.map(g => {
const hl = active && c === highlightCase && g === highlightGender;
const display = combinePoss(stem, POSS_ENDINGS[g][c]);
return <td key={g} style={{...S.fcTd, fontSize:14, fontWeight: hl?700:400, color: hl?GREEN:'#555', background: hl?GREEN+'15':'transparent', borderRadius: 3}}>{display}</td>;
})}
</tr>
))}
</tbody>
</table>
<div style={{fontSize:11, color:'#aaa', marginTop:6}}>Showing forms for <strong style={{color:'#666'}}>{stem}-</strong>. Same endings apply to all possessive bases.</div>
</div>
);
}
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 (
<table style={S.fullChart}>
<thead><tr>
<th style={{...S.fcTh, textAlign:'left'}}>english</th>
<th style={{...S.fcTh, textAlign:'left', color:'#aaa'}}>person</th>
{cases.map(c => <th key={c} style={{...S.fcTh, color: CASE_C[c]}}>{labels[c]}</th>)}
</tr></thead>
<tbody>
{PRONOUNS.map((p, idx) => {
const sep = idx === 6;
return (
<tr key={p.key} style={{borderTop: sep ? '2px solid #ddd' : 'none'}}>
<td style={{...S.fcTd, textAlign:'left', fontSize:12, color:'#888'}}>{p.en}</td>
<td style={{...S.fcTd, textAlign:'left', fontSize:11, color:'#bbb'}}>{p.tag}</td>
{cases.map(c => {
const hl = active && c === highlightCase && p.key === highlightKey;
return <td key={c} style={{...S.fcTd, fontSize:14, fontWeight: hl?700:400, color: hl?GREEN:'#555', background: hl?GREEN+'15':'transparent', borderRadius: 3}}>{p[keys[c]]}</td>;
})}
</tr>
);
})}
</tbody>
</table>
);
}
// ═══════════════════════════════════════════
// 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' },
};