Upgraded pro/extra access
This commit is contained in:
fraxle
2026-07-24 18:40:25 +01:00
parent 7068ad91a9
commit 84d035d903
10 changed files with 223 additions and 61 deletions
+6 -1
View File
@@ -45,7 +45,12 @@ export function RestoreModal({ onClose, setIsPro }) {
});
const data = await res.json();
if (data.active) {
try { localStorage.setItem('sunscope_pro', '1'); } catch (err) { /* ignore */ }
try {
localStorage.setItem('sunscope_pro', '1');
if (data.mode) localStorage.setItem('sunscope_pro_mode', data.mode);
if (data.customer) localStorage.setItem('sunscope_pro_customer', data.customer);
localStorage.setItem('sunscope_pro_checked_at', String(Date.now()));
} catch (err) { /* ignore */ }
setIsPro(true);
onClose();
return;
+18 -3
View File
@@ -22,8 +22,15 @@ import { Wordmark } from './Wordmark.js';
const html = htm.bind(h);
const SUBSCRIBE_URL = 'https://buy.stripe.com/9B63cw7vl8k15Ei9DQd7q00';
const MANAGE_URL = 'https://billing.stripe.com/p/login/9B63cw7vl8k15Ei9DQd7q00';
const SUBSCRIBE_URL = 'https://buy.stripe.com/9B63cw7vl8k15Ei9DQd7q00';
// TODO: replace with the one-time Payment Link created in the Stripe
// Dashboard (see plan Part 3) - a flat-rate product with a few selectable
// price options (e.g. £3 / £5 / £10), since Stripe Payment Links don't
// support true customer-chosen amounts. Configure its after-payment
// redirect to https://sunscope.net/?session_id={CHECKOUT_SESSION_ID}
// the same way the monthly link should be.
const SUBSCRIBE_URL_ONEOFF = 'https://buy.stripe.com/REPLACE_WITH_ONEOFF_PAYMENT_LINK';
const MANAGE_URL = 'https://billing.stripe.com/p/login/9B63cw7vl8k15Ei9DQd7q00';
export function SubscribeModal({ title, detail, onClose, openRestore }) {
// Dismiss on Esc.
@@ -77,10 +84,18 @@ export function SubscribeModal({ title, detail, onClose, openRestore }) {
marginBottom: '18px',
}}>£2 / month · cancel any time</div>
<a class="welcome-cta" href=${SUBSCRIBE_URL} target="_blank" rel="noopener noreferrer"
style=${{ textDecoration: 'none', textAlign: 'center', marginBottom: '16px' }}>
style=${{ textDecoration: 'none', textAlign: 'center', marginBottom: '10px' }}>
Subscribe — £2/month
</a>
<div style=${{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '10px' }}>
<a href=${SUBSCRIBE_URL_ONEOFF} target="_blank" rel="noopener noreferrer"
style=${{
...secondaryLink,
color: '#c8922a',
borderBottom: '1px solid rgba(200,146,42,0.4)',
paddingBottom: '1px',
}}
>Or make a one-off payment →</a>
<button
type="button"
onClick=${() => { onClose(); openRestore(); }}
+82 -9
View File
@@ -90,18 +90,91 @@ export function useAppState() {
// Pro tier flag is read early so useForecast can pick its refresh cadence
// (Pro: 5 min, free: 15 min). Full setup notes in the PRO TIER section below.
const [isPro, setIsPro] = useState(() => {
const params = new URLSearchParams(window.location.search);
if (params.get('pro') === '1') {
localStorage.setItem('sunscope_pro', '1');
window.history.replaceState({}, '', window.location.pathname);
return true;
}
return localStorage.getItem('sunscope_pro') === '1';
});
// Initial state trusts only what's already in localStorage - a bare
// ?session_id=... in the URL is verified against Stripe (see the effect
// below) before it's ever allowed to flip this on, so pasting/guessing a
// URL param can't grant free access.
const [isPro, setIsPro] = useState(() => localStorage.getItem('sunscope_pro') === '1');
const { forecast, airQuality, loading, error, now, fetchedAt, liveElev, normals } = useForecast(location, isPro);
// Just returned from a Stripe Payment Link: verify the checkout session
// server-side (verify-session.php) before granting Pro. Also records
// which kind of purchase it was (subscription vs one-off) and the Stripe
// customer id, so the re-check effect below knows whether/how to follow up.
useEffect(() => {
const params = new URLSearchParams(window.location.search);
const sessionId = params.get('session_id');
if (!sessionId) return;
window.history.replaceState({}, '', window.location.pathname);
fetch(`verify-session.php?session_id=${encodeURIComponent(sessionId)}`)
.then((r) => r.json())
.then((data) => {
if (!data.paid) return;
try {
localStorage.setItem('sunscope_pro', '1');
if (data.mode) localStorage.setItem('sunscope_pro_mode', data.mode);
if (data.customer) localStorage.setItem('sunscope_pro_customer', data.customer);
localStorage.setItem('sunscope_pro_checked_at', String(Date.now()));
} catch (e) { /* ignore */ }
setIsPro(true);
})
.catch(() => {});
}, []);
// Dev-only testing unlock: ?dev=<token>, verified server-side against
// DEV_UNLOCK_TOKEN in secrets.local.php (dev-unlock.php). Replaces the old
// bare ?pro=1 trick - a guessed/copied URL with the wrong token does nothing.
useEffect(() => {
const params = new URLSearchParams(window.location.search);
const devToken = params.get('dev');
if (!devToken) return;
window.history.replaceState({}, '', window.location.pathname);
fetch(`dev-unlock.php?token=${encodeURIComponent(devToken)}`)
.then((r) => r.json())
.then((data) => {
if (!data.ok) return;
try {
localStorage.setItem('sunscope_pro', '1');
localStorage.setItem('sunscope_pro_mode', 'dev');
localStorage.removeItem('sunscope_pro_customer');
localStorage.setItem('sunscope_pro_checked_at', String(Date.now()));
} catch (e) { /* ignore */ }
setIsPro(true);
})
.catch(() => {});
}, []);
// Subscribers (not one-off payers) can cancel in Stripe at any time, so
// Pro access shouldn't stay granted forever once localStorage is set.
// Re-check roughly once a day per visitor - one-off payments are skipped
// entirely since that access is permanent by design.
useEffect(() => {
if (!isPro) return;
const mode = (() => { try { return localStorage.getItem('sunscope_pro_mode'); } catch (e) { return null; } })();
if (mode !== 'subscription') return;
const customer = (() => { try { return localStorage.getItem('sunscope_pro_customer'); } catch (e) { return null; } })();
if (!customer) return;
const lastChecked = (() => { try { return Number(localStorage.getItem('sunscope_pro_checked_at')) || 0; } catch (e) { return 0; } })();
const RECHECK_MS = 24 * 60 * 60 * 1000;
if (Date.now() - lastChecked < RECHECK_MS) return;
fetch(`check-subscription.php?customer=${encodeURIComponent(customer)}`)
.then((r) => r.json())
.then((data) => {
try { localStorage.setItem('sunscope_pro_checked_at', String(Date.now())); } catch (e) { /* ignore */ }
if (!data.active) {
try {
localStorage.removeItem('sunscope_pro');
localStorage.removeItem('sunscope_pro_mode');
localStorage.removeItem('sunscope_pro_customer');
} catch (e) { /* ignore */ }
setIsPro(false);
}
})
.catch(() => {});
}, [isPro]);
useEffect(() => { track('visit'); }, []);
const [searchQuery, setSearchQuery] = useState('');