lmao
This commit is contained in:
parent
03445188ea
commit
524580eb25
102 changed files with 36625 additions and 1295 deletions
|
|
@ -11,7 +11,7 @@ import { FeatureActions } from '../ui/FeatureIcons';
|
|||
import { FeatureLabel } from '../ui/FeatureLabel';
|
||||
import { RouteIcon, PlusIcon } from '../ui/icons';
|
||||
import { IconButton } from '../ui/IconButton';
|
||||
import { TRANSPORT_MODES, MODE_LABELS, type TransportMode } from '../../hooks/useTravelTime';
|
||||
import { TRANSPORT_MODES, MODE_LABELS, type TransportMode, type TravelTimeEntry } from '../../hooks/useTravelTime';
|
||||
|
||||
interface FeatureBrowserProps {
|
||||
availableFeatures: FeatureMeta[];
|
||||
|
|
@ -22,8 +22,8 @@ interface FeatureBrowserProps {
|
|||
onNavigateToSource?: (slug: string, featureName: string) => void;
|
||||
openInfoFeature?: string | null;
|
||||
onClearOpenInfoFeature?: () => void;
|
||||
activeTravelModes: TransportMode[];
|
||||
onEnableTravelMode: (mode: TransportMode) => void;
|
||||
travelTimeEntries: TravelTimeEntry[];
|
||||
onAddTravelTimeEntry: (mode: TransportMode) => void;
|
||||
}
|
||||
|
||||
export default function FeatureBrowser({
|
||||
|
|
@ -35,8 +35,8 @@ export default function FeatureBrowser({
|
|||
onNavigateToSource,
|
||||
openInfoFeature,
|
||||
onClearOpenInfoFeature,
|
||||
activeTravelModes,
|
||||
onEnableTravelMode,
|
||||
travelTimeEntries,
|
||||
onAddTravelTimeEntry,
|
||||
}: FeatureBrowserProps) {
|
||||
const [search, setSearch] = useState('');
|
||||
const [infoFeature, setInfoFeature] = useState<FeatureMeta | null>(null);
|
||||
|
|
@ -61,15 +61,9 @@ export default function FeatureBrowser({
|
|||
// When searching, expand all groups so results are visible
|
||||
const isSearching = search.length > 0;
|
||||
|
||||
// Inactive modes available to add
|
||||
const inactiveModes = useMemo(
|
||||
() => TRANSPORT_MODES.filter((m) => !activeTravelModes.includes(m)),
|
||||
[activeTravelModes]
|
||||
);
|
||||
|
||||
// All modes are always available (can add multiple entries per mode)
|
||||
const showTravelModes =
|
||||
inactiveModes.length > 0 &&
|
||||
(!search || 'travel time journey commute car bicycle walking transit'.includes(search.toLowerCase()));
|
||||
!search || 'travel time journey commute car bicycle walking transit'.includes(search.toLowerCase());
|
||||
|
||||
return (
|
||||
<>
|
||||
|
|
@ -77,21 +71,21 @@ export default function FeatureBrowser({
|
|||
<SearchInput value={search} onChange={setSearch} placeholder="Search features..." />
|
||||
</div>
|
||||
<div className="md:min-h-0 md:flex-1 md:overflow-y-auto flex flex-col">
|
||||
{showTravelModes && inactiveModes.map((mode) => (
|
||||
{showTravelModes && TRANSPORT_MODES.map((mode) => (
|
||||
<div key={mode} className="shrink-0 border-b border-warm-200 dark:border-warm-700">
|
||||
<div className="flex items-start justify-between px-3 py-2 hover:bg-teal-50 dark:hover:bg-teal-900/30 cursor-pointer">
|
||||
<div className="flex items-center gap-2 min-w-0" onClick={() => onEnableTravelMode(mode)}>
|
||||
<div className="flex items-center gap-2 min-w-0" onClick={() => onAddTravelTimeEntry(mode)}>
|
||||
<RouteIcon className="w-4 h-4 text-teal-600 dark:text-teal-400 shrink-0" />
|
||||
<div className="min-w-0">
|
||||
<span className="text-sm font-medium text-navy-950 dark:text-warm-100">
|
||||
Travel Time ({MODE_LABELS[mode]})
|
||||
</span>
|
||||
<span className="text-xs text-warm-400 dark:text-warm-500 block">
|
||||
Color by journey time to a destination
|
||||
Filter by journey time to a destination
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<IconButton onClick={() => onEnableTravelMode(mode)} title={`Add ${MODE_LABELS[mode]} travel time`}>
|
||||
<IconButton onClick={() => onAddTravelTimeEntry(mode)} title={`Add ${MODE_LABELS[mode]} travel time`}>
|
||||
<PlusIcon className="w-3.5 h-3.5" />
|
||||
</IconButton>
|
||||
</div>
|
||||
|
|
@ -139,7 +133,7 @@ export default function FeatureBrowser({
|
|||
</div>
|
||||
);
|
||||
})}
|
||||
{grouped.length === 0 && !showTravelModes ? (
|
||||
{grouped.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={<FilterIcon className="w-8 h-8 text-warm-300 dark:text-warm-600" />}
|
||||
title={search ? 'No matching features' : 'All features are active'}
|
||||
|
|
|
|||
|
|
@ -18,9 +18,8 @@ import AiFilterInput from './AiFilterInput';
|
|||
import FeatureBrowser from './FeatureBrowser';
|
||||
import { TravelTimeCard } from './TravelTimeCard';
|
||||
import {
|
||||
TRANSPORT_MODES,
|
||||
type TransportMode,
|
||||
type TravelTimeEntries,
|
||||
type TravelTimeEntry,
|
||||
} from '../../hooks/useTravelTime';
|
||||
|
||||
function SliderLabels({
|
||||
|
|
@ -77,12 +76,12 @@ interface FiltersProps {
|
|||
onNavigateToSource?: (slug: string, featureName: string) => void;
|
||||
openInfoFeature?: string | null;
|
||||
onClearOpenInfoFeature?: () => void;
|
||||
travelTimeEntries: TravelTimeEntries;
|
||||
travelTimeDataRanges: Partial<Record<TransportMode, [number, number]>>;
|
||||
onTravelTimeEnableMode: (mode: TransportMode) => void;
|
||||
onTravelTimeDisableMode: (mode: TransportMode) => void;
|
||||
onTravelTimeSetDestination: (mode: TransportMode, lat: number, lon: number, label: string) => void;
|
||||
onTravelTimeRangeChange: (mode: TransportMode, range: [number, number]) => void;
|
||||
travelTimeEntries: TravelTimeEntry[];
|
||||
travelTimeDataRanges: Map<number, [number, number]>;
|
||||
onTravelTimeAddEntry: (mode: TransportMode) => void;
|
||||
onTravelTimeRemoveEntry: (index: number) => void;
|
||||
onTravelTimeSetDestination: (index: number, slug: string, label: string) => void;
|
||||
onTravelTimeRangeChange: (index: number, range: [number, number]) => void;
|
||||
aiFilterLoading: boolean;
|
||||
aiFilterError: string | null;
|
||||
aiFilterNotes: string | null;
|
||||
|
|
@ -109,8 +108,8 @@ export default memo(function Filters({
|
|||
onClearOpenInfoFeature,
|
||||
travelTimeEntries,
|
||||
travelTimeDataRanges,
|
||||
onTravelTimeEnableMode,
|
||||
onTravelTimeDisableMode,
|
||||
onTravelTimeAddEntry,
|
||||
onTravelTimeRemoveEntry,
|
||||
onTravelTimeSetDestination,
|
||||
onTravelTimeRangeChange,
|
||||
aiFilterLoading,
|
||||
|
|
@ -156,10 +155,7 @@ export default memo(function Filters({
|
|||
const [activeInfoFeature, setActiveInfoFeature] = useState<FeatureMeta | null>(null);
|
||||
const [collapsedGroups, toggleGroup] = useCollapsibleGroups();
|
||||
|
||||
const activeModes = useMemo(
|
||||
() => TRANSPORT_MODES.filter((m) => m in travelTimeEntries),
|
||||
[travelTimeEntries]
|
||||
);
|
||||
const activeEntryCount = travelTimeEntries.length;
|
||||
|
||||
const handleAddAndScroll = useCallback(
|
||||
(name: string) => {
|
||||
|
|
@ -186,7 +182,7 @@ export default memo(function Filters({
|
|||
}, [features]);
|
||||
|
||||
const hasListingFilter = !listingToggles.historical || !listingToggles.buy || !listingToggles.rent;
|
||||
const badgeCount = enabledFeatureList.length + activeModes.length + (hasListingFilter ? 1 : 0);
|
||||
const badgeCount = enabledFeatureList.length + activeEntryCount + (hasListingFilter ? 1 : 0);
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="flex flex-col bg-white dark:bg-navy-950 overflow-y-auto md:overflow-hidden h-full">
|
||||
|
|
@ -228,25 +224,22 @@ export default memo(function Filters({
|
|||
</div>
|
||||
|
||||
<div className="md:flex-1 md:overflow-y-auto">
|
||||
{activeModes.map((mode) => {
|
||||
const entry = travelTimeEntries[mode]!;
|
||||
return (
|
||||
<div key={mode} className="px-2 py-1">
|
||||
{travelTimeEntries.map((entry, index) => (
|
||||
<div key={index} className="px-2 py-1">
|
||||
<TravelTimeCard
|
||||
mode={mode}
|
||||
destination={entry.destination}
|
||||
destinationLabel={entry.destinationLabel}
|
||||
mode={entry.mode}
|
||||
slug={entry.slug}
|
||||
label={entry.label}
|
||||
timeRange={entry.timeRange}
|
||||
dataRange={travelTimeDataRanges[mode] ?? null}
|
||||
onSetDestination={(lat, lon, label) => onTravelTimeSetDestination(mode, lat, lon, label)}
|
||||
onTimeRangeChange={(range) => onTravelTimeRangeChange(mode, range)}
|
||||
onRemove={() => onTravelTimeDisableMode(mode)}
|
||||
dataRange={travelTimeDataRanges.get(index) ?? null}
|
||||
onSetDestination={(slug, label) => onTravelTimeSetDestination(index, slug, label)}
|
||||
onTimeRangeChange={(range) => onTravelTimeRangeChange(index, range)}
|
||||
onRemove={() => onTravelTimeRemoveEntry(index)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
))}
|
||||
|
||||
{enabledFeatureList.length === 0 && activeModes.length === 0 && (
|
||||
{enabledFeatureList.length === 0 && activeEntryCount === 0 && (
|
||||
<p className="px-3 py-1.5 text-xs text-warm-400 dark:text-warm-500">
|
||||
Browse features below and click + to add a filter
|
||||
</p>
|
||||
|
|
@ -378,8 +371,8 @@ export default memo(function Filters({
|
|||
onNavigateToSource={onNavigateToSource}
|
||||
openInfoFeature={openInfoFeature}
|
||||
onClearOpenInfoFeature={onClearOpenInfoFeature}
|
||||
activeTravelModes={activeModes}
|
||||
onEnableTravelMode={onTravelTimeEnableMode}
|
||||
travelTimeEntries={travelTimeEntries}
|
||||
onAddTravelTimeEntry={onTravelTimeAddEntry}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -51,7 +51,7 @@ export default function LocationSearch({
|
|||
};
|
||||
document.addEventListener('mousedown', handler);
|
||||
return () => document.removeEventListener('mousedown', handler);
|
||||
}, [isMobile, search]);
|
||||
}, [isMobile, search.close]);
|
||||
|
||||
// Focus input when expanding on mobile
|
||||
useEffect(() => {
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ import MapLegend from './MapLegend';
|
|||
import HoverCard from './HoverCard';
|
||||
import type { FeatureFilters } from '../../types';
|
||||
import { useDeckLayers, osmIdToUrl } from '../../hooks/useDeckLayers';
|
||||
import { MODE_LABELS, type TransportMode, type TravelTimeEntries } from '../../hooks/useTravelTime';
|
||||
import { MODE_LABELS, type TravelTimeEntry, travelFieldKey } from '../../hooks/useTravelTime';
|
||||
|
||||
interface MapProps {
|
||||
data: HexagonData[];
|
||||
|
|
@ -48,10 +48,13 @@ interface MapProps {
|
|||
onLocationSearched?: (location: SearchedLocation | null) => void;
|
||||
bounds?: Bounds | null;
|
||||
hideLegend?: boolean;
|
||||
travelTimeEntries?: TravelTimeEntries;
|
||||
travelTimeColorRanges?: Partial<Record<TransportMode, [number, number]>>;
|
||||
travelTimeEntries?: TravelTimeEntry[];
|
||||
travelTimeColorRanges?: Map<number, [number, number]>;
|
||||
}
|
||||
|
||||
const EMPTY_TRAVEL_ENTRIES: TravelTimeEntry[] = [];
|
||||
const EMPTY_TRAVEL_RANGES = new globalThis.Map<number, [number, number]>();
|
||||
|
||||
interface Dimensions {
|
||||
width: number;
|
||||
height: number;
|
||||
|
|
@ -102,8 +105,8 @@ export default memo(function Map({
|
|||
onLocationSearched,
|
||||
bounds: viewportBounds,
|
||||
hideLegend = false,
|
||||
travelTimeEntries = {},
|
||||
travelTimeColorRanges = {},
|
||||
travelTimeEntries = EMPTY_TRAVEL_ENTRIES,
|
||||
travelTimeColorRanges = EMPTY_TRAVEL_RANGES,
|
||||
}: MapProps) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [viewState, setViewState] = useState<ViewState>(initialViewState || INITIAL_VIEW_STATE);
|
||||
|
|
@ -166,7 +169,7 @@ export default memo(function Map({
|
|||
postcodeCountRange,
|
||||
colorFeatureMeta,
|
||||
handleMouseLeave,
|
||||
primaryTravelMode,
|
||||
primaryTravelIndex,
|
||||
} = useDeckLayers({
|
||||
data,
|
||||
postcodeData,
|
||||
|
|
@ -221,10 +224,10 @@ export default memo(function Map({
|
|||
<>
|
||||
<LocationSearch onFlyTo={handleFlyTo} onLocationSearched={onLocationSearched} />
|
||||
{!hideLegend &&
|
||||
(primaryTravelMode && travelTimeColorRanges[primaryTravelMode] ? (
|
||||
(primaryTravelIndex >= 0 && travelTimeColorRanges.get(primaryTravelIndex) ? (
|
||||
<MapLegend
|
||||
featureLabel={`Travel time (${MODE_LABELS[primaryTravelMode]})`}
|
||||
range={travelTimeColorRanges[primaryTravelMode]!}
|
||||
featureLabel={`Travel time (${MODE_LABELS[travelTimeEntries[primaryTravelIndex].mode]})`}
|
||||
range={travelTimeColorRanges.get(primaryTravelIndex)!}
|
||||
showCancel={false}
|
||||
onCancel={onCancelPin}
|
||||
mode="feature"
|
||||
|
|
|
|||
|
|
@ -23,12 +23,13 @@ import { getTutorialStyles } from '../../lib/tutorial-styles';
|
|||
import Joyride from 'react-joyride';
|
||||
import {
|
||||
useTravelTime,
|
||||
TRANSPORT_MODES,
|
||||
MODE_LABELS,
|
||||
type TransportMode,
|
||||
travelFieldKey,
|
||||
type TravelTimeInitial,
|
||||
} from '../../hooks/useTravelTime';
|
||||
import { apiUrl, assertOk, buildFilterString, logNonAbortError } from '../../lib/api';
|
||||
import { apiUrl, assertOk, authHeaders, buildFilterString, logNonAbortError } from '../../lib/api';
|
||||
import { useLicense } from '../../hooks/useLicense';
|
||||
import UpgradeModal from '../ui/UpgradeModal';
|
||||
import { SpinnerIcon } from '../ui/icons/SpinnerIcon';
|
||||
import { MapPinIcon } from '../ui/icons/MapPinIcon';
|
||||
|
||||
|
|
@ -54,6 +55,9 @@ interface MapPageProps {
|
|||
ogMode?: boolean;
|
||||
isMobile?: boolean;
|
||||
initialTravelTime?: TravelTimeInitial;
|
||||
user?: { id: string; subscription: string } | null;
|
||||
onLoginClick?: () => void;
|
||||
onRegisterClick?: () => void;
|
||||
}
|
||||
|
||||
export default function MapPage({
|
||||
|
|
@ -73,6 +77,9 @@ export default function MapPage({
|
|||
ogMode,
|
||||
isMobile = false,
|
||||
initialTravelTime,
|
||||
user,
|
||||
onLoginClick,
|
||||
onRegisterClick,
|
||||
}: MapPageProps) {
|
||||
const [selectedPOICategories, setSelectedPOICategories] =
|
||||
useState<Set<string>>(initialPOICategories);
|
||||
|
|
@ -125,6 +132,9 @@ export default function MapPage({
|
|||
// Travel time hook
|
||||
const travelTime = useTravelTime(initialTravelTime);
|
||||
|
||||
// License hook
|
||||
const license = useLicense();
|
||||
|
||||
// Map data hook
|
||||
const mapData = useMapData({
|
||||
filters,
|
||||
|
|
@ -164,20 +174,21 @@ export default function MapPage({
|
|||
// POI data
|
||||
const pois = usePOIData(mapData.bounds, selectedPOICategories);
|
||||
|
||||
// Compute data range for travel time slider per mode (full min/max for slider bounds)
|
||||
const travelTimeDataRanges = useMemo((): Partial<Record<TransportMode, [number, number]>> => {
|
||||
const ranges: Partial<Record<TransportMode, [number, number]>> = {};
|
||||
for (const mode of TRANSPORT_MODES) {
|
||||
const entry = travelTime.entries[mode];
|
||||
if (!entry?.destination) continue;
|
||||
// Compute data range for travel time slider per entry index (full min/max for slider bounds)
|
||||
const travelTimeDataRanges = useMemo((): globalThis.Map<number, [number, number]> => {
|
||||
const ranges = new globalThis.Map<number, [number, number]>();
|
||||
for (let i = 0; i < travelTime.entries.length; i++) {
|
||||
const entry = travelTime.entries[i];
|
||||
if (!entry.slug) continue;
|
||||
const fieldName = `avg_${travelFieldKey(entry)}`;
|
||||
const vals: number[] = [];
|
||||
for (const item of mapData.data) {
|
||||
const val = item[`travel_time_${mode}`];
|
||||
const val = item[fieldName];
|
||||
if (typeof val === 'number' && !isNaN(val)) vals.push(val);
|
||||
}
|
||||
if (vals.length === 0) continue;
|
||||
vals.sort((a, b) => a - b);
|
||||
ranges[mode] = [vals[0], vals[vals.length - 1]];
|
||||
ranges.set(i, [vals[0], vals[vals.length - 1]]);
|
||||
}
|
||||
return ranges;
|
||||
}, [travelTime.entries, mapData.data]);
|
||||
|
|
@ -253,7 +264,7 @@ export default function MapPage({
|
|||
const url = apiUrl('export', params);
|
||||
|
||||
setExporting(true);
|
||||
fetch(url)
|
||||
fetch(url, authHeaders())
|
||||
.then((res) => {
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
return res.blob();
|
||||
|
|
@ -397,8 +408,8 @@ export default function MapPage({
|
|||
onClearOpenInfoFeature={onClearPendingInfoFeature}
|
||||
travelTimeEntries={travelTime.entries}
|
||||
travelTimeDataRanges={travelTimeDataRanges}
|
||||
onTravelTimeEnableMode={travelTime.handleEnableMode}
|
||||
onTravelTimeDisableMode={travelTime.handleDisableMode}
|
||||
onTravelTimeAddEntry={travelTime.handleAddEntry}
|
||||
onTravelTimeRemoveEntry={travelTime.handleRemoveEntry}
|
||||
onTravelTimeSetDestination={travelTime.handleSetDestination}
|
||||
onTravelTimeRangeChange={travelTime.handleTimeRangeChange}
|
||||
aiFilterLoading={aiFilters.loading}
|
||||
|
|
@ -478,14 +489,14 @@ export default function MapPage({
|
|||
>
|
||||
{/* Legend */}
|
||||
{(() => {
|
||||
const primaryMode = TRANSPORT_MODES.find(
|
||||
(m) => travelTime.entries[m]?.destination && mapData.travelTimeColorRanges[m]
|
||||
const primaryIdx = travelTime.entries.findIndex(
|
||||
(e, i) => e.slug && mapData.travelTimeColorRanges.get(i)
|
||||
);
|
||||
if (primaryMode) {
|
||||
if (primaryIdx >= 0) {
|
||||
return (
|
||||
<MapLegend
|
||||
featureLabel={`Travel time (${MODE_LABELS[primaryMode]})`}
|
||||
range={mapData.travelTimeColorRanges[primaryMode]!}
|
||||
featureLabel={`Travel time (${MODE_LABELS[travelTime.entries[primaryIdx].mode]})`}
|
||||
range={mapData.travelTimeColorRanges.get(primaryIdx)!}
|
||||
showCancel={false}
|
||||
onCancel={handleCancelPin}
|
||||
mode="feature"
|
||||
|
|
@ -539,6 +550,16 @@ export default function MapPage({
|
|||
renderProperties={renderPropertiesPane}
|
||||
/>
|
||||
)}
|
||||
|
||||
{mapData.licenseRequired && (
|
||||
<UpgradeModal
|
||||
isLoggedIn={!!user}
|
||||
onLoginClick={onLoginClick ?? (() => {})}
|
||||
onRegisterClick={onRegisterClick ?? (() => {})}
|
||||
onStartCheckout={() => license.startCheckout()}
|
||||
onDismiss={() => {}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -664,6 +685,16 @@ export default function MapPage({
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{mapData.licenseRequired && (
|
||||
<UpgradeModal
|
||||
isLoggedIn={!!user}
|
||||
onLoginClick={onLoginClick ?? (() => {})}
|
||||
onRegisterClick={onRegisterClick ?? (() => {})}
|
||||
onStartCheckout={() => license.startCheckout()}
|
||||
onDismiss={() => {}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { useState, useCallback, useRef, useEffect } from 'react';
|
||||
import { useRef, useEffect, useCallback } from 'react';
|
||||
import { Slider } from '../ui/Slider';
|
||||
import { IconButton } from '../ui/IconButton';
|
||||
import { PlaceSearchInput } from '../ui/PlaceSearchInput';
|
||||
|
|
@ -6,34 +6,31 @@ 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, 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;
|
||||
slug: string;
|
||||
label: string;
|
||||
timeRange: [number, number] | null;
|
||||
dataRange: [number, number] | null;
|
||||
onSetDestination: (lat: number, lon: number, label: string) => void;
|
||||
onSetDestination: (slug: string, label: string) => void;
|
||||
onTimeRangeChange: (range: [number, number]) => void;
|
||||
onRemove: () => void;
|
||||
}
|
||||
|
||||
export function TravelTimeCard({
|
||||
mode,
|
||||
destination,
|
||||
destinationLabel,
|
||||
slug,
|
||||
label,
|
||||
timeRange,
|
||||
dataRange,
|
||||
onSetDestination,
|
||||
onTimeRangeChange,
|
||||
onRemove,
|
||||
}: TravelTimeCardProps) {
|
||||
const search = useLocationSearch();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const search = useLocationSearch(mode);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Close dropdown on outside click
|
||||
|
|
@ -45,42 +42,16 @@ export function TravelTimeCard({
|
|||
};
|
||||
document.addEventListener('mousedown', handler);
|
||||
return () => document.removeEventListener('mousedown', handler);
|
||||
}, [search]);
|
||||
}, [search.close]);
|
||||
|
||||
const selectResult = useCallback(
|
||||
async (result: SearchResult) => {
|
||||
(result: SearchResult) => {
|
||||
if (result.type === 'place') {
|
||||
onSetDestination(result.lat, result.lon, result.name);
|
||||
onSetDestination(result.slug, result.name);
|
||||
search.clear();
|
||||
setError(null);
|
||||
return;
|
||||
}
|
||||
|
||||
// Postcode — fetch coordinates
|
||||
setError(null);
|
||||
setLoading(true);
|
||||
search.close();
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/postcode/${encodeURIComponent(result.label)}`,
|
||||
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);
|
||||
search.clear();
|
||||
} catch (err) {
|
||||
logNonAbortError('Postcode lookup failed', err);
|
||||
setError('Lookup failed');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
},
|
||||
[onSetDestination, search],
|
||||
[onSetDestination, search.clear],
|
||||
);
|
||||
|
||||
const sliderMin = dataRange ? Math.floor(dataRange[0]) : 0;
|
||||
|
|
@ -107,28 +78,23 @@ export function TravelTimeCard({
|
|||
<PlaceSearchInput
|
||||
search={search}
|
||||
onSelect={selectResult}
|
||||
loading={loading}
|
||||
placeholder={destination ? 'Change destination...' : 'Search destination...'}
|
||||
placeholder={slug ? '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>
|
||||
)}
|
||||
{destination && destinationLabel && (
|
||||
{slug && label && (
|
||||
<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}
|
||||
{label}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Time range slider — only show when we have data */}
|
||||
{destination && dataRange && (
|
||||
{slug && dataRange && (
|
||||
<div>
|
||||
<span className="text-[10px] font-medium text-warm-500 dark:text-warm-400 uppercase tracking-wide">
|
||||
Max time
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue