This commit is contained in:
Andras Schmelczer 2026-02-14 12:53:29 +00:00
parent 3a3f899ea2
commit 128b3191e7
68 changed files with 28060 additions and 1152 deletions

View file

@ -1,61 +1,69 @@
import { useState, useCallback } from 'react';
import { useState, useCallback, useRef, useEffect } from 'react';
import { Slider } from '../ui/Slider';
import { PillToggle } from '../ui/PillToggle';
import { PillGroup } from '../ui/PillGroup';
import { IconButton } from '../ui/IconButton';
import { PlaceSearchInput } from '../ui/PlaceSearchInput';
import { CloseIcon } from '../ui/icons/CloseIcon';
import { MapPinIcon } from '../ui/icons/MapPinIcon';
import { RouteIcon } from '../ui/icons/RouteIcon';
import { formatFilterValue } from '../../lib/format';
import { authHeaders } from '../../lib/api';
import type { TransportMode } from '../../hooks/useTravelTime';
const MODES: { value: TransportMode; label: string }[] = [
{ value: 'car', label: 'Car' },
{ value: 'bicycle', label: 'Bicycle' },
{ value: 'walking', label: 'Walking' },
{ value: 'transit', label: 'Transit' },
];
import { authHeaders, logNonAbortError } from '../../lib/api';
import { useLocationSearch, type SearchResult } from '../../hooks/useLocationSearch';
import { MODE_LABELS, type TransportMode } from '../../hooks/useTravelTime';
interface TravelTimeCardProps {
mode: TransportMode;
destination: [number, number] | null;
destinationLabel: string;
mode: TransportMode;
timeRange: [number, number] | null;
dataRange: [number, number] | null;
onSetDestination: (lat: number, lon: number, label: string) => void;
onModeChange: (mode: TransportMode) => void;
onTimeRangeChange: (range: [number, number]) => void;
onRemove: () => void;
}
export function TravelTimeCard({
mode,
destination,
destinationLabel,
mode,
timeRange,
dataRange,
onSetDestination,
onModeChange,
onTimeRangeChange,
onRemove,
}: TravelTimeCardProps) {
const [query, setQuery] = useState('');
const search = useLocationSearch();
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const containerRef = useRef<HTMLDivElement>(null);
const handleSearch = useCallback(
async (e: React.FormEvent) => {
e.preventDefault();
const trimmed = query.trim();
if (!trimmed) return;
// Close dropdown on outside click
useEffect(() => {
const handler = (e: MouseEvent) => {
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
search.close();
}
};
document.addEventListener('mousedown', handler);
return () => document.removeEventListener('mousedown', handler);
}, [search]);
const selectResult = useCallback(
async (result: SearchResult) => {
if (result.type === 'place') {
onSetDestination(result.lat, result.lon, result.name);
search.clear();
setError(null);
return;
}
// Postcode — fetch coordinates
setError(null);
setLoading(true);
search.close();
try {
const res = await fetch(
`/api/postcode/${encodeURIComponent(trimmed)}`,
authHeaders()
`/api/postcode/${encodeURIComponent(result.label)}`,
authHeaders(),
);
if (!res.ok) {
setError('Postcode not found');
@ -64,14 +72,15 @@ export function TravelTimeCard({
const json: { postcode: string; latitude: number; longitude: number } =
await res.json();
onSetDestination(json.latitude, json.longitude, json.postcode);
setQuery('');
} catch {
search.clear();
} catch (err) {
logNonAbortError('Postcode lookup failed', err);
setError('Lookup failed');
} finally {
setLoading(false);
}
},
[query, onSetDestination]
[onSetDestination, search],
);
const sliderMin = dataRange ? Math.floor(dataRange[0]) : 0;
@ -85,7 +94,7 @@ export function TravelTimeCard({
<div className="flex items-center gap-1.5">
<RouteIcon className="w-4 h-4 text-teal-600 dark:text-teal-400" />
<span className="text-sm font-medium text-navy-950 dark:text-warm-100">
Travel Time
Travel Time ({MODE_LABELS[mode]})
</span>
</div>
<IconButton onClick={() => onRemove()} title="Remove travel time">
@ -94,26 +103,17 @@ export function TravelTimeCard({
</div>
{/* Destination search */}
<div>
<form onSubmit={handleSearch} className="flex gap-1">
<input
type="text"
value={query}
onChange={(e) => {
setQuery(e.target.value);
setError(null);
}}
placeholder={destination ? 'Change destination...' : 'Enter postcode...'}
className="flex-1 min-w-0 px-2 py-1 text-xs rounded border border-warm-200 dark:border-warm-600 bg-white dark:bg-warm-800 text-navy-950 dark:text-warm-200 placeholder-warm-400 dark:placeholder-warm-500 outline-none focus:ring-1 focus:ring-teal-400"
/>
<button
type="submit"
disabled={loading || !query.trim()}
className="px-2 py-1 text-xs rounded bg-teal-600 text-white hover:bg-teal-700 disabled:opacity-50"
>
{loading ? '...' : 'Go'}
</button>
</form>
<div ref={containerRef} className="relative">
<PlaceSearchInput
search={search}
onSelect={selectResult}
loading={loading}
placeholder={destination ? 'Change destination...' : 'Search destination...'}
size="xs"
inputClassName="w-full px-2 py-1 text-xs rounded border border-warm-200 dark:border-warm-600 bg-white dark:bg-warm-800 text-navy-950 dark:text-warm-200 placeholder-warm-400 dark:placeholder-warm-500 outline-none focus:ring-1 focus:ring-teal-400"
onInputChange={() => setError(null)}
/>
{error && (
<p className="text-xs text-red-600 dark:text-red-400 mt-0.5">{error}</p>
)}
@ -127,24 +127,6 @@ export function TravelTimeCard({
)}
</div>
{/* Mode selector */}
<div>
<span className="text-[10px] font-medium text-warm-500 dark:text-warm-400 uppercase tracking-wide">
Mode
</span>
<PillGroup className="mt-0.5">
{MODES.map((m) => (
<PillToggle
key={m.value}
label={m.label}
active={mode === m.value}
onClick={() => onModeChange(m.value)}
size="xs"
/>
))}
</PillGroup>
</div>
{/* Time range slider — only show when we have data */}
{destination && dataRange && (
<div>