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) && (