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:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user