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 → - - -
-
`; + <${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` +
+ +
`; +} 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` +
+ +
`; +} 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` +
+ +
`; +} 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` +
+ SunScope +
`; +} 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 @@ - +
- + diff --git a/restore.php b/restore.php index 6b799a3..3124a7e 100644 --- a/restore.php +++ b/restore.php @@ -1,196 +1,59 @@ true, - CURLOPT_USERPWD => STRIPE_SECRET_KEY . ':', - ]); - $response = json_decode(curl_exec($ch), true); - curl_close($ch); +$email = trim(strtolower($_POST['email'] ?? '')); - $isActive = false; +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); - 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($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 (!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.'; - } + if (!$isActive) { + $error = 'No active SunScope Extra subscription found for that email.'; } } -?> - - - - - - Restore Access · SunScope Extra - - - - - -
- -
See the sun the way your skin does.
-

Restore your access

-

Enter the email address you used to subscribe and we'll unlock SunScope Extra on this browser.

- -
- - -
- - -
- - - -
- - +header('Content-Type: application/json'); +echo json_encode(['active' => $isActive, 'error' => $error]);