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:
74
src/app/api/public/companies/[slug]/preview/route.ts
Normal file
74
src/app/api/public/companies/[slug]/preview/route.ts
Normal 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" } },
|
||||
);
|
||||
}
|
||||
@@ -6,6 +6,8 @@ import {
|
||||
getLegacyRecentSlugs,
|
||||
getLegacyRelatedArticles,
|
||||
} from "@/lib/articles/legacy-source";
|
||||
import { linkifyArticleHtml } from "@/lib/company-mentions";
|
||||
import CompanyMentionsHover from "@/components/CompanyMentionsHover";
|
||||
import NewsArticleDetailClient from "./NewsArticleDetailClient";
|
||||
|
||||
// ISR: serve cached pages but revalidate hourly. New imported posts surface
|
||||
@@ -89,6 +91,11 @@ export default async function NewsArticlePage({ params }: PageProps) {
|
||||
redirect("/news");
|
||||
}
|
||||
|
||||
// Auto-link company-name mentions in the article body to /manufacturers/<slug>
|
||||
// with data-company-slug so the hover card can pop a preview.
|
||||
const linkedContent = await linkifyArticleHtml(article.content || "");
|
||||
const articleWithLinks: Article = { ...article, content: linkedContent };
|
||||
|
||||
const articleSchema = {
|
||||
"@context": "https://schema.org",
|
||||
"@type": "NewsArticle",
|
||||
@@ -112,7 +119,8 @@ export default async function NewsArticlePage({ params }: PageProps) {
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(articleSchema) }}
|
||||
/>
|
||||
<NewsArticleDetailClient article={article} relatedArticles={related} />
|
||||
<NewsArticleDetailClient article={articleWithLinks} relatedArticles={related} />
|
||||
<CompanyMentionsHover />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user