perfect-postcode/frontend/src/components/map/TravelTimeCard.tsx
Andras Schmelczer 05a1f316e1
Some checks failed
CI / Check (push) Failing after 2m14s
Build and publish Docker image / build-and-push (push) Failing after 2m38s
More
2026-05-04 17:21:26 +01:00

176 lines
6.2 KiB
TypeScript

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';
import { DestinationDropdown } from '../ui/DestinationDropdown';
import InfoPopup from '../ui/InfoPopup';
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, formatNumber } from '../../lib/format';
import { useTravelDestinations } from '../../hooks/useTravelDestinations';
import { MODE_ICONS, useTranslatedModes, type TransportMode } from '../../hooks/useTravelTime';
interface TravelTimeCardProps {
mode: TransportMode;
slug: string;
label: string;
timeRange: [number, number] | null;
useBest: boolean;
isPinned: boolean;
isActive: boolean;
dragValue: [number, number] | null;
onTogglePin: () => void;
onSetDestination: (slug: string, label: string, lat: number, lon: number) => void;
onTimeRangeChange: (range: [number, number]) => void;
onDragStart: () => void;
onDragChange: (value: [number, number]) => void;
onDragEnd: () => void;
onToggleBest: () => void;
onRemove: () => void;
filterImpact?: number;
destinationDropdownPortal?: boolean;
}
export function TravelTimeCard({
mode,
slug,
label,
timeRange,
useBest,
isPinned,
isActive,
dragValue,
onTogglePin,
onSetDestination,
onTimeRangeChange: _onTimeRangeChange,
onDragStart,
onDragChange,
onDragEnd,
onToggleBest,
onRemove,
filterImpact,
destinationDropdownPortal = true,
}: TravelTimeCardProps) {
const { t } = useTranslation();
const modes = useTranslatedModes();
const { destinations, loading: destinationsLoading } = useTravelDestinations(mode);
const [showInfo, setShowInfo] = useState(false);
const [showBestInfo, setShowBestInfo] = useState(false);
const handleDestinationSelect = useCallback(
(selectedSlug: string, selectedLabel: string, lat: number, lon: number) => {
onSetDestination(selectedSlug, selectedLabel, lat, lon);
},
[onSetDestination]
);
const sliderMin = 0;
const sliderMax = 120;
const displayRange = isActive && dragValue ? dragValue : (timeRange ?? [sliderMin, sliderMax]);
const ModeIcon = MODE_ICONS[mode];
return (
<div
className={`space-y-2 px-2 py-2 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' : ''}`}
>
{/* Header */}
<div className="flex items-center justify-between">
<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">
{t('travel.travelTime', { mode: modes.label(mode) })}
</span>
</div>
<div className="flex items-center gap-0.5">
<IconButton onClick={() => setShowInfo(true)} title={t('filters.featureInfo')}>
<InfoIcon className="w-3.5 h-3.5" />
</IconButton>
{slug && (
<IconButton
onClick={onTogglePin}
active={isPinned || isActive}
title={isPinned ? t('travel.stopPreviewing') : t('travel.previewOnMap')}
>
<EyeIcon className="w-3.5 h-3.5" filled={isPinned || isActive} />
</IconButton>
)}
<IconButton onClick={() => onRemove()} title={t('travel.removeTravelTime')}>
<CloseIcon className="w-3.5 h-3.5" />
</IconButton>
</div>
</div>
{/* Destination */}
<DestinationDropdown
destinations={destinations}
loading={destinationsLoading}
onSelect={handleDestinationSelect}
value={label || undefined}
onClear={() => onSetDestination('', '', 0, 0)}
placeholder={t('travel.selectDestination')}
portal={destinationDropdownPortal}
/>
{/* Best-case toggle — transit only, shown when destination is set */}
{slug && mode === 'transit' && (
<div className="flex items-center gap-1.5">
<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>
)}
{showInfo && <TravelTimeInfoPopup mode={mode} onClose={() => setShowInfo(false)} />}
{showBestInfo && (
<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>
)}
{/* Time range slider — only show when we have data */}
{slug && (
<div>
<span className="text-[10px] font-medium text-warm-500 dark:text-warm-400 uppercase tracking-wide">
{t('travel.maxTime')}
</span>
<Slider
min={sliderMin}
max={sliderMax}
step={1}
value={[displayRange[0], displayRange[1]]}
onValueChange={([min, max]) => onDragChange([min, max])}
onPointerDown={() => onDragStart()}
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])} {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>
);
}