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

@@ -8,9 +8,9 @@ const SUPABASE_URL = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const SUPABASE_ANON = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
const SCHEMA = process.env.NEXT_PUBLIC_SUPABASE_SCHEMA || "bb";
function client() {
function client(schema = SCHEMA) {
return createClient(SUPABASE_URL, SUPABASE_ANON, {
db: { schema: SCHEMA as "public" },
db: { schema: schema as "public" },
auth: { persistSession: false },
});
}
@@ -50,11 +50,51 @@ export async function GET(req: Request) {
const { searchParams } = new URL(req.url);
const q = (searchParams.get("q") || "").trim();
if (q.length < 2) {
return NextResponse.json({ items: [] });
return NextResponse.json({ items: [], companies: [] });
}
const limit = Math.min(parseInt(searchParams.get("limit") || "8", 10) || 8, 15);
const like = `%${q.replace(/[%_]/g, " ")}%`;
// Parallel: matching directory companies (sponsors first).
const companiesPromise = (async () => {
try {
const sb = client();
const { data } = await sb
.from("tracked_companies")
.select("slug, company_name, logo_url, exhibits_nab, exhibits_ibc")
.eq("directory_visible", true)
.ilike("company_name", like)
.order("featured", { ascending: false })
.order("mention_count", { ascending: false })
.limit(6);
if (!data) return [];
let sponsors = new Set<string>();
try {
const adv = client("adv");
const { data: ads } = await adv
.from("active_advertisers_by_property")
.select("company_name_lower")
.eq("property", "broadcastbeat");
sponsors = new Set((ads || []).map((r: any) => String(r.company_name_lower)));
} catch { /* tolerate */ }
const rows = (data as any[]).map((r) => ({
slug: r.slug as string,
name: r.company_name as string,
logoUrl: (r.logo_url as string) || null,
exhibitsNab: !!r.exhibits_nab,
exhibitsIbc: !!r.exhibits_ibc,
isSponsor: sponsors.has(String(r.company_name || "").toLowerCase()),
href: `/manufacturers/${r.slug}`,
}));
rows.sort((a, b) => (b.isSponsor ? 1 : 0) - (a.isSponsor ? 1 : 0));
return rows;
} catch {
return [];
}
})();
const sb = client();
// Match against title, excerpt, content, AND author name (was just
// title+author). Content match is what surfaces hits when the phrase
@@ -126,8 +166,10 @@ export async function GET(req: Request) {
};
});
const companies = await companiesPromise;
return NextResponse.json(
{ items, query: q },
{ items, companies, query: q },
{
headers: {
"Cache-Control": "public, s-maxage=30, stale-while-revalidate=60",