articles: auto-link company mentions to manufacturer directory

When an article body is rendered, scan it for any company name in
bb.tracked_companies (directory_visible=true) and wrap the first
mention of each company in
  <a class="company-mention" data-company-slug="...">Name</a>
linking to /manufacturers/<slug>. Caps at 12 mentions per article so
heavy press-release lists don't get visually noisy.

Match rules:
- Word-boundary, case-insensitive
- Skips text already inside <a>, <script>, <style>, code/pre/headings
- Skips any element with data-no-autolink="true"
- Longest name wins on overlap (Sony Pictures beats Sony)
- One link per company per article (first occurrence)

Hover behavior: a new CompanyMentionsHover client component mounts once
per article page, listens globally for [data-company-slug] hovers, and
pops a floating card showing logo, name, 2-sentence bio, sponsor/NAB/IBC
badges, HQ city, 3 recent stories, and links to the full profile +
external website. Preview data fetched from new endpoint
  /api/public/companies/[slug]/preview
with a client-side cache keyed by slug.

Includes non-advertisers — any visible directory entry gets linked,
which is the point. If a company is *also* a current banner advertiser
on broadcastbeat, the hover card adds a green "Sponsor" badge.

Styling: dotted underline that becomes solid on hover, slightly bolder
weight, accent color on hover. Subtle so multi-mention paragraphs don't
look like a link-soup.
This commit is contained in:
Ryan Salazar
2026-05-27 12:32:14 +00:00
parent c1ab36297c
commit b479b62331
5 changed files with 432 additions and 1 deletions

View File

@@ -0,0 +1,74 @@
import { NextResponse } from "next/server";
import { createAdminClient } from "@/lib/supabase/admin";
export const revalidate = 300;
export async function GET(_req: Request, { params }: { params: Promise<{ slug: string }> }) {
const { slug } = await params;
if (!slug) return NextResponse.json({ error: "slug required" }, { status: 400 });
const svc = createAdminClient();
const { data: company, error } = await svc
.from("tracked_companies")
.select("id, slug, company_name, bio, logo_url, company_website, hq_city, hq_country, exhibits_nab, exhibits_ibc, featured, categories")
.eq("slug", slug)
.eq("directory_visible", true)
.maybeSingle();
if (error || !company) return NextResponse.json({ error: "not found" }, { status: 404 });
// Recent stories: title contains company name. Cap at 5, newest first.
const needle = String(company.company_name || "").trim();
let stories: any[] = [];
if (needle.length >= 3) {
const { data } = await svc
.from("wp_imported_posts")
.select("wp_slug, title, wp_published_at, featured_image")
.eq("status", "published")
.ilike("title", `%${needle.replace(/[%_]/g, "")}%`)
.order("wp_published_at", { ascending: false, nullsFirst: false })
.limit(5);
stories = (data || []).map((r: any) => ({
slug: r.wp_slug,
title: r.title,
date: r.wp_published_at,
image: r.featured_image,
}));
}
// Also check if they're a current advertiser on BB.
let isActiveAdvertiser = false;
try {
const advSvc = createAdminClient("adv");
const { count } = await advSvc
.from("active_advertisers_by_property")
.select("client_id", { count: "exact", head: true })
.eq("property", "broadcastbeat")
.eq("company_name_lower", needle.toLowerCase());
isActiveAdvertiser = (count || 0) > 0;
} catch { /* tolerate adv lookup failures */ }
// Trim bio to a couple sentences max for the popover.
const bioShort = (company.bio || "")
.split(/(?<=[.!?])\s+/)
.slice(0, 2)
.join(" ")
.slice(0, 320);
return NextResponse.json(
{
slug: company.slug,
name: company.company_name,
bio: bioShort,
logoUrl: company.logo_url,
website: company.company_website,
hq: [company.hq_city, company.hq_country].filter(Boolean).join(", "),
featured: !!company.featured,
exhibitsNab: !!company.exhibits_nab,
exhibitsIbc: !!company.exhibits_ibc,
categories: Array.isArray(company.categories) ? company.categories.slice(0, 4) : [],
isActiveAdvertiser,
stories,
},
{ headers: { "Cache-Control": "public, s-maxage=300, stale-while-revalidate=600" } },
);
}