2.5.4
Upgraded pro/extra access
This commit is contained in:
@@ -29,3 +29,4 @@ dist/
|
|||||||
*.pem
|
*.pem
|
||||||
*.ps1
|
*.ps1
|
||||||
*.py
|
*.py
|
||||||
|
secrets.local.php
|
||||||
|
|||||||
@@ -1740,7 +1740,6 @@
|
|||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.07em;
|
letter-spacing: 0.07em;
|
||||||
color: #7a5c2a;
|
color: #7a5c2a;
|
||||||
margin-right: 5px;
|
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1748,7 +1747,6 @@
|
|||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
margin-right: 8px;
|
|
||||||
}
|
}
|
||||||
.fvt-btn {
|
.fvt-btn {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
|
|||||||
@@ -1134,44 +1134,6 @@
|
|||||||
.welcome-step-num {
|
.welcome-step-num {
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
}
|
}
|
||||||
margin-top: 80px; /* Ensure it sits below the fixed mobile header */
|
|
||||||
}
|
|
||||||
.welcome-title {
|
|
||||||
font-size: 1.1rem;
|
|
||||||
}
|
|
||||||
.welcome-intro {
|
|
||||||
font-size: 0.85rem;
|
|
||||||
}
|
|
||||||
.welcome-step-text {
|
|
||||||
font-size: 0.85rem;
|
|
||||||
}
|
|
||||||
.welcome-step-title {
|
|
||||||
font-size: 0.9rem;
|
|
||||||
}
|
|
||||||
.welcome-step-body {
|
|
||||||
font-size: 0.8rem;
|
|
||||||
}
|
|
||||||
.welcome-cta {
|
|
||||||
font-size: 0.85rem;
|
|
||||||
}
|
|
||||||
.welcome-step-num {
|
|
||||||
font-size: 0.75rem;
|
|
||||||
}
|
|
||||||
.welcome-intro {
|
|
||||||
font-size: 0.85rem;
|
|
||||||
}
|
|
||||||
.welcome-step-text {
|
|
||||||
font-size: 0.85rem;
|
|
||||||
}
|
|
||||||
.welcome-step-title {
|
|
||||||
font-size: 0.9rem;
|
|
||||||
}
|
|
||||||
.welcome-step-body {
|
|
||||||
font-size: 0.8rem;
|
|
||||||
}
|
|
||||||
.welcome-cta {
|
|
||||||
font-size: 0.85rem;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
.utci-fetch-time {
|
.utci-fetch-time {
|
||||||
text-align: right;
|
text-align: right;
|
||||||
|
|||||||
@@ -45,7 +45,12 @@ export function RestoreModal({ onClose, setIsPro }) {
|
|||||||
});
|
});
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
if (data.active) {
|
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);
|
setIsPro(true);
|
||||||
onClose();
|
onClose();
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -22,8 +22,15 @@ import { Wordmark } from './Wordmark.js';
|
|||||||
|
|
||||||
const html = htm.bind(h);
|
const html = htm.bind(h);
|
||||||
|
|
||||||
const SUBSCRIBE_URL = 'https://buy.stripe.com/9B63cw7vl8k15Ei9DQd7q00';
|
const SUBSCRIBE_URL = 'https://buy.stripe.com/9B63cw7vl8k15Ei9DQd7q00';
|
||||||
const MANAGE_URL = 'https://billing.stripe.com/p/login/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 }) {
|
export function SubscribeModal({ title, detail, onClose, openRestore }) {
|
||||||
// Dismiss on Esc.
|
// Dismiss on Esc.
|
||||||
@@ -77,10 +84,18 @@ export function SubscribeModal({ title, detail, onClose, openRestore }) {
|
|||||||
marginBottom: '18px',
|
marginBottom: '18px',
|
||||||
}}>£2 / month · cancel any time</div>
|
}}>£2 / month · cancel any time</div>
|
||||||
<a class="welcome-cta" href=${SUBSCRIBE_URL} target="_blank" rel="noopener noreferrer"
|
<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
|
Subscribe — £2/month
|
||||||
</a>
|
</a>
|
||||||
<div style=${{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '10px' }}>
|
<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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick=${() => { onClose(); openRestore(); }}
|
onClick=${() => { onClose(); openRestore(); }}
|
||||||
|
|||||||
@@ -90,18 +90,91 @@ export function useAppState() {
|
|||||||
|
|
||||||
// Pro tier flag is read early so useForecast can pick its refresh cadence
|
// 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.
|
// (Pro: 5 min, free: 15 min). Full setup notes in the PRO TIER section below.
|
||||||
const [isPro, setIsPro] = useState(() => {
|
// Initial state trusts only what's already in localStorage - a bare
|
||||||
const params = new URLSearchParams(window.location.search);
|
// ?session_id=... in the URL is verified against Stripe (see the effect
|
||||||
if (params.get('pro') === '1') {
|
// below) before it's ever allowed to flip this on, so pasting/guessing a
|
||||||
localStorage.setItem('sunscope_pro', '1');
|
// URL param can't grant free access.
|
||||||
window.history.replaceState({}, '', window.location.pathname);
|
const [isPro, setIsPro] = useState(() => localStorage.getItem('sunscope_pro') === '1');
|
||||||
return true;
|
|
||||||
}
|
|
||||||
return localStorage.getItem('sunscope_pro') === '1';
|
|
||||||
});
|
|
||||||
|
|
||||||
const { forecast, airQuality, loading, error, now, fetchedAt, liveElev, normals } = useForecast(location, isPro);
|
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'); }, []);
|
useEffect(() => { track('visit'); }, []);
|
||||||
|
|
||||||
const [searchQuery, setSearchQuery] = useState('');
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
<?php
|
||||||
|
// ─── SunScope Extra — Subscription Still Active? ─────────────────────────────
|
||||||
|
// JSON endpoint polled roughly once a day per visitor (see useAppState.js)
|
||||||
|
// to catch subscribers who cancelled in Stripe. One-off payers never call
|
||||||
|
// this - their access is permanent by design.
|
||||||
|
//
|
||||||
|
// Returns {"active": bool}
|
||||||
|
|
||||||
|
require __DIR__ . '/secrets.local.php';
|
||||||
|
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
|
||||||
|
$customer = trim($_GET['customer'] ?? $_POST['customer'] ?? '');
|
||||||
|
|
||||||
|
if ($customer === '' || !preg_match('/^cus_[A-Za-z0-9_]+$/', $customer)) {
|
||||||
|
echo json_encode(['active' => false]);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$ch = curl_init('https://api.stripe.com/v1/subscriptions?customer=' . urlencode($customer) . '&status=active&limit=1');
|
||||||
|
curl_setopt_array($ch, [
|
||||||
|
CURLOPT_RETURNTRANSFER => true,
|
||||||
|
CURLOPT_USERPWD => STRIPE_SECRET_KEY . ':',
|
||||||
|
]);
|
||||||
|
$response = json_decode(curl_exec($ch), true);
|
||||||
|
curl_close($ch);
|
||||||
|
|
||||||
|
echo json_encode(['active' => !empty($response['data'])]);
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
<?php
|
||||||
|
// ─── SunScope Extra — Dev Pro Unlock ──────────────────────────────────────────
|
||||||
|
// JSON endpoint for the site owner's own testing. Visiting the app with
|
||||||
|
// ?dev=<DEV_UNLOCK_TOKEN> triggers a call here; only a matching token flips
|
||||||
|
// Pro on. Replaces the old bare ?pro=1 trick, which anyone could type in.
|
||||||
|
//
|
||||||
|
// Returns {"ok": bool}
|
||||||
|
|
||||||
|
require __DIR__ . '/secrets.local.php';
|
||||||
|
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
|
||||||
|
$token = trim($_GET['token'] ?? $_POST['token'] ?? '');
|
||||||
|
|
||||||
|
$ok = $token !== '' && hash_equals(DEV_UNLOCK_TOKEN, $token);
|
||||||
|
|
||||||
|
echo json_encode(['ok' => $ok]);
|
||||||
+36
-8
@@ -1,11 +1,10 @@
|
|||||||
<?php
|
<?php
|
||||||
// ─── SunScope Extra — Restore Access ─────────────────────────────────────────
|
// ─── SunScope Extra — Restore Access ─────────────────────────────────────────
|
||||||
// JSON endpoint for the in-app "Already subscribed? Restore access" modal.
|
// JSON endpoint for the in-app "Already subscribed? Restore access" modal.
|
||||||
// The modal POSTs an email here and reads back {"active": bool, "error": str}.
|
// The modal POSTs an email here and reads back
|
||||||
// Paste your Stripe SECRET key below (never the publishable key).
|
// {"active": bool, "mode": "subscription"|"payment"|null, "customer": string|null, "error": str}.
|
||||||
// Swap sk_test_... for sk_live_... when you go live.
|
|
||||||
|
|
||||||
define('STRIPE_SECRET_KEY', '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');
|
require __DIR__ . '/secrets.local.php';
|
||||||
define('SUNSCOPE_URL', 'https://sunscope.net');
|
define('SUNSCOPE_URL', 'https://sunscope.net');
|
||||||
|
|
||||||
// Direct visits no longer get a page — the restore flow is an in-app modal
|
// Direct visits no longer get a page — the restore flow is an in-app modal
|
||||||
@@ -15,8 +14,10 @@ if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
|||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
$error = '';
|
$error = '';
|
||||||
$isActive = false;
|
$isActive = false;
|
||||||
|
$activeMode = null;
|
||||||
|
$activeCust = null;
|
||||||
|
|
||||||
$email = trim(strtolower($_POST['email'] ?? ''));
|
$email = trim(strtolower($_POST['email'] ?? ''));
|
||||||
|
|
||||||
@@ -44,7 +45,34 @@ if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
|
|||||||
curl_close($ch2);
|
curl_close($ch2);
|
||||||
|
|
||||||
if (!empty($subs['data'])) {
|
if (!empty($subs['data'])) {
|
||||||
$isActive = true;
|
$isActive = true;
|
||||||
|
$activeMode = 'subscription';
|
||||||
|
$activeCust = $customer['id'];
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// No active subscription - check for a completed one-off payment
|
||||||
|
// (the "pay what you like, once" option has no subscription at all).
|
||||||
|
$ch3 = curl_init('https://api.stripe.com/v1/checkout/sessions?customer=' . $customer['id'] . '&limit=5');
|
||||||
|
curl_setopt_array($ch3, [
|
||||||
|
CURLOPT_RETURNTRANSFER => true,
|
||||||
|
CURLOPT_USERPWD => STRIPE_SECRET_KEY . ':',
|
||||||
|
]);
|
||||||
|
$sessions = json_decode(curl_exec($ch3), true);
|
||||||
|
curl_close($ch3);
|
||||||
|
|
||||||
|
$paidOneOff = false;
|
||||||
|
foreach ($sessions['data'] ?? [] as $session) {
|
||||||
|
if (($session['mode'] ?? '') === 'payment' && ($session['payment_status'] ?? '') === 'paid') {
|
||||||
|
$paidOneOff = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($paidOneOff) {
|
||||||
|
$isActive = true;
|
||||||
|
$activeMode = 'payment';
|
||||||
|
$activeCust = $customer['id'];
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -56,4 +84,4 @@ if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
header('Content-Type: application/json');
|
header('Content-Type: application/json');
|
||||||
echo json_encode(['active' => $isActive, 'error' => $error]);
|
echo json_encode(['active' => $isActive, 'mode' => $activeMode, 'customer' => $activeCust, 'error' => $error]);
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
<?php
|
||||||
|
// ─── SunScope Extra — Verify Checkout Session ────────────────────────────────
|
||||||
|
// JSON endpoint hit right after a Stripe Payment Link redirect
|
||||||
|
// (?session_id={CHECKOUT_SESSION_ID}). Confirms server-side that the
|
||||||
|
// session was actually paid before the app unlocks Pro - a bare ?pro=1
|
||||||
|
// URL param can no longer grant access on its own.
|
||||||
|
//
|
||||||
|
// Returns {"paid": bool, "mode": "subscription"|"payment"|null, "customer": string|null}
|
||||||
|
|
||||||
|
require __DIR__ . '/secrets.local.php';
|
||||||
|
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
|
||||||
|
$sessionId = trim($_GET['session_id'] ?? $_POST['session_id'] ?? '');
|
||||||
|
|
||||||
|
if ($sessionId === '' || !preg_match('/^cs_[A-Za-z0-9_]+$/', $sessionId)) {
|
||||||
|
echo json_encode(['paid' => false, 'mode' => null, 'customer' => null]);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$ch = curl_init('https://api.stripe.com/v1/checkout/sessions/' . urlencode($sessionId));
|
||||||
|
curl_setopt_array($ch, [
|
||||||
|
CURLOPT_RETURNTRANSFER => true,
|
||||||
|
CURLOPT_USERPWD => STRIPE_SECRET_KEY . ':',
|
||||||
|
]);
|
||||||
|
$session = json_decode(curl_exec($ch), true);
|
||||||
|
curl_close($ch);
|
||||||
|
|
||||||
|
$paid = ($session['payment_status'] ?? '') === 'paid';
|
||||||
|
|
||||||
|
echo json_encode([
|
||||||
|
'paid' => $paid,
|
||||||
|
'mode' => $paid ? ($session['mode'] ?? null) : null,
|
||||||
|
'customer' => $paid ? ($session['customer'] ?? null) : null,
|
||||||
|
]);
|
||||||
Reference in New Issue
Block a user