GdzieWjade/src/components/PlacesView.tsx
2026-10-03 14:29:28 +02:00

110 lines
5.5 KiB
TypeScript

import { useMemo, useState } from 'react';
import { useI18n, type Key } from '../lib/i18n';
import { VERDICT_ORDER } from '../lib/assess';
import { DEMO_PLACES } from '../lib/demo';
import { fold } from '../lib/text';
import type { Assessment, Place, PlaceCategory, Profile } from '../lib/types';
import { Icon, VerdictTag } from './ui';
export interface Filters { query: string; cat: PlaceCategory | 'all'; onlyFit: boolean; onlySaved: boolean }
export const DEFAULT_FILTERS: Filters = { query: '', cat: 'all', onlyFit: false, onlySaved: false };
const CATS: (PlaceCategory | 'all')[] = ['all', 'food', 'culture', 'shop', 'stay', 'health', 'services', 'transit'];
const PAGE = 40;
export function filterPlaces(places: Place[], assessments: Map<string, Assessment>, f: Filters, saved: ReadonlySet<string>): Place[] {
const q = fold(f.query.trim());
return places
.filter((p) => {
if (f.cat !== 'all' && p.cat !== f.cat) return false;
if (f.onlySaved && !saved.has(p.id)) return false;
const v = assessments.get(p.id)?.verdict;
if (f.onlyFit && v !== 'match' && v !== 'partial') return false;
if (!q) return true;
return fold(`${p.name ?? ''} ${p.tags['addr:street'] ?? ''} ${p.tags['addr:housenumber'] ?? ''}`).includes(q);
})
.sort((a, b) => {
const va = VERDICT_ORDER[assessments.get(a.id)?.verdict ?? 'incomplete'];
const vb = VERDICT_ORDER[assessments.get(b.id)?.verdict ?? 'incomplete'];
return va - vb || Number(!!a.demo) - Number(!!b.demo) || (a.name ?? '').localeCompare(b.name ?? '', 'pl');
});
}
interface Props {
profile: Profile;
filters: Filters;
onFilters: (f: Filters) => void;
results: Place[];
assessments: Map<string, Assessment>;
onSelect: (id: string) => void;
}
export function PlacesView({ profile, filters, onFilters, results, assessments, onSelect }: Props) {
const { t } = useI18n();
const [shown, setShown] = useState(PAGE);
const visible = useMemo(() => results.slice(0, shown), [results, shown]);
const set = (patch: Partial<Filters>) => { setShown(PAGE); onFilters({ ...filters, ...patch }); };
return (
<section aria-labelledby="places-title">
<h1 id="places-title" className="margin-top-0 font-sans-xl">{t('places.title')}</h1>
<p className="font-sans-xs text-base-dark margin-top-0">
{t('places.hint', { profile: t(`profile.${profile.kind}`).toLowerCase() })}
</p>
<div role="search">
<label className="usa-label margin-top-0" htmlFor="q">{t('places.search')}</label>
<input id="q" className="usa-input maxw-full" type="search" value={filters.query} autoComplete="off" onChange={(e) => set({ query: e.target.value })} />
<label className="usa-label" htmlFor="cat">{t('places.category')}</label>
<select id="cat" className="usa-select maxw-full" value={filters.cat} onChange={(e) => set({ cat: e.target.value as Filters['cat'] })}>
{CATS.map((c) => <option key={c} value={c}>{t(`cat.${c}` as Key)}</option>)}
</select>
<div className="usa-checkbox margin-top-2">
<input className="usa-checkbox__input" id="onlysaved" type="checkbox" checked={filters.onlySaved} onChange={(e) => set({ onlySaved: e.target.checked })} />
<label className="usa-checkbox__label" htmlFor="onlysaved">{t('places.onlySaved')}</label>
</div>
<div className="usa-checkbox margin-top-2">
<input className="usa-checkbox__input" id="onlyfit" type="checkbox" checked={filters.onlyFit} onChange={(e) => set({ onlyFit: e.target.checked })} />
<label className="usa-checkbox__label" htmlFor="onlyfit">{t('places.onlyFit')}</label>
</div>
</div>
<p className="margin-top-3 margin-bottom-1 text-bold" role="status" aria-live="polite">{t('places.count', { n: results.length })}</p>
{results.length === 0 ? (
<p>{t('places.empty')}</p>
) : (
<ul className="usa-list--unstyled margin-0 padding-0">
{visible.map((p) => {
const a = assessments.get(p.id);
const isDemo = DEMO_PLACES.some((d) => d.id === p.id);
return (
<li key={p.id} className="margin-bottom-1">
<button
type="button" id={`place-${p.id}`} onClick={() => onSelect(p.id)}
className="kbb-place-btn width-full text-left bg-white border-1px border-base-lighter radius-md padding-2 display-flex flex-justify flex-align-start"
>
<span className="display-block">
<span className="display-block text-bold text-ink">{p.name}</span>
<span className="display-block font-sans-xs text-base-dark margin-top-05">
{t(`cat.${p.cat}` as Key)}
{p.tags['addr:street'] ? ` · ${p.tags['addr:street']} ${p.tags['addr:housenumber'] ?? ''}` : ''}
</span>
{isDemo && <span className="usa-tag bg-ink text-white margin-top-05">{t('detail.demo')}</span>}
<span className="display-block margin-top-1">{a && <VerdictTag verdict={a.verdict} />}</span>
</span>
<Icon name="navigate_next" size={3} className="text-base margin-top-05" />
</button>
</li>
);
})}
</ul>
)}
{results.length > shown && (
<button type="button" className="usa-button usa-button--outline margin-top-2" onClick={() => setShown((n) => n + PAGE)}>
{t('places.more')}
</button>
)}
</section>
);
}