diff --git a/assets/css/ui.css b/assets/css/ui.css
index 4d3cd7f..364bdeb 100644
--- a/assets/css/ui.css
+++ b/assets/css/ui.css
@@ -868,6 +868,188 @@
text-transform: uppercase;
color: #7a5c2a;
}
+
+/* ── Welcome / "How it works" popup ─────────────────────────────────── */
+.welcome-overlay {
+ position: fixed;
+ inset: 0;
+ background: rgba(0, 0, 0, 0.45);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ z-index: 10000;
+ padding: 20px;
+ animation: welcome-fade 0.18s ease-out;
+}
+@keyframes welcome-fade {
+ from { opacity: 0; }
+ to { opacity: 1; }
+}
+.welcome-card {
+ position: relative;
+ background: #fdf8ee;
+ border: 1.5px solid #c9b08a;
+ border-radius: 12px;
+ padding: 30px 34px;
+ width: 100%;
+ max-width: 480px;
+ max-height: 90vh;
+ overflow-y: auto;
+ box-shadow: 0 8px 32px rgba(0, 0, 0, 0.22);
+ animation: welcome-pop 0.28s cubic-bezier(0.34, 1.4, 0.64, 1);
+}
+@keyframes welcome-pop {
+ from { opacity: 0; transform: scale(0.94) translateY(8px); }
+ to { opacity: 1; transform: scale(1) translateY(0); }
+}
+.welcome-close {
+ position: absolute;
+ top: 12px;
+ right: 14px;
+ background: none;
+ border: none;
+ font-size: 26px;
+ line-height: 1;
+ color: #b09870;
+ cursor: pointer;
+ padding: 2px 6px;
+ border-radius: 6px;
+ transition: color 0.15s, background 0.15s;
+}
+.welcome-close:hover {
+ color: #1e1208;
+ background: rgba(176, 152, 112, 0.16);
+}
+.welcome-head {
+ text-align: center;
+ margin-bottom: 22px;
+}
+.welcome-kicker {
+ font-family: Manrope, sans-serif;
+ font-size: 11px;
+ font-weight: 700;
+ letter-spacing: 0.1em;
+ text-transform: uppercase;
+ color: #c8922a;
+ margin-bottom: 6px;
+}
+.welcome-title {
+ font-family: Fraunces, serif;
+ font-size: 1.5rem;
+ font-weight: 600;
+ color: #1e1208;
+ margin: 0;
+ line-height: 1.2;
+}
+.welcome-intro {
+ font-family: Manrope, sans-serif;
+ font-size: 0.9rem;
+ line-height: 1.5;
+ color: #4a3420;
+ text-align: center;
+ margin: 0 0 22px;
+}
+.welcome-intro em {
+ font-style: italic;
+ color: #6b4f2a;
+}
+.welcome-intro strong {
+ color: #c8922a;
+}
+.welcome-remember {
+ font-family: Manrope, sans-serif;
+ font-size: 0.82rem;
+ line-height: 1.4;
+ color: #6b4f2a;
+ text-align: center;
+ background: #f0e4c4;
+ border-radius: 8px;
+ padding: 10px 14px;
+ margin: 0 0 18px;
+}
+.welcome-steps {
+ list-style: none;
+ margin: 0 0 22px;
+ padding: 0;
+ display: flex;
+ flex-direction: column;
+ gap: 18px;
+}
+.welcome-step {
+ display: grid;
+ grid-template-columns: auto auto 1fr;
+ align-items: start;
+ gap: 12px;
+}
+.welcome-step-num {
+ font-family: Fraunces, serif;
+ font-size: 0.85rem;
+ font-weight: 700;
+ width: 24px;
+ height: 24px;
+ border-radius: 50%;
+ background: #c8922a;
+ color: #fffcf2;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ flex: 0 0 auto;
+ margin-top: 2px;
+}
+.welcome-step-icon {
+ font-size: 1.6rem;
+ line-height: 1;
+ margin-top: 1px;
+}
+.welcome-step-text {
+ display: flex;
+ flex-direction: column;
+ gap: 3px;
+}
+.welcome-step-title {
+ font-family: Manrope, sans-serif;
+ font-weight: 700;
+ font-size: 0.98rem;
+ color: #1e1208;
+}
+.welcome-step-body {
+ font-family: Manrope, sans-serif;
+ font-size: 0.88rem;
+ line-height: 1.45;
+ color: #4a3420;
+}
+.welcome-step-body strong {
+ color: #6b4f2a;
+}
+.welcome-cta {
+ display: block;
+ width: 100%;
+ font-family: Manrope, sans-serif;
+ font-weight: 700;
+ font-size: 0.95rem;
+ letter-spacing: 0.03em;
+ color: #fffcf2;
+ background: #c8922a;
+ border: none;
+ border-radius: 9px;
+ padding: 13px;
+ cursor: pointer;
+ transition: background 0.15s, transform 0.1s;
+}
+.welcome-cta:hover {
+ background: #b3801f;
+}
+.welcome-cta:active {
+ transform: translateY(1px);
+}
+@media (max-width: 800px) {
+ .welcome-card {
+ padding: 26px 20px;
+ }
+ .welcome-title {
+ font-size: 1.3rem;
+ }
+}
.utci-fetch-time {
text-align: right;
font-size: 0.72rem;
diff --git a/assets/js/app.js b/assets/js/app.js
index 40097a0..6b16e21 100644
--- a/assets/js/app.js
+++ b/assets/js/app.js
@@ -34,6 +34,8 @@ import { getCellTagEvents, getUpcomingEvents } from './events.js';
import { POLLEN_TYPES, COL_DESCRIPTIONS, UTCI_ENVIRONMENTS } from './config.js';
import { useAppState } from './hooks/useAppState.js';
import { DayTabs } from './components/DayTabs.js';
+import { WelcomeModal } from './components/WelcomeModal.js';
+import { RestoreModal } from './components/RestoreModal.js';
import { computeWhyFeelsLike, computeGlanceSummary } from './compute.js';
import { solarElevationDeg } from './physics.js';
import { exportDayXls } from './export.js';
@@ -94,6 +96,8 @@ export function UTCIForecast() {
activeProfile, setActiveProfile, activateProfile, activeCols,
visibleCols, setVisibleCols, toggleCol,
showDecimals, toggleShowDecimals,
+ welcomeOpen, closeWelcome, openWelcome,
+ restoreOpen, openRestore, closeRestore,
showUnits, toggleShowUnits,
tableInterval, setTableInterval,
activityOptions, placeOptions, workOptions,
@@ -613,10 +617,14 @@ export function UTCIForecast() {
`}
+ ${welcomeOpen && html`<${WelcomeModal} onClose=${closeWelcome} />`}
+ ${restoreOpen && html`<${RestoreModal} onClose=${closeRestore} setIsPro=${setIsPro} />`}
+
${forecast && days.length > 0 && html`<${DayTabs}
days=${days}
selectedDay=${selectedDay} setSelectedDay=${setSelectedDay}
isPro=${isPro}
+ openRestore=${openRestore}
proPromptDay=${proPromptDay} setProPromptDay=${setProPromptDay}
proPromptSource=${proPromptSource} setProPromptSource=${setProPromptSource}
activeProfile=${activeProfile} activateProfile=${activateProfile} activeCols=${activeCols}
@@ -1331,6 +1339,10 @@ export function UTCIForecast() {
that gap can exceed 10 °C even at modest air temperatures. The environment modifier in the
SunSoak dropdown adjusts the solar load for your surroundings — switch it to match where you are
for the most accurate reading.
+ ${' '}
+ How it works →
+
${isPro && html`
SunScope Extra is active.
diff --git a/assets/js/components/DayTabs.js b/assets/js/components/DayTabs.js
index 6830316..a7cb69b 100644
--- a/assets/js/components/DayTabs.js
+++ b/assets/js/components/DayTabs.js
@@ -11,6 +11,7 @@
// selectedDay - index of the active day tab
// setSelectedDay - setter for selectedDay
// isPro - boolean Pro status
+// openRestore - opens the "Already subscribed?" restore modal
// FREE_DAYS - number of free days
// proPromptDay - index of locked day that was clicked
// setProPromptDay - setter for proPromptDay
@@ -47,6 +48,7 @@ import htm from '../../vendor/htm.js';
import { confidenceBand } from '../utils.js';
import { FREE_DAYS, FILTER_PROFILES, OUTDOORS_VARIANTS, profileButtonOrder, activityVariantKeys, placeVariantKeys, workVariantKeys } from '../config.js';
import { CloudIcon, PrecipIcon, CustomSelect } from '../components.js';
+import { SubscribeModal } from './SubscribeModal.js';
const html = htm.bind(h);
@@ -80,6 +82,7 @@ export function DayTabs({
days,
selectedDay, setSelectedDay,
isPro,
+ openRestore,
proPromptDay, setProPromptDay,
proPromptSource, setProPromptSource,
activeProfile, activateProfile, activeCols,
@@ -447,131 +450,12 @@ export function DayTabs({
detail: 'Extra unlocks the full 14-day forecast, customisable columns, specialist profiles, and an ad-free view.',
};
return html`
-
-
-
- 🔒 ${promptCopy.title}
-
-
- ${promptCopy.detail}
-
-
- £2 / month · cancel any time
-
-
-
-
- Subscribe — £2/month
-
-
- Already subscribed? Restore access →
-
-
- Manage or cancel subscription →
-
-
setProPromptDay(null)}
- style=${{
- background: 'transparent',
- border: 'none',
- cursor: 'pointer',
- fontFamily: 'Manrope, sans-serif',
- fontWeight: 700,
- fontSize: '10px',
- textTransform: 'uppercase',
- letterSpacing: '0.07em',
- color: '#b09870',
- padding: '2px 4px',
- }}
- >
- dismiss
-
-
-
`;
+ <${SubscribeModal}
+ title=${promptCopy.title}
+ detail=${promptCopy.detail}
+ onClose=${() => setProPromptDay(null)}
+ openRestore=${openRestore}
+ />`;
})()}
diff --git a/assets/js/components/RestoreModal.js b/assets/js/components/RestoreModal.js
new file mode 100644
index 0000000..7dced8b
--- /dev/null
+++ b/assets/js/components/RestoreModal.js
@@ -0,0 +1,123 @@
+// ------------------------------------------------------------------------
+// components/RestoreModal.js - "Already subscribed? Restore access" popup.
+//
+// Replaces the old standalone restore.php page. Collects the subscriber's
+// email, POSTs it to restore.php (now a JSON endpoint), and on an active
+// subscription flips the app into Pro mode in place - no navigation.
+//
+// Reuses the .welcome-* overlay/card styling; input/error styling is inline
+// to avoid new CSS. All open/close state lives in useAppState.
+//
+// Props:
+// onClose - called when the user dismisses (×, backdrop, or Esc)
+// setIsPro - state setter from useAppState; called with true on success
+// ------------------------------------------------------------------------
+
+import { h } from '../../vendor/preact.js';
+import { useEffect, useState } from '../../vendor/preact-hooks.js';
+import htm from '../../vendor/htm.js';
+import { Wordmark } from './Wordmark.js';
+
+const html = htm.bind(h);
+
+export function RestoreModal({ onClose, setIsPro }) {
+ const [email, setEmail] = useState('');
+ const [error, setError] = useState('');
+ const [busy, setBusy] = useState(false);
+
+ // Dismiss on Esc.
+ useEffect(() => {
+ const onKey = (e) => { if (e.key === 'Escape') onClose(); };
+ document.addEventListener('keydown', onKey);
+ return () => document.removeEventListener('keydown', onKey);
+ }, [onClose]);
+
+ const submit = async (e) => {
+ e.preventDefault();
+ if (busy) return;
+ setError('');
+ setBusy(true);
+ try {
+ const res = await fetch('/restore.php', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
+ body: new URLSearchParams({ email }),
+ });
+ const data = await res.json();
+ if (data.active) {
+ try { localStorage.setItem('sunscope_pro', '1'); } catch (err) { /* ignore */ }
+ setIsPro(true);
+ onClose();
+ return;
+ }
+ setError(data.error || 'No active SunScope Extra subscription found for that email.');
+ } catch (err) {
+ setError('Something went wrong. Please try again.');
+ }
+ setBusy(false);
+ };
+
+ return html`
+
+
e.stopPropagation()} style=${{ maxWidth: '420px' }}>
+
×
+
+ <${Wordmark} />
+
See the world the way your skin does.
+
+
+ Restore your access
+
+
+ Enter the email you subscribed with and we'll unlock${' '}
+ SunScope Extra on this browser.
+
+
+
+
`;
+}
diff --git a/assets/js/components/SubscribeModal.js b/assets/js/components/SubscribeModal.js
new file mode 100644
index 0000000..47e409f
--- /dev/null
+++ b/assets/js/components/SubscribeModal.js
@@ -0,0 +1,105 @@
+// ------------------------------------------------------------------------
+// components/SubscribeModal.js - SunScope Extra upsell popup.
+//
+// Replaces the old inline pro-prompt box. Shown when a free user taps a
+// locked day, profile, or feature. Presents the upsell copy plus Subscribe /
+// Restore / Manage links. Easy to dismiss: ×, backdrop click, or Esc.
+//
+// Reuses the .welcome-* overlay/card styling. Purely presentational - the
+// open/close state is proPromptDay in useAppState, dismissed via onClose.
+//
+// Props:
+// title - upsell heading (e.g. "Hiking is part of SunScope Extra")
+// detail - supporting paragraph
+// onClose - dismiss the prompt (clears proPromptDay)
+// openRestore - opens the "Already subscribed?" restore modal
+// ------------------------------------------------------------------------
+
+import { h } from '../../vendor/preact.js';
+import { useEffect } from '../../vendor/preact-hooks.js';
+import htm from '../../vendor/htm.js';
+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';
+
+export function SubscribeModal({ title, detail, onClose, openRestore }) {
+ // Dismiss on Esc.
+ useEffect(() => {
+ const onKey = (e) => { if (e.key === 'Escape') onClose(); };
+ document.addEventListener('keydown', onKey);
+ return () => document.removeEventListener('keydown', onKey);
+ }, [onClose]);
+
+ const secondaryLink = {
+ fontFamily: 'Manrope, sans-serif',
+ fontWeight: 700,
+ fontSize: '10px',
+ textTransform: 'uppercase',
+ letterSpacing: '0.07em',
+ textDecoration: 'none',
+ background: 'none',
+ border: 'none',
+ cursor: 'pointer',
+ };
+
+ return html`
+
+
e.stopPropagation()} style=${{ maxWidth: '440px' }}>
+
×
+
+ <${Wordmark} />
+
See the world the way your skin does.
+
+
+ 🔒 ${title}
+
+
${detail}
+
£2 / month · cancel any time
+
+ Subscribe — £2/month
+
+
+
{ onClose(); openRestore(); }}
+ style=${{
+ ...secondaryLink,
+ color: '#c8922a',
+ borderBottom: '1px solid rgba(200,146,42,0.4)',
+ padding: '0 0 1px',
+ }}
+ >Already subscribed? Restore access →
+
Manage or cancel subscription →
+
+
+
`;
+}
diff --git a/assets/js/components/WelcomeModal.js b/assets/js/components/WelcomeModal.js
new file mode 100644
index 0000000..e9886c0
--- /dev/null
+++ b/assets/js/components/WelcomeModal.js
@@ -0,0 +1,81 @@
+// ------------------------------------------------------------------------
+// components/WelcomeModal.js - First-visit onboarding popup.
+//
+// A friendly, dead-simple overlay that walks a new user through the three
+// key moves in plain language. Auto-shows once on first visit (gated by the
+// sunscope_welcome_seen flag in useAppState) and is reopenable from the
+// footer "How it works" link.
+//
+// Purely presentational - all open/close state lives in useAppState.
+//
+// Props:
+// onClose - called when the user dismisses (Got it, ×, backdrop, or Esc)
+// ------------------------------------------------------------------------
+
+import { h } from '../../vendor/preact.js';
+import { useEffect } from '../../vendor/preact-hooks.js';
+import htm from '../../vendor/htm.js';
+
+const html = htm.bind(h);
+
+const STEPS = [
+ {
+ icon: '☀️',
+ title: 'SunSoak is the number to watch',
+ body: html`Pick a
Solar model (open, urban, beach…) in the SunSoak dropdown
+ to match where you are. It shows what your body actually feels in the sun — not just the
+ air temperature.`,
+ },
+ {
+ icon: '🎯',
+ title: 'Choose a profile',
+ body: html`Tap
Places ,
Activities or
Work
+ to instantly load the columns that matter for your situation — like Beach, Cycling or
+ Farming.`,
+ },
+ {
+ icon: '⚙️',
+ title: 'Make it yours',
+ body: html`Hit
Edit columns to add or remove any data you like — UV and
+ sunburn time, vehicle interior, soil, air quality and more.`,
+ },
+];
+
+export function WelcomeModal({ onClose }) {
+ // Dismiss on Esc.
+ useEffect(() => {
+ const onKey = (e) => { if (e.key === 'Escape') onClose(); };
+ document.addEventListener('keydown', onKey);
+ return () => document.removeEventListener('keydown', onKey);
+ }, [onClose]);
+
+ return html`
+
+
e.stopPropagation()}>
+
×
+
+
Welcome to SunScope
+
How to read the sky in 3 steps
+
+
+ This isn't your usual weather forecast. SunScope shows how the weather actually${' '}
+ feels — so for the best results, pick the environment that
+ matches where you'll be.
+
+
+ ${STEPS.map((s, i) => html`
+
+ ${i + 1}
+ ${s.icon}
+
+ ${s.title}
+ ${s.body}
+
+ `)}
+
+
💾 Don't worry — SunScope remembers your choices for next time.
+
Got it
+
+
`;
+}
diff --git a/assets/js/components/Wordmark.js b/assets/js/components/Wordmark.js
new file mode 100644
index 0000000..edfba23
--- /dev/null
+++ b/assets/js/components/Wordmark.js
@@ -0,0 +1,34 @@
+// ------------------------------------------------------------------------
+// components/Wordmark.js - The SunScope sun-icon wordmark (Sun + icon +
+// Scope), sized for use inside modals.
+//
+// Mirrors the nav/header markup but self-contained: each instance gets a
+// unique clipPath id so multiple wordmarks can render on the page without
+// the SVG clip colliding. Size via the `fontSize` prop.
+// ------------------------------------------------------------------------
+
+import { h } from '../../vendor/preact.js';
+import { useState } from '../../vendor/preact-hooks.js';
+import htm from '../../vendor/htm.js';
+
+const html = htm.bind(h);
+
+let _wmCounter = 0; // bump per instance for a stable unique id
+
+export function Wordmark({ fontSize = '34px' }) {
+ const [clipId] = useState(() => 'ss-clip-wm-' + (++_wmCounter));
+ return html`
+
`;
+}
diff --git a/assets/js/config.js b/assets/js/config.js
index 57f30c0..d68f991 100644
--- a/assets/js/config.js
+++ b/assets/js/config.js
@@ -29,7 +29,7 @@ export const FILTER_PROFILES = {
label: 'Basic',
icon: '🌡️',
scene: "url('assets/images/profiles/basic.png') center / cover no-repeat, linear-gradient(160deg,#b8d8e8,#daeef8)",
- cols: { hour: true, air: true, rh: false, dew: false, wind: true, dir: false, cloud: true, sun: false, direct: false, diffuse: false, tmrt: false, delta: false, utci: false, uvA: false, uvB: false, burn: false, utciP: true, precip: true, precipProb: true, soilT: false, soilT6: false, soilM: false, concreteT: false, vehicleT: false, indoorT: false, managedT: false, vis: false, aqi: false, pollen: true, rh: true },
+ cols: { hour: true, air: true, rh: true, dew: false, wind: true, dir: false, cloud: true, sun: false, direct: false, diffuse: false, tmrt: false, delta: false, utci: false, uvA: false, uvB: false, burn: false, utciP: true, precip: true, precipProb: true, soilT: false, soilT6: false, soilM: false, concreteT: false, vehicleT: false, indoorT: false, managedT: false, vis: false, aqi: false, pollen: true },
},
home: {
label: 'Home',
diff --git a/assets/js/hooks/useAppState.js b/assets/js/hooks/useAppState.js
index caf3fe6..7074844 100644
--- a/assets/js/hooks/useAppState.js
+++ b/assets/js/hooks/useAppState.js
@@ -297,6 +297,29 @@ export function useAppState() {
return next;
});
+ // ── Welcome / "How it works" popup ───────────────────────────────────
+ // Auto-shows on first visit (no seen flag), can be reopened anytime via
+ // the footer link. Reopening does NOT clear the flag.
+ const [welcomeOpen, setWelcomeOpen] = useState(() => {
+ try { return localStorage.getItem('sunscope_welcome_seen') !== '1'; }
+ catch (e) { return false; }
+ });
+ useEffect(() => {
+ if (welcomeOpen && (() => { try { return localStorage.getItem('sunscope_welcome_seen') !== '1'; } catch (e) { return false; } })()) {
+ track('welcome_shown');
+ }
+ }, []);
+ const closeWelcome = () => {
+ try { localStorage.setItem('sunscope_welcome_seen', '1'); } catch (e) { /* ignore */ }
+ setWelcomeOpen(false);
+ };
+ const openWelcome = () => setWelcomeOpen(true);
+
+ // Restore-access modal ("Already subscribed?").
+ const [restoreOpen, setRestoreOpen] = useState(false);
+ const openRestore = () => setRestoreOpen(true);
+ const closeRestore = () => setRestoreOpen(false);
+
const [showUnits, setShowUnits] = useState(() => {
try {
const s = localStorage.getItem('sunscope_show_units');
@@ -631,6 +654,8 @@ export function useAppState() {
pollenType, setPollenTypeAndSave,
utciEnv, setUtciEnv: setUtciEnvAndSave,
showDecimals, toggleShowDecimals,
+ welcomeOpen, closeWelcome, openWelcome,
+ restoreOpen, openRestore, closeRestore,
showUnits, toggleShowUnits,
tableInterval, setTableInterval,
// table refs
diff --git a/data/tracking.json b/data/tracking.json
index 1361303..1efa5cf 100644
--- a/data/tracking.json
+++ b/data/tracking.json
@@ -18,7 +18,7 @@
}
},
"2026-06-11": {
- "visits": 2,
+ "visits": 15,
"profiles": []
}
}
\ No newline at end of file
diff --git a/index.html b/index.html
index 717bc2b..2938ca5 100644
--- a/index.html
+++ b/index.html
@@ -70,10 +70,10 @@
-
+
-
+