cross-link: companies-in-this-story sidebar + unified company search

Feature 1 — sidebar
  Adds a "Companies in this story" panel on every news article that
  surfaces every directory company my linker matched in the body. Each
  tile shows logo, name, Sponsor / NAB 26 / IBC badges, and a one-sentence
  bio. Tiles link to /manufacturers/<slug> and carry data-company-slug so
  the global hover card pops on hover too.

  Extends company-mentions.ts with linkifyAndExtractMentions() that
  returns both the linked HTML and the ordered list of matched slugs in
  one pass, so /news/[slug] doesn't re-scan the body for the sidebar.

Feature 2 — unified typeahead
  /api/search/suggest now returns { companies, items } — companies are
  matched against bb.tracked_companies (directory_visible=true), capped
  at 6, ordered by featured → mention_count, then bumped if they're an
  active advertiser on broadcastbeat (adv schema lookup).

  Header search dropdown renders a "Companies" section at the top with
  logo, name, Sponsor badge (active advertiser) or NAB 26 badge, above
  the existing "Stories" results. Each company link carries
  data-company-slug so the hover card works in the dropdown too.
This commit is contained in:
Ryan Salazar
2026-05-27 12:47:54 +00:00
parent 621d7f45fc
commit a59846a524
6 changed files with 287 additions and 12 deletions

View File

@@ -51,6 +51,17 @@ export default function Header() {
href: string;
}>
>([]);
const [suggestedCompanies, setSuggestedCompanies] = useState<
Array<{
slug: string;
name: string;
logoUrl: string | null;
exhibitsNab: boolean;
exhibitsIbc: boolean;
isSponsor: boolean;
href: string;
}>
>([]);
const [suggestOpen, setSuggestOpen] = useState(false);
const submitSearch = () => {
@@ -70,12 +81,13 @@ export default function Header() {
}
const handle = setTimeout(() => {
fetch(`/api/search/suggest?q=${encodeURIComponent(q)}&limit=8`, { cache: "no-store" })
.then((r) => r.ok ? r.json() : { items: [] })
.then((r) => r.ok ? r.json() : { items: [], companies: [] })
.then((d) => {
setSuggestions(d.items || []);
setSuggestedCompanies(d.companies || []);
setSuggestOpen(true);
})
.catch(() => setSuggestions([]));
.catch(() => { setSuggestions([]); setSuggestedCompanies([]); });
}, 150);
return () => clearTimeout(handle);
}, [searchQuery]);
@@ -573,11 +585,49 @@ export default function Header() {
)}
</div>
{suggestOpen && suggestions.length > 0 && (
{suggestOpen && (suggestions.length > 0 || suggestedCompanies.length > 0) && (
<ul
id="bb-search-suggest"
role="listbox"
className="bb-browse-search-suggest">
{suggestedCompanies.length > 0 && (
<>
<li className="px-3 pt-2 pb-1 text-[10px] uppercase tracking-widest text-[#666] font-bold border-b border-[#1a1a1a]">
Companies
</li>
{suggestedCompanies.map((c) => (
<li key={`co-${c.slug}`} role="option">
<Link
href={c.href}
data-company-slug={c.slug}
className="bb-browse-search-suggest-item flex items-center gap-2.5"
onMouseDown={(e) => e.preventDefault()}
onClick={() => { setSuggestOpen(false); setSearchQuery(""); }}>
{c.logoUrl ? (
/* eslint-disable-next-line @next/next/no-img-element */
<img src={c.logoUrl} alt="" className="w-7 h-7 rounded bg-white p-0.5 object-contain flex-shrink-0" loading="lazy" />
) : (
<div className="w-7 h-7 rounded bg-[#222] flex items-center justify-center text-[#666] font-bold text-xs flex-shrink-0">
{(c.name || "?").trim().charAt(0).toUpperCase()}
</div>
)}
<span className="flex-1 min-w-0 truncate font-semibold text-[#e0e0e0]">{c.name}</span>
{c.isSponsor && (
<span className="text-[9px] font-bold uppercase tracking-wider bg-emerald-500/15 text-emerald-300 px-1.5 py-0.5 rounded">Sponsor</span>
)}
{c.exhibitsNab && !c.isSponsor && (
<span className="text-[9px] font-bold uppercase tracking-wider bg-amber-500/15 text-amber-300 px-1.5 py-0.5 rounded">NAB 26</span>
)}
</Link>
</li>
))}
{suggestions.length > 0 && (
<li className="px-3 pt-2 pb-1 text-[10px] uppercase tracking-widest text-[#666] font-bold border-y border-[#1a1a1a]">
Stories
</li>
)}
</>
)}
{suggestions.map((s) => {
const snippet = s.snippet;
const beforeMatch = snippet?.text.slice(0, snippet.matchStart) || "";