spb/countryatlas
Public
TypeScript 57%
Python 38.6%
JavaScript 3.6%
CSS 0.6%
1'use client';2import { ArrowDownAZ, ArrowDownWideNarrow, SlidersHorizontal, X } from 'lucide-react';3import Link from 'next/link';4import { useRouter, useSearchParams } from 'next/navigation';5import { useCallback, useMemo, useState } from 'react';6import { t } from '@/i18n';7import { cn } from '@/lib/cn';8import { compact, formatPct, grouped, isNum } from '@/lib/format';9import { INCOME_GROUPS, WB_REGIONS } from '@/lib/regions';10import { regionShort } from '@/lib/regions';11import { routes } from '@/lib/site';12import type { CountrySummary } from '@/lib/types';13import { BottomSheet } from '@/components/data/bottom-sheet';1415type SortKey = 'name' | 'population' | 'gdp' | 'coverage';1617/**18 * Country directory: the 218 rows are server-rendered (this client component SSRs with the full list) and19 * filtered/sorted client-side. Region + income chips (bottom sheet on mobile), sort control, A–Z grouping20 * with a sticky letter index on desktop. URL `?region=&income=&q=&sort=` is kept in sync (shallow).21 */22export function CountryDirectory({ items }: { items: CountrySummary[] }) {23 const params = useSearchParams();24 const router = useRouter();25 const [q, setQ] = useState(params.get('q') ?? '');26 const [region, setRegion] = useState<string | null>(params.get('region'));27 const [income, setIncome] = useState<string | null>(params.get('income'));28 const [sort, setSort] = useState<SortKey>((params.get('sort') as SortKey) || 'name');29 const [sheet, setSheet] = useState(false);3031 const sync = useCallback(32 (next: { q?: string; region?: string | null; income?: string | null; sort?: SortKey }) => {33 const p = new URLSearchParams();34 const nq = next.q ?? q;35 const nr = next.region === undefined ? region : next.region;36 const ni = next.income === undefined ? income : next.income;37 const ns = next.sort ?? sort;38 if (nq) p.set('q', nq);39 if (nr) p.set('region', nr);40 if (ni) p.set('income', ni);41 if (ns !== 'name') p.set('sort', ns);42 const s = p.toString();43 router.replace(`${routes.countries()}${s ? `?${s}` : ''}`, { scroll: false });44 },45 [q, region, income, sort, router],46 );4748 const filtered = useMemo(() => {49 const ql = q.trim().toLowerCase();50 let rows = items.filter((c) => (!region || (c.region ?? '').toUpperCase() === region.toUpperCase()) && (!income || (c.income ?? '').toUpperCase() === income.toUpperCase()));51 if (ql) rows = rows.filter((c) => (c.name ?? '').toLowerCase().includes(ql) || c.id.toLowerCase() === ql || (c.capital ?? '').toLowerCase().includes(ql));52 const num = (v: number | null) => (isNum(v) ? v : -Infinity);53 rows.sort((a, b) => {54 if (sort === 'population') return num(b.population_latest) - num(a.population_latest) || (a.name ?? '').localeCompare(b.name ?? '');55 if (sort === 'gdp') return num(b.gdp_per_capita_latest) - num(a.gdp_per_capita_latest) || (a.name ?? '').localeCompare(b.name ?? '');56 if (sort === 'coverage') return num(b.coverage_pct) - num(a.coverage_pct) || (a.name ?? '').localeCompare(b.name ?? '');57 return (a.name ?? '').localeCompare(b.name ?? '');58 });59 return rows;60 }, [items, q, region, income, sort]);6162 const groupsAZ = useMemo(() => {63 if (sort !== 'name') return null;64 const m = new Map<string, CountrySummary[]>();65 for (const c of filtered) {66 const letter = (c.name ?? '#').charAt(0).toUpperCase();67 const key = /[A-Z]/.test(letter) ? letter : '#';68 if (!m.has(key)) m.set(key, []);69 m.get(key)!.push(c);70 }71 return Array.from(m.entries());72 }, [filtered, sort]);7374 const activeCount = (region ? 1 : 0) + (income ? 1 : 0);75 const letters = groupsAZ?.map(([l]) => l) ?? [];7677 const filters = (78 <div className="space-y-4">79 <ChipGroup label={t('common.region')} value={region} onChange={(v) => { setRegion(v); sync({ region: v }); }} options={WB_REGIONS.map((r) => ({ id: r.id, label: r.short }))} />80 <ChipGroup label={t('common.income')} value={income} onChange={(v) => { setIncome(v); sync({ income: v }); }} options={INCOME_GROUPS.map((g) => ({ id: g.id, label: g.name }))} />81 </div>82 );8384 return (85 <div className="lg:grid lg:grid-cols-[1fr_2.5rem] lg:gap-6">86 <div className="min-w-0">87 {/* Controls row */}88 <div className="flex flex-wrap items-center gap-2 py-3">89 <div className="relative min-w-0 flex-1 basis-56">90 <input91 type="search"92 value={q}93 onChange={(e) => { setQ(e.target.value); sync({ q: e.target.value }); }}94 placeholder={t('countries.search')}95 aria-label={t('countries.search')}96 className="h-10 w-full rounded-sm border border-rule bg-surface px-3 text-sm outline-none placeholder:text-ink-3 focus:border-accent"97 />98 {q ? (99 <button type="button" onClick={() => { setQ(''); sync({ q: '' }); }} className="absolute right-1 top-1 grid h-8 w-8 place-items-center text-ink-3 hover:text-ink" aria-label={t('search.clear')}>100 <X size={14} aria-hidden />101 </button>102 ) : null}103 </div>104 <label className="inline-flex h-10 items-center gap-1.5 rounded-sm border border-rule bg-surface px-2 text-sm text-ink-2">105 {sort === 'name' ? <ArrowDownAZ size={15} aria-hidden /> : <ArrowDownWideNarrow size={15} aria-hidden />}106 <span className="sr-only">{t('common.sortBy')}</span>107 <select value={sort} onChange={(e) => { const v = e.target.value as SortKey; setSort(v); sync({ sort: v }); }} className="bg-transparent pr-1 text-ink outline-none">108 <option value="name">{t('countries.sort.name')}</option>109 <option value="population">{t('countries.sort.population')}</option>110 <option value="gdp">{t('countries.sort.gdp')}</option>111 <option value="coverage">{t('countries.sort.coverage')}</option>112 </select>113 </label>114 <button type="button" onClick={() => setSheet(true)} className={cn('inline-flex h-10 items-center gap-1.5 rounded-sm border px-3 text-sm md:hidden', activeCount ? 'border-accent text-accent' : 'border-rule text-ink-2')}>115 <SlidersHorizontal size={15} aria-hidden />116 {t('countries.filters')}117 {activeCount ? <span className="tnum">({activeCount})</span> : null}118 </button>119 <span className="tnum ml-auto text-xs text-ink-3">{t('countries.count', { n: grouped(filtered.length), total: grouped(items.length) })}</span>120 </div>121 <div className="hidden pb-3 md:block">{filters}</div>122 {(region || income) && (123 <div className="flex flex-wrap items-center gap-2 pb-3 md:hidden">124 {region ? <ActiveChip label={WB_REGIONS.find((r) => r.id === region)?.short ?? region} onClear={() => { setRegion(null); sync({ region: null }); }} /> : null}125 {income ? <ActiveChip label={INCOME_GROUPS.find((g) => g.id === income)?.name ?? income} onClear={() => { setIncome(null); sync({ income: null }); }} /> : null}126 </div>127 )}128129 {/* Header row (sm+) */}130 <div className="hidden grid-cols-[minmax(0,1fr)_7rem_7rem_5rem] gap-3 border-b border-rule pb-1.5 text-2xs font-medium uppercase tracking-wide text-ink-3 sm:grid md:grid-cols-[minmax(0,1fr)_13rem_8rem_8rem_5rem]">131 <span>{t('common.country')}</span>132 <span className="hidden md:block">{t('common.region')}</span>133 <span className="text-right">{t('common.population')}</span>134 <span className="text-right">{t('common.gdpPerCapita')}</span>135 <span className="text-right">{t('common.coverage')}</span>136 </div>137138 {filtered.length === 0 ? <p className="py-10 text-center text-sm text-ink-3">{t('countries.noMatch')}</p> : null}139140 {groupsAZ ? (141 groupsAZ.map(([letter, rows]) => (142 <section key={letter} id={`letter-${letter}`} className="scroll-mt-28">143 <h2 className="display sticky top-[52px] z-10 -mx-4 border-b border-rule bg-paper/95 px-4 py-1 text-lg text-ink-2 backdrop-blur sm:mx-0 sm:px-0 md:top-14 md:static md:mt-4 md:border-0 md:bg-transparent md:py-0">144 {letter}145 </h2>146 <ul className="divide-y divide-rule">147 {rows.map((c) => (148 <Row key={c.id} c={c} />149 ))}150 </ul>151 </section>152 ))153 ) : (154 <ol className="divide-y divide-rule">155 {filtered.map((c, i) => (156 <Row key={c.id} c={c} rank={i + 1} />157 ))}158 </ol>159 )}160 </div>161162 {/* Sticky letter index (desktop) */}163 {letters.length > 1 ? (164 <nav aria-label={t('countries.letterIndex')} className="hidden lg:block">165 <ol className="sticky top-20 flex flex-col items-center gap-0.5 text-2xs">166 {letters.map((l) => (167 <li key={l}>168 <a href={`#letter-${l}`} className="grid h-6 w-8 place-items-center rounded-xs text-ink-3 hover:bg-surface-2 hover:text-accent">169 {l}170 </a>171 </li>172 ))}173 </ol>174 </nav>175 ) : null}176177 <BottomSheet open={sheet} onClose={() => setSheet(false)} side="center" title={t('countries.filters')}>178 {filters}179 <div className="mt-6 flex justify-between">180 <button type="button" className="tap rounded-sm px-3 text-sm text-ink-2 hover:bg-surface-2" onClick={() => { setRegion(null); setIncome(null); sync({ region: null, income: null }); }}>181 {t('common.reset')}182 </button>183 <button type="button" className="tap rounded-sm bg-ink px-4 text-sm font-medium text-paper" onClick={() => setSheet(false)}>184 {t('common.apply')}185 </button>186 </div>187 </BottomSheet>188 </div>189 );190}191192function Row({ c, rank }: { c: CountrySummary; rank?: number }) {193 return (194 <li>195 <Link href={routes.country(c.slug ?? c.id)} className="group grid min-h-[52px] grid-cols-[minmax(0,1fr)_auto] items-center gap-x-3 py-2 sm:grid-cols-[minmax(0,1fr)_7rem_7rem_5rem] md:grid-cols-[minmax(0,1fr)_13rem_8rem_8rem_5rem]">196 <span className="flex min-w-0 items-center gap-2.5">197 {rank ? <span className="tnum w-6 shrink-0 text-right text-xs text-ink-3">{rank}</span> : null}198 <span aria-hidden className="text-xl leading-none">199 {c.flag}200 </span>201 <span className="min-w-0">202 <span className="block truncate text-sm font-medium text-ink group-hover:text-accent">203 {c.name}204 {c.kind === 'territory' ? <span className="ml-1.5 rounded-xs border border-rule px-1 text-2xs font-normal text-ink-3">{t('countries.territory')}</span> : null}205 </span>206 <span className="block truncate text-xs text-ink-3 md:hidden">{regionShort(c.region) ?? c.region_name ?? ''}</span>207 </span>208 </span>209 <span className="hidden truncate text-xs text-ink-2 md:block">{c.region_name}</span>210 <span className="tnum text-right text-sm text-ink sm:text-sm">211 <span className="block">{isNum(c.population_latest) ? compact(c.population_latest) : t('common.na')}</span>212 <span className="block text-2xs text-ink-3 sm:hidden">{isNum(c.gdp_per_capita_latest) ? `US$${compact(c.gdp_per_capita_latest)} /cap` : ''}</span>213 </span>214 <span className="tnum hidden text-right text-sm text-ink sm:block">{isNum(c.gdp_per_capita_latest) ? `US$${compact(c.gdp_per_capita_latest)}` : t('common.na')}</span>215 <span className="tnum hidden text-right text-xs text-ink-2 sm:block">{formatPct(c.coverage_pct)}</span>216 </Link>217 </li>218 );219}220221function ChipGroup({ label, value, onChange, options }: { label: string; value: string | null; onChange: (v: string | null) => void; options: Array<{ id: string; label: string }> }) {222 return (223 <div>224 <div className="eyebrow mb-1.5">{label}</div>225 <ul className="flex flex-wrap gap-1.5" role="radiogroup" aria-label={label}>226 <li>227 <Chip active={!value} onClick={() => onChange(null)}>228 {t('common.all')}229 </Chip>230 </li>231 {options.map((o) => (232 <li key={o.id}>233 <Chip active={value?.toUpperCase() === o.id.toUpperCase()} onClick={() => onChange(value?.toUpperCase() === o.id.toUpperCase() ? null : o.id)}>234 {o.label}235 </Chip>236 </li>237 ))}238 </ul>239 </div>240 );241}242243function Chip({ active, onClick, children }: { active: boolean; onClick: () => void; children: React.ReactNode }) {244 return (245 <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')}>246 {children}247 </button>248 );249}250251function ActiveChip({ label, onClear }: { label: string; onClear: () => void }) {252 return (253 <span className="inline-flex h-8 items-center gap-1 rounded-sm bg-accent-soft pl-2.5 pr-1 text-xs text-accent">254 {label}255 <button type="button" onClick={onClear} className="grid h-7 w-7 place-items-center" aria-label={t('common.reset')}>256 <X size={12} aria-hidden />257 </button>258 </span>259 );260}261