alright
This commit is contained in:
parent
c645b0f1d4
commit
39ef5c6646
79 changed files with 5660 additions and 2199 deletions
|
|
@ -41,7 +41,6 @@ import { EmptyState } from '../ui/EmptyState';
|
|||
import { FeatureLabel } from '../ui/FeatureLabel';
|
||||
import { IndeterminateProgressBar } from '../ui/IndeterminateProgressBar';
|
||||
import StreetViewEmbed from './StreetViewEmbed';
|
||||
import HistogramLegend from './HistogramLegend';
|
||||
import JourneyInstructions from './JourneyInstructions';
|
||||
|
||||
interface AreaPaneProps {
|
||||
|
|
@ -462,7 +461,6 @@ export default function AreaPane({
|
|||
) : stats ? (
|
||||
<div>
|
||||
{hexagonLocation && <StreetViewEmbed location={hexagonLocation} />}
|
||||
{stats.count > 0 && <HistogramLegend />}
|
||||
{stats.price_history &&
|
||||
(() => {
|
||||
const uniqueYears = new Set(stats.price_history.map((p) => Math.floor(p.year)));
|
||||
|
|
@ -547,6 +545,10 @@ export default function AreaPane({
|
|||
|
||||
if (total === 0) return null;
|
||||
|
||||
const crimeSeries = chart.feature
|
||||
? crimeByYearByFeatureName.get(chart.feature)
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={ts(chart.label)}
|
||||
|
|
@ -585,6 +587,11 @@ export default function AreaPane({
|
|||
: STACKED_SEGMENT_COLORS
|
||||
}
|
||||
/>
|
||||
{crimeSeries && crimeSeries.points.length > 1 && (
|
||||
<div className="mt-2">
|
||||
<CrimeYearChart points={crimeSeries.points} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
|
|
|||
|
|
@ -1,21 +0,0 @@
|
|||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
export default function HistogramLegend() {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="mx-3 mt-3 flex flex-wrap items-center gap-x-3 gap-y-1 rounded border border-warm-200 bg-white px-2.5 py-1.5 text-[10px] text-warm-500 dark:border-navy-800 dark:bg-navy-950/60 dark:text-warm-400">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className="h-2.5 w-2 rounded-[2px] bg-teal-600 dark:bg-teal-400" />
|
||||
<span className="font-medium text-warm-700 dark:text-warm-200">
|
||||
{t('histogramLegend.tealBars')}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className="h-2.5 w-2 rounded-[2px] bg-warm-300/70 dark:bg-warm-600/70" />
|
||||
<span className="font-medium text-warm-700 dark:text-warm-200">
|
||||
{t('histogramLegend.greyBars')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -96,7 +96,7 @@ export default memo(function HoverCard({
|
|||
if (!data) {
|
||||
return (
|
||||
<div
|
||||
className="absolute bg-white dark:bg-warm-800 rounded-lg shadow-lg p-3 text-sm pointer-events-none z-50 min-w-[140px]"
|
||||
className="absolute bg-white dark:bg-warm-800 rounded-lg shadow-lg p-3 text-sm pointer-events-none z-30 min-w-[140px]"
|
||||
style={cardStyle}
|
||||
>
|
||||
<div className="animate-pulse space-y-2">
|
||||
|
|
@ -109,7 +109,7 @@ export default memo(function HoverCard({
|
|||
|
||||
return (
|
||||
<div
|
||||
className="absolute bg-white dark:bg-warm-800 rounded-lg shadow-lg p-3 text-sm dark:text-white pointer-events-none z-50 min-w-[180px] max-w-[260px]"
|
||||
className="absolute bg-white dark:bg-warm-800 rounded-lg shadow-lg p-3 text-sm dark:text-white pointer-events-none z-30 min-w-[180px] max-w-[260px]"
|
||||
style={cardStyle}
|
||||
>
|
||||
<div className="relative">
|
||||
|
|
|
|||
215
frontend/src/components/map/LocationSearch.test.tsx
Normal file
215
frontend/src/components/map/LocationSearch.test.tsx
Normal file
|
|
@ -0,0 +1,215 @@
|
|||
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import LocationSearch from './LocationSearch';
|
||||
import type { PostcodeGeometry } from '../../types';
|
||||
|
||||
const RECENT_SEARCHES_STORAGE_KEY = 'perfect-postcode.locationSearch.recent';
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) =>
|
||||
key === 'locationSearch.placeholder' ? 'Search places or postcodes...' : key,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('../../hooks/useIsMobile', () => ({
|
||||
useIsMobile: () => false,
|
||||
}));
|
||||
|
||||
vi.mock('../../lib/pocketbase', () => ({
|
||||
default: { authStore: { isValid: false, token: '' } },
|
||||
}));
|
||||
|
||||
const postcodeGeometry: PostcodeGeometry = {
|
||||
type: 'Polygon',
|
||||
coordinates: [
|
||||
[
|
||||
[-0.12, 51.5],
|
||||
[-0.11, 51.5],
|
||||
[-0.11, 51.51],
|
||||
[-0.12, 51.51],
|
||||
[-0.12, 51.5],
|
||||
],
|
||||
],
|
||||
};
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void;
|
||||
let reject!: (reason?: unknown) => void;
|
||||
const promise = new Promise<T>((res, rej) => {
|
||||
resolve = res;
|
||||
reject = rej;
|
||||
});
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
|
||||
function jsonResponse(body: unknown): Response {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
describe('LocationSearch', () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
window.localStorage.clear();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('ignores stale postcode lookups when a newer search starts', async () => {
|
||||
const firstLookup = deferred<Response>();
|
||||
const secondLookup = deferred<Response>();
|
||||
const requests: { postcode: string; signal?: AbortSignal | null }[] = [];
|
||||
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn((input: string | URL | Request, init?: RequestInit) => {
|
||||
const url = new URL(String(input), 'http://localhost');
|
||||
const postcode = decodeURIComponent(url.pathname.replace('/api/postcode/', ''));
|
||||
requests.push({ postcode, signal: init?.signal });
|
||||
if (postcode === 'SW1A 1AA') return firstLookup.promise;
|
||||
if (postcode === 'E14 2DG') return secondLookup.promise;
|
||||
return Promise.resolve(new Response(null, { status: 404 }));
|
||||
})
|
||||
);
|
||||
|
||||
const onFlyTo = vi.fn();
|
||||
const onLocationSearched = vi.fn();
|
||||
|
||||
render(<LocationSearch onFlyTo={onFlyTo} onLocationSearched={onLocationSearched} />);
|
||||
|
||||
const input = screen.getByRole('textbox');
|
||||
fireEvent.change(input, { target: { value: 'SW1A 1AA' } });
|
||||
fireEvent.keyDown(input, { key: 'Enter' });
|
||||
|
||||
fireEvent.change(input, { target: { value: 'E14 2DG' } });
|
||||
fireEvent.keyDown(input, { key: 'Enter' });
|
||||
|
||||
expect(requests).toHaveLength(2);
|
||||
expect(requests[0].signal?.aborted).toBe(true);
|
||||
|
||||
secondLookup.resolve(
|
||||
jsonResponse({
|
||||
postcode: 'E14 2DG',
|
||||
latitude: 51.505,
|
||||
longitude: -0.01,
|
||||
geometry: postcodeGeometry,
|
||||
})
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onLocationSearched).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
expect(onFlyTo).toHaveBeenCalledWith(51.505, -0.01, 16);
|
||||
expect(onLocationSearched).toHaveBeenCalledWith({
|
||||
postcode: 'E14 2DG',
|
||||
geometry: postcodeGeometry,
|
||||
latitude: 51.505,
|
||||
longitude: -0.01,
|
||||
zoom: 16,
|
||||
});
|
||||
|
||||
firstLookup.resolve(
|
||||
jsonResponse({
|
||||
postcode: 'SW1A 1AA',
|
||||
latitude: 51.501,
|
||||
longitude: -0.141,
|
||||
geometry: postcodeGeometry,
|
||||
})
|
||||
);
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
expect(onFlyTo).toHaveBeenCalledTimes(1);
|
||||
expect(onLocationSearched).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('stores successful searches locally and shows them when the input is empty', async () => {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn((input: string | URL | Request) => {
|
||||
const url = new URL(String(input), 'http://localhost');
|
||||
const postcode = decodeURIComponent(url.pathname.replace('/api/postcode/', ''));
|
||||
return Promise.resolve(
|
||||
jsonResponse({
|
||||
postcode,
|
||||
latitude: 51.505,
|
||||
longitude: -0.01,
|
||||
geometry: postcodeGeometry,
|
||||
})
|
||||
);
|
||||
})
|
||||
);
|
||||
|
||||
const onFlyTo = vi.fn();
|
||||
const onLocationSearched = vi.fn();
|
||||
|
||||
render(<LocationSearch onFlyTo={onFlyTo} onLocationSearched={onLocationSearched} />);
|
||||
|
||||
const input = screen.getByRole('textbox');
|
||||
fireEvent.change(input, { target: { value: 'SW1A 1AA' } });
|
||||
fireEvent.keyDown(input, { key: 'Enter' });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onLocationSearched).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
expect(JSON.parse(window.localStorage.getItem(RECENT_SEARCHES_STORAGE_KEY) ?? '[]')).toEqual([
|
||||
{ type: 'postcode', label: 'SW1A 1AA' },
|
||||
]);
|
||||
|
||||
fireEvent.focus(input);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('button', { name: 'SW1A 1AA' })).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps only the three most recent local searches', async () => {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn((input: string | URL | Request) => {
|
||||
const url = new URL(String(input), 'http://localhost');
|
||||
const postcode = decodeURIComponent(url.pathname.replace('/api/postcode/', ''));
|
||||
return Promise.resolve(
|
||||
jsonResponse({
|
||||
postcode,
|
||||
latitude: 51.505,
|
||||
longitude: -0.01,
|
||||
geometry: postcodeGeometry,
|
||||
})
|
||||
);
|
||||
})
|
||||
);
|
||||
|
||||
render(<LocationSearch onFlyTo={vi.fn()} onLocationSearched={vi.fn()} />);
|
||||
|
||||
const input = screen.getByRole('textbox');
|
||||
for (const postcode of ['SW1A 1AA', 'E14 2DG', 'W1A 1AA', 'EC1A 1BB']) {
|
||||
fireEvent.change(input, { target: { value: postcode } });
|
||||
fireEvent.keyDown(input, { key: 'Enter' });
|
||||
|
||||
await waitFor(() => {
|
||||
const stored = JSON.parse(
|
||||
window.localStorage.getItem(RECENT_SEARCHES_STORAGE_KEY) ?? '[]'
|
||||
) as { label?: string }[];
|
||||
expect(stored[0]?.label).toBe(postcode);
|
||||
});
|
||||
}
|
||||
|
||||
const stored = JSON.parse(window.localStorage.getItem(RECENT_SEARCHES_STORAGE_KEY) ?? '[]') as {
|
||||
label?: string;
|
||||
}[];
|
||||
expect(stored.map((search) => search.label)).toEqual(['EC1A 1BB', 'W1A 1AA', 'E14 2DG']);
|
||||
|
||||
fireEvent.focus(input);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('button', { name: 'EC1A 1BB' })).toBeTruthy();
|
||||
expect(screen.getByRole('button', { name: 'W1A 1AA' })).toBeTruthy();
|
||||
expect(screen.getByRole('button', { name: 'E14 2DG' })).toBeTruthy();
|
||||
});
|
||||
expect(screen.queryByText('SW1A 1AA')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
import { useState, useCallback, useRef, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { MapFlyToOptions, PostcodeGeometry } from '../../types';
|
||||
import { authHeaders } from '../../lib/api';
|
||||
import { authHeaders, isAbortError } from '../../lib/api';
|
||||
import { POSTCODE_SEARCH_ZOOM } from '../../lib/consts';
|
||||
import { useIsMobile } from '../../hooks/useIsMobile';
|
||||
import { useLocationSearch, type SearchResult } from '../../hooks/useLocationSearch';
|
||||
|
|
@ -16,6 +16,7 @@ export interface SearchedLocation {
|
|||
geometry: PostcodeGeometry;
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
zoom: number;
|
||||
markerLatitude?: number;
|
||||
markerLongitude?: number;
|
||||
openProperties?: boolean;
|
||||
|
|
@ -73,6 +74,34 @@ export default function LocationSearch({
|
|||
const isMobile = useIsMobile();
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const lookupAbortRef = useRef<AbortController | null>(null);
|
||||
const lookupRequestIdRef = useRef(0);
|
||||
|
||||
const cancelLookup = useCallback((updateLoading = true) => {
|
||||
lookupRequestIdRef.current += 1;
|
||||
lookupAbortRef.current?.abort();
|
||||
lookupAbortRef.current = null;
|
||||
if (updateLoading) setLoading(false);
|
||||
}, []);
|
||||
|
||||
const beginLookup = useCallback(() => {
|
||||
lookupAbortRef.current?.abort();
|
||||
const controller = new AbortController();
|
||||
lookupAbortRef.current = controller;
|
||||
lookupRequestIdRef.current += 1;
|
||||
setError(null);
|
||||
setLoading(true);
|
||||
search.close();
|
||||
return { controller, requestId: lookupRequestIdRef.current };
|
||||
}, [search]);
|
||||
|
||||
const isCurrentLookup = useCallback((requestId: number, controller: AbortController) => {
|
||||
return (
|
||||
lookupRequestIdRef.current === requestId &&
|
||||
lookupAbortRef.current === controller &&
|
||||
!controller.signal.aborted
|
||||
);
|
||||
}, []);
|
||||
|
||||
// Close on outside click
|
||||
useEffect(() => {
|
||||
|
|
@ -93,106 +122,136 @@ export default function LocationSearch({
|
|||
}
|
||||
}, [isMobile, expanded]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => cancelLookup(false);
|
||||
}, [cancelLookup]);
|
||||
|
||||
const selectResult = useCallback(
|
||||
async (result: SearchResult) => {
|
||||
const { controller, requestId } = beginLookup();
|
||||
|
||||
if (result.type === 'place') {
|
||||
const zoom = ZOOM_FOR_TYPE[result.place_type] ?? 14;
|
||||
setError(null);
|
||||
setLoading(true);
|
||||
search.close();
|
||||
onFlyTo(result.lat, result.lon, zoom);
|
||||
// On mobile the drawer opens after onLocationSearched; MapPage handles
|
||||
// the fly-to there with the correct viewport inset so the target isn't
|
||||
// hidden behind the drawer. On desktop fly immediately for snappy feedback.
|
||||
if (!isMobile) onFlyTo(result.lat, result.lon, zoom);
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
lat: String(result.lat),
|
||||
lng: String(result.lon),
|
||||
log: 'false',
|
||||
});
|
||||
const res = await fetch(`/api/nearest-postcode?${params}`, authHeaders());
|
||||
const res = await fetch(
|
||||
`/api/nearest-postcode?${params}`,
|
||||
authHeaders({ signal: controller.signal })
|
||||
);
|
||||
if (!isCurrentLookup(requestId, controller)) return;
|
||||
if (!res.ok) {
|
||||
setError(t('locationSearch.lookupFailed'));
|
||||
return;
|
||||
}
|
||||
const json: PostcodeLookupResponse = await res.json();
|
||||
if (!isCurrentLookup(requestId, controller)) return;
|
||||
onLocationSearched?.({
|
||||
postcode: json.postcode,
|
||||
geometry: json.geometry,
|
||||
latitude: json.latitude,
|
||||
longitude: json.longitude,
|
||||
zoom,
|
||||
markerLatitude: result.lat,
|
||||
markerLongitude: result.lon,
|
||||
});
|
||||
search.saveRecentSearch(result);
|
||||
search.clear();
|
||||
if (isMobile) setExpanded(false);
|
||||
} catch {
|
||||
} catch (error) {
|
||||
if (!isCurrentLookup(requestId, controller) || isAbortError(error)) return;
|
||||
setError(t('locationSearch.lookupFailed'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
if (isCurrentLookup(requestId, controller)) {
|
||||
lookupAbortRef.current = null;
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (result.type === 'address') {
|
||||
setError(null);
|
||||
setLoading(true);
|
||||
search.close();
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/postcode/${encodeURIComponent(result.postcode)}`,
|
||||
authHeaders()
|
||||
authHeaders({ signal: controller.signal })
|
||||
);
|
||||
if (!isCurrentLookup(requestId, controller)) return;
|
||||
if (!res.ok) {
|
||||
setError(t('locationSearch.postcodeNotFound'));
|
||||
return;
|
||||
}
|
||||
const json: PostcodeLookupResponse = await res.json();
|
||||
onFlyTo(result.lat, result.lon, 17);
|
||||
if (!isCurrentLookup(requestId, controller)) return;
|
||||
if (!isMobile) onFlyTo(result.lat, result.lon, 17);
|
||||
onLocationSearched?.({
|
||||
postcode: json.postcode,
|
||||
geometry: json.geometry,
|
||||
latitude: result.lat,
|
||||
longitude: result.lon,
|
||||
zoom: 17,
|
||||
markerLatitude: result.lat,
|
||||
markerLongitude: result.lon,
|
||||
openProperties: true,
|
||||
focusAddress: result.address,
|
||||
});
|
||||
search.saveRecentSearch(result);
|
||||
search.clear();
|
||||
if (isMobile) setExpanded(false);
|
||||
} catch {
|
||||
} catch (error) {
|
||||
if (!isCurrentLookup(requestId, controller) || isAbortError(error)) return;
|
||||
setError(t('locationSearch.lookupFailed'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
if (isCurrentLookup(requestId, controller)) {
|
||||
lookupAbortRef.current = null;
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Postcode — fetch geometry
|
||||
setError(null);
|
||||
setLoading(true);
|
||||
search.close();
|
||||
try {
|
||||
const res = await fetch(`/api/postcode/${encodeURIComponent(result.label)}`, authHeaders());
|
||||
const res = await fetch(
|
||||
`/api/postcode/${encodeURIComponent(result.label)}`,
|
||||
authHeaders({ signal: controller.signal })
|
||||
);
|
||||
if (!isCurrentLookup(requestId, controller)) return;
|
||||
if (!res.ok) {
|
||||
setError(t('locationSearch.postcodeNotFound'));
|
||||
return;
|
||||
}
|
||||
const json: PostcodeLookupResponse = await res.json();
|
||||
onFlyTo(json.latitude, json.longitude, POSTCODE_SEARCH_ZOOM);
|
||||
if (!isCurrentLookup(requestId, controller)) return;
|
||||
if (!isMobile) onFlyTo(json.latitude, json.longitude, POSTCODE_SEARCH_ZOOM);
|
||||
onLocationSearched?.({
|
||||
postcode: json.postcode,
|
||||
geometry: json.geometry,
|
||||
latitude: json.latitude,
|
||||
longitude: json.longitude,
|
||||
zoom: POSTCODE_SEARCH_ZOOM,
|
||||
});
|
||||
search.saveRecentSearch(result);
|
||||
search.clear();
|
||||
if (isMobile) setExpanded(false);
|
||||
} catch {
|
||||
} catch (error) {
|
||||
if (!isCurrentLookup(requestId, controller) || isAbortError(error)) return;
|
||||
setError(t('locationSearch.lookupFailed'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
if (isCurrentLookup(requestId, controller)) {
|
||||
lookupAbortRef.current = null;
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
},
|
||||
[onFlyTo, onLocationSearched, isMobile, search, t]
|
||||
[beginLookup, isCurrentLookup, onFlyTo, onLocationSearched, isMobile, search, t]
|
||||
);
|
||||
|
||||
const [locating, setLocating] = useState(false);
|
||||
|
|
@ -203,6 +262,7 @@ export default function LocationSearch({
|
|||
return;
|
||||
}
|
||||
setError(null);
|
||||
cancelLookup();
|
||||
setLocating(true);
|
||||
search.close();
|
||||
try {
|
||||
|
|
@ -234,7 +294,7 @@ export default function LocationSearch({
|
|||
} finally {
|
||||
setLocating(false);
|
||||
}
|
||||
}, [onFlyTo, onCurrentLocationFound, isMobile, search, t]);
|
||||
}, [cancelLookup, onFlyTo, onCurrentLocationFound, isMobile, search, t]);
|
||||
|
||||
// Mobile collapsed state: search icon + locate button
|
||||
if (isMobile && !expanded) {
|
||||
|
|
@ -281,7 +341,10 @@ export default function LocationSearch({
|
|||
'px-2 py-2 text-sm w-56 border-none outline-none bg-transparent text-warm-700 dark:text-warm-200 placeholder-warm-400 dark:placeholder-warm-500'
|
||||
}
|
||||
inputRef={inputRef}
|
||||
onInputChange={() => setError(null)}
|
||||
onInputChange={() => {
|
||||
setError(null);
|
||||
cancelLookup();
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
|
|
|
|||
|
|
@ -33,6 +33,7 @@ import {
|
|||
MAP_BOUNDS,
|
||||
POI_GROUP_COLORS,
|
||||
POSTCODE_ZOOM_THRESHOLD,
|
||||
POI_AUTO_CARD_ZOOM_THRESHOLD,
|
||||
} from '../../lib/consts';
|
||||
import LocationSearch, { type SearchedLocation } from './LocationSearch';
|
||||
import MapLegend from './MapLegend';
|
||||
|
|
@ -104,6 +105,15 @@ function formatListingHeadline(listing: ActualListing, t: TFunction): string | n
|
|||
return parts.length > 0 ? parts.join(' · ') : null;
|
||||
}
|
||||
|
||||
interface PoiPopupCardData {
|
||||
name: string;
|
||||
category: string;
|
||||
icon_category?: string;
|
||||
group: string;
|
||||
emoji: string;
|
||||
school?: SchoolMetadata;
|
||||
}
|
||||
|
||||
interface Dimensions {
|
||||
width: number;
|
||||
height: number;
|
||||
|
|
@ -289,10 +299,16 @@ function renderSchoolMetadata(school: SchoolMetadata) {
|
|||
)}
|
||||
{school.fsm_percent !== undefined && (
|
||||
<>
|
||||
<dt className="text-warm-500 dark:text-warm-400">FSM</dt>
|
||||
<dt className="text-warm-500 dark:text-warm-400">Free meal</dt>
|
||||
<dd className="dark:text-warm-200">{school.fsm_percent.toFixed(1)}%</dd>
|
||||
</>
|
||||
)}
|
||||
{school.ofsted_rating && (
|
||||
<>
|
||||
<dt className="text-warm-500 dark:text-warm-400">Ofsted</dt>
|
||||
<dd className="dark:text-warm-200">{school.ofsted_rating}</dd>
|
||||
</>
|
||||
)}
|
||||
{school.sixth_form === 'Has a sixth form' && (
|
||||
<>
|
||||
<dt className="text-warm-500 dark:text-warm-400">Sixth form</dt>
|
||||
|
|
@ -358,6 +374,36 @@ function renderSchoolMetadata(school: SchoolMetadata) {
|
|||
);
|
||||
}
|
||||
|
||||
function PoiPopupCardContent({ poi }: { poi: PoiPopupCardData }) {
|
||||
return (
|
||||
<div className="px-3 py-2 max-w-[280px]">
|
||||
<div className="flex items-center gap-2">
|
||||
<img
|
||||
src={getPoiIconUrl(poi.category, poi.emoji, poi.icon_category, poi.name)}
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
loading="lazy"
|
||||
referrerPolicy="no-referrer"
|
||||
className="h-5 w-5 shrink-0 rounded-[4px] bg-white object-contain p-0.5"
|
||||
/>
|
||||
<div className="min-w-0">
|
||||
<div className="font-semibold dark:text-warm-100">{poi.name}</div>
|
||||
<div className="flex items-center gap-1.5 text-xs text-warm-500 dark:text-warm-400">
|
||||
<span
|
||||
className="inline-block w-2 h-2 rounded-full flex-shrink-0"
|
||||
style={{
|
||||
backgroundColor: `rgb(${getPoiGroupColor(poi.group).join(',')})`,
|
||||
}}
|
||||
/>
|
||||
{ts(poi.category)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{poi.school && renderSchoolMetadata(poi.school)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function getRenderedViewState(map: MapRef | null): ViewState | null {
|
||||
if (!map) return null;
|
||||
|
||||
|
|
@ -575,6 +621,7 @@ export default memo(function Map({
|
|||
const densityLabel = densityLabelProp ?? t('mapLegend.numberOfProperties');
|
||||
const [internalViewState, setInternalViewState] = useState<ViewState>(initialViewState);
|
||||
const [dimensions, setDimensions] = useState<Dimensions>({ width: 0, height: 0 });
|
||||
const [mapReady, setMapReady] = useState(false);
|
||||
|
||||
// In screenshot mode, use the prop directly for instant updates (no async lag)
|
||||
const viewState = screenshotMode ? initialViewState : internalViewState;
|
||||
|
|
@ -664,6 +711,10 @@ export default memo(function Map({
|
|||
if (screenshotMode) window.__map_idle = true;
|
||||
}, [screenshotMode]);
|
||||
|
||||
const handleLoad = useCallback(() => {
|
||||
setMapReady(true);
|
||||
}, []);
|
||||
|
||||
const handleFlyTo = useCallback(
|
||||
(lat: number, lng: number, zoom: number, options?: MapFlyToOptions) => {
|
||||
setInternalViewState((prev) => {
|
||||
|
|
@ -715,6 +766,7 @@ export default memo(function Map({
|
|||
layers,
|
||||
popupInfo,
|
||||
clearPopupInfo,
|
||||
visiblePois,
|
||||
listingPopup,
|
||||
clearListingPopup,
|
||||
hoverPosition,
|
||||
|
|
@ -744,6 +796,31 @@ export default memo(function Map({
|
|||
travelTimeEntries,
|
||||
});
|
||||
|
||||
const showAutoPoiCards = !screenshotMode && viewState.zoom >= POI_AUTO_CARD_ZOOM_THRESHOLD;
|
||||
const autoPoiCards = useMemo(() => {
|
||||
const map = mapRef.current;
|
||||
if (!showAutoPoiCards || !mapReady || !map || dimensions.width <= 0 || dimensions.height <= 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return visiblePois.flatMap((poi) => {
|
||||
const point = map.project([poi.lng, poi.lat]);
|
||||
if (
|
||||
!Number.isFinite(point.x) ||
|
||||
!Number.isFinite(point.y) ||
|
||||
point.x < 0 ||
|
||||
point.x > dimensions.width ||
|
||||
point.y < 0 ||
|
||||
point.y > dimensions.height
|
||||
) {
|
||||
return [];
|
||||
}
|
||||
return [{ poi, x: point.x, y: point.y }];
|
||||
});
|
||||
// viewState isn't read directly but drives map.project — recompute when the camera moves.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [showAutoPoiCards, mapReady, visiblePois, dimensions, viewState]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`flex-1 h-full relative ${bottomScreenInset > 0 ? 'map-has-mobile-bottom-sheet' : ''}`}
|
||||
|
|
@ -755,7 +832,7 @@ export default memo(function Map({
|
|||
ref={mapRef}
|
||||
{...viewState}
|
||||
onMove={handleMove}
|
||||
onLoad={undefined}
|
||||
onLoad={handleLoad}
|
||||
onIdle={handleIdle}
|
||||
mapStyle={mapStyle}
|
||||
style={{ width: '100%', height: '100%' }}
|
||||
|
|
@ -896,14 +973,28 @@ export default memo(function Map({
|
|||
))}
|
||||
</div>
|
||||
)}
|
||||
{popupInfo && (
|
||||
{autoPoiCards.map(({ poi, x, y }) => (
|
||||
<div
|
||||
key={poi.id}
|
||||
className="pointer-events-none absolute bg-white dark:bg-warm-800 rounded-lg shadow-lg text-sm dark:text-white"
|
||||
style={{
|
||||
left: x,
|
||||
top: y - 12,
|
||||
transform: 'translate(-50%, -100%)',
|
||||
zIndex: 9,
|
||||
}}
|
||||
>
|
||||
<PoiPopupCardContent poi={poi} />
|
||||
</div>
|
||||
))}
|
||||
{popupInfo && (!showAutoPoiCards || popupInfo.isCluster) && (
|
||||
<div
|
||||
className="pointer-events-none absolute bg-white dark:bg-warm-800 rounded-lg shadow-lg text-sm dark:text-white"
|
||||
style={{
|
||||
left: popupInfo.x,
|
||||
top: popupInfo.y - 50,
|
||||
transform: 'translateX(-50%)',
|
||||
zIndex: 9999,
|
||||
zIndex: 30,
|
||||
}}
|
||||
>
|
||||
<button
|
||||
|
|
@ -922,36 +1013,7 @@ export default memo(function Map({
|
|||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="px-3 py-2 max-w-[280px]">
|
||||
<div className="flex items-center gap-2">
|
||||
<img
|
||||
src={getPoiIconUrl(
|
||||
popupInfo.category,
|
||||
popupInfo.emoji,
|
||||
popupInfo.icon_category,
|
||||
popupInfo.name
|
||||
)}
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
loading="lazy"
|
||||
referrerPolicy="no-referrer"
|
||||
className="h-5 w-5 shrink-0 rounded-[4px] bg-white object-contain p-0.5"
|
||||
/>
|
||||
<div>
|
||||
<div className="font-semibold dark:text-warm-100">{popupInfo.name}</div>
|
||||
<div className="flex items-center gap-1.5 text-xs text-warm-500 dark:text-warm-400">
|
||||
<span
|
||||
className="inline-block w-2 h-2 rounded-full flex-shrink-0"
|
||||
style={{
|
||||
backgroundColor: `rgb(${getPoiGroupColor(popupInfo.group).join(',')})`,
|
||||
}}
|
||||
/>
|
||||
{ts(popupInfo.category)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{popupInfo.school && renderSchoolMetadata(popupInfo.school)}
|
||||
</div>
|
||||
<PoiPopupCardContent poi={popupInfo} />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -962,7 +1024,7 @@ export default memo(function Map({
|
|||
left: listingPopup.x,
|
||||
top: listingPopup.y - 12,
|
||||
transform: 'translate(-50%, -100%)',
|
||||
zIndex: 9999,
|
||||
zIndex: 30,
|
||||
}}
|
||||
onMouseLeave={clearListingPopup}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { Suspense, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Trans, useTranslation } from 'react-i18next';
|
||||
|
||||
import type { MapFlyToOptions, PostcodeGeometry } from '../../types';
|
||||
import type { ActualListing, MapFlyToOptions, PostcodeGeometry } from '../../types';
|
||||
import type { SearchedLocation } from './LocationSearch';
|
||||
import { useMapData } from '../../hooks/useMapData';
|
||||
import { usePOIData } from '../../hooks/usePOIData';
|
||||
|
|
@ -25,11 +25,7 @@ import {
|
|||
import { apiUrl, authHeaders, buildFilterString } from '../../lib/api';
|
||||
import { useFilterCounts } from '../../hooks/useFilterCounts';
|
||||
import { trackEvent } from '../../lib/analytics';
|
||||
import {
|
||||
INITIAL_VIEW_STATE,
|
||||
POSTCODE_SEARCH_ZOOM,
|
||||
POSTCODE_ZOOM_THRESHOLD,
|
||||
} from '../../lib/consts';
|
||||
import { INITIAL_VIEW_STATE, POSTCODE_ZOOM_THRESHOLD } from '../../lib/consts';
|
||||
import type { OverlayId } from '../../lib/overlays';
|
||||
import { useLicense } from '../../hooks/useLicense';
|
||||
import { stateToParams } from '../../lib/url-state';
|
||||
|
|
@ -67,6 +63,9 @@ import type { MapFlyTo, MapPageProps } from './map-page/types';
|
|||
export type { ExportState } from './map-page/types';
|
||||
|
||||
type PendingFlyTo = { lat: number; lng: number; zoom: number };
|
||||
const EMPTY_ACTUAL_LISTINGS: ActualListing[] = [];
|
||||
|
||||
declare const __DEV__: boolean;
|
||||
|
||||
export default function MapPage({
|
||||
features,
|
||||
|
|
@ -115,6 +114,7 @@ export default function MapPage({
|
|||
const [poiPaneOpen, setPoiPaneOpen] = useState(false);
|
||||
const [overlayPaneOpen, setOverlayPaneOpen] = useState(false);
|
||||
const [currentLocation, setCurrentLocation] = useState<{ lat: number; lng: number } | null>(null);
|
||||
const [devActualListingsEnabled, setDevActualListingsEnabled] = useState(true);
|
||||
|
||||
const {
|
||||
filters,
|
||||
|
|
@ -378,7 +378,7 @@ export default function MapPage({
|
|||
pendingLocationSearchFlyToRef.current = {
|
||||
lat: markerLat ?? result.latitude,
|
||||
lng: markerLng ?? result.longitude,
|
||||
zoom: result.openProperties ? 17 : POSTCODE_SEARCH_ZOOM,
|
||||
zoom: result.zoom,
|
||||
};
|
||||
setMobileDrawerOpen(true);
|
||||
consumePendingLocationSearchFlyTo();
|
||||
|
|
@ -450,11 +450,20 @@ export default function MapPage({
|
|||
[filters, features]
|
||||
);
|
||||
const actualListingsTravelParam = useMemo(() => buildTravelParam(entries), [entries]);
|
||||
const { listings: actualListings } = useActualListings(mapData.bounds, {
|
||||
filterParam: actualListingsFilterParam,
|
||||
travelParam: actualListingsTravelParam,
|
||||
shareCode,
|
||||
});
|
||||
const actualListingsEnabled = !__DEV__ || devActualListingsEnabled;
|
||||
const { listings: actualListings } = useActualListings(
|
||||
actualListingsEnabled ? mapData.bounds : null,
|
||||
{
|
||||
filterParam: actualListingsFilterParam,
|
||||
travelParam: actualListingsTravelParam,
|
||||
shareCode,
|
||||
}
|
||||
);
|
||||
const visibleActualListings = actualListingsEnabled ? actualListings : EMPTY_ACTUAL_LISTINGS;
|
||||
const handleToggleActualListings = useCallback(() => {
|
||||
if (!__DEV__) return;
|
||||
setDevActualListingsEnabled((enabled) => !enabled);
|
||||
}, []);
|
||||
const [isAreaGroupExpanded, toggleAreaGroup] = useCollapsibleGroups(true);
|
||||
|
||||
useUrlSync(
|
||||
|
|
@ -798,7 +807,9 @@ export default function MapPage({
|
|||
onLocationSearched={handleLocationSearchResult}
|
||||
onCurrentLocationFound={handleCurrentLocationFound}
|
||||
currentLocation={currentLocation}
|
||||
actualListings={actualListings}
|
||||
actualListings={visibleActualListings}
|
||||
actualListingsEnabled={actualListingsEnabled}
|
||||
onToggleActualListings={__DEV__ ? handleToggleActualListings : undefined}
|
||||
travelTimeEntries={entries}
|
||||
bottomScreenInset={mobileBottomSheetHeight}
|
||||
onBottomSheetCoveredHeightChange={setMobileBottomSheetHeight}
|
||||
|
|
@ -866,7 +877,9 @@ export default function MapPage({
|
|||
onLocationSearched={handleLocationSearchResult}
|
||||
onCurrentLocationFound={handleCurrentLocationFound}
|
||||
currentLocation={currentLocation}
|
||||
actualListings={actualListings}
|
||||
actualListings={visibleActualListings}
|
||||
actualListingsEnabled={actualListingsEnabled}
|
||||
onToggleActualListings={__DEV__ ? handleToggleActualListings : undefined}
|
||||
travelTimeEntries={entries}
|
||||
densityLabel={densityLabel}
|
||||
totalCount={hasActiveFilters ? filterCounts.total : undefined}
|
||||
|
|
|
|||
|
|
@ -36,7 +36,7 @@ export function MapPageSelectionPane({
|
|||
return (
|
||||
<div
|
||||
data-tutorial="right-pane"
|
||||
className="flex bg-white dark:bg-navy-950 shadow-lg z-10"
|
||||
className="relative z-40 flex bg-white dark:bg-navy-950 shadow-lg"
|
||||
style={{ width }}
|
||||
>
|
||||
<div
|
||||
|
|
|
|||
|
|
@ -52,7 +52,7 @@ export default function MobileDrawer({
|
|||
}, [onClose]);
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex flex-col">
|
||||
<div data-tutorial="right-pane" className="fixed inset-0 z-50 flex flex-col">
|
||||
{/* Backdrop — top 10% */}
|
||||
<div className="h-[10%] bg-black/50" onClick={onClose} />
|
||||
|
||||
|
|
|
|||
|
|
@ -191,7 +191,7 @@ function PropertyCard({ property }: { property: Property }) {
|
|||
)}
|
||||
{property.listed_building === 'Yes' && (
|
||||
<span className="text-xs bg-amber-50 dark:bg-amber-900/30 text-amber-700 dark:text-amber-300 rounded-full px-1.5 py-0.5 font-medium leading-none">
|
||||
{ts('Listed building')}
|
||||
{t('propertyCard.listedBuildingBadge')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ import type { OverlayId } from '../../../lib/overlays';
|
|||
import type { SearchedLocation } from '../LocationSearch';
|
||||
import { MapPinIcon } from '../../ui/icons/MapPinIcon';
|
||||
import { EyeIcon } from '../../ui/icons/EyeIcon';
|
||||
import { HouseIcon } from '../../ui/icons/HouseIcon';
|
||||
import { IndeterminateProgressBar } from '../../ui/IndeterminateProgressBar';
|
||||
import type { MapFlyTo, PaneResizeHandlers } from './types';
|
||||
import { MapFallback, PaneFallback } from './Fallbacks';
|
||||
|
|
@ -56,6 +57,8 @@ interface DesktopMapPageProps {
|
|||
onCurrentLocationFound: (lat: number, lng: number) => void;
|
||||
currentLocation: { lat: number; lng: number } | null;
|
||||
actualListings: ActualListing[];
|
||||
actualListingsEnabled: boolean;
|
||||
onToggleActualListings?: () => void;
|
||||
travelTimeEntries: TravelTimeEntry[];
|
||||
densityLabel: string;
|
||||
totalCount?: number;
|
||||
|
|
@ -106,6 +109,8 @@ export function DesktopMapPage({
|
|||
onCurrentLocationFound,
|
||||
currentLocation,
|
||||
actualListings,
|
||||
actualListingsEnabled,
|
||||
onToggleActualListings,
|
||||
travelTimeEntries,
|
||||
densityLabel,
|
||||
totalCount,
|
||||
|
|
@ -154,7 +159,7 @@ export function DesktopMapPage({
|
|||
|
||||
<div
|
||||
data-tutorial="filters"
|
||||
className="flex bg-white dark:bg-navy-950 shadow-lg overflow-hidden"
|
||||
className="relative z-40 flex bg-white dark:bg-navy-950 shadow-lg overflow-hidden"
|
||||
style={{ width: leftPaneWidth }}
|
||||
>
|
||||
<div className="flex-1 flex flex-col overflow-hidden">{filtersPane}</div>
|
||||
|
|
@ -208,7 +213,20 @@ export function DesktopMapPage({
|
|||
totalCount={totalCount}
|
||||
/>
|
||||
</Suspense>
|
||||
<div className="absolute bottom-4 right-4 z-10 flex flex-col items-end gap-2">
|
||||
<div className="absolute bottom-4 right-4 z-10 flex max-w-[calc(100%_-_2rem)] flex-row flex-wrap justify-end gap-2">
|
||||
{onToggleActualListings && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggleActualListings}
|
||||
aria-pressed={actualListingsEnabled}
|
||||
aria-label={actualListingsEnabled ? 'Hide actual listings' : 'Show actual listings'}
|
||||
title={actualListingsEnabled ? 'Hide actual listings' : 'Show actual listings'}
|
||||
className={`flex items-center gap-2 rounded-lg bg-white px-3 py-2 shadow-lg dark:bg-warm-800 ${actualListingsEnabled ? 'text-red-600 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300' : 'text-warm-500 hover:text-red-600 dark:text-warm-400 dark:hover:text-red-400'}`}
|
||||
>
|
||||
<HouseIcon className="h-5 w-5" />
|
||||
<span className="text-sm font-medium">Listings</span>
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={onToggleOverlayPane}
|
||||
className={`flex items-center gap-2 rounded-lg bg-white px-3 py-2 shadow-lg dark:bg-warm-800 ${overlayPaneOpen ? 'text-teal-600 dark:text-teal-400' : 'text-warm-500 hover:text-teal-600 dark:text-warm-400 dark:hover:text-teal-400'}`}
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ import type { SearchedLocation } from '../LocationSearch';
|
|||
import MobileBottomSheet from '../MobileBottomSheet';
|
||||
import { MapPinIcon } from '../../ui/icons/MapPinIcon';
|
||||
import { EyeIcon } from '../../ui/icons/EyeIcon';
|
||||
import { HouseIcon } from '../../ui/icons/HouseIcon';
|
||||
import { IndeterminateProgressBar } from '../../ui/IndeterminateProgressBar';
|
||||
import type { MapFlyTo } from './types';
|
||||
import { MapFallback, PaneFallback } from './Fallbacks';
|
||||
|
|
@ -47,6 +48,8 @@ interface MobileMapPageProps {
|
|||
onCurrentLocationFound: (lat: number, lng: number) => void;
|
||||
currentLocation: { lat: number; lng: number } | null;
|
||||
actualListings: ActualListing[];
|
||||
actualListingsEnabled: boolean;
|
||||
onToggleActualListings?: () => void;
|
||||
travelTimeEntries: TravelTimeEntry[];
|
||||
bottomScreenInset: number;
|
||||
onBottomSheetCoveredHeightChange: (height: number) => void;
|
||||
|
|
@ -94,6 +97,8 @@ export function MobileMapPage({
|
|||
onCurrentLocationFound,
|
||||
currentLocation,
|
||||
actualListings,
|
||||
actualListingsEnabled,
|
||||
onToggleActualListings,
|
||||
travelTimeEntries,
|
||||
bottomScreenInset,
|
||||
onBottomSheetCoveredHeightChange,
|
||||
|
|
@ -161,7 +166,19 @@ export function MobileMapPage({
|
|||
</Suspense>
|
||||
</div>
|
||||
|
||||
<div className="absolute right-3 top-3 z-20 flex flex-col gap-2">
|
||||
<div className="absolute right-3 top-3 z-20 flex max-w-[calc(100%_-_1.5rem)] flex-row flex-wrap justify-end gap-2">
|
||||
{onToggleActualListings && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggleActualListings}
|
||||
className={`rounded-lg bg-white p-2 shadow-lg dark:bg-warm-800 ${actualListingsEnabled ? 'text-red-600 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300' : 'text-warm-500 hover:text-red-600 dark:text-warm-400 dark:hover:text-red-400'}`}
|
||||
aria-pressed={actualListingsEnabled}
|
||||
aria-label={actualListingsEnabled ? 'Hide actual listings' : 'Show actual listings'}
|
||||
title={actualListingsEnabled ? 'Hide actual listings' : 'Show actual listings'}
|
||||
>
|
||||
<HouseIcon className="h-5 w-5" />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={onToggleOverlayPane}
|
||||
className={`rounded-lg bg-white p-2 shadow-lg dark:bg-warm-800 ${overlayPaneOpen ? 'text-teal-600 dark:text-teal-400' : 'text-warm-500 hover:text-teal-600 dark:text-warm-400 dark:hover:text-teal-400'}`}
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ import type { Page } from '../../ui/Header';
|
|||
import type { PointerEvent } from 'react';
|
||||
|
||||
export interface ExportState {
|
||||
onExport: () => void;
|
||||
onExport: (options?: { postcodes?: string[] }) => void;
|
||||
exporting: boolean;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -135,73 +135,86 @@ export function useExportController({
|
|||
|
||||
useEffect(() => clearExportNoticeTimer, [clearExportNoticeTimer]);
|
||||
|
||||
const handleExport = useCallback(() => {
|
||||
if (exporting) return;
|
||||
if (!bounds) {
|
||||
showExportNotice({ kind: 'error', message: t('header.exportUnavailable') });
|
||||
return;
|
||||
}
|
||||
const handleExport = useCallback(
|
||||
(options?: { postcodes?: string[] }) => {
|
||||
if (exporting) return;
|
||||
|
||||
const { south, west, north, east } = bounds;
|
||||
const params = new URLSearchParams({
|
||||
bounds: `${south},${west},${north},${east}`,
|
||||
});
|
||||
const filterStr = buildFilterString(filters, features);
|
||||
if (filterStr) params.set('filters', filterStr);
|
||||
const travelParam = buildTravelParam(travelTimeEntries);
|
||||
if (travelParam) params.set('travel', travelParam);
|
||||
appendTravelStateParams(params, travelTimeEntries);
|
||||
for (const overlay of selectedOverlays) {
|
||||
params.append('overlay', overlay);
|
||||
}
|
||||
if (shareCode) params.set('share', shareCode);
|
||||
const url = apiUrl('export', params);
|
||||
const postcodeList = options?.postcodes?.map((p) => p.trim()).filter(Boolean) ?? [];
|
||||
const isListMode = postcodeList.length > 0;
|
||||
|
||||
const controller = new AbortController();
|
||||
let timedOut = false;
|
||||
const timeoutId = window.setTimeout(() => {
|
||||
timedOut = true;
|
||||
controller.abort();
|
||||
}, EXPORT_TIMEOUT_MS);
|
||||
|
||||
setExporting(true);
|
||||
clearExportNotice();
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
const res = await fetch(url, authHeaders({ signal: controller.signal }));
|
||||
if (!res.ok) throw new Error(await getExportErrorMessage(res));
|
||||
|
||||
const blob = await res.blob();
|
||||
if (blob.size === 0) throw new Error(t('header.exportEmpty'));
|
||||
|
||||
triggerExportDownload(blob, getExportFileName(res));
|
||||
trackEvent('Export');
|
||||
showExportNotice({ kind: 'success', message: t('header.exportReady') });
|
||||
} catch (err) {
|
||||
if (!timedOut) logNonAbortError('Export failed', err);
|
||||
const detail = err instanceof Error && err.message.trim() ? ` ${err.message}` : '';
|
||||
showExportNotice({
|
||||
kind: 'error',
|
||||
message: timedOut ? t('header.exportTimedOut') : `${t('header.exportFailed')}${detail}`,
|
||||
});
|
||||
} finally {
|
||||
window.clearTimeout(timeoutId);
|
||||
setExporting(false);
|
||||
if (!isListMode && !bounds) {
|
||||
showExportNotice({ kind: 'error', message: t('header.exportUnavailable') });
|
||||
return;
|
||||
}
|
||||
})();
|
||||
}, [
|
||||
bounds,
|
||||
clearExportNotice,
|
||||
exporting,
|
||||
features,
|
||||
filters,
|
||||
selectedOverlays,
|
||||
shareCode,
|
||||
showExportNotice,
|
||||
t,
|
||||
travelTimeEntries,
|
||||
]);
|
||||
|
||||
const params = new URLSearchParams();
|
||||
if (isListMode) {
|
||||
params.set('postcodes', postcodeList.join(','));
|
||||
if (shareCode) params.set('share', shareCode);
|
||||
} else {
|
||||
const { south, west, north, east } = bounds!;
|
||||
params.set('bounds', `${south},${west},${north},${east}`);
|
||||
const filterStr = buildFilterString(filters, features);
|
||||
if (filterStr) params.set('filters', filterStr);
|
||||
const travelParam = buildTravelParam(travelTimeEntries);
|
||||
if (travelParam) params.set('travel', travelParam);
|
||||
appendTravelStateParams(params, travelTimeEntries);
|
||||
for (const overlay of selectedOverlays) {
|
||||
params.append('overlay', overlay);
|
||||
}
|
||||
if (shareCode) params.set('share', shareCode);
|
||||
}
|
||||
const url = apiUrl('export', params);
|
||||
|
||||
const controller = new AbortController();
|
||||
let timedOut = false;
|
||||
const timeoutId = window.setTimeout(() => {
|
||||
timedOut = true;
|
||||
controller.abort();
|
||||
}, EXPORT_TIMEOUT_MS);
|
||||
|
||||
setExporting(true);
|
||||
clearExportNotice();
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
const res = await fetch(url, authHeaders({ signal: controller.signal }));
|
||||
if (!res.ok) throw new Error(await getExportErrorMessage(res));
|
||||
|
||||
const blob = await res.blob();
|
||||
if (blob.size === 0) throw new Error(t('header.exportEmpty'));
|
||||
|
||||
triggerExportDownload(blob, getExportFileName(res));
|
||||
trackEvent('Export');
|
||||
showExportNotice({ kind: 'success', message: t('header.exportReady') });
|
||||
} catch (err) {
|
||||
if (!timedOut) logNonAbortError('Export failed', err);
|
||||
const detail = err instanceof Error && err.message.trim() ? ` ${err.message}` : '';
|
||||
showExportNotice({
|
||||
kind: 'error',
|
||||
message: timedOut
|
||||
? t('header.exportTimedOut')
|
||||
: `${t('header.exportFailed')}${detail}`,
|
||||
});
|
||||
} finally {
|
||||
window.clearTimeout(timeoutId);
|
||||
setExporting(false);
|
||||
}
|
||||
})();
|
||||
},
|
||||
[
|
||||
bounds,
|
||||
clearExportNotice,
|
||||
exporting,
|
||||
features,
|
||||
filters,
|
||||
selectedOverlays,
|
||||
shareCode,
|
||||
showExportNotice,
|
||||
t,
|
||||
travelTimeEntries,
|
||||
]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
onExportStateChange?.({ onExport: handleExport, exporting });
|
||||
|
|
|
|||
221
frontend/src/components/ui/ExportMenu.tsx
Normal file
221
frontend/src/components/ui/ExportMenu.tsx
Normal file
|
|
@ -0,0 +1,221 @@
|
|||
import { useEffect, useId, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { CloseIcon } from './icons/CloseIcon';
|
||||
import { DownloadIcon } from './icons/DownloadIcon';
|
||||
import { SpinnerIcon } from './icons/SpinnerIcon';
|
||||
|
||||
export type ExportMode = 'filters' | 'list';
|
||||
|
||||
interface ExportMenuProps {
|
||||
open: boolean;
|
||||
exporting: boolean;
|
||||
onClose: () => void;
|
||||
onExport: (options?: { postcodes?: string[] }) => void;
|
||||
}
|
||||
|
||||
const EMPTY_LIST: string[] = [''];
|
||||
|
||||
export default function ExportMenu({ open, exporting, onClose, onExport }: ExportMenuProps) {
|
||||
const { t } = useTranslation();
|
||||
const [mode, setMode] = useState<ExportMode>('filters');
|
||||
const [postcodes, setPostcodes] = useState<string[]>(EMPTY_LIST);
|
||||
const titleId = useId();
|
||||
const inputRefs = useRef<Array<HTMLInputElement | null>>([]);
|
||||
const focusIndexRef = useRef<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onClose();
|
||||
};
|
||||
window.addEventListener('keydown', handler);
|
||||
return () => window.removeEventListener('keydown', handler);
|
||||
}, [open, onClose]);
|
||||
|
||||
useEffect(() => {
|
||||
if (focusIndexRef.current == null) return;
|
||||
inputRefs.current[focusIndexRef.current]?.focus();
|
||||
focusIndexRef.current = null;
|
||||
}, [postcodes.length]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
const cleaned = postcodes.map((p) => p.trim()).filter(Boolean);
|
||||
const canSubmit = !exporting && (mode === 'filters' || cleaned.length > 0);
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (!canSubmit) return;
|
||||
if (mode === 'list') {
|
||||
onExport({ postcodes: cleaned });
|
||||
} else {
|
||||
onExport();
|
||||
}
|
||||
};
|
||||
|
||||
const updateAt = (idx: number, value: string) => {
|
||||
setPostcodes((prev) => prev.map((p, i) => (i === idx ? value : p)));
|
||||
};
|
||||
|
||||
const addRow = () => {
|
||||
setPostcodes((prev) => {
|
||||
focusIndexRef.current = prev.length;
|
||||
return [...prev, ''];
|
||||
});
|
||||
};
|
||||
|
||||
const removeAt = (idx: number) => {
|
||||
setPostcodes((prev) => {
|
||||
if (prev.length <= 1) return [''];
|
||||
return prev.filter((_, i) => i !== idx);
|
||||
});
|
||||
};
|
||||
|
||||
const handleInputKeyDown = (e: React.KeyboardEvent<HTMLInputElement>, idx: number) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
if (idx === postcodes.length - 1 && postcodes[idx].trim()) {
|
||||
addRow();
|
||||
} else {
|
||||
inputRefs.current[idx + 1]?.focus();
|
||||
}
|
||||
} else if (
|
||||
e.key === 'Backspace' &&
|
||||
postcodes[idx] === '' &&
|
||||
postcodes.length > 1
|
||||
) {
|
||||
e.preventDefault();
|
||||
focusIndexRef.current = Math.max(0, idx - 1);
|
||||
removeAt(idx);
|
||||
}
|
||||
};
|
||||
|
||||
const cardClass = (selected: boolean) =>
|
||||
`w-full text-left cursor-pointer rounded-lg border p-3 transition-colors ${
|
||||
selected
|
||||
? 'border-teal-500 bg-teal-50 dark:bg-teal-900/20'
|
||||
: 'border-warm-200 dark:border-warm-700 hover:border-warm-300 dark:hover:border-warm-600 bg-white dark:bg-warm-800'
|
||||
}`;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className="fixed inset-0 bg-black/50 z-[90]"
|
||||
onClick={onClose}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby={titleId}
|
||||
className="fixed left-1/2 top-1/2 z-[91] -translate-x-1/2 -translate-y-1/2 w-[92vw] max-w-md bg-white dark:bg-warm-800 rounded-lg shadow-xl flex flex-col max-h-[90vh]"
|
||||
>
|
||||
<div className="flex items-center justify-between px-4 h-12 border-b border-warm-200 dark:border-warm-700 shrink-0">
|
||||
<span id={titleId} className="font-semibold text-navy-950 dark:text-warm-100">
|
||||
{t('export.title')}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
aria-label={t('common.close')}
|
||||
className="flex cursor-pointer items-center justify-center w-9 h-9 -mr-2 rounded hover:bg-warm-100 dark:hover:bg-warm-700 transition-colors"
|
||||
>
|
||||
<CloseIcon className="w-5 h-5 text-warm-700 dark:text-warm-300" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="p-4 space-y-3 overflow-y-auto">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setMode('filters')}
|
||||
className={cardClass(mode === 'filters')}
|
||||
>
|
||||
<div className="font-medium text-navy-950 dark:text-warm-100">
|
||||
{t('export.modeFilters')}
|
||||
</div>
|
||||
<div className="text-xs text-warm-500 dark:text-warm-400 mt-1">
|
||||
{t('export.modeFiltersHint')}
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setMode('list')}
|
||||
className={cardClass(mode === 'list')}
|
||||
>
|
||||
<div className="font-medium text-navy-950 dark:text-warm-100">
|
||||
{t('export.modeList')}
|
||||
</div>
|
||||
<div className="text-xs text-warm-500 dark:text-warm-400 mt-1">
|
||||
{t('export.modeListHint')}
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{mode === 'list' && (
|
||||
<div className="flex flex-col gap-2 pt-1">
|
||||
<span className="text-xs font-medium text-warm-600 dark:text-warm-300">
|
||||
{t('export.listLabel')}
|
||||
</span>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
{postcodes.map((value, idx) => (
|
||||
<div key={idx} className="flex items-center gap-1.5">
|
||||
<input
|
||||
ref={(el) => {
|
||||
inputRefs.current[idx] = el;
|
||||
}}
|
||||
type="text"
|
||||
value={value}
|
||||
onChange={(e) => updateAt(idx, e.target.value)}
|
||||
onKeyDown={(e) => handleInputKeyDown(e, idx)}
|
||||
placeholder={t('export.listPlaceholder')}
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
className="flex-1 rounded border border-warm-200 dark:border-warm-700 bg-white dark:bg-warm-900 px-3 py-1.5 text-sm text-navy-950 dark:text-warm-100 font-mono uppercase placeholder:normal-case placeholder:font-sans placeholder:text-warm-400 focus:outline-none focus:border-teal-500"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeAt(idx)}
|
||||
disabled={postcodes.length === 1 && !value}
|
||||
aria-label={t('export.removeRow')}
|
||||
className="flex cursor-pointer items-center justify-center w-8 h-8 rounded text-warm-500 hover:text-warm-700 hover:bg-warm-100 dark:hover:bg-warm-700 disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
<CloseIcon className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={addRow}
|
||||
className="self-start flex cursor-pointer items-center gap-1 text-sm text-teal-700 dark:text-teal-300 hover:text-teal-800 dark:hover:text-teal-200 mt-0.5"
|
||||
>
|
||||
<span aria-hidden="true" className="text-base leading-none">
|
||||
+
|
||||
</span>
|
||||
{t('export.addRow')}
|
||||
</button>
|
||||
<div className="text-xs text-warm-500 dark:text-warm-400">
|
||||
{t('export.listCount', { count: cleaned.length })}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="px-4 py-3 border-t border-warm-200 dark:border-warm-700 shrink-0">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSubmit}
|
||||
disabled={!canSubmit}
|
||||
className="w-full flex cursor-pointer items-center justify-center gap-2 px-4 py-2 rounded bg-teal-600 hover:bg-teal-700 text-white text-sm font-semibold disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
{exporting ? (
|
||||
<SpinnerIcon className="w-4 h-4 animate-spin" />
|
||||
) : (
|
||||
<DownloadIcon className="w-4 h-4" />
|
||||
)}
|
||||
{exporting ? t('header.exporting') : t('header.exportLabel')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -15,6 +15,7 @@ import { SpinnerIcon } from './icons/SpinnerIcon';
|
|||
import UserMenu from './UserMenu';
|
||||
import MobileMenu from './MobileMenu';
|
||||
import LanguageDropdown from './LanguageDropdown';
|
||||
import ExportMenu from './ExportMenu';
|
||||
|
||||
export type Page =
|
||||
| 'home'
|
||||
|
|
@ -37,7 +38,7 @@ export type Page =
|
|||
| 'invite';
|
||||
|
||||
export interface HeaderExportState {
|
||||
onExport: () => void;
|
||||
onExport: (options?: { postcodes?: string[] }) => void;
|
||||
exporting: boolean;
|
||||
}
|
||||
|
||||
|
|
@ -110,6 +111,7 @@ export default function Header({
|
|||
const [copied, setCopied] = useState(false);
|
||||
const [sharing, setSharing] = useState(false);
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
const [exportMenuOpen, setExportMenuOpen] = useState(false);
|
||||
const [isDashboardTabletSidebarWidth, setIsDashboardTabletSidebarWidth] = useState(
|
||||
() => window.matchMedia(DASHBOARD_TABLET_SIDEBAR_QUERY).matches
|
||||
);
|
||||
|
|
@ -292,7 +294,7 @@ export default function Header({
|
|||
</button>
|
||||
{exportState && (
|
||||
<button
|
||||
onClick={exportState.onExport}
|
||||
onClick={() => setExportMenuOpen(true)}
|
||||
disabled={exportState.exporting}
|
||||
className="flex cursor-pointer 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"
|
||||
title={t('header.exportToExcel')}
|
||||
|
|
@ -407,6 +409,7 @@ export default function Header({
|
|||
theme={theme}
|
||||
onToggleTheme={onToggleTheme}
|
||||
exportState={exportState}
|
||||
onOpenExportMenu={() => setExportMenuOpen(true)}
|
||||
onSaveSearch={onSaveSearch}
|
||||
savingSearch={savingSearch}
|
||||
isEditingSearch={!!editingSearch}
|
||||
|
|
@ -420,6 +423,18 @@ export default function Header({
|
|||
sharing={sharing}
|
||||
/>
|
||||
)}
|
||||
{/* Export menu modal (shared between desktop and mobile triggers) */}
|
||||
{exportState && (
|
||||
<ExportMenu
|
||||
open={exportMenuOpen}
|
||||
exporting={exportState.exporting}
|
||||
onClose={() => setExportMenuOpen(false)}
|
||||
onExport={(opts) => {
|
||||
setExportMenuOpen(false);
|
||||
exportState.onExport(opts);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{/* Mobile "Copied" toast */}
|
||||
{useSidebarNav && 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">
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ interface MobileMenuProps {
|
|||
theme: 'light' | 'dark';
|
||||
onToggleTheme: () => void;
|
||||
exportState: HeaderExportState | null;
|
||||
onOpenExportMenu: () => void;
|
||||
onSaveSearch: (() => void) | null;
|
||||
savingSearch: boolean;
|
||||
isEditingSearch: boolean;
|
||||
|
|
@ -39,6 +40,7 @@ export default function MobileMenu({
|
|||
theme,
|
||||
onToggleTheme,
|
||||
exportState,
|
||||
onOpenExportMenu,
|
||||
onSaveSearch,
|
||||
savingSearch,
|
||||
isEditingSearch,
|
||||
|
|
@ -122,8 +124,8 @@ export default function MobileMenu({
|
|||
{exportState && (
|
||||
<button
|
||||
onClick={() => {
|
||||
exportState.onExport();
|
||||
onClose();
|
||||
onOpenExportMenu();
|
||||
}}
|
||||
disabled={exportState.exporting}
|
||||
className={dashboardActionClass}
|
||||
|
|
|
|||
|
|
@ -13,9 +13,9 @@ interface SearchHook {
|
|||
activeIndex: number;
|
||||
setActiveIndex: (idx: number) => void;
|
||||
open: boolean;
|
||||
setOpen: (open: boolean) => void;
|
||||
handleInputChange: (value: string) => void;
|
||||
handleKeyDown: (e: React.KeyboardEvent, onSelect: (result: SearchResult) => void) => void;
|
||||
showEmptySearches: () => void;
|
||||
}
|
||||
|
||||
interface PlaceSearchInputProps {
|
||||
|
|
@ -129,7 +129,7 @@ export function PlaceSearchInput({
|
|||
onInputChange?.();
|
||||
}}
|
||||
onFocus={() => {
|
||||
if (search.results.length > 0) search.setOpen(true);
|
||||
search.showEmptySearches();
|
||||
}}
|
||||
onKeyDown={(e) => search.handleKeyDown(e, onSelect)}
|
||||
placeholder={placeholder}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue