diff --git a/assets/css/ui.css b/assets/css/ui.css index 27ee8eb..ddae7f9 100644 --- a/assets/css/ui.css +++ b/assets/css/ui.css @@ -899,8 +899,67 @@ background: #f0e4c4; border-radius: 8px; 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; } +.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 { list-style: none; margin: 0 0 22px; @@ -1002,6 +1061,30 @@ .welcome-step-num { 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 { text-align: right; diff --git a/assets/js/app.js b/assets/js/app.js index 9c697ae..4b8f77c 100644 --- a/assets/js/app.js +++ b/assets/js/app.js @@ -39,6 +39,7 @@ import { DayTabs } from './components/DayTabs.js'; import { ConfigPanel } from './components/ConfigPanel.js'; import { WelcomeModal } from './components/WelcomeModal.js'; import { RestoreModal } from './components/RestoreModal.js'; +import { WeeklyNudge } from './components/WeeklyNudge.js'; import { buildColumnDefs, airTempRgb, petAirTempRgb } from './tableColumns.js'; import { computeWhyFeelsLike, computeGlanceSummary, computeBestDay, bestDaysLabel, bestDaysHint } from './compute.js'; import { solarElevationDeg } from './physics.js'; @@ -215,6 +216,7 @@ export function UTCIForecast() { visibleCols, setVisibleCols, toggleCol, showDecimals, toggleShowDecimals, welcomeOpen, closeWelcome, openWelcome, + weeklyNudgeOpen, closeWeeklyNudge, restoreOpen, openRestore, closeRestore, panelOpen, openPanel, closePanel, showUnits, toggleShowUnits, @@ -1114,6 +1116,8 @@ export function UTCIForecast() { ${welcomeOpen && html`<${WelcomeModal} onClose=${closeWelcome} />`} ${restoreOpen && html`<${RestoreModal} onClose=${closeRestore} setIsPro=${setIsPro} />`} + ${weeklyNudgeOpen && !welcomeOpen && html` + <${WeeklyNudge} onClose=${closeWeeklyNudge} openRestore=${openRestore} />`} ${forecast && days.length > 0 && (() => { const { mainLabel, mainConfigKey } = deriveProfileMain(activeProfile, outdoorsVariant); diff --git a/assets/js/components/SubscribeModal.js b/assets/js/components/SubscribeModal.js index a834113..133a2a1 100644 --- a/assets/js/components/SubscribeModal.js +++ b/assets/js/components/SubscribeModal.js @@ -22,9 +22,9 @@ import { Wordmark } from './Wordmark.js'; const html = htm.bind(h); -const SUBSCRIBE_URL = 'https://buy.stripe.com/9B63cw7vl8k15Ei9DQd7q00'; -const SUBSCRIBE_URL_ONEOFF = 'https://buy.stripe.com/14A14odTJ6bT0jY4jwd7q02'; -const MANAGE_URL = 'https://billing.stripe.com/p/login/9B63cw7vl8k15Ei9DQd7q00'; +export const SUBSCRIBE_URL = 'https://buy.stripe.com/9B63cw7vl8k15Ei9DQd7q00'; +export const SUBSCRIBE_URL_ONEOFF = 'https://buy.stripe.com/14A14odTJ6bT0jY4jwd7q02'; +export const MANAGE_URL = 'https://billing.stripe.com/p/login/9B63cw7vl8k15Ei9DQd7q00'; export function SubscribeModal({ title, detail, onClose, openRestore }) { // Dismiss on Esc. diff --git a/assets/js/components/WeeklyNudge.js b/assets/js/components/WeeklyNudge.js new file mode 100644 index 0000000..2080296 --- /dev/null +++ b/assets/js/components/WeeklyNudge.js @@ -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` +
`; +} diff --git a/assets/js/components/WelcomeModal.js b/assets/js/components/WelcomeModal.js index d72fce9..5d1a285 100644 --- a/assets/js/components/WelcomeModal.js +++ b/assets/js/components/WelcomeModal.js @@ -21,23 +21,21 @@ const html = htm.bind(h); const STEPS = [ { icon: 'βοΈ', - title: 'SunSoak is the number to watch', - body: html`Pick a Solar model (open, urban, beachβ¦) in the SunSoak dropdown - to match where you are. It shows what your body actually feels in the sun β not just the - air temperature.`, + title: 'Watch SunSoak', + body: html`It's how the sun actually feels, not just air temperature. Pick the + Solar model that matches where you are.`, }, { icon: 'π―', - title: 'Choose a profile', - body: html`Tap Places, Activities or Work - to instantly load the columns that matter for your situation β like Beach, Cycling or - Farming.`, + title: 'Pick a profile', + body: html`Places, Activities or Work β + Beach, Cycling, Farming and more β loads the right columns instantly.`, }, { icon: 'βοΈ', title: 'Make it yours', - body: html`Hit Edit columns to add or remove any data you like β UV and - sunburn time, vehicle interior, soil, air quality and more.`, + body: html`Every profile brings its own columns β UV, sunburn time, vehicle interior, soil, + air quality. Edit columns (Extra) lets you pick your own.`, }, ]; @@ -59,9 +57,8 @@ export function WelcomeModal({ onClose }) {- This isn't your usual weather forecast. SunScope goes deeper than temperature alone β - it shows how the environment affects you: sun exposure, wind chill, UV intensity, and more. - For the best results, pick the environment that matches where you'll be. + Not your usual forecast β SunScope shows how the weather affects + you: sun, wind chill, UV and more.
πΎ Don't worry β SunScope remembers your choices for next time.
++ β¨ SunScope Extra unlocks the π profiles and custom columns, + Β£2/month β tap any locked one to subscribe. +
+πΎ SunScope remembers your choices for next time.
`; diff --git a/assets/js/hooks/useAppState.js b/assets/js/hooks/useAppState.js index 8f48583..7a72e27 100644 --- a/assets/js/hooks/useAppState.js +++ b/assets/js/hooks/useAppState.js @@ -626,6 +626,30 @@ export function useAppState() { }; 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?"). const [restoreOpen, setRestoreOpen] = useState(false); const openRestore = () => setRestoreOpen(true); @@ -910,6 +934,7 @@ export function useAppState() { utciEnv, setUtciEnv: setUtciEnvAndSave, showDecimals, toggleShowDecimals, welcomeOpen, closeWelcome, openWelcome, + weeklyNudgeOpen, closeWeeklyNudge, restoreOpen, openRestore, closeRestore, panelOpen, openPanel, closePanel, showUnits, toggleShowUnits,