Files
avbeat-com/src/lib/promoted-articles.ts
Local Administrator f44add70c7 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>
2026-06-04 18:37:15 +00:00

283 lines
9.0 KiB
TypeScript

/**
* Promoted-article logic for the BB homepage Industry News feed.
*
* Three tiers, top-to-bottom:
* 1) PREMIUM — explicitly purchased slot in adv.featured_story_slots
* (active right now). Sorted by `position` ascending.
* 2) CLIENT — article whose title mentions a company that currently has
* an active banner campaign on this property, AND was published in
* the last 48 hours. Multiple matches sorted by publish date desc.
* 3) GENERAL — everything else, publish date desc.
*
* The output is decorated with `promoted_kind` ('premium' | 'client' | null)
* so the renderer can highlight tiers 1 + 2.
*/
import { createAdminClient } from "./supabase/admin";
const CLIENT_PROMO_WINDOW_HOURS = 48;
export type PromotedKind = "premium" | "client" | null;
export interface PromotableArticle {
title: string;
slug: string;
date: string; // ISO or parseable; we use Date(article.date)
[k: string]: any;
}
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 {
id: string;
article_kind: string;
article_id: string;
article_slug_cache: string | null;
position: number;
ends_at: string;
starts_at: string;
}
async function fetchActiveAdvertisers(property: string): Promise<AdvertiserName[]> {
try {
const svc = createAdminClient("adv");
const { data } = await svc
.from("active_advertisers_by_property")
.select("client_id, company_name, company_name_lower, brand_aliases")
.eq("property", property);
return (data || []) as AdvertiserName[];
} catch {
return [];
}
}
async function fetchActiveSlots(property: string): Promise<FeaturedSlot[]> {
try {
const svc = createAdminClient("adv");
const nowIso = new Date().toISOString();
const { data } = await svc
.from("featured_story_slots")
.select("id, article_kind, article_id, article_slug_cache, position, ends_at, starts_at")
.eq("property", property)
.eq("status", "active")
.lte("starts_at", nowIso)
.gte("ends_at", nowIso)
.order("position", { ascending: true });
return (data || []) as FeaturedSlot[];
} catch {
return [];
}
}
/**
* "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: { adv: AdvertiserName; matchLen: number } | null = null;
for (const a of advertisers) {
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?.adv ?? null;
}
function withinPromoWindow(dateStr: string): boolean {
const d = new Date(dateStr);
if (isNaN(d.getTime())) return false;
const ageHours = (Date.now() - d.getTime()) / 3_600_000;
return ageHours >= 0 && ageHours <= CLIENT_PROMO_WINDOW_HOURS;
}
export interface DecoratedArticle<T extends PromotableArticle> {
article: T;
promoted_kind: PromotedKind;
/** Set only for client-promoted articles — the advertiser name we matched. */
promoted_advertiser?: string;
/** Set for premium-promoted — the slot's position number. */
promoted_position?: number;
/** ISO timestamp when this promotion expires; null for general */
promoted_until?: string | null;
}
export async function decorateHomepageFeed<T extends PromotableArticle>(
property: string,
articles: T[],
): Promise<DecoratedArticle<T>[]> {
const [advertisers, slots] = await Promise.all([
fetchActiveAdvertisers(property),
fetchActiveSlots(property),
]);
// Build slot lookup: article_slug → slot (for wp_imported we slot-key by
// slug since wp_imported_posts.id is a uuid but our admin UI typically
// pins by slug for readability).
const slotBySlug = new Map<string, FeaturedSlot>();
for (const s of slots) {
if (s.article_slug_cache) slotBySlug.set(s.article_slug_cache, s);
}
const decorated: DecoratedArticle<T>[] = articles.map((a) => {
// Premium check first
const slot = slotBySlug.get(a.slug);
if (slot) {
return {
article: a,
promoted_kind: "premium",
promoted_position: slot.position,
promoted_until: slot.ends_at,
};
}
// Client check — only stories within 48hrs of publish
if (withinPromoWindow(a.date)) {
const adv = matchAdvertiserInTitle(a.title, advertisers);
if (adv) {
const promotedUntil = new Date(new Date(a.date).getTime() + CLIENT_PROMO_WINDOW_HOURS * 3_600_000).toISOString();
return {
article: a,
promoted_kind: "client",
promoted_advertiser: adv.company_name,
promoted_until: promotedUntil,
};
}
}
return { article: a, promoted_kind: null, promoted_until: null };
});
// Sort: premium first (by position asc), then client (by date desc), then general (by date desc)
function tier(d: DecoratedArticle<T>): number {
if (d.promoted_kind === "premium") return 0;
if (d.promoted_kind === "client") return 1;
return 2;
}
function dateTs(d: DecoratedArticle<T>): number {
const t = new Date(d.article.date).getTime();
return isNaN(t) ? 0 : t;
}
decorated.sort((a, b) => {
const ta = tier(a);
const tb = tier(b);
if (ta !== tb) return ta - tb;
if (ta === 0) return (a.promoted_position ?? 999) - (b.promoted_position ?? 999);
return dateTs(b) - dateTs(a); // newest first
});
return decorated;
}