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

@@ -1,11 +1,11 @@
import { NextResponse } from "next/server";
import { getLatestImportedArticles } from "@/lib/articles/legacy-source";
import { decorateHomepageFeed } from "@/lib/promoted-articles";
export const revalidate = 300;
// Lowered from 300 → 60 so the promoted-story sort reflects current
// advertiser state within a minute of a campaign flip.
export const revalidate = 60;
// Homepage feed now renders every imported post; pagination + infinite-
// scroll happen client-side over the full set. Keep an absolute ceiling
// only as a safety valve, not a UX cap.
const MAX_LIMIT = 10000;
const DEFAULT_LIMIT = 100;
@@ -20,7 +20,7 @@ export async function GET(request: Request) {
const { items, total } = await getLatestImportedArticles(limit, offset);
const payload = items.map((a) => ({
const base = items.map((a) => ({
title: a.title,
excerpt: a.excerpt,
category: a.category,
@@ -33,6 +33,16 @@ export async function GET(request: Request) {
source: "imported" as const,
}));
// Decorate + reorder so premium and current-advertiser stories surface
// at the top of the feed.
const decorated = await decorateHomepageFeed("broadcastbeat", base);
const payload = decorated.map((d) => ({
...d.article,
promoted_kind: d.promoted_kind,
promoted_advertiser: d.promoted_advertiser || null,
promoted_until: d.promoted_until || null,
}));
return NextResponse.json(
{
items: payload,
@@ -43,7 +53,7 @@ export async function GET(request: Request) {
},
{
headers: {
"Cache-Control": "public, s-maxage=300, stale-while-revalidate=600",
"Cache-Control": "public, s-maxage=60, stale-while-revalidate=120",
},
},
);

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}

View File

@@ -0,0 +1,177 @@
/**
* 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;
}
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")
.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 [];
}
}
/**
* 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.
*/
function matchAdvertiserInTitle(title: string, advertisers: AdvertiserName[]): AdvertiserName | null {
if (!title || advertisers.length === 0) return null;
const t = title.toLowerCase();
let best: AdvertiserName | 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;
}
}
return best;
}
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;
}

View File

@@ -8,7 +8,7 @@ import { createClient } from "@supabase/supabase-js";
* Needs SUPABASE_SERVICE_ROLE_KEY in the env (set as a Coolify app env var,
* NOT prefixed with NEXT_PUBLIC_ so it never ships to the client bundle).
*/
export function createAdminClient() {
export function createAdminClient(schema?: string) {
const url = process.env.NEXT_PUBLIC_SUPABASE_URL;
const key = process.env.SUPABASE_SERVICE_ROLE_KEY;
if (!url || !key) {
@@ -18,6 +18,6 @@ export function createAdminClient() {
}
return createClient(url, key, {
auth: { persistSession: false, autoRefreshToken: false },
db: { schema: process.env.NEXT_PUBLIC_SUPABASE_SCHEMA || "bb" },
db: { schema: schema || process.env.NEXT_PUBLIC_SUPABASE_SCHEMA || "bb" },
});
}