SPB Git forge

spb/countryatlas

Public
20commits 1branches 0releases
268.3 MBsize
maindefault branch
12 days agolast push
TypeScript 57% Python 38.6% JavaScript 3.6% CSS 0.6%
7.5 KB · 123 lines tsx
Raw Blame History
1import { Download, Scale } from 'lucide-react';2import Link from 'next/link';3import { t } from '@/i18n';4import { compact, displayValue, formatDate, formatPct, grouped, isNum } from '@/lib/format';5import { routes } from '@/lib/site';6import type { CountryResponse } from '@/lib/types';7import type { CountryQualityResponse } from '@/lib/types-analytics';8import { FreshnessBadge } from '@/components/data/freshness-badge';9import { QualityBadges } from '@/components/data/quality-badge';10import { CopyApiButton } from './copy-api-button';11import { MiniMap } from './mini-map';12import { ShareButton } from './share-button';1314/**15 * Country hero 2.0: flag + display name + official name, chips (region · income · capital), a fact strip16 * (population, GDP, GDP/capita, area, currency), locator mini map, bordering countries, freshness + coverage17 * badges, actions Compare / Download / Share / API. Editorial: one rule below, no card.18 */19export function CountryHeader({ data, quality }: { data: CountryResponse; quality: CountryQualityResponse | null }) {20  const c = data.country;21  const name = c.name ?? c.id;22  const pick = (id: string) => data.headline.find((m) => m.indicator === id && m.has_data) ?? null;23  const pop = pick('population');24  const gdp = pick('gdp');25  const gpc = pick('gdp-per-capita');26  const facts: Array<{ k: string; v: string; sub?: string }> = [];27  if (pop && isNum(pop.value)) facts.push({ k: t('country.population'), v: displayValue(pop.value, pop, pop.formatted), sub: pop.year ? String(pop.year) : undefined });28  if (gdp && isNum(gdp.value)) facts.push({ k: t('common.gdp'), v: displayValue(gdp.value, gdp, gdp.formatted), sub: gdp.year ? String(gdp.year) : undefined });29  if (gpc && isNum(gpc.value)) facts.push({ k: t('common.gdpPerCapita'), v: displayValue(gpc.value, gpc, gpc.formatted), sub: gpc.year ? String(gpc.year) : undefined });30  if (isNum(c.area_km2)) facts.push({ k: t('country.area'), v: `${c.area_km2 >= 1e6 ? compact(c.area_km2) : grouped(c.area_km2)} km²` });31  if (c.currency_code) facts.push({ k: t('country.currency'), v: c.currency_code, sub: c.currency_name ?? undefined });32  const chips = [c.region_name, c.income_name, c.capital ? `${t('country.capital')} ${c.capital}` : null].filter(Boolean) as string[];33  const badges = quality ? deriveCountryBadges(quality) : null;3435  return (36    <header className="border-b border-rule pb-5 pt-6 md:pb-6 md:pt-8">37      <div className="grid gap-x-8 gap-y-5 md:grid-cols-[minmax(0,1fr)_16rem] lg:grid-cols-[minmax(0,1fr)_20rem]">38        <div className="min-w-0">39          <div className="flex items-start gap-3 md:gap-4">40            <span aria-hidden className="text-4xl leading-none md:text-6xl">41              {c.flag}42            </span>43            <div className="min-w-0">44              <h1 className="display text-3xl leading-none text-ink md:text-5xl">{name}</h1>45              {c.official_name && c.official_name !== name ? <p className="mt-1.5 text-sm text-ink-2">{c.official_name}</p> : null}46              <ul className="mt-2 flex flex-wrap gap-1.5 text-xs">47                {chips.map((ch) => (48                  <li key={ch} className="badge border-rule text-ink-2">49                    {ch}50                  </li>51                ))}52                {c.landlocked ? <li className="badge border-rule text-ink-3">{t('country.landlocked')}</li> : null}53                {c.status === 'territory' ? <li className="badge border-rule text-ink-3">{t('countries.territory')}</li> : null}54              </ul>55            </div>56          </div>5758          <dl className="ticker mt-5 gap-0 border-y border-rule sm:grid sm:grid-cols-5 sm:divide-x sm:divide-rule">59            {facts.map((f, i) => (60              <div key={f.k} className={`min-w-[8rem] py-3 pr-5 sm:min-w-0 sm:pr-4 ${i > 0 ? 'border-l border-rule pl-4 sm:border-l-0' : ''}`}>61                <dt className="eyebrow">{f.k}</dt>62                <dd className="pnum mt-1 text-xl font-semibold leading-none text-ink md:text-2xl">{f.v}</dd>63                {f.sub ? <dd className="tnum mt-1 truncate text-xs text-ink-3">{f.sub}</dd> : null}64              </div>65            ))}66          </dl>6768          <div className="mt-3 flex flex-wrap items-center gap-x-3 gap-y-1.5 text-xs text-ink-3">69            {data.freshness.retrieved_at ? <span className="tnum">{t('country.refreshed', { date: formatDate(data.freshness.built_at ?? data.freshness.retrieved_at) })}</span> : null}70            <FreshnessBadge retrievedAt={data.freshness.retrieved_at} now={Date.parse(data.meta.generated_at) || Date.now()} />71            {quality ? <span className="tnum">{t('country.coverage', { pct: formatPct(quality.summary.coverage_pct), n: grouped(quality.summary.n_with_data) })}</span> : data.coverage ? <span className="tnum">{t('country.coverage', { pct: formatPct(data.coverage.coverage_pct), n: grouped(data.coverage.n_indicators ?? 0) })}</span> : null}72            {quality ? <span className="tnum">{t('country.freshSplit', { fresh: quality.summary.n_fresh, stale: quality.summary.n_stale })}</span> : null}73            <QualityBadges badges={badges} max={3} />74          </div>7576          <div className="mt-4 flex flex-wrap gap-2">77            <Link href={routes.compare(c.slug ?? c.id)} className="inline-flex h-10 items-center justify-center gap-2 rounded-sm bg-ink px-4 text-sm font-medium text-paper hover:bg-accent hover:text-accent-ink" aria-label={t('country.compareWith', { name })}>78              <Scale size={15} aria-hidden /> {t('country.compare')}79            </Link>80            <a href={routes.countryDownload(c.id)} className="inline-flex h-10 items-center justify-center gap-2 rounded-sm border border-rule px-4 text-sm text-ink hover:bg-surface-2" title={t('country.downloadHint')}>81              <Download size={15} aria-hidden /> {t('country.download')}82            </a>83            <ShareButton title={t('country.title', { name })} />84            <CopyApiButton path={`/api/v1/countries/${c.id}`} />85          </div>86        </div>8788        <div className="min-w-0">89          <MiniMap iso3={c.id} neighbours={c.borders ?? []} latitude={c.latitude} longitude={c.longitude} name={name} className="rounded-sm" />90          {data.neighbours.length ? (91            <div className="mt-2 text-xs">92              <span className="text-ink-3">{t('country.borders')}: </span>93              <ul className="mt-1 flex flex-wrap gap-1">94                {data.neighbours.map((n) => (95                  <li key={n.id}>96                    <Link href={routes.country(n.slug ?? n.id)} className="badge min-h-[28px] border-rule text-ink hover:border-accent hover:text-accent">97                      <span aria-hidden>{n.flag}</span> {n.name}98                    </Link>99                  </li>100                ))}101              </ul>102            </div>103          ) : (104            <p className="mt-2 text-xs text-ink-3">{c.landlocked === false ? t('country.noLandBorders') : ''}</p>105          )}106        </div>107      </div>108    </header>109  );110}111112/** Coarse badges for the whole country from the quality summary. */113function deriveCountryBadges(q: CountryQualityResponse): Array<'fresh' | 'stale' | 'limited-coverage' | 'sparse' | 'flagged'> {114  const out: Array<'fresh' | 'stale' | 'limited-coverage' | 'sparse' | 'flagged'> = [];115  const s = q.summary;116  if (s.n_with_data && s.n_fresh / s.n_with_data >= 0.6) out.push('fresh');117  if (s.n_with_data && s.n_stale / s.n_with_data >= 0.4) out.push('stale');118  if ((s.coverage_pct ?? 100) < 50) out.push('limited-coverage');119  if (s.n_with_data && s.n_sparse / s.n_with_data >= 0.3) out.push('sparse');120  if (s.n_with_data && s.n_flagged / s.n_with_data >= 0.3) out.push('flagged');121  return out;122}123