110 lines
5.5 KiB
TypeScript
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>
|
|
);
|
|
}
|