247 lines
10 KiB
TypeScript
247 lines
10 KiB
TypeScript
import { useState, useMemo, useEffect } from 'react';
|
|
import { useCollapsibleGroups } from '../../hooks/useCollapsibleGroups';
|
|
import { useTravelModes } from '../../hooks/useTravelModes';
|
|
import { SearchInput } from '../ui/SearchInput';
|
|
import { FilterIcon } from '../ui/icons';
|
|
import { CollapsibleGroupHeader } from '../ui/CollapsibleGroupHeader';
|
|
import { EmptyState } from '../ui/EmptyState';
|
|
import type { FeatureMeta } from '../../types';
|
|
import { groupFeaturesByCategory } from '../../lib/features';
|
|
import { FeatureInfoPopup } from '../ui/FeatureInfoPopup';
|
|
import { FeatureActions } from '../ui/FeatureIcons';
|
|
import { FeatureLabel } from '../ui/FeatureLabel';
|
|
import { PlusIcon, InfoIcon } from '../ui/icons';
|
|
import { IconButton } from '../ui/IconButton';
|
|
import { TravelTimeInfoPopup } from '../ui/TravelTimeInfoPopup';
|
|
import {
|
|
TRANSPORT_MODES,
|
|
MODE_LABELS,
|
|
MODE_DESCRIPTIONS,
|
|
MODE_ICONS,
|
|
type TransportMode,
|
|
type TravelTimeEntry,
|
|
} from '../../hooks/useTravelTime';
|
|
|
|
interface FeatureBrowserProps {
|
|
availableFeatures: FeatureMeta[];
|
|
allFeatures: FeatureMeta[];
|
|
pinnedFeature: string | null;
|
|
onAddFilter: (name: string) => void;
|
|
onTogglePin: (name: string) => void;
|
|
onNavigateToSource?: (slug: string, featureName: string) => void;
|
|
openInfoFeature?: string | null;
|
|
onClearOpenInfoFeature?: () => void;
|
|
travelTimeEntries: TravelTimeEntry[];
|
|
onAddTravelTimeEntry: (mode: TransportMode) => void;
|
|
isLicensed: boolean;
|
|
onUpgradeClick?: () => void;
|
|
}
|
|
|
|
export default function FeatureBrowser({
|
|
availableFeatures,
|
|
allFeatures,
|
|
pinnedFeature,
|
|
onAddFilter,
|
|
onTogglePin,
|
|
onNavigateToSource,
|
|
openInfoFeature,
|
|
onClearOpenInfoFeature,
|
|
travelTimeEntries: _travelTimeEntries,
|
|
onAddTravelTimeEntry,
|
|
isLicensed,
|
|
onUpgradeClick,
|
|
}: FeatureBrowserProps) {
|
|
const [search, setSearch] = useState('');
|
|
const [infoFeature, setInfoFeature] = useState<FeatureMeta | null>(null);
|
|
const [travelInfoMode, setTravelInfoMode] = useState<TransportMode | null>(null);
|
|
const [isGroupExpanded, toggleGroup] = useCollapsibleGroups(true);
|
|
const availableTravelModes = useTravelModes();
|
|
|
|
useEffect(() => {
|
|
if (openInfoFeature) {
|
|
const feat = allFeatures.find((f) => f.name === openInfoFeature);
|
|
if (feat) setInfoFeature(feat);
|
|
onClearOpenInfoFeature?.();
|
|
}
|
|
}, [openInfoFeature, allFeatures, onClearOpenInfoFeature]);
|
|
|
|
const filtered = useMemo(() => {
|
|
if (!search) return availableFeatures;
|
|
const lower = search.toLowerCase();
|
|
return availableFeatures.filter((f) => f.name.toLowerCase().includes(lower));
|
|
}, [availableFeatures, search]);
|
|
|
|
const grouped = useMemo(() => groupFeaturesByCategory(filtered), [filtered]);
|
|
|
|
// When searching, expand all groups so results are visible
|
|
const isSearching = search.length > 0;
|
|
|
|
// Only show modes that have precomputed travel time data
|
|
const visibleModes = useMemo(
|
|
() => (availableTravelModes ? TRANSPORT_MODES.filter((m) => availableTravelModes.has(m)) : []),
|
|
[availableTravelModes]
|
|
);
|
|
|
|
const showTravelModes =
|
|
visibleModes.length > 0 &&
|
|
(!search ||
|
|
'travel time journey commute car bicycle walking transit transport station tube train'.includes(
|
|
search.toLowerCase()
|
|
));
|
|
|
|
// Ensure "Transport" group exists when travel modes should be shown
|
|
const mergedGrouped = useMemo(() => {
|
|
if (!showTravelModes) return grouped;
|
|
if (grouped.some((g) => g.name === 'Transport')) return grouped;
|
|
const groups = [...grouped];
|
|
const propsIdx = groups.findIndex((g) => g.name === 'Properties in the area');
|
|
groups.splice(propsIdx === -1 ? 0 : propsIdx + 1, 0, { name: 'Transport', features: [] });
|
|
return groups;
|
|
}, [grouped, showTravelModes]);
|
|
|
|
return (
|
|
<>
|
|
<div className="shrink-0 p-2 border-b border-warm-200 dark:border-navy-700">
|
|
<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">
|
|
{mergedGrouped.map((group) => {
|
|
const isExpanded = isSearching || isGroupExpanded(group.name);
|
|
return (
|
|
<div key={group.name} className="shrink-0">
|
|
<CollapsibleGroupHeader
|
|
name={group.name}
|
|
expanded={isExpanded}
|
|
onToggle={() => toggleGroup(group.name)}
|
|
className="px-3 py-2.5 text-sm font-bold text-navy-950 bg-warm-200 dark:bg-navy-900 dark:text-warm-100 sticky top-0 z-10 hover:bg-warm-200 dark:hover:bg-warm-800"
|
|
>
|
|
<span className="text-xs font-medium text-warm-400 dark:text-warm-500">
|
|
{group.features.length +
|
|
(group.name === 'Transport' && showTravelModes ? visibleModes.length : 0)}
|
|
</span>
|
|
</CollapsibleGroupHeader>
|
|
{isExpanded && (
|
|
<>
|
|
{group.features.map((f) => {
|
|
const isPinned = pinnedFeature === f.name;
|
|
return (
|
|
<div
|
|
key={f.name}
|
|
className="flex items-center justify-between px-3 py-1.5 hover:bg-teal-50 dark:hover:bg-teal-900/30 dark:text-warm-300"
|
|
>
|
|
<div className="min-w-0 mr-2">
|
|
<FeatureLabel feature={f} size="sm" description={f.description} />
|
|
</div>
|
|
<FeatureActions
|
|
feature={f}
|
|
isPinned={isPinned}
|
|
onTogglePin={onTogglePin}
|
|
onShowInfo={setInfoFeature}
|
|
onAdd={onAddFilter}
|
|
/>
|
|
</div>
|
|
);
|
|
})}
|
|
{group.name === 'Transport' &&
|
|
showTravelModes &&
|
|
visibleModes.map((mode) => {
|
|
const ModeIcon = MODE_ICONS[mode];
|
|
return (
|
|
<div
|
|
key={mode}
|
|
className="flex items-center justify-between px-3 py-1.5 hover:bg-teal-50 dark:hover:bg-teal-900/30 cursor-pointer"
|
|
>
|
|
<div
|
|
className="flex items-center gap-2 min-w-0"
|
|
onClick={() => onAddTravelTimeEntry(mode)}
|
|
>
|
|
<ModeIcon 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">
|
|
{MODE_LABELS[mode]}
|
|
</span>
|
|
<span className="text-xs text-warm-400 dark:text-warm-500 block">
|
|
{MODE_DESCRIPTIONS[mode]}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
<div className="flex items-center gap-0.5 shrink-0">
|
|
<IconButton
|
|
onClick={() => setTravelInfoMode(mode)}
|
|
title="Feature info"
|
|
size="md"
|
|
>
|
|
<InfoIcon className="w-5 h-5 md:w-3.5 md:h-3.5" />
|
|
</IconButton>
|
|
<button
|
|
onClick={() => onAddTravelTimeEntry(mode)}
|
|
title={`Add ${MODE_LABELS[mode]} travel time`}
|
|
className="p-1 rounded-md text-teal-600 dark:text-teal-400 bg-teal-50 dark:bg-teal-900/30 hover:bg-teal-100 dark:hover:bg-teal-800/40"
|
|
>
|
|
<PlusIcon className="w-5 h-5 md:w-5 md:h-5" strokeWidth={2.5} />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</>
|
|
)}
|
|
</div>
|
|
);
|
|
})}
|
|
{mergedGrouped.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'}
|
|
description={
|
|
search ? 'Try a different search term' : 'Remove a filter to see available features'
|
|
}
|
|
className="px-3 py-4"
|
|
/>
|
|
) : isLicensed ? (
|
|
<p className="flex-1 flex items-center justify-center px-4 py-6 text-xs text-center text-warm-400 dark:text-warm-500">
|
|
Everyone cares about different things. Pick the filters that matter most to you.
|
|
</p>
|
|
) : (
|
|
<div className="mt-auto flex flex-col items-center px-5 pt-6 pb-0">
|
|
<p className="text-sm text-warm-600 dark:text-warm-400 text-center leading-relaxed mb-1">
|
|
The biggest financial decision of your life deserves proper tools behind it.
|
|
</p>
|
|
<p className="text-xs text-warm-400 dark:text-warm-500 text-center mb-4">
|
|
Don't leave it to chance.
|
|
</p>
|
|
<button
|
|
onClick={onUpgradeClick}
|
|
className="px-5 py-2.5 rounded-lg bg-gradient-to-r from-teal-500 to-teal-600 hover:from-teal-600 hover:to-teal-700 text-white font-medium text-sm shadow-sm hover:shadow-md"
|
|
>
|
|
Upgrade to full map
|
|
</button>
|
|
<svg
|
|
viewBox="0 120 1600 230"
|
|
className="w-full mt-4 block shrink-0"
|
|
preserveAspectRatio="xMidYMax meet"
|
|
>
|
|
<path
|
|
d="M0,350 C400,150 1200,150 1600,350 Z"
|
|
className="fill-green-500 dark:fill-green-600"
|
|
/>
|
|
<path d="M100,350 C450,180 1150,180 1500,350 Z" fill="#000" opacity="0.08" />
|
|
<path d="M250,350 C550,220 1050,220 1350,350 Z" fill="#000" opacity="0.06" />
|
|
<image href="/house.png" x="735" y="110" width="130" height="120" />
|
|
</svg>
|
|
</div>
|
|
)}
|
|
</div>
|
|
{infoFeature && (
|
|
<FeatureInfoPopup
|
|
feature={infoFeature}
|
|
onClose={() => setInfoFeature(null)}
|
|
onNavigateToSource={onNavigateToSource}
|
|
/>
|
|
)}
|
|
{travelInfoMode && (
|
|
<TravelTimeInfoPopup mode={travelInfoMode} onClose={() => setTravelInfoMode(null)} />
|
|
)}
|
|
</>
|
|
);
|
|
}
|