← 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&rsquo;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>
  );
}