Good changes

This commit is contained in:
Andras Schmelczer 2026-02-10 22:09:46 +00:00
parent d39d1b15fd
commit 1f68ca0512
23 changed files with 670 additions and 289 deletions

View file

@ -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' ? (
<HomePage onOpenDashboard={() => navigateTo('dashboard')} onOpenPricing={() => navigateTo('pricing')} theme={theme} features={features} />
) : activePage === 'data-sources' ? (
<DataSourcesPage />
) : activePage === 'faq' ? (
<FAQPage />
) : activePage === 'learn' ? (
<LearnPage />
) : activePage === 'pricing' ? (
<PricingPage onOpenDashboard={() => navigateTo('dashboard')} />
) : activePage === 'saved-searches' ? (

View file

@ -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<string | null>(null);
const [loading, setLoading] = useState(false);
const [expanded, setExpanded] = useState(false);
const isMobile = useIsMobile();
const formRef = useRef<HTMLFormElement>(null);
const inputRef = useRef<HTMLInputElement>(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 (
<button
type="button"
onClick={() => setExpanded(true)}
className="absolute top-3 left-3 z-10 p-2 bg-white dark:bg-warm-800 rounded shadow-lg"
aria-label="Search postcode"
>
<SearchIcon className="w-5 h-5 text-warm-600 dark:text-warm-300" />
</button>
);
}
return (
<form onSubmit={handleSubmit} className="absolute top-3 left-3 z-10 flex flex-col gap-1">
<form
ref={formRef}
onSubmit={handleSubmit}
className="absolute top-3 left-3 z-10 flex flex-col gap-1"
>
<div className="flex shadow-lg rounded overflow-hidden">
<input
ref={inputRef}
type="text"
value={query}
onChange={(e) => {

View file

@ -32,11 +32,11 @@ export function FeatureActions({
active={isPinned}
size="md"
>
<EyeIcon filled={isPinned} />
<EyeIcon filled={isPinned} className="w-7 h-7 md:w-3.5 md:h-3.5" />
</IconButton>
{onAdd && (
<IconButton onClick={() => onAdd(feature.name)} title="Add filter" size="md">
<PlusIcon />
<PlusIcon className="w-7 h-7 md:w-3.5 md:h-3.5" />
</IconButton>
)}
{onRemove && (

View file

@ -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')}
>
<LogoIcon className="w-5 h-5 text-teal-400" />
<span className="font-semibold text-lg">Perfect Postcodes</span>
<span className="font-semibold text-lg">Perfect Postcode</span>
</button>
{/* Desktop nav */}
@ -115,15 +133,6 @@ export default function Header({
Saved
</button>
)}
<button
className={tabClass('data-sources')}
onClick={() => onPageChange('data-sources')}
>
Data Sources
</button>
<button className={tabClass('faq')} onClick={() => onPageChange('faq')}>
FAQ
</button>
<button className={tabClass('pricing')} onClick={() => onPageChange('pricing')}>
Pricing
</button>
@ -152,9 +161,15 @@ export default function Header({
)}
<button
onClick={handleShare}
className="flex items-center gap-1.5 px-3 py-1.5 rounded bg-navy-800 hover:bg-navy-700 transition-colors text-sm"
disabled={sharing}
className="flex items-center gap-1.5 px-3 py-1.5 rounded bg-navy-800 hover:bg-navy-700 transition-colors text-sm disabled:opacity-50 disabled:cursor-wait"
>
{copied ? (
{sharing ? (
<>
<SpinnerIcon className="w-4 h-4 animate-spin" />
Sharing...
</>
) : copied ? (
<>
<CheckIcon className="w-4 h-4" />
Copied!
@ -255,6 +270,13 @@ export default function Header({
copied={copied}
/>
)}
{/* Mobile "Copied" toast */}
{isMobile && copied && (
<div className="fixed top-14 left-1/2 -translate-x-1/2 z-[60] flex items-center gap-2 px-4 py-2 rounded-lg bg-navy-900 text-white text-sm shadow-lg animate-fade-in">
<CheckIcon className="w-4 h-4 text-teal-400" />
Copied to clipboard
</div>
)}
</header>
);
}

View file

@ -78,10 +78,9 @@ export default function MobileMenu({
</button>
</div>
<nav className="flex-1 flex flex-col gap-1 p-3 overflow-y-auto">
{mobileNavItem('home', 'Home')}
{mobileNavItem('dashboard', 'Dashboard')}
{user && mobileNavItem('saved-searches', 'Saved')}
{mobileNavItem('data-sources', 'Data Sources')}
{mobileNavItem('faq', 'FAQ')}
{mobileNavItem('pricing', 'Pricing')}
{/* Dashboard actions */}

View file

@ -0,0 +1,16 @@
import type { ReactNode } from 'react';
interface PillGroupProps {
children: ReactNode;
className?: string;
}
export function PillGroup({ children, className = '' }: PillGroupProps) {
return (
<div
className={`flex flex-nowrap overflow-x-auto gap-1.5 md:flex-wrap md:overflow-x-visible scrollbar-hide ${className}`}
>
{children}
</div>
);
}

View file

@ -0,0 +1,34 @@
interface PillToggleProps {
label: string;
active: boolean;
onClick: () => void;
/** Visual hint for partial selection (e.g. some children selected) */
indeterminate?: boolean;
size?: 'sm' | 'xs';
}
export function PillToggle({
label,
active,
onClick,
indeterminate,
size = 'sm',
}: PillToggleProps) {
const sizeClasses = size === 'xs' ? 'px-2 py-0.5 text-xs' : 'px-2.5 py-1 text-sm';
const colorClasses = active
? 'bg-teal-600 text-white dark:bg-teal-500'
: indeterminate
? 'bg-teal-100 text-teal-700 dark:bg-teal-900/40 dark:text-teal-300'
: 'bg-warm-100 text-warm-600 hover:bg-warm-200 dark:bg-warm-700 dark:text-warm-300 dark:hover:bg-warm-600';
return (
<button
type="button"
onClick={onClick}
className={`${sizeClasses} ${colorClasses} rounded-full font-medium whitespace-nowrap cursor-pointer`}
>
{label}
</button>
);
}

View file

@ -2,10 +2,11 @@
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Perfect Postcodes — Every neighbourhood in England & Wales</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
<meta name="theme-color" content="#0f1528" />
<title>Perfect Postcode — Every neighbourhood in England & Wales</title>
<meta name="description" content="Explore property prices, energy ratings, crime stats, school ratings, and more across England & Wales on one interactive map." />
<meta name="x-og-placeholder" content="__PERFECT_POSTCODES_OG_TAGS__" />
<meta name="x-og-placeholder" content="__PERFECT_POSTCODE_OG_TAGS__" />
<script>
(function() {
if (localStorage.getItem('theme') === 'dark') {