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
+3
View File
@@ -0,0 +1,3 @@
VITE_STRIPE_YEARLY_URL=
VITE_STRIPE_LIFETIME_URL=
VITE_STRIPE_WEEKLY_URL=
+32
View File
@@ -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. 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. 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.
+1
View File
@@ -7,6 +7,7 @@
<meta name="apple-mobile-web-app-capable" content="yes" /> <meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="default" /> <meta name="apple-mobile-web-app-status-bar-style" content="default" />
<meta name="apple-mobile-web-app-title" content="Beug" /> <meta name="apple-mobile-web-app-title" content="Beug" />
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
<title>Beug</title> <title>Beug</title>
</head> </head>
<body> <body>
+4
View File
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<rect width="64" height="64" rx="14" fill="#58a700"/>
<text x="32" y="39" text-anchor="middle" font-family="Arial, sans-serif" font-size="28" font-weight="700" fill="#fff">B</text>
</svg>

After

Width:  |  Height:  |  Size: 253 B

+256 -37
View File
@@ -5,6 +5,7 @@ import { useState, useEffect, useCallback, useRef } from "react";
// ═══════════════════════════════════════════ // ═══════════════════════════════════════════
const SKEY = 'gd-v10'; const SKEY = 'gd-v10';
const ACCESS_KEY = 'beug-access-v1';
const Storage = { const Storage = {
async get() { async get() {
try { 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 // DATA
// ═══════════════════════════════════════════ // ═══════════════════════════════════════════
@@ -87,7 +104,7 @@ const PREPS_BY_CASE = {
accusative:['durch','für','gegen','ohne','um','bis'], accusative:['durch','für','gegen','ohne','um','bis'],
dative:['aus','bei','mit','nach','seit','von','zu','außer','gegenüber'], dative:['aus','bei','mit','nach','seit','von','zu','außer','gegenüber'],
genitive:['trotz','während','wegen','statt','anstatt','außerhalb','innerhalb'], 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 COMMON_PREPS = ['mit','von','zu','in','an','auf','für','durch','ohne','um','aus','bei','nach','vor','über','wegen'];
const RARE_PREPS = ['gegen','bis','seit','außer','gegenüber','hinter','neben','unter','zwischen','trotz','während','statt','anstatt','außerhalb','innerhalb']; const 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'}, {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 = [ const LEVELS = [
{id:1, tab:'article', name:'Nominativ', desc:'Gender basics: der, die, or das?', cases:['nominative'], preps:[], type:'article', artType:'definite'}, {id:1, tab:'article', name:'Nominativ', desc:'Gender basics: der, die, or das?', cases:['nominative'], preps:[], type:'article', artType:'definite'},
{id:2, tab:'article', name:'Akkusativ', desc:'durch, für, gegen, ohne, um, bis', cases:['accusative'], preps:PREPS_BY_CASE.accusative, type:'article', artType:'definite'}, {id: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?).', case:'wechsel', label:'Wechselpräpositionen', desc:'Two-way: motion → Akkusativ (wohin?), staying put → Dativ (wo?).',
words:[ words:[
{de:'an', en:'at / on'}, {de:'auf', en:'on / onto'}, {de:'hinter', en:'behind'}, {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:'neben', en:'next to'}, {de:'unter', en:'under'}, {de:'über', en:'over / above'},
{de:'unter', en:'under'}, {de:'vor', en:'in front of'}, {de:'zwischen', en:'between'}, {de:'in', en:'in / into'}, {de:'vor', en:'in front of'}, {de:'zwischen', en:'between'},
], ],
mnemonics:[ 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.'}, {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'; const RED = '#dc2626';
export default function App() { export default function App() {
const [scr, setScr] = useState('menu'); const [scr, setScr] = useState('home');
const [tab, setTab] = useState('prep'); const [tab, setTab] = useState('prep');
const [lv, setLv] = useState(1); const [lv, setLv] = useState(1);
const [q, setQ] = useState(null); const [q, setQ] = useState(null);
@@ -588,6 +637,8 @@ export default function App() {
const [chart, setChart] = useState(false); const [chart, setChart] = useState(false);
const [learnStep, setLearnStep] = useState(0); const [learnStep, setLearnStep] = useState(0);
const [st, setSt] = useState({sc:{},tc:0,ta:0,streak:0,ld:null}); 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 [loaded, setLoaded] = useState(false);
const [shake, setShake] = useState(false); const [shake, setShake] = useState(false);
const iref = useRef(null); const iref = useRef(null);
@@ -595,6 +646,14 @@ export default function App() {
useEffect(()=>{(async()=>{ useEffect(()=>{(async()=>{
const data = await Storage.get(); const data = await Storage.get();
if (data) setSt(data); 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); setLoaded(true);
})();},[]); })();},[]);
@@ -603,17 +662,11 @@ export default function App() {
await Storage.set(s); 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) => { const unlocked = (id) => {
if (ALWAYS_UNLOCKED.has(id)) return true; return !gated(id);
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;
}; };
const nextLevelInTab = (id) => { const nextLevelInTab = (id) => {
@@ -626,10 +679,18 @@ export default function App() {
}; };
const startLv = id => { 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'); 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 submitRef = useRef(null);
const nextRef = useRef(null); const nextRef = useRef(null);
@@ -703,32 +764,41 @@ export default function App() {
<div style={S.inner}> <div style={S.inner}>
<div style={S.hdr}> <div style={S.hdr}>
{scr !== 'menu' {scr !== 'home' && scr !== 'menu'
? <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} onClick={()=>setChart(true)}>charts</button>} {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> <span style={S.stat}>🔥 {st.streak||0}</span>
</div> </div>
</div> </div>
{/* HOME */}
{scr === 'home' && (
<HomeScreen
hasAccess={hasAccess}
onStart={()=>setScr('menu')}
/>
)}
{/* MENU */} {/* MENU */}
{scr === 'menu' && ( {scr === 'menu' && (
<div> <div>
<div style={S.tabs}> <div style={S.tabs}>
{TABS.map(t => ( {TABS.map(t => (
<button key={t.id} <button key={t.id}
style={{...S.tab, ...(tab===t.id ? S.tabActive : {})}} style={{...S.tab, ...(tab===t.id ? S.tabActive : {})}}
onClick={()=>setTab(t.id)}> onClick={()=>setTab(t.id)}>
{t.label} {t.label}
</button> </button>
))} ))}
</div> </div>
<div style={{marginTop:10}}> <div style={{marginTop:10}}>
{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'); }}>
<div style={{flex:1, minWidth:0}}> <div style={{flex:1, minWidth:0}}>
<div style={S.lvName}>📚 Lernen</div> <div style={S.lvName}>📚 Lernen</div>
<div style={S.lvDesc}>Chunk the lists first Akkusativ, Dativ, Wechsel, Genitiv</div> <div style={S.lvDesc}>Chunk the lists first Akkusativ, Dativ, Wechsel, Genitiv</div>
@@ -736,16 +806,16 @@ export default function App() {
</div> </div>
)} )}
{LEVELS.filter(l => l.tab === tab).sort((a,b)=>a.id-b.id).map(l => { {LEVELS.filter(l => l.tab === tab).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;
return ( return (
<div key={l.id} <div key={l.id}
style={{...S.lvRow, opacity:u?1:0.4, cursor:u?'pointer':'default'}} style={{...S.lvRow, ...(u ? {} : S.lvRowBlurred), cursor:'pointer'}}
onClick={()=>startLv(l.id)}> onClick={()=>startLv(l.id)}>
<div style={{flex:1, minWidth:0}}> <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.lvName}>{l.name}</div>
<div style={S.lvDesc}>{l.desc}</div> <div style={S.lvDesc}>{l.desc}</div>
{s && <div style={{fontSize:11,color:'#bbb',marginTop:3}}>{s.c}/{s.t}</div>} {s && <div style={{fontSize:11,color:'#bbb',marginTop:3}}>{s.c}/{s.t}</div>}
</div> </div>
{p !== null && <div style={{...S.lvPct, color:p>=70?GREEN:RED}}>{p}%</div>} {p !== null && <div style={{...S.lvPct, color:p>=70?GREEN:RED}}>{p}%</div>}
@@ -754,10 +824,21 @@ export default function App() {
})} })}
</div> </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> </div>
)} )}
{/* PAYWALL */}
{scr === 'paywall' && (
<PaywallScreen
message={payMsg}
onCheckout={openCheckout}
onBack={()=>setScr('menu')}
/>
)}
{/* LEARN: chunked study cards for prepositions */} {/* LEARN: chunked study cards for prepositions */}
{scr === 'learn' && (() => { {scr === 'learn' && (() => {
const card = LEARN_CARDS[learnStep]; 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 // 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.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.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.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>
<div style={{fontSize:12, color:'#999', marginTop:14, lineHeight:1.6}}> <div style={{fontSize:12, color:'#999', marginTop:14, lineHeight:1.6}}>
<strong>Wechsel</strong>: accusative for motion (wohin?), dative for location (wo?). <strong>Wechsel</strong>: accusative for motion (wohin?), dative for location (wo?).
@@ -1423,10 +1611,26 @@ const S = {
stat: { fontSize:13, color:'#999', fontWeight:500 }, stat: { fontSize:13, color:'#999', fontWeight:500 },
tabs: { display:'flex', gap:0, borderBottom:'1px solid #eee', marginTop:6, marginBottom:12 }, 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 }, 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' }, 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' }, lvName: { fontSize:14, fontWeight:700, color:'#1a1a1a' },
lvDesc: { fontSize:12, color:'#999', marginTop:1 }, lvDesc: { fontSize:12, color:'#999', marginTop:1 },
lvPct: { fontSize:14, fontWeight:700 }, 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' }, 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 }, 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)' }, 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)' },