GdzieWjade/src/components/PlaceDetail.tsx
2026-10-03 18:22:27 +02:00

231 lines
11 KiB
TypeScript

import { forwardRef, useState } from 'react';
import { googleMapsPlaceUrl } from '../lib/links';
import { useI18n, type Key } from '../lib/i18n';
import { DEMO_PLACES } from '../lib/demo';
import type { Assessment, Place, ReportAttr, Status, UserReport } from '../lib/types';
import { Alert, FactCard, Icon, VERDICT_STYLE, VerdictTag } from './ui';
import { ReportForm } from './ReportForm';
import type { ProcessedPhoto } from '../lib/photo';
import { ReportCard } from './ReportCard';
export interface ReachState { minutes: number | null; summary: { n: number; fit: number } | 'stuck' | null }
export type RefreshState =
| { kind: 'idle' }
| { kind: 'loading' }
| { kind: 'ok'; at: string }
| { kind: 'fail' };
interface Props {
place: Place;
assessment: Assessment;
reports: UserReport[];
snapshotDate: string;
refresh: RefreshState;
saved: boolean;
onToggleSave: () => void;
onCard: () => void;
reach: ReachState;
onReach: (minutes: number) => void;
onClearReach: () => void;
onBack: () => void;
onRouteTo: () => void;
onRefresh: () => void;
onAddReport: (r: Omit<UserReport, 'id' | 'createdAt'>, photo: ProcessedPhoto | null) => void;
onRemoveReport: (id: string) => void;
}
/** Worst news first: barriers, then conflicting data, cautions, what is fine, and what is unknown. */
const FACT_GROUPS: Status[] = ['fail', 'conflict', 'warn', 'ok', 'unknown'];
const FACT_ICON: Record<Status, string> = { fail: 'error', conflict: 'report', warn: 'warning', ok: 'check_circle', unknown: 'help' };
const FACT_TONE: Record<Status, string> = { fail: 'text-error-dark', conflict: 'text-accent-warm-darker', warn: 'text-warning-darker', ok: 'text-success-dark', unknown: 'text-base-dark' };
export const PlaceDetail = forwardRef<HTMLHeadingElement, Props>(function PlaceDetail(p, headingRef) {
const { t, tm, date } = useI18n();
const [reporting, setReporting] = useState<ReportAttr | null>(null);
const [thanks, setThanks] = useState(false);
const { place, assessment } = p;
const demo = DEMO_PLACES.find((d) => d.id === place.id);
const address = [place.tags['addr:street'], place.tags['addr:housenumber']].filter(Boolean).join(' ');
const gmaps = googleMapsPlaceUrl([place.lat, place.lon]);
const [reachOpen, setReachOpen] = useState(false);
const mine = p.reports.filter((r) => r.placeId === place.id);
const missing = assessment.facts.filter((f) => f.required && f.status === 'unknown').map((f) => t(`fact.title.${f.key}`).toLowerCase());
return (
<article aria-labelledby="detail-title">
<button type="button" className="usa-button usa-button--unstyled display-flex flex-align-center margin-bottom-2" onClick={p.onBack}>
<Icon name="arrow_back" size={3} className="margin-right-05" />
{t('detail.back')}
</button>
<h1 id="detail-title" ref={headingRef} tabIndex={-1} className="margin-0 font-sans-xl">
<a className="kbb-extlink" href={gmaps} target="_blank" rel="noopener noreferrer">
{place.name ?? t('common.noName')}<Icon name="launch" size={3} className="kbb-extlink__icon" />
<span className="usa-sr-only"> {t('detail.gmaps')}</span>
</a>
</h1>
{address && (
<p className="margin-top-05 margin-bottom-0">
<a className="kbb-extlink kbb-extlink--muted" href={gmaps} target="_blank" rel="noopener noreferrer">
{address}<Icon name="launch" size={2} className="kbb-extlink__icon" />
<span className="usa-sr-only"> {t('detail.gmaps')}</span>
</a>
</p>
)}
{demo && (
<div className="margin-top-1">
<span className="usa-tag bg-ink text-white">{t('detail.demo')}</span>
<p className="font-sans-xs margin-top-1 margin-bottom-0">
{t('detail.demoNote', { state: t(`verdict.${demoState(demo.demoState)}`) })}
</p>
</div>
)}
<div className={`usa-alert ${VERDICT_STYLE[assessment.verdict].alert} margin-top-2`} role="status">
<div className="usa-alert__body">
<h2 className="usa-alert__heading"><VerdictTag verdict={assessment.verdict} /></h2>
<p className="usa-alert__text">{t(`verdict.${assessment.verdict}.long`)}</p>
{missing.length > 0 && <p className="usa-alert__text">{t('verdict.missing', { list: missing.join(', ') })}</p>}
</div>
</div>
<div className="kbb-actions margin-top-2">
<button type="button" className="usa-button kbb-actions__main" onClick={p.onRouteTo}>
<Icon name="directions_walk" size={3} className="margin-right-05" />{t('detail.routeTo')}
</button>
<button
type="button" className={`kbb-iconbtn ${p.saved ? 'is-on' : ''}`} aria-pressed={p.saved}
aria-label={t('detail.save')} title={p.saved ? t('detail.saved') : t('detail.save')} onClick={p.onToggleSave}
>
<Icon name={p.saved ? 'favorite' : 'favorite_border'} size={3} />
</button>
</div>
<details
key={place.id} className="kbb-reach border-1px border-base-lighter radius-md margin-top-2 bg-base-lightest"
open={reachOpen || p.reach.minutes !== null} onToggle={(e) => setReachOpen(e.currentTarget.open)}
>
<summary id="reach-title">
<Icon name="directions_walk" size={3} className="margin-right-05 text-primary" />
<span className="flex-1">{t('reach.title')}</span>
<Icon name="expand_more" size={3} className="kbb-chevron" />
</summary>
<div className="padding-x-2 padding-bottom-2">
<p className="margin-top-0 font-sans-xs">{t('reach.intro')}</p>
<ul className="usa-button-group usa-button-group--segmented margin-0 padding-0">
{[5, 10, 15].map((m) => (
<li className="usa-button-group__item" key={m}>
<button type="button" aria-pressed={p.reach.minutes === m} className={`usa-button usa-button--small ${p.reach.minutes === m ? '' : 'usa-button--outline'}`} onClick={() => p.onReach(m)}>
{t('reach.minutes', { n: m })}
</button>
</li>
))}
</ul>
<div aria-live="polite" className="margin-top-1">
{p.reach.summary === 'stuck' && <p className="margin-0">{t('reach.stuck')}</p>}
{p.reach.summary && p.reach.summary !== 'stuck' && p.reach.minutes && (
<>
<p className="margin-y-05">
{p.reach.summary.n === 0 ? t('reach.result.zero') : t('reach.result', { min: p.reach.minutes, n: p.reach.summary.n, fit: p.reach.summary.fit })}
</p>
<p className="font-sans-3xs text-base-dark margin-y-05">{t('reach.show')} {t('reach.caveat')}</p>
<button type="button" className="usa-button usa-button--unstyled font-sans-xs" onClick={p.onClearReach}>{t('reach.clear')}</button>
</>
)}
</div>
</div>
</details>
<h2 className="margin-top-3 font-sans-lg">{t('detail.facts')}</h2>
{FACT_GROUPS.map((status) => {
const facts = assessment.facts.filter((f) => f.status === status);
if (!facts.length) return null;
return (
<section key={status} aria-labelledby={`facts-${status}`}>
<h3 id={`facts-${status}`} className="kbb-facts-h margin-bottom-1">
<Icon name={FACT_ICON[status]} size={3} className={`margin-right-05 ${FACT_TONE[status]}`} />
{t(`detail.group.${status}` as Key)} <span className="text-base-dark text-normal">({facts.length})</span>
</h3>
<ul className="usa-list--unstyled margin-0 padding-0">
{facts.map((f) => (
<FactCard key={f.key} fact={f} onReport={() => { setReporting(f.key as ReportAttr); setThanks(false); }} />
))}
</ul>
</section>
);
})}
{thanks && <Alert kind="success" slim role="status">{t('rep.thanks')}</Alert>}
{reporting && (
<ReportForm
placeId={place.id}
initialAttr={reporting}
onCancel={() => setReporting(null)}
onSubmit={(r, photo) => { p.onAddReport(r, photo); setReporting(null); setThanks(true); }}
/>
)}
<section aria-labelledby="reports-h" className="margin-top-4">
<h2 id="reports-h" className="margin-top-0 margin-bottom-1 font-sans-lg">{t('detail.reports')}</h2>
<p className="font-sans-xs text-base-dark margin-top-0">{t('detail.reports.note')}</p>
{mine.length === 0 ? (
<div className="kbb-empty"><Icon name="edit" size={3} /><p className="margin-0">{t('detail.reports.none')}</p></div>
) : (
<ul className="usa-list--unstyled margin-0 padding-0">
{mine.map((r) => (
<li key={r.id} className="margin-bottom-2">
<ReportCard report={r}>
{!r.demo && <button type="button" className="usa-button usa-button--unstyled" onClick={() => p.onRemoveReport(r.id)}>{t('detail.reports.remove')}</button>}
</ReportCard>
</li>
))}
</ul>
)}
</section>
<section aria-labelledby="tools-h" className="margin-top-4">
<h2 id="tools-h" className="margin-top-0 margin-bottom-1 font-sans-lg">{t('detail.tools')}</h2>
<ul className="kbb-rows usa-list--unstyled margin-0 padding-0">
{place.demo ? (
<li><p className="font-sans-xs text-base-dark margin-y-1">{t('detail.refreshDemo')}</p></li>
) : (
<>
<li>
<button type="button" className="kbb-row" onClick={p.onRefresh} disabled={p.refresh.kind === 'loading'}>
<Icon name="update" size={3} className="kbb-row__icon" />
<span className="kbb-row__text">{p.refresh.kind === 'loading' ? t('detail.refreshing') : t('detail.refresh')}</span>
</button>
</li>
<li>
<a className="kbb-row" href={`https://www.openstreetmap.org/${osmPath(place.id)}`} target="_blank" rel="noopener noreferrer">
<Icon name="map" size={3} className="kbb-row__icon" />
<span className="kbb-row__text">{t('detail.openOsm')}</span>
<Icon name="launch" size={2} className="kbb-row__end" />
</a>
</li>
</>
)}
<li>
<button type="button" className="kbb-row" onClick={p.onCard}>
<Icon name="print" size={3} className="kbb-row__icon" />
<span className="kbb-row__text">{t('detail.card')}</span>
</button>
</li>
</ul>
<div aria-live="polite" className="margin-top-1">
{p.refresh.kind === 'ok' && <Alert kind="success" slim>{t('detail.refreshed', { time: new Date(p.refresh.at).toLocaleTimeString() })}</Alert>}
{p.refresh.kind === 'fail' && <Alert kind="warning" role="alert">{t('detail.refreshFail', { date: date(p.snapshotDate) })}</Alert>}
</div>
</section>
</article>
);
});
const demoState = (s: string) => (s === 'verified' ? 'match' : s === 'conflict' ? 'conflict' : 'incomplete');
function osmPath(id: string) {
const kind = id[0] === 'n' ? 'node' : id[0] === 'w' ? 'way' : 'relation';
return `${kind}/${id.slice(1)}`;
}