// ------------------------------------------------------------------------ // 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 */ } } export function useAppState() { // ── 1. LOCATION ────────────────────────────────────────────────────── const [location, setLocation] = useState(() => { 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); }; // Pro tier flag is read early so useForecast can pick its refresh cadence // (Pro: 5 min, free: 15 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, loading, error, now, fetchedAt, liveElev, normals } = 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(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