172 lines
5.8 KiB
TypeScript
172 lines
5.8 KiB
TypeScript
import { useState, useCallback } from 'react';
|
|
import { Slider } from '../ui/Slider';
|
|
import { PillToggle } from '../ui/PillToggle';
|
|
import { PillGroup } from '../ui/PillGroup';
|
|
import { IconButton } from '../ui/IconButton';
|
|
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: 'transit', label: 'Transit' },
|
|
{ value: 'car', label: 'Car' },
|
|
{ value: 'bicycle', label: 'Bicycle' },
|
|
];
|
|
|
|
interface TravelTimeCardProps {
|
|
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({
|
|
destination,
|
|
destinationLabel,
|
|
mode,
|
|
timeRange,
|
|
dataRange,
|
|
onSetDestination,
|
|
onModeChange,
|
|
onTimeRangeChange,
|
|
onRemove,
|
|
}: TravelTimeCardProps) {
|
|
const [query, setQuery] = useState('');
|
|
const [loading, setLoading] = useState(false);
|
|
const [error, setError] = useState<string | null>(null);
|
|
|
|
const handleSearch = useCallback(
|
|
async (e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
const trimmed = query.trim();
|
|
if (!trimmed) return;
|
|
|
|
setError(null);
|
|
setLoading(true);
|
|
try {
|
|
const res = await fetch(
|
|
`/api/postcode/${encodeURIComponent(trimmed)}`,
|
|
authHeaders()
|
|
);
|
|
if (!res.ok) {
|
|
setError('Postcode not found');
|
|
return;
|
|
}
|
|
const json: { postcode: string; latitude: number; longitude: number } =
|
|
await res.json();
|
|
onSetDestination(json.latitude, json.longitude, json.postcode);
|
|
setQuery('');
|
|
} catch {
|
|
setError('Lookup failed');
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
},
|
|
[query, onSetDestination]
|
|
);
|
|
|
|
const sliderMin = dataRange ? Math.floor(dataRange[0]) : 0;
|
|
const sliderMax = dataRange ? Math.ceil(dataRange[1]) : 120;
|
|
const displayRange = timeRange ?? [sliderMin, sliderMax];
|
|
|
|
return (
|
|
<div className="space-y-2 px-2 py-2 rounded ring-2 ring-teal-400 bg-teal-50/50 dark:bg-teal-900/20">
|
|
{/* Header */}
|
|
<div className="flex items-center justify-between">
|
|
<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
|
|
</span>
|
|
</div>
|
|
<IconButton onClick={() => onRemove()} title="Remove travel time">
|
|
<CloseIcon className="w-3.5 h-3.5" />
|
|
</IconButton>
|
|
</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>
|
|
{error && (
|
|
<p className="text-xs text-red-600 dark:text-red-400 mt-0.5">{error}</p>
|
|
)}
|
|
{destination && destinationLabel && (
|
|
<div className="flex items-center gap-1 mt-1">
|
|
<MapPinIcon className="w-3 h-3 text-red-500 shrink-0" />
|
|
<span className="text-xs text-warm-600 dark:text-warm-300">
|
|
{destinationLabel}
|
|
</span>
|
|
</div>
|
|
)}
|
|
</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>
|
|
<span className="text-[10px] font-medium text-warm-500 dark:text-warm-400 uppercase tracking-wide">
|
|
Max time
|
|
</span>
|
|
<Slider
|
|
min={sliderMin}
|
|
max={sliderMax}
|
|
step={1}
|
|
value={[displayRange[0], displayRange[1]]}
|
|
onValueChange={([min, max]) => onTimeRangeChange([min, max])}
|
|
/>
|
|
<div className="relative h-4 mt-1 mx-2.5 text-[10px] text-warm-500 dark:text-warm-400 leading-tight">
|
|
<span className="absolute left-0">
|
|
{formatFilterValue(displayRange[0])} min
|
|
</span>
|
|
<span className="absolute right-0">
|
|
{formatFilterValue(displayRange[1])} min
|
|
</span>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|