// ------------------------------------------------------------------------ // hooks/useAppState.js - All state and side-effect logic for UTCIForecast. // // Extracted from app.js to keep the main component under the AI edit // safe-zone. This hook owns every useState, useEffect, useCallback and // useRef that app.js needs, returning them as a single flat object. // // Usage in app.js: // const state = useAppState(); // const { forecast, isPro, visibleCols, ... } = state; // // Reading order: // 1. Location + search // 2. Day-tab scroll refs // 3. Pro tier // 4. Profile + column visibility // 5. Skin, vehicle, indoor, pollen // 6. Table refs // 7. Column popup + table scroll hooks // 8. Geocoding search effect // 9. Computation - rows, days, current row // 10. Events - active, lens, selected-day // ------------------------------------------------------------------------ import { useState, useEffect, useRef, useCallback } from '../../vendor/preact-hooks.js'; import { FREE_DAYS, FILTER_PROFILES, OUTDOORS_VARIANTS, profileButtonOrder, variantIcons, activityVariantKeys, placeVariantKeys, workVariantKeys, UTCI_ENVIRONMENTS, VARIANT_DEFAULT_ENV, } from '../config.js'; import { utciCategory, VEHICLE_SPEEDS } from '../utils.js'; import { buildHourlyRows, aggregateRows } from '../compute.js'; import { useForecast } from './useForecast.js'; import { useColumnPopup } from './useColumnPopup.js'; import { useTableScroll } from './useTableScroll.js'; import { getActiveEvents, getLensEvent } from '../events.js'; function track(event, profile) { try { fetch('track.php', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(profile ? { event, profile } : { event }), }).catch(() => {}); } catch (e) { /* ignore */ } } // ── SHARE LINK PARAMS ─────────────────────────────────────────────────── // Read once at module load, before any state initialiser or effect runs. The // Stripe and dev-unlock effects each strip the query string on mount, so // anything read lazily later would already be gone. // // Everything is validated: a link is untrusted input, and these values feed // straight into the fetch URL and the column set. Anything unrecognised is // dropped silently and the normal localStorage / default path takes over. // // Note there is deliberately no `pro` param. Pro is granted only by a // Stripe-verified session_id or the server-checked dev token; a shareable link // must never become an unlock. Pro-only profiles and variants are therefore // accepted only for a visitor who is already Pro on this device. const SHARE_PARAMS = (() => { const out = {}; try { const p = new URLSearchParams(window.location.search); if (!p.has('lat') && !p.has('profile') && !p.has('day')) return out; const lat = parseFloat(p.get('lat')); const lon = parseFloat(p.get('lon')); if (Number.isFinite(lat) && Number.isFinite(lon) && lat >= -90 && lat <= 90 && lon >= -180 && lon <= 180) { // Strip control characters only - place names legitimately contain // spaces, hyphens, apostrophes and accents. const raw = (p.get('name') || '').replace(/[\u0000-\u001f\u007f]/g, '').trim(); out.location = { name: raw.slice(0, 60) || `${lat.toFixed(2)}°, ${lon.toFixed(2)}°`, lat, lon, country: (p.get('country') || '').slice(0, 2).toUpperCase() || '', }; } const alreadyPro = (() => { try { return localStorage.getItem('sunscope_pro') === '1'; } catch (e) { return false; } })(); const profile = p.get('profile'); if (profile && Object.prototype.hasOwnProperty.call(FILTER_PROFILES, profile) && (alreadyPro || !FILTER_PROFILES[profile].proOnly)) { out.profile = profile; } const variant = p.get('variant'); if (variant && Object.prototype.hasOwnProperty.call(OUTDOORS_VARIANTS, variant) && (alreadyPro || !OUTDOORS_VARIANTS[variant].proOnly)) { out.variant = variant; } const day = parseInt(p.get('day'), 10); if (Number.isInteger(day) && day >= 0 && day < FREE_DAYS) out.day = day; } catch (e) { /* malformed query string - ignore entirely */ } return out; })(); export function useAppState() { // ── 1. LOCATION ────────────────────────────────────────────────────── // Precedence: share link → last saved location → London. const [location, setLocation] = useState(() => { if (SHARE_PARAMS.location) return SHARE_PARAMS.location; try { const saved = localStorage.getItem('sunscope_last_location'); if (saved) return JSON.parse(saved); } catch (e) { /* ignore */ } return { name: 'London, England', lat: 51.509, lon: -0.126, country: 'GB' }; }); // Recent locations: the most recently viewed spots, so the user can // hop back with one click. The list is kept newest-first with the // current location at the front; the UI shows the others as chips. const [recentLocations, setRecentLocations] = useState(() => { try { const saved = localStorage.getItem('sunscope_recent_locations'); if (saved) return JSON.parse(saved); } catch (e) { /* ignore */ } return []; }); const locKey = (l) => `${l.lat.toFixed(3)},${l.lon.toFixed(3)}`; const setLocationAndSave = (loc) => { try { localStorage.setItem('sunscope_last_location', JSON.stringify(loc)); } catch (e) { /* ignore */ } setRecentLocations((prev) => { // Make sure the spot we're switching away from lands in the recent // list too - otherwise the very first switch drops it on the floor // since it was never added to recentLocations before now. const withCurrent = locKey(location) !== locKey(loc) ? [location, ...prev.filter((l) => locKey(l) !== locKey(location))] : prev; // Keep the current spot at the front and the 3 previous distinct // ones behind it (4 total, so 3 chips remain after excluding current). const next = [loc, ...withCurrent.filter((l) => locKey(l) !== locKey(loc))].slice(0, 4); try { localStorage.setItem('sunscope_recent_locations', JSON.stringify(next)); } catch (e) { /* ignore */ } return next; }); setLocation(loc); }; // ── GEOLOCATION ────────────────────────────────────────────────────── // Without this the first-time default is London for everyone on earth. // Never auto-prompted: a saved location always wins, and an unrequested // permission dialog on load is hostile. The pin button in the header is // the only entry point. const [locating, setLocating] = useState(false); const [locateError, setLocateError] = useState(null); const useMyLocation = () => { if (!navigator.geolocation) { setLocateError("This browser can't share your location."); return; } setLocating(true); setLocateError(null); navigator.geolocation.getCurrentPosition( (pos) => { // Named "My location" rather than a place name: Open-Meteo's geocoding // API is forward-only (?name=), with no reverse endpoint, and pulling // in a second provider just to label a pin isn't worth the extra host. // The header already prints the coordinates underneath the name. setLocationAndSave({ name: 'My location', lat: pos.coords.latitude, lon: pos.coords.longitude, country: '', }); setSelectedDay(0); setLocating(false); track('geolocate'); }, (err) => { setLocating(false); setLocateError( err.code === 1 ? 'Location permission denied.' : err.code === 3 ? 'Timed out finding your location.' : "Couldn't get your location." ); }, { timeout: 8000, maximumAge: 10 * 60 * 1000 } ); }; // Pro tier flag is read early so useForecast can pick its refresh cadence // (Pro: 15 min, free: 30 min). Full setup notes in the PRO TIER section below. // Initial state trusts only what's already in localStorage - a bare // ?session_id=... in the URL is verified against Stripe (see the effect // below) before it's ever allowed to flip this on, so pasting/guessing a // URL param can't grant free access. const [isPro, setIsPro] = useState(() => localStorage.getItem('sunscope_pro') === '1'); const { forecast, airQuality, aqHorizon, loading, error, now, fetchedAt, liveElev, normals, retry } = useForecast(location, isPro); // Just returned from a Stripe Payment Link: verify the checkout session // server-side (verify-session.php) before granting Pro. Also records // which kind of purchase it was (subscription vs one-off) and the Stripe // customer id, so the re-check effect below knows whether/how to follow up. useEffect(() => { const params = new URLSearchParams(window.location.search); const sessionId = params.get('session_id'); if (!sessionId) return; window.history.replaceState({}, '', window.location.pathname); fetch(`verify-session.php?session_id=${encodeURIComponent(sessionId)}`) .then((r) => r.json()) .then((data) => { if (!data.paid) return; try { localStorage.setItem('sunscope_pro', '1'); if (data.mode) localStorage.setItem('sunscope_pro_mode', data.mode); if (data.customer) localStorage.setItem('sunscope_pro_customer', data.customer); localStorage.setItem('sunscope_pro_checked_at', String(Date.now())); } catch (e) { /* ignore */ } setIsPro(true); }) .catch(() => {}); }, []); // Dev-only testing unlock: ?dev=, verified server-side against // DEV_UNLOCK_TOKEN in secrets.local.php (dev-unlock.php). Replaces the old // bare ?pro=1 trick - a guessed/copied URL with the wrong token does nothing. useEffect(() => { const params = new URLSearchParams(window.location.search); const devToken = params.get('dev'); if (!devToken) return; window.history.replaceState({}, '', window.location.pathname); fetch(`dev-unlock.php?token=${encodeURIComponent(devToken)}`) .then((r) => r.json()) .then((data) => { if (!data.ok) return; try { localStorage.setItem('sunscope_pro', '1'); localStorage.setItem('sunscope_pro_mode', 'dev'); localStorage.removeItem('sunscope_pro_customer'); localStorage.setItem('sunscope_pro_checked_at', String(Date.now())); } catch (e) { /* ignore */ } setIsPro(true); }) .catch(() => {}); }, []); // Subscribers (not one-off payers) can cancel in Stripe at any time, so // Pro access shouldn't stay granted forever once localStorage is set. // Re-check roughly once a day per visitor - one-off payments are skipped // entirely since that access is permanent by design. useEffect(() => { if (!isPro) return; const mode = (() => { try { return localStorage.getItem('sunscope_pro_mode'); } catch (e) { return null; } })(); if (mode !== 'subscription') return; const customer = (() => { try { return localStorage.getItem('sunscope_pro_customer'); } catch (e) { return null; } })(); if (!customer) return; const lastChecked = (() => { try { return Number(localStorage.getItem('sunscope_pro_checked_at')) || 0; } catch (e) { return 0; } })(); const RECHECK_MS = 24 * 60 * 60 * 1000; if (Date.now() - lastChecked < RECHECK_MS) return; fetch(`check-subscription.php?customer=${encodeURIComponent(customer)}`) .then((r) => r.json()) .then((data) => { try { localStorage.setItem('sunscope_pro_checked_at', String(Date.now())); } catch (e) { /* ignore */ } if (!data.active) { try { localStorage.removeItem('sunscope_pro'); localStorage.removeItem('sunscope_pro_mode'); localStorage.removeItem('sunscope_pro_customer'); } catch (e) { /* ignore */ } setIsPro(false); } }) .catch(() => {}); }, [isPro]); useEffect(() => { track('visit'); }, []); const [searchQuery, setSearchQuery] = useState(''); const [searchResults, setSearchResults] = useState([]); const [searching, setSearching] = useState(false); const [selectedDay, setSelectedDay] = useState(SHARE_PARAMS.day ?? 0); const [proPromptDay, setProPromptDay] = useState(null); const [proPromptSource, setProPromptSource] = useState('day'); // ── 2. DAY-TAB SCROLL ──────────────────────────────────────────────── const dayTabsRef = useRef(null); const [canScrollLeft, setCanScrollLeft] = useState(false); const [canScrollRight, setCanScrollRight] = useState(false); useEffect(() => { const el = dayTabsRef.current; if (!el) return; const update = () => { setCanScrollLeft(el.scrollLeft > 1); setCanScrollRight(el.scrollLeft + el.clientWidth < el.scrollWidth - 1); }; update(); el.addEventListener('scroll', update, { passive: true }); window.addEventListener('resize', update); let ro = null; if (typeof ResizeObserver !== 'undefined') { ro = new ResizeObserver(update); ro.observe(el); } return () => { el.removeEventListener('scroll', update); window.removeEventListener('resize', update); if (ro) ro.disconnect(); }; }, [forecast]); useEffect(() => { const el = dayTabsRef.current; if (!el) return; const activeTab = el.querySelector('.utci-day-tab.active'); if (!activeTab) return; const elRect = el.getBoundingClientRect(); const tabRect = activeTab.getBoundingClientRect(); if (tabRect.left < elRect.left + 8) { el.scrollBy({ left: tabRect.left - elRect.left - 24, behavior: 'smooth' }); } else if (tabRect.right > elRect.right - 8) { el.scrollBy({ left: tabRect.right - elRect.right + 24, behavior: 'smooth' }); } }, [selectedDay]); const scrollDayTabs = (dir) => { const el = dayTabsRef.current; if (!el) return; el.scrollBy({ left: dir * 200, behavior: 'smooth' }); }; // Drag-to-scroll for the day-tab strip, mirroring the hourly table's body // scroller (see useTableScroll). Unlike the table scroller, the draggable // surface here IS the clickable element (each day is a