diff --git a/src/app/about/contact/page.tsx b/src/app/about/contact/page.tsx index d0bdce3..e88f670 100644 --- a/src/app/about/contact/page.tsx +++ b/src/app/about/contact/page.tsx @@ -8,7 +8,7 @@ const CONTACTS = [ { title: "Editorial", body: ( - editor@avbeat.com + editor@avbeat.com ), hint: "Story tips, corrections, interview requests.", }, @@ -17,7 +17,7 @@ const CONTACTS = [ body: ( <> Route PR-firm submissions through{" "} - distribution.relevantmediaproperties.com. + distribution.relevantmediaproperties.com. ), hint: "Routed submissions land in our editorial queue with provenance.", @@ -26,8 +26,8 @@ const CONTACTS = [ title: "Advertising", body: ( <> - advertising@relevantmediaproperties.com — see the{" "} - media kit. + advertising@relevantmediaproperties.com — see the{" "} + media kit. ), hint: "728×90, 300×600, 300×250 inventory; CTR + impression reports monthly.", @@ -37,7 +37,7 @@ const CONTACTS = [ body: ( <> Ryan Salazar ·{" "} - ryan.salazar@relevantmediaproperties.com + ryan.salazar@relevantmediaproperties.com ), hint: "Partnerships, M&A, syndication, network membership.", @@ -48,27 +48,31 @@ export default function ContactPage() { return (
-
+
-
+
AV Beat
-

Contact

-

+

Contact

+

Pitches, press, partnerships — pick the right desk and we'll get back fast.

    {CONTACTS.map((c) => ( -
  • -
    + // 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. +
  • + +
    {c.title}
    -
    {c.body}
    -
    {c.hint}
    +
    {c.body}
    +
    {c.hint}
  • ))}
diff --git a/src/app/about/page.tsx b/src/app/about/page.tsx index 2e27f82..33e80d7 100644 --- a/src/app/about/page.tsx +++ b/src/app/about/page.tsx @@ -18,7 +18,7 @@ export default function AboutPage() {
AV Beat
-

About

+

About

An independent trade publication covering pro AV, live production, display tech, and signal-flow technology.

@@ -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 ( -
-
{value}
+
+ +
{value}
{label}
diff --git a/src/app/about/team/page.tsx b/src/app/about/team/page.tsx index 648edc5..445066e 100644 --- a/src/app/about/team/page.tsx +++ b/src/app/about/team/page.tsx @@ -93,21 +93,20 @@ export default async function TeamPage() { return (
-
+
- {/* Hero */} -
-
-
-
- + {/* Hero — brand-gradient (animated sheen) */} +
+
+
+ AV Beat · editorial
-

+

The team behind every byline

-

+

Beats covered by trained AI personas working under house style, plus the business and support people who keep things moving.

@@ -117,7 +116,7 @@ export default async function TeamPage() { {/* Editorial team — three-col grid on desktop, larger portraits */}
-

Editorial

+

Editorial

{team.length} {team.length === 1 ? "writer" : "writers"} @@ -129,7 +128,7 @@ export default async function TeamPage() { return (
  • {/* Top accent */}
    @@ -146,16 +145,16 @@ export default async function TeamPage() { /> ) : ( -
    +
    {initials(p.name)}
    )}
    -

    {p.name}

    +

    {p.name}

    {(p.beat || p.slug === "ryan-salazar") && ( -
    +
    {displayBeat(p)}
    )} @@ -173,11 +172,11 @@ export default async function TeamPage() { View → @@ -193,14 +192,14 @@ export default async function TeamPage() { {ops.length > 0 && (
    -

    +

    Accounts & support

    {ops.length} {ops.length === 1 ? "contact" : "contacts"}
    -

    +

    For billing, accounts receivable, vendor onboarding, sales, and general support questions — these are the people to reach.

    @@ -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" /> ) : ( -
    +
    {initials(p.display_name)}
    )}
    -

    {p.display_name}

    -
    +

    {p.display_name}

    +
    {p.role}
    @@ -235,13 +234,13 @@ export default async function TeamPage() { if (!e) return null; return (
    - +
    ); })()} {p.phone && ( diff --git a/src/app/forum/[slug]/page.tsx b/src/app/forum/[slug]/page.tsx index 1601871..a1a5728 100644 --- a/src/app/forum/[slug]/page.tsx +++ b/src/app/forum/[slug]/page.tsx @@ -299,14 +299,14 @@ export default function ForumCategoryPage() { {isAuthenticated ? ( ) : ( Sign in to post @@ -393,16 +393,16 @@ export default function ForumCategoryPage() { )}
    - {/* Sort Options */} -
    + {/* Sort Options — segmented pill control; active uses brand-gradient */} +
    {sortOptions.map(opt => (
    - {/* Sort Options */} -
    + {/* Sort Options — segmented pill control; active uses brand-gradient */} +
    {sortOptions.map(opt => (
    ) : ( -
    +

    Sign in to join the discussion. Vote, reply, and follow industry conversations.

    Sign in Create account diff --git a/src/lib/promoted-articles.ts b/src/lib/promoted-articles.ts index 81470c8..117d101 100644 --- a/src/lib/promoted-articles.ts +++ b/src/lib/promoted-articles.ts @@ -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 { } /** - * 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 { diff --git a/src/styles/tailwind.css b/src/styles/tailwind.css index 25898e6..0f42ec6 100644 --- a/src/styles/tailwind.css +++ b/src/styles/tailwind.css @@ -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 , 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; + } +}