231 lines
11 KiB
TypeScript
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)}`;
|
|
}
|