homepage: auto-promote advertiser stories + premium slots in Industry News

Three-tier sort on the Industry News feed:
1. Premium — articles pinned via adv.featured_story_slots (paid slots).
   Sorted by position ascending. Highlighted with an amber left-border
   gradient + ★ Featured ribbon.
2. Advertiser — articles whose title contains the company name of a
   current active banner advertiser on this property, AND were published
   in the last 48 hours. Highlighted with a blue left-border gradient
   + ◆ Advertiser ribbon. Multiple matches ordered by publish date.
3. General — everything else, newest first.

After 48h, advertiser stories naturally drop back into the general
tier. Premium slots fall off when their ends_at passes.

Detection is title-only with case-insensitive word-boundary match; the
longest matching company name wins so "Sony Pictures" beats "Sony"
when both advertise. Backed by the new adv.active_advertisers_by_
property view.

createAdminClient() now accepts an optional schema arg so we can hit
adv from BB without juggling clients.

API revalidate dropped 300 → 60s so the feed reflects campaign flips
within a minute.
This commit is contained in:
Ryan Salazar
2026-05-27 12:25:20 +00:00
parent d82b8edd21
commit c1ab36297c
4 changed files with 227 additions and 10 deletions

View File

@@ -23,6 +23,9 @@ interface ArticleItem {
alt: string;
source: "live" | "imported";
externalUrl?: string;
promoted_kind?: "premium" | "client" | null;
promoted_advertiser?: string | null;
promoted_until?: string | null;
}
@@ -179,8 +182,19 @@ export default function ArticleFeed() {
return matchCat && matchSource && matchSearch && matchAuthor && matchDate;
});
// Sort filtered articles based on sortOrder
// Sort filtered articles based on sortOrder. For every order we honor
// the promoted-tier hierarchy first (premium > advertiser > general) so
// paid placements and advertiser news always lead. Within each tier the
// user's chosen sort applies.
function promotedTier(a: ArticleItem): number {
if (a.promoted_kind === "premium") return 0;
if (a.promoted_kind === "client") return 1;
return 2;
}
const sortedFiltered = [...filtered].sort((a, b) => {
const ta = promotedTier(a);
const tb = promotedTier(b);
if (ta !== tb) return ta - tb;
if (sortOrder === "recent") {
const da = parseArticleDate(a.date);
const db = parseArticleDate(b.date);
@@ -566,9 +580,15 @@ export default function ArticleFeed() {
? mobileInfeedAds[Math.floor((i + 1) / MOBILE_AD_EVERY_N - 1) % mobileInfeedAds.length]
: null;
const promoted = article?.promoted_kind;
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-[#3b82f6] bg-gradient-to-r from-[#3b82f6]/8 to-transparent pl-3 -ml-2 mr-0'
: '';
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">
<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}`}>
<Link
href={articleHref}
{...linkProps}
@@ -587,6 +607,16 @@ export default function ArticleFeed() {
</Link>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-1.5 md:gap-2 mb-1 md:mb-1.5 flex-wrap">
{promoted === 'premium' && (
<span className="font-body text-[9px] font-bold text-amber-300 bg-amber-500/20 border border-amber-500/40 px-1.5 py-0.5 rounded uppercase tracking-widest">
Featured
</span>
)}
{promoted === 'client' && (
<span className="font-body text-[9px] font-bold text-[#7aa7d4] bg-[#3b82f6]/15 border border-[#3b82f6]/40 px-1.5 py-0.5 rounded uppercase tracking-widest" title={article?.promoted_advertiser ? `Advertiser coverage: ${article.promoted_advertiser}` : 'Advertiser coverage'}>
Advertiser
</span>
)}
{article?.category && article.category.toLowerCase() !== "legacy" && (
<span className="font-body text-[10px] font-bold text-[#3b82f6] uppercase tracking-wide">
{article.category}