diff --git a/docker-compose.yml b/docker-compose.yml index 1a5b3b7..f0c7277 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -23,6 +23,7 @@ services: POCKETBASE_URL: http://pocketbase:8090 SCREENSHOT_URL: http://screenshot:8002 OLLAMA_URL: http://host.docker.internal:11434 + R5_URL: http://r5:8003 depends_on: pocketbase: condition: service_healthy @@ -84,12 +85,32 @@ services: retries: 3 start_period: 5s + r5: + build: ./r5-service + ports: + - "8003:8003" + networks: + - dev-network + volumes: + - ./property-data/transit:/data/transit + - r5-cache:/root/.cache/r5py + environment: + TRANSIT_DATA_DIR: /data/transit + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8003/health"] + interval: 10s + timeout: 5s + retries: 3 + start_period: 600s + init: true + volumes: pb-data: cargo-registry: cargo-target: frontend-node-modules: screenshot-cache: + r5-cache: networks: dev-network: diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index c74acc1..11b4bd7 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,7 +1,6 @@ import { useState, useEffect, useCallback, useMemo } from 'react'; import MapPage, { type ExportState } from './components/map/MapPage'; -import DataSourcesPage from './components/data-sources/DataSourcesPage'; -import FAQPage from './components/faq/FAQPage'; +import LearnPage from './components/learn/LearnPage'; import PricingPage from './components/pricing/PricingPage'; import HomePage from './components/home/HomePage'; import SavedSearchesPage from './components/saved-searches/SavedSearchesPage'; @@ -27,10 +26,8 @@ function pageToPath(page: Page): string { switch (page) { case 'dashboard': return '/dashboard'; - case 'data-sources': - return '/data-sources'; - case 'faq': - return '/faq'; + case 'learn': + return '/learn'; case 'saved-searches': return '/saved'; case 'pricing': @@ -42,8 +39,7 @@ function pageToPath(page: Page): string { 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 === '/learn') return 'learn'; if (pathname === '/saved') return 'saved-searches'; if (pathname === '/pricing') return 'pricing'; if (pathname === '/') return 'home'; @@ -85,7 +81,7 @@ export default function App() { // Backward compat: dashboard params on unknown path const params = new URLSearchParams(window.location.search); - if (params.has('v') || params.has('f') || params.has('poi') || params.has('tab')) { + if (params.has('lat') || params.has('filter') || params.has('poi') || params.has('tab') || params.has('v') || params.has('f')) { // Rewrite URL to /dashboard keeping query params window.history.replaceState({ page: 'dashboard' }, '', `/dashboard${window.location.search}`); return 'dashboard'; @@ -240,10 +236,8 @@ export default function App() { /> {activePage === 'home' ? ( navigateTo('dashboard')} onOpenPricing={() => navigateTo('pricing')} theme={theme} features={features} /> - ) : activePage === 'data-sources' ? ( - - ) : activePage === 'faq' ? ( - + ) : activePage === 'learn' ? ( + ) : activePage === 'pricing' ? ( navigateTo('dashboard')} /> ) : activePage === 'saved-searches' ? ( diff --git a/frontend/src/components/map/PostcodeSearch.tsx b/frontend/src/components/map/PostcodeSearch.tsx index 93c46bd..20a1426 100644 --- a/frontend/src/components/map/PostcodeSearch.tsx +++ b/frontend/src/components/map/PostcodeSearch.tsx @@ -1,6 +1,8 @@ -import { useState, useCallback } from 'react'; +import { useState, useCallback, useRef, useEffect } from 'react'; import type { PostcodeGeometry } from '../../types'; import { authHeaders } from '../../lib/api'; +import { useIsMobile } from '../../hooks/useIsMobile'; +import { SearchIcon } from '../ui/icons/SearchIcon'; export interface SearchedPostcode { postcode: string; @@ -17,6 +19,29 @@ export default function PostcodeSearch({ const [query, setQuery] = useState(''); const [error, setError] = useState(null); const [loading, setLoading] = useState(false); + const [expanded, setExpanded] = useState(false); + const isMobile = useIsMobile(); + const formRef = useRef(null); + const inputRef = useRef(null); + + // Close on outside click (mobile only) + useEffect(() => { + if (!isMobile || !expanded) return; + const handler = (e: MouseEvent) => { + if (formRef.current && !formRef.current.contains(e.target as Node)) { + setExpanded(false); + } + }; + document.addEventListener('mousedown', handler); + return () => document.removeEventListener('mousedown', handler); + }, [isMobile, expanded]); + + // Focus input when expanding on mobile + useEffect(() => { + if (isMobile && expanded) { + inputRef.current?.focus(); + } + }, [isMobile, expanded]); const handleSubmit = useCallback( async (e: React.FormEvent) => { @@ -41,19 +66,39 @@ export default function PostcodeSearch({ onFlyTo(json.latitude, json.longitude, 16); onPostcodeSearched?.({ postcode: json.postcode, geometry: json.geometry }); setQuery(''); + if (isMobile) setExpanded(false); } catch { setError('Lookup failed'); } finally { setLoading(false); } }, - [query, onFlyTo, onPostcodeSearched] + [query, onFlyTo, onPostcodeSearched, isMobile] ); + // Mobile collapsed state: just a search icon button + if (isMobile && !expanded) { + return ( + + ); + } + return ( -
+
{ diff --git a/frontend/src/components/ui/FeatureIcons.tsx b/frontend/src/components/ui/FeatureIcons.tsx index 220105a..564294e 100644 --- a/frontend/src/components/ui/FeatureIcons.tsx +++ b/frontend/src/components/ui/FeatureIcons.tsx @@ -32,11 +32,11 @@ export function FeatureActions({ active={isPinned} size="md" > - + {onAdd && ( onAdd(feature.name)} title="Add filter" size="md"> - + )} {onRemove && ( diff --git a/frontend/src/components/ui/Header.tsx b/frontend/src/components/ui/Header.tsx index 465b18e..3cba4c5 100644 --- a/frontend/src/components/ui/Header.tsx +++ b/frontend/src/components/ui/Header.tsx @@ -1,5 +1,6 @@ import { useState, useCallback, useEffect } from 'react'; import type { AuthUser } from '../../hooks/useAuth'; +import { shortenUrl } from '../../lib/api'; import { DownloadIcon } from './icons/DownloadIcon'; import { BookmarkIcon } from './icons/BookmarkIcon'; import { LogoIcon } from './icons/LogoIcon'; @@ -12,7 +13,7 @@ import { SpinnerIcon } from './icons/SpinnerIcon'; import UserMenu from './UserMenu'; import MobileMenu from './MobileMenu'; -export type Page = 'home' | 'dashboard' | 'data-sources' | 'faq' | 'saved-searches' | 'pricing'; +export type Page = 'home' | 'dashboard' | 'saved-searches' | 'pricing'; export default function Header({ activePage, @@ -44,6 +45,7 @@ export default function Header({ isMobile: boolean; }) { const [copied, setCopied] = useState(false); + const [sharing, setSharing] = useState(false); const [menuOpen, setMenuOpen] = useState(false); // Close menu on Escape @@ -61,17 +63,16 @@ export default function Header({ if (!isMobile) setMenuOpen(false); }, [isMobile]); - const handleShare = useCallback(() => { - const url = window.location.href; + const copyToClipboard = useCallback((text: string) => { const onSuccess = () => { setCopied(true); setTimeout(() => setCopied(false), 2000); }; if (navigator.clipboard?.writeText) { - navigator.clipboard.writeText(url).then(onSuccess); + navigator.clipboard.writeText(text).then(onSuccess); } else { const ta = document.createElement('textarea'); - ta.value = url; + ta.value = text; ta.style.position = 'fixed'; ta.style.opacity = '0'; document.body.appendChild(ta); @@ -82,6 +83,23 @@ export default function Header({ } }, []); + const handleShare = useCallback(async () => { + const params = window.location.search.replace(/^\?/, ''); + if (!params) { + copyToClipboard(window.location.href); + return; + } + setSharing(true); + try { + const shortUrl = await shortenUrl(params); + copyToClipboard(shortUrl); + } catch { + copyToClipboard(window.location.href); + } finally { + setSharing(false); + } + }, [copyToClipboard]); + const tabClass = (page: Page) => `px-3 py-1.5 rounded text-sm font-medium transition-colors ${ activePage === page @@ -98,7 +116,7 @@ export default function Header({ onClick={() => onPageChange('home')} > - Perfect Postcodes + Perfect Postcode {/* Desktop nav */} @@ -115,15 +133,6 @@ export default function Header({ Saved )} - - @@ -152,9 +161,15 @@ export default function Header({ )}