spb/countryatlas
Public
TypeScript 57%
Python 38.6%
JavaScript 3.6%
CSS 0.6%
1'use client';2import { Check, Code2, Download, ExternalLink } from 'lucide-react';3import Link from 'next/link';4import { useEffect, useState } from 'react';5import { t } from '@/i18n';6import { displayValue, formatDate, formatPeriod } from '@/lib/format';7import { routes } from '@/lib/site';8import type { ObservationStatus } from '@/lib/types';9import { BottomSheet } from './bottom-sheet';10import { StatusBadge } from './freshness-badge';11import { useProvenance } from './provenance-context';12import { QualityBadges, QualityStrip, deriveBadges } from './quality-badge';1314/**15 * Global provenance sheet 2.0: mounted once in the root layout; opened by <Metric>, chart source lines and any16 * component calling `useProvenance().open(payload)`. Layout: value block (indicator · country · period · value),17 * SOURCE / DATASET / SERIES / SOURCE UPDATED / RETRIEVED / LICENCE, transform + status, quality badges, actions18 * [Open original] [API] [Download series] [Indicator page].19 */20export function ProvenanceSheet() {21 const { payload, close } = useProvenance();22 const open = !!payload;23 const ind = payload?.indicator;24 const v = payload?.value ?? null;25 const p = v?.provenance ?? null;26 const country = payload?.country ?? null;27 const download = payload?.downloadHref ?? (country ? routes.countryDownload(country.id) : ind ? routes.indicatorDownload(ind.slug) : null);28 const apiPath = ind ? (country ? `/api/v1/countries/${country.id}/series/${ind.slug}` : `/api/v1/indicators/${ind.slug}`) : null;29 const [copied, setCopied] = useState(false);30 useEffect(() => {31 if (!open) setCopied(false);32 }, [open]);3334 const copyApi = async () => {35 if (!apiPath) return;36 const url = `${window.location.origin}${apiPath}`;37 try {38 await navigator.clipboard.writeText(url);39 setCopied(true);40 setTimeout(() => setCopied(false), 1800);41 } catch {42 /* clipboard unavailable — the link below still opens the docs */43 }44 };4546 const badges = payload?.quality?.badges ?? (v ? deriveBadges({ latestYear: v.year ?? null, firstYear: payload?.quality?.firstYear ?? null, points: payload?.quality?.points ?? null, hasForecast: v.is_forecast, referenceYear: payload?.quality?.referenceYear ?? null }) : []);4748 return (49 <BottomSheet open={open} onClose={close} labelledBy="prov-title" title={<span id="prov-title">{t('prov.title')}</span>}>50 {payload && ind ? (51 <div className="space-y-5 text-sm">52 {/* Value block */}53 <div>54 <div className="eyebrow">{ind.name}</div>55 {country ? (56 <h3 className="display mt-1 text-xl text-ink">57 {country.flag ? <span aria-hidden>{country.flag} </span> : null}58 {country.name}59 </h3>60 ) : (61 <h3 className="display mt-1 text-xl text-ink">{t('common.world')}</h3>62 )}63 {v ? (64 <div className="mt-2 flex flex-wrap items-baseline gap-x-3 gap-y-1">65 <span className="tnum text-3xl font-semibold text-ink">{displayValue(v.value, ind, v.formatted)}</span>66 <span className="tnum text-ink-2">{formatPeriod(v.period, ind.frequency)}</span>67 {v.status ? <StatusBadge status={v.status as ObservationStatus} /> : null}68 {v.is_forecast ? <span className="text-xs text-ink-3">{t('common.forecast')}</span> : null}69 {v.is_estimate ? <span className="text-xs text-ink-3">{t('common.estimate')}</span> : null}70 </div>71 ) : (72 <p className="mt-2 text-ink-3">{t('prov.noValue')}</p>73 )}74 {v?.is_forecast ? <p className="mt-1 text-xs text-ink-3">{t('prov.status.forecastNote')}</p> : null}75 </div>7677 <dl className="divide-y divide-rule border-y border-rule">78 <Row k={t('prov.source')} v={p?.source_name ?? t('common.na')} strong />79 <Row k={t('prov.dataset')} v={p?.dataset ?? t('common.na')} />80 <Row k={t('prov.series')} v={p?.series_code ?? t('common.na')} mono />81 <Row k={t('prov.observation')} v={formatPeriod(v?.period, ind.frequency)} />82 <Row k={t('prov.sourceUpdated')} v={formatDate(p?.source_updated_at)} />83 <Row k={t('prov.retrieved')} v={formatDate(p?.retrieved_at)} />84 <Row k={t('prov.unit')} v={v?.unit ?? ind.unit ?? t('common.na')} />85 <Row k={t('prov.transform')} v={p?.transform ?? t('prov.none')} mono={!!p?.transform} />86 <Row k={t('prov.licence')} v={p?.licence ?? t('common.na')} />87 </dl>8889 {badges.length || payload.quality ? (90 <div>91 <div className="eyebrow mb-1">{t('prov.quality')}</div>92 {payload.quality ? <QualityStrip latestYear={payload.quality.latestYear} firstYear={payload.quality.firstYear} points={payload.quality.points} missingYears={payload.quality.missingYears} continuityPct={payload.quality.continuityPct} coveragePct={payload.quality.coveragePct} badges={badges} /> : <QualityBadges badges={badges} />}93 <p className="mt-1.5 text-xs text-ink-3">{t('quality.noScore')}</p>94 </div>95 ) : null}9697 {ind.methodology || ind.description ? (98 <div>99 <div className="eyebrow">{ind.methodology ? t('prov.methodology') : t('prov.definition')}</div>100 <p className="mt-1 text-ink-2">{ind.methodology ?? ind.description}</p>101 </div>102 ) : null}103104 <div className="flex flex-wrap gap-2 pt-1">105 {p?.url ? (106 <a href={p.url} target="_blank" rel="noopener noreferrer" className="tap inline-flex items-center gap-1.5 rounded-sm border border-rule px-3 text-sm hover:bg-surface-2">107 <ExternalLink size={14} aria-hidden /> {t('prov.openSource')}108 </a>109 ) : null}110 {apiPath ? (111 <Link href={routes.api()} onClick={() => void copyApi()} className="tap inline-flex items-center gap-1.5 rounded-sm border border-rule px-3 text-sm hover:bg-surface-2" title={t('prov.apiHint')} aria-live="polite">112 {copied ? <Check size={14} aria-hidden className="text-up" /> : <Code2 size={14} aria-hidden />}113 {copied ? t('prov.copiedApi') : t('prov.copyApi')}114 </Link>115 ) : null}116 {download ? (117 <a href={download} className="tap inline-flex items-center gap-1.5 rounded-sm border border-rule px-3 text-sm hover:bg-surface-2">118 <Download size={14} aria-hidden /> {t('prov.downloadSeries')}119 </a>120 ) : null}121 <Link href={routes.indicator(ind.slug)} className="tap inline-flex items-center rounded-sm px-3 text-sm text-accent hover:underline" onClick={close}>122 {t('prov.indicatorPage')} →123 </Link>124 </div>125 {apiPath ? <code className="block break-all font-mono text-2xs text-ink-3">{apiPath}</code> : null}126 </div>127 ) : null}128 </BottomSheet>129 );130}131132function Row({ k, v, mono, strong }: { k: string; v: string; mono?: boolean; strong?: boolean }) {133 return (134 <div className="grid grid-cols-[8.5rem_1fr] gap-3 py-2">135 <dt className="text-2xs font-medium uppercase tracking-wide text-ink-3 pt-0.5">{k}</dt>136 <dd className={mono ? 'break-all font-mono text-[13px] text-ink' : strong ? 'font-medium text-ink' : 'text-ink'}>{v}</dd>137 </div>138 );139}140