Add pinned Daily Practice card + one-tap mode
- Daily Practice (all 31 prepositions) is pinned to the top of the Prepositions tab once the "Alle Präpositionen" lesson is passed (>=70% over a full round). - One-tap mode: after answering, the app flashes right/wrong and auto-advances. Always on in Daily Practice; a persisted "One-tap" toggle in the practice header enables it for any other level.
This commit is contained in:
+44
-4
@@ -60,7 +60,10 @@ const TABS = [
|
|||||||
{id:'pronoun', label:'Pronouns'},
|
{id:'pronoun', label:'Pronouns'},
|
||||||
];
|
];
|
||||||
|
|
||||||
const FREE_LEVEL_IDS = new Set([2, 3, 4, 8, 9, 10, 18]);
|
// 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 CORE_ARTICLE_CASES = ['nominative', 'accusative', 'dative'];
|
||||||
const PAYMENTS_ENABLED = import.meta.env.VITE_PAYMENTS_ENABLED === 'true';
|
const PAYMENTS_ENABLED = import.meta.env.VITE_PAYMENTS_ENABLED === 'true';
|
||||||
const STRIPE_PLANS = PAYMENTS_ENABLED ? [
|
const STRIPE_PLANS = PAYMENTS_ENABLED ? [
|
||||||
@@ -220,7 +223,7 @@ export default function App() {
|
|||||||
const nextLevelInTab = (id) => {
|
const nextLevelInTab = (id) => {
|
||||||
const lvObj = LEVELS.find(l => l.id === id);
|
const lvObj = LEVELS.find(l => l.id === id);
|
||||||
if (!lvObj) return null;
|
if (!lvObj) return null;
|
||||||
const tabLevels = LEVELS.filter(l => l.tab === lvObj.tab).sort((a,b) => a.id - b.id);
|
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);
|
const idx = tabLevels.findIndex(l => l.id === id);
|
||||||
if (idx === -1 || idx === tabLevels.length - 1) return null;
|
if (idx === -1 || idx === tabLevels.length - 1) return null;
|
||||||
return tabLevels[idx + 1];
|
return tabLevels[idx + 1];
|
||||||
@@ -297,12 +300,30 @@ export default function App() {
|
|||||||
return () => window.removeEventListener('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>;
|
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 rc = res.filter(Boolean).length;
|
||||||
const cur = LEVELS.find(l=>l.id===lv);
|
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 chartSection = scr === 'menu' ? tab : (q?.type === 'wechselContext' ? 'prep' : q?.type === 'chunk' ? 'article' : (q?.type || tab));
|
||||||
const nextInTab = nextLevelInTab(lv);
|
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 (
|
return (
|
||||||
<div style={S.wrap}>
|
<div style={S.wrap}>
|
||||||
@@ -314,6 +335,14 @@ export default function App() {
|
|||||||
? <button style={S.linkBtn} onClick={()=>setScr('menu')}>← back</button>
|
? <button style={S.linkBtn} onClick={()=>setScr('menu')}>← back</button>
|
||||||
: <div style={S.logo}>Beug</div>}
|
: <div style={S.logo}>Beug</div>}
|
||||||
<div style={{display:'flex',gap:14,alignItems:'center'}}>
|
<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>}
|
{scr === 'practice' && <button style={S.linkBtn} onClick={()=>setChart(true)}>charts</button>}
|
||||||
{PAYMENTS_ENABLED && hasPaidAccess && <span style={S.stat}>Unlocked</span>}
|
{PAYMENTS_ENABLED && hasPaidAccess && <span style={S.stat}>Unlocked</span>}
|
||||||
<StreakBadge st={st}/>
|
<StreakBadge st={st}/>
|
||||||
@@ -342,6 +371,17 @@ export default function App() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{marginTop:10}}>
|
<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' && (
|
{tab === 'prep' && (
|
||||||
<div style={{...S.lvRow, cursor:'pointer'}}
|
<div style={{...S.lvRow, cursor:'pointer'}}
|
||||||
onClick={()=>{ setLearnStep(0); setScr('learn'); }}>
|
onClick={()=>{ setLearnStep(0); setScr('learn'); }}>
|
||||||
@@ -351,7 +391,7 @@ export default function App() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{LEVELS.filter(l => l.tab === tab).sort((a,b)=>a.id-b.id).map(l => {
|
{LEVELS.filter(l => l.tab === tab && !l.daily).sort((a,b)=>a.id-b.id).map(l => {
|
||||||
const u = unlocked(l.id);
|
const u = unlocked(l.id);
|
||||||
const s = st.sc[l.id];
|
const s = st.sc[l.id];
|
||||||
const p = s ? Math.round(s.c/s.t*100) : null;
|
const p = s ? Math.round(s.c/s.t*100) : null;
|
||||||
@@ -467,7 +507,7 @@ export default function App() {
|
|||||||
color: rev?(ok?GREEN:RED):'#1a1a1a',
|
color: rev?(ok?GREEN:RED):'#1a1a1a',
|
||||||
minWidth: q.type==='poss' ? 110 : 80,
|
minWidth: q.type==='poss' ? 110 : 80,
|
||||||
}}>
|
}}>
|
||||||
{rev ? q.ans : (inp || '\u00A0\u00A0\u00A0\u00A0\u00A0')}
|
{rev ? q.ans : (inp || ' ')}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
Reference in New Issue
Block a user