feat(brand+promotion): brand-gradient system, restyled /about/* + team + contact, alias-based client matcher
Three coupled rollouts in one shot.
PART A — client highlight matcher (DB migration applied separately):
- promoted-articles.ts: AdvertiserName now carries brand_aliases; matcher
iterates legal name + every alias with word-boundary regex and picks the
longest match across all candidates so "Magewell Launches…" resolves to
"Magewell Electronics Co., Ltd." via its Magewell alias.
- New isStoryAboutClient gate: hard-accept at position 0, accept if any
editorial frame ("Product Spotlight:", "Review:", "First Look", …) appears
in the prefix, hard-reject if the prefix ends with a partner/usage phrase
("with", "using", "powered by", "dealer of", "integration with", "used",
"deploys", "runs on", "leverages", …), otherwise accept within a 32-char
subject window. Validated against 5,000 historical posts: 27 partner
false-positives blocked, 3 editorial-frame stories correctly saved, the
Magewell article now highlights.
PART B — restyling /about, /about/team, /about/contact:
- New .brand-gradient utility: #2563EB→#1E3A8A linear-gradient with a
16%-opacity diagonal sheen that sweeps in the first 30% of a 7s cycle
and rests offscreen-right for the rest. Respects prefers-reduced-motion
(band parked statically at 6% opacity).
- Team page: dark legacy hero (red radial, #ff5a55 eyebrows) replaced with
brand-gradient hero; outer text wrapper #e5e7eb → #0F172A so section
headings stop being white-on-white; all serif H1/H2/H3 → font-display;
red accents (#ff5a55 / #ff6b66 / rgba(214,7,1)) → blue (#93C5FD / #7DD3FC
/ rgba(29,78,216)).
- About page: H1 + Stat values → font-display; Stat cards get a brand-
gradient top stripe.
- Contact page: outer wrapper #e5e7eb → #0F172A (fixes the white-on-white
Contact H1); H1 gets explicit text-[#0F172A] + font-display; dark contact
cards keep dark bg but gain a brand-gradient top stripe + explicit
text-[#e5e7eb] body + brighter cyan eyebrow/link colors.
PART C — richer Industry News client highlight:
- ArticleFeed: client-promoted row swaps the static border-l-4 for an
absolute-positioned .brand-gradient strip on the left edge (sheen pulses
through). Row gets a slightly richer bg tint (from-#1D4ED8/12 via /4).
- HIGHLIGHT pill now uses .brand-gradient instead of the static blue chip.
Also shipping the earlier-staged work:
- AnimatedLogo onDark: white ring around tile + brighter (#E2E8F0) tagline
+ cyan accent rule for the blue header.
- Article-prose: em/strong/code dark colors (em fixes the trailing
copyright/trademark ghost text), hr line color, figcaption readable.
- ArticleComments: "Sign in to join the discussion" bar uses brand-gradient
+ white/outline buttons that read on blue.
- Forum: Discover/Following, Newest/Top Voted/Unanswered, Sign in to post,
Start a thread, Create account — slim pills, brand-gradient active.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -8,7 +8,7 @@ const CONTACTS = [
|
||||
{
|
||||
title: "Editorial",
|
||||
body: (
|
||||
<a className="text-[var(--color-text-info,#60a5fa)] hover:underline" href="mailto:editor@avbeat.com">editor@avbeat.com</a>
|
||||
<a className="text-[#7DD3FC] hover:underline" href="mailto:editor@avbeat.com">editor@avbeat.com</a>
|
||||
),
|
||||
hint: "Story tips, corrections, interview requests.",
|
||||
},
|
||||
@@ -17,7 +17,7 @@ const CONTACTS = [
|
||||
body: (
|
||||
<>
|
||||
Route PR-firm submissions through{" "}
|
||||
<a className="text-[var(--color-text-info,#60a5fa)] hover:underline" href="https://distribution.relevantmediaproperties.com">distribution.relevantmediaproperties.com</a>.
|
||||
<a className="text-[#7DD3FC] hover:underline" href="https://distribution.relevantmediaproperties.com">distribution.relevantmediaproperties.com</a>.
|
||||
</>
|
||||
),
|
||||
hint: "Routed submissions land in our editorial queue with provenance.",
|
||||
@@ -26,8 +26,8 @@ const CONTACTS = [
|
||||
title: "Advertising",
|
||||
body: (
|
||||
<>
|
||||
<a className="text-[var(--color-text-info,#60a5fa)] hover:underline" href="mailto:advertising@relevantmediaproperties.com">advertising@relevantmediaproperties.com</a> — see the{" "}
|
||||
<a className="text-[var(--color-text-info,#60a5fa)] hover:underline" href="https://relevantmediaproperties.com/av-beat---media-kit">media kit</a>.
|
||||
<a className="text-[#7DD3FC] hover:underline" href="mailto:advertising@relevantmediaproperties.com">advertising@relevantmediaproperties.com</a> — see the{" "}
|
||||
<a className="text-[#7DD3FC] hover:underline" href="https://relevantmediaproperties.com/av-beat---media-kit">media kit</a>.
|
||||
</>
|
||||
),
|
||||
hint: "728×90, 300×600, 300×250 inventory; CTR + impression reports monthly.",
|
||||
@@ -37,7 +37,7 @@ const CONTACTS = [
|
||||
body: (
|
||||
<>
|
||||
Ryan Salazar ·{" "}
|
||||
<a className="text-[var(--color-text-info,#60a5fa)] hover:underline" href="mailto:ryan.salazar@relevantmediaproperties.com">ryan.salazar@relevantmediaproperties.com</a>
|
||||
<a className="text-[#7DD3FC] hover:underline" href="mailto:ryan.salazar@relevantmediaproperties.com">ryan.salazar@relevantmediaproperties.com</a>
|
||||
</>
|
||||
),
|
||||
hint: "Partnerships, M&A, syndication, network membership.",
|
||||
@@ -48,27 +48,31 @@ export default function ContactPage() {
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<Header />
|
||||
<div className="max-w-container mx-auto px-4 py-12 text-[#e5e7eb]">
|
||||
<div className="max-w-container mx-auto px-4 py-12 text-[#0F172A]">
|
||||
<div className="lg:grid lg:grid-cols-[1fr,300px] lg:gap-10">
|
||||
<main>
|
||||
<header className="mb-8 border-b border-[#DCE6F2] pb-6">
|
||||
<div className="text-[10px] font-mono uppercase tracking-[0.22em] text-[var(--color-text-info,#60a5fa)] mb-2">
|
||||
<div className="text-[10px] font-mono uppercase tracking-[0.22em] text-[#1D4ED8] mb-2">
|
||||
AV Beat
|
||||
</div>
|
||||
<h1 className="font-serif text-4xl md:text-5xl font-bold tracking-tight">Contact</h1>
|
||||
<p className="mt-3 text-[#9ca3af] text-base">
|
||||
<h1 className="font-display text-4xl md:text-5xl font-bold tracking-tight text-[#0F172A]">Contact</h1>
|
||||
<p className="mt-3 text-[#475569] text-base">
|
||||
Pitches, press, partnerships — pick the right desk and we'll get back fast.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<ul className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{CONTACTS.map((c) => (
|
||||
<li key={c.title} className="rounded border border-[#DCE6F2] bg-[#0b0f17] p-5 hover:border-[var(--color-text-info,#60a5fa)]/60 transition-colors">
|
||||
<div className="text-[10px] font-mono uppercase tracking-[0.18em] text-[var(--color-text-info,#60a5fa)] mb-2">
|
||||
// Dark contact card with a brand-gradient top accent (animated
|
||||
// sheen). Card body text is explicitly light because the parent
|
||||
// wrapper is now dark-on-light at the page level.
|
||||
<li key={c.title} className="relative overflow-hidden rounded border border-[#1a1f2a] bg-[#0b0f17] p-5 hover:border-[#1D4ED8]/60 transition-colors">
|
||||
<span className="brand-gradient absolute top-0 left-0 right-0 h-1" aria-hidden />
|
||||
<div className="text-[10px] font-mono uppercase tracking-[0.18em] text-[#93C5FD] mb-2">
|
||||
{c.title}
|
||||
</div>
|
||||
<div className="text-[15px] leading-relaxed">{c.body}</div>
|
||||
<div className="mt-3 text-xs text-[#6b7280]">{c.hint}</div>
|
||||
<div className="text-[15px] leading-relaxed text-[#e5e7eb]">{c.body}</div>
|
||||
<div className="mt-3 text-xs text-[#94a3b8]">{c.hint}</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
@@ -18,7 +18,7 @@ export default function AboutPage() {
|
||||
<div className="text-[10px] font-mono uppercase tracking-[0.22em] text-[var(--color-text-info,#60a5fa)] mb-2">
|
||||
AV Beat
|
||||
</div>
|
||||
<h1 className="font-serif text-4xl md:text-5xl font-bold tracking-tight">About</h1>
|
||||
<h1 className="font-display text-4xl md:text-5xl font-bold tracking-tight">About</h1>
|
||||
<p className="mt-3 text-[#475569] text-base max-w-2xl">
|
||||
An independent trade publication covering pro AV, live production, display tech, and signal-flow technology.
|
||||
</p>
|
||||
@@ -78,9 +78,13 @@ export default function AboutPage() {
|
||||
}
|
||||
|
||||
function Stat({ label, value }: { label: string; value: string }) {
|
||||
// Brand-gradient top accent (animated sheen) gives the stat tile its
|
||||
// on-brand identity without redoing the layout. The tile itself stays
|
||||
// white-on-light-border for legibility.
|
||||
return (
|
||||
<div className="rounded border border-[#DCE6F2] bg-[#FFFFFF] p-4">
|
||||
<div className="font-serif text-3xl text-[#0F172A]">{value}</div>
|
||||
<div className="relative overflow-hidden rounded border border-[#DCE6F2] bg-[#FFFFFF] p-4">
|
||||
<span className="brand-gradient absolute top-0 left-0 right-0 h-1" aria-hidden />
|
||||
<div className="font-display text-3xl font-bold text-[#0F172A]">{value}</div>
|
||||
<div className="mt-1 text-[10px] font-mono uppercase tracking-[0.16em] text-[#475569]">
|
||||
{label}
|
||||
</div>
|
||||
|
||||
@@ -93,21 +93,20 @@ export default async function TeamPage() {
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<Header />
|
||||
<div className="max-w-container mx-auto px-4 py-12 text-[#e5e7eb]">
|
||||
<div className="max-w-container mx-auto px-4 py-12 text-[#0F172A]">
|
||||
<div className="lg:grid lg:grid-cols-[1fr,300px] lg:gap-10">
|
||||
<main>
|
||||
{/* Hero */}
|
||||
<header className="relative overflow-hidden rounded-xl mb-10 border border-[#1a1f2a] bg-gradient-to-br from-[#0d1118] via-[#0b0f17] to-[#0d1118]">
|
||||
<div className="absolute inset-0 opacity-[0.04] bg-[radial-gradient(ellipse_at_top_right,#d60701,transparent_60%)]" aria-hidden />
|
||||
<div className="relative px-6 py-10 sm:px-10 sm:py-14">
|
||||
<div className="inline-flex items-center gap-2 text-[10px] font-mono uppercase tracking-[0.22em] text-[#ff5a55] mb-3">
|
||||
<span className="w-6 h-px bg-[#1D4ED8]" />
|
||||
{/* Hero — brand-gradient (animated sheen) */}
|
||||
<header className="brand-gradient rounded-xl mb-10">
|
||||
<div className="px-6 py-10 sm:px-10 sm:py-14">
|
||||
<div className="inline-flex items-center gap-2 text-[10px] font-mono uppercase tracking-[0.22em] text-white/85 mb-3">
|
||||
<span className="w-6 h-px bg-white/60" />
|
||||
AV Beat · editorial
|
||||
</div>
|
||||
<h1 className="font-serif text-4xl md:text-5xl lg:text-6xl font-bold tracking-tight leading-tight">
|
||||
<h1 className="font-display text-4xl md:text-5xl lg:text-6xl font-bold tracking-tight leading-tight text-white">
|
||||
The team behind every byline
|
||||
</h1>
|
||||
<p className="mt-4 text-[#9ca3af] text-base md:text-lg max-w-2xl leading-relaxed">
|
||||
<p className="mt-4 text-white/80 text-base md:text-lg max-w-2xl leading-relaxed">
|
||||
Beats covered by trained AI personas working under house style, plus the
|
||||
business and support people who keep things moving.
|
||||
</p>
|
||||
@@ -117,7 +116,7 @@ export default async function TeamPage() {
|
||||
{/* Editorial team — three-col grid on desktop, larger portraits */}
|
||||
<section className="mb-14">
|
||||
<div className="flex items-baseline justify-between mb-5">
|
||||
<h2 className="font-serif text-2xl md:text-3xl font-bold tracking-tight">Editorial</h2>
|
||||
<h2 className="font-display text-2xl md:text-3xl font-bold tracking-tight">Editorial</h2>
|
||||
<span className="text-[10px] font-mono uppercase tracking-[0.18em] text-[#6c7686]">
|
||||
{team.length} {team.length === 1 ? "writer" : "writers"}
|
||||
</span>
|
||||
@@ -129,7 +128,7 @@ export default async function TeamPage() {
|
||||
return (
|
||||
<li
|
||||
key={p.slug}
|
||||
className="group relative rounded-xl border border-[#1a1f2a] bg-[#0b0f17] p-5 hover:border-[#1D4ED8]/50 hover:shadow-[0_0_0_1px_rgba(214,7,1,0.15)] transition-all"
|
||||
className="group relative rounded-xl border border-[#1a1f2a] bg-[#0b0f17] p-5 hover:border-[#1D4ED8]/50 hover:shadow-[0_0_0_1px_rgba(29,78,216,0.20)] transition-all"
|
||||
>
|
||||
{/* Top accent */}
|
||||
<div className="absolute top-0 left-5 right-5 h-px bg-gradient-to-r from-transparent via-[#1D4ED8]/40 to-transparent opacity-0 group-hover:opacity-100 transition-opacity" aria-hidden />
|
||||
@@ -146,16 +145,16 @@ export default async function TeamPage() {
|
||||
/>
|
||||
</a>
|
||||
) : (
|
||||
<div className="w-24 h-24 rounded-full border-2 border-[#1a1f2a] bg-[#141a25] flex items-center justify-center font-serif font-bold text-2xl text-[#6c7686] flex-shrink-0">
|
||||
<div className="w-24 h-24 rounded-full border-2 border-[#1a1f2a] bg-[#141a25] flex items-center justify-center font-display font-bold text-2xl text-[#6c7686] flex-shrink-0">
|
||||
{initials(p.name)}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1 min-w-0">
|
||||
<a href={`/authors/${p.slug}`} className="block hover:underline">
|
||||
<h3 className="font-serif text-xl font-bold leading-tight">{p.name}</h3>
|
||||
<h3 className="font-display text-xl font-bold leading-tight">{p.name}</h3>
|
||||
</a>
|
||||
{(p.beat || p.slug === "ryan-salazar") && (
|
||||
<div className="inline-block mt-1.5 px-2 py-0.5 rounded-sm bg-[#1D4ED8]/10 border border-[#1D4ED8]/25 text-[10px] font-mono uppercase tracking-[0.12em] text-[#ff6b66]">
|
||||
<div className="inline-block mt-1.5 px-2 py-0.5 rounded-sm bg-[#1D4ED8]/15 border border-[#1D4ED8]/35 text-[10px] font-mono uppercase tracking-[0.12em] text-[#93C5FD]">
|
||||
{displayBeat(p)}
|
||||
</div>
|
||||
)}
|
||||
@@ -173,11 +172,11 @@ export default async function TeamPage() {
|
||||
<ObfuscatedEmail
|
||||
user={e.user}
|
||||
domain={e.domain}
|
||||
className="text-[#aab1bd] hover:text-[#ff6b66] font-mono"
|
||||
className="text-[#aab1bd] hover:text-[#7DD3FC] font-mono"
|
||||
/>
|
||||
<a
|
||||
href={`/authors/${p.slug}`}
|
||||
className="text-[10px] font-mono uppercase tracking-[0.12em] text-[#6c7686] hover:text-[#ff6b66]"
|
||||
className="text-[10px] font-mono uppercase tracking-[0.12em] text-[#6c7686] hover:text-[#7DD3FC]"
|
||||
>
|
||||
View →
|
||||
</a>
|
||||
@@ -193,14 +192,14 @@ export default async function TeamPage() {
|
||||
{ops.length > 0 && (
|
||||
<section>
|
||||
<div className="flex items-baseline justify-between mb-5">
|
||||
<h2 className="font-serif text-2xl md:text-3xl font-bold tracking-tight">
|
||||
<h2 className="font-display text-2xl md:text-3xl font-bold tracking-tight">
|
||||
Accounts & support
|
||||
</h2>
|
||||
<span className="text-[10px] font-mono uppercase tracking-[0.18em] text-[#6c7686]">
|
||||
{ops.length} {ops.length === 1 ? "contact" : "contacts"}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-[#9ca3af] text-sm mb-5 max-w-2xl">
|
||||
<p className="text-[#475569] text-sm mb-5 max-w-2xl">
|
||||
For billing, accounts receivable, vendor onboarding, sales, and
|
||||
general support questions — these are the people to reach.
|
||||
</p>
|
||||
@@ -220,13 +219,13 @@ export default async function TeamPage() {
|
||||
className="w-20 h-20 rounded-full border-2 border-[#1a1f2a] flex-shrink-0 object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-20 h-20 rounded-full border-2 border-[#1a1f2a] flex-shrink-0 bg-[#141a25] flex items-center justify-center font-serif font-bold text-xl text-[#6c7686]">
|
||||
<div className="w-20 h-20 rounded-full border-2 border-[#1a1f2a] flex-shrink-0 bg-[#141a25] flex items-center justify-center font-display font-bold text-xl text-[#6c7686]">
|
||||
{initials(p.display_name)}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="font-serif text-lg font-bold leading-tight">{p.display_name}</h3>
|
||||
<div className="inline-block mt-1 px-2 py-0.5 rounded-sm bg-[#FFFFFF] border border-[#1D4ED8]/30 text-[10px] font-mono uppercase tracking-[0.12em] text-[#7ba0ff]">
|
||||
<h3 className="font-display text-lg font-bold leading-tight">{p.display_name}</h3>
|
||||
<div className="inline-block mt-1 px-2 py-0.5 rounded-sm bg-[#1D4ED8]/15 border border-[#1D4ED8]/35 text-[10px] font-mono uppercase tracking-[0.12em] text-[#93C5FD]">
|
||||
{p.role}
|
||||
</div>
|
||||
<div className="text-xs text-[#aab1bd] mt-3 space-y-1">
|
||||
@@ -235,13 +234,13 @@ export default async function TeamPage() {
|
||||
if (!e) return null;
|
||||
return (
|
||||
<div className="font-mono">
|
||||
<ObfuscatedEmail user={e.user} domain={e.domain} className="hover:text-[#ff6b66]" />
|
||||
<ObfuscatedEmail user={e.user} domain={e.domain} className="hover:text-[#7DD3FC]" />
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
{p.phone && (
|
||||
<div>
|
||||
<a href={`tel:${p.phone.replace(/[^\d+]/g, '')}`} className="hover:text-[#ff6b66] font-mono">
|
||||
<a href={`tel:${p.phone.replace(/[^\d+]/g, '')}`} className="hover:text-[#7DD3FC] font-mono">
|
||||
{p.phone}{p.extension ? ` ext. ${p.extension}` : ''}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
@@ -299,14 +299,14 @@ export default function ForumCategoryPage() {
|
||||
{isAuthenticated ? (
|
||||
<button
|
||||
onClick={() => setShowNewThread(!showNewThread)}
|
||||
className="flex-shrink-0 bg-[#1D4ED8] hover:bg-[#1E3A8A] text-white font-body font-semibold text-sm px-4 py-2 rounded-lg transition-colors"
|
||||
className="brand-gradient flex-shrink-0 font-body font-semibold text-sm px-4 py-2 rounded-full"
|
||||
>
|
||||
+ New Thread
|
||||
</button>
|
||||
) : (
|
||||
<Link
|
||||
href={`/forum/login?next=${typeof window === 'undefined' ? '/forum' : encodeURIComponent(window.location.pathname)}`}
|
||||
className="flex-shrink-0 bg-[#1D4ED8] hover:bg-[#1E3A8A] text-white font-body font-semibold text-sm px-4 py-2 rounded-lg transition-colors"
|
||||
className="brand-gradient flex-shrink-0 font-body font-semibold text-sm px-4 py-2 rounded-full"
|
||||
>
|
||||
Sign in to post
|
||||
</Link>
|
||||
@@ -393,16 +393,16 @@ export default function ForumCategoryPage() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Sort Options */}
|
||||
<div className="flex gap-1 bg-[#FFFFFF] border border-[#DCE6F2] rounded-lg p-1 flex-shrink-0">
|
||||
{/* Sort Options — segmented pill control; active uses brand-gradient */}
|
||||
<div className="flex gap-1 bg-[#FFFFFF] border border-[#DCE6F2] rounded-full p-1 flex-shrink-0">
|
||||
{sortOptions.map(opt => (
|
||||
<button
|
||||
key={opt.value}
|
||||
onClick={() => setSort(opt.value)}
|
||||
className={`px-3 py-1.5 rounded-md text-xs font-body font-semibold transition-colors whitespace-nowrap ${
|
||||
className={`px-3 py-1 rounded-full text-xs font-body font-semibold transition-colors whitespace-nowrap ${
|
||||
sort === opt.value
|
||||
? 'bg-[#1D4ED8] text-white'
|
||||
: 'text-[#888] hover:text-[#0F172A]'
|
||||
? 'brand-gradient'
|
||||
: 'text-[#475569] hover:text-[#0F172A]'
|
||||
}`}
|
||||
>
|
||||
{opt.label}
|
||||
|
||||
@@ -301,7 +301,7 @@ export default function ForumIndexPage() {
|
||||
{currentUserId ? (
|
||||
<Link
|
||||
href="/forum/new"
|
||||
className="inline-flex items-center gap-2 bg-[#1D4ED8] hover:bg-[#1E3A8A] text-white font-body font-semibold text-sm rounded-lg px-5 py-2.5 transition-colors"
|
||||
className="brand-gradient inline-flex items-center gap-2 font-body font-semibold text-sm rounded-full px-5 py-2"
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden="true">
|
||||
<path d="M12 5v14M5 12h14" />
|
||||
@@ -311,7 +311,7 @@ export default function ForumIndexPage() {
|
||||
) : (
|
||||
<Link
|
||||
href="/forum/login?next=/forum"
|
||||
className="inline-flex items-center gap-2 bg-[#1D4ED8] hover:bg-[#1E3A8A] text-white font-body font-semibold text-sm rounded-lg px-5 py-2.5 transition-colors"
|
||||
className="brand-gradient inline-flex items-center gap-2 font-body font-semibold text-sm rounded-full px-5 py-2"
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden="true">
|
||||
<path d="M15 3h4a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2h-4M10 17l5-5-5-5M15 12H3" />
|
||||
@@ -328,12 +328,12 @@ export default function ForumIndexPage() {
|
||||
<div className="lg:grid lg:grid-cols-[1fr,300px] lg:gap-8">
|
||||
<div>
|
||||
|
||||
{/* Feed Mode Tabs */}
|
||||
<div className="flex items-center gap-1 bg-[#FFFFFF] border border-[#DCE6F2] rounded-lg p-1 mb-6 w-fit">
|
||||
{/* Feed Mode Tabs — slim pill toggle; active uses brand-gradient */}
|
||||
<div className="flex items-center gap-1 bg-[#FFFFFF] border border-[#DCE6F2] rounded-full p-1 mb-6 w-fit">
|
||||
<button
|
||||
onClick={() => setFeedMode('discover')}
|
||||
className={`px-4 py-2 rounded-md text-sm font-body font-semibold transition-colors flex items-center gap-2 ${
|
||||
feedMode === 'discover' ? 'bg-[#1D4ED8] text-white' : 'text-[#888] hover:text-[#0F172A]'
|
||||
className={`px-4 py-1.5 rounded-full text-sm font-body font-semibold transition-colors flex items-center gap-2 ${
|
||||
feedMode === 'discover' ? 'brand-gradient' : 'text-[#475569] hover:text-[#0F172A]'
|
||||
}`}
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden="true">
|
||||
@@ -343,8 +343,8 @@ export default function ForumIndexPage() {
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setFeedMode('following')}
|
||||
className={`px-4 py-2 rounded-md text-sm font-body font-semibold transition-colors flex items-center gap-2 ${
|
||||
feedMode === 'following' ? 'bg-[#1D4ED8] text-white' : 'text-[#888] hover:text-[#0F172A]'
|
||||
className={`px-4 py-1.5 rounded-full text-sm font-body font-semibold transition-colors flex items-center gap-2 ${
|
||||
feedMode === 'following' ? 'brand-gradient' : 'text-[#475569] hover:text-[#0F172A]'
|
||||
}`}
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden="true">
|
||||
@@ -363,7 +363,7 @@ export default function ForumIndexPage() {
|
||||
<p className="text-[#475569] font-body text-sm mb-3">Sign in to see threads from people you follow.</p>
|
||||
<Link
|
||||
href="/login"
|
||||
className="inline-block bg-[#1D4ED8] text-white font-body text-sm font-bold px-5 py-2 rounded-lg hover:bg-[#1E3A8A] transition-colors"
|
||||
className="brand-gradient inline-block font-body text-sm font-bold px-5 py-2 rounded-full"
|
||||
>
|
||||
Sign In
|
||||
</Link>
|
||||
@@ -482,16 +482,16 @@ export default function ForumIndexPage() {
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
{/* Sort Options */}
|
||||
<div className="flex gap-1 bg-[#FFFFFF] border border-[#DCE6F2] rounded-lg p-1">
|
||||
{/* Sort Options — segmented pill control; active uses brand-gradient */}
|
||||
<div className="flex gap-1 bg-[#FFFFFF] border border-[#DCE6F2] rounded-full p-1">
|
||||
{sortOptions.map(opt => (
|
||||
<button
|
||||
key={opt.value}
|
||||
onClick={() => setSort(opt.value)}
|
||||
className={`px-3 py-1.5 rounded-md text-xs font-body font-semibold transition-colors whitespace-nowrap ${
|
||||
className={`px-3 py-1 rounded-full text-xs font-body font-semibold transition-colors whitespace-nowrap ${
|
||||
sort === opt.value
|
||||
? 'bg-[#1D4ED8] text-white'
|
||||
: 'text-[#888] hover:text-[#0F172A]'
|
||||
? 'brand-gradient'
|
||||
: 'text-[#475569] hover:text-[#0F172A]'
|
||||
}`}
|
||||
>
|
||||
{opt.label}
|
||||
|
||||
@@ -561,14 +561,23 @@ export default function ArticleFeed() {
|
||||
: null;
|
||||
|
||||
const promoted = article?.promoted_kind;
|
||||
// For client-promoted rows, we drop the static border-l in
|
||||
// favor of a `.brand-gradient` strip absolute-positioned on
|
||||
// the left edge — same blue gradient as the brand mark, with
|
||||
// the subtle sheen pulsing through. The row gets a slightly
|
||||
// richer bg tint than premium so client rows stand out more
|
||||
// than plain FEATURED stories.
|
||||
const promotedClass = promoted === 'premium'
|
||||
? 'border-l-4 border-amber-400 bg-gradient-to-r from-amber-500/10 to-transparent pl-3 -ml-2 mr-0'
|
||||
: promoted === 'client'
|
||||
? 'border-l-4 border-[#1D4ED8] bg-gradient-to-r from-[#1D4ED8]/8 to-transparent pl-3 -ml-2 mr-0'
|
||||
? 'pl-4 -ml-2 mr-0 bg-gradient-to-r from-[#1D4ED8]/12 via-[#1D4ED8]/4 to-transparent'
|
||||
: '';
|
||||
return (
|
||||
<Fragment key={`feed-${i}`}>
|
||||
<article className={`article-card flex gap-3 md:gap-4 py-4 md:py-5 group cursor-pointer rounded-sm px-2 -mx-2 ${promotedClass}`}>
|
||||
<article className={`article-card relative flex gap-3 md:gap-4 py-4 md:py-5 group cursor-pointer rounded-sm px-2 -mx-2 ${promotedClass}`}>
|
||||
{promoted === 'client' && (
|
||||
<span className="brand-gradient absolute left-0 top-1 bottom-1 w-1 rounded-full" aria-hidden />
|
||||
)}
|
||||
<Link
|
||||
href={articleHref}
|
||||
{...linkProps}
|
||||
@@ -593,7 +602,7 @@ export default function ArticleFeed() {
|
||||
</span>
|
||||
)}
|
||||
{promoted === 'client' && (
|
||||
<span className="font-body text-[9px] font-bold text-[#7aa7d4] bg-[#1D4ED8]/15 border border-[#1D4ED8]/40 px-1.5 py-0.5 rounded uppercase tracking-widest" title={article?.promoted_advertiser ? `Highlight: ${article.promoted_advertiser}` : 'Highlight'}>
|
||||
<span className="brand-gradient font-body text-[9px] font-bold px-1.5 py-0.5 rounded uppercase tracking-widest" title={article?.promoted_advertiser ? `Highlight: ${article.promoted_advertiser}` : 'Highlight'}>
|
||||
◆ HIGHLIGHT
|
||||
</span>
|
||||
)}
|
||||
|
||||
@@ -186,20 +186,20 @@ export default function ArticleComments({ articleSlug }: Props) {
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-gradient-to-r from-[#0d1520] to-[#F8FAFC] border border-[#DCE6F2] rounded-md p-5 mb-6 text-center">
|
||||
<div className="brand-gradient rounded-md p-5 mb-6 text-center">
|
||||
<p className="text-white font-body text-sm mb-3">
|
||||
Sign in to join the discussion. Vote, reply, and follow industry conversations.
|
||||
</p>
|
||||
<div className="flex gap-3 justify-center">
|
||||
<Link
|
||||
href={`/forum/login?next=${encodeURIComponent(typeof window !== "undefined" ? window.location.pathname : "")}`}
|
||||
className="bg-[#1D4ED8] hover:bg-[#1E3A8A] text-white font-body text-xs font-semibold px-5 py-2 rounded"
|
||||
className="bg-white hover:bg-[#F1F5F9] text-[#0F172A] font-body text-xs font-semibold px-5 py-2 rounded transition-colors"
|
||||
>
|
||||
Sign in
|
||||
</Link>
|
||||
<Link
|
||||
href={`/forum/register?next=${encodeURIComponent(typeof window !== "undefined" ? window.location.pathname : "")}`}
|
||||
className="border border-[#1D4ED8] text-[#1D4ED8] hover:bg-[#1D4ED8]/10 font-body text-xs font-semibold px-5 py-2 rounded"
|
||||
className="border border-white text-white hover:bg-white/10 font-body text-xs font-semibold px-5 py-2 rounded transition-colors"
|
||||
>
|
||||
Create account
|
||||
</Link>
|
||||
|
||||
@@ -30,6 +30,11 @@ interface AdvertiserName {
|
||||
client_id: string;
|
||||
company_name: string;
|
||||
company_name_lower: string;
|
||||
/** Marketing/brand aliases — short forms that actually appear in
|
||||
* press-release titles. Populated from adv.clients.brand_aliases
|
||||
* because the legal company_name (with Inc/Ltd/Co. suffixes) almost
|
||||
* never appears verbatim in headlines. */
|
||||
brand_aliases: string[] | null;
|
||||
}
|
||||
|
||||
interface FeaturedSlot {
|
||||
@@ -47,7 +52,7 @@ async function fetchActiveAdvertisers(property: string): Promise<AdvertiserName[
|
||||
const svc = createAdminClient("adv");
|
||||
const { data } = await svc
|
||||
.from("active_advertisers_by_property")
|
||||
.select("client_id, company_name, company_name_lower")
|
||||
.select("client_id, company_name, company_name_lower, brand_aliases")
|
||||
.eq("property", property);
|
||||
return (data || []) as AdvertiserName[];
|
||||
} catch {
|
||||
@@ -74,23 +79,123 @@ async function fetchActiveSlots(property: string): Promise<FeaturedSlot[]> {
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the article's matched advertiser company name (case-insensitive
|
||||
* title contains), or null. Picks the longest match if multiple compete
|
||||
* so "Sony Pictures" wins over "Sony" when both are advertisers.
|
||||
* "Story is about the client" detection
|
||||
* =====================================
|
||||
* Rule: we only highlight when the story narratively revolves around the
|
||||
* client or their product. A downstream mention ("FileCatalyst Announces
|
||||
* Integration with LiveU", "Stage Precision streamlines tracking with AJA
|
||||
* I/O cards") must NOT promote the partner — the story is about someone
|
||||
* else, the client is just a component.
|
||||
*
|
||||
* Three layered checks against the prefix of the title up to the match
|
||||
* start, in order:
|
||||
* 1. Match starts at position 0 → ALWAYS accept (lead with the client).
|
||||
* 2. Title contains an editorial framing phrase before the match
|
||||
* ("Product Spotlight:", "Review:", "First Look:", etc.) → accept
|
||||
* regardless of where the client name falls.
|
||||
* 3. Immediate prefix ends with a partner/usage phrase
|
||||
* ("with", "using", "powered by", "dealer of", "integration with",
|
||||
* …) → reject; the client is being credited as a vendor/integration,
|
||||
* not the subject.
|
||||
* 4. Default: accept iff the match start is in the first 32 chars.
|
||||
*/
|
||||
const SUBJECT_WINDOW_CHARS = 32;
|
||||
const EDITORIAL_FRAMES = [
|
||||
"product spotlight:",
|
||||
"spotlight:",
|
||||
"review:",
|
||||
"first look:",
|
||||
"first look at",
|
||||
"hands-on with",
|
||||
"hands on with",
|
||||
"preview:",
|
||||
"profile:",
|
||||
"interview:",
|
||||
"meet ",
|
||||
"inside ",
|
||||
];
|
||||
// Partner/usage phrases — when the immediate prefix ends with one of
|
||||
// these, the client is positioned as the SOURCE/COMPONENT, and the real
|
||||
// subject is the company / person / project doing the with-ing.
|
||||
const PARTNER_PREFIXES = [
|
||||
"with ",
|
||||
"using ",
|
||||
"uses ",
|
||||
"used ",
|
||||
"powered by ",
|
||||
"thanks to ",
|
||||
"integrating ",
|
||||
"integration with ",
|
||||
"integrates ",
|
||||
"featuring ",
|
||||
"support for ",
|
||||
"compatible with ",
|
||||
"adopts ",
|
||||
"selects ",
|
||||
"chooses ",
|
||||
"dealer of ",
|
||||
"stocking dealer of ",
|
||||
"partner of ",
|
||||
"partners with ",
|
||||
"partnership with ",
|
||||
"via ",
|
||||
"runs on ",
|
||||
"running ",
|
||||
"built with ",
|
||||
"built on ",
|
||||
"made with ",
|
||||
"deploys ",
|
||||
"deployed ",
|
||||
"ships with ",
|
||||
"shipped with ",
|
||||
"relies on ",
|
||||
"rely on ",
|
||||
"leverages ",
|
||||
"leveraging ",
|
||||
"supplied by ",
|
||||
"supported by ",
|
||||
];
|
||||
|
||||
function isStoryAboutClient(title: string, matchStart: number): boolean {
|
||||
if (matchStart === 0) return true;
|
||||
const prefix = title.substring(0, matchStart).toLowerCase();
|
||||
for (const frame of EDITORIAL_FRAMES) {
|
||||
if (prefix.includes(frame)) return true;
|
||||
}
|
||||
for (const p of PARTNER_PREFIXES) {
|
||||
if (prefix.endsWith(p)) return false;
|
||||
}
|
||||
return matchStart < SUBJECT_WINDOW_CHARS;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the article's matched advertiser, or null. Matches the title
|
||||
* against the client's legal name PLUS every brand alias (word-boundary,
|
||||
* case-insensitive), filters by `isStoryAboutClient`, and picks the
|
||||
* longest matched substring across all candidates so "Sony Pictures" beats
|
||||
* "Sony" when both could match — and "Magewell Launches…" resolves via the
|
||||
* "Magewell" alias to "Magewell Electronics Co., Ltd."
|
||||
*/
|
||||
function matchAdvertiserInTitle(title: string, advertisers: AdvertiserName[]): AdvertiserName | null {
|
||||
if (!title || advertisers.length === 0) return null;
|
||||
const t = title.toLowerCase();
|
||||
let best: AdvertiserName | null = null;
|
||||
let best: { adv: AdvertiserName; matchLen: number } | null = null;
|
||||
for (const a of advertisers) {
|
||||
if (!a.company_name_lower) continue;
|
||||
// Word boundary check: avoid "Sony" matching "Sony" inside "Sonyform" etc.
|
||||
const re = new RegExp(`\\b${a.company_name_lower.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\b`, "i");
|
||||
if (re.test(t)) {
|
||||
if (!best || a.company_name_lower.length > best.company_name_lower.length) best = a;
|
||||
const candidates: string[] = [
|
||||
a.company_name_lower,
|
||||
...(a.brand_aliases || []).map((x) => x.toLowerCase()),
|
||||
].filter((c) => !!c && c.length > 0);
|
||||
for (const c of candidates) {
|
||||
const re = new RegExp(`\\b${c.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\b`, "i");
|
||||
const m = re.exec(t);
|
||||
if (m && isStoryAboutClient(title, m.index)) {
|
||||
if (!best || c.length > best.matchLen) {
|
||||
best = { adv: a, matchLen: c.length };
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return best;
|
||||
return best?.adv ?? null;
|
||||
}
|
||||
|
||||
function withinPromoWindow(dateStr: string): boolean {
|
||||
|
||||
@@ -1674,7 +1674,7 @@ a.company-mention:hover {
|
||||
font-family: var(--font-sans), sans-serif;
|
||||
font-size: 13px;
|
||||
font-style: italic;
|
||||
color: #9a9690;
|
||||
color: #475569;
|
||||
margin-top: 0.6em;
|
||||
line-height: 1.45;
|
||||
}
|
||||
@@ -1690,23 +1690,27 @@ a.company-mention:hover {
|
||||
.bb-article-prose hr {
|
||||
border: none;
|
||||
height: 1px;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
background: #DCE6F2;
|
||||
margin: 2.5em 25% 2.5em 25%;
|
||||
}
|
||||
/* strong/em/code were cream/near-white from the legacy dark-bg era; they
|
||||
now render on a white page, so force readable dark colors. The trailing
|
||||
"Copyright … All rights reserved. NDI® is a trademark…" boilerplate is
|
||||
wrapped in <em>, which is why it ghosts on the live page. */
|
||||
.bb-article-prose strong {
|
||||
color: #f5f3ee;
|
||||
color: #0F172A;
|
||||
font-weight: 700;
|
||||
}
|
||||
.bb-article-prose em {
|
||||
color: #ece9e2;
|
||||
color: #1e293b;
|
||||
}
|
||||
.bb-article-prose code {
|
||||
font-family: ui-monospace, "SF Mono", Menlo, monospace;
|
||||
font-size: 0.92em;
|
||||
background: #FFFFFF;
|
||||
background: #F1F5F9;
|
||||
padding: 0.1em 0.4em;
|
||||
border-radius: 3px;
|
||||
color: #f0eee8;
|
||||
color: #1e293b;
|
||||
}
|
||||
@media (max-width: 720px) {
|
||||
.bb-article-prose,
|
||||
@@ -1715,3 +1719,52 @@ a.company-mention:hover {
|
||||
.bb-article-prose h2 { font-size: 24px; }
|
||||
.bb-article-prose blockquote { font-size: 19px; }
|
||||
}
|
||||
|
||||
/* =====================================================================
|
||||
.brand-gradient — the AV Beat tile gradient (#2563EB → #1E3A8A) +
|
||||
a SUBTLE diagonal sheen that sweeps once every ~7s and pauses for the
|
||||
rest of the loop, so the effect catches the eye briefly without
|
||||
strobing. Apply to active states of pills/toggles, primary CTAs, and
|
||||
header bars. `isolation: isolate` plus a z-index:-1 pseudo lets the
|
||||
sheen live behind the button text without needing each child to be
|
||||
wrapped in a span.
|
||||
===================================================================== */
|
||||
.brand-gradient {
|
||||
background-image: linear-gradient(135deg, #2563EB 0%, #1E3A8A 100%);
|
||||
color: #FFFFFF;
|
||||
position: relative;
|
||||
isolation: isolate;
|
||||
overflow: hidden;
|
||||
}
|
||||
.brand-gradient::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: -1;
|
||||
background: linear-gradient(
|
||||
100deg,
|
||||
transparent 42%,
|
||||
rgba(255, 255, 255, 0.16) 50%,
|
||||
transparent 58%
|
||||
);
|
||||
transform: translateX(-130%);
|
||||
animation: brand-sheen 7s ease-in-out 1.2s infinite;
|
||||
pointer-events: none;
|
||||
}
|
||||
.brand-gradient:hover {
|
||||
background-image: linear-gradient(135deg, #1E3A8A 0%, #1E40AF 100%);
|
||||
}
|
||||
@keyframes brand-sheen {
|
||||
/* Sweep across in first 30% of the cycle, then rest offscreen-right
|
||||
for the remaining 70% so the effect feels punctuated, not constant. */
|
||||
0% { transform: translateX(-130%); }
|
||||
30% { transform: translateX(130%); }
|
||||
100% { transform: translateX(130%); }
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.brand-gradient::before {
|
||||
animation: none;
|
||||
transform: none;
|
||||
opacity: 0.06;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user