- Standardize "NAB Show" / "2026 NAB Show" across badges, filter chips, and headings; never bare "NAB" in user-facing copy. - /manufacturers/[slug]: normalize show name at render, strip randomstring suffix on booth, hide booth unless the show is within 90 days, filter single-letter junk out of category display. - News filter "NAB Show" chip loose-matches any nab-tagged article so legacy "NAB 2026" tags still surface. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
173 lines
7.4 KiB
TypeScript
173 lines
7.4 KiB
TypeScript
"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<string, Promise<Preview | null>>();
|
|
|
|
async function fetchPreview(slug: string): Promise<Preview | null> {
|
|
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<HTMLElement | null>(null);
|
|
const [preview, setPreview] = useState<Preview | null>(null);
|
|
const [loading, setLoading] = useState(false);
|
|
const [pos, setPos] = useState<{ top: number; left: number; align: "left" | "right" } | null>(null);
|
|
const hideTimer = useRef<number | null>(null);
|
|
const showTimer = useRef<number | null>(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 (
|
|
<div
|
|
data-company-card="true"
|
|
role="dialog"
|
|
aria-label={preview?.name ? `${preview.name} preview` : "Company preview"}
|
|
style={{ position: "absolute", top: pos.top, left: pos.left, width: 360, zIndex: 999 }}
|
|
className="bg-white text-ink border border-ink/10 rounded-xl shadow-2xl p-4 text-sm"
|
|
onMouseEnter={() => { if (hideTimer.current) { window.clearTimeout(hideTimer.current); hideTimer.current = null; } }}
|
|
onMouseLeave={() => { hideTimer.current = window.setTimeout(() => { setAnchor(null); setPreview(null); setPos(null); }, 180); }}
|
|
>
|
|
{loading || !preview ? (
|
|
<div className="text-ink/50 text-xs py-2">Loading…</div>
|
|
) : (
|
|
<>
|
|
<div className="flex items-start gap-3 mb-2">
|
|
{preview.logoUrl ? (
|
|
/* eslint-disable-next-line @next/next/no-img-element */
|
|
<img src={preview.logoUrl} alt="" className="w-12 h-12 rounded bg-ink/5 object-contain flex-shrink-0" loading="lazy" />
|
|
) : (
|
|
<div className="w-12 h-12 rounded bg-ink/5 flex items-center justify-center text-ink/40 font-bold flex-shrink-0">
|
|
{(preview.name || "?").trim().charAt(0).toUpperCase()}
|
|
</div>
|
|
)}
|
|
<div className="min-w-0 flex-1">
|
|
<Link href={`/manufacturers/${preview.slug}`} className="font-semibold text-ink hover:text-brand-primary block truncate">
|
|
{preview.name}
|
|
</Link>
|
|
<div className="flex items-center gap-1.5 mt-0.5 flex-wrap text-[10px]">
|
|
{preview.isActiveAdvertiser && (
|
|
<span className="bg-emerald-50 text-emerald-700 px-1.5 py-0.5 rounded font-semibold uppercase tracking-wider">Sponsor</span>
|
|
)}
|
|
{preview.exhibitsNab && (
|
|
<span className="bg-amber-50 text-amber-700 px-1.5 py-0.5 rounded font-semibold uppercase tracking-wider">2026 NAB Show</span>
|
|
)}
|
|
{preview.exhibitsIbc && (
|
|
<span className="bg-blue-50 text-blue-700 px-1.5 py-0.5 rounded font-semibold uppercase tracking-wider">IBC 2026</span>
|
|
)}
|
|
{preview.hq && <span className="text-ink/60">{preview.hq}</span>}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{preview.bio && (
|
|
<p className="text-ink/70 text-xs leading-snug mb-3 line-clamp-4">{preview.bio}</p>
|
|
)}
|
|
|
|
{preview.stories.length > 0 && (
|
|
<div className="border-t border-ink/10 pt-2">
|
|
<div className="text-[10px] uppercase tracking-widest text-ink/50 font-bold mb-1.5">Recent coverage</div>
|
|
<ul className="space-y-1">
|
|
{preview.stories.slice(0, 3).map((s) => (
|
|
<li key={s.slug}>
|
|
<Link href={`/news/${s.slug}`} className="text-xs text-brand-primary hover:underline line-clamp-2">
|
|
{s.title}
|
|
</Link>
|
|
{s.date && <span className="text-[10px] text-ink/40 ml-1">· {new Date(s.date).toISOString().slice(0, 10)}</span>}
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</div>
|
|
)}
|
|
|
|
<div className="border-t border-ink/10 mt-3 pt-2 flex items-center justify-between">
|
|
<Link href={`/manufacturers/${preview.slug}`} className="text-xs font-semibold text-brand-primary hover:underline">
|
|
Full profile →
|
|
</Link>
|
|
{preview.website && (
|
|
<a href={preview.website} target="_blank" rel="noopener noreferrer" className="text-[10px] text-ink/50 hover:text-brand-primary">
|
|
{preview.website.replace(/^https?:\/\//, "").replace(/\/$/, "")} ↗
|
|
</a>
|
|
)}
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|