diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index a769b85..3534056 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -4,14 +4,17 @@ import MapPage, { type ExportState } from './components/map/MapPage'; import DataSourcesPage from './components/data-sources/DataSourcesPage'; import FAQPage from './components/faq/FAQPage'; import HomePage from './components/home/HomePage'; +import SavedSearchesPage from './components/saved-searches/SavedSearchesPage'; import Header, { type Page } from './components/ui/Header'; import AuthModal from './components/ui/AuthModal'; +import SaveSearchModal from './components/ui/SaveSearchModal'; import type { FeatureMeta, FeatureGroup, POICategoriesResponse, POICategoryGroup } from './types'; import { fetchWithRetry, apiUrl } from './lib/api'; import { parseUrlState } from './lib/url-state'; import { INITIAL_VIEW_STATE } from './lib/consts'; import { useTheme } from './hooks/useTheme'; import { useAuth } from './hooks/useAuth'; +import { useSavedSearches } from './hooks/useSavedSearches'; declare global { interface Window { @@ -19,6 +22,25 @@ declare global { } } +function pageToPath(page: Page): string { + switch (page) { + case 'dashboard': return '/dashboard'; + case 'data-sources': return '/data-sources'; + case 'faq': return '/faq'; + case 'saved-searches': return '/saved'; + default: return '/'; + } +} + +function pathToPage(pathname: string): Page | null { + if (pathname === '/dashboard') return 'dashboard'; + if (pathname === '/data-sources') return 'data-sources'; + if (pathname === '/faq') return 'faq'; + if (pathname === '/saved') return 'saved-searches'; + if (pathname === '/') return 'home'; + return null; +} + export default function App() { const urlState = useMemo(() => parseUrlState(), []); const initialViewState = useMemo(() => urlState.viewState || INITIAL_VIEW_STATE, []); @@ -27,6 +49,10 @@ export default function App() { const params = new URLSearchParams(window.location.search); return params.get('screenshot') === '1'; }, []); + const isOgMode = useMemo(() => { + const params = new URLSearchParams(window.location.search); + return params.get('og') === '1'; + }, []); // Core data const [features, setFeatures] = useState([]); @@ -37,11 +63,27 @@ export default function App() { const [pendingInfoFeature, setPendingInfoFeature] = useState(null); const [activePage, setActivePage] = useState(() => { if (isScreenshotMode) return 'dashboard'; + + // Derive page from URL pathname + const fromPath = pathToPage(window.location.pathname); + if (fromPath) return fromPath; + + // Restore from history state (e.g. popstate) if (window.history.state?.page) return window.history.state.page; + + // Backward compat: dashboard params on unknown path const params = new URLSearchParams(window.location.search); - return params.has('v') || params.has('f') || params.has('poi') || params.has('tab') - ? 'dashboard' - : 'home'; + if (params.has('v') || params.has('f') || params.has('poi') || params.has('tab')) { + // Rewrite URL to /dashboard keeping query params + window.history.replaceState( + { page: 'dashboard' }, + '', + `/dashboard${window.location.search}` + ); + return 'dashboard'; + } + + return 'home'; }); const { theme, toggleTheme } = useTheme(); @@ -52,9 +94,14 @@ export default function App() { login, register, logout, + requestPasswordReset, clearError, } = useAuth(); const [showAuthModal, setShowAuthModal] = useState(false); + const [authModalTab, setAuthModalTab] = useState<'login' | 'register'>('login'); + + const savedSearches = useSavedSearches(user?.id ?? null); + const [showSaveModal, setShowSaveModal] = useState(false); // Load features and POI categories on mount useEffect(() => { @@ -92,21 +139,15 @@ export default function App() { return () => controller.abort(); }, []); - // Screenshot mode ready signal - useEffect(() => { - if (isScreenshotMode && !initialLoading && features.length > 0) { - window.__og_ready = true; - } - }, [isScreenshotMode, initialLoading, features]); + // Screenshot mode ready signal — MapPage sets __og_ready once map data loads // Navigation const navigateTo = useCallback((page: Page, hash?: string, infoFeature?: string) => { if (infoFeature) { window.history.replaceState({ ...window.history.state, infoFeature }, ''); } - const url = hash - ? `${window.location.pathname}${window.location.search}#${hash}` - : `${window.location.pathname}${window.location.search}`; + const path = pageToPath(page); + const url = hash ? `${path}#${hash}` : path; window.history.pushState({ page }, '', url); setActivePage(page); trackPageview(); @@ -114,7 +155,11 @@ export default function App() { useEffect(() => { if (!window.history.state?.page) { - window.history.replaceState({ page: activePage }, ''); + window.history.replaceState( + { page: activePage }, + '', + pageToPath(activePage) + window.location.search + window.location.hash + ); } const handlePopState = (e: PopStateEvent) => { if (e.state?.page) { @@ -122,12 +167,23 @@ export default function App() { if (e.state.infoFeature) { setPendingInfoFeature(e.state.infoFeature); } + } else { + // Fall back to deriving page from pathname + const page = pathToPage(window.location.pathname); + setActivePage(page || 'home'); } }; window.addEventListener('popstate', handlePopState); return () => window.removeEventListener('popstate', handlePopState); }, []); // eslint-disable-line react-hooks/exhaustive-deps + // Fetch saved searches when page becomes active + useEffect(() => { + if (activePage === 'saved-searches') { + savedSearches.fetchSearches(); + } + }, [activePage, savedSearches.fetchSearches]); + const [exportState, setExportState] = useState(null); if (isScreenshotMode) { @@ -145,6 +201,7 @@ export default function App() { onClearPendingInfoFeature={() => {}} onNavigateTo={() => {}} screenshotMode + ogMode={isOgMode} /> ); } @@ -158,8 +215,17 @@ export default function App() { onToggleTheme={toggleTheme} onExport={exportState?.onExport ?? null} exporting={exportState?.exporting ?? false} + onSaveSearch={activePage === 'dashboard' && user ? () => setShowSaveModal(true) : null} + savingSearch={savedSearches.saving} user={user} - onLoginClick={() => setShowAuthModal(true)} + onLoginClick={() => { + setAuthModalTab('login'); + setShowAuthModal(true); + }} + onRegisterClick={() => { + setAuthModalTab('register'); + setShowAuthModal(true); + }} onLogout={logout} /> {activePage === 'home' ? ( @@ -168,6 +234,15 @@ export default function App() { ) : activePage === 'faq' ? ( + ) : activePage === 'saved-searches' ? ( + { + window.location.href = `/?${params}`; + }} + /> ) : ( setShowAuthModal(false)} onLogin={login} onRegister={register} + onForgotPassword={requestPasswordReset} loading={authLoading} error={authError} onClearError={clearError} + initialTab={authModalTab} + /> + )} + {showSaveModal && ( + setShowSaveModal(false)} + onSave={savedSearches.saveSearch} + saving={savedSearches.saving} + error={savedSearches.error} /> )} diff --git a/frontend/src/components/map/AreaPane.tsx b/frontend/src/components/map/AreaPane.tsx index 692ee8d..a7855bb 100644 --- a/frontend/src/components/map/AreaPane.tsx +++ b/frontend/src/components/map/AreaPane.tsx @@ -1,7 +1,7 @@ import { useMemo, useState } from 'react'; import type { FeatureFilters, FeatureMeta, HexagonStatsResponse, PostcodeFeature } from '../../types'; import type { HexagonLocation } from '../../lib/external-search'; -import { formatValue, formatFilterValue, calculateHistogramMean, FEATURE_FORMATS } from '../../lib/format'; +import { formatValue, formatFilterValue, calculateHistogramMean } from '../../lib/format'; import { groupFeaturesByCategory } from '../../lib/features'; import { STACKED_GROUPS, STACKED_ENUM_GROUPS } from '../../lib/consts'; import { DualHistogram, LoadingSkeleton } from './DualHistogram'; @@ -11,6 +11,8 @@ import StackedEnumChart from './StackedEnumChart'; import PriceHistoryChart from './PriceHistoryChart'; import ExternalSearchLinks from './ExternalSearchLinks'; import { InfoIcon, CloseIcon } from '../ui/icons'; +import { CollapsibleGroupHeader } from '../ui/CollapsibleGroupHeader'; +import { LightbulbIcon } from '../ui/icons/LightbulbIcon'; import { IconButton } from '../ui/IconButton'; import { FeatureInfoPopup } from '../ui/FeatureInfoPopup'; import { EmptyState } from '../ui/EmptyState'; @@ -28,6 +30,10 @@ interface AreaPaneProps { hexagonLocation: HexagonLocation | null; filters: FeatureFilters; onNavigateToSource?: (slug: string, featureName: string) => void; + aiSummary?: string; + aiSummaryLoading?: boolean; + aiSummaryError?: string | null; + onRetryAiSummary?: () => void; } export default function AreaPane({ @@ -42,11 +48,24 @@ export default function AreaPane({ hexagonLocation, filters, onNavigateToSource, + aiSummary, + aiSummaryLoading, + aiSummaryError, + onRetryAiSummary, }: AreaPaneProps) { // For postcodes, use local data for count const propertyCount = isPostcode && postcodeData ? postcodeData.properties.count : stats?.count; const featureGroups = useMemo(() => groupFeaturesByCategory(globalFeatures), [globalFeatures]); const [infoFeature, setInfoFeature] = useState(null); + const [collapsedGroups, setCollapsedGroups] = useState>(new Set()); + + const toggleGroup = (name: string) => + setCollapsedGroups((prev) => { + const next = new Set(prev); + if (next.has(name)) next.delete(name); + else next.add(name); + return next; + }); const numericByName = useMemo(() => { if (!stats) return new Map(); @@ -78,12 +97,17 @@ export default function AreaPane({
-
-

- {isPostcode ? hexagonId : 'Area Statistics'} -

- {isPostcode && ( - Postcode +
+
+

+ {isPostcode ? hexagonId : 'Area Statistics'} +

+ {isPostcode && ( + Postcode + )} +
+ {loading && stats && ( +
)}
@@ -109,17 +133,68 @@ export default function AreaPane({ )} + {/* AI Summary Card */} + {(aiSummary || aiSummaryLoading || aiSummaryError) && ( +
+
+
+ + AI Summary +
+ {aiSummaryError ? ( +
+ Failed to generate summary. + {onRetryAiSummary && ( + + )} +
+ ) : aiSummaryLoading ? ( +
+
+
+
+ ) : ( +

+ {aiSummary} +

+ )} +
+
+ )} +
{loading && !stats ? ( ) : stats ? ( -
- {stats.price_history && stats.price_history.length > 0 && ( -
- Price History - +
+ {/* Histogram color legend */} +
+
+
+
+ + Teal bars show the distribution in this selected area + +
+
+
+ + Gray bars show the overall distribution across all areas + +
+
+
+ + Dashed line indicates the global average + +
- )} +
{featureGroups.map((group) => { const hasData = group.features.some( (feature) => numericByName.has(feature.name) || enumByName.has(feature.name) @@ -136,12 +211,28 @@ export default function AreaPane({ ) as string[] ?? [] ); + const isExpanded = !collapsedGroups.has(group.name); + return (
-

- {group.name} -

-
+ toggleGroup(group.name)} + className="px-3 py-1.5 text-xs font-bold text-warm-500 bg-warm-50 dark:bg-warm-900 dark:text-warm-400 sticky top-0 hover:bg-warm-100 dark:hover:bg-warm-800" + /> + {isExpanded &&
+ {/* Price History in Property group */} + {group.name === 'Property' && stats.price_history && (() => { + // Only show chart if there are at least 2 unique years + const uniqueYears = new Set(stats.price_history.map(p => Math.floor(p.year))); + return uniqueYears.size > 1; + })() && ( +
+ Price History + +
+ )} {stackedCharts ? // Render stacked charts for this group stackedCharts.map((chart) => { @@ -218,7 +309,7 @@ export default function AreaPane({ className="mr-2" /> - {formatValue(numericStats.mean, FEATURE_FORMATS[feature.name])} + {formatValue(numericStats.mean, feature)}
{numericStats.histogram && ( @@ -343,10 +434,29 @@ export default function AreaPane({
); })} -
+
}
); })} + {/* Google Street View */} + {hexagonLocation && ( +
+
+ Street View +
+
+
+