2.9.8
Move info boxes to model popups
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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() {
|
||||
</div>
|
||||
</div>`}
|
||||
|
||||
${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.
|
||||
${' '}<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`
|
||||
<div style=${{ marginTop: '10px', paddingTop: '10px', borderTop: '1px solid #d4c0a0' }}>
|
||||
SunScope Extra is active.
|
||||
|
||||
@@ -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}
|
||||
/>`;
|
||||
})()}
|
||||
|
||||
|
||||
|
||||
@@ -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>`;
|
||||
}
|
||||
@@ -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>`;
|
||||
}
|
||||
@@ -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>`;
|
||||
}
|
||||
@@ -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
@@ -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',
|
||||
|
||||
@@ -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
|
||||
|
||||
+1
-1
@@ -18,7 +18,7 @@
|
||||
}
|
||||
},
|
||||
"2026-06-11": {
|
||||
"visits": 2,
|
||||
"visits": 15,
|
||||
"profiles": []
|
||||
}
|
||||
}
|
||||
+2
-2
@@ -70,10 +70,10 @@
|
||||
|
||||
<link rel="preconnect" href="https://api.open-meteo.com" crossorigin />
|
||||
<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>
|
||||
<body>
|
||||
<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>
|
||||
</html>
|
||||
|
||||
+40
-177
@@ -1,196 +1,59 @@
|
||||
<?php
|
||||
// ─── 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).
|
||||
// Swap sk_test_... for sk_live_... when you go live.
|
||||
|
||||
define('STRIPE_SECRET_KEY', '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');
|
||||
define('SUNSCOPE_URL', 'https://sunscope.net');
|
||||
|
||||
$error = '';
|
||||
$success = false;
|
||||
// Direct visits no longer get a page — the restore flow is an in-app modal
|
||||
// that POSTs here and reads back JSON. Send stray GETs home.
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
header('Location: ' . SUNSCOPE_URL);
|
||||
exit;
|
||||
}
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
$email = trim(strtolower($_POST['email'] ?? ''));
|
||||
$error = '';
|
||||
$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);
|
||||
$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.';
|
||||
}
|
||||
}
|
||||
?>
|
||||
<!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>
|
||||
<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>
|
||||
header('Content-Type: application/json');
|
||||
echo json_encode(['active' => $isActive, 'error' => $error]);
|
||||
|
||||
Reference in New Issue
Block a user