Fix PDF export, fix places ordering and individual site
This commit is contained in:
parent
1e234013c6
commit
984ac0fd3f
17 changed files with 553 additions and 187 deletions
25
src/App.tsx
25
src/App.tsx
|
|
@ -8,6 +8,7 @@ import { PlaceDetail, type ReachState, type RefreshState } from './components/Pl
|
|||
import { DEFAULT_FILTERS, PlacesView, filterPlaces, type Filters } from './components/PlacesView';
|
||||
import { ProfileView } from './components/ProfileView';
|
||||
import { RouteView, type RouteForm } from './components/RouteView';
|
||||
import { LangToggle } from './components/LangToggle';
|
||||
import { SettingsPanel } from './components/SettingsPanel';
|
||||
import { SheetHandle, type Sheet } from './components/SheetHandle';
|
||||
import { Alert, Icon } from './components/ui';
|
||||
|
|
@ -19,6 +20,7 @@ import type { LatLon } from './lib/geo';
|
|||
import { I18nContext, formatDate, makeT, type I18n, type Key } from './lib/i18n';
|
||||
import { buildLabels } from './lib/labels';
|
||||
import { refreshPlace } from './lib/live';
|
||||
import { withCityOffer } from './lib/osmPlace';
|
||||
import { DEFAULT_PROFILE, isProfile } from './lib/profile';
|
||||
import { computeReach, placesInReach, type Reach } from './lib/reach';
|
||||
import { buildGraph, type RouteOption } from './lib/route';
|
||||
|
|
@ -109,7 +111,7 @@ export default function App() {
|
|||
const index = useMemo(() => (snap ? buildIndex(snap) : null), [snap]);
|
||||
const graph = useMemo(() => (snap ? buildGraph(snap) : null), [snap]);
|
||||
const [overrides, setOverrides] = useState<Record<string, Place>>({});
|
||||
const realPlaces = useMemo<Place[]>(() => (snap ? snap.places.map((p) => overrides[p.id] ?? p) : []), [snap, overrides]);
|
||||
const realPlaces = useMemo<Place[]>(() => (snap ? snap.places.map((p) => withCityOffer(overrides[p.id] ?? p)) : []), [snap, overrides]);
|
||||
const places = useMemo<Place[]>(() => (settings.showDemoPlaces ? [...DEMO_PLACES, ...realPlaces] : realPlaces), [realPlaces, settings.showDemoPlaces]);
|
||||
const reports = useMemo<UserReport[]>(
|
||||
() => [...DEMO_REPORTS, ...userReports].map((r) => (decisions[r.id] ? { ...r, ...decisions[r.id] } : r)),
|
||||
|
|
@ -133,6 +135,11 @@ export default function App() {
|
|||
const [filters, setFilters] = useState<Filters>(DEFAULT_FILTERS);
|
||||
const favSet = useMemo(() => new Set(favorites), [favorites]);
|
||||
const results = useMemo(() => filterPlaces(places, assessments, filters, favSet), [places, assessments, filters, favSet]);
|
||||
const catCounts = useMemo(() => {
|
||||
const c: Record<string, number> = {};
|
||||
for (const p of filterPlaces(places, assessments, { ...filters, cat: 'all' }, favSet)) { c[p.cat] = (c[p.cat] ?? 0) + 1; c.all = (c.all ?? 0) + 1; }
|
||||
return c;
|
||||
}, [places, assessments, filters, favSet]);
|
||||
const mapPlaces = useMemo(
|
||||
() => results.flatMap((place) => { const a = assessments.get(place.id); return a ? [{ place, verdict: a.verdict }] : []; }),
|
||||
[results, assessments],
|
||||
|
|
@ -386,17 +393,10 @@ export default function App() {
|
|||
<Icon name="accessibility_new" size={3} className="margin-right-05" />
|
||||
{account.id === 'guest' ? t('auth.role.guest') : t(`auth.${account.id}` as Key)}
|
||||
</span>
|
||||
<div role="group" aria-label={t('lang.label')} className="display-flex">
|
||||
{(['pl', 'en'] as const).map((l) => (
|
||||
<button
|
||||
key={l} type="button" lang={l} aria-pressed={lang === l}
|
||||
className={`usa-button usa-button--small margin-right-0 ${lang === l ? '' : 'usa-button--outline'}`}
|
||||
onClick={() => i18n.setLang(l)}
|
||||
>
|
||||
{l.toUpperCase()}
|
||||
<button type="button" className="kbb-signout" onClick={signOut} aria-label={t('auth.signOut')} title={t('auth.signOut')}>
|
||||
<Icon name="logout" size={3} />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<LangToggle lang={lang} onLang={i18n.setLang} />
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
|
@ -481,7 +481,6 @@ export default function App() {
|
|||
onClearReach={clearReach}
|
||||
onBack={back}
|
||||
onRouteTo={() => routeTo(selected)}
|
||||
onShowOnMap={() => setPane('map')}
|
||||
onRefresh={() => void doRefresh(selected)}
|
||||
onAddReport={(r, photo) => void addReport(r, photo)}
|
||||
onRemoveReport={(id) => { void deletePhoto(id); setUserReports((cur) => cur.filter((r) => r.id !== id)); }}
|
||||
|
|
@ -489,7 +488,7 @@ export default function App() {
|
|||
) : (
|
||||
<PlacesView
|
||||
profile={profile} filters={filters} onFilters={setFilters} results={results}
|
||||
assessments={assessments} onSelect={select}
|
||||
assessments={assessments} counts={catCounts} onSelect={select}
|
||||
/>
|
||||
))}
|
||||
{view === 'route' && (
|
||||
|
|
|
|||
|
|
@ -1,8 +1,11 @@
|
|||
import { useEffect, useRef } from 'react';
|
||||
import { useI18n } from '../lib/i18n';
|
||||
import type { Assessment, Place, Profile } from '../lib/types';
|
||||
import { BrandMark } from './BrandMark';
|
||||
import { Icon, SourceLine, StatusLabel, VerdictTag } from './ui';
|
||||
|
||||
const VERDICT_COLOR: Record<Assessment['verdict'], string> = { match: '#00a91c', partial: '#ffbe2e', conflict: '#a2461c', incomplete: '#71767a', fail: '#b50909' };
|
||||
|
||||
interface Props { place: Place; assessment: Assessment; profile: Profile; onClose: () => void }
|
||||
|
||||
/** Printable one-page summary: for a venue's website, a carer, or a council officer. */
|
||||
|
|
@ -22,32 +25,47 @@ export function AccessCard({ place, assessment, profile, onClose }: Props) {
|
|||
<div className="kbb-card__sheet">
|
||||
<div className="kbb-card__bar">
|
||||
<button type="button" className="usa-button" onClick={() => window.print()}>
|
||||
<Icon name="list" size={3} className="margin-right-05" />{t('card.print')}
|
||||
<Icon name="print" size={3} className="margin-right-05" />{t('card.print')}
|
||||
</button>
|
||||
<button type="button" className="usa-button usa-button--outline" onClick={onClose}>
|
||||
<Icon name="close" size={3} className="margin-right-05" />{t('card.close')}
|
||||
</button>
|
||||
</div>
|
||||
<p className="margin-0 text-bold text-primary-darker">{t('card.title')} · {t('app.name')}</p>
|
||||
<h2 id="card-title" ref={heading} tabIndex={-1} className="margin-top-05 margin-bottom-0">{place.name}</h2>
|
||||
{address && <p className="margin-top-05 text-base-dark">{address}</p>}
|
||||
<p className="margin-y-1"><VerdictTag verdict={assessment.verdict} /> <span className="font-sans-xs">{t('card.for', { profile: t(`profile.${profile.kind}`) })}</span></p>
|
||||
<p>{t(`verdict.${assessment.verdict}.long`)}</p>
|
||||
<ul className="usa-list--unstyled margin-0 padding-0">
|
||||
<header className="kbb-doc__head">
|
||||
<BrandMark size={44} />
|
||||
<div className="kbb-doc__brand">
|
||||
<strong>{t('app.name')}</strong>
|
||||
<span>{t('card.title')}</span>
|
||||
</div>
|
||||
<span className="kbb-doc__date">{date(new Date().toISOString().slice(0, 10))}</span>
|
||||
</header>
|
||||
|
||||
<h2 id="card-title" ref={heading} tabIndex={-1} className="kbb-doc__title">{place.name}</h2>
|
||||
{address && <p className="kbb-doc__addr">{address}</p>}
|
||||
|
||||
<section className="kbb-doc__verdict" style={{ borderLeftColor: VERDICT_COLOR[assessment.verdict] }} aria-label={t(`verdict.${assessment.verdict}`)}>
|
||||
<p className="margin-0"><VerdictTag verdict={assessment.verdict} /> <span className="kbb-doc__for">{t('card.for', { profile: t(`profile.${profile.kind}`) })}</span></p>
|
||||
<p className="margin-y-05">{t(`verdict.${assessment.verdict}.long`)}</p>
|
||||
</section>
|
||||
|
||||
<ul className="kbb-doc__facts usa-list--unstyled margin-0 padding-0">
|
||||
{assessment.facts.map((f) => (
|
||||
<li key={f.key} className="border-top-1px border-base-lighter padding-y-1">
|
||||
<div className="display-flex flex-justify flex-wrap">
|
||||
<li key={f.key} className="kbb-doc__fact">
|
||||
<div className="kbb-doc__fact-head">
|
||||
<strong>{t(`fact.title.${f.key}`)}</strong>
|
||||
<StatusLabel status={f.status} />
|
||||
</div>
|
||||
<p className="margin-y-05">{tm(f.headline)}</p>
|
||||
<SourceLine source={f.source} confidence={f.confidence} date={f.date} />
|
||||
{f.alt && <p className="font-sans-xs margin-y-05"><strong>{t('fact.otherSource')}:</strong> {tm(f.alt.headline)}</p>}
|
||||
{f.alt && <p className="margin-y-05"><strong>{t('fact.otherSource')}:</strong> {tm(f.alt.headline)}</p>}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<p className="font-sans-3xs text-base-dark margin-top-2">{t('card.legend')}</p>
|
||||
<p className="font-sans-3xs text-base-dark">{t('card.generated', { date: date(new Date().toISOString().slice(0, 10)) })} {t('footer.attr')}</p>
|
||||
|
||||
<footer className="kbb-doc__foot">
|
||||
<p>{t('card.legend')}</p>
|
||||
<p>{t('card.generated', { date: date(new Date().toISOString().slice(0, 10)) })} {t('footer.attr')}</p>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
12
src/components/BrandMark.tsx
Normal file
12
src/components/BrandMark.tsx
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
/** App logo (same artwork as public/icons/icon.svg): decorative, the app name is always written next to it. */
|
||||
export function BrandMark({ size = 40, className = '' }: { size?: number; className?: string }) {
|
||||
return (
|
||||
<svg className={className} width={size} height={size} viewBox="0 0 512 512" aria-hidden="true" focusable="false">
|
||||
<rect width="512" height="512" rx="92.16" fill="#005ea2" />
|
||||
<g fill="#ffffff" transform="translate(97.28 97.28) scale(13.2267)">
|
||||
<circle cx="17" cy="4.54" r="2" />
|
||||
<path d="M14 17h-2c0 1.65-1.35 3-3 3s-3-1.35-3-3 1.35-3 3-3v-2c-2.76 0-5 2.24-5 5s2.24 5 5 5 5-2.24 5-5zm3-3.5h-1.86l1.67-3.67C17.42 8.5 16.44 7 14.96 7h-5.2c-.81 0-1.54.47-1.87 1.2L7.22 10l1.92.53L9.79 9H12l-1.83 4.1c-.6 1.33.39 2.9 1.85 2.9H17v5h2v-5.5c0-1.1-.9-2-2-2z" />
|
||||
</g>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
import { useI18n, type Key } from '../lib/i18n';
|
||||
import type { Stats } from '../lib/civic';
|
||||
import type { Place, UserReport } from '../lib/types';
|
||||
import { Alert } from './ui';
|
||||
import { Alert, Icon } from './ui';
|
||||
|
||||
const pct = (n: number, d: number) => (d ? Math.round((n / d) * 100) : 0);
|
||||
|
||||
|
|
@ -18,80 +18,124 @@ function agreement(reports: UserReport[]): { agree: number; total: number } {
|
|||
return { agree, total };
|
||||
}
|
||||
|
||||
/** Data-quality overview: KPIs, the transit finding, gaps by category and anonymous demand signals. */
|
||||
export function DashData({ stats, reports, demand }: { stats: Stats; reports: UserReport[]; demand: { place: Place; views: number }[] }) {
|
||||
/** Decorative bar; the numbers next to it carry the information. */
|
||||
function Meter({ value, className = '' }: { value: number; className?: string }) {
|
||||
return <span className={`kbb-meter ${className}`} aria-hidden="true"><span style={{ width: `${Math.min(100, value)}%` }} /></span>;
|
||||
}
|
||||
|
||||
function Kpi({ label, value, sub, meter }: { label: string; value: string; sub?: string | undefined; meter?: number | undefined }) {
|
||||
return (
|
||||
<li className="kbb-kpi">
|
||||
<span className="kbb-kpi__v">{value}</span>
|
||||
<span className="kbb-kpi__l">{label}</span>
|
||||
{meter !== undefined && <Meter value={meter} />}
|
||||
{sub && <span className="kbb-kpi__s">{sub}</span>}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
interface Props {
|
||||
stats: Stats;
|
||||
reports: UserReport[];
|
||||
demand: { place: Place; views: number }[];
|
||||
onOpenPlace: (id: string) => void;
|
||||
}
|
||||
|
||||
/** Data-quality overview, ordered by the question an official asks: how good is it, what is missing, where do people look. */
|
||||
export function DashData({ stats, reports, demand, onOpenPlace }: Props) {
|
||||
const { t } = useI18n();
|
||||
const ag = agreement(reports);
|
||||
const kpis: { label: string; value: string; sub?: string }[] = [
|
||||
{ label: t('dash.kpi.places'), value: String(stats.total) },
|
||||
{ label: t('dash.kpi.entrance'), value: `${pct(stats.entranceKnown, stats.total)}%`, sub: `${stats.entranceKnown} / ${stats.total}` },
|
||||
{ label: t('dash.kpi.fresh'), value: `${pct(stats.fresh, stats.total)}%`, sub: `${stats.fresh} / ${stats.total}` },
|
||||
{ label: t('dash.kpi.verified'), value: `${stats.verified}`, sub: `${pct(stats.verified, stats.total)}%` },
|
||||
{ label: t('dash.kpi.stops'), value: `${stats.transitOperatorKnown} / ${stats.transitTotal}` },
|
||||
{ label: t('dash.kpi.stopsOsm'), value: `${stats.transitCommunityKnown} / ${stats.transitTotal}` },
|
||||
{ label: t('dash.kpi.agreement'), value: ag.total ? `${Math.round((ag.agree / ag.total) * 100)}%` : t('dash.kpi.agreement.none'), ...(ag.total ? { sub: t('dash.kpi.agreement.n', { n: ag.total }) } : {}) },
|
||||
const maxViews = Math.max(1, ...demand.map((d) => d.views));
|
||||
const stops: { label: string; n: number; cls: string }[] = [
|
||||
{ label: t('dash.kpi.stops'), n: stats.transitOperatorKnown, cls: 'kbb-meter--op' },
|
||||
{ label: t('dash.kpi.stopsOsm'), n: stats.transitCommunityKnown, cls: 'kbb-meter--osm' },
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<p className="text-base-dark margin-top-0">{t('dash.intro')}</p>
|
||||
<ul className="kbb-kpis usa-list--unstyled margin-0 padding-0">
|
||||
{kpis.map((k) => (
|
||||
<li key={k.label} className="kbb-kpi">
|
||||
<span className="kbb-kpi__v">{k.value}</span>
|
||||
<span className="kbb-kpi__l">{k.label}</span>
|
||||
{k.sub && <span className="kbb-kpi__s">{k.sub}</span>}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<p className="text-base-dark font-sans-xs margin-top-0">{t('dash.intro')}</p>
|
||||
|
||||
<Alert kind="info">
|
||||
<div className="kbb-dq">
|
||||
<section aria-labelledby="dq-state" className="kbb-dq__wide">
|
||||
<h2 id="dq-state" className="kbb-h2">{t('dash.sec.state', { n: stats.total })}</h2>
|
||||
<ul className="kbb-kpis usa-list--unstyled margin-0 padding-0">
|
||||
<Kpi label={t('dash.kpi.entrance')} value={`${pct(stats.entranceKnown, stats.total)}%`} sub={`${stats.entranceKnown} / ${stats.total}`} meter={pct(stats.entranceKnown, stats.total)} />
|
||||
<Kpi label={t('dash.kpi.fresh')} value={`${pct(stats.fresh, stats.total)}%`} sub={`${stats.fresh} / ${stats.total}`} meter={pct(stats.fresh, stats.total)} />
|
||||
<Kpi label={t('dash.kpi.verified')} value={String(stats.verified)} sub={`${pct(stats.verified, stats.total)}%`} meter={pct(stats.verified, stats.total)} />
|
||||
<Kpi
|
||||
label={t('dash.kpi.agreement')}
|
||||
value={ag.total ? `${Math.round((ag.agree / ag.total) * 100)}%` : '—'}
|
||||
sub={ag.total ? t('dash.kpi.agreement.n', { n: ag.total }) : t('dash.kpi.agreement.none')}
|
||||
{...(ag.total ? { meter: pct(ag.agree, ag.total) } : {})}
|
||||
/>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section aria-labelledby="dq-stops">
|
||||
<h2 id="dq-stops" className="kbb-h2">{t('dash.sec.stops', { total: stats.transitTotal })}</h2>
|
||||
<div className="kbb-compare">
|
||||
{stops.map((s) => (
|
||||
<div key={s.label} className="kbb-compare__row">
|
||||
<span className="kbb-compare__label">{s.label}</span>
|
||||
<Meter value={pct(s.n, stats.transitTotal)} className={s.cls} />
|
||||
<strong className="kbb-compare__n">{s.n} / {stats.transitTotal}</strong>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<Alert kind="info" slim>
|
||||
{t('dash.insight', { n: stats.transitTotal - stats.transitOperatorKnown, total: stats.transitTotal, osm: stats.transitCommunityKnown })}
|
||||
</Alert>
|
||||
</section>
|
||||
|
||||
<section aria-labelledby="gaps" className="margin-top-4">
|
||||
<h2 id="gaps">{t('dash.gaps')}</h2>
|
||||
<table className="usa-table usa-table--borderless usa-table--stacked width-full">
|
||||
<caption className="usa-sr-only">{t('dash.gaps.caption')}</caption>
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">{t('dash.gaps.cat')}</th>
|
||||
<th scope="col">{t('dash.gaps.total')}</th>
|
||||
<th scope="col">{t('dash.gaps.known')}</th>
|
||||
<th scope="col">{t('dash.gaps.stale')}</th>
|
||||
<th scope="col">{t('dash.gaps.unknown')}</th>
|
||||
<th scope="col"><span className="usa-sr-only">{t('dash.gaps.caption')}</span></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<section aria-labelledby="demand">
|
||||
<h2 id="demand" className="kbb-h2">{t('dash.demand')}</h2>
|
||||
<p className="font-sans-xs text-base-dark margin-top-0">{t('dash.demand.intro')}</p>
|
||||
{demand.length === 0 ? (
|
||||
<div className="kbb-empty"><Icon name="search" size={4} /><p className="margin-0">{t('dash.demand.none')}</p></div>
|
||||
) : (
|
||||
<ol className="kbb-demand usa-list--unstyled margin-0 padding-0">
|
||||
{demand.map(({ place, views }, i) => (
|
||||
<li key={place.id} className="kbb-demand__row">
|
||||
<span className="kbb-rank" aria-hidden="true">{i + 1}</span>
|
||||
<div className="kbb-demand__body">
|
||||
<p className="margin-0"><strong>{place.name}</strong> <span className="text-base-dark font-sans-xs">· {t(`cat.${place.cat}` as Key)}</span></p>
|
||||
<div className="kbb-demand__bar"><Meter value={pct(views, maxViews)} className="kbb-meter--osm" /><span>{t('dash.demand.views', { n: views })}</span></div>
|
||||
</div>
|
||||
<button type="button" className="usa-button usa-button--outline usa-button--small margin-0" onClick={() => onOpenPlace(place.id)}>{t('dash.q.openPlace')}</button>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
)}
|
||||
</section>
|
||||
<section aria-labelledby="gaps" className="kbb-dq__wide">
|
||||
<h2 id="gaps" className="kbb-h2">{t('dash.gaps')}</h2>
|
||||
<p className="font-sans-xs text-base-dark margin-top-0">{t('dash.gaps.caption')}</p>
|
||||
<ul className="kbb-legend-row usa-list--unstyled margin-0 padding-0" aria-hidden="true">
|
||||
<li><span className="kbb-dot kbb-bar__k" />{t('dash.gaps.known')}</li>
|
||||
<li><span className="kbb-dot kbb-bar__s" />{t('dash.gaps.stale')}</li>
|
||||
<li><span className="kbb-dot kbb-bar__u" />{t('dash.gaps.unknown')}</li>
|
||||
</ul>
|
||||
<ul className="kbb-gaps usa-list--unstyled margin-0 padding-0">
|
||||
{stats.byCat.map((r) => (
|
||||
<tr key={r.cat}>
|
||||
<th scope="row" data-label={t('dash.gaps.cat')}>{t(`cat.${r.cat}` as Key)}</th>
|
||||
<td data-label={t('dash.gaps.total')}>{r.total}</td>
|
||||
<td data-label={t('dash.gaps.known')}>{r.known} ({pct(r.known, r.total)}%)</td>
|
||||
<td data-label={t('dash.gaps.stale')}>{r.stale} ({pct(r.stale, r.total)}%)</td>
|
||||
<td data-label={t('dash.gaps.unknown')}>{r.unknown} ({pct(r.unknown, r.total)}%)</td>
|
||||
<td aria-hidden="true" className="kbb-bar">
|
||||
<li key={r.cat} className="kbb-gap">
|
||||
<div className="kbb-gap__head">
|
||||
<strong>{t(`cat.${r.cat}` as Key)}</strong>
|
||||
<span className="text-base-dark">{r.total}</span>
|
||||
</div>
|
||||
<span className="kbb-stack" aria-hidden="true">
|
||||
<span className="kbb-bar__k" style={{ width: `${pct(r.known, r.total)}%` }} />
|
||||
<span className="kbb-bar__s" style={{ width: `${pct(r.stale, r.total)}%` }} />
|
||||
<span className="kbb-bar__u" style={{ width: `${pct(r.unknown, r.total)}%` }} />
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
|
||||
<section aria-labelledby="demand" className="margin-top-4">
|
||||
<h2 id="demand">{t('dash.demand')}</h2>
|
||||
<p className="text-base-dark">{t('dash.demand.intro')}</p>
|
||||
{demand.length === 0 ? <p>{t('dash.demand.none')}</p> : (
|
||||
<ul className="usa-list">
|
||||
{demand.map(({ place, views }) => (
|
||||
<li key={place.id}><strong>{place.name}</strong> — {t('dash.demand.views', { n: views })}</li>
|
||||
</span>
|
||||
<p className="kbb-gap__nums">
|
||||
{t('dash.gaps.known')}: {r.known} ({pct(r.known, r.total)}%) · {t('dash.gaps.stale')}: {r.stale} ({pct(r.stale, r.total)}%) · {t('dash.gaps.unknown')}: {r.unknown} ({pct(r.unknown, r.total)}%)
|
||||
</p>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -51,7 +51,7 @@ export function DashboardView(p: Props) {
|
|||
<div {...tabPanelProps('dash', tab)}>
|
||||
{tab === 'queue' && <DashQueue reports={p.reports} placeById={p.placeById} onDecide={p.onDecide} onUndo={p.onUndo} onOpenPlace={p.onOpenPlace} />}
|
||||
{tab === 'priorities' && <DashPriorities hotspots={p.hotspots} onShowHotspot={p.onShowHotspot} />}
|
||||
{tab === 'data' && <DashData stats={p.stats} reports={p.reports} demand={p.demand} />}
|
||||
{tab === 'data' && <DashData stats={p.stats} reports={p.reports} demand={p.demand} onOpenPlace={p.onOpenPlace} />}
|
||||
{tab === 'export' && (
|
||||
<div>
|
||||
<p className="text-base-dark margin-top-0">{t('dash.export.intro')}</p>
|
||||
|
|
|
|||
15
src/components/LangToggle.tsx
Normal file
15
src/components/LangToggle.tsx
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
import { useI18n } from '../lib/i18n';
|
||||
import type { Lang } from '../lib/types';
|
||||
|
||||
/** One switch for PL/EN: both languages are visible, the active one is highlighted. */
|
||||
export function LangToggle({ lang, onLang }: { lang: Lang; onLang: (l: Lang) => void }) {
|
||||
const { t } = useI18n();
|
||||
const other: Lang = lang === 'pl' ? 'en' : 'pl';
|
||||
return (
|
||||
<button type="button" className="kbb-lang" aria-label={t('lang.switchTo')} lang={lang} onClick={() => onLang(other)}>
|
||||
{(['pl', 'en'] as const).map((l) => (
|
||||
<span key={l} lang={l} className={lang === l ? 'is-on' : ''} aria-hidden="true">{l.toUpperCase()}</span>
|
||||
))}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
import { ACCOUNTS, GUEST, type Account } from '../lib/auth';
|
||||
import { useI18n, type Key } from '../lib/i18n';
|
||||
import type { Lang } from '../lib/types';
|
||||
import { LangToggle } from './LangToggle';
|
||||
import { Alert, Icon } from './ui';
|
||||
|
||||
export function LoginView({ onPick, lang, onLang }: { onPick: (a: Account) => void; lang: Lang; onLang: (l: Lang) => void }) {
|
||||
|
|
@ -11,12 +12,7 @@ export function LoginView({ onPick, lang, onLang }: { onPick: (a: Account) => vo
|
|||
<p className="margin-0 display-flex flex-align-center text-bold font-sans-lg text-primary-darker">
|
||||
<Icon name="accessible_forward" size={4} className="margin-right-1" />{t('app.name')}
|
||||
</p>
|
||||
<div role="group" aria-label={t('lang.label')} className="display-flex">
|
||||
{(['pl', 'en'] as const).map((l) => (
|
||||
<button key={l} type="button" lang={l} aria-pressed={lang === l} onClick={() => onLang(l)}
|
||||
className={`usa-button usa-button--small margin-right-0 ${lang === l ? '' : 'usa-button--outline'}`}>{l.toUpperCase()}</button>
|
||||
))}
|
||||
</div>
|
||||
<LangToggle lang={lang} onLang={onLang} />
|
||||
</div>
|
||||
<h1 className="margin-top-4">{t('auth.title')}</h1>
|
||||
<p className="usa-intro">{t('app.tagline')}</p>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
import { forwardRef, useState } from 'react';
|
||||
import { useI18n } from '../lib/i18n';
|
||||
import { googleMapsPlaceUrl } from '../lib/links';
|
||||
import { useI18n, type Key } from '../lib/i18n';
|
||||
import { DEMO_PLACES } from '../lib/demo';
|
||||
import type { Assessment, Place, ReportAttr, UserReport } from '../lib/types';
|
||||
import type { Assessment, Place, ReportAttr, Status, UserReport } from '../lib/types';
|
||||
import { Alert, FactCard, Icon, VERDICT_STYLE, VerdictTag, VettingSummary } from './ui';
|
||||
import { ReportForm } from './ReportForm';
|
||||
import type { ProcessedPhoto } from '../lib/photo';
|
||||
|
|
@ -29,12 +30,16 @@ interface Props {
|
|||
onClearReach: () => void;
|
||||
onBack: () => void;
|
||||
onRouteTo: () => void;
|
||||
onShowOnMap: () => 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);
|
||||
|
|
@ -42,6 +47,8 @@ export const PlaceDetail = forwardRef<HTMLHeadingElement, Props>(function PlaceD
|
|||
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());
|
||||
|
||||
|
|
@ -53,9 +60,19 @@ export const PlaceDetail = forwardRef<HTMLHeadingElement, Props>(function PlaceD
|
|||
</button>
|
||||
|
||||
<h1 id="detail-title" ref={headingRef} tabIndex={-1} className="margin-0 font-sans-xl">
|
||||
{place.name ?? t('common.noName')}
|
||||
<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 text-base-dark">{address}</p>}
|
||||
{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">
|
||||
|
|
@ -74,28 +91,28 @@ export const PlaceDetail = forwardRef<HTMLHeadingElement, Props>(function PlaceD
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div className="display-flex flex-wrap margin-top-2">
|
||||
<button type="button" className="usa-button" onClick={p.onRouteTo}>
|
||||
<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="usa-button usa-button--outline tablet:display-none" onClick={p.onShowOnMap}>
|
||||
<Icon name="map" size={3} className="margin-right-05" />{t('view.map')}
|
||||
<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>
|
||||
|
||||
<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>
|
||||
<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) => (
|
||||
|
|
@ -118,14 +135,27 @@ export const PlaceDetail = forwardRef<HTMLHeadingElement, Props>(function PlaceD
|
|||
</>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</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">
|
||||
{assessment.facts.map((f) => (
|
||||
{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 && (
|
||||
|
|
@ -137,35 +167,52 @@ export const PlaceDetail = forwardRef<HTMLHeadingElement, Props>(function PlaceD
|
|||
/>
|
||||
)}
|
||||
|
||||
<h2 className="margin-top-3 font-sans-lg">{t('detail.reports')}</h2>
|
||||
<p className="font-sans-xs text-base-dark">{t('detail.reports.note')}</p>
|
||||
<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 ? (
|
||||
<p>{t('detail.reports.none')}</p>
|
||||
<div className="kbb-empty"><Icon name="edit" size={3} /><p className="margin-0">{t('detail.reports.none')}</p></div>
|
||||
) : (
|
||||
<ul className="usa-list">
|
||||
{mine.map((r) => <ReportItem key={r.id} r={r} onRemove={() => p.onRemoveReport(r.id)} />)}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<div className="border-top-1px border-base-lighter padding-top-2 margin-top-3">
|
||||
{!place.demo && (
|
||||
<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>
|
||||
) : (
|
||||
<>
|
||||
<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')}
|
||||
<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>
|
||||
<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" />
|
||||
</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>
|
||||
</>
|
||||
)}
|
||||
{place.demo && <p className="font-sans-xs text-base-dark">{t('detail.refreshDemo')}</p>}
|
||||
<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>
|
||||
</div>
|
||||
</section>
|
||||
</article>
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -9,7 +9,9 @@ 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 CATS: PlaceCategory[] = ['city', 'food', 'culture', 'shop', 'stay', 'health', 'services', 'transit'];
|
||||
/** Items shown per category in the overview before "show all". */
|
||||
const PER_GROUP = 5;
|
||||
const PAGE = 40;
|
||||
|
||||
export function filterPlaces(places: Place[], assessments: Map<string, Assessment>, f: Filters, saved: ReadonlySet<string>): Place[] {
|
||||
|
|
@ -35,15 +37,22 @@ interface Props {
|
|||
filters: Filters;
|
||||
onFilters: (f: Filters) => void;
|
||||
results: Place[];
|
||||
/** Matches per category ignoring the category filter (key `all` = total). */
|
||||
counts: Record<string, number>;
|
||||
assessments: Map<string, Assessment>;
|
||||
onSelect: (id: string) => void;
|
||||
}
|
||||
|
||||
export function PlacesView({ profile, filters, onFilters, results, assessments, onSelect }: Props) {
|
||||
export function PlacesView({ profile, filters, onFilters, results, counts, assessments, onSelect }: Props) {
|
||||
const { t } = useI18n();
|
||||
const [shown, setShown] = useState(PAGE);
|
||||
const visible = useMemo(() => results.slice(0, shown), [results, shown]);
|
||||
const activeFilters = (filters.cat !== DEFAULT_FILTERS.cat ? 1 : 0) + (filters.onlySaved ? 1 : 0) + (filters.onlyFit ? 1 : 0);
|
||||
const activeFilters = (filters.onlySaved ? 1 : 0) + (filters.onlyFit ? 1 : 0);
|
||||
const groups = useMemo(() => {
|
||||
const m = new Map<PlaceCategory, Place[]>();
|
||||
for (const p of results) m.set(p.cat, [...(m.get(p.cat) ?? []), p]);
|
||||
return CATS.flatMap((c) => { const l = m.get(c); return l ? [[c, l] as const] : []; });
|
||||
}, [results]);
|
||||
const set = (patch: Partial<Filters>) => { setShown(PAGE); onFilters({ ...filters, ...patch }); };
|
||||
|
||||
return (
|
||||
|
|
@ -58,10 +67,6 @@ export function PlacesView({ profile, filters, onFilters, results, assessments,
|
|||
<input id="q" className="usa-input maxw-full" type="search" value={filters.query} autoComplete="off" onChange={(e) => set({ query: e.target.value })} />
|
||||
<details className="kbb-filters margin-top-1">
|
||||
<summary>{activeFilters ? t('places.filtersActive', { n: activeFilters }) : t('places.filters')}</summary>
|
||||
<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>
|
||||
|
|
@ -73,17 +78,52 @@ export function PlacesView({ profile, filters, onFilters, results, assessments,
|
|||
</details>
|
||||
</div>
|
||||
|
||||
<p className="margin-top-3 margin-bottom-1 text-bold" role="status" aria-live="polite">{t('places.count', { n: results.length })}</p>
|
||||
<div className="kbb-chips kbb-chips--scroll margin-top-2" role="group" aria-label={t('places.cats')}>
|
||||
{(['all', ...CATS] as const).filter((c) => c === 'all' || c === filters.cat || (counts[c] ?? 0) > 0).map((c) => (
|
||||
<button key={c} type="button" aria-pressed={filters.cat === c} className={`kbb-chip ${filters.cat === c ? 'is-on' : ''}`} onClick={() => set({ cat: c })}>
|
||||
{t(`cat.${c}` as Key)}<span className="kbb-chip__n">{counts[c] ?? 0}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<p className="margin-top-2 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>
|
||||
) : filters.cat === 'all' ? (
|
||||
// overview: a few places per category, then a jump into the full category
|
||||
groups.map(([cat, list]) => (
|
||||
<section key={cat} aria-labelledby={`grp-${cat}`} className="kbb-group">
|
||||
<h2 id={`grp-${cat}`} className="kbb-group__h">{t(`cat.${cat}` as Key)} <span className="text-base-dark text-normal">({list.length})</span></h2>
|
||||
<ul className="usa-list--unstyled margin-0 padding-0">
|
||||
{list.slice(0, PER_GROUP).map((p) => <PlaceRow key={p.id} place={p} assessment={assessments.get(p.id)} onSelect={onSelect} />)}
|
||||
</ul>
|
||||
{list.length > PER_GROUP && (
|
||||
<button type="button" className="usa-button usa-button--unstyled" onClick={() => set({ cat })}>
|
||||
{t('places.groupMore', { n: list.length })}
|
||||
</button>
|
||||
)}
|
||||
</section>
|
||||
))
|
||||
) : (
|
||||
<ul className="usa-list--unstyled margin-0 padding-0">
|
||||
{visible.map((p) => {
|
||||
const a = assessments.get(p.id);
|
||||
{visible.map((p) => <PlaceRow key={p.id} place={p} assessment={assessments.get(p.id)} onSelect={onSelect} />)}
|
||||
</ul>
|
||||
)}
|
||||
{filters.cat !== 'all' && 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>
|
||||
);
|
||||
}
|
||||
|
||||
function PlaceRow({ place: p, assessment: a, onSelect }: { place: Place; assessment: Assessment | undefined; onSelect: (id: string) => void }) {
|
||||
const { t } = useI18n();
|
||||
const isDemo = DEMO_PLACES.some((d) => d.id === p.id);
|
||||
return (
|
||||
<li key={p.id} className="margin-bottom-1">
|
||||
<li 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"
|
||||
|
|
@ -101,14 +141,4 @@ export function PlacesView({ profile, filters, onFilters, results, assessments,
|
|||
</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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -98,7 +98,7 @@ export function FactCard({ fact, onReport }: { fact: Fact; onReport?: (() => voi
|
|||
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">
|
||||
<h3 className="margin-0 font-sans-md">{t(`fact.title.${fact.key}`)}</h3>
|
||||
<h4 className="margin-0 font-sans-md">{t(`fact.title.${fact.key}`)}</h4>
|
||||
<StatusLabel status={fact.status} />
|
||||
</div>
|
||||
<p className="margin-top-1 margin-bottom-0">{tm(fact.headline)}</p>
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ export const DEMO_PLACES: (Place & { demoState: DemoState })[] = [
|
|||
},
|
||||
{
|
||||
id: 'demo-stale', demo: true, demoState: 'stale', name: 'Muzeum Przykładowe (DEMO)',
|
||||
lat: 50.0606, lon: 19.9386, cat: 'culture', kind: 'museum', ts: '2021-05-20',
|
||||
lat: 50.0606, lon: 19.9386, cat: 'city', kind: 'museum', ts: '2021-05-20',
|
||||
tags: { wheelchair: 'limited', 'wheelchair:description:pl': 'Winda tylko do pierwszego piętra (opis z 2021 r.)', 'addr:street': 'ulica Przykładowa', 'addr:housenumber': '3' },
|
||||
},
|
||||
{
|
||||
|
|
|
|||
|
|
@ -12,6 +12,9 @@ const pl = {
|
|||
'nav.about': 'Dane',
|
||||
'nav.profile': 'Profil',
|
||||
'lang.label': 'Język',
|
||||
'lang.switchTo': 'Przełącz język na angielski',
|
||||
'dash.sec.state': 'Stan danych ({n} miejsc)',
|
||||
'dash.sec.stops': 'Przystanki ({total}) — kto wie o dostępności',
|
||||
'skip': 'Przejdź do treści',
|
||||
|
||||
'profile.title': 'Twoje potrzeby',
|
||||
|
|
@ -46,6 +49,13 @@ const pl = {
|
|||
'sheet.collapse': 'Zwiń panel',
|
||||
'places.count': 'Wyniki: {n}',
|
||||
'places.more': 'Pokaż więcej',
|
||||
'places.cats': 'Kategorie miejsc',
|
||||
'places.groupMore': 'Pokaż wszystkie w kategorii ({n})',
|
||||
'detail.group.fail': 'Bariery',
|
||||
'detail.group.conflict': 'Sprzeczne dane',
|
||||
'detail.group.warn': 'Uwagi',
|
||||
'detail.group.ok': 'W porządku',
|
||||
'detail.group.unknown': 'Brak danych',
|
||||
'places.empty': 'Brak miejsc dla tych kryteriów.',
|
||||
'places.hint': 'Oceny liczone są dla Twojego profilu: {profile}.',
|
||||
'view.list': 'Lista',
|
||||
|
|
@ -53,7 +63,8 @@ const pl = {
|
|||
'view.label': 'Widok wyników',
|
||||
'cat.all': 'Wszystkie',
|
||||
'cat.food': 'Jedzenie i picie',
|
||||
'cat.culture': 'Kultura i zabytki',
|
||||
'cat.city': 'Oferta Miasta',
|
||||
'cat.culture': 'Kultura i religia',
|
||||
'cat.shop': 'Sklepy',
|
||||
'cat.stay': 'Noclegi',
|
||||
'cat.health': 'Zdrowie',
|
||||
|
|
@ -425,6 +436,8 @@ const pl = {
|
|||
'detail.saved': 'Zapisane',
|
||||
'places.onlySaved': 'Tylko zapisane miejsca',
|
||||
'detail.card': 'Karta dostępności (drukuj)',
|
||||
'detail.gmaps': '— otwiera Google Maps w nowej karcie',
|
||||
'detail.tools': 'Dane i narzędzia',
|
||||
'detail.reach': 'Co jest w zasięgu bez barier?',
|
||||
'cat.transit.long': 'Przystanki',
|
||||
'card.title': 'Karta dostępności',
|
||||
|
|
@ -695,6 +708,9 @@ const en: Dict = {
|
|||
'nav.about': 'Data',
|
||||
'nav.profile': 'Profile',
|
||||
'lang.label': 'Language',
|
||||
'lang.switchTo': 'Switch language to Polish',
|
||||
'dash.sec.state': 'State of the data ({n} places)',
|
||||
'dash.sec.stops': 'Stops ({total}) — who knows about accessibility',
|
||||
'skip': 'Skip to content',
|
||||
|
||||
'profile.title': 'Your needs',
|
||||
|
|
@ -729,6 +745,13 @@ const en: Dict = {
|
|||
'sheet.collapse': 'Collapse panel',
|
||||
'places.count': 'Results: {n}',
|
||||
'places.more': 'Show more',
|
||||
'places.cats': 'Place categories',
|
||||
'places.groupMore': 'Show all in this category ({n})',
|
||||
'detail.group.fail': 'Barriers',
|
||||
'detail.group.conflict': 'Conflicting data',
|
||||
'detail.group.warn': 'Cautions',
|
||||
'detail.group.ok': 'All clear',
|
||||
'detail.group.unknown': 'No data',
|
||||
'places.empty': 'No places for these criteria.',
|
||||
'places.hint': 'Ratings are computed for your profile: {profile}.',
|
||||
'view.list': 'List',
|
||||
|
|
@ -736,7 +759,8 @@ const en: Dict = {
|
|||
'view.label': 'Results view',
|
||||
'cat.all': 'All',
|
||||
'cat.food': 'Food and drink',
|
||||
'cat.culture': 'Culture and sights',
|
||||
'cat.city': 'City attractions',
|
||||
'cat.culture': 'Culture and worship',
|
||||
'cat.shop': 'Shops',
|
||||
'cat.stay': 'Stays',
|
||||
'cat.health': 'Health',
|
||||
|
|
@ -1108,6 +1132,8 @@ const en: Dict = {
|
|||
'detail.saved': 'Saved',
|
||||
'places.onlySaved': 'Saved places only',
|
||||
'detail.card': 'Access card (print)',
|
||||
'detail.gmaps': '— opens Google Maps in a new tab',
|
||||
'detail.tools': 'Data and tools',
|
||||
'detail.reach': 'What is within barrier-free reach?',
|
||||
'cat.transit.long': 'Stops',
|
||||
'card.title': 'Access card',
|
||||
|
|
|
|||
|
|
@ -391,3 +391,28 @@ describe('cue tracker speaks events once, and only when they matter', () => {
|
|||
expect(t.update({ ...base, arrived: true, remaining: 2 })).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
import { category, withCityOffer } from './osmPlace';
|
||||
|
||||
describe('city offer category', () => {
|
||||
it('files tourist attractions, museums and historic sites under city', () => {
|
||||
expect(category({ tourism: 'museum', name: 'x' })).toBe('city');
|
||||
expect(category({ tourism: 'attraction' })).toBe('city');
|
||||
expect(category({ historic: 'castle' })).toBe('city');
|
||||
});
|
||||
it('keeps cinemas, theatres and worship under culture and hotels under stay', () => {
|
||||
expect(category({ amenity: 'theatre' })).toBe('culture');
|
||||
expect(category({ amenity: 'place_of_worship' })).toBe('culture');
|
||||
expect(category({ tourism: 'hotel' })).toBe('stay');
|
||||
});
|
||||
it('re-files sights from an older snapshot by kind, leaving the rest alone', () => {
|
||||
const base = { id: 'n1', lat: 0, lon: 0, name: 'x', ts: null, tags: {} };
|
||||
expect(withCityOffer({ ...base, cat: 'culture', kind: 'museum' }).cat).toBe('city');
|
||||
expect(withCityOffer({ ...base, cat: 'culture', kind: 'theatre' }).cat).toBe('culture');
|
||||
expect(withCityOffer({ ...base, cat: 'food', kind: 'cafe' }).cat).toBe('food');
|
||||
});
|
||||
it('real snapshot has a populated city category after re-filing', () => {
|
||||
const n = snap.places.map(withCityOffer).filter((p) => p.cat === 'city').length;
|
||||
expect(n).toBeGreaterThan(100);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -26,6 +26,9 @@ export function googleMapsUrl(result: RouteResult, from: LatLon, to: LatLon): st
|
|||
return `https://www.google.com/maps/dir/?${q.toString()}`;
|
||||
}
|
||||
|
||||
/** Opens the place itself in Google Maps (by coordinates, so the pin is exact). */
|
||||
export const googleMapsPlaceUrl = (p: LatLon) => `https://www.google.com/maps/search/?api=1&query=${fmt(p)}`;
|
||||
|
||||
export function appleMapsUrl(from: LatLon, to: LatLon): string {
|
||||
return `https://maps.apple.com/?saddr=${fmt(from)}&daddr=${fmt(to)}&dirflg=w`;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -22,13 +22,18 @@ export function category(t: Record<string, string>): PlaceCategory {
|
|||
const a = t.amenity, to = t.tourism, h = t.historic;
|
||||
if (['restaurant', 'cafe', 'bar', 'pub', 'fast_food', 'ice_cream', 'food_court'].includes(a ?? '')) return 'food';
|
||||
if (['pharmacy', 'clinic', 'doctors', 'dentist', 'hospital'].includes(a ?? '')) return 'health';
|
||||
if (['museum', 'gallery', 'attraction', 'information'].includes(to ?? '') || ['cinema', 'theatre', 'library', 'arts_centre'].includes(a ?? '') || h) return 'culture';
|
||||
if (['museum', 'gallery', 'attraction', 'information'].includes(to ?? '') || h) return 'city';
|
||||
if (['cinema', 'theatre', 'library', 'arts_centre'].includes(a ?? '')) return 'culture';
|
||||
if (['hotel', 'hostel', 'guest_house', 'apartment'].includes(to ?? '')) return 'stay';
|
||||
if (a === 'place_of_worship') return 'culture';
|
||||
if (t.shop) return 'shop';
|
||||
return 'services';
|
||||
}
|
||||
|
||||
/** Snapshots built before the "city offer" category still file sights under culture; this re-files them by `kind`. */
|
||||
const CITY_KINDS = new Set(['museum', 'gallery', 'attraction', 'information', 'castle', 'monument', 'church', 'city_gate', 'fort', 'building']);
|
||||
export const withCityOffer = (p: Place): Place => (p.cat === 'culture' && p.kind && CITY_KINDS.has(p.kind) ? { ...p, cat: 'city' } : p);
|
||||
|
||||
const round = (n: number) => Math.round(n * 1e6) / 1e6;
|
||||
const day = (ts: string | undefined) => (ts ? ts.slice(0, 10) : null);
|
||||
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ export interface Profile {
|
|||
needChanging: boolean;
|
||||
}
|
||||
|
||||
export type PlaceCategory = 'food' | 'culture' | 'shop' | 'stay' | 'health' | 'services' | 'toilets' | 'elevator' | 'transit';
|
||||
export type PlaceCategory = 'city' | 'food' | 'culture' | 'shop' | 'stay' | 'health' | 'services' | 'toilets' | 'elevator' | 'transit';
|
||||
|
||||
export interface Place {
|
||||
id: string;
|
||||
|
|
|
|||
|
|
@ -272,3 +272,149 @@ body { margin: 0; }
|
|||
/* tab bar: badge floats on the icon instead of widening the label */
|
||||
.kbb-nav__badge { position: absolute; top: 0.2rem; left: calc(50% + 0.4rem); margin: 0; min-width: 1.15rem; height: 1.15rem; font-size: 0.7rem; }
|
||||
}
|
||||
|
||||
/* ---- language toggle, sign-out ---- */
|
||||
.kbb-lang { appearance: none; display: inline-flex; padding: 0.2rem; min-height: 2.75rem; border: 2px solid #005ea2; border-radius: 1.5rem; background: #fff; font: inherit; cursor: pointer; }
|
||||
.kbb-lang span { display: inline-flex; align-items: center; justify-content: center; min-width: 2.4rem; padding: 0 0.5rem; border-radius: 1.2rem; color: #005ea2; font-weight: 700; font-size: 0.9rem; }
|
||||
.kbb-lang span.is-on { background: #005ea2; color: #fff; }
|
||||
.kbb-lang:focus-visible, .kbb-signout:focus-visible { outline: 0.25rem solid #2491ff; outline-offset: 2px; }
|
||||
.kbb-signout { appearance: none; display: inline-flex; align-items: center; justify-content: center; width: 2.75rem; min-height: 2.75rem; padding: 0; border: 0; border-radius: 0.25rem; background: none; color: #005ea2; font: inherit; font-weight: 700; cursor: pointer; }
|
||||
.kbb-signout:hover { background: #e7f6f8; }
|
||||
.kbb-signout .usa-icon { fill: currentColor; }
|
||||
|
||||
/* ---- city panel: quality tab ---- */
|
||||
.kbb-h2 { margin: 0 0 0.5rem; font-size: 1.25rem; }
|
||||
.kbb-kpis { grid-template-columns: repeat(auto-fit, minmax(10.5rem, 1fr)); }
|
||||
.kbb-meter { display: block; width: 100%; height: 0.5rem; margin-top: 0.5rem; border-radius: 0.25rem; background: #dfe1e2; overflow: hidden; }
|
||||
.kbb-meter > span { display: block; height: 100%; background: #005ea2; border-radius: 0.25rem; }
|
||||
.kbb-meter--op > span { background: #b50909; }
|
||||
.kbb-meter--osm > span { background: #00a91c; }
|
||||
.kbb-compare { display: grid; gap: 0.75rem; margin-bottom: 1rem; }
|
||||
.kbb-compare__row { display: grid; grid-template-columns: minmax(0, 14rem) minmax(0, 1fr) 5rem; align-items: center; gap: 0.75rem; }
|
||||
.kbb-compare__row .kbb-meter { margin: 0; height: 0.9rem; }
|
||||
.kbb-compare__n { text-align: right; white-space: nowrap; }
|
||||
@media (max-width: 39.99em) { .kbb-compare__row { grid-template-columns: 1fr auto; } .kbb-compare__row .kbb-meter { grid-column: 1 / -1; order: 3; } }
|
||||
.kbb-legend-row { display: flex; flex-wrap: wrap; gap: 0.25rem 1rem; margin-bottom: 0.75rem !important; font-size: 0.875rem; }
|
||||
.kbb-legend-row li { display: inline-flex; align-items: center; gap: 0.4rem; }
|
||||
.kbb-dot { display: inline-block; width: 0.9rem; height: 0.9rem; border-radius: 0.2rem; }
|
||||
.kbb-gaps { display: grid; gap: 0.9rem; }
|
||||
.kbb-gap__head { display: flex; justify-content: space-between; gap: 1rem; }
|
||||
.kbb-stack { display: flex; width: 100%; height: 0.9rem; margin: 0.3rem 0; border-radius: 0.25rem; overflow: hidden; background: #dfe1e2; }
|
||||
.kbb-stack > span { display: block; height: 100%; }
|
||||
.kbb-gap__nums { margin: 0; font-size: 0.875rem; line-height: 1.4; color: #454545; }
|
||||
.kbb-demand__row { display: flex; align-items: center; gap: 0.75rem; padding: 0.75rem; margin-bottom: 0.5rem; border: 1px solid #dfe1e2; border-radius: 0.25rem; }
|
||||
.kbb-demand__row .kbb-rank { margin-right: 0; flex: none; }
|
||||
.kbb-demand__body { flex: 1; min-width: 0; }
|
||||
.kbb-demand__bar { display: flex; align-items: center; gap: 0.6rem; margin-top: 0.3rem; font-size: 0.875rem; color: #454545; white-space: nowrap; }
|
||||
.kbb-demand__bar .kbb-meter { margin: 0; flex: 1; }
|
||||
@media (max-width: 39.99em) { .kbb-demand__row { flex-wrap: wrap; } .kbb-demand__row .usa-button { width: 100%; } }
|
||||
.kbb-empty { display: flex; align-items: center; gap: 0.75rem; padding: 1rem; border: 1px dashed #a9aeb1; border-radius: 0.25rem; color: #454545; }
|
||||
.kbb-empty .usa-icon { flex: none; fill: #71767a; }
|
||||
|
||||
/* quality tab: sections side by side on wide screens */
|
||||
.kbb-dq { display: grid; gap: 2rem; grid-template-columns: minmax(0, 1fr); }
|
||||
@media (min-width: 64em) {
|
||||
.kbb-dq { grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); align-items: start; }
|
||||
.kbb-dq__wide { grid-column: 1 / -1; }
|
||||
.kbb-dq .kbb-gaps { grid-template-columns: repeat(2, minmax(0, 1fr)); column-gap: 2rem; }
|
||||
.kbb-dq .kbb-kpis { grid-template-columns: repeat(4, minmax(0, 1fr)); }
|
||||
.kbb-dq .kbb-compare__row { grid-template-columns: minmax(0, 11rem) minmax(0, 1fr) 4.5rem; }
|
||||
}
|
||||
|
||||
/* ---- places: category chips, grouped overview, facts groups ---- */
|
||||
.kbb-chips--scroll { flex-wrap: nowrap; overflow-x: auto; scrollbar-width: none; padding-bottom: 0.25rem; margin-right: -1rem; padding-right: 1rem; }
|
||||
.kbb-chips--scroll::-webkit-scrollbar { display: none; }
|
||||
.kbb-chips--scroll .kbb-chip { flex: none; white-space: nowrap; }
|
||||
@media (min-width: 64em) { .kbb-chips--scroll { flex-wrap: wrap; overflow: visible; margin-right: 0; padding-right: 0; } }
|
||||
.kbb-group { margin-top: 1.25rem; }
|
||||
.kbb-group__h { margin: 0 0 0.5rem; padding-bottom: 0.25rem; border-bottom: 2px solid #dfe1e2; font-size: 1.15rem; }
|
||||
.kbb-facts-h { display: flex; align-items: center; margin: 1.25rem 0 0.5rem; font-size: 1.1rem; }
|
||||
.kbb-facts-h .usa-icon { fill: currentColor; }
|
||||
|
||||
/* ---- place detail: actions, external links, collapsible reach ---- */
|
||||
.kbb-actions { display: flex; flex-wrap: wrap; align-items: center; gap: 0.5rem; }
|
||||
.kbb-actions .usa-button { margin: 0; }
|
||||
.kbb-actions__main { flex: 1 1 12rem; }
|
||||
.kbb-iconbtn { appearance: none; flex: none; display: inline-flex; align-items: center; justify-content: center; width: 2.75rem; height: 2.75rem; padding: 0; border: 2px solid #005ea2; border-radius: 0.25rem; background: #fff; color: #005ea2; cursor: pointer; }
|
||||
.kbb-iconbtn .usa-icon { fill: currentColor; }
|
||||
.kbb-iconbtn.is-on { background: #005ea2; color: #fff; }
|
||||
.kbb-iconbtn:focus-visible { outline: 0.25rem solid #2491ff; outline-offset: 2px; }
|
||||
.kbb-extlink { color: inherit; text-decoration: none; }
|
||||
.kbb-extlink:hover { text-decoration: underline; }
|
||||
.kbb-extlink:focus-visible { outline: 0.25rem solid #2491ff; outline-offset: 2px; }
|
||||
.kbb-extlink--muted { color: #454545; }
|
||||
.kbb-extlink__icon { margin-left: 0.35rem; vertical-align: -0.1em; fill: #005ea2; }
|
||||
.kbb-reach > summary { display: flex; align-items: center; padding: 0 1rem; min-height: 2.75rem; cursor: pointer; font-weight: 700; }
|
||||
.kbb-reach > summary:focus-visible { outline: 0.25rem solid #2491ff; outline-offset: -0.25rem; }
|
||||
.kbb-facts-h > span { margin-left: 0.35rem; }
|
||||
|
||||
/* visited links keep their colour (USWDS turns a:visited purple) */
|
||||
.kbb-extlink:visited { color: inherit; }
|
||||
.kbb-extlink--muted:visited { color: #454545; }
|
||||
.kbb-extlink:visited .kbb-extlink__icon { fill: #005ea2; }
|
||||
|
||||
/* reach: expand/collapse affordance */
|
||||
.kbb-reach > summary { list-style: none; }
|
||||
.kbb-reach > summary::-webkit-details-marker { display: none; }
|
||||
.kbb-chevron { flex: none; margin-left: 0.5rem; fill: #005ea2; transition: transform 0.15s ease; }
|
||||
.kbb-reach[open] > summary .kbb-chevron { transform: rotate(180deg); }
|
||||
@media (prefers-reduced-motion: reduce) { .kbb-chevron { transition: none; } }
|
||||
|
||||
/* place detail: tool rows */
|
||||
.kbb-rows { border: 1px solid #dfe1e2; border-radius: 0.25rem; background: #fff; }
|
||||
.kbb-rows > li + li { border-top: 1px solid #dfe1e2; }
|
||||
.kbb-row { appearance: none; display: flex; align-items: center; gap: 0.75rem; width: 100%; min-height: 3rem; padding: 0.5rem 1rem; border: 0; background: none; font: inherit; color: #1b1b1b; text-align: left; text-decoration: none; cursor: pointer; }
|
||||
.kbb-row:hover:not(:disabled) { background: #f0f0f0; }
|
||||
.kbb-row:disabled { color: #71767a; cursor: progress; }
|
||||
.kbb-row:visited { color: #1b1b1b; }
|
||||
.kbb-row:focus-visible { outline: 0.25rem solid #2491ff; outline-offset: -0.25rem; }
|
||||
.kbb-row__icon { flex: none; fill: #005ea2; }
|
||||
.kbb-row__text { flex: 1; font-weight: 700; }
|
||||
.kbb-row__end { flex: none; fill: #565c65; }
|
||||
|
||||
/* ---- access card: A4 portrait document ---- */
|
||||
.kbb-card__sheet { max-width: 21cm; padding: 1.25rem; box-shadow: 0 4px 24px rgba(0, 0, 0, 0.35); }
|
||||
.kbb-card, .kbb-card * { -webkit-print-color-adjust: exact; print-color-adjust: exact; }
|
||||
.kbb-doc__head { display: flex; align-items: center; gap: 0.75rem; padding-bottom: 0.75rem; border-bottom: 3px solid #005ea2; }
|
||||
.kbb-doc__brand { display: flex; flex-direction: column; flex: 1; min-width: 0; line-height: 1.2; }
|
||||
.kbb-doc__brand strong { font-size: 1.15rem; color: #1a4480; }
|
||||
.kbb-doc__brand span { font-size: 0.9rem; color: #565c65; letter-spacing: 0.02em; text-transform: uppercase; }
|
||||
.kbb-doc__date { font-size: 0.85rem; color: #565c65; white-space: nowrap; }
|
||||
.kbb-doc__title { margin: 1rem 0 0.15rem; font-size: 1.75rem; line-height: 1.15; }
|
||||
.kbb-doc__addr { margin: 0 0 0.75rem; color: #565c65; }
|
||||
.kbb-doc__verdict { margin: 0.5rem 0 1rem; padding: 0.6rem 0.9rem; border: 1px solid #dfe1e2; border-left: 0.4rem solid; border-radius: 0.25rem; background: #f7f7f7; }
|
||||
.kbb-doc__for { font-size: 0.85rem; color: #3d4551; }
|
||||
.kbb-doc__facts { display: grid; gap: 0.6rem; grid-template-columns: repeat(auto-fit, minmax(16rem, 1fr)); }
|
||||
.kbb-doc__fact { padding: 0.55rem 0.75rem; border: 1px solid #dfe1e2; border-radius: 0.25rem; break-inside: avoid; page-break-inside: avoid; }
|
||||
.kbb-doc__fact-head { display: flex; justify-content: space-between; align-items: baseline; gap: 0.5rem; }
|
||||
.kbb-doc__fact p { font-size: 0.9rem; line-height: 1.35; }
|
||||
.kbb-doc .kbb-source, .kbb-doc__fact .kbb-source { gap: 0 0.9rem; font-size: 0.75rem; }
|
||||
.kbb-doc__foot { margin-top: 1rem; padding-top: 0.6rem; border-top: 1px solid #dfe1e2; font-size: 0.75rem; color: #565c65; }
|
||||
.kbb-doc__foot p { margin: 0 0 0.3rem; }
|
||||
@page { size: A4 portrait; margin: 12mm; }
|
||||
@media print {
|
||||
html, body { height: auto !important; background: #fff; }
|
||||
.kbb-app { display: block; height: auto !important; padding: 0 !important; }
|
||||
.kbb-card { position: static !important; padding: 0; background: none; overflow: visible; }
|
||||
.kbb-card__sheet { max-width: none; margin: 0; padding: 0; box-shadow: none; border-radius: 0; font-size: 9.5pt; }
|
||||
.kbb-doc__title { font-size: 20pt; }
|
||||
.kbb-doc__brand strong { font-size: 13pt; }
|
||||
.kbb-doc__brand span, .kbb-doc__date { font-size: 8.5pt; }
|
||||
.kbb-doc__fact p { font-size: 9pt; }
|
||||
.kbb-doc .kbb-source, .kbb-doc__fact .kbb-source { font-size: 7.5pt; }
|
||||
.kbb-doc__foot { font-size: 7.5pt; }
|
||||
.kbb-doc__facts { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 3mm; }
|
||||
.usa-tag { padding: 0.1rem 0.4rem; font-size: 7.5pt; border: 1px solid rgba(0, 0, 0, 0.25); }
|
||||
}
|
||||
|
||||
.kbb-doc__title:focus { outline: none; }
|
||||
.kbb-doc__fact-head strong { font-size: 1rem; }
|
||||
.kbb-doc__fact .kbb-source { font-size: 0.75rem !important; line-height: 1.4 !important; }
|
||||
@media print {
|
||||
.kbb-doc__fact-head strong { font-size: 10.5pt; }
|
||||
.kbb-doc__fact p { font-size: 9.5pt; }
|
||||
.kbb-doc__fact .kbb-source { font-size: 8pt !important; }
|
||||
.kbb-doc__fact { padding: 3mm 3.5mm; }
|
||||
.kbb-doc__foot { font-size: 8pt; }
|
||||
}
|
||||
.kbb-doc__fact-head > span { white-space: nowrap; flex: none; }
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue