diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..b3cb9bc --- /dev/null +++ b/.env.example @@ -0,0 +1,3 @@ +VITE_STRIPE_YEARLY_URL= +VITE_STRIPE_LIFETIME_URL= +VITE_STRIPE_WEEKLY_URL= diff --git a/README.md b/README.md index 668e596..0e813e6 100644 --- a/README.md +++ b/README.md @@ -36,3 +36,35 @@ Open the URL in Safari, tap Share → **Add to Home Screen**. It opens fullscree The app tries `window.storage` first (Claude artifact environment), then falls back to `localStorage`. When self-hosted, only localStorage runs, which iOS Safari persists reliably. To wipe progress: open browser devtools → Application → Local Storage → delete the `gd-v10` key. Or on iPhone: Settings → Safari → Advanced → Website Data → search your domain → delete. + +## Stripe checkout + +Beug uses public Stripe Checkout or Payment Link URLs, supplied through environment variables. Do not put Stripe secret keys in this Vite app. + +Create one product in Stripe, for example `Beug Premium`, with three prices: + +- Weekly subscription: `$2.99` every week +- Yearly subscription: `$9.99` every year +- Lifetime access: `$19.99` one-time payment + +Create a Payment Link or Checkout link for each price. Set the success redirect URL to: + +```text +https://beug.george1.dev/?checkout=success +``` + +Set the cancel URL to: + +```text +https://beug.george1.dev/ +``` + +Then add these public URLs in the deployment environment: + +```bash +VITE_STRIPE_YEARLY_URL=https://buy.stripe.com/... +VITE_STRIPE_LIFETIME_URL=https://buy.stripe.com/... +VITE_STRIPE_WEEKLY_URL=https://buy.stripe.com/... +``` + +The current v1 unlock is intentionally simple: after Stripe redirects back with `?checkout=success`, Beug stores the unlock in this browser's localStorage. This avoids accounts for the tiny-app version, but it is a soft paywall. A stricter version needs a small backend, Stripe webhooks, and account or email-based entitlement lookup. diff --git a/index.html b/index.html index 2ce6aea..92e657c 100644 --- a/index.html +++ b/index.html @@ -7,6 +7,7 @@ + Beug diff --git a/public/favicon.svg b/public/favicon.svg new file mode 100644 index 0000000..59d9bb2 --- /dev/null +++ b/public/favicon.svg @@ -0,0 +1,4 @@ + + + B + diff --git a/src/App.jsx b/src/App.jsx index f4ef2f1..1c7a346 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -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 Beethoven’s 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() {
- {scr !== 'menu' + {scr !== 'home' && scr !== 'menu' ? :
Beug
}
{scr === 'practice' && } + {hasAccess && Unlocked} 🔥 {st.streak||0}
+ {/* HOME */} + {scr === 'home' && ( + setScr('menu')} + /> + )} + {/* MENU */} {scr === 'menu' && (
- {TABS.map(t => ( - - ))} + {TABS.map(t => ( + + ))}
{tab === 'prep' && (
{ setLearnStep(0); setScr('learn'); }}> + onClick={()=>{ setLearnStep(0); setScr('learn'); }}>
📚 Lernen
Chunk the lists first — Akkusativ, Dativ, Wechsel, Genitiv
@@ -736,16 +806,16 @@ export default function App() {
)} {LEVELS.filter(l => l.tab === tab).sort((a,b)=>a.id-b.id).map(l => { - const u = unlocked(l.id); - const s = st.sc[l.id]; - const p = s ? Math.round(s.c/s.t*100) : null; - return ( -
startLv(l.id)}> -
-
{l.name} {!u && locked}
-
{l.desc}
+ const u = unlocked(l.id); + const s = st.sc[l.id]; + const p = s ? Math.round(s.c/s.t*100) : null; + return ( +
startLv(l.id)}> +
+
{l.name}
+
{l.desc}
{s &&
{s.c}/{s.t}
}
{p !== null &&
=70?GREEN:RED}}>{p}%
} @@ -754,10 +824,21 @@ export default function App() { })}
- +
)} + {/* PAYWALL */} + {scr === 'paywall' && ( + 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 ( +
+
+
der · den · dem
+

Beat German cases into muscle memory.

+

+ No mascot. No scenic route. Drill the prepositions, articles, possessives, and pronouns that keep slowing you down, then get back to actual German. +

+
+
Learn this stuff in days, not months.
+
Built by fast German learners for other fast German learners.
+
Have a feature request? Get in touch. Always looking to improve the tool.
+
+
+ + + Write an email + +
+
+ + +
+ ); +} + +function CoreReference() { + const prepositionGroups = [ + ['accusative', 'Akkusativ', PREPS_BY_CASE.accusative], + ['dative', 'Dativ', PREPS_BY_CASE.dative], + ['wechsel', 'Two-way', PREPS_BY_CASE.wechsel], + ['genitive', 'Genitiv', PREPS_BY_CASE.genitive], + ]; + const genders = ['masculine','feminine','neuter','plural']; + + return ( +
+
Case map
+
+
+
Prepositions
+ {prepositionGroups.map(([key, label, words]) => ( +
+ {label}: {words.join(', ')} +
+ ))} +
+
+
Definite Articles
+ + + + + {genders.map(g => )} + + + + {CORE_ARTICLE_CASES.map(c => ( + + + {genders.map(g => )} + + ))} + +
{g.slice(0,4)}
{CL[c]}{DEF[g][c]}
+
+
+
+ ); +} + +function PaywallScreen({ message, onCheckout, onBack }) { + return ( +
+
+
Beug Premium
+

Take the whole thing.

+

+ Possessives, pronouns, contractions, genitive articles, and mixed rounds. Built for people who want the case system handled, fast. +

+
+ {['Possessives and pronouns', 'Mixed article rounds', 'Contractions and genitive', 'Saved progress on this device'].map(item => ( +
✓ {item}
+ ))} +
+
+ {STRIPE_PLANS.map(plan => ( + + ))} +
+ {message &&
{message}
} +
No account required for v1. Stripe redirects back here and unlocks this browser.
+ +
+
+ ); +} + // ═══════════════════════════════════════════ // FEEDBACK COMPONENTS // ═══════════════════════════════════════════ @@ -1293,7 +1481,7 @@ function ChartModal({ section, q, onClose }) {
Akkusativ: durch, für, gegen, ohne, um, bis
Dativ: aus, außer, bei, gegenüber, mit, nach, seit, von, zu
Genitiv: trotz, während, wegen, statt, anstatt, außerhalb, innerhalb
-
Wechsel: an, auf, hinter, in, neben, über, unter, vor, zwischen
+
Wechsel: {PREPS_BY_CASE.wechsel.join(', ')}
Wechsel: 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)' },