spb/countryatlas
Public
TypeScript 57%
Python 38.6%
JavaScript 3.6%
CSS 0.6%
1'use client';2import { AlertTriangle, ArrowDownRight, ArrowUpRight, GitCommitHorizontal, Repeat, SlidersHorizontal, TrendingDown, TrendingUp, Trophy, Waves, X } from 'lucide-react';3import Link from 'next/link';4import { useEffect, useMemo, useRef, useState } from 'react';5import { t } from '@/i18n';6import { clientExplore } from '@/lib/client-api-explore';7import { cn } from '@/lib/cn';8import { formatPeriod, grouped } from '@/lib/format';9import { WB_REGIONS } from '@/lib/regions';10import { severityLabel, severityLevel } from '@/lib/severity';11import { routes } from '@/lib/site';12import type { ChangeItem } from '@/lib/types';13import { BottomSheet } from '@/components/data/bottom-sheet';14import { kindLabel } from '@/components/data/change-list';15import { CountryChip } from '@/components/data/country-chip';16import { EntityPicker, type PickedEntity } from '@/components/explore/entity-picker';1718const ICON: Record<string, typeof ArrowUpRight> = {19 yoy_jump: ArrowUpRight,20 yoy_drop: ArrowDownRight,21 record_high: Trophy,22 record_low: AlertTriangle,23 n_year_high: TrendingUp,24 n_year_low: TrendingDown,25 sign_flip: Repeat,26 accelerating: TrendingUp,27 decelerating: TrendingDown,28 structural_break: GitCommitHorizontal,29 trend_reversal: Repeat,30 volatility_spike: Waves,31};3233type Sev = 0 | 0.4 | 0.7;3435/**36 * Global change feed with filters (kind chips, indicator typeahead, region, minimum severity) in a bottom37 * sheet on phones and inline on desktop. Items are grouped by period (year). Server passes the first page;38 * changing a server-side filter (kind / indicator / severity) refetches `/api/v1/changes`; region filters39 * client-side (the API has no region parameter but every item carries the country's region).40 */41export function ChangesFeed({ initial, kinds, limit = 100, compact = false }: { initial: ChangeItem[]; kinds: string[]; limit?: number; compact?: boolean }) {42 const [kind, setKind] = useState<string | null>(null);43 const [indicator, setIndicator] = useState<PickedEntity | null>(null);44 const [region, setRegion] = useState<string | null>(null);45 const [minSev, setMinSev] = useState<Sev>(0);46 const [items, setItems] = useState<ChangeItem[]>(initial);47 const [state, setState] = useState<'idle' | 'loading' | 'error'>('idle');48 const [sheet, setSheet] = useState(false);49 const first = useRef(true);5051 useEffect(() => {52 if (first.current) {53 first.current = false;54 return;55 }56 const ctrl = new AbortController();57 setState('loading');58 clientExplore59 .changes({ limit, kind, indicator: indicator?.slug ?? null, min_severity: minSev || null }, ctrl.signal)60 .then((r) => {61 setItems(r.items);62 setState('idle');63 })64 .catch((e) => {65 if ((e as Error).name !== 'AbortError') setState('error');66 });67 return () => ctrl.abort();68 }, [kind, indicator, minSev, limit]);6970 const visible = useMemo(() => (region ? items.filter((c) => (c.country?.region ?? '').toUpperCase() === region) : items), [items, region]);71 const groups = useMemo(() => {72 const m = new Map<string, ChangeItem[]>();73 for (const c of visible) {74 const key = c.period ? formatPeriod(c.period, 'A') : t('common.na');75 if (!m.has(key)) m.set(key, []);76 m.get(key)!.push(c);77 }78 return Array.from(m.entries()).sort((a, b) => b[0].localeCompare(a[0]));79 }, [visible]);8081 const active = (kind ? 1 : 0) + (indicator ? 1 : 0) + (region ? 1 : 0) + (minSev ? 1 : 0);82 const reset = () => {83 setKind(null);84 setIndicator(null);85 setRegion(null);86 setMinSev(0);87 };8889 const filters = (90 <div className="space-y-4">91 <div>92 <div className="eyebrow mb-1.5">{t('changes.filter.kind')}</div>93 <ul className="flex flex-wrap gap-1.5" role="radiogroup" aria-label={t('changes.filter.kind')}>94 <li>95 <Chip active={!kind} onClick={() => setKind(null)}>96 {t('common.all')}97 </Chip>98 </li>99 {kinds.map((k) => (100 <li key={k}>101 <Chip active={kind === k} onClick={() => setKind(kind === k ? null : k)}>102 {kindLabel(k, 10)}103 </Chip>104 </li>105 ))}106 </ul>107 </div>108 <div>109 <div className="eyebrow mb-1.5">{t('changes.filter.indicator')}</div>110 {indicator ? (111 <span className="inline-flex h-11 items-center gap-1 rounded-sm bg-accent-soft pl-2.5 pr-1 text-sm text-accent md:h-9">112 {indicator.name}113 <button type="button" onClick={() => setIndicator(null)} className="grid h-9 w-8 place-items-center" aria-label={t('common.reset')}>114 <X size={13} aria-hidden />115 </button>116 </span>117 ) : (118 <EntityPicker type="indicator" placeholder={t('changes.filter.search')} onPick={setIndicator} className="max-w-sm" size="sm" />119 )}120 </div>121 {!compact ? (122 <div>123 <div className="eyebrow mb-1.5">{t('changes.filter.region')}</div>124 <ul className="flex flex-wrap gap-1.5" role="radiogroup" aria-label={t('changes.filter.region')}>125 <li>126 <Chip active={!region} onClick={() => setRegion(null)}>127 {t('common.all')}128 </Chip>129 </li>130 {WB_REGIONS.map((r) => (131 <li key={r.id}>132 <Chip active={region === r.id} onClick={() => setRegion(region === r.id ? null : r.id)}>133 {r.short}134 </Chip>135 </li>136 ))}137 </ul>138 </div>139 ) : null}140 <div>141 <div className="eyebrow mb-1.5">{t('changes.filter.severity')}</div>142 <ul className="flex flex-wrap gap-1.5" role="radiogroup" aria-label={t('changes.filter.severity')}>143 {(144 [145 [0, t('changes.filter.severity.any')],146 [0.4, t('changes.filter.severity.notable')],147 [0.7, t('changes.filter.severity.major')],148 ] as Array<[Sev, string]>149 ).map(([v, label]) => (150 <li key={v}>151 <Chip active={minSev === v} onClick={() => setMinSev(v)}>152 {label}153 </Chip>154 </li>155 ))}156 </ul>157 </div>158 </div>159 );160161 return (162 <div className="min-w-0">163 <div className="flex flex-wrap items-center gap-2 pb-3">164 <button type="button" onClick={() => setSheet(true)} className={cn('inline-flex h-11 items-center gap-1.5 rounded-sm border px-3 text-sm md:hidden', active ? 'border-accent text-accent' : 'border-rule text-ink-2')}>165 <SlidersHorizontal size={15} aria-hidden />166 {t('common.filters')}167 {active ? <span className="tnum">({active})</span> : null}168 </button>169 {compact ? (170 <button type="button" onClick={() => setSheet(true)} className={cn('hidden h-9 items-center gap-1.5 rounded-sm border px-3 text-sm md:inline-flex', active ? 'border-accent text-accent' : 'border-rule text-ink-2')}>171 <SlidersHorizontal size={15} aria-hidden />172 {t('common.filters')}173 {active ? <span className="tnum">({active})</span> : null}174 </button>175 ) : null}176 {active ? (177 <button type="button" onClick={reset} className="inline-flex h-11 items-center gap-1 rounded-sm px-2 text-xs text-ink-2 hover:text-ink md:h-9">178 <X size={13} aria-hidden /> {t('common.reset')}179 </button>180 ) : null}181 <span className="tnum ml-auto text-xs text-ink-3" aria-live="polite">182 {state === 'loading' ? t('changes.loading') : t('changes.count', { n: grouped(visible.length) })}183 </span>184 </div>185 {!compact ? <div className="hidden pb-4 md:block">{filters}</div> : null}186187 {state === 'error' ? <p className="py-6 text-sm text-down">{t('changes.error')}</p> : null}188 {state !== 'error' && visible.length === 0 && state !== 'loading' ? <p className="py-8 text-center text-sm text-ink-3">{t('changes.none')}</p> : null}189190 <div className={cn('transition-opacity', state === 'loading' && 'opacity-50')} aria-busy={state === 'loading'}>191 {groups.map(([period, rows]) => (192 <section key={period} className="pb-4">193 <h3 className="tnum sticky top-[52px] z-10 -mx-4 border-b border-rule bg-paper/95 px-4 py-1 text-xs font-semibold uppercase tracking-wide text-ink-2 backdrop-blur sm:mx-0 sm:px-0 md:top-14">194 {period}195 </h3>196 <ol className="divide-y divide-rule">197 {rows.map((c, i) => (198 <ChangeRow key={c.id ?? `${period}-${i}`} c={c} />199 ))}200 </ol>201 </section>202 ))}203 </div>204205 <BottomSheet open={sheet} onClose={() => setSheet(false)} side="center" title={t('common.filters')}>206 {filters}207 <div className="mt-6 flex justify-between">208 <button type="button" className="tap rounded-sm px-3 text-sm text-ink-2 hover:bg-surface-2" onClick={reset}>209 {t('common.reset')}210 </button>211 <button type="button" className="tap rounded-sm bg-ink px-4 text-sm font-medium text-paper" onClick={() => setSheet(false)}>212 {t('common.apply')}213 </button>214 </div>215 </BottomSheet>216 </div>217 );218}219220function ChangeRow({ c }: { c: ChangeItem }) {221 const Icon = ICON[c.kind ?? ''] ?? ArrowUpRight;222 const lvl = severityLevel(c.severity);223 const ind = c.indicator as { id: string; slug?: string; name?: string | null; topic?: string | null };224 const slug = ind.slug ?? ind.id;225 const seriesHref = c.country?.slug && ind.topic ? routes.countryIndicator(c.country.slug, ind.topic, slug) : routes.indicator(slug);226 return (227 <li className="flex gap-3 py-3">228 <span className={cn('mt-0.5 grid h-7 w-7 shrink-0 place-items-center rounded-sm', lvl === 'high' ? 'bg-accent-soft text-accent' : 'bg-surface-2 text-ink-2')} aria-hidden>229 <Icon size={15} />230 </span>231 <div className="min-w-0 flex-1">232 <div className="flex flex-wrap items-center gap-x-2 gap-y-1 text-2xs text-ink-3">233 {c.country ? <CountryChip country={{ slug: c.country.slug ?? c.country.id, name: c.country.name ?? c.country.id, flag: c.country.flag }} size="sm" /> : null}234 <span className="font-medium uppercase tracking-wide">{kindLabel(c.kind, c.window_years)}</span>235 <span className={cn('rounded-xs px-1 py-px', lvl === 'high' ? 'bg-accent-soft text-accent' : lvl === 'medium' ? 'bg-surface-2 text-ink-2' : 'bg-surface-2 text-ink-3')}>{severityLabel(c.severity)}</span>236 </div>237 <p className="mt-0.5 text-sm leading-snug text-ink">{c.headline ?? `${ind.name ?? slug}: ${c.formatted ?? ''}`}</p>238 <div className="mt-1 flex flex-wrap gap-x-3 text-xs">239 <Link href={seriesHref} className="inline-flex min-h-[32px] items-center text-accent hover:underline">240 {t('changes.series')} →241 </Link>242 <Link href={routes.indicator(slug)} className="inline-flex min-h-[32px] items-center text-ink-2 hover:text-accent hover:underline">243 {ind.name ?? slug}244 </Link>245 </div>246 </div>247 </li>248 );249}250251function Chip({ active, onClick, children }: { active: boolean; onClick: () => void; children: React.ReactNode }) {252 return (253 <button type="button" role="radio" aria-checked={active} onClick={onClick} className={cn('inline-flex h-11 items-center rounded-sm border px-3 text-sm md:h-9 md:px-2.5', active ? 'border-ink bg-ink text-paper' : 'border-rule text-ink-2 hover:border-rule-strong hover:text-ink')}>254 {children}255 </button>256 );257}258