5.2.1
New welcome note & subscribe reminders
This commit is contained in:
@@ -899,8 +899,67 @@
|
|||||||
background: #f0e4c4;
|
background: #f0e4c4;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
padding: 10px 14px;
|
padding: 10px 14px;
|
||||||
|
margin: 0 0 10px;
|
||||||
|
}
|
||||||
|
.welcome-remember strong {
|
||||||
|
color: #c8922a;
|
||||||
|
}
|
||||||
|
.welcome-note {
|
||||||
|
font-family: Manrope, sans-serif;
|
||||||
|
font-size: 0.76rem;
|
||||||
|
line-height: 1.35;
|
||||||
|
color: #8a7250;
|
||||||
|
text-align: center;
|
||||||
|
margin: 0 0 14px;
|
||||||
|
}
|
||||||
|
/* Weekly free-tier nudge (WeeklyNudge.js) - reuses the .welcome-* card. */
|
||||||
|
.nudge-fact {
|
||||||
|
text-align: center;
|
||||||
margin: 0 0 18px;
|
margin: 0 0 18px;
|
||||||
}
|
}
|
||||||
|
.nudge-fact-icon {
|
||||||
|
display: block;
|
||||||
|
font-size: 2.2rem;
|
||||||
|
line-height: 1;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
.nudge-fact .welcome-title {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
.nudge-fact-body {
|
||||||
|
font-family: Manrope, sans-serif;
|
||||||
|
font-size: 0.88rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
color: #4a3420;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.nudge-links {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
.nudge-links a,
|
||||||
|
.nudge-links button {
|
||||||
|
font-family: Manrope, sans-serif;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 10px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.07em;
|
||||||
|
text-decoration: none;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
padding: 0 0 1px;
|
||||||
|
cursor: pointer;
|
||||||
|
color: #c8922a;
|
||||||
|
border-bottom: 1px solid rgba(200, 146, 42, 0.4);
|
||||||
|
}
|
||||||
|
.nudge-links .nudge-dismiss {
|
||||||
|
color: #b09870;
|
||||||
|
border-bottom-color: rgba(176, 152, 112, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
.welcome-steps {
|
.welcome-steps {
|
||||||
list-style: none;
|
list-style: none;
|
||||||
margin: 0 0 22px;
|
margin: 0 0 22px;
|
||||||
@@ -1002,6 +1061,30 @@
|
|||||||
.welcome-step-num {
|
.welcome-step-num {
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
}
|
}
|
||||||
|
.welcome-steps {
|
||||||
|
gap: 13px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
.welcome-intro {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
.welcome-remember {
|
||||||
|
font-size: 0.78rem;
|
||||||
|
padding: 8px 12px;
|
||||||
|
}
|
||||||
|
.welcome-note {
|
||||||
|
font-size: 0.72rem;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
.nudge-fact-icon {
|
||||||
|
font-size: 1.8rem;
|
||||||
|
}
|
||||||
|
.nudge-fact .welcome-title {
|
||||||
|
font-size: 1.05rem;
|
||||||
|
}
|
||||||
|
.nudge-fact-body {
|
||||||
|
font-size: 0.82rem;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.utci-fetch-time {
|
.utci-fetch-time {
|
||||||
text-align: right;
|
text-align: right;
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ import { DayTabs } from './components/DayTabs.js';
|
|||||||
import { ConfigPanel } from './components/ConfigPanel.js';
|
import { ConfigPanel } from './components/ConfigPanel.js';
|
||||||
import { WelcomeModal } from './components/WelcomeModal.js';
|
import { WelcomeModal } from './components/WelcomeModal.js';
|
||||||
import { RestoreModal } from './components/RestoreModal.js';
|
import { RestoreModal } from './components/RestoreModal.js';
|
||||||
|
import { WeeklyNudge } from './components/WeeklyNudge.js';
|
||||||
import { buildColumnDefs, airTempRgb, petAirTempRgb } from './tableColumns.js';
|
import { buildColumnDefs, airTempRgb, petAirTempRgb } from './tableColumns.js';
|
||||||
import { computeWhyFeelsLike, computeGlanceSummary, computeBestDay, bestDaysLabel, bestDaysHint } from './compute.js';
|
import { computeWhyFeelsLike, computeGlanceSummary, computeBestDay, bestDaysLabel, bestDaysHint } from './compute.js';
|
||||||
import { solarElevationDeg } from './physics.js';
|
import { solarElevationDeg } from './physics.js';
|
||||||
@@ -215,6 +216,7 @@ export function UTCIForecast() {
|
|||||||
visibleCols, setVisibleCols, toggleCol,
|
visibleCols, setVisibleCols, toggleCol,
|
||||||
showDecimals, toggleShowDecimals,
|
showDecimals, toggleShowDecimals,
|
||||||
welcomeOpen, closeWelcome, openWelcome,
|
welcomeOpen, closeWelcome, openWelcome,
|
||||||
|
weeklyNudgeOpen, closeWeeklyNudge,
|
||||||
restoreOpen, openRestore, closeRestore,
|
restoreOpen, openRestore, closeRestore,
|
||||||
panelOpen, openPanel, closePanel,
|
panelOpen, openPanel, closePanel,
|
||||||
showUnits, toggleShowUnits,
|
showUnits, toggleShowUnits,
|
||||||
@@ -1114,6 +1116,8 @@ export function UTCIForecast() {
|
|||||||
|
|
||||||
${welcomeOpen && html`<${WelcomeModal} onClose=${closeWelcome} />`}
|
${welcomeOpen && html`<${WelcomeModal} onClose=${closeWelcome} />`}
|
||||||
${restoreOpen && html`<${RestoreModal} onClose=${closeRestore} setIsPro=${setIsPro} />`}
|
${restoreOpen && html`<${RestoreModal} onClose=${closeRestore} setIsPro=${setIsPro} />`}
|
||||||
|
${weeklyNudgeOpen && !welcomeOpen && html`
|
||||||
|
<${WeeklyNudge} onClose=${closeWeeklyNudge} openRestore=${openRestore} />`}
|
||||||
|
|
||||||
${forecast && days.length > 0 && (() => {
|
${forecast && days.length > 0 && (() => {
|
||||||
const { mainLabel, mainConfigKey } = deriveProfileMain(activeProfile, outdoorsVariant);
|
const { mainLabel, mainConfigKey } = deriveProfileMain(activeProfile, outdoorsVariant);
|
||||||
|
|||||||
@@ -22,9 +22,9 @@ import { Wordmark } from './Wordmark.js';
|
|||||||
|
|
||||||
const html = htm.bind(h);
|
const html = htm.bind(h);
|
||||||
|
|
||||||
const SUBSCRIBE_URL = 'https://buy.stripe.com/9B63cw7vl8k15Ei9DQd7q00';
|
export const SUBSCRIBE_URL = 'https://buy.stripe.com/9B63cw7vl8k15Ei9DQd7q00';
|
||||||
const SUBSCRIBE_URL_ONEOFF = 'https://buy.stripe.com/14A14odTJ6bT0jY4jwd7q02';
|
export const SUBSCRIBE_URL_ONEOFF = 'https://buy.stripe.com/14A14odTJ6bT0jY4jwd7q02';
|
||||||
const MANAGE_URL = 'https://billing.stripe.com/p/login/9B63cw7vl8k15Ei9DQd7q00';
|
export const MANAGE_URL = 'https://billing.stripe.com/p/login/9B63cw7vl8k15Ei9DQd7q00';
|
||||||
|
|
||||||
export function SubscribeModal({ title, detail, onClose, openRestore }) {
|
export function SubscribeModal({ title, detail, onClose, openRestore }) {
|
||||||
// Dismiss on Esc.
|
// Dismiss on Esc.
|
||||||
|
|||||||
@@ -0,0 +1,112 @@
|
|||||||
|
// ------------------------------------------------------------------------
|
||||||
|
// components/WeeklyNudge.js - Friendly weekly reminder for free users.
|
||||||
|
//
|
||||||
|
// Shown at most once every 7 days to non-subscribers (never on the very
|
||||||
|
// first visit - the WelcomeModal owns that moment). Leads with a small
|
||||||
|
// sun-themed fact so it reads as a bit of sunshine trivia rather than a
|
||||||
|
// nag, then offers SunScope Extra.
|
||||||
|
//
|
||||||
|
// Reuses the .welcome-* overlay/card styling. Purely presentational - the
|
||||||
|
// open/close state lives in useAppState (weeklyNudgeOpen).
|
||||||
|
//
|
||||||
|
// Props:
|
||||||
|
// onClose - dismiss and stamp the "shown" date (7-day cooldown)
|
||||||
|
// openRestore - opens the "Already subscribed?" restore modal
|
||||||
|
// ------------------------------------------------------------------------
|
||||||
|
|
||||||
|
import { h } from '../../vendor/preact.js';
|
||||||
|
import { useEffect, useMemo } from '../../vendor/preact-hooks.js';
|
||||||
|
import htm from '../../vendor/htm.js';
|
||||||
|
import { Wordmark } from './Wordmark.js';
|
||||||
|
import { SUBSCRIBE_URL, SUBSCRIBE_URL_ONEOFF } from './SubscribeModal.js';
|
||||||
|
|
||||||
|
const html = htm.bind(h);
|
||||||
|
|
||||||
|
// One of these is picked at random each time the nudge appears, so a
|
||||||
|
// regular user gets a different bit of sun trivia week to week.
|
||||||
|
const SUN_FACTS = [
|
||||||
|
{
|
||||||
|
icon: '☀️',
|
||||||
|
title: 'Sunlight is eight minutes old',
|
||||||
|
body: html`Every ray that warms your face left the Sun eight and a half minutes ago and
|
||||||
|
crossed 93 million miles to get here. The least you can do is dress for it.`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: '🌡️',
|
||||||
|
title: 'Shade can feel 15°C cooler',
|
||||||
|
body: html`Air temperature barely changes when you step into shade — but what your body
|
||||||
|
feels can drop by ten or fifteen degrees. That gap is exactly what SunSoak measures.`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: '🧴',
|
||||||
|
title: 'Clouds are not sunscreen',
|
||||||
|
body: html`Up to 80% of UV sails straight through thin cloud, which is why overcast days
|
||||||
|
catch people out. SunScope's UV and sunburn-time columns keep score for you.`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: '🚗',
|
||||||
|
title: 'A parked car beats the desert',
|
||||||
|
body: html`On a mild 22°C afternoon a car's interior can pass 50°C in under an hour —
|
||||||
|
hotter than the Sahara. SunScope can show you that number before you park.`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: '💨',
|
||||||
|
title: 'Wind steals your warmth',
|
||||||
|
body: html`A brisk breeze can strip several degrees off how cold you feel without moving
|
||||||
|
the thermometer at all. Wind chill is baked into every SunSoak reading.`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: '🌅',
|
||||||
|
title: 'The golden hour is real',
|
||||||
|
body: html`Low sun travels through far more atmosphere, so early and late light is gentler
|
||||||
|
on skin — a good reason to check the hourly view before heading out.`,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export function WeeklyNudge({ onClose, openRestore }) {
|
||||||
|
const fact = useMemo(() => SUN_FACTS[Math.floor(Math.random() * SUN_FACTS.length)], []);
|
||||||
|
|
||||||
|
// 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="nudge-title"
|
||||||
|
onClick=${(e) => e.stopPropagation()} style=${{ maxWidth: '440px' }}>
|
||||||
|
<button class="welcome-close" aria-label="Close" onClick=${onClose}>×</button>
|
||||||
|
<div class="welcome-head">
|
||||||
|
<${Wordmark} />
|
||||||
|
<div class="welcome-kicker" style=${{ marginTop: '10px' }}>Your weekly ray of sun</div>
|
||||||
|
</div>
|
||||||
|
<div class="nudge-fact">
|
||||||
|
<span class="nudge-fact-icon" aria-hidden="true">${fact.icon}</span>
|
||||||
|
<h2 class="welcome-title" id="nudge-title">${fact.title}</h2>
|
||||||
|
<p class="nudge-fact-body">${fact.body}</p>
|
||||||
|
</div>
|
||||||
|
<p class="welcome-remember">
|
||||||
|
✨ <strong>SunScope Extra</strong> unlocks the 🔒 profiles — hiking, sailing, fishing,
|
||||||
|
photography, winter sports and the whole Work set — plus Show All and custom columns,
|
||||||
|
so you can build your own view. £2 a month.
|
||||||
|
</p>
|
||||||
|
<a class="welcome-cta" href=${SUBSCRIBE_URL} target="_blank" rel="noopener noreferrer"
|
||||||
|
style=${{ textDecoration: 'none', textAlign: 'center', marginBottom: '10px' }}>
|
||||||
|
Subscribe — £2/month
|
||||||
|
</a>
|
||||||
|
<div class="nudge-links">
|
||||||
|
<a href=${SUBSCRIBE_URL_ONEOFF} target="_blank" rel="noopener noreferrer">
|
||||||
|
Or make a one-off payment →
|
||||||
|
</a>
|
||||||
|
<button type="button" onClick=${() => { onClose(); openRestore(); }}>
|
||||||
|
Already subscribed? Restore access →
|
||||||
|
</button>
|
||||||
|
<button type="button" class="nudge-dismiss" onClick=${onClose}>
|
||||||
|
Maybe next week
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
@@ -21,23 +21,21 @@ const html = htm.bind(h);
|
|||||||
const STEPS = [
|
const STEPS = [
|
||||||
{
|
{
|
||||||
icon: '☀️',
|
icon: '☀️',
|
||||||
title: 'SunSoak is the number to watch',
|
title: 'Watch SunSoak',
|
||||||
body: html`Pick a <strong>Solar model</strong> (open, urban, beach…) in the SunSoak dropdown
|
body: html`It's how the sun actually feels, not just air temperature. Pick the
|
||||||
to match where you are. It shows what your body actually feels in the sun — not just the
|
<strong>Solar model</strong> that matches where you are.`,
|
||||||
air temperature.`,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: '🎯',
|
icon: '🎯',
|
||||||
title: 'Choose a profile',
|
title: 'Pick a profile',
|
||||||
body: html`Tap <strong>Places</strong>, <strong>Activities</strong> or <strong>Work</strong>
|
body: html`<strong>Places</strong>, <strong>Activities</strong> or <strong>Work</strong> —
|
||||||
to instantly load the columns that matter for your situation — like Beach, Cycling or
|
Beach, Cycling, Farming and more — loads the right columns instantly.`,
|
||||||
Farming.`,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: '⚙️',
|
icon: '⚙️',
|
||||||
title: 'Make it yours',
|
title: 'Make it yours',
|
||||||
body: html`Hit <strong>Edit columns</strong> to add or remove any data you like — UV and
|
body: html`Every profile brings its own columns — UV, sunburn time, vehicle interior, soil,
|
||||||
sunburn time, vehicle interior, soil, air quality and more.`,
|
air quality. <strong>Edit columns</strong> (Extra) lets you pick your own.`,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -59,9 +57,8 @@ export function WelcomeModal({ onClose }) {
|
|||||||
<h2 class="welcome-title" id="welcome-title">How to read the sky in 3 steps</h2>
|
<h2 class="welcome-title" id="welcome-title">How to read the sky in 3 steps</h2>
|
||||||
</div>
|
</div>
|
||||||
<p class="welcome-intro">
|
<p class="welcome-intro">
|
||||||
This isn't your usual weather forecast. SunScope goes deeper than temperature alone —
|
Not your usual forecast — SunScope shows how the weather affects
|
||||||
it shows how the environment affects you: sun exposure, wind chill, UV intensity, and more.
|
<strong>you</strong>: sun, wind chill, UV and more.
|
||||||
For the best results, pick the <strong>environment</strong> that matches where you'll be.
|
|
||||||
</p>
|
</p>
|
||||||
<ol class="welcome-steps">
|
<ol class="welcome-steps">
|
||||||
${STEPS.map((s, i) => html`
|
${STEPS.map((s, i) => html`
|
||||||
@@ -74,7 +71,11 @@ export function WelcomeModal({ onClose }) {
|
|||||||
</span>
|
</span>
|
||||||
</li>`)}
|
</li>`)}
|
||||||
</ol>
|
</ol>
|
||||||
<p class="welcome-remember">💾 Don't worry — SunScope remembers your choices for next time.</p>
|
<p class="welcome-remember">
|
||||||
|
✨ <strong>SunScope Extra</strong> unlocks the 🔒 profiles and custom columns,
|
||||||
|
£2/month — tap any locked one to subscribe.
|
||||||
|
</p>
|
||||||
|
<p class="welcome-note">💾 SunScope remembers your choices for next time.</p>
|
||||||
<button class="welcome-cta" onClick=${onClose}>Got it</button>
|
<button class="welcome-cta" onClick=${onClose}>Got it</button>
|
||||||
</div>
|
</div>
|
||||||
</div>`;
|
</div>`;
|
||||||
|
|||||||
@@ -626,6 +626,30 @@ export function useAppState() {
|
|||||||
};
|
};
|
||||||
const openWelcome = () => setWelcomeOpen(true);
|
const openWelcome = () => setWelcomeOpen(true);
|
||||||
|
|
||||||
|
// ── Weekly subscribe nudge (free users only) ─────────────────────────
|
||||||
|
// At most once every 7 days, and never on the first visit - the welcome
|
||||||
|
// popup owns that moment. The last-shown date lives in localStorage;
|
||||||
|
// subscribing (isPro) suppresses it entirely.
|
||||||
|
const NUDGE_KEY = 'sunscope_nudge_last';
|
||||||
|
const NUDGE_COOLDOWN = 7 * 24 * 60 * 60 * 1000;
|
||||||
|
const [weeklyNudgeOpen, setWeeklyNudgeOpen] = useState(false);
|
||||||
|
useEffect(() => {
|
||||||
|
if (isPro || welcomeOpen) return;
|
||||||
|
let last = 0;
|
||||||
|
try {
|
||||||
|
last = Number(localStorage.getItem(NUDGE_KEY)) || 0;
|
||||||
|
// First run for an existing user: start the clock, don't nudge yet.
|
||||||
|
if (!last) { localStorage.setItem(NUDGE_KEY, String(Date.now())); return; }
|
||||||
|
} catch (e) { return; }
|
||||||
|
if (Date.now() - last < NUDGE_COOLDOWN) return;
|
||||||
|
setWeeklyNudgeOpen(true);
|
||||||
|
track('weekly_nudge_shown');
|
||||||
|
}, [isPro, welcomeOpen]);
|
||||||
|
const closeWeeklyNudge = () => {
|
||||||
|
try { localStorage.setItem(NUDGE_KEY, String(Date.now())); } catch (e) { /* ignore */ }
|
||||||
|
setWeeklyNudgeOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
// Restore-access modal ("Already subscribed?").
|
// Restore-access modal ("Already subscribed?").
|
||||||
const [restoreOpen, setRestoreOpen] = useState(false);
|
const [restoreOpen, setRestoreOpen] = useState(false);
|
||||||
const openRestore = () => setRestoreOpen(true);
|
const openRestore = () => setRestoreOpen(true);
|
||||||
@@ -910,6 +934,7 @@ export function useAppState() {
|
|||||||
utciEnv, setUtciEnv: setUtciEnvAndSave,
|
utciEnv, setUtciEnv: setUtciEnvAndSave,
|
||||||
showDecimals, toggleShowDecimals,
|
showDecimals, toggleShowDecimals,
|
||||||
welcomeOpen, closeWelcome, openWelcome,
|
welcomeOpen, closeWelcome, openWelcome,
|
||||||
|
weeklyNudgeOpen, closeWeeklyNudge,
|
||||||
restoreOpen, openRestore, closeRestore,
|
restoreOpen, openRestore, closeRestore,
|
||||||
panelOpen, openPanel, closePanel,
|
panelOpen, openPanel, closePanel,
|
||||||
showUnits, toggleShowUnits,
|
showUnits, toggleShowUnits,
|
||||||
|
|||||||
Reference in New Issue
Block a user