Move info boxes to model popups
This commit is contained in:
fraxle
2026-06-11 18:12:04 +01:00
parent b41c4bd75c
commit 9a9684ba99
12 changed files with 615 additions and 306 deletions
+182
View File
@@ -868,6 +868,188 @@
text-transform: uppercase; text-transform: uppercase;
color: #7a5c2a; 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 { .utci-fetch-time {
text-align: right; text-align: right;
font-size: 0.72rem; font-size: 0.72rem;
+12
View File
@@ -34,6 +34,8 @@ import { getCellTagEvents, getUpcomingEvents } from './events.js';
import { POLLEN_TYPES, COL_DESCRIPTIONS, UTCI_ENVIRONMENTS } from './config.js'; import { POLLEN_TYPES, COL_DESCRIPTIONS, UTCI_ENVIRONMENTS } from './config.js';
import { useAppState } from './hooks/useAppState.js'; import { useAppState } from './hooks/useAppState.js';
import { DayTabs } from './components/DayTabs.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 { computeWhyFeelsLike, computeGlanceSummary } from './compute.js';
import { solarElevationDeg } from './physics.js'; import { solarElevationDeg } from './physics.js';
import { exportDayXls } from './export.js'; import { exportDayXls } from './export.js';
@@ -94,6 +96,8 @@ export function UTCIForecast() {
activeProfile, setActiveProfile, activateProfile, activeCols, activeProfile, setActiveProfile, activateProfile, activeCols,
visibleCols, setVisibleCols, toggleCol, visibleCols, setVisibleCols, toggleCol,
showDecimals, toggleShowDecimals, showDecimals, toggleShowDecimals,
welcomeOpen, closeWelcome, openWelcome,
restoreOpen, openRestore, closeRestore,
showUnits, toggleShowUnits, showUnits, toggleShowUnits,
tableInterval, setTableInterval, tableInterval, setTableInterval,
activityOptions, placeOptions, workOptions, activityOptions, placeOptions, workOptions,
@@ -613,10 +617,14 @@ export function UTCIForecast() {
</div> </div>
</div>`} </div>`}
${welcomeOpen && html`<${WelcomeModal} onClose=${closeWelcome} />`}
${restoreOpen && html`<${RestoreModal} onClose=${closeRestore} setIsPro=${setIsPro} />`}
${forecast && days.length > 0 && html`<${DayTabs} ${forecast && days.length > 0 && html`<${DayTabs}
days=${days} days=${days}
selectedDay=${selectedDay} setSelectedDay=${setSelectedDay} selectedDay=${selectedDay} setSelectedDay=${setSelectedDay}
isPro=${isPro} isPro=${isPro}
openRestore=${openRestore}
proPromptDay=${proPromptDay} setProPromptDay=${setProPromptDay} proPromptDay=${proPromptDay} setProPromptDay=${setProPromptDay}
proPromptSource=${proPromptSource} setProPromptSource=${setProPromptSource} proPromptSource=${proPromptSource} setProPromptSource=${setProPromptSource}
activeProfile=${activeProfile} activateProfile=${activateProfile} activeCols=${activeCols} 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 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 SunSoak dropdown adjusts the solar load for your surroundings switch it to match where you are
for the most accurate reading. for the most accurate reading.
${' '}<a onClick=${openWelcome}
style=${{ color: '#9a7d5a', borderBottom: '1px solid rgba(154,125,90,0.4)', paddingBottom: '1px', textDecoration: 'none', cursor: 'pointer' }}>
How it works
</a>
${isPro && html` ${isPro && html`
<div style=${{ marginTop: '10px', paddingTop: '10px', borderTop: '1px solid #d4c0a0' }}> <div style=${{ marginTop: '10px', paddingTop: '10px', borderTop: '1px solid #d4c0a0' }}>
SunScope Extra is active. SunScope Extra is active.
+9 -125
View File
@@ -11,6 +11,7 @@
// selectedDay - index of the active day tab // selectedDay - index of the active day tab
// setSelectedDay - setter for selectedDay // setSelectedDay - setter for selectedDay
// isPro - boolean Pro status // isPro - boolean Pro status
// openRestore - opens the "Already subscribed?" restore modal
// FREE_DAYS - number of free days // FREE_DAYS - number of free days
// proPromptDay - index of locked day that was clicked // proPromptDay - index of locked day that was clicked
// setProPromptDay - setter for proPromptDay // setProPromptDay - setter for proPromptDay
@@ -47,6 +48,7 @@ import htm from '../../vendor/htm.js';
import { confidenceBand } from '../utils.js'; import { confidenceBand } from '../utils.js';
import { FREE_DAYS, FILTER_PROFILES, OUTDOORS_VARIANTS, profileButtonOrder, activityVariantKeys, placeVariantKeys, workVariantKeys } from '../config.js'; import { FREE_DAYS, FILTER_PROFILES, OUTDOORS_VARIANTS, profileButtonOrder, activityVariantKeys, placeVariantKeys, workVariantKeys } from '../config.js';
import { CloudIcon, PrecipIcon, CustomSelect } from '../components.js'; import { CloudIcon, PrecipIcon, CustomSelect } from '../components.js';
import { SubscribeModal } from './SubscribeModal.js';
const html = htm.bind(h); const html = htm.bind(h);
@@ -80,6 +82,7 @@ export function DayTabs({
days, days,
selectedDay, setSelectedDay, selectedDay, setSelectedDay,
isPro, isPro,
openRestore,
proPromptDay, setProPromptDay, proPromptDay, setProPromptDay,
proPromptSource, setProPromptSource, proPromptSource, setProPromptSource,
activeProfile, activateProfile, activeCols, 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.', detail: 'Extra unlocks the full 14-day forecast, customisable columns, specialist profiles, and an ad-free view.',
}; };
return html` return html`
<div style=${{ <${SubscribeModal}
margin: '12px 0', title=${promptCopy.title}
padding: '18px 22px', detail=${promptCopy.detail}
background: '#fdf8ee', onClose=${() => setProPromptDay(null)}
border: '1.5px solid #c9b08a', openRestore=${openRestore}
borderLeft: '4px solid #c8922a', />`;
borderRadius: '0 4px 4px 0',
display: 'flex',
flexWrap: 'wrap',
alignItems: 'center',
justifyContent: 'space-between',
gap: '14px',
}}>
<div style=${{ flex: '1 1 320px', minWidth: '260px' }}>
<div style=${{
fontFamily: 'Fraunces, serif',
fontStyle: 'italic',
fontSize: '19px',
fontWeight: 700,
color: '#1e1208',
marginBottom: '6px',
lineHeight: 1.25,
}}>
<span style=${{ fontStyle: 'normal' }}>🔒</span> ${promptCopy.title}
</div>
<div style=${{
fontFamily: 'Manrope, sans-serif',
fontSize: '13.5px',
color: '#4a3420',
lineHeight: 1.65,
}}>
${promptCopy.detail}
</div>
<div style=${{
fontFamily: 'Manrope, sans-serif',
fontWeight: 700,
fontSize: '11px',
textTransform: 'uppercase',
letterSpacing: '0.07em',
color: '#c8922a',
marginTop: '10px',
}}>
£2 / month · cancel any time
</div>
</div>
<div style=${{
display: 'flex',
flexDirection: 'column',
gap: '8px',
alignItems: 'flex-end',
}}>
<a
href="https://buy.stripe.com/9B63cw7vl8k15Ei9DQd7q00"
target="_blank"
rel="noopener noreferrer"
style=${{
display: 'inline-block',
padding: '10px 18px',
background: '#c8922a',
color: '#fff',
textDecoration: 'none',
fontFamily: 'Manrope, sans-serif',
fontWeight: 700,
fontSize: '11px',
textTransform: 'uppercase',
letterSpacing: '0.07em',
borderRadius: '3px',
whiteSpace: 'nowrap',
}}
>
Subscribe — £2/month
</a>
<a
href="/restore.php"
style=${{
fontFamily: 'Manrope, sans-serif',
fontWeight: 700,
fontSize: '10px',
textTransform: 'uppercase',
letterSpacing: '0.07em',
color: '#c8922a',
textDecoration: 'none',
borderBottom: '1px solid rgba(200,146,42,0.4)',
paddingBottom: '1px',
}}
>
Already subscribed? Restore access →
</a>
<a
href="https://billing.stripe.com/p/login/9B63cw7vl8k15Ei9DQd7q00"
target="_blank"
rel="noopener noreferrer"
style=${{
fontFamily: 'Manrope, sans-serif',
fontWeight: 700,
fontSize: '10px',
textTransform: 'uppercase',
letterSpacing: '0.07em',
color: '#b09870',
textDecoration: 'none',
borderBottom: '1px solid rgba(176,152,112,0.4)',
paddingBottom: '1px',
}}
>
Manage or cancel subscription →
</a>
<button
onClick=${() => 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
</button>
</div>
</div>`;
})()} })()}
+123
View File
@@ -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`
<div class="welcome-overlay" onClick=${onClose}>
<div class="welcome-card" role="dialog" aria-modal="true" aria-labelledby="restore-title"
onClick=${(e) => e.stopPropagation()} style=${{ maxWidth: '420px' }}>
<button class="welcome-close" aria-label="Close" onClick=${onClose}>×</button>
<div class="welcome-head">
<${Wordmark} />
<div style=${{
fontFamily: 'Manrope, sans-serif',
fontWeight: 600,
fontSize: '11px',
letterSpacing: '0.12em',
textTransform: 'uppercase',
color: '#7a5c2a',
marginTop: '10px',
}}>See the world the way your skin does.</div>
</div>
<h2 class="welcome-title" id="restore-title" style=${{ textAlign: 'center', fontSize: '1.3rem', marginBottom: '8px' }}>
Restore your access
</h2>
<p class="welcome-intro">
Enter the email you subscribed with and we'll unlock${' '}
<strong>SunScope Extra</strong> on this browser.
</p>
<form onSubmit=${submit}>
<input
type="email"
required
autofocus
placeholder="your@email.com"
value=${email}
onInput=${(e) => setEmail(e.target.value)}
style=${{
width: '100%',
padding: '11px 14px',
border: '1.5px solid #c9b08a',
borderRadius: '8px',
fontFamily: 'Manrope, sans-serif',
fontSize: '0.95rem',
background: '#fff',
color: '#1e1208',
marginBottom: '12px',
outline: 'none',
boxSizing: 'border-box',
}}
/>
${error && html`<div style=${{
padding: '10px 14px',
background: '#fdf0e0',
border: '1px solid #c9b08a',
borderLeft: '3px solid #c8922a',
borderRadius: '6px',
fontFamily: 'Manrope, sans-serif',
fontSize: '0.85rem',
color: '#4a3420',
marginBottom: '12px',
}}>${error}</div>`}
<button class="welcome-cta" type="submit" disabled=${busy}>
${busy ? 'Checking…' : 'Restore access →'}
</button>
</form>
</div>
</div>`;
}
+105
View File
@@ -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`
<div class="welcome-overlay" onClick=${onClose}>
<div class="welcome-card" role="dialog" aria-modal="true" aria-labelledby="subscribe-title"
onClick=${(e) => e.stopPropagation()} style=${{ maxWidth: '440px' }}>
<button class="welcome-close" aria-label="Close" onClick=${onClose}>×</button>
<div class="welcome-head">
<${Wordmark} />
<div style=${{
fontFamily: 'Manrope, sans-serif',
fontWeight: 600,
fontSize: '11px',
letterSpacing: '0.12em',
textTransform: 'uppercase',
color: '#7a5c2a',
marginTop: '10px',
}}>See the world the way your skin does.</div>
</div>
<h2 class="welcome-title" id="subscribe-title" style=${{ textAlign: 'center', fontSize: '1.3rem', marginBottom: '10px' }}>
<span aria-hidden="true">🔒</span> ${title}
</h2>
<p class="welcome-intro">${detail}</p>
<div style=${{
fontFamily: 'Manrope, sans-serif',
fontWeight: 700,
fontSize: '11px',
textTransform: 'uppercase',
letterSpacing: '0.07em',
color: '#c8922a',
textAlign: 'center',
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' }}>
Subscribe — £2/month
</a>
<div style=${{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '10px' }}>
<button
type="button"
onClick=${() => { onClose(); openRestore(); }}
style=${{
...secondaryLink,
color: '#c8922a',
borderBottom: '1px solid rgba(200,146,42,0.4)',
padding: '0 0 1px',
}}
>Already subscribed? Restore access →</button>
<a href=${MANAGE_URL} target="_blank" rel="noopener noreferrer"
style=${{
...secondaryLink,
color: '#b09870',
borderBottom: '1px solid rgba(176,152,112,0.4)',
paddingBottom: '1px',
}}
>Manage or cancel subscription →</a>
</div>
</div>
</div>`;
}
+81
View File
@@ -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 <strong>Solar model</strong> (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 <strong>Places</strong>, <strong>Activities</strong> or <strong>Work</strong>
to instantly load the columns that matter for your situation — like Beach, Cycling or
Farming.`,
},
{
icon: '⚙️',
title: 'Make it yours',
body: html`Hit <strong>Edit columns</strong> 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`
<div class="welcome-overlay" onClick=${onClose}>
<div class="welcome-card" role="dialog" aria-modal="true" aria-labelledby="welcome-title"
onClick=${(e) => e.stopPropagation()}>
<button class="welcome-close" aria-label="Close" onClick=${onClose}>×</button>
<div class="welcome-head">
<div class="welcome-kicker">Welcome to SunScope</div>
<h2 class="welcome-title" id="welcome-title">How to read the sky in 3 steps</h2>
</div>
<p class="welcome-intro">
This isn't your usual weather forecast. SunScope shows how the weather actually${' '}
<em>feels</em> — so for the best results, pick the <strong>environment</strong> that
matches where you'll be.
</p>
<ol class="welcome-steps">
${STEPS.map((s, i) => html`
<li class="welcome-step" key=${i}>
<span class="welcome-step-num">${i + 1}</span>
<span class="welcome-step-icon" aria-hidden="true">${s.icon}</span>
<span class="welcome-step-text">
<span class="welcome-step-title">${s.title}</span>
<span class="welcome-step-body">${s.body}</span>
</span>
</li>`)}
</ol>
<p class="welcome-remember">💾 Don't worry — SunScope remembers your choices for next time.</p>
<button class="welcome-cta" onClick=${onClose}>Got it</button>
</div>
</div>`;
}
+34
View File
@@ -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`
<div style=${{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontFamily: 'Manrope, sans-serif',
fontWeight: 250,
fontSize,
lineHeight: 1,
letterSpacing: '-0.01em',
color: '#1e1208',
}}>
<span>Sun</span><svg viewBox="0 0 174 173" aria-hidden="true" style=${{ width: '0.82em', height: '0.82em', flexShrink: 0 }}><g transform="matrix(0.986226,0,0,0.986226,-585.724252,-110.041209)"><clipPath id=${clipId}><circle cx="681.9838" cy="199.4502" r="83.2987"/></clipPath><g clip-path=${`url(#${clipId})`}><g transform="matrix(0.948836,0,0,0.948836,19.787839,-2.49381)"><path d="M666.992,233.0768C666.3949,230.6581 666.1785,228.6317 666.1785,226.0296C666.1785,208.6786 680.2653,194.5919 697.6163,194.5919C714.9672,194.5919 729.054,208.6786 729.054,226.0296C729.054,228.4125 728.9892,230.9346 728.4859,233.1663" fill="none" stroke="#c8922a" stroke-width="7.48"/></g><g transform="matrix(0.520349,0,0,0.520349,304.766642,64.226716)"><path d="M724.9315,291.8131C777.359,291.8131 826.5862,305.6245 869.1698,329.8044" fill="none" stroke="currentColor" stroke-width="13.64"/></g><g transform="matrix(0.520349,0,0,0.520349,304.766642,64.226716)"><path d="M580.8436,329.719C623.3927,305.592 672.5657,291.8131 724.9315,291.8131" fill="none" stroke="currentColor" stroke-width="13.64"/></g></g><circle cx="681.9838" cy="199.4502" r="83.2987" fill="none" stroke="currentColor" stroke-width="9.13"/></g></svg><span>Scope</span>
</div>`;
}
+1 -1
View File
@@ -29,7 +29,7 @@ export const FILTER_PROFILES = {
label: 'Basic', label: 'Basic',
icon: '🌡️', icon: '🌡️',
scene: "url('assets/images/profiles/basic.png') center / cover no-repeat, linear-gradient(160deg,#b8d8e8,#daeef8)", 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: { home: {
label: 'Home', label: 'Home',
+25
View File
@@ -297,6 +297,29 @@ export function useAppState() {
return next; 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(() => { const [showUnits, setShowUnits] = useState(() => {
try { try {
const s = localStorage.getItem('sunscope_show_units'); const s = localStorage.getItem('sunscope_show_units');
@@ -631,6 +654,8 @@ export function useAppState() {
pollenType, setPollenTypeAndSave, pollenType, setPollenTypeAndSave,
utciEnv, setUtciEnv: setUtciEnvAndSave, utciEnv, setUtciEnv: setUtciEnvAndSave,
showDecimals, toggleShowDecimals, showDecimals, toggleShowDecimals,
welcomeOpen, closeWelcome, openWelcome,
restoreOpen, openRestore, closeRestore,
showUnits, toggleShowUnits, showUnits, toggleShowUnits,
tableInterval, setTableInterval, tableInterval, setTableInterval,
// table refs // table refs
+1 -1
View File
@@ -18,7 +18,7 @@
} }
}, },
"2026-06-11": { "2026-06-11": {
"visits": 2, "visits": 15,
"profiles": [] "profiles": []
} }
} }
+2 -2
View File
@@ -70,10 +70,10 @@
<link rel="preconnect" href="https://api.open-meteo.com" crossorigin /> <link rel="preconnect" href="https://api.open-meteo.com" crossorigin />
<link rel="icon" type="image/svg+xml" href="./favicon.svg" /> <link rel="icon" type="image/svg+xml" href="./favicon.svg" />
<link rel="stylesheet" href="./assets/sunscope.css?v=15h"> <link rel="stylesheet" href="./assets/sunscope.css?v=15i">
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>
<script type="module" src="./assets/js/main.js?v=15c"></script> <script type="module" src="./assets/js/main.js?v=15e"></script>
</body> </body>
</html> </html>
+14 -151
View File
@@ -1,16 +1,23 @@
<?php <?php
// ─── SunScope Extra — Restore Access ───────────────────────────────────────── // ─── SunScope Extra — Restore Access ─────────────────────────────────────────
// Drop this file on your VPS alongside index.html. // JSON endpoint for the in-app "Already subscribed? Restore access" modal.
// The modal POSTs an email here and reads back {"active": bool, "error": str}.
// Paste your Stripe SECRET key below (never the publishable key). // Paste your Stripe SECRET key below (never the publishable key).
// Swap sk_test_... for sk_live_... when you go live. // Swap sk_test_... for sk_live_... when you go live.
define('STRIPE_SECRET_KEY', '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'); define('STRIPE_SECRET_KEY', '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');
define('SUNSCOPE_URL', 'https://sunscope.net'); define('SUNSCOPE_URL', 'https://sunscope.net');
$error = ''; // Direct visits no longer get a page — the restore flow is an in-app modal
$success = false; // that POSTs here and reads back JSON. Send stray GETs home.
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
header('Location: ' . SUNSCOPE_URL);
exit;
}
$error = '';
$isActive = false;
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$email = trim(strtolower($_POST['email'] ?? '')); $email = trim(strtolower($_POST['email'] ?? ''));
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) { if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
@@ -25,8 +32,6 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$response = json_decode(curl_exec($ch), true); $response = json_decode(curl_exec($ch), true);
curl_close($ch); curl_close($ch);
$isActive = false;
if (!empty($response['data'])) { if (!empty($response['data'])) {
foreach ($response['data'] as $customer) { foreach ($response['data'] as $customer) {
// Check subscriptions for this customer // Check subscriptions for this customer
@@ -45,152 +50,10 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
} }
} }
if ($isActive) { 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.'; $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: 'Manrope', sans-serif;
font-weight: 700;
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.07em;
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: 'Manrope', sans-serif;
font-weight: 700;
font-size: 0.7rem;
text-transform: uppercase;
letter-spacing: 0.06em;
}
.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 skin does.</div>
<h1>Restore your access</h1> header('Content-Type: application/json');
<p>Enter the email address you used to subscribe and we'll unlock SunScope Extra on this browser.</p> echo json_encode(['active' => $isActive, 'error' => $error]);
<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>