spb/countryatlas
Public
1'use client';2import { AlertTriangle, ArrowDownRight, ArrowUpRight, Repeat, SlidersHorizontal, TrendingDown, TrendingUp, Trophy, 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};2930type Sev = 0 | 0.4 | 0.7;3132/**33 * Global change feed with filters (kind chips, indicator typeahead, region, minimum severity) in a bottom34 * sheet on phones and inline on desktop. Items are grouped by period (year). Server passes the first page;35 * changing a server-side filter (kind / indicator / severity) refetches `/api/v1/changes`; region filters36 * client-side (the API has no region parameter but every item carries the country's region).37 */38export function ChangesFeed({ initial, kinds, limit = 100, compact = false }: { initial: ChangeItem[]; kinds: string[]; limit?: number; compact?: boolean }) {39 const [kind, setKind] = useState<string | null>(null);40 const [indicator, setIndicator] = useState<PickedEntity | null>(null);41 const [region, setRegion] = useState<string | null>(null);42 const [minSev, setMinSev] = useState<Sev>(0);43 const [items, setItems] = useState<ChangeItem[]>(initial);44 const [state, setState] = useState<'idle' | 'loading' | 'error'>('idle');45 const [sheet, setSheet] = useState(false);46 const first = useRef(true);4748 useEffect(() => {49 if (first.current) {50 first.current = false;51 return;52 }53 const ctrl = new AbortController();54 setState('loading');55 clientExplore56 .changes({ limit, kind, indicator: indicator?.slug ?? null, min_severity: minSev || null }, ctrl.signal)57 .then((r) => {58 setItems(r.items);59 setState('idle');60 })61 .catch((e) => {62 if ((e as Error).name !== 'AbortError') setState('error');63 });64 return () => ctrl.abort();65 }, [kind, indicator, minSev, limit]);6667 const visible = useMemo(() => (region ? items.filter((c) => (c.country?.region ?? '').toUpperCase() === region) : items), [items, region]);68 const groups = useMemo(() => {69 const m = new Map<string, ChangeItem[]>();70 for (const c of visible) {71 const key = c.period ? formatPeriod(c.period, 'A') : t('common.na');72 if (!m.has(key)) m.set(key, []);73 m.get(key)!.push(c);74 }75 return Array.from(m.entries()).sort((a, b) => b[0].localeCompare(a[0]));76 }, [visible]);7778 const active = (kind ? 1 : 0) + (indicator ? 1 : 0) + (region ? 1 : 0) + (minSev ? 1 : 0);79 const reset = () => {80 setKind(null);81 setIndicator(null);82 setRegion(null);83 setMinSev(0);84 };8586 const filters = (87 <div className="space-y-4">88 <div>89 <div className="eyebrow mb-1.5">{t('changes.filter.kind')}</div>90 <ul className="flex flex-wrap gap-1.5" role="radiogroup" aria-label={t('changes.filter.kind')}>91 <li>92 <Chip active={!kind} onClick={() => setKind(null)}>93 {t('common.all')}94 </Chip>95 </li>96 {kinds.map((k) => (97 <li key={k}>98 <Chip active={kind === k} onClick={() => setKind(kind === k ? null : k)}>99 {kindLabel(k, 10)}100 </Chip>101 </li>102 ))}103 </ul>104 </div>105 <div>106 <div className="eyebrow mb-1.5">{t('changes.filter.indicator')}</div>107 {indicator ? (108 <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">109 {indicator.name}110 <button type="button" onClick={() => setIndicator(null)} className="grid h-9 w-8 place-items-center" aria-label={t('common.reset')}>111 <X size={13} aria-hidden />112 </button>113 </span>114 ) : (115 <EntityPicker type="indicator" placeholder={t('changes.filter.search')} onPick={setIndicator} className="max-w-sm" size="sm" />116 )}117 </div>118 {!compact ? (119 <div>120 <div className="eyebrow mb-1.5">{t('changes.filter.region')}</div>121 <ul className="flex flex-wrap gap-1.5" role="radiogroup" aria-label={t('changes.filter.region')}>122 <li>123 <Chip active={!region} onClick={() => setRegion(null)}>124 {t('common.all')}125 </Chip>126 </li>127 {WB_REGIONS.map((r) => (128 <li key={r.id}>129 <Chip active={region === r.id} onClick={() => setRegion(region === r.id ? null : r.id)}>130 {r.short}131 </Chip>132 </li>133 ))}134 </ul>135 </div>136 ) : null}137 <div>138 <div className="eyebrow mb-1.5">{t('changes.filter.severity')}</div>139 <ul className="flex flex-wrap gap-1.5" role="radiogroup" aria-label={t('changes.filter.severity')}>140 {(141 [142 [0, t('changes.filter.severity.any')],143 [0.4, t('changes.filter.severity.notable')],144 [0.7, t('changes.filter.severity.major')],145 ] as Array<[Sev, string]>146 ).map(([v, label]) => (147 <li key={v}>148 <Chip active={minSev === v} onClick={() => setMinSev(v)}>149 {label}150 </Chip>151 </li>152 ))}153 </ul>154 </div>155 </div>156 );157158 return (159 <div className="min-w-0">160 <div className="flex flex-wrap items-center gap-2 pb-3">161 <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')}>162 <SlidersHorizontal size={15} aria-hidden />163 {t('common.filters')}164 {active ? <span className="tnum">({active})</span> : null}165 </button>166 {compact ? (167 <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')}>168 <SlidersHorizontal size={15} aria-hidden />169 {t('common.filters')}170 {active ? <span className="tnum">({active})</span> : null}171 </button>172 ) : null}173 {active ? (174 <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">175 <X size={13} aria-hidden /> {t('common.reset')}176 </button>177 ) : null}178 <span className="tnum ml-auto text-xs text-ink-3" aria-live="polite">179 {state === 'loading' ? t('changes.loading') : t('changes.count', { n: grouped(visible.length) })}180 </span>181 </div>182 {!compact ? <div className="hidden pb-4 md:block">{filters}</div> : null}183184 {state === 'error' ? <p className="py-6 text-sm text-down">{t('changes.error')}</p> : null}185 {state !== 'error' && visible.length === 0 && state !== 'loading' ? <p className="py-8 text-center text-sm text-ink-3">{t('changes.none')}</p> : null}186187 <div className={cn('transition-opacity', state === 'loading' && 'opacity-50')} aria-busy={state === 'loading'}>188 {groups.map(([period, rows]) => (189 <section key={period} className="pb-4">190 <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">191 {period}192 </h3>193 <ol className="divide-y divide-rule">194 {rows.map((c, i) => (195 <ChangeRow key={c.id ?? `${period}-${i}`} c={c} />196 ))}197 </ol>198 </section>199 ))}200 </div>201202 <BottomSheet open={sheet} onClose={() => setSheet(false)} side="center" title={t('common.filters')}>203 {filters}204 <div className="mt-6 flex justify-between">205 <button type="button" className="tap rounded-sm px-3 text-sm text-ink-2 hover:bg-surface-2" onClick={reset}>206 {t('common.reset')}207 </button>208 <button type="button" className="tap rounded-sm bg-ink px-4 text-sm font-medium text-paper" onClick={() => setSheet(false)}>209 {t('common.apply')}210 </button>211 </div>212 </BottomSheet>213 </div>214 );215}216217function ChangeRow({ c }: { c: ChangeItem }) {218 const Icon = ICON[c.kind ?? ''] ?? ArrowUpRight;219 const lvl = severityLevel(c.severity);220 const ind = c.indicator as { id: string; slug?: string; name?: string | null; topic?: string | null };221 const slug = ind.slug ?? ind.id;222 const seriesHref = c.country?.slug && ind.topic ? routes.countryIndicator(c.country.slug, ind.topic, slug) : routes.indicator(slug);223 return (224 <li className="flex gap-3 py-3">225 <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>226 <Icon size={15} />227 </span>228 <div className="min-w-0 flex-1">229 <div className="flex flex-wrap items-center gap-x-2 gap-y-1 text-2xs text-ink-3">230 {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}231 <span className="font-medium uppercase tracking-wide">{kindLabel(c.kind, c.window_years)}</span>232 <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>233 </div>234 <p className="mt-0.5 text-sm leading-snug text-ink">{c.headline ?? `${ind.name ?? slug}: ${c.formatted ?? ''}`}</p>235 <div className="mt-1 flex flex-wrap gap-x-3 text-xs">236 <Link href={seriesHref} className="inline-flex min-h-[32px] items-center text-accent hover:underline">237 {t('changes.series')} →238 </Link>239 <Link href={routes.indicator(slug)} className="inline-flex min-h-[32px] items-center text-ink-2 hover:text-accent hover:underline">240 {ind.name ?? slug}241 </Link>242 </div>243 </div>244 </li>245 );246}247248function Chip({ active, onClick, children }: { active: boolean; onClick: () => void; children: React.ReactNode }) {249 return (250 <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')}>251 {children}252 </button>253 );254}255