'use client'; import { Clock, CornerDownLeft, Search, X } from 'lucide-react'; import { useRouter } from 'next/navigation'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { t } from '@/i18n'; import { clientApi } from '@/lib/client-api'; import { cn } from '@/lib/cn'; import { routes } from '@/lib/site'; import type { SearchHit, SearchHitType } from '@/lib/types'; import { BottomSheet } from '@/components/data/bottom-sheet'; import { useSearch } from './search-context'; const RECENT_KEY = 'ca-recent-searches'; const MAX_RECENT = 8; function hrefFor(h: SearchHit): string { if (h.url) return h.url; const slug = h.slug ?? h.id; switch (h.type) { case 'country': return routes.country(slug); case 'indicator': return routes.indicator(slug); case 'topic': return routes.indicators(slug); case 'region': return routes.region(slug); case 'source': return routes.source(h.id); default: return '/'; } } function loadRecent(): SearchHit[] { try { const raw = localStorage.getItem(RECENT_KEY); return raw ? (JSON.parse(raw) as SearchHit[]) : []; } catch { return []; } } function saveRecent(h: SearchHit) { try { const cur = loadRecent().filter((x) => !(x.type === h.type && x.id === h.id)); localStorage.setItem(RECENT_KEY, JSON.stringify([h, ...cur].slice(0, MAX_RECENT))); } catch { /* ignore */ } } const TYPE_ORDER: string[] = ['action', 'country', 'country_topic', 'country_indicator', 'indicator', 'topic', 'region', 'source']; /** * Global search (⌘K / "/" / tab bar). Debounced `/api/v1/search?q=`, grouped hits with type chips, keyboard * navigation, recent searches in localStorage. Full-screen sheet on mobile, centred panel on desktop. */ export function SearchDialog() { const { open, setOpen } = useSearch(); const router = useRouter(); const [q, setQ] = useState(''); const [hits, setHits] = useState([]); const [state, setState] = useState<'idle' | 'loading' | 'error'>('idle'); const [recent, setRecent] = useState([]); const [active, setActive] = useState(0); const inputRef = useRef(null); const abortRef = useRef(null); useEffect(() => { if (open) { setRecent(loadRecent()); setTimeout(() => inputRef.current?.focus(), 30); } else { setQ(''); setHits([]); setState('idle'); setActive(0); } }, [open]); useEffect(() => { const term = q.trim(); abortRef.current?.abort(); if (term.length < 1) { setHits([]); setState('idle'); return; } const ctrl = new AbortController(); abortRef.current = ctrl; setState('loading'); const timer = setTimeout(async () => { try { const res = await clientApi.search(term, 14, ctrl.signal); if (ctrl.signal.aborted) return; setHits(res.hits); setActive(0); setState('idle'); } catch (e) { if ((e as Error).name === 'AbortError') return; setState('error'); } }, 160); return () => clearTimeout(timer); }, [q]); const list = useMemo(() => { const src = q.trim() ? hits : recent; return [...src].sort((a, b) => Number(b.type === 'action') - Number(a.type === 'action') || (q.trim() ? b.score - a.score : 0) || TYPE_ORDER.indexOf(a.type) - TYPE_ORDER.indexOf(b.type)); }, [hits, recent, q]); const go = useCallback( (h: SearchHit) => { saveRecent(h); setOpen(false); router.push(hrefFor(h)); }, [router, setOpen], ); const onKey = (e: React.KeyboardEvent) => { if (e.key === 'ArrowDown') { e.preventDefault(); setActive((a) => Math.min(list.length - 1, a + 1)); } else if (e.key === 'ArrowUp') { e.preventDefault(); setActive((a) => Math.max(0, a - 1)); } else if (e.key === 'Enter') { const h = list[active]; if (h) go(h); } }; const clearRecent = () => { try { localStorage.removeItem(RECENT_KEY); } catch { /* ignore */ } setRecent([]); }; return ( setOpen(false)} side="full" labelledBy="search-title" title={
setQ(e.target.value)} onKeyDown={onKey} placeholder={t('search.placeholder')} aria-label={t('nav.search')} aria-controls="search-results" aria-activedescendant={list[active] ? `hit-${list[active].type}-${list[active].id}` : undefined} autoComplete="off" enterKeyHint="go" className="h-10 w-full rounded-sm border border-rule bg-paper pl-8 pr-8 text-base outline-none placeholder:text-ink-3 focus:border-accent" /> {q ? ( ) : null}
} >
{!q.trim() && recent.length > 0 ? (
{t('search.recent')}
) : null} {!q.trim() ? (
{recent.length === 0 ?

{t('search.start')}

: null}

{t('search.examples')} {(['compare', 'rank', 'indicatorCountry', 'indicatorGroup'] as const).map((k) => { const ex = t(`search.example.${k}` as 'search.example.compare'); return ( ); })}

) : null} {state === 'error' ?

{t('search.error')}

: null} {q.trim() && state !== 'error' && state !== 'loading' && list.length === 0 ?

{t('search.empty', { q: q.trim() })}

: null}
    {list.map((h, i) => (
  • ))}

{t('search.hint')}

{state === 'loading' ? t('search.loading') : q.trim() ? t('search.results', { n: list.length }) : ''}
); } const CHIP_TYPE: Record = { country: 'country', indicator: 'indicator', topic: 'topic', region: 'region', source: 'source', country_topic: 'topic', country_indicator: 'indicator', action: 'action' }; export function TypeChip({ type }: { type: SearchHitType | string }) { const key = CHIP_TYPE[type] ?? 'country'; return {t(`search.type.${key}` as 'search.type.country')}; }