'use client'; import { Search, X } from 'lucide-react'; import Link from 'next/link'; import { usePathname, useRouter } from 'next/navigation'; import { useEffect, useRef, useState } from 'react'; import { EntityBadge } from '@/components/ui/badges'; import { clientApi } from '@/lib/client-api'; import { cn } from '@/lib/cn'; import { typeLabel } from '@/lib/site'; import type { Suggestion } from '@/lib/types'; import { COMPARE_MAX, COMPARE_MIN, compareHref, type TrayItem, trayType, useCompareTray } from './compare-store'; /** * Compare picker + tray for /compare. Suggestions come from /search/suggest; once the tray holds an item, only * entities of the same (normalised) type are selectable — the API compares 2–6 entities of one type. * `initial` (from `?ids=` resolved server-side) seeds the tray on load; tray changes are mirrored into `?ids=`. */ export function ComparePicker({ initial, exampleHref }: { initial: TrayItem[]; exampleHref?: string | null }) { const tray = useCompareTray(); const router = useRouter(); const pathname = usePathname(); const [q, setQ] = useState(''); const [items, setItems] = useState([]); const [loading, setLoading] = useState(false); const [failed, setFailed] = useState(false); const seeded = useRef(false); /** Slugs the tray must show before we start mirroring it into the URL (avoids stripping ?ids= during the seed commit). */ const awaiting = useRef(null); const inputRef = useRef(null); // Seed the tray from the URL once hydrated (URL wins over localStorage when present). useEffect(() => { if (!tray.ready || seeded.current) return; seeded.current = true; if (initial.length) { const same = initial.every((i) => trayType(i.entity_type) === trayType(initial[0]!.entity_type)); const want = initial.map((i) => i.slug).join(','); const current = tray.items.map((i) => i.slug).join(','); if (same && current !== want) { awaiting.current = want; tray.replace(initial); } } }, [tray.ready, tray.items, tray.replace, initial]); // Mirror the tray into ?ids= (no scroll, replace) — only once the seeded items are in state. useEffect(() => { if (!tray.ready || !seeded.current) return; const ids = tray.items.map((i) => i.slug); if (awaiting.current !== null) { if (ids.join(',') !== awaiting.current) return; awaiting.current = null; } const url = new URL(window.location.href); const cur = url.searchParams.get('ids') ?? ''; const next = ids.length >= COMPARE_MIN ? ids.join(',') : ''; if (cur === next) return; if (next) url.searchParams.set('ids', next); else url.searchParams.delete('ids'); router.replace(`${pathname}${url.search}`, { scroll: false }); }, [tray.items, tray.ready, router, pathname]); useEffect(() => { const term = q.trim(); if (term.length < 1) { setItems([]); return; } const ctrl = new AbortController(); const t = setTimeout(async () => { setLoading(true); try { const res = await clientApi.suggest(term, ctrl.signal); setItems(res.items ?? []); setFailed(false); } catch (e) { if ((e as Error).name !== 'AbortError') setFailed(true); } finally { setLoading(false); } }, 120); return () => { clearTimeout(t); ctrl.abort(); }; }, [q]); const type = tray.type; const full = tray.full; const pick = (s: Suggestion) => { tray.add({ slug: s.slug, name: s.name, entity_type: s.entity_type, organization: s.organization_name }); setQ(''); setItems([]); inputRef.current?.focus(); }; const term = q.trim(); const selectable = items.filter((s) => !tray.has(s.slug)); const matching = type ? selectable.filter((s) => trayType(s.entity_type) === type) : selectable; const others = type ? selectable.filter((s) => trayType(s.entity_type) !== type) : []; return (
{/* ---------------------------------------------------------------------------------------------- search */}
setQ(e.target.value)} disabled={full} placeholder={full ? `Tray full (${COMPARE_MAX}) — remove one to add another` : type ? `Search ${typeLabel(type, true).toLowerCase()}…` : 'Search models, providers, hardware, companies…'} className="h-11 min-w-0 flex-1 bg-transparent text-[16px] text-ink placeholder:text-ink-3 focus:outline-none" autoComplete="off" spellCheck={false} aria-autocomplete="list" aria-controls="compare-suggestions" /> {q && ( )}
{type &&

Comparing {typeLabel(type, true).toLowerCase()} — pick another {typeLabel(type).toLowerCase()}. Entities of another type would start a new comparison.

} {term && (
    {failed &&
  • Suggestions unavailable — try again in a moment.
  • } {!loading && !failed && matching.length === 0 && others.length === 0 &&
  • {selectable.length === 0 && items.length > 0 ? 'Already in the tray.' : 'No match.'}
  • } {matching.map((s) => (
  • ))} {others.length > 0 && (
  • Other types (selecting one starts a new {' '}comparison)
  • )} {others.map((s) => (
  • ))}
)}
{/* ---------------------------------------------------------------------------------------------- tray */}

Tray {tray.ready ? tray.items.length : initial.length} / {COMPARE_MAX}

); } export function CompareTray({ items, onRemove, onClear, ready, exampleHref }: { items: TrayItem[]; onRemove: (slug: string) => void; onClear: () => void; ready: boolean; exampleHref?: string | null }) { const can = items.length >= COMPARE_MIN; return (
{items.length === 0 ? (

Nothing selected yet. Use the search on the left, or the “Compare” buttons on listings and entity pages. {exampleHref && ( <> {' '} Example: two well-documented models. )}

) : (
    {items.map((it) => (
  • {it.name} {it.organization && {it.organization}}
  • ))}
)}
{can ? ( Compare {items.length} → ) : ( Pick at least {COMPARE_MIN} )} {items.length > 0 && ( )}
); }