← back to Stayclaim
src/components/HeaderSearch.tsx
210 lines
'use client';
import Link from 'next/link';
import { useEffect, useRef, useState } from 'react';
type ListingHit = {
slug: string;
title: string;
city: string | null;
state: string | null;
address_line1: string | null;
hero_image: string | null;
};
type EntityHit = {
slug: string;
display_name: string;
kind: string;
birth_year: number | null;
death_year: number | null;
};
type LaHit = {
apn: string;
situs_address: string | null;
year_built: number | null;
};
// Heuristic: does the query look like an address (number + a street word)?
// Avoids hitting LA records on every two-letter type.
function looksLikeAddress(q: string): boolean {
return /\b\d{2,5}\b/.test(q) && /\b(st|ave|blvd|dr|rd|pl|ln|ct|ter|way|drive|avenue|street|boulevard|road|place|lane|court|terrace)\b/i.test(q);
}
export function HeaderSearch() {
const [q, setQ] = useState('');
const [open, setOpen] = useState(false);
const [listings, setListings] = useState<ListingHit[]>([]);
const [entities, setEntities] = useState<EntityHit[]>([]);
const [laHit, setLaHit] = useState<LaHit | null>(null);
const [loading, setLoading] = useState(false);
const wrapperRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (q.trim().length < 2) {
setListings([]);
setEntities([]);
setLaHit(null);
setLoading(false); // ensure spinner clears when user shortens query
return;
}
let cancelled = false;
const ctrl = new AbortController();
setLoading(true);
const t = setTimeout(async () => {
try {
const queryStr = q.trim();
// Always: local archive search
const archivePromise = fetch(`/api/search?q=${encodeURIComponent(queryStr)}`, { signal: ctrl.signal })
.then(r => r.ok ? r.json() : null);
// If it looks like an address, also hit LA records in parallel
const laPromise = looksLikeAddress(queryStr)
? fetch(`/api/la-records/lookup?q=${encodeURIComponent(queryStr)}`, { signal: ctrl.signal })
.then(r => r.ok ? r.json() : null)
: Promise.resolve(null);
const [archive, la] = await Promise.all([archivePromise, laPromise]);
if (cancelled) return;
setListings(archive?.listings ?? []);
setEntities(archive?.entities ?? []);
setLaHit(la?.found ? { apn: la.apn, situs_address: la.situs_address, year_built: la.year_built } : null);
} catch (err) {
if (cancelled) return;
// Abort errors are expected on rapid typing — swallow; surface others.
if (!(err instanceof DOMException && err.name === 'AbortError')) {
// Clear stale results so previous query's matches don't appear under the new query.
setListings([]);
setEntities([]);
setLaHit(null);
}
} finally {
if (!cancelled) setLoading(false);
}
}, 220);
return () => {
cancelled = true;
clearTimeout(t);
ctrl.abort();
};
}, [q]);
useEffect(() => {
function onClick(e: MouseEvent) {
if (!wrapperRef.current?.contains(e.target as Node)) setOpen(false);
}
document.addEventListener('mousedown', onClick);
return () => document.removeEventListener('mousedown', onClick);
}, []);
const hasResults = listings.length > 0 || entities.length > 0;
const showLaCta = laHit && listings.length === 0; // only surface if archive miss
return (
<div ref={wrapperRef} className="relative w-72">
<input
type="search"
placeholder="Search the archive…"
value={q}
onChange={e => {
setQ(e.target.value);
setOpen(true);
}}
onFocus={() => setOpen(true)}
className="w-full bg-sand border border-ink/15 px-3 py-1.5 text-sm font-sans text-ink placeholder:text-ink/40 focus:outline-none focus:border-moss"
/>
{open && q.trim().length >= 2 && (
<div className="absolute right-0 top-full mt-2 w-[420px] max-w-[calc(100vw-32px)] bg-sand border border-ink/15 shadow-lg z-30 max-h-[70vh] overflow-y-auto">
{loading && (
<p className="px-4 py-3 text-xs uppercase tracking-[0.15em] text-ink/40">
Searching…
</p>
)}
{!loading && !hasResults && !showLaCta && (
<p className="px-4 py-6 text-sm italic text-ink/50">
No matches in the archive yet. Try the address, the architect, or the era.
</p>
)}
{!loading && showLaCta && (
<div className="px-4 py-4 border-b border-ink/10 bg-[#fdf9ee]">
<p className="text-[10px] uppercase tracking-[0.18em] text-[#c9292e] font-mono font-bold mb-2">
Not in our archive — but it’s on file with LA County
</p>
{laHit?.situs_address && (
<p className="font-sans font-bold text-sm text-ink leading-snug">
{laHit.situs_address}
</p>
)}
<p className="font-mono text-[11px] text-ink/55 mt-1">
APN {laHit?.apn}{laHit?.year_built ? ` · built ${laHit.year_built}` : ''}
</p>
<Link
href={`/submit?q=${encodeURIComponent(q.trim())}`}
onClick={() => setOpen(false)}
className="mt-3 inline-block bg-[#c9292e] text-white px-3 py-1.5 text-[10px] uppercase tracking-[0.18em] font-mono font-bold hover:opacity-90 transition"
>
Start a record →
</Link>
</div>
)}
{entities.length > 0 && (
<div className="border-b border-ink/10">
<p className="px-4 pt-3 pb-1 text-[10px] uppercase tracking-[0.15em] text-ink/40">
People
</p>
<ul>
{entities.map(e => (
<li key={e.slug}>
<Link
href={`/entity/${e.slug}`}
className="block px-4 py-2 hover:bg-ink/5 transition"
onClick={() => setOpen(false)}
>
<div className="font-display text-lg text-ink leading-tight">{e.display_name}</div>
<div className="font-mono text-[10px] text-ink/50 mt-0.5">
{e.kind} · {e.birth_year ?? '?'}–{e.death_year ?? '?'}
</div>
</Link>
</li>
))}
</ul>
</div>
)}
{listings.length > 0 && (
<div>
<p className="px-4 pt-3 pb-1 text-[10px] uppercase tracking-[0.15em] text-ink/40">
Addresses
</p>
<ul>
{listings.map(l => (
<li key={l.slug}>
<Link
href={`/address/${l.slug}`}
className="block px-4 py-2 hover:bg-ink/5 transition"
onClick={() => setOpen(false)}
>
<div className="font-display text-lg text-ink leading-tight">{l.title}</div>
<div className="text-[11px] text-ink/55 mt-0.5">
{l.address_line1 ?? l.city}
{l.city && l.address_line1 ? ` · ${l.city}` : ''}
{l.state ? `, ${l.state}` : ''}
</div>
</Link>
</li>
))}
</ul>
</div>
)}
{hasResults && (
<Link
href={`/browse?q=${encodeURIComponent(q.trim())}`}
className="block px-4 py-2.5 border-t border-ink/10 text-[11px] uppercase tracking-[0.15em] text-ink/60 hover:text-coral transition"
onClick={() => setOpen(false)}
>
See all results in the archive →
</Link>
)}
</div>
)}
</div>
);
}