Add premium paywall and core German drills
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
VITE_STRIPE_YEARLY_URL=
|
||||
VITE_STRIPE_LIFETIME_URL=
|
||||
VITE_STRIPE_WEEKLY_URL=
|
||||
@@ -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.
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
<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-title" content="Beug" />
|
||||
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
|
||||
<title>Beug</title>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -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
@@ -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() {
|
||||
|
||||
<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)' },
|
||||
|
||||
|
||||
Reference in New Issue
Block a user