perfect-postcode/frontend/src/components/map/POIPane.tsx

223 lines
8.2 KiB
TypeScript

import { useState, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { ts } from '../../i18n/server';
import { useCollapsibleGroups } from '../../hooks/useCollapsibleGroups';
import { trackEvent } from '../../lib/analytics';
import { POI_CATEGORY_LOGOS } from '../../lib/consts';
import type { POICategoryGroup } from '../../types';
import InfoPopup from '../ui/InfoPopup';
import { SearchInput } from '../ui/SearchInput';
import { PillToggle } from '../ui/PillToggle';
import { PillGroup } from '../ui/PillGroup';
import { InfoIcon, ChevronIcon, CloseIcon } from '../ui/icons';
import { IconButton } from '../ui/IconButton';
interface POIPaneProps {
groups: POICategoryGroup[];
selectedCategories: Set<string>;
onCategoriesChange: (categories: Set<string>) => void;
poiCount: number;
onNavigateToSource?: (slug: string) => void;
onClose?: () => void;
}
export default function POIPane({
groups,
selectedCategories,
onCategoriesChange,
poiCount: _poiCount,
onNavigateToSource,
onClose,
}: POIPaneProps) {
const { t } = useTranslation();
const [searchTerm, setSearchTerm] = useState('');
const [isGroupExpanded, toggleCollapse] = useCollapsibleGroups();
const [showInfo, setShowInfo] = useState(false);
const allCategories = groups.flatMap((g) => g.categories);
const toggleCategory = (category: string) => {
const newSet = new Set(selectedCategories);
const wasSelected = newSet.has(category);
if (wasSelected) {
newSet.delete(category);
} else {
newSet.add(category);
}
trackEvent('POI Toggle', { category, selected: String(!wasSelected) });
onCategoriesChange(newSet);
};
const selectAll = () => {
trackEvent('POI Select All');
onCategoriesChange(new Set(allCategories));
};
const selectNone = () => {
trackEvent('POI Select None');
onCategoriesChange(new Set());
};
const toggleGroup = useCallback(
(groupName: string) => {
const group = groups.find((g) => g.name === groupName);
if (!group) return;
const allSelected = group.categories.every((c) => selectedCategories.has(c));
const newSet = new Set(selectedCategories);
if (allSelected) {
group.categories.forEach((c) => newSet.delete(c));
} else {
group.categories.forEach((c) => newSet.add(c));
}
onCategoriesChange(newSet);
},
[groups, selectedCategories, onCategoriesChange]
);
const lowerSearch = searchTerm.toLowerCase();
const filteredGroups = groups
.map((group) => {
if (!searchTerm) return group;
const matchingCats = group.categories.filter((c) => c.toLowerCase().includes(lowerSearch));
const groupMatches = group.name.toLowerCase().includes(lowerSearch);
if (groupMatches) return group;
if (matchingCats.length === 0) return null;
return { ...group, categories: matchingCats };
})
.filter(Boolean) as POICategoryGroup[];
const selectedCount = selectedCategories.size;
return (
<div className="flex h-full min-h-0 flex-col overflow-hidden bg-white shadow-lg dark:bg-warm-900">
<div className="flex-shrink-0 px-3 pt-3 pb-2">
<div className="flex items-center gap-2">
<span className="text-xs font-semibold text-warm-500 dark:text-warm-400 uppercase tracking-wide">
{t('poiPane.pois')}
</span>
<span className="text-xs text-warm-400 dark:text-warm-500">
{selectedCount}/{allCategories.length}
</span>
<IconButton onClick={() => setShowInfo(true)} title={t('poiPane.dataSourceInfo')}>
<InfoIcon />
</IconButton>
<div className="flex gap-1 ml-auto items-center">
<button
onClick={selectAll}
className="px-2 py-0.5 text-xs rounded border border-warm-300 dark:border-warm-700 text-warm-600 dark:text-warm-400 hover:bg-warm-50 dark:hover:bg-warm-700"
>
{t('common.all')}
</button>
<button
onClick={selectNone}
className="px-2 py-0.5 text-xs rounded border border-warm-300 dark:border-warm-700 text-warm-600 dark:text-warm-400 hover:bg-warm-50 dark:hover:bg-warm-700"
>
{t('common.none')}
</button>
{onClose && (
<button
onClick={onClose}
className="ml-1 p-0.5 text-warm-400 hover:text-warm-700 dark:hover:text-warm-300"
title={t('common.close')}
>
<CloseIcon className="w-4 h-4" />
</button>
)}
</div>
</div>
{showInfo && (
<InfoPopup
title={t('poiPane.pointsOfInterest')}
onClose={() => setShowInfo(false)}
sourceLink={
onNavigateToSource
? {
label: t('common.viewDataSource'),
onClick: () => {
onNavigateToSource('osm-pois');
setShowInfo(false);
},
}
: undefined
}
>
<p className="text-sm text-warm-700 dark:text-warm-300 mb-4 leading-relaxed">
{t('poiPane.poiDescription')}
</p>
</InfoPopup>
)}
</div>
<div className="flex-1 min-h-0 overflow-y-auto overscroll-contain border-t border-warm-200 dark:border-warm-700">
<div className="px-3 pt-2 pb-1">
<SearchInput
value={searchTerm}
onChange={setSearchTerm}
placeholder={t('poiPane.searchCategories')}
/>
</div>
{filteredGroups.map((group) => {
const groupSelected = group.categories.filter((c) => selectedCategories.has(c)).length;
const allInGroupSelected = groupSelected === group.categories.length;
const someInGroupSelected = groupSelected > 0 && !allInGroupSelected;
const isCollapsed = !isGroupExpanded(group.name) && !searchTerm;
return (
<div key={group.name}>
<div className="flex items-center gap-1 px-3 py-1.5 bg-warm-50 dark:bg-navy-950 border-b border-warm-100 dark:border-navy-700 sticky top-0 z-10">
<button
onClick={() => toggleCollapse(group.name)}
className={`p-0.5 text-warm-400 hover:text-warm-600 transition-transform ${isCollapsed ? '' : 'rotate-90'}`}
>
<ChevronIcon direction="right" className="w-3 h-3" />
</button>
<PillToggle
label={ts(group.name)}
active={allInGroupSelected}
indeterminate={someInGroupSelected}
onClick={() => toggleGroup(group.name)}
size="xs"
/>
<span className="text-xs text-warm-400 ml-auto">
{groupSelected}/{group.categories.length}
</span>
</div>
{!isCollapsed && (
<div className="px-3 py-2">
<PillGroup>
{group.categories.map((category) => {
const logo = POI_CATEGORY_LOGOS[category];
return (
<PillToggle
key={category}
label={ts(category)}
icon={
logo ? (
<img
src={logo}
alt=""
aria-hidden="true"
loading="lazy"
referrerPolicy="no-referrer"
className="h-4 w-4 shrink-0 rounded-[3px] bg-white object-contain p-0.5"
/>
) : undefined
}
active={selectedCategories.has(category)}
onClick={() => toggleCategory(category)}
size="xs"
/>
);
})}
</PillGroup>
</div>
)}
</div>
);
})}
</div>
</div>
);
}