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>
283 lines
9.0 KiB
TypeScript
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;
|
|
}
|