Files
Beug/src/App.jsx
T
george f567555876 Feedback: show the full declension table, not one row
Every answer card now renders the complete 4×4 declension grid instead
of a single case row. The correct cell is highlighted green; on a wrong
answer, the cell matching what you typed is highlighted red, so you see
both your pick and the right one in context.

- FillFeedback (articles + possessives): full table, correct + wrong
  highlighting within the noun's gender column.
- ChunkFeedback + ContractionFeedback: contraction questions now show
  the definite-article table too, not just the fused answer.
- PronounFeedback: correct cell green, your wrong pick red.
2026-07-17 07:42:56 +00:00

1388 lines
66 KiB
React
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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";
import {
DEF, INDEF, STRONG, WEAK, PREPS, PREPS_BY_CASE, CL, CASE_C, GENDER_C,
PRONOUNS, POSSESSIVES, POSS_ENDINGS, combinePoss, LEVELS,
wechselExample, createSession, bumpStreak, currentStreak, weekDots,
} from './engine.js';
// ═══════════════════════════════════════════
// STORAGE (window.storage in Claude, localStorage when deployed)
// ═══════════════════════════════════════════
const SKEY = 'gd-v10';
const ACCESS_KEY = 'beug-access-v1';
const Storage = {
async get() {
try {
if (typeof window !== 'undefined' && window.storage?.get) {
const r = await window.storage.get(SKEY);
if (r?.value) return JSON.parse(r.value);
}
} catch (e) {}
try {
const v = localStorage.getItem(SKEY);
if (v) return JSON.parse(v);
} catch (e) {}
return null;
},
async set(state) {
const json = JSON.stringify(state);
try {
if (typeof window !== 'undefined' && window.storage?.set) {
await window.storage.set(SKEY, json);
return;
}
} catch (e) {}
try { localStorage.setItem(SKEY, json); } catch (e) {}
}
};
const Access = {
get() {
try {
const v = localStorage.getItem(ACCESS_KEY);
return v ? JSON.parse(v) : null;
} catch (e) {
return null;
}
},
set(plan = 'paid') {
const value = { paid: true, plan, unlockedAt: new Date().toISOString() };
try { localStorage.setItem(ACCESS_KEY, JSON.stringify(value)); } catch (e) {}
return value;
}
};
const TABS = [
{id:'prep', label:'Prepositions'},
{id:'article', label:'Articles'},
{id:'poss', label:'Possessives'},
{id:'pronoun', label:'Pronouns'},
];
// Daily Practice pseudo-level: pinned to the top of the Prepositions tab once
// the "Alle Präpositionen" lesson (id 10) is completed. Always free.
const DAILY_ID = 10.5;
const FREE_LEVEL_IDS = new Set([2, 3, 4, 8, 9, 10, 10.5, 18]);
const CORE_ARTICLE_CASES = ['nominative', 'accusative', 'dative'];
const PAYMENTS_ENABLED = import.meta.env.VITE_PAYMENTS_ENABLED === 'true';
const STRIPE_PLANS = PAYMENTS_ENABLED ? [
{
id:'weekly',
name:'Weekly',
price:'$2.99',
cadence:'per week',
note:'Start here',
url: import.meta.env.VITE_STRIPE_WEEKLY_URL || '',
env:'VITE_STRIPE_WEEKLY_URL',
},
{
id:'yearly',
name:'Yearly',
price:'$9.99',
cadence:'per year',
note:'Best value',
url: import.meta.env.VITE_STRIPE_YEARLY_URL || '',
env:'VITE_STRIPE_YEARLY_URL',
},
{
id:'lifetime',
name:'Lifetime',
price:'$19.99',
cadence:'one time',
note:'Pay once',
url: import.meta.env.VITE_STRIPE_LIFETIME_URL || '',
env:'VITE_STRIPE_LIFETIME_URL',
},
] : [];
// Audio served from /public/audio
const AUDIO = {
dativSung: '/audio/dativ-blue-danube-sung.m4a',
blueDanube: '/audio/blue-danube.mp3',
odeToJoy: '/audio/ode-to-joy.mp3',
};
// Chunked study cards for the Prepositions "Lernen" section.
// Mnemonics reproduced from the U-Michigan German Resources page.
const LEARN_CARDS = [
{
case:'accusative', label:'Akkusativ', desc:'These prepositions always take the Accusative.',
words:[
{de:'durch', en:'through'}, {de:'für', en:'for'}, {de:'gegen', en:'against'},
{de:'ohne', en:'without'}, {de:'um', en:'around / at'}, {de:'bis', en:'until / up to'},
],
mnemonics:[
{label:'Acronym', text:'“O Fudge” — ohne, für, um, durch, gegen.'},
{label:'Rhyme', text:'“Durch-für-gegen-ohne-um, Deutsch zu lernen ist nicht dumm.”'},
],
audio:[],
},
{
case:'dative', label:'Dativ', desc:'These prepositions always take the Dative.',
words:[
{de:'aus', en:'out of'}, {de:'außer', en:'except for'}, {de:'bei', en:'at / near'},
{de:'mit', en:'with'}, {de:'nach', en:'to / after'}, {de:'seit', en:'since'},
{de:'von', en:'from'}, {de:'zu', en:'to'}, {de:'gegenüber', en:'opposite'},
],
mnemonics:[
{label:'Sing it', text:'Sing “Aus-außer-bei-mit, nach-seit, von-zu” to the tune of the “Blue Danube” waltz.'},
{label:'Rhyme', text:'“Roses are red, violets are blue, aus-außer-bei-mit, nach-seit, von-zu.”'},
],
audio:[
{label:'🎤 Sung to the Blue Danube', src:AUDIO.dativSung},
{label:'🎻 The melody — The Blue Danube', src:AUDIO.blueDanube},
],
},
{
case:'wechsel', label:'Wechselpräpositionen', desc:'Two-way: motion → Akkusativ (wohin?), staying put → Dativ (wo?).',
words:[
{de:'an', en:'at / on'}, {de:'auf', en:'on / onto'}, {de:'hinter', en:'behind'},
{de:'neben', en:'next to'}, {de:'unter', en:'under'}, {de:'über', en:'over / above'},
{de:'in', en:'in / into'}, {de:'vor', en:'in front of'}, {de:'zwischen', en:'between'},
],
mnemonics:[
{label:'Sing it', text:'Sing “An, auf, hin-ter, ne-ben, un-ter/ü-ber, in, vor, zwi-i-schen” to the tune of “An die Freude” (“Ode to Joy”) from 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:[],
},
];
// ═══════════════════════════════════════════
// MAIN APP
// ═══════════════════════════════════════════
const ROUND = 10;
const GREEN = '#58a700';
const RED = '#dc2626';
export default function App() {
const [scr, setScr] = useState('home');
const [tab, setTab] = useState('prep');
const [lv, setLv] = useState(1);
const [q, setQ] = useState(null);
const [inp, setInp] = useState('');
const [rev, setRev] = useState(false);
const [ok, setOk] = useState(false);
const [res, setRes] = useState([]);
const [chart, setChart] = useState(false);
const [learnStep, setLearnStep] = useState(0);
const [st, setSt] = useState({sc:{},tc:0,ta:0,streak:0,ld:null});
const [access, setAccess] = useState(null);
const [payMsg, setPayMsg] = useState('');
const [loaded, setLoaded] = useState(false);
const [shake, setShake] = useState(false);
const iref = useRef(null);
const sessionRef = useRef(null);
useEffect(()=>{(async()=>{
const data = await Storage.get();
// Seed the practice-day history for users from before it was tracked.
if (data?.ld && !data.days) data.days = { [data.ld]: true };
if (data) setSt(data);
let nextAccess = Access.get();
const params = new URLSearchParams(window.location.search);
if (params.get('checkout') === 'success' || params.get('beug_unlock') === '1') {
nextAccess = Access.set(params.get('plan') || 'paid');
window.history.replaceState({}, '', window.location.pathname);
setScr('menu');
}
if (nextAccess?.paid) setAccess(nextAccess);
setLoaded(true);
})();},[]);
const save = useCallback(async s => {
setSt(s);
await Storage.set(s);
},[]);
const hintHidden = k => !!st.hide?.[k];
const toggleHint = k => save({...st, hide:{...(st.hide||{}), [k]: !st.hide?.[k]}});
const hasPaidAccess = !!access?.paid;
const hasAccess = !PAYMENTS_ENABLED || hasPaidAccess;
const gated = id => PAYMENTS_ENABLED && !hasPaidAccess && !FREE_LEVEL_IDS.has(id);
const unlocked = (id) => {
return !gated(id);
};
const nextLevelInTab = (id) => {
const lvObj = LEVELS.find(l => l.id === id);
if (!lvObj) return null;
const tabLevels = LEVELS.filter(l => l.tab === lvObj.tab && !l.daily).sort((a,b) => a.id - b.id);
const idx = tabLevels.findIndex(l => l.id === id);
if (idx === -1 || idx === tabLevels.length - 1) return null;
return tabLevels[idx + 1];
};
const startLv = id => {
if (!unlocked(id)) { setPayMsg(''); setScr('paywall'); return; }
sessionRef.current = createSession(id);
setLv(id); setRes([]); setInp(''); setRev(false); setQ(sessionRef.current.next()); setScr('practice');
};
const openCheckout = plan => {
if (!plan.url) {
setPayMsg(`Stripe link missing: add ${plan.env} in the deployment environment.`);
return;
}
window.location.href = plan.url;
};
const submitRef = useRef(null);
const nextRef = useRef(null);
const doSubmit = useCallback((overrideInput) => {
const userAns = (overrideInput ?? inp).trim();
if (!userAns) { setShake(true); setTimeout(()=>setShake(false),400); return; }
const correct = userAns.toLowerCase() === q.ans.toLowerCase();
if (overrideInput !== undefined) setInp(overrideInput);
// Dismiss mobile keyboard so feedback is visible
try { iref.current?.blur(); } catch(e){}
setOk(correct); setRev(true);
const nr = [...res, correct]; setRes(nr);
let ns = {...st};
ns.ta = (ns.ta||0) + 1;
if (correct) ns.tc = (ns.tc||0) + 1;
if (!ns.sc[lv]) ns.sc[lv] = {c:0,t:0};
ns.sc[lv] = {c: ns.sc[lv].c + (correct?1:0), t: ns.sc[lv].t + 1};
ns = bumpStreak(ns);
save(ns);
}, [inp, q, res, st, lv, save]);
const doNext = useCallback(() => {
if (res.length >= ROUND) { setScr('summary'); return; }
setInp(''); setRev(false); setOk(false);
if (!sessionRef.current) sessionRef.current = createSession(lv);
setQ(sessionRef.current.next());
}, [res, lv]);
submitRef.current = doSubmit;
nextRef.current = doNext;
useEffect(() => {
const h = e => {
if (chart) { if (e.key === 'Escape') setChart(false); return; }
if (scr === 'summary' && e.key === 'Enter') { e.preventDefault(); startLv(lv); return; }
if (scr !== 'practice') return;
if (!rev && (q?.type === 'article' || q?.type === 'chunk') && q.choices) {
const idx = ['1','2','3','4'].indexOf(e.key);
if (idx >= 0 && q.choices[idx]) { e.preventDefault(); submitRef.current(q.choices[idx]); return; }
}
if (!rev && q?.type === 'prep') {
if (e.key === '1') { e.preventDefault(); submitRef.current('accusative'); return; }
if (e.key === '2') { e.preventDefault(); submitRef.current('dative'); return; }
if (e.key === '3') { e.preventDefault(); submitRef.current('genitive'); return; }
if (e.key === '4') { e.preventDefault(); submitRef.current('wechsel'); return; }
}
if (!rev && q?.type === 'wechselContext') {
if (e.key === '1') { e.preventDefault(); submitRef.current('accusative'); return; }
if (e.key === '2') { e.preventDefault(); submitRef.current('dative'); return; }
}
if (e.key === 'Enter') { e.preventDefault(); if (rev) nextRef.current(); else if (q?.type !== 'article' && q?.type !== 'chunk') submitRef.current(); }
if (rev && (e.key === 'ArrowRight' || e.key === ' ')) { e.preventDefault(); nextRef.current(); }
};
window.addEventListener('keydown', h);
return () => window.removeEventListener('keydown', h);
});
// One-tap mode: after an answer is revealed, auto-advance to the next
// question. Always on in Daily Practice; opt-in (st.auto) elsewhere. Correct
// answers flash briefly; wrong answers linger so the correction can register.
useEffect(() => {
if (scr !== 'practice' || !rev) return;
const quick = lv === DAILY_ID || !!st.auto;
if (!quick) return;
const t = setTimeout(() => { nextRef.current?.(); }, ok ? 750 : 1650);
return () => clearTimeout(t);
}, [scr, rev, ok, lv, st.auto]);
if (!loaded) return <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);
const quickActive = lv === DAILY_ID || !!st.auto;
// Daily Practice unlocks once the all-31 lesson (id 10) is passed:
// at least one full round with the app's 70% pass rate.
const prepMastery = st.sc?.[10];
const dailyUnlocked = !!(prepMastery && prepMastery.t >= ROUND && prepMastery.c / prepMastery.t >= 0.7);
const dailySc = st.sc?.[DAILY_ID];
const dailyPct = dailySc ? Math.round(dailySc.c / dailySc.t * 100) : null;
return (
<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, color: quickActive ? GREEN : '#999', fontWeight: quickActive ? 700 : 500}}
title={lv === DAILY_ID ? 'One-tap is always on in Daily Practice' : 'Answer with one tap, then auto-advance'}
onClick={()=>{ if (lv === DAILY_ID) return; save({...st, auto: !st.auto}); }}>
{quickActive ? 'One-tap' : 'One-tap off'}
</button>
)}
{scr === 'practice' && <button style={S.linkBtn} onClick={()=>setChart(true)}>charts</button>}
{PAYMENTS_ENABLED && hasPaidAccess && <span style={S.stat}>Unlocked</span>}
<StreakBadge st={st}/>
</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' && dailyUnlocked && (
<div style={{...S.lvRow, cursor:'pointer', border:`1.5px solid ${GREEN}`, background:'#f3ffe8'}}
onClick={()=>startLv(DAILY_ID)}>
<div style={{flex:1, minWidth:0}}>
<div style={S.lvName}> Daily Practice</div>
<div style={S.lvDesc}>All 31 prepositions · one-tap · keep them sharp</div>
{dailySc && <div style={{fontSize:11,color:'#bbb',marginTop:3}}>{dailySc.c}/{dailySc.t}</div>}
</div>
{dailyPct !== null && <div style={{...S.lvPct, color:dailyPct>=70?GREEN:RED}}>{dailyPct}%</div>}
</div>
)}
{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 && !l.daily).sort((a,b)=>a.id-b.id).map(l => {
const u = unlocked(l.id);
const s = st.sc[l.id];
const p = s ? Math.round(s.c/s.t*100) : null;
return (
<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 || '     ')}
</span>
)}
</span>
))}
</div>
<div style={S.translation}>{q.translation}</div>
{(q.type === 'article' || q.type === 'chunk') ? (
<Hideable hidden={hintHidden('gender')} onToggle={()=>toggleHint('gender')} label="gender" style={S.keyInfo}>
<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>
</Hideable>
) : (
<div style={S.keyInfo}>
{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>
)}
<Hideable hidden={hintHidden('caseInfo')} onToggle={()=>toggleHint('caseInfo')} label="case" 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}</>}
</Hideable>
{!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:'grid',gridTemplateColumns:'1fr 1fr',gap:10,maxWidth:340,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>
{(!hintHidden('meaning') || rev) ? (
<>
<div style={S.translation}>
{q.translation}
{!rev && <button style={{...S.hintToggle, marginLeft:8}} onClick={()=>toggleHint('meaning')}>hide</button>}
</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.translation}>
<button style={S.hintToggle} onClick={()=>toggleHint('meaning')}>show translation</button>
</div>
<div style={S.keyInfo}>
<span style={{fontWeight:700}}>{q.prepKey}</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'],['dative','Dativ','2']].map(([val,lab,key]) => (
<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}}><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>
)}
{scr !== 'practice' && <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>
<div>Have a feature request?</div>
<div>Always looking to improve the tool.</div>
</div>
<a
style={S.footerMail}
href={`mailto:genesis_iodine_03@icloud.com?subject=${encodeURIComponent('Beug feature request')}&body=${encodeURIComponent('Hey, I have a feature request for Beug:\n\n')}`}
>
Get in touch
</a>
</footer>
);
}
// A hint line with a persistent hide/show toggle. Hidden stays hidden
// (across sessions) until the user shows it again — for authentic testing.
function Hideable({ hidden, onToggle, label, style, children }) {
if (hidden) {
return (
<div style={style}>
<button style={S.hintToggle} onClick={onToggle}>show {label}</button>
</div>
);
}
return (
<div style={style}>
{children}
<button style={{...S.hintToggle, marginLeft:8}} onClick={onToggle}>hide</button>
</div>
);
}
function StreakBadge({ st }) {
const dots = weekDots(st);
const n = currentStreak(st);
const practiced = dots.filter(d=>d.practiced).length;
return (
<span style={{display:'flex', alignItems:'center', gap:7}}
title={`Practiced ${practiced} of the last 7 days · ${n}-day streak`}>
<span style={{display:'flex', gap:3, alignItems:'center'}}>
{dots.map(d=>(
<span key={d.date} style={{
width:6, height:6, borderRadius:3,
background: d.practiced ? GREEN : '#ddd',
boxShadow: d.isToday ? '0 0 0 1.5px #fff, 0 0 0 2.5px #ccc' : 'none',
}}/>
))}
</span>
<span style={S.stat}>🔥 {n}</span>
</span>
);
}
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
// ═══════════════════════════════════════════
const FB_GENDERS = ['masculine','feminine','neuter','plural'];
const FB_CASES = ['nominative','accusative','dative','genitive'];
const normAns = s => (s || '').trim().toLowerCase();
// A full declension grid (all 4 cases × 4 genders) for answer feedback.
// The correct cell is highlighted green; when the answer was wrong, the
// cell(s) matching the user's input are highlighted red — so you see both
// your pick and the right one in the whole table, not just one row.
// focusG (optional) keeps one gender column sharp and greys the rest.
function FeedbackTable({ valueFor, isCorrect, isWrong, focusG }) {
return (
<table style={S.stripTbl}>
<thead><tr>
<th style={{...S.stripH, textAlign:'left'}}></th>
{FB_GENDERS.map(g => (
<th key={g} style={{...S.stripH, color: GENDER_C[g], opacity: (!focusG || g === focusG) ? 1 : 0.5}}>{g.slice(0,4)}.</th>
))}
</tr></thead>
<tbody>
{FB_CASES.map(c => (
<tr key={c}>
<td style={{...S.stripD, textAlign:'left', fontSize:11, fontWeight:600, color: CASE_C[c]}}>{CL[c]}</td>
{FB_GENDERS.map(g => {
const val = valueFor(g, c);
const correct = isCorrect(g, c, val);
const wrong = !correct && !!isWrong && isWrong(g, c, val);
const bg = correct ? GREEN + '22' : wrong ? RED + '22' : 'transparent';
const color = correct ? GREEN : wrong ? RED : (focusG && g !== focusG ? '#c8c8c8' : '#555');
return (
<td key={g} style={{...S.stripD, background:bg, borderRadius:4,
fontWeight:(correct||wrong)?700:400, color, fontSize:(correct||wrong)?14:13}}>{val}</td>
);
})}
</tr>
))}
</tbody>
</table>
);
}
function FillFeedback({ q, inp, ok }) {
const valueFor = q.type === 'article'
? (g, c) => (q.artType === 'indefinite' ? INDEF : DEF)[g][c]
: (g, c) => combinePoss(q.stem, POSS_ENDINGS[g][c]);
const wrong = normAns(inp);
return (
<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>
)}
<FeedbackTable
valueFor={valueFor}
focusG={q.noun.g}
isCorrect={(g, c) => g === q.noun.g && c === q.cas}
isWrong={ok ? null : (g, c, val) => g === q.noun.g && normAns(val) === wrong}
/>
</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.exampleFull ? q.exampleEn : `${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> when something moves toward a destination wohin? (gehen, fahren, legen, stellen) and <span style={{color:CASE_C.dative}}>Dativ</span> when it stays put wo? (sein, stehen, sitzen, liegen).
</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 style={{marginTop:10, fontSize:11, color:'#aaa', marginBottom:2}}>
Definite article by case <span style={{color:GENDER_C[q.noun.g]}}>{DEF[q.noun.g].nominative} {q.noun.w}</span>
</div>
<FeedbackTable
valueFor={(g, c) => DEF[g][c]}
focusG={q.noun.g}
isCorrect={(g, c) => g === q.noun.g && c === q.cas}
isWrong={null}
/>
</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 style={{marginTop:10, fontSize:11, color:'#aaa', marginBottom:2}}>
Where {q.art}" sits in the definite-article table
</div>
<FeedbackTable
valueFor={(g, c) => DEF[g][c]}
focusG={null}
isCorrect={(g, c, val) => normAns(val) === normAns(q.art)}
isWrong={null}
/>
</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 val = q.pron[keys[c]];
const correct = c === q.cas;
const wrong = !ok && !correct && normAns(val) === normAns(inp);
return <td key={c} style={{
...S.stripD, fontWeight: (correct || wrong) ? 700 : 400,
color: correct ? GREEN : wrong ? RED : '#bbb',
background: correct ? GREEN + '22' : wrong ? RED + '22' : 'transparent',
borderRadius: 4, fontSize: (correct || wrong) ? 15 : 13,
}}>{val}</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' },
hintToggle: { background:'none', border:'none', padding:0, color:'#c2c2c2', fontSize:10, fontWeight:600, textTransform:'uppercase', letterSpacing:0.5, cursor:'pointer', fontFamily:'inherit', textDecoration:'underline', textDecorationStyle:'dotted', textUnderlineOffset:3 },
kbdSmall: { background:'#f0f0f0', padding:'1px 4px', borderRadius:3, fontSize:9, color:'#888', border:'1px solid #e0e0e0' },
articleGrid: { display:'grid', gridTemplateColumns:'1fr 1fr', gap:10, maxWidth:320, margin:'12px auto 0' },
articleBtn: { padding:'18px 16px', borderRadius:10, border:'1.5px solid #ddd', background:'#fff', color:'#1a1a1a', cursor:'pointer', fontFamily:'inherit', textAlign:'center', transition:'all 0.12s' },
choiceBtn: { flex:'1 1 auto', minWidth:90, padding:'14px 16px', borderRadius:10, border:'1.5px solid', background:'#fff', color:'#1a1a1a', cursor:'pointer', fontSize:14, fontFamily:'inherit', textAlign:'center', transition:'all 0.12s' },
feedback: { textAlign:'left', background:'#fff', borderRadius:10, padding:'14px 18px', margin:'0 auto', maxWidth:400, border:'1px solid #eee' },
stripTbl: { width:'100%', borderCollapse:'collapse', marginTop:10 },
stripH: { padding:'4px 6px', fontSize:10, fontWeight:600, textTransform:'uppercase', letterSpacing:0.3, color:'#bbb', textAlign:'center' },
stripD: { padding:'6px', textAlign:'center', fontSize:13 },
dotsRow: { display:'flex', gap:5, justifyContent:'center', alignItems:'center', position:'fixed', bottom:20, left:0, right:0 },
dotsLabel: { fontSize:11, color:'#aaa', marginLeft:10 },
dot: { width:7, height:7, borderRadius:4, transition:'background 0.2s' },
nextBtn: { padding:'11px 22px', borderRadius:8, border:'none', background:GREEN, color:'#fff', fontSize:14, fontWeight:700, cursor:'pointer', fontFamily:'inherit' },
paywallCard: { background:'#fff', border:'1px solid #eee', borderRadius:12, padding:'24px 20px', textAlign:'left' },
payTitle: { fontSize:30, lineHeight:1.1, margin:'0 0 10px', color:'#1a1a1a', letterSpacing:0, fontWeight:800 },
payCopy: { fontSize:14, lineHeight:1.55, color:'#666', margin:'0 0 16px' },
benefits: { display:'grid', gap:7, margin:'0 0 18px' },
benefit: { fontSize:13, color:'#444', fontWeight:600 },
planGrid: { display:'grid', gridTemplateColumns:'1fr', gap:10 },
planCard: { position:'relative', display:'grid', gridTemplateColumns:'1fr auto', alignItems:'end', gap:'2px 12px', width:'100%', padding:'15px 14px', borderRadius:10, border:'1.5px solid #e6e6e6', background:'#fff', color:'#1a1a1a', cursor:'pointer', fontFamily:'inherit', textAlign:'left' },
planFeatured: { borderColor:GREEN, background:'#f3ffe8', boxShadow:'0 0 0 3px rgba(88,167,0,0.14)' },
planNote: { gridColumn:'1 / -1', fontSize:10, fontWeight:800, letterSpacing:0.6, textTransform:'uppercase', color:GREEN, marginBottom:2 },
planName: { fontSize:14, fontWeight:800 },
planPrice: { fontSize:20, fontWeight:800, textAlign:'right' },
planCadence: { fontSize:11, color:'#999' },
payMsg: { marginTop:12, border:'1px solid #f5d0d0', background:'#fff7f7', color:'#b91c1c', borderRadius:8, padding:'10px 12px', fontSize:12, lineHeight:1.4 },
payFine: { marginTop:12, fontSize:11, color:'#aaa', lineHeight:1.4 },
footerContact: { borderTop:'1px solid #e9e9e9', marginTop:28, paddingTop:18, display:'flex', gap:12, justifyContent:'space-between', alignItems:'center', color:'#777', fontSize:12, lineHeight:1.4 },
footerMail: { flex:'0 0 auto', color:GREEN, fontSize:12, fontWeight:800, textDecoration:'none' },
overlay: { position:'fixed', inset:0, background:'rgba(0,0,0,0.35)', display:'flex', alignItems:'center', justifyContent:'center', zIndex:100, padding:16 },
modal: { background:'#fff', borderRadius:14, padding:'22px 22px', maxWidth:520, width:'100%', maxHeight:'88vh', overflow:'auto', boxShadow:'0 8px 30px rgba(0,0,0,0.12)' },
chartH: { fontSize:13, fontWeight:700, color:'#1a1a1a', marginBottom:6 },
fullChart: { width:'100%', borderCollapse:'collapse', fontSize:14 },
fcTh: { padding:'6px 4px', fontSize:11, fontWeight:600, textAlign:'center', color:'#888', textTransform:'capitalize' },
fcTd: { padding:'7px 4px', textAlign:'center', fontSize:14, borderBottom:'1px solid #f3f3f3' },
};