RC!
Payment setup
This commit is contained in:
+179
@@ -0,0 +1,179 @@
|
|||||||
|
# SunScope Extra — Stripe Subscription Setup
|
||||||
|
## Step-by-step guide to wiring up the £2/month paywall
|
||||||
|
|
||||||
|
SunScope is a static site with no backend server, so this guide uses
|
||||||
|
**Stripe Payment Links** — a no-code/no-server approach where Stripe
|
||||||
|
hosts the checkout page for you. You get a URL, you paste it into the
|
||||||
|
app. Done.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## STEP 1 — Create a Stripe account
|
||||||
|
|
||||||
|
1. Go to **https://stripe.com** and sign up (free).
|
||||||
|
2. Complete the identity verification (required before going live).
|
||||||
|
3. You'll land in the Stripe Dashboard. Make sure you're in **Test mode**
|
||||||
|
first (toggle in the top-left) — you can do the whole setup in test
|
||||||
|
mode and flip to live when ready.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## STEP 2 — Create the £2/month product
|
||||||
|
|
||||||
|
1. In the Stripe Dashboard sidebar, go to **Product catalogue → + Add product**.
|
||||||
|
2. Fill in:
|
||||||
|
- **Name:** `SunScope Extra`
|
||||||
|
- **Description:** `Full 14-day forecast, specialist profiles, custom columns`
|
||||||
|
- **Pricing model:** `Recurring`
|
||||||
|
- **Price:** `£2.00`
|
||||||
|
- **Billing period:** `Monthly`
|
||||||
|
3. Click **Save product**.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## STEP 3 — Create a Payment Link
|
||||||
|
|
||||||
|
This is the URL you'll drop into the app — Stripe hosts the whole
|
||||||
|
checkout, card capture, and subscription management for you.
|
||||||
|
|
||||||
|
1. In the product you just created, click **Create payment link**.
|
||||||
|
2. Settings to check:
|
||||||
|
- **Quantity:** 1 (fixed)
|
||||||
|
- **Collect customer email:** Yes (you'll need this to verify subscribers)
|
||||||
|
- **Allow promotion codes:** Up to you (handy for early adopters)
|
||||||
|
- **After payment:** set the redirect URL to `https://sunscope.net/?pro=1`
|
||||||
|
(we'll use this query param to activate Pro — see Step 5)
|
||||||
|
3. Click **Create link**.
|
||||||
|
4. Copy the URL — it looks like: `https://buy.stripe.com/test_xxxxxxxxxxxxxxxx`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## STEP 4 — Update the upsell card in app.js
|
||||||
|
|
||||||
|
Open `assets/js/app.js`. Find **line ~913** and **line ~923**. You need
|
||||||
|
to change two things:
|
||||||
|
|
||||||
|
### 4a — Update the pricing line (line ~913)
|
||||||
|
|
||||||
|
Find this:
|
||||||
|
```
|
||||||
|
£2 / month · launching soon
|
||||||
|
```
|
||||||
|
Change it to:
|
||||||
|
```
|
||||||
|
£2 / month · cancel any time
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4b — Replace the mailto: button with the Stripe link (line ~923)
|
||||||
|
|
||||||
|
Find this block (starts around line 923):
|
||||||
|
```javascript
|
||||||
|
href=${`mailto:fraxle@yahoo.co.uk?subject=${encodeURIComponent('SunScope Extra — notify me at launch')}&body=${encodeURIComponent('Hi — please let me know when SunScope Extra launches. (Triggered by ' + dayLong + ')')}`}
|
||||||
|
```
|
||||||
|
|
||||||
|
Replace the entire `href=` value with your Stripe Payment Link:
|
||||||
|
```javascript
|
||||||
|
href="https://buy.stripe.com/YOUR_ACTUAL_LINK_HERE"
|
||||||
|
```
|
||||||
|
|
||||||
|
Also change the button label on line ~938 from:
|
||||||
|
```
|
||||||
|
Notify me at launch
|
||||||
|
```
|
||||||
|
To:
|
||||||
|
```
|
||||||
|
Subscribe — £2/month
|
||||||
|
```
|
||||||
|
|
||||||
|
And add `target="_blank" rel="noopener noreferrer"` so it opens in a
|
||||||
|
new tab (the user stays on the forecast page):
|
||||||
|
```javascript
|
||||||
|
<a
|
||||||
|
href="https://buy.stripe.com/YOUR_ACTUAL_LINK_HERE"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
style=${{ ...existing styles... }}
|
||||||
|
>
|
||||||
|
Subscribe — £2/month
|
||||||
|
</a>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## STEP 5 — Activate Pro after payment (simple URL param method)
|
||||||
|
|
||||||
|
When Stripe redirects back to `https://sunscope.net/?pro=1` after a
|
||||||
|
successful payment, the app needs to detect that and set `isPro = true`.
|
||||||
|
|
||||||
|
Open `assets/js/app.js` and find the `isPro` state (line ~116):
|
||||||
|
```javascript
|
||||||
|
const [isPro, setIsPro] = useState(false);
|
||||||
|
```
|
||||||
|
|
||||||
|
Replace that single line with:
|
||||||
|
```javascript
|
||||||
|
const [isPro, setIsPro] = useState(() => {
|
||||||
|
// Check URL param first (just returned from Stripe checkout)
|
||||||
|
const params = new URLSearchParams(window.location.search);
|
||||||
|
if (params.get('pro') === '1') {
|
||||||
|
localStorage.setItem('sunscope_pro', '1');
|
||||||
|
// Clean the URL so the param doesn't stay visible
|
||||||
|
window.history.replaceState({}, '', window.location.pathname);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
// Check localStorage (returning Pro subscriber)
|
||||||
|
return localStorage.getItem('sunscope_pro') === '1';
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
That's it. After subscribing, the user lands back on the site as Pro,
|
||||||
|
and localStorage remembers them on future visits.
|
||||||
|
|
||||||
|
> **Important caveat:** This is a trust-based system — it stores Pro
|
||||||
|
> status in the user's browser. Anyone who knows the trick can set
|
||||||
|
> `localStorage.setItem('sunscope_pro','1')` in their console. For a
|
||||||
|
> £2/month product with a small audience this is probably fine. When
|
||||||
|
> you want proper enforcement, see Step 6.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## STEP 6 — (Optional) Proper server-side verification
|
||||||
|
|
||||||
|
When you're ready to properly enforce subscriptions (no console tricks):
|
||||||
|
|
||||||
|
1. **Stripe Customer Portal** — enable it in the Stripe Dashboard so
|
||||||
|
subscribers can manage/cancel their own subscriptions.
|
||||||
|
2. **Webhook → backend** — Stripe fires a `checkout.session.completed`
|
||||||
|
event when someone subscribes. You'd need a tiny serverless function
|
||||||
|
(Cloudflare Worker, Vercel Edge Function, or Netlify Function) to:
|
||||||
|
- Receive the webhook
|
||||||
|
- Store the subscriber's email + Stripe customer ID in a small DB
|
||||||
|
(or even Airtable/Supabase free tier)
|
||||||
|
- Issue a signed token (JWT) back to the browser
|
||||||
|
3. **Replace localStorage** with checking that JWT against your backend
|
||||||
|
on page load.
|
||||||
|
|
||||||
|
This is a future step — the localStorage approach is perfectly fine for
|
||||||
|
launch.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## STEP 7 — Go live
|
||||||
|
|
||||||
|
When you're happy with testing:
|
||||||
|
|
||||||
|
1. In Stripe Dashboard, flip the **Test mode** toggle to **Live mode**.
|
||||||
|
2. Repeat Steps 2–3 in live mode to get a live Payment Link URL.
|
||||||
|
3. Replace the test URL in `app.js` with the live URL.
|
||||||
|
4. Deploy.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Quick reference — files changed
|
||||||
|
|
||||||
|
| File | What changed |
|
||||||
|
|------|-------------|
|
||||||
|
| `assets/js/app.js` line ~116 | `isPro` state reads from localStorage + URL param |
|
||||||
|
| `assets/js/app.js` line ~913 | Pricing copy: "cancel any time" |
|
||||||
|
| `assets/js/app.js` line ~923 | `href` → Stripe Payment Link URL |
|
||||||
|
| `assets/js/app.js` line ~938 | Button label → "Subscribe — £2/month" |
|
||||||
+1
-1
@@ -34,7 +34,7 @@
|
|||||||
|
|
||||||
/* ── 1. FONTS + RESET ───────────────────────────────────────────────── */
|
/* ── 1. FONTS + RESET ───────────────────────────────────────────────── */
|
||||||
|
|
||||||
@import "https://fonts.bunny.net/css2?family=Fraunces:opsz,wght@9..144,300;9..144,500;9..144,700;9..144,900&family=JetBrains+Mono:wght@400;600&family=Manrope:wght@400;500;600;700&display=swap";
|
@import "https://fonts.bunny.net/css2?family=Fraunces:ital,wght@0,300;0,500;0,700;0,900;1,300;1,500;1,700;1,900&family=JetBrains+Mono:wght@400;600&family=Manrope:wght@400;500;600;700&display=swap";
|
||||||
|
|
||||||
*,
|
*,
|
||||||
*:before,
|
*:before,
|
||||||
|
|||||||
@@ -39,9 +39,9 @@
|
|||||||
font-style: normal;
|
font-style: normal;
|
||||||
}
|
}
|
||||||
|
|
||||||
.utci-title .title-scope { /* "Scope" — italic */
|
.utci-title .title-scope { /* "Scope" — italic light, matching about page */
|
||||||
font-style: italic;
|
font-style: italic;
|
||||||
font-weight: 900;
|
font-weight: 300;
|
||||||
color: #1e1208;
|
color: #1e1208;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+33
-4
@@ -113,7 +113,19 @@ export function UTCIForecast() {
|
|||||||
// FLIP THE `false` BELOW TO `true` TO PREVIEW THE PRO EXPERIENCE.
|
// FLIP THE `false` BELOW TO `true` TO PREVIEW THE PRO EXPERIENCE.
|
||||||
// When this is wired to real billing/auth, replace `useState(false)`
|
// When this is wired to real billing/auth, replace `useState(false)`
|
||||||
// with a check against the logged-in user.
|
// with a check against the logged-in user.
|
||||||
const [isPro, setIsPro] = useState(false);
|
//const [isPro, setIsPro] = useState(false);
|
||||||
|
const [isPro, setIsPro] = useState(() => {
|
||||||
|
// Check URL param first (just returned from Stripe checkout)
|
||||||
|
const params = new URLSearchParams(window.location.search);
|
||||||
|
if (params.get('pro') === '1') {
|
||||||
|
localStorage.setItem('sunscope_pro', '1');
|
||||||
|
// Clean the URL so the param doesn't stay visible
|
||||||
|
window.history.replaceState({}, '', window.location.pathname);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
// Check localStorage (returning Pro subscriber)
|
||||||
|
return localStorage.getItem('sunscope_pro') === '1';
|
||||||
|
});
|
||||||
|
|
||||||
// How many days the free tier shows. Days beyond this get a 🔒.
|
// How many days the free tier shows. Days beyond this get a 🔒.
|
||||||
// Bump this number if you want to give free users more access.
|
// Bump this number if you want to give free users more access.
|
||||||
@@ -910,7 +922,7 @@ export function UTCIForecast() {
|
|||||||
color: '#c8922a',
|
color: '#c8922a',
|
||||||
marginTop: '10px',
|
marginTop: '10px',
|
||||||
}}>
|
}}>
|
||||||
£2 / month · launching soon
|
£2 / month · cancel any time
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div style=${{
|
<div style=${{
|
||||||
@@ -920,7 +932,9 @@ export function UTCIForecast() {
|
|||||||
alignItems: 'flex-end',
|
alignItems: 'flex-end',
|
||||||
}}>
|
}}>
|
||||||
<a
|
<a
|
||||||
href=${`mailto:fraxle@yahoo.co.uk?subject=${encodeURIComponent('SunScope Extra — notify me at launch')}&body=${encodeURIComponent('Hi — please let me know when SunScope Extra launches. (Triggered by ' + dayLong + ')')}`}
|
href="https://buy.stripe.com/test_28E00j4xr6Es0izeE3fw400"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
style=${{
|
style=${{
|
||||||
display: 'inline-block',
|
display: 'inline-block',
|
||||||
padding: '10px 18px',
|
padding: '10px 18px',
|
||||||
@@ -935,7 +949,22 @@ export function UTCIForecast() {
|
|||||||
whiteSpace: 'nowrap',
|
whiteSpace: 'nowrap',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Notify me at launch
|
Subscribe — £2/month
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
href="/restore.php"
|
||||||
|
style=${{
|
||||||
|
fontFamily: 'JetBrains Mono, monospace',
|
||||||
|
fontSize: '10px',
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
letterSpacing: '0.14em',
|
||||||
|
color: '#c8922a',
|
||||||
|
textDecoration: 'none',
|
||||||
|
borderBottom: '1px solid rgba(200,146,42,0.4)',
|
||||||
|
paddingBottom: '1px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Already subscribed? Restore access →
|
||||||
</a>
|
</a>
|
||||||
<button
|
<button
|
||||||
onClick=${() => setProPromptDay(null)}
|
onClick=${() => setProPromptDay(null)}
|
||||||
|
|||||||
+194
@@ -0,0 +1,194 @@
|
|||||||
|
<?php
|
||||||
|
// ─── SunScope Extra — Restore Access ─────────────────────────────────────────
|
||||||
|
// Drop this file on your VPS alongside index.html.
|
||||||
|
// Paste your Stripe SECRET key below (never the publishable key).
|
||||||
|
// Swap sk_test_... for sk_live_... when you go live.
|
||||||
|
|
||||||
|
define('STRIPE_SECRET_KEY', 'sk_test_51TD27GEwik8ohlUdzs6LZx9mu3NOIERBnlzPoOcdWA1wfxM8YqVnIEowEpCJI7nMIrXNZuPMbwnLz9CgYbUrORkV00Yzf1MfVI');
|
||||||
|
define('SUNSCOPE_URL', 'https://sunscope.net');
|
||||||
|
|
||||||
|
$error = '';
|
||||||
|
$success = false;
|
||||||
|
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||||
|
$email = trim(strtolower($_POST['email'] ?? ''));
|
||||||
|
|
||||||
|
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
|
||||||
|
$error = 'Please enter a valid email address.';
|
||||||
|
} else {
|
||||||
|
// Search Stripe for a customer with this email
|
||||||
|
$ch = curl_init('https://api.stripe.com/v1/customers?email=' . urlencode($email) . '&limit=5');
|
||||||
|
curl_setopt_array($ch, [
|
||||||
|
CURLOPT_RETURNTRANSFER => true,
|
||||||
|
CURLOPT_USERPWD => STRIPE_SECRET_KEY . ':',
|
||||||
|
]);
|
||||||
|
$response = json_decode(curl_exec($ch), true);
|
||||||
|
curl_close($ch);
|
||||||
|
|
||||||
|
$isActive = false;
|
||||||
|
|
||||||
|
if (!empty($response['data'])) {
|
||||||
|
foreach ($response['data'] as $customer) {
|
||||||
|
// Check subscriptions for this customer
|
||||||
|
$ch2 = curl_init('https://api.stripe.com/v1/subscriptions?customer=' . $customer['id'] . '&status=active&limit=5');
|
||||||
|
curl_setopt_array($ch2, [
|
||||||
|
CURLOPT_RETURNTRANSFER => true,
|
||||||
|
CURLOPT_USERPWD => STRIPE_SECRET_KEY . ':',
|
||||||
|
]);
|
||||||
|
$subs = json_decode(curl_exec($ch2), true);
|
||||||
|
curl_close($ch2);
|
||||||
|
|
||||||
|
if (!empty($subs['data'])) {
|
||||||
|
$isActive = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($isActive) {
|
||||||
|
// Active subscriber — redirect with pro=1
|
||||||
|
header('Location: ' . SUNSCOPE_URL . '/?pro=1');
|
||||||
|
exit;
|
||||||
|
} else {
|
||||||
|
$error = 'No active SunScope Extra subscription found for that email.';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
?>
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Restore Access · SunScope Extra</title>
|
||||||
|
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>☀️</text></svg>" />
|
||||||
|
<link href="https://fonts.bunny.net/css2?family=Fraunces:ital,wght@0,900;1,300&family=Manrope:wght@400;600&family=JetBrains+Mono:wght@400;600&display=swap" rel="stylesheet" />
|
||||||
|
<style>
|
||||||
|
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
body {
|
||||||
|
font-family: Manrope, sans-serif;
|
||||||
|
background: #f5edd6;
|
||||||
|
color: #1e1208;
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 2rem;
|
||||||
|
}
|
||||||
|
.card {
|
||||||
|
background: #fffcf2;
|
||||||
|
border: 1.5px solid #c9b08a;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 2.5rem 2.5rem 2rem;
|
||||||
|
max-width: 420px;
|
||||||
|
width: 100%;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.logo {
|
||||||
|
font-family: Fraunces, serif;
|
||||||
|
font-size: 2rem;
|
||||||
|
line-height: 1;
|
||||||
|
margin-bottom: 0.4rem;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
}
|
||||||
|
.logo-sun { color: #c8922a; font-weight: 900; font-style: normal; }
|
||||||
|
.logo-scope { color: #1e1208; font-weight: 300; font-style: italic; }
|
||||||
|
.tagline {
|
||||||
|
font-family: Fraunces, serif;
|
||||||
|
font-style: italic;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
color: #7a5c2a;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
h1 {
|
||||||
|
font-family: Fraunces, serif;
|
||||||
|
font-weight: 900;
|
||||||
|
font-size: 1.15rem;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
p {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: #4a3420;
|
||||||
|
line-height: 1.6;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
input[type="email"] {
|
||||||
|
width: 100%;
|
||||||
|
padding: 10px 14px;
|
||||||
|
border: 1.5px solid #c9b08a;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-family: Manrope, sans-serif;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
background: #fff;
|
||||||
|
color: #1e1208;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
input[type="email"]:focus { border-color: #c8922a; }
|
||||||
|
button {
|
||||||
|
width: 100%;
|
||||||
|
padding: 11px;
|
||||||
|
background: #c8922a;
|
||||||
|
color: #fff;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-family: 'JetBrains Mono', monospace;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.12em;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
button:hover { background: #a06e18; }
|
||||||
|
.error {
|
||||||
|
margin-top: 1rem;
|
||||||
|
padding: 10px 14px;
|
||||||
|
background: #fdf0e0;
|
||||||
|
border: 1px solid #c9b08a;
|
||||||
|
border-left: 3px solid #c8922a;
|
||||||
|
border-radius: 3px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: #4a3420;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
.back {
|
||||||
|
margin-top: 1.5rem;
|
||||||
|
font-family: 'JetBrains Mono', monospace;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.1em;
|
||||||
|
}
|
||||||
|
.back a { color: #b09870; text-decoration: none; }
|
||||||
|
.back a:hover { color: #c8922a; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="card">
|
||||||
|
<div class="logo">
|
||||||
|
<span class="logo-sun">SUN</span><span class="logo-scope">Scope</span>
|
||||||
|
</div>
|
||||||
|
<div class="tagline">See the sun the way your body does.</div>
|
||||||
|
|
||||||
|
<h1>Restore your access</h1>
|
||||||
|
<p>Enter the email address you used to subscribe and we'll unlock SunScope Extra on this browser.</p>
|
||||||
|
|
||||||
|
<form method="POST" action="">
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
name="email"
|
||||||
|
placeholder="your@email.com"
|
||||||
|
value="<?= htmlspecialchars($_POST['email'] ?? '') ?>"
|
||||||
|
required
|
||||||
|
autofocus
|
||||||
|
/>
|
||||||
|
<button type="submit">Restore access →</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<?php if ($error): ?>
|
||||||
|
<div class="error"><?= htmlspecialchars($error) ?></div>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<div class="back"><a href="/">← Back to SunScope</a></div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Reference in New Issue
Block a user