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:
119
src/components/CompaniesInThisStory.tsx
Normal file
119
src/components/CompaniesInThisStory.tsx
Normal file
@@ -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<CompanyTile[]> {
|
||||
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<string>();
|
||||
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<string, CompanyTile>();
|
||||
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 (
|
||||
<div className="bg-[#111] border border-[#222] p-5">
|
||||
<h3 className="font-body font-bold text-xs text-[#3b82f6] uppercase tracking-widest mb-4 pb-2 border-b border-[#222]">
|
||||
Companies in this story
|
||||
</h3>
|
||||
<ul className="space-y-3">
|
||||
{tiles.map((t) => (
|
||||
<li key={t.slug}>
|
||||
<Link
|
||||
href={`/manufacturers/${t.slug}`}
|
||||
data-company-slug={t.slug}
|
||||
className="flex items-start gap-3 group hover:bg-[#161616] -mx-2 px-2 py-1.5 rounded-sm"
|
||||
>
|
||||
{t.logoUrl ? (
|
||||
/* eslint-disable-next-line @next/next/no-img-element */
|
||||
<img
|
||||
src={t.logoUrl}
|
||||
alt=""
|
||||
className="w-10 h-10 rounded bg-white p-1 object-contain flex-shrink-0"
|
||||
loading="lazy"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-10 h-10 rounded bg-[#222] flex items-center justify-center text-[#666] font-bold text-sm flex-shrink-0">
|
||||
{(t.name || "?").trim().charAt(0).toUpperCase()}
|
||||
</div>
|
||||
)}
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-1 flex-wrap">
|
||||
<span className="font-heading text-[#e0e0e0] text-sm font-bold group-hover:text-[#3b82f6] transition-colors truncate">
|
||||
{t.name}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 flex-wrap mt-0.5 text-[9px] uppercase tracking-wider font-semibold">
|
||||
{t.isActiveAdvertiser && (
|
||||
<span className="bg-emerald-500/15 text-emerald-300 px-1.5 py-0.5 rounded">
|
||||
Sponsor
|
||||
</span>
|
||||
)}
|
||||
{t.exhibitsNab && (
|
||||
<span className="bg-amber-500/15 text-amber-300 px-1.5 py-0.5 rounded">
|
||||
NAB 26
|
||||
</span>
|
||||
)}
|
||||
{t.exhibitsIbc && (
|
||||
<span className="bg-blue-500/15 text-blue-300 px-1.5 py-0.5 rounded">
|
||||
IBC
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{t.bioShort && (
|
||||
<p className="text-[#888] text-[11px] leading-snug mt-1 line-clamp-2">
|
||||
{t.bioShort}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user