Add premium paywall and core German drills

This commit is contained in:
fightme-cmd
2026-07-05 10:28:11 +02:00
parent 960f63dc21
commit 49f78ef6b0
5 changed files with 296 additions and 37 deletions
+256 -37
View File
@@ -5,6 +5,7 @@ import { useState, useEffect, useCallback, useRef } from "react";
// ═══════════════════════════════════════════
const SKEY = 'gd-v10';
const ACCESS_KEY = 'beug-access-v1';
const Storage = {
async get() {
try {
@@ -31,6 +32,22 @@ const Storage = {
}
};
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
// ═══════════════════════════════════════════
@@ -87,7 +104,7 @@ 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','in','neben','über','unter','vor','zwischen'],
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'];
@@ -214,6 +231,38 @@ const TABS = [
{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 STRIPE_PLANS = [
{
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',
},
{
id:'weekly',
name:'Weekly',
price:'$2.99',
cadence:'per week',
note:'Try it first',
url: import.meta.env.VITE_STRIPE_WEEKLY_URL || '',
env:'VITE_STRIPE_WEEKLY_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'},
@@ -282,8 +331,8 @@ const LEARN_CARDS = [
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:'in', en:'in / into'}, {de:'neben', en:'next to'}, {de:'über', en:'over / above'},
{de:'unter', en:'under'}, {de:'vor', en:'in front of'}, {de:'zwischen', en:'between'},
{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.'},
@@ -577,7 +626,7 @@ const GREEN = '#58a700';
const RED = '#dc2626';
export default function App() {
const [scr, setScr] = useState('menu');
const [scr, setScr] = useState('home');
const [tab, setTab] = useState('prep');
const [lv, setLv] = useState(1);
const [q, setQ] = useState(null);
@@ -588,6 +637,8 @@ export default function App() {
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);
@@ -595,6 +646,14 @@ export default function App() {
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);
})();},[]);
@@ -603,17 +662,11 @@ export default function App() {
await Storage.set(s);
},[]);
const ALWAYS_UNLOCKED = new Set([1, 2, 8, 11, 14]);
const hasAccess = !!access?.paid;
const gated = id => !hasAccess && !FREE_LEVEL_IDS.has(id);
const unlocked = (id) => {
if (ALWAYS_UNLOCKED.has(id)) return true;
const lvObj = LEVELS.find(l => l.id === id);
if (!lvObj) return false;
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 === 0) return true;
const prev = tabLevels[idx-1];
const p = st.sc[prev.id];
return p && p.t >= 10 && (p.c/p.t) >= 0.7;
return !gated(id);
};
const nextLevelInTab = (id) => {
@@ -626,10 +679,18 @@ export default function App() {
};
const startLv = id => {
if (!unlocked(id)) return;
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);
@@ -703,32 +764,41 @@ export default function App() {
<div style={S.inner}>
<div style={S.hdr}>
{scr !== 'menu'
{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>}
{hasAccess && <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>
))}
{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'); }}>
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>
@@ -736,16 +806,16 @@ export default function App() {
</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, opacity:u?1:0.4, cursor:u?'pointer':'default'}}
onClick={()=>startLv(l.id)}>
<div style={{flex:1, minWidth:0}}>
<div style={S.lvName}>{l.name} {!u && <span style={{fontSize:11,color:'#aaa'}}>locked</span>}</div>
<div style={S.lvDesc}>{l.desc}</div>
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>}
@@ -754,10 +824,21 @@ export default function App() {
})}
</div>
<button style={S.chartLinkBtn} onClick={()=>setChart(true)}>View {TABS.find(t=>t.id===tab).label.toLowerCase()} charts</button>
<button style={S.chartLinkBtn} onClick={()=>setChart(true)}>
View {TABS.find(t=>t.id===tab).label.toLowerCase()} charts
</button>
</div>
)}
{/* PAYWALL */}
{scr === 'paywall' && (
<PaywallScreen
message={payMsg}
onCheckout={openCheckout}
onBack={()=>setScr('menu')}
/>
)}
{/* LEARN: chunked study cards for prepositions */}
{scr === 'learn' && (() => {
const card = LEARN_CARDS[learnStep];
@@ -1051,6 +1132,113 @@ export default function App() {
);
}
function HomeScreen({ onStart }) {
return (
<div style={{paddingTop:18}}>
<section style={S.hero}>
<div style={S.heroMark}>der · den · dem</div>
<h1 style={S.heroTitle}>Beat German cases into muscle memory.</h1>
<p style={S.heroCopy}>
No mascot. No scenic route. Drill the prepositions, articles, possessives, and pronouns that keep slowing you down, then get back to actual German.
</p>
<div style={S.claimStack}>
<div>Learn this stuff in days, not months.</div>
<div>Built by fast German learners for other fast German learners.</div>
<div>Have a feature request? Get in touch. Always looking to improve the tool.</div>
</div>
<div style={S.heroActions}>
<button style={S.nextBtn} onClick={onStart}>Get started</button>
<a
style={S.mailBtn}
href={`mailto:?subject=${encodeURIComponent('Beug feature request')}&body=${encodeURIComponent('Hey, I have a feature request for Beug:\n\n')}`}
>
Write an email
</a>
</div>
</section>
<CoreReference />
</div>
);
}
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
// ═══════════════════════════════════════════
@@ -1293,7 +1481,7 @@ function ChartModal({ section, q, onClose }) {
<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> an, auf, hinter, in, neben, über, unter, vor, zwischen</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?).
@@ -1423,10 +1611,26 @@ const S = {
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', borderBottom:'2px solid transparent', marginBottom:-1, transition:'all 0.15s' },
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' },
claimStack: { display:'grid', gap:8, margin:'0 0 20px', fontSize:13, lineHeight:1.35, color:'#222', fontWeight:800 },
heroActions: { display:'flex', gap:10, flexWrap:'wrap' },
secondaryBtn: { padding:'11px 18px', borderRadius:8, border:'1.5px solid #ddd', background:'#fff', color:'#444', fontSize:14, fontWeight:700, cursor:'pointer', fontFamily:'inherit' },
mailBtn: { display:'inline-flex', alignItems:'center', justifyContent:'center', padding:'11px 18px', borderRadius:8, border:'1.5px solid #ddd', background:'#fff', color:'#444', fontSize:14, fontWeight:700, cursor:'pointer', fontFamily:'inherit', textDecoration:'none' },
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(2px)', opacity:0.62 },
lvName: { fontSize:14, fontWeight:700, color:'#1a1a1a' },
lvDesc: { fontSize:12, color:'#999', marginTop:1 },
lvPct: { fontSize:14, fontWeight:700 },
@@ -1465,6 +1669,21 @@ const S = {
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, boxShadow:'0 0 0 3px rgba(88,167,0,0.10)' },
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 },
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)' },