Lots of improvements
This commit is contained in:
parent
3853b5dce7
commit
b94cf17d75
33 changed files with 2587 additions and 1866 deletions
|
|
@ -2,7 +2,7 @@ import { Fragment, memo, useState, useMemo, useRef, useCallback, useEffect } fro
|
|||
import { useTranslation } from 'react-i18next';
|
||||
import { ts } from '../../i18n/server';
|
||||
import { Slider } from '../ui/Slider';
|
||||
import { ChevronIcon, LightbulbIcon } from '../ui/icons';
|
||||
import { ChevronIcon, CloseIcon, LightbulbIcon, SpinnerIcon } from '../ui/icons';
|
||||
|
||||
import { PillToggle } from '../ui/PillToggle';
|
||||
import { PillGroup } from '../ui/PillGroup';
|
||||
|
|
@ -203,6 +203,9 @@ interface FiltersProps {
|
|||
onUpgradeClick?: () => void;
|
||||
onResetTutorial?: () => void;
|
||||
filterImpacts?: Record<string, number>;
|
||||
onClearAll: () => void;
|
||||
onSaveSearch?: (name: string) => Promise<void>;
|
||||
savingSearch?: boolean;
|
||||
}
|
||||
|
||||
export default memo(function Filters({
|
||||
|
|
@ -242,6 +245,9 @@ export default memo(function Filters({
|
|||
onUpgradeClick,
|
||||
onResetTutorial,
|
||||
filterImpacts,
|
||||
onClearAll,
|
||||
onSaveSearch,
|
||||
savingSearch,
|
||||
}: FiltersProps) {
|
||||
const { t } = useTranslation();
|
||||
const modeRestrictions = useMemo(() => {
|
||||
|
|
@ -416,6 +422,50 @@ export default memo(function Filters({
|
|||
|
||||
const badgeCount = enabledFeatureList.length + activeEntryCount;
|
||||
|
||||
const [showClearPopup, setShowClearPopup] = useState(false);
|
||||
const [clearSaveName, setClearSaveName] = useState('');
|
||||
const [clearSaveError, setClearSaveError] = useState<string | null>(null);
|
||||
|
||||
const handleClearAllClick = useCallback(() => {
|
||||
if (badgeCount === 0) return;
|
||||
if (onSaveSearch) {
|
||||
setShowClearPopup(true);
|
||||
setClearSaveName('');
|
||||
setClearSaveError(null);
|
||||
} else {
|
||||
onClearAll();
|
||||
}
|
||||
}, [badgeCount, onSaveSearch, onClearAll]);
|
||||
|
||||
const handleSaveAndClear = useCallback(
|
||||
async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!clearSaveName.trim() || savingSearch) return;
|
||||
try {
|
||||
await onSaveSearch!(clearSaveName.trim());
|
||||
setShowClearPopup(false);
|
||||
onClearAll();
|
||||
} catch {
|
||||
setClearSaveError(t('saveSearch.saving'));
|
||||
}
|
||||
},
|
||||
[clearSaveName, savingSearch, onSaveSearch, onClearAll, t]
|
||||
);
|
||||
|
||||
const handleClearWithoutSaving = useCallback(() => {
|
||||
setShowClearPopup(false);
|
||||
onClearAll();
|
||||
}, [onClearAll]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!showClearPopup) return;
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') setShowClearPopup(false);
|
||||
};
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
return () => document.removeEventListener('keydown', handleKeyDown);
|
||||
}, [showClearPopup]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
|
|
@ -424,8 +474,7 @@ export default memo(function Filters({
|
|||
<div
|
||||
className="flex flex-col min-h-0"
|
||||
style={{
|
||||
flexGrow: activeFilterCollapsed ? 0 : addFilterCollapsed ? 1 : 3,
|
||||
flexShrink: activeFilterCollapsed ? 0 : 1,
|
||||
flex: activeFilterCollapsed ? '0 0 auto' : addFilterCollapsed ? '1 1 0' : '3 1 0',
|
||||
}}
|
||||
>
|
||||
<button
|
||||
|
|
@ -442,10 +491,31 @@ export default memo(function Filters({
|
|||
</span>
|
||||
)}
|
||||
</div>
|
||||
<ChevronIcon
|
||||
direction={activeFilterCollapsed ? 'down' : 'up'}
|
||||
className="w-4 h-4 text-warm-400 dark:text-warm-500"
|
||||
/>
|
||||
<div className="flex items-center gap-2">
|
||||
{badgeCount > 0 && (
|
||||
<span
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleClearAllClick();
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.stopPropagation();
|
||||
handleClearAllClick();
|
||||
}
|
||||
}}
|
||||
className="text-xs text-warm-500 dark:text-warm-400 hover:text-warm-700 dark:hover:text-warm-200 underline"
|
||||
>
|
||||
{t('filters.clearAll')}
|
||||
</span>
|
||||
)}
|
||||
<ChevronIcon
|
||||
direction={activeFilterCollapsed ? 'down' : 'up'}
|
||||
className="w-4 h-4 text-warm-400 dark:text-warm-500"
|
||||
/>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{!activeFilterCollapsed && <div ref={scrollRef} className="flex-1 min-h-0 overflow-y-auto overflow-x-hidden">
|
||||
|
|
@ -521,6 +591,7 @@ export default memo(function Filters({
|
|||
onDragEnd={() => onTravelTimeDragEnd(index)}
|
||||
onToggleBest={() => onTravelTimeToggleBest(index)}
|
||||
onRemove={() => onTravelTimeRemoveEntry(index)}
|
||||
filterImpact={filterImpacts?.[travelFieldKey(entry)]}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
|
|
@ -618,6 +689,7 @@ export default memo(function Filters({
|
|||
onDragEnd={() => onTravelTimeDragEnd(index)}
|
||||
onToggleBest={() => onTravelTimeToggleBest(index)}
|
||||
onRemove={() => onTravelTimeRemoveEntry(index)}
|
||||
filterImpact={filterImpacts?.[travelFieldKey(entry)]}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
|
|
@ -625,7 +697,7 @@ export default memo(function Filters({
|
|||
data-filter-name={feature.name}
|
||||
className={`space-y-0.5 px-2 py-1.5 rounded ${isActive ? 'ring-2 ring-teal-400 bg-teal-50 dark:bg-teal-900/30' : isPinned ? 'ring-2 ring-teal-400 bg-teal-50/50 dark:bg-teal-900/20' : ''}`}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-1">
|
||||
<div className="relative z-10 flex items-center justify-between gap-1">
|
||||
<FeatureLabel feature={feature} size="sm" className="min-w-0 shrink" hideIconOnMobile />
|
||||
<FeatureActions
|
||||
feature={feature}
|
||||
|
|
@ -705,6 +777,7 @@ export default memo(function Filters({
|
|||
onDragEnd={() => onTravelTimeDragEnd(index)}
|
||||
onToggleBest={() => onTravelTimeToggleBest(index)}
|
||||
onRemove={() => onTravelTimeRemoveEntry(index)}
|
||||
filterImpact={filterImpacts?.[travelFieldKey(entry)]}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
|
|
@ -715,8 +788,7 @@ export default memo(function Filters({
|
|||
<div
|
||||
className="flex flex-col min-h-0 border-t border-warm-200 dark:border-warm-700"
|
||||
style={{
|
||||
flexGrow: addFilterCollapsed ? 0 : activeFilterCollapsed ? 1 : 2,
|
||||
flexShrink: addFilterCollapsed ? 0 : 1,
|
||||
flex: addFilterCollapsed ? '0 0 auto' : activeFilterCollapsed ? '1 1 0' : '2 1 0',
|
||||
}}
|
||||
>
|
||||
<button
|
||||
|
|
@ -730,7 +802,7 @@ export default memo(function Filters({
|
|||
/>
|
||||
</button>
|
||||
{!addFilterCollapsed && (
|
||||
<div className="flex-1 min-h-0 overflow-y-auto flex flex-col">
|
||||
<div className="flex-1 min-h-0 overflow-y-auto">
|
||||
<FeatureBrowser
|
||||
availableFeatures={availableFeatures}
|
||||
allFeatures={features}
|
||||
|
|
@ -826,6 +898,63 @@ export default memo(function Filters({
|
|||
onNavigateToSource={onNavigateToSource}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showClearPopup && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center" onClick={() => setShowClearPopup(false)}>
|
||||
<div className="absolute inset-0 bg-black/50 dark:bg-black/70" />
|
||||
<div
|
||||
className="relative w-full max-w-sm mx-4 bg-white dark:bg-warm-900 rounded-lg shadow-xl border border-warm-200 dark:border-warm-700"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-center justify-between px-5 pt-5 pb-3">
|
||||
<h2 className="text-lg font-semibold text-navy-950 dark:text-white">
|
||||
{t('filters.clearAllTitle')}
|
||||
</h2>
|
||||
<button
|
||||
onClick={() => setShowClearPopup(false)}
|
||||
className="text-warm-400 hover:text-warm-700 dark:text-warm-400 dark:hover:text-warm-200"
|
||||
>
|
||||
<CloseIcon className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
<form onSubmit={handleSaveAndClear} className="p-5 pt-2 space-y-4">
|
||||
<p className="text-sm text-warm-600 dark:text-warm-400">
|
||||
{t('filters.clearAllSavePrompt')}
|
||||
</p>
|
||||
<div>
|
||||
<input
|
||||
type="text"
|
||||
value={clearSaveName}
|
||||
onChange={(e) => setClearSaveName(e.target.value)}
|
||||
className="w-full px-3 py-2 text-sm rounded border border-warm-200 dark:border-warm-700 bg-white dark:bg-warm-800 text-navy-950 dark:text-white placeholder-warm-400 dark:placeholder-warm-500 outline-none focus:ring-2 ring-teal-400 dark:ring-teal-500"
|
||||
placeholder={t('saveSearch.namePlaceholder')}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
{clearSaveError && (
|
||||
<p className="text-sm text-red-600 dark:text-red-300">{clearSaveError}</p>
|
||||
)}
|
||||
<div className="flex gap-3 justify-end">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClearWithoutSaving}
|
||||
className="px-4 py-2 text-sm rounded border border-warm-200 dark:border-warm-700 text-warm-700 dark:text-warm-300 hover:bg-warm-50 dark:hover:bg-warm-700"
|
||||
>
|
||||
{t('filters.clearWithoutSaving')}
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!clearSaveName.trim() || savingSearch}
|
||||
className="flex items-center gap-2 px-4 py-2 text-sm rounded bg-teal-600 text-white font-medium hover:bg-teal-700 disabled:opacity-50 disabled:cursor-wait"
|
||||
>
|
||||
{savingSearch && <SpinnerIcon className="w-4 h-4 animate-spin" />}
|
||||
{savingSearch ? t('saveSearch.saving') : t('filters.saveAndClear')}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,9 +1,11 @@
|
|||
import { useState, useCallback, useRef, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { PostcodeGeometry } from '../../types';
|
||||
import { authHeaders } from '../../lib/api';
|
||||
import { useIsMobile } from '../../hooks/useIsMobile';
|
||||
import { useLocationSearch, type SearchResult } from '../../hooks/useLocationSearch';
|
||||
import { PlaceSearchInput } from '../ui/PlaceSearchInput';
|
||||
import { LocateIcon } from '../ui/icons/LocateIcon';
|
||||
import { SearchIcon } from '../ui/icons/SearchIcon';
|
||||
|
||||
export interface SearchedLocation {
|
||||
|
|
@ -14,6 +16,7 @@ export interface SearchedLocation {
|
|||
const ZOOM_FOR_TYPE: Record<string, number> = {
|
||||
city: 10,
|
||||
borough: 12,
|
||||
outcode: 14,
|
||||
town: 13,
|
||||
suburb: 14,
|
||||
quarter: 14,
|
||||
|
|
@ -35,6 +38,7 @@ export default function LocationSearch({
|
|||
onLocationSearched?: (postcode: SearchedLocation | null) => void;
|
||||
onMouseEnter?: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const search = useLocationSearch();
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
|
@ -80,7 +84,7 @@ export default function LocationSearch({
|
|||
try {
|
||||
const res = await fetch(`/api/postcode/${encodeURIComponent(result.label)}`, authHeaders());
|
||||
if (!res.ok) {
|
||||
setError('Postcode not found');
|
||||
setError(t('locationSearch.postcodeNotFound'));
|
||||
return;
|
||||
}
|
||||
const json: {
|
||||
|
|
@ -94,7 +98,7 @@ export default function LocationSearch({
|
|||
search.clear();
|
||||
if (isMobile) setExpanded(false);
|
||||
} catch {
|
||||
setError('Lookup failed');
|
||||
setError(t('locationSearch.lookupFailed'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
|
|
@ -102,17 +106,71 @@ export default function LocationSearch({
|
|||
[onFlyTo, onLocationSearched, isMobile, search]
|
||||
);
|
||||
|
||||
// Mobile collapsed state: just a search icon button
|
||||
const [locating, setLocating] = useState(false);
|
||||
|
||||
const locateUser = useCallback(async () => {
|
||||
if (!navigator.geolocation) {
|
||||
setError(t('locationSearch.geolocationUnsupported'));
|
||||
return;
|
||||
}
|
||||
setError(null);
|
||||
setLocating(true);
|
||||
search.close();
|
||||
try {
|
||||
const position = await new Promise<GeolocationPosition>((resolve, reject) => {
|
||||
navigator.geolocation.getCurrentPosition(resolve, reject, {
|
||||
enableHighAccuracy: true,
|
||||
timeout: 10000,
|
||||
});
|
||||
});
|
||||
const { latitude, longitude } = position.coords;
|
||||
const res = await fetch(
|
||||
`/api/nearest-postcode?lat=${latitude}&lng=${longitude}`,
|
||||
authHeaders()
|
||||
);
|
||||
if (!res.ok) {
|
||||
setError(t('locationSearch.lookupFailed'));
|
||||
return;
|
||||
}
|
||||
const json: {
|
||||
postcode: string;
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
geometry: PostcodeGeometry;
|
||||
} = await res.json();
|
||||
onFlyTo(json.latitude, json.longitude, 16);
|
||||
onLocationSearched?.({ postcode: json.postcode, geometry: json.geometry });
|
||||
search.clear();
|
||||
if (isMobile) setExpanded(false);
|
||||
} catch {
|
||||
setError(t('locationSearch.geolocationFailed'));
|
||||
} finally {
|
||||
setLocating(false);
|
||||
}
|
||||
}, [onFlyTo, onLocationSearched, isMobile, search, t]);
|
||||
|
||||
// Mobile collapsed state: search icon + locate button
|
||||
if (isMobile && !expanded) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setExpanded(true)}
|
||||
className="p-2 bg-white dark:bg-warm-800 rounded shadow-lg pointer-events-auto"
|
||||
aria-label="Search places or postcodes"
|
||||
>
|
||||
<SearchIcon className="w-5 h-5 text-warm-600 dark:text-warm-300" />
|
||||
</button>
|
||||
<div className="flex gap-2 pointer-events-auto">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setExpanded(true)}
|
||||
className="p-2 bg-white dark:bg-warm-800 rounded shadow-lg"
|
||||
aria-label={t('locationSearch.searchLabel')}
|
||||
>
|
||||
<SearchIcon className="w-5 h-5 text-warm-600 dark:text-warm-300" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={locateUser}
|
||||
disabled={locating}
|
||||
className="p-2 bg-white dark:bg-warm-800 rounded shadow-lg text-warm-600 dark:text-warm-300 hover:text-teal-600 dark:hover:text-teal-400 disabled:opacity-50"
|
||||
aria-label={t('locationSearch.locateMe')}
|
||||
>
|
||||
<LocateIcon className={`w-5 h-5 ${locating ? 'animate-pulse' : ''}`} />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -129,12 +187,22 @@ export default function LocationSearch({
|
|||
search={search}
|
||||
onSelect={selectResult}
|
||||
loading={loading}
|
||||
placeholder="Search places or postcodes..."
|
||||
placeholder={t('locationSearch.placeholder')}
|
||||
size="sm"
|
||||
inputClassName="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)}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={locateUser}
|
||||
disabled={locating}
|
||||
className="p-2 mr-0.5 rounded hover:bg-warm-100 dark:hover:bg-warm-700 text-warm-400 dark:text-warm-500 hover:text-teal-600 dark:hover:text-teal-400 disabled:opacity-50"
|
||||
aria-label={t('locationSearch.locateMe')}
|
||||
title={t('locationSearch.locateMe')}
|
||||
>
|
||||
<LocateIcon className={`w-4 h-4 ${locating ? 'animate-pulse' : ''}`} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ import { CloseIcon } from '../ui/icons/CloseIcon';
|
|||
import type { FeatureFilters } from '../../types';
|
||||
import { useDeckLayers } from '../../hooks/useDeckLayers';
|
||||
import { useTranslatedModes, type TravelTimeEntry } from '../../hooks/useTravelTime';
|
||||
import { ts } from '../../i18n/server';
|
||||
|
||||
interface MapProps {
|
||||
data: HexagonData[];
|
||||
|
|
@ -59,6 +60,7 @@ interface MapProps {
|
|||
hideLegend?: boolean;
|
||||
travelTimeEntries?: TravelTimeEntry[];
|
||||
densityLabel?: string;
|
||||
totalCount?: number;
|
||||
}
|
||||
|
||||
const EMPTY_TRAVEL_ENTRIES: TravelTimeEntry[] = [];
|
||||
|
|
@ -115,11 +117,13 @@ export default memo(function Map({
|
|||
bounds: viewportBounds,
|
||||
hideLegend = false,
|
||||
travelTimeEntries = EMPTY_TRAVEL_ENTRIES,
|
||||
densityLabel = 'Number of properties',
|
||||
densityLabel: densityLabelProp,
|
||||
totalCount: totalCountProp,
|
||||
}: MapProps) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const { t } = useTranslation();
|
||||
const modes = useTranslatedModes();
|
||||
const densityLabel = densityLabelProp ?? t('mapLegend.numberOfProperties');
|
||||
const [internalViewState, setInternalViewState] = useState<ViewState>(
|
||||
initialViewState || INITIAL_VIEW_STATE
|
||||
);
|
||||
|
|
@ -288,8 +292,8 @@ export default memo(function Map({
|
|||
<MapLegend
|
||||
featureLabel={
|
||||
viewSource === 'eye'
|
||||
? `Previewing \u201c${colorFeatureMeta.name}\u201d`
|
||||
: colorFeatureMeta.name
|
||||
? t('mapLegend.previewing', { name: ts(colorFeatureMeta.name) })
|
||||
: ts(colorFeatureMeta.name)
|
||||
}
|
||||
range={colorRange}
|
||||
showCancel={viewSource === 'eye'}
|
||||
|
|
@ -311,7 +315,7 @@ export default memo(function Map({
|
|||
: [countRange.min, countRange.max]
|
||||
}
|
||||
totalCount={
|
||||
usePostcodeView ? postcodeCountRange.total : countRange.total
|
||||
totalCountProp ?? (usePostcodeView ? postcodeCountRange.total : countRange.total)
|
||||
}
|
||||
showCancel={false}
|
||||
onCancel={onCancelPin}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { useState, useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Slider } from '../ui/Slider';
|
||||
import { IconButton } from '../ui/IconButton';
|
||||
import { PillToggle } from '../ui/PillToggle';
|
||||
|
|
@ -8,9 +9,9 @@ import { TravelTimeInfoPopup } from '../ui/TravelTimeInfoPopup';
|
|||
import { CloseIcon } from '../ui/icons/CloseIcon';
|
||||
import { EyeIcon } from '../ui/icons/EyeIcon';
|
||||
import { InfoIcon } from '../ui/icons/InfoIcon';
|
||||
import { formatFilterValue } from '../../lib/format';
|
||||
import { formatFilterValue, formatNumber } from '../../lib/format';
|
||||
import { useTravelDestinations } from '../../hooks/useTravelDestinations';
|
||||
import { MODE_LABELS, MODE_ICONS, type TransportMode } from '../../hooks/useTravelTime';
|
||||
import { MODE_ICONS, useTranslatedModes, type TransportMode } from '../../hooks/useTravelTime';
|
||||
|
||||
interface TravelTimeCardProps {
|
||||
mode: TransportMode;
|
||||
|
|
@ -29,6 +30,7 @@ interface TravelTimeCardProps {
|
|||
onDragEnd: () => void;
|
||||
onToggleBest: () => void;
|
||||
onRemove: () => void;
|
||||
filterImpact?: number;
|
||||
}
|
||||
|
||||
export function TravelTimeCard({
|
||||
|
|
@ -48,7 +50,10 @@ export function TravelTimeCard({
|
|||
onDragEnd,
|
||||
onToggleBest,
|
||||
onRemove,
|
||||
filterImpact,
|
||||
}: TravelTimeCardProps) {
|
||||
const { t } = useTranslation();
|
||||
const modes = useTranslatedModes();
|
||||
const { destinations, loading: destinationsLoading } = useTravelDestinations(mode);
|
||||
const [showInfo, setShowInfo] = useState(false);
|
||||
const [showBestInfo, setShowBestInfo] = useState(false);
|
||||
|
|
@ -75,23 +80,23 @@ export function TravelTimeCard({
|
|||
<div className="flex items-center gap-1.5">
|
||||
<ModeIcon 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 ({MODE_LABELS[mode]})
|
||||
{t('travel.travelTime', { mode: modes.label(mode) })}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-0.5">
|
||||
<IconButton onClick={() => setShowInfo(true)} title="Feature info">
|
||||
<IconButton onClick={() => setShowInfo(true)} title={t('filters.featureInfo')}>
|
||||
<InfoIcon className="w-3.5 h-3.5" />
|
||||
</IconButton>
|
||||
{slug && (
|
||||
<IconButton
|
||||
onClick={onTogglePin}
|
||||
active={isPinned}
|
||||
title={isPinned ? 'Stop previewing' : 'Preview on map'}
|
||||
title={isPinned ? t('travel.stopPreviewing') : t('travel.previewOnMap')}
|
||||
>
|
||||
<EyeIcon className="w-3.5 h-3.5" filled={isPinned} />
|
||||
</IconButton>
|
||||
)}
|
||||
<IconButton onClick={() => onRemove()} title="Remove travel time">
|
||||
<IconButton onClick={() => onRemove()} title={t('travel.removeTravelTime')}>
|
||||
<CloseIcon className="w-3.5 h-3.5" />
|
||||
</IconButton>
|
||||
</div>
|
||||
|
|
@ -104,14 +109,14 @@ export function TravelTimeCard({
|
|||
onSelect={handleDestinationSelect}
|
||||
value={label || undefined}
|
||||
onClear={() => onSetDestination('', '', 0, 0)}
|
||||
placeholder="Select destination..."
|
||||
placeholder={t('travel.selectDestination')}
|
||||
/>
|
||||
|
||||
{/* Best-case toggle — transit only, shown when destination is set */}
|
||||
{slug && mode === 'transit' && (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<PillToggle label="Best case" active={useBest} onClick={onToggleBest} size="xs" />
|
||||
<IconButton onClick={() => setShowBestInfo(true)} title="What is best case?">
|
||||
<PillToggle label={t('travel.bestCase')} active={useBest} onClick={onToggleBest} size="xs" />
|
||||
<IconButton onClick={() => setShowBestInfo(true)} title={t('travel.bestCaseTitle')}>
|
||||
<InfoIcon className="w-3 h-3" />
|
||||
</IconButton>
|
||||
</div>
|
||||
|
|
@ -120,12 +125,11 @@ export function TravelTimeCard({
|
|||
{showInfo && <TravelTimeInfoPopup mode={mode} onClose={() => setShowInfo(false)} />}
|
||||
|
||||
{showBestInfo && (
|
||||
<InfoPopup title="Best case travel time" onClose={() => setShowBestInfo(false)}>
|
||||
<p className="text-sm text-warm-700 dark:text-warm-300 leading-relaxed">
|
||||
Uses the fastest realistic journey time (if you time your departure well and catch good
|
||||
connections). The default uses the <strong>median</strong>, representing a typical journey
|
||||
regardless of when you leave.
|
||||
</p>
|
||||
<InfoPopup title={t('travel.bestCaseTitle')} onClose={() => setShowBestInfo(false)}>
|
||||
<p
|
||||
className="text-sm text-warm-700 dark:text-warm-300 leading-relaxed"
|
||||
dangerouslySetInnerHTML={{ __html: t('travel.bestCaseDesc') }}
|
||||
/>
|
||||
</InfoPopup>
|
||||
)}
|
||||
|
||||
|
|
@ -133,7 +137,7 @@ export function TravelTimeCard({
|
|||
{slug && (
|
||||
<div>
|
||||
<span className="text-[10px] font-medium text-warm-500 dark:text-warm-400 uppercase tracking-wide">
|
||||
Max time
|
||||
{t('travel.maxTime')}
|
||||
</span>
|
||||
<Slider
|
||||
min={sliderMin}
|
||||
|
|
@ -145,9 +149,14 @@ export function TravelTimeCard({
|
|||
onPointerUp={() => onDragEnd()}
|
||||
/>
|
||||
<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>
|
||||
<span className="absolute left-0">{formatFilterValue(displayRange[0])} {t('common.min')}</span>
|
||||
<span className="absolute right-0">{formatFilterValue(displayRange[1])} {t('common.min')}</span>
|
||||
</div>
|
||||
{filterImpact != null && filterImpact > 0 && (
|
||||
<p className="text-[10px] text-warm-400 dark:text-warm-500 -mt-1 ml-2.5">
|
||||
+{formatNumber(filterImpact)} without this filter
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue