GdzieWjade/src/components/PlaceDetail.tsx

203 lines
9.6 KiB
TypeScript
Raw Normal View History

2026-10-03 13:33:50 +02:00
import { forwardRef, useState } from 'react';
import { useI18n } from '../lib/i18n';
import { DEMO_PLACES } from '../lib/demo';
import type { Assessment, Place, ReportAttr, UserReport } from '../lib/types';
2026-10-03 14:29:28 +02:00
import { Alert, FactCard, Icon, VERDICT_STYLE, VerdictTag, VettingSummary } from './ui';
2026-10-03 13:33:50 +02:00
import { ReportForm } from './ReportForm';
2026-10-03 14:29:28 +02:00
import type { ProcessedPhoto } from '../lib/photo';
import { usePhoto } from '../lib/usePhoto';
export interface ReachState { minutes: number | null; summary: { n: number; fit: number } | 'stuck' | null }
2026-10-03 13:33:50 +02:00
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;
2026-10-03 14:29:28 +02:00
saved: boolean;
onToggleSave: () => void;
onCard: () => void;
reach: ReachState;
onReach: (minutes: number) => void;
onClearReach: () => void;
2026-10-03 13:33:50 +02:00
onBack: () => void;
onRouteTo: () => void;
onShowOnMap: () => void;
onRefresh: () => void;
2026-10-03 14:29:28 +02:00
onAddReport: (r: Omit<UserReport, 'id' | 'createdAt'>, photo: ProcessedPhoto | null) => void;
2026-10-03 13:33:50 +02:00
onRemoveReport: (id: string) => void;
}
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 mine = p.reports.filter((r) => r.placeId === place.id);
2026-10-03 14:29:28 +02:00
const missing = assessment.facts.filter((f) => f.required && f.status === 'unknown').map((f) => t(`fact.title.${f.key}`).toLowerCase());
2026-10-03 13:33:50 +02:00
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>
2026-10-03 14:29:28 +02:00
<h1 id="detail-title" ref={headingRef} tabIndex={-1} className="margin-0 font-sans-xl">
2026-10-03 13:33:50 +02:00
{place.name ?? t('common.noName')}
2026-10-03 14:29:28 +02:00
</h1>
2026-10-03 13:33:50 +02:00
{address && <p className="margin-top-05 margin-bottom-0 text-base-dark">{address}</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">
2026-10-03 14:29:28 +02:00
<h2 className="usa-alert__heading"><VerdictTag verdict={assessment.verdict} /></h2>
2026-10-03 13:33:50 +02:00
<p className="usa-alert__text">{t(`verdict.${assessment.verdict}.long`)}</p>
2026-10-03 14:29:28 +02:00
{missing.length > 0 && <p className="usa-alert__text">{t('verdict.missing', { list: missing.join(', ') })}</p>}
2026-10-03 13:33:50 +02:00
</div>
</div>
<div className="display-flex flex-wrap margin-top-2">
<button type="button" className="usa-button" onClick={p.onRouteTo}>
<Icon name="directions_walk" size={3} className="margin-right-05" />{t('detail.routeTo')}
</button>
<button type="button" className="usa-button usa-button--outline tablet:display-none" onClick={p.onShowOnMap}>
<Icon name="map" size={3} className="margin-right-05" />{t('view.map')}
</button>
</div>
2026-10-03 14:29:28 +02:00
<div className="display-flex flex-wrap margin-top-1">
<button type="button" className="usa-button usa-button--outline usa-button--small" aria-pressed={p.saved} onClick={p.onToggleSave}>
<Icon name={p.saved ? 'check_circle' : 'add'} size={3} className="margin-right-05" />{p.saved ? t('detail.saved') : t('detail.save')}
</button>
<button type="button" className="usa-button usa-button--outline usa-button--small" onClick={p.onCard}>
<Icon name="list" size={3} className="margin-right-05" />{t('detail.card')}
</button>
</div>
<section aria-labelledby="reach-title" className="border-1px border-base-lighter radius-md padding-2 margin-top-2 bg-base-lightest">
<h2 id="reach-title" className="margin-top-0 font-sans-md">
<Icon name="directions_walk" size={3} className="margin-right-05 text-primary" />{t('reach.title')}
</h2>
<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>
</section>
<h2 className="margin-top-3 font-sans-lg">{t('detail.facts')}</h2>
2026-10-03 13:33:50 +02:00
<ul className="usa-list--unstyled margin-0 padding-0">
{assessment.facts.map((f) => (
<FactCard key={f.key} fact={f} onReport={() => { setReporting(f.key as ReportAttr); setThanks(false); }} />
))}
</ul>
{thanks && <Alert kind="success" slim role="status">{t('rep.thanks')}</Alert>}
{reporting && (
<ReportForm
placeId={place.id}
initialAttr={reporting}
onCancel={() => setReporting(null)}
2026-10-03 14:29:28 +02:00
onSubmit={(r, photo) => { p.onAddReport(r, photo); setReporting(null); setThanks(true); }}
2026-10-03 13:33:50 +02:00
/>
)}
2026-10-03 14:29:28 +02:00
<h2 className="margin-top-3 font-sans-lg">{t('detail.reports')}</h2>
2026-10-03 13:33:50 +02:00
<p className="font-sans-xs text-base-dark">{t('detail.reports.note')}</p>
{mine.length === 0 ? (
<p>{t('detail.reports.none')}</p>
) : (
<ul className="usa-list">
2026-10-03 14:29:28 +02:00
{mine.map((r) => <ReportItem key={r.id} r={r} onRemove={() => p.onRemoveReport(r.id)} />)}
2026-10-03 13:33:50 +02:00
</ul>
)}
<div className="border-top-1px border-base-lighter padding-top-2 margin-top-3">
{!place.demo && (
<>
<button type="button" className="usa-button usa-button--outline" onClick={p.onRefresh} disabled={p.refresh.kind === 'loading'}>
<Icon name="update" size={3} className="margin-right-05" />
{p.refresh.kind === 'loading' ? t('detail.refreshing') : t('detail.refresh')}
</button>
<a className="usa-button usa-button--unstyled margin-left-1" href={`https://www.openstreetmap.org/${osmPath(place.id)}`} target="_blank" rel="noreferrer">
{t('detail.openOsm')}
<Icon name="launch" size={2} className="margin-left-05" />
</a>
</>
)}
{place.demo && <p className="font-sans-xs text-base-dark">{t('detail.refreshDemo')}</p>}
<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>
</div>
</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)}`;
}
2026-10-03 14:29:28 +02:00
function ReportItem({ r, onRemove }: { r: UserReport; onRemove: () => void }) {
const { t, date } = useI18n();
const photo = usePhoto(r.photoId);
return (
<li>
<div className="display-flex">
{photo && <img className="kbb-thumb margin-right-1" src={photo} alt={t('rep.photo.alt')} />}
<div>
<strong>{t(`rep.attr.${r.attr}`)}:</strong>{' '}
{typeof r.value === 'number' ? `${r.value} ${t('unit.cm')}` : t(`rep.${r.value}`)}
{r.demo && <span className="usa-tag margin-left-1 bg-ink text-white">{t('detail.reports.demo')}</span>}
<span className="text-base-dark"> — {date(r.createdAt.slice(0, 10))}</span>
{r.measure && <div className="font-sans-xs">{t(`vet.measure.${r.measure.kind}`)}: {r.measure.value} {r.measure.unit === 'cm' ? 'cm' : '%'} ({t(`vet.method.${r.measure.method}`)})</div>}
{r.note && <div className="text-base-dark font-sans-xs">{r.note}</div>}
<VettingSummary v={r.vetting} />
{!r.demo && (
<button type="button" className="usa-button usa-button--unstyled font-sans-xs" onClick={onRemove}>{t('detail.reports.remove')}</button>
)}
</div>
</div>
</li>
);
}