diff --git a/src/app/api/public/companies/[slug]/preview/route.ts b/src/app/api/public/companies/[slug]/preview/route.ts new file mode 100644 index 0000000..6d5381f --- /dev/null +++ b/src/app/api/public/companies/[slug]/preview/route.ts @@ -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" } }, + ); +} diff --git a/src/app/news/[slug]/page.tsx b/src/app/news/[slug]/page.tsx index 098e1bd..278534d 100644 --- a/src/app/news/[slug]/page.tsx +++ b/src/app/news/[slug]/page.tsx @@ -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/ + // 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) }} /> - + + ); } diff --git a/src/components/CompanyMentionsHover.tsx b/src/components/CompanyMentionsHover.tsx new file mode 100644 index 0000000..cd13b14 --- /dev/null +++ b/src/components/CompanyMentionsHover.tsx @@ -0,0 +1,172 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; +import Link from "next/link"; + +type Preview = { + slug: string; + name: string; + bio: string; + logoUrl: string | null; + website: string | null; + hq: string; + featured: boolean; + exhibitsNab: boolean; + exhibitsIbc: boolean; + categories: string[]; + isActiveAdvertiser: boolean; + stories: { slug: string; title: string; date: string | null; image: string | null }[]; +}; + +const cache = new Map>(); + +async function fetchPreview(slug: string): Promise { + if (cache.has(slug)) return cache.get(slug)!; + const p = (async () => { + try { + const r = await fetch(`/api/public/companies/${encodeURIComponent(slug)}/preview`); + if (!r.ok) return null; + return (await r.json()) as Preview; + } catch { + return null; + } + })(); + cache.set(slug, p); + return p; +} + +/** + * Mount once per page. Listens for hover/focus on any [data-company-slug] + * element, fetches preview data, and pops a floating card. + */ +export default function CompanyMentionsHover() { + const [anchor, setAnchor] = useState(null); + const [preview, setPreview] = useState(null); + const [loading, setLoading] = useState(false); + const [pos, setPos] = useState<{ top: number; left: number; align: "left" | "right" } | null>(null); + const hideTimer = useRef(null); + const showTimer = useRef(null); + + useEffect(() => { + function onEnter(e: MouseEvent | FocusEvent) { + const t = (e.target as HTMLElement | null)?.closest?.("[data-company-slug]") as HTMLElement | null; + if (!t) return; + if (hideTimer.current) { window.clearTimeout(hideTimer.current); hideTimer.current = null; } + if (showTimer.current) window.clearTimeout(showTimer.current); + showTimer.current = window.setTimeout(async () => { + const slug = t.getAttribute("data-company-slug"); + if (!slug) return; + setAnchor(t); + setLoading(true); + const data = await fetchPreview(slug); + setLoading(false); + if (!data) { setAnchor(null); return; } + setPreview(data); + const rect = t.getBoundingClientRect(); + const cardWidth = 360; + const fitsRight = rect.left + cardWidth < window.innerWidth - 12; + setPos({ + top: rect.bottom + window.scrollY + 6, + left: fitsRight ? rect.left + window.scrollX : Math.max(12, rect.right + window.scrollX - cardWidth), + align: fitsRight ? "left" : "right", + }); + }, 220); + } + function onLeave(e: MouseEvent | FocusEvent) { + const related = (e as MouseEvent).relatedTarget as HTMLElement | null; + if (related && (related.closest?.("[data-company-card]") || related.closest?.("[data-company-slug]"))) return; + if (showTimer.current) { window.clearTimeout(showTimer.current); showTimer.current = null; } + if (hideTimer.current) window.clearTimeout(hideTimer.current); + hideTimer.current = window.setTimeout(() => { setAnchor(null); setPreview(null); setPos(null); }, 180); + } + document.addEventListener("mouseover", onEnter, true); + document.addEventListener("mouseout", onLeave, true); + document.addEventListener("focusin", onEnter, true); + document.addEventListener("focusout", onLeave, true); + return () => { + document.removeEventListener("mouseover", onEnter, true); + document.removeEventListener("mouseout", onLeave, true); + document.removeEventListener("focusin", onEnter, true); + document.removeEventListener("focusout", onLeave, true); + }; + }, []); + + if (!anchor || !pos) return null; + + return ( +
{ if (hideTimer.current) { window.clearTimeout(hideTimer.current); hideTimer.current = null; } }} + onMouseLeave={() => { hideTimer.current = window.setTimeout(() => { setAnchor(null); setPreview(null); setPos(null); }, 180); }} + > + {loading || !preview ? ( +
Loading…
+ ) : ( + <> +
+ {preview.logoUrl ? ( + /* eslint-disable-next-line @next/next/no-img-element */ + + ) : ( +
+ {(preview.name || "?").trim().charAt(0).toUpperCase()} +
+ )} +
+ + {preview.name} + +
+ {preview.isActiveAdvertiser && ( + Sponsor + )} + {preview.exhibitsNab && ( + NAB 26 + )} + {preview.exhibitsIbc && ( + IBC + )} + {preview.hq && {preview.hq}} +
+
+
+ + {preview.bio && ( +

{preview.bio}

+ )} + + {preview.stories.length > 0 && ( +
+
Recent coverage
+
    + {preview.stories.slice(0, 3).map((s) => ( +
  • + + {s.title} + + {s.date && · {new Date(s.date).toISOString().slice(0, 10)}} +
  • + ))} +
+
+ )} + +
+ + Full profile → + + {preview.website && ( + + {preview.website.replace(/^https?:\/\//, "").replace(/\/$/, "")} ↗ + + )} +
+ + )} +
+ ); +} diff --git a/src/lib/company-mentions.ts b/src/lib/company-mentions.ts new file mode 100644 index 0000000..b441bcd --- /dev/null +++ b/src/lib/company-mentions.ts @@ -0,0 +1,158 @@ +/** + * Auto-link company names in article body HTML to their manufacturer + * directory profiles. Mentions are wrapped in + * Name + * so a client-side hover-card component can pop a preview on hover. + * + * Linking respects the directory_visible flag, only links the FIRST + * occurrence per company per article (to avoid clutter), and never + * double-wraps existing tags. + */ + +import * as cheerio from "cheerio"; +import { createAdminClient } from "./supabase/admin"; + +export type DirectoryCompany = { + name: string; + slug: string; + /** lower-case name used for matching */ + needle: string; +}; + +const MAX_LINKS_PER_ARTICLE = 12; + +let cached: { rows: DirectoryCompany[]; expires: number } | null = null; +const CACHE_TTL_MS = 5 * 60 * 1000; // 5 min — companies barely change + +export async function loadDirectoryCompanies(): Promise { + if (cached && cached.expires > Date.now()) return cached.rows; + try { + const svc = createAdminClient(); // bb schema default + const { data } = await svc + .from("tracked_companies") + .select("company_name, slug") + .eq("directory_visible", true) + .not("slug", "is", null) + .not("company_name", "is", null); + const rows: DirectoryCompany[] = (data || []) + .filter((r: any) => r.company_name && r.slug) + .map((r: any) => ({ + name: String(r.company_name).trim(), + slug: String(r.slug), + needle: String(r.company_name).trim().toLowerCase(), + })) + .filter((r) => r.needle.length >= 3); // skip 2-char names — too noisy + // Sort by longest name first so "Sony Pictures" wins over "Sony" + rows.sort((a, b) => b.needle.length - a.needle.length); + cached = { rows, expires: Date.now() + CACHE_TTL_MS }; + return rows; + } catch { + return cached?.rows || []; + } +} + +function escapeRegex(s: string): string { + return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +/** + * Walk text nodes of the parsed HTML and replace the first occurrence of + * each company name with a link. Skips text inside ,