spb/countryatlas
Public
TypeScript 57%
Python 38.6%
JavaScript 3.6%
CSS 0.6%
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