SPB Git

spb/countryatlas

Public
11.3 KB · 255 lines tsx
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
Files · d588bee37a233436d1bef2f0970b4b557e87e5f7
Menu

Theme