Manufacturers directory: fix light-theme text contrast and filter bar

This commit is contained in:
2026-06-25 16:22:40 +00:00
parent 64600c4f28
commit 3f23447af7

View File

@@ -1,21 +1,21 @@
"use client"; "use client";
import { useMemo, useState } from "react"; import { useMemo, useState } from "react";
import Link from "next/link"; import Link from "next/link";
type Manufacturer = { type Manufacturer = {
slug: string; slug: string;
company_name: string; company_name: string;
bio: string | null; bio: string | null;
logo_url: string | null; logo_url: string | null;
categories: string[] | null; categories: string[] | null;
exhibits_nab: boolean; exhibits_nab: boolean;
exhibits_ibc: boolean; exhibits_ibc: boolean;
shows_attending: string[] | null; shows_attending: string[] | null;
mention_count: number; mention_count: number;
featured: boolean; featured: boolean;
}; };
type ShowOption = { type ShowOption = {
slug: string; slug: string;
short_name: string | null; short_name: string | null;
@@ -25,184 +25,192 @@ type ShowOption = {
status: string; status: string;
}; };
export default function ManufacturerDirectoryClient({ const LOGO_OVERRIDES: Record<string, string> = {
manufacturers, "blackmagic-design": "/img/partners/blackmagic-design.png",
shows = [], };
}: {
manufacturers: Manufacturer[];
shows?: ShowOption[];
}) {
const [q, setQ] = useState("");
// showFilter is now a show slug from bb.events (e.g. 'nab-show-2026') or
// 'all' / 'both-major' (NAB+IBC overlap).
const [showFilter, setShowFilter] = useState<string>("all");
const [letter, setLetter] = useState<string>("All");
// Per-show exhibitor counts for badge display + auto-hiding empty shows. function resolveLogoUrl(slug: string, logoUrl: string | null): string | null {
const showCounts = useMemo(() => { return LOGO_OVERRIDES[slug] || logoUrl || null;
const counts: Record<string, number> = {}; }
for (const m of manufacturers) {
for (const s of m.shows_attending || []) { export default function ManufacturerDirectoryClient({
counts[s] = (counts[s] || 0) + 1; manufacturers,
} shows = [],
} }: {
return counts; manufacturers: Manufacturer[];
}, [manufacturers]); shows?: ShowOption[];
}) {
const filtered = useMemo(() => { const [q, setQ] = useState("");
const needle = q.trim().toLowerCase(); // showFilter is now a show slug from bb.events (e.g. 'nab-show-2026') or
return manufacturers.filter((m) => { // 'all' / 'both-major' (NAB+IBC overlap).
if (needle) { const [showFilter, setShowFilter] = useState<string>("all");
const hay = `${m.company_name} ${m.bio || ""}`.toLowerCase(); const [letter, setLetter] = useState<string>("All");
if (!hay.includes(needle)) return false;
} // Per-show exhibitor counts for badge display + auto-hiding empty shows.
if (showFilter === "both-major") { const showCounts = useMemo(() => {
if (!(m.exhibits_nab && m.exhibits_ibc)) return false; const counts: Record<string, number> = {};
} else if (showFilter !== "all") { for (const m of manufacturers) {
if (!(m.shows_attending || []).includes(showFilter)) return false; for (const s of m.shows_attending || []) {
} counts[s] = (counts[s] || 0) + 1;
if (letter !== "All") { }
const c = (m.company_name || "").trim().charAt(0).toUpperCase(); }
if (letter === "#") { return counts;
if (/^[A-Z]/.test(c)) return false; }, [manufacturers]);
} else if (c !== letter) {
return false; const filtered = useMemo(() => {
} const needle = q.trim().toLowerCase();
} return manufacturers.filter((m) => {
return true; if (needle) {
}); const hay = `${m.company_name} ${m.bio || ""}`.toLowerCase();
}, [manufacturers, q, showFilter, letter]); if (!hay.includes(needle)) return false;
}
const letters = "ABCDEFGHIJKLMNOPQRSTUVWXYZ".split(""); if (showFilter === "both-major") {
if (!(m.exhibits_nab && m.exhibits_ibc)) return false;
return ( } else if (showFilter !== "all") {
<div> if (!(m.shows_attending || []).includes(showFilter)) return false;
<div className="bg-[#111] border border-[#DCE6F2] rounded-xl p-4 mb-6 flex flex-col gap-3 md:flex-row md:items-center md:gap-4"> }
if (letter !== "All") {
const c = (m.company_name || "").trim().charAt(0).toUpperCase();
if (letter === "#") {
if (/^[A-Z]/.test(c)) return false;
} else if (c !== letter) {
return false;
}
}
return true;
});
}, [manufacturers, q, showFilter, letter]);
const letters = "ABCDEFGHIJKLMNOPQRSTUVWXYZ".split("");
return (
<div>
<div className="bg-white border border-[#DCE6F2] rounded-xl p-4 mb-6 flex flex-col gap-3 md:flex-row md:items-center md:gap-4">
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<input <input
type="search" type="search"
value={q} value={q}
onChange={(e) => setQ(e.target.value)} onChange={(e) => setQ(e.target.value)}
placeholder="Search by company or product…" placeholder="Search by company or product…"
className="w-full bg-[#F8FAFC] border border-[#DCE6F2] rounded-md px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-[#1D4ED8]/40" className="w-full bg-[#F8FAFC] border border-[#DCE6F2] rounded-md px-3 py-2 text-sm text-[#0F172A] placeholder:text-[#94A3B8] focus:outline-none focus:ring-2 focus:ring-[#1D4ED8]/40"
/> />
</div> </div>
</div> </div>
{/* Per-show filter chips. Every event from bb.events shows up; ones {/* Per-show filter chips. Every event from bb.events shows up; ones
without exhibitor mappings yet render a "(soon)" badge so the without exhibitor mappings yet render a "(soon)" badge so the
admin can see what's still missing data. */} admin can see what's still missing data. */}
<div className="flex flex-wrap gap-1.5 mb-4"> <div className="flex flex-wrap gap-1.5 mb-4">
<button <button
type="button" type="button"
onClick={() => setShowFilter("all")} onClick={() => setShowFilter("all")}
className={`px-3 py-1.5 rounded text-xs font-semibold ${ className={`px-3 py-1.5 rounded text-xs font-semibold ${
showFilter === "all" ? "bg-[#1D4ED8] text-white" : "bg-[#F8FAFC] border border-[#DCE6F2] text-[#aaa] hover:bg-[#FFFFFF]" showFilter === "all" ? "bg-[#1D4ED8] text-white" : "bg-white border border-[#DCE6F2] text-[#475569] hover:bg-[#F8FAFC]"
}`} }`}
> >
All shows <span className="ml-1 opacity-60">({manufacturers.length.toLocaleString()})</span> All shows <span className="ml-1 opacity-60">({manufacturers.length.toLocaleString()})</span>
</button> </button>
{shows.map((s) => { {shows.map((s) => {
const count = showCounts[s.slug] || 0; const count = showCounts[s.slug] || 0;
const active = showFilter === s.slug; const active = showFilter === s.slug;
const label = s.short_name || s.name; const label = s.short_name || s.name;
return ( return (
<button <button
key={s.slug} key={s.slug}
type="button" type="button"
onClick={() => setShowFilter(s.slug)} onClick={() => setShowFilter(s.slug)}
className={`px-3 py-1.5 rounded text-xs font-semibold ${ className={`px-3 py-1.5 rounded text-xs font-semibold ${
active ? "bg-[#1D4ED8] text-white" : "bg-[#F8FAFC] border border-[#DCE6F2] text-[#aaa] hover:bg-[#FFFFFF]" active ? "bg-[#1D4ED8] text-white" : "bg-white border border-[#DCE6F2] text-[#475569] hover:bg-[#F8FAFC]"
}`} }`}
title={`${s.name} · ${s.start_date}${count === 0 ? " · exhibitor list coming soon" : ""}`} title={`${s.name} · ${s.start_date}${count === 0 ? " · exhibitor list coming soon" : ""}`}
> >
{label} {label}
{count > 0 ? ( {count > 0 ? (
<span className="ml-1 opacity-60">({count})</span> <span className="ml-1 opacity-60">({count})</span>
) : ( ) : (
<span className="ml-1 text-amber-400 text-[10px]">soon</span> <span className="ml-1 text-amber-700 text-[10px]">soon</span>
)} )}
</button> </button>
); );
})} })}
</div> </div>
<nav className="flex flex-wrap gap-1 mb-6 text-xs"> <nav className="flex flex-wrap gap-1 mb-6 text-xs">
{(["All", "#", ...letters] as const).map((l) => ( {(["All", "#", ...letters] as const).map((l) => (
<button <button
key={l} key={l}
type="button" type="button"
onClick={() => setLetter(l)} onClick={() => setLetter(l)}
className={`min-w-[28px] h-7 px-1.5 rounded font-semibold ${ className={`min-w-[28px] h-7 px-1.5 rounded font-semibold ${
letter === l letter === l
? "bg-[#1D4ED8] text-white" ? "bg-[#1D4ED8] text-white"
: "bg-[#F8FAFC] border border-[#DCE6F2] text-[#aaa] hover:bg-[#FFFFFF]" : "bg-white border border-[#DCE6F2] text-[#475569] hover:bg-[#F8FAFC]"
}`} }`}
> >
{l} {l}
</button> </button>
))} ))}
</nav> </nav>
<p className="text-xs text-[#888] mb-3"> <p className="text-xs text-[#888] mb-3">
Showing {filtered.length.toLocaleString()} of {manufacturers.length.toLocaleString()} manufacturers Showing {filtered.length.toLocaleString()} of {manufacturers.length.toLocaleString()} manufacturers
</p> </p>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4"> <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{filtered.map((m) => ( {filtered.map((m) => (
<Link <Link
key={m.slug} key={m.slug}
href={`/manufacturers/${m.slug}`} href={`/manufacturers/${m.slug}`}
className="group bg-[#F8FAFC] border border-[#DCE6F2] rounded-xl p-4 hover:border-[#1D4ED8] hover:shadow-sm transition-all flex gap-3" className="group bg-white border border-[#DCE6F2] rounded-xl p-4 hover:border-[#1D4ED8] hover:shadow-sm transition-all flex gap-3"
> >
<div className="w-14 h-14 rounded-lg bg-[#FFFFFF] flex items-center justify-center overflow-hidden flex-shrink-0"> <div className="w-14 h-14 rounded-lg bg-[#FFFFFF] flex items-center justify-center overflow-hidden flex-shrink-0">
{m.logo_url ? ( {resolveLogoUrl(m.slug, m.logo_url) ? (
/* eslint-disable-next-line @next/next/no-img-element */ /* eslint-disable-next-line @next/next/no-img-element */
<img <img
src={m.logo_url} src={resolveLogoUrl(m.slug, m.logo_url)!}
alt={`${m.company_name} logo`} alt={`${m.company_name} logo`}
className="w-full h-full object-contain" className="w-full h-full object-contain p-1"
loading="lazy" loading="lazy"
/> />
) : ( ) : (
<span className="text-[#666] text-lg font-semibold"> <span className="text-[#666] text-lg font-semibold">
{(m.company_name || "?").trim().charAt(0).toUpperCase()} {(m.company_name || "?").trim().charAt(0).toUpperCase()}
</span> </span>
)} )}
</div> </div>
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<h3 className="font-semibold text-[#e0e0e0] truncate group-hover:text-[#1D4ED8] transition-colors"> <h3 className="font-semibold text-[#0F172A] truncate group-hover:text-[#1D4ED8] transition-colors">
{m.company_name} {m.company_name}
</h3> </h3>
<div className="flex items-center gap-1.5 mt-1 mb-1.5 flex-wrap"> <div className="flex items-center gap-1.5 mt-1 mb-1.5 flex-wrap">
{(m.shows_attending || []).slice(0, 3).map((slug) => { {(m.shows_attending || []).slice(0, 3).map((slug) => {
const ev = shows.find((s) => s.slug === slug); const ev = shows.find((s) => s.slug === slug);
if (!ev) return null; if (!ev) return null;
return ( return (
<span key={slug} className="text-[10px] font-semibold uppercase tracking-wider bg-[#1D4ED8]/10 text-[#1D4ED8] px-1.5 py-0.5 rounded"> <span key={slug} className="text-[10px] font-semibold uppercase tracking-wider bg-[#1D4ED8]/10 text-[#1D4ED8] px-1.5 py-0.5 rounded">
{ev.short_name || ev.name} {ev.short_name || ev.name}
</span> </span>
); );
})} })}
{m.featured && ( {m.featured && (
<span className="text-[10px] font-semibold uppercase tracking-wider bg-amber-50 text-amber-700 px-1.5 py-0.5 rounded"> <span className="text-[10px] font-semibold uppercase tracking-wider bg-amber-50 text-amber-700 px-1.5 py-0.5 rounded">
Featured Featured
</span> </span>
)} )}
</div> </div>
<p className="text-xs text-[#888] line-clamp-2"> <p className="text-xs text-[#64748B] line-clamp-2">
{m.bio || "Profile coming soon."} {m.bio || "Profile coming soon."}
</p> </p>
</div> </div>
</Link> </Link>
))} ))}
</div> </div>
{filtered.length === 0 && ( {filtered.length === 0 && (
<div className="text-center py-12 text-[#888] text-sm"> <div className="text-center py-12 text-[#888] text-sm">
No manufacturers match these filters. No manufacturers match these filters.
</div> </div>
)} )}
</div> </div>
); );
} }