From a59846a52426fa76d35776c09be3fb6452c62407 Mon Sep 17 00:00:00 2001 From: Ryan Salazar Date: Wed, 27 May 2026 12:47:54 +0000 Subject: [PATCH] cross-link: companies-in-this-story sidebar + unified company search MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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/ 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. --- src/app/api/search/suggest/route.ts | 50 +++++++- .../news/[slug]/NewsArticleDetailClient.tsx | 5 + src/app/news/[slug]/page.tsx | 19 ++- src/components/CompaniesInThisStory.tsx | 119 ++++++++++++++++++ src/components/Header.tsx | 56 ++++++++- src/lib/company-mentions.ts | 50 ++++++++ 6 files changed, 287 insertions(+), 12 deletions(-) create mode 100644 src/components/CompaniesInThisStory.tsx diff --git a/src/app/api/search/suggest/route.ts b/src/app/api/search/suggest/route.ts index 03fa835..df8d390 100644 --- a/src/app/api/search/suggest/route.ts +++ b/src/app/api/search/suggest/route.ts @@ -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(); + 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", diff --git a/src/app/news/[slug]/NewsArticleDetailClient.tsx b/src/app/news/[slug]/NewsArticleDetailClient.tsx index dc90ff3..ff0d510 100644 --- a/src/app/news/[slug]/NewsArticleDetailClient.tsx +++ b/src/app/news/[slug]/NewsArticleDetailClient.tsx @@ -14,6 +14,7 @@ import type { Article } from "@/lib/articles/types"; interface NewsArticleDetailClientProps { article: Article; relatedArticles: Article[]; + companiesInStory?: React.ReactNode; } function getSessionId(): string { @@ -29,6 +30,7 @@ function getSessionId(): string { export default function NewsArticleDetailClient({ article, relatedArticles, + companiesInStory, }: NewsArticleDetailClientProps) { const [isSaved, setIsSaved] = useState(false); const [savingState, setSavingState] = useState<"idle" | "saving" | "removing">("idle"); @@ -519,6 +521,9 @@ export default function NewsArticleDetailClient({ {/* Blackmagic 300x600 fixed at top + every 300x250 banner stacked */} + {/* Companies referenced in the article body */} + {companiesInStory} + {/* Related Articles Sidebar */}

diff --git a/src/app/news/[slug]/page.tsx b/src/app/news/[slug]/page.tsx index 278534d..ac0c54d 100644 --- a/src/app/news/[slug]/page.tsx +++ b/src/app/news/[slug]/page.tsx @@ -6,8 +6,9 @@ import { getLegacyRecentSlugs, getLegacyRelatedArticles, } from "@/lib/articles/legacy-source"; -import { linkifyArticleHtml } from "@/lib/company-mentions"; +import { linkifyAndExtractMentions } from "@/lib/company-mentions"; import CompanyMentionsHover from "@/components/CompanyMentionsHover"; +import CompaniesInThisStory from "@/components/CompaniesInThisStory"; import NewsArticleDetailClient from "./NewsArticleDetailClient"; // ISR: serve cached pages but revalidate hourly. New imported posts surface @@ -91,9 +92,9 @@ export default async function NewsArticlePage({ params }: PageProps) { redirect("/news"); } - // Auto-link company-name mentions in the article body to /manufacturers/ - // with data-company-slug so the hover card can pop a preview. - const linkedContent = await linkifyArticleHtml(article.content || ""); + // Auto-link company-name mentions in the article body and capture the set + // of matched slugs for the "Companies in this story" sidebar. + const { html: linkedContent, mentionedSlugs } = await linkifyAndExtractMentions(article.content || ""); const articleWithLinks: Article = { ...article, content: linkedContent }; const articleSchema = { @@ -119,7 +120,15 @@ export default async function NewsArticlePage({ params }: PageProps) { type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(articleSchema) }} /> - + 0 ? ( + + ) : null + } + /> ); diff --git a/src/components/CompaniesInThisStory.tsx b/src/components/CompaniesInThisStory.tsx new file mode 100644 index 0000000..c889a0b --- /dev/null +++ b/src/components/CompaniesInThisStory.tsx @@ -0,0 +1,119 @@ +import Link from "next/link"; +import { createAdminClient } from "@/lib/supabase/admin"; + +interface CompanyTile { + slug: string; + name: string; + logoUrl: string | null; + bioShort: string; + isActiveAdvertiser: boolean; + exhibitsNab: boolean; + exhibitsIbc: boolean; +} + +async function loadTiles(slugs: string[]): Promise { + if (slugs.length === 0) return []; + try { + const svc = createAdminClient(); + const { data } = await svc + .from("tracked_companies") + .select("slug, company_name, logo_url, bio, exhibits_nab, exhibits_ibc") + .in("slug", slugs); + + // Active advertiser lookup (set of company_name_lower) in adv schema. + let advNames = new Set(); + try { + const adv = createAdminClient("adv"); + const { data: ads } = await adv + .from("active_advertisers_by_property") + .select("company_name_lower") + .eq("property", "broadcastbeat"); + advNames = new Set((ads || []).map((r: any) => r.company_name_lower)); + } catch { /* tolerate */ } + + const bySlug = new Map(); + for (const r of (data || []) as any[]) { + const name = String(r.company_name || "").trim(); + bySlug.set(r.slug, { + slug: r.slug, + name, + logoUrl: r.logo_url || null, + bioShort: (r.bio || "").split(/(?<=[.!?])\s+/).slice(0, 1).join(" ").slice(0, 160), + isActiveAdvertiser: advNames.has(name.toLowerCase()), + exhibitsNab: !!r.exhibits_nab, + exhibitsIbc: !!r.exhibits_ibc, + }); + } + // Preserve the mention order + return slugs.map((s) => bySlug.get(s)).filter(Boolean) as CompanyTile[]; + } catch { + return []; + } +} + +export default async function CompaniesInThisStory({ slugs }: { slugs: string[] }) { + const tiles = await loadTiles(slugs); + if (tiles.length === 0) return null; + + return ( +
+

