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
+9 -125
View File
@@ -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`
<div style=${{
margin: '12px 0',
padding: '18px 22px',
background: '#fdf8ee',
border: '1.5px solid #c9b08a',
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>`;
<${SubscribeModal}
title=${promptCopy.title}
detail=${promptCopy.detail}
onClose=${() => setProPromptDay(null)}
openRestore=${openRestore}
/>`;
})()}
+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>`;
}