2026-10-03 14:29:28 +02:00
|
|
|
import { useId, useLayoutEffect, useRef, useState, type ReactNode } from 'react';
|
2026-10-03 13:33:50 +02:00
|
|
|
import { useI18n } from '../lib/i18n';
|
2026-10-03 14:29:28 +02:00
|
|
|
import type { Confidence, Fact, SourceKind, Status, Verdict, VetState } from '../lib/types';
|
2026-10-03 13:33:50 +02:00
|
|
|
|
|
|
|
|
const SPRITE = `${import.meta.env.BASE_URL}uswds/sprite.svg`;
|
|
|
|
|
|
|
|
|
|
export function Icon({ name, size = 3, className = '' }: { name: string; size?: 2 | 3 | 4 | 5; className?: string }) {
|
|
|
|
|
return (
|
|
|
|
|
<svg className={`usa-icon usa-icon--size-${size} ${className}`} aria-hidden="true" focusable="false" role="img">
|
|
|
|
|
<use href={`${SPRITE}#${name}`} />
|
|
|
|
|
</svg>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export const VERDICT_STYLE: Record<Verdict, { icon: string; tag: string; alert: string; hex: string }> = {
|
|
|
|
|
match: { icon: 'check_circle', tag: 'bg-success-dark text-white', alert: 'usa-alert--success', hex: '#00a91c' },
|
|
|
|
|
partial: { icon: 'warning', tag: 'bg-warning text-ink', alert: 'usa-alert--warning', hex: '#ffbe2e' },
|
|
|
|
|
conflict: { icon: 'report', tag: 'bg-accent-warm-darker text-white', alert: 'usa-alert--warning', hex: '#a2461c' },
|
|
|
|
|
incomplete: { icon: 'help', tag: 'bg-base-lighter text-ink', alert: 'usa-alert--info', hex: '#ffffff' },
|
|
|
|
|
fail: { icon: 'error', tag: 'bg-error-dark text-white', alert: 'usa-alert--error', hex: '#b50909' },
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
export function VerdictTag({ verdict }: { verdict: Verdict }) {
|
|
|
|
|
const { t } = useI18n();
|
|
|
|
|
const s = VERDICT_STYLE[verdict];
|
|
|
|
|
return (
|
|
|
|
|
<span className={`usa-tag display-inline-flex flex-align-center ${s.tag}`}>
|
|
|
|
|
<Icon name={s.icon} size={2} className="margin-right-05" />
|
|
|
|
|
{t(`verdict.${verdict}`)}
|
|
|
|
|
</span>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const STATUS_STYLE: Record<Status, { icon: string; text: string }> = {
|
|
|
|
|
ok: { icon: 'check_circle', text: 'text-success-dark' },
|
|
|
|
|
warn: { icon: 'warning', text: 'text-warning-darker' },
|
|
|
|
|
fail: { icon: 'error', text: 'text-error-dark' },
|
|
|
|
|
unknown: { icon: 'help', text: 'text-base-dark' },
|
|
|
|
|
conflict: { icon: 'report', text: 'text-accent-warm-darker' },
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
export function StatusLabel({ status }: { status: Status }) {
|
|
|
|
|
const { t } = useI18n();
|
|
|
|
|
const s = STATUS_STYLE[status];
|
|
|
|
|
return (
|
|
|
|
|
<span className={`display-inline-flex flex-align-center text-bold ${s.text}`}>
|
|
|
|
|
<Icon name={s.icon} size={3} className="margin-right-05" />
|
|
|
|
|
{t(`status.${status}`)}
|
|
|
|
|
</span>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const CONF_STYLE: Record<Confidence, string> = {
|
|
|
|
|
verified: 'bg-success-dark text-white',
|
|
|
|
|
mapped: 'bg-primary text-white',
|
|
|
|
|
stale: 'bg-warning text-ink',
|
|
|
|
|
inferred: 'bg-base-dark text-white',
|
|
|
|
|
user: 'bg-accent-warm-darker text-white',
|
|
|
|
|
none: 'bg-base-lighter text-ink',
|
|
|
|
|
};
|
|
|
|
|
const CONF_ICON: Record<Confidence, string> = {
|
|
|
|
|
verified: 'verified', mapped: 'check', stale: 'update', inferred: 'info_outline', user: 'edit', none: 'help_outline',
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
export function ConfidenceTag({ confidence }: { confidence: Confidence }) {
|
|
|
|
|
const { t } = useI18n();
|
|
|
|
|
return (
|
|
|
|
|
<span className={`usa-tag display-inline-flex flex-align-center ${CONF_STYLE[confidence]}`} title={t(`conf.${confidence}.help`)}>
|
|
|
|
|
<Icon name={CONF_ICON[confidence]} size={2} className="margin-right-05" />
|
|
|
|
|
{t(`conf.${confidence}`)}
|
|
|
|
|
</span>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function SourceLine({ source, confidence, date }: { source: SourceKind; confidence: Confidence; date: string | null }) {
|
|
|
|
|
const { t, date: fmt } = useI18n();
|
|
|
|
|
return (
|
|
|
|
|
<dl className="kbb-source font-sans-3xs margin-top-1 margin-bottom-0">
|
|
|
|
|
<div>
|
|
|
|
|
<dt className="text-base-dark">{t('src.label')}</dt>
|
|
|
|
|
<dd>{t(`src.${source}`)}</dd>
|
|
|
|
|
</div>
|
|
|
|
|
<div>
|
|
|
|
|
<dt className="text-base-dark">{t('fact.updated')}</dt>
|
|
|
|
|
<dd>{date ? fmt(date) : t('fact.dateUnknown')}</dd>
|
|
|
|
|
</div>
|
|
|
|
|
<div>
|
|
|
|
|
<dt className="text-base-dark">{t('fact.confidence')}</dt>
|
|
|
|
|
<dd><ConfidenceTag confidence={confidence} /></dd>
|
|
|
|
|
</div>
|
|
|
|
|
</dl>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function FactCard({ fact, onReport }: { fact: Fact; onReport?: (() => void) | undefined }) {
|
|
|
|
|
const { t, tm } = useI18n();
|
|
|
|
|
const canReport = onReport && ['entrance', 'steps', 'width', 'toilet', 'ramp'].includes(fact.key);
|
|
|
|
|
return (
|
|
|
|
|
<li className="border-1px border-base-lighter radius-md padding-2 margin-bottom-2 bg-white">
|
|
|
|
|
<div className="display-flex flex-justify flex-wrap flex-align-center">
|
2026-10-03 14:29:28 +02:00
|
|
|
<h3 className="margin-0 font-sans-md">{t(`fact.title.${fact.key}`)}</h3>
|
2026-10-03 13:33:50 +02:00
|
|
|
<StatusLabel status={fact.status} />
|
|
|
|
|
</div>
|
|
|
|
|
<p className="margin-top-1 margin-bottom-0">{tm(fact.headline)}</p>
|
|
|
|
|
{fact.detail && <p className="margin-top-05 margin-bottom-0 text-base-dark font-sans-xs">{tm(fact.detail)}</p>}
|
|
|
|
|
<SourceLine source={fact.source} confidence={fact.confidence} date={fact.date} />
|
|
|
|
|
{fact.alt && (
|
|
|
|
|
<div className="usa-alert usa-alert--warning usa-alert--slim margin-top-2">
|
|
|
|
|
<div className="usa-alert__body">
|
|
|
|
|
<p className="usa-alert__text">
|
|
|
|
|
<strong>{t('fact.otherSource')}:</strong> {tm(fact.alt.headline)}
|
|
|
|
|
</p>
|
|
|
|
|
<SourceLine source={fact.alt.source} confidence={fact.alt.confidence} date={fact.alt.date} />
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
<div className="display-flex flex-wrap flex-align-center margin-top-1">
|
|
|
|
|
{fact.required && <span className="font-sans-3xs text-base-dark margin-right-2">{t('fact.required')}</span>}
|
|
|
|
|
{canReport && (
|
|
|
|
|
<button type="button" className="usa-button usa-button--unstyled font-sans-xs" onClick={onReport}>
|
|
|
|
|
{t('fact.report')}
|
|
|
|
|
</button>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
</li>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function Alert({ kind, heading, children, slim = false, role }: { kind: 'info' | 'warning' | 'error' | 'success'; heading?: string; children: ReactNode; slim?: boolean; role?: 'alert' | 'status' }) {
|
|
|
|
|
return (
|
|
|
|
|
<div className={`usa-alert usa-alert--${kind} ${slim ? 'usa-alert--slim' : ''}`} role={role}>
|
|
|
|
|
<div className="usa-alert__body">
|
|
|
|
|
{heading && <h4 className="usa-alert__heading">{heading}</h4>}
|
|
|
|
|
<div className="usa-alert__text">{children}</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
2026-10-03 14:29:28 +02:00
|
|
|
|
|
|
|
|
/** Accessible help bubble: hover, focus or click opens it; Escape or blur closes it (WCAG 1.4.13). */
|
|
|
|
|
export function InfoTip({ topic, text }: { topic: string; text: string }) {
|
|
|
|
|
const { t } = useI18n();
|
|
|
|
|
const [open, setOpen] = useState(false);
|
|
|
|
|
const [hover, setHover] = useState(false);
|
|
|
|
|
const id = useId();
|
|
|
|
|
const show = open || hover;
|
|
|
|
|
const bubble = useRef<HTMLSpanElement>(null);
|
|
|
|
|
const [shift, setShift] = useState(0);
|
|
|
|
|
useLayoutEffect(() => {
|
|
|
|
|
const el = bubble.current;
|
|
|
|
|
if (!show || !el) return;
|
|
|
|
|
el.style.transform = '';
|
|
|
|
|
const r = el.getBoundingClientRect();
|
|
|
|
|
const over = r.right - (window.innerWidth - 8);
|
|
|
|
|
setShift(over > 0 ? -Math.min(over, r.left - 8) : 0);
|
|
|
|
|
}, [show]);
|
|
|
|
|
return (
|
|
|
|
|
<span className="kbb-tip" onMouseEnter={() => setHover(true)} onMouseLeave={() => setHover(false)}>
|
|
|
|
|
<button
|
|
|
|
|
type="button" className="kbb-tip__btn" aria-expanded={show} aria-controls={id} aria-label={t('tip.label', { topic })}
|
|
|
|
|
onClick={() => setOpen((o) => !o)}
|
|
|
|
|
onBlur={() => setOpen(false)}
|
|
|
|
|
onKeyDown={(e) => { if (e.key === 'Escape') { setOpen(false); setHover(false); } }}
|
|
|
|
|
>
|
|
|
|
|
<Icon name="help_outline" size={3} />
|
|
|
|
|
</button>
|
|
|
|
|
<span id={id} ref={bubble} role="tooltip" className="kbb-tip__bubble" hidden={!show} style={{ transform: `translateX(${shift}px)` }}>{text}</span>
|
|
|
|
|
</span>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const TRIAGE_STYLE = { 'fast-track': 'bg-primary-dark text-white', review: 'bg-base-lighter text-ink', 'likely-reject': 'bg-base-dark text-white' } as const;
|
|
|
|
|
const SEVERITY_STYLE = { critical: 'bg-error-dark text-white', major: 'bg-accent-warm-darker text-white', minor: 'bg-warning text-ink', none: 'bg-base-lighter text-ink' } as const;
|
|
|
|
|
|
|
|
|
|
/** Automatic first assessment of a report. Always framed as advisory. */
|
|
|
|
|
export function VettingSummary({ v, detailed = false }: { v: VetState | undefined; detailed?: boolean }) {
|
|
|
|
|
const { t, tm } = useI18n();
|
|
|
|
|
if (!v) return null;
|
|
|
|
|
if (v.state === 'running') return <p className="font-sans-xs margin-y-05" role="status"><Icon name="update" size={2} className="margin-right-05" />{t('vet.running')}</p>;
|
|
|
|
|
if (v.state === 'failed') {
|
|
|
|
|
return <p className="font-sans-xs margin-y-05 text-error-dark" role="status"><Icon name="error_outline" size={2} className="margin-right-05" />{t(v.reason === 'no-credits' ? 'vet.noCredits' : 'vet.error')}</p>;
|
|
|
|
|
}
|
|
|
|
|
const r = v.result;
|
|
|
|
|
return (
|
|
|
|
|
<div className="margin-y-05">
|
|
|
|
|
<p className="margin-y-05 display-flex flex-wrap flex-align-center">
|
|
|
|
|
<span className={`usa-tag margin-right-05 ${TRIAGE_STYLE[r.triage]}`}>{t(`vet.triage.${r.triage}`)}</span>
|
|
|
|
|
<span className={`usa-tag margin-right-05 ${SEVERITY_STYLE[r.severity]}`}>{t('vet.severityLabel')}: {t(`vet.severity.${r.severity}`)}</span>
|
|
|
|
|
<span className="usa-tag bg-base-lighter text-ink margin-right-05">{t(`vet.validity.${r.validity}`)}</span>
|
|
|
|
|
{r.flags.map((f) => <span key={f} className="usa-tag bg-warning text-ink margin-right-05">{t(`vet.flag.${f}`)}</span>)}
|
|
|
|
|
</p>
|
|
|
|
|
{detailed && (
|
|
|
|
|
<>
|
|
|
|
|
<p className="font-sans-xs margin-y-05">
|
|
|
|
|
{t('vet.classLabel')}: <strong>{t(`vet.class.${r.accessibilityClass}`)}</strong> · {t(`vet.consistency.${r.consistency}`)} · {t('vet.confidence', { pct: Math.round(r.confidence * 100) })}
|
|
|
|
|
</p>
|
|
|
|
|
<details className="font-sans-xs">
|
|
|
|
|
<summary>{t('dash.queue.reasons')}</summary>
|
|
|
|
|
<ul className="usa-list">
|
|
|
|
|
{r.reasons.map((m, i) => <li key={i}>{tm(m)}</li>)}
|
|
|
|
|
</ul>
|
|
|
|
|
<p className="text-base-dark margin-y-05">{t('vet.provider', { name: r.provider })}</p>
|
|
|
|
|
</details>
|
|
|
|
|
<p className="font-sans-3xs text-base-dark margin-y-05">{t('vet.disclaimer')}</p>
|
|
|
|
|
</>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|