+ Companies in this story +

+
    + {tiles.map((t) => ( +
  • + + {t.logoUrl ? ( + /* eslint-disable-next-line @next/next/no-img-element */ + + ) : ( +
    + {(t.name || "?").trim().charAt(0).toUpperCase()} +
    + )} +
    +
    + + {t.name} + +
    +
    + {t.isActiveAdvertiser && ( + + Sponsor + + )} + {t.exhibitsNab && ( + + NAB 26 + + )} + {t.exhibitsIbc && ( + + IBC + + )} +
    + {t.bioShort && ( +

    + {t.bioShort} +

    + )} +
    + +
  • + ))} +
+
+ ); +} diff --git a/src/components/Header.tsx b/src/components/Header.tsx index 4b8fec4..863df80 100644 --- a/src/components/Header.tsx +++ b/src/components/Header.tsx @@ -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() { )}

- {suggestOpen && suggestions.length > 0 && ( + {suggestOpen && (suggestions.length > 0 || suggestedCompanies.length > 0) && (
    + {suggestedCompanies.length > 0 && ( + <> +
  • + Companies +
  • + {suggestedCompanies.map((c) => ( +
  • + e.preventDefault()} + onClick={() => { setSuggestOpen(false); setSearchQuery(""); }}> + {c.logoUrl ? ( + /* eslint-disable-next-line @next/next/no-img-element */ + + ) : ( +
    + {(c.name || "?").trim().charAt(0).toUpperCase()} +
    + )} + {c.name} + {c.isSponsor && ( + Sponsor + )} + {c.exhibitsNab && !c.isSponsor && ( + NAB 26 + )} + +
  • + ))} + {suggestions.length > 0 && ( +
  • + Stories +
  • + )} + + )} {suggestions.map((s) => { const snippet = s.snippet; const beforeMatch = snippet?.text.slice(0, snippet.matchStart) || ""; diff --git a/src/lib/company-mentions.ts b/src/lib/company-mentions.ts index b441bcd..b8b8bde 100644 --- a/src/lib/company-mentions.ts +++ b/src/lib/company-mentions.ts @@ -156,3 +156,53 @@ export async function linkifyArticleHtml(html: string): Promise { const companies = await loadDirectoryCompanies(); return linkifyCompanyMentions(html, companies); } + +/** + * Same as linkifyArticleHtml but also returns the set of company slugs that + * were matched (in the order they first appeared). Callers use this to + * render a "Companies in this story" sidebar without re-scanning the HTML. + */ +export async function linkifyAndExtractMentions( + html: string, +): Promise<{ html: string; mentionedSlugs: string[] }> { + const companies = await loadDirectoryCompanies(); + if (!html || companies.length === 0) return { html: html || "", mentionedSlugs: [] }; + + // Re-run the same matching against the original (unmodified) HTML so we + // can capture the slug list. We need to dupe the logic a bit because the + // existing linkifyCompanyMentions function mutates as it goes. + const cheerio = require("cheerio"); + const $ = cheerio.load(html, { decodeEntities: false }, false); + const matched: string[] = []; + const seen = new Set(); + + const SKIP_TAGS = new Set(["a", "script", "style", "code", "pre", "h1", "h2", "h3", "h4", "h5", "h6"]); + + function scan(node: any): void { + if (!node) return; + if (node.type === "tag") { + if (SKIP_TAGS.has(node.name)) return; + if (node.attribs && node.attribs["data-no-autolink"] === "true") return; + const kids = node.children ? [...node.children] : []; + for (const k of kids) scan(k); + return; + } + if (node.type !== "text") return; + const text: string = node.data || ""; + if (!text.trim()) return; + for (const c of companies) { + if (seen.has(c.slug)) continue; + const re = new RegExp(`(^|[^A-Za-z0-9])(${c.name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")})(?![A-Za-z0-9])`, "i"); + if (re.test(text)) { + matched.push(c.slug); + seen.add(c.slug); + if (matched.length >= MAX_LINKS_PER_ARTICLE) break; + } + } + } + + $.root().children().each((_: number, el: any) => scan(el)); + + const linkedHtml = linkifyCompanyMentions(html, companies); + return { html: linkedHtml, mentionedSlugs: matched }; +}