From c1ab36297c61d5c207c27aa71dba0243dea9d93b Mon Sep 17 00:00:00 2001 From: Ryan Salazar Date: Wed, 27 May 2026 12:25:20 +0000 Subject: [PATCH] homepage: auto-promote advertiser stories + premium slots in Industry News MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/app/api/public/posts/route.ts | 22 ++- src/app/home-page/components/ArticleFeed.tsx | 34 +++- src/lib/promoted-articles.ts | 177 +++++++++++++++++++ src/lib/supabase/admin.ts | 4 +- 4 files changed, 227 insertions(+), 10 deletions(-) create mode 100644 src/lib/promoted-articles.ts diff --git a/src/app/api/public/posts/route.ts b/src/app/api/public/posts/route.ts index 03237bb..5e80a28 100644 --- a/src/app/api/public/posts/route.ts +++ b/src/app/api/public/posts/route.ts @@ -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", }, }, ); diff --git a/src/app/home-page/components/ArticleFeed.tsx b/src/app/home-page/components/ArticleFeed.tsx index 79c72e1..c1a3356 100644 --- a/src/app/home-page/components/ArticleFeed.tsx +++ b/src/app/home-page/components/ArticleFeed.tsx @@ -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 ( -
+
+ {promoted === 'premium' && ( + + ★ Featured + + )} + {promoted === 'client' && ( + + ◆ Advertiser + + )} {article?.category && article.category.toLowerCase() !== "legacy" && ( {article.category} diff --git a/src/lib/promoted-articles.ts b/src/lib/promoted-articles.ts new file mode 100644 index 0000000..81470c8 --- /dev/null +++ b/src/lib/promoted-articles.ts @@ -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 { + 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 { + 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 { + 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( + property: string, + articles: T[], +): Promise[]> { + 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(); + for (const s of slots) { + if (s.article_slug_cache) slotBySlug.set(s.article_slug_cache, s); + } + + const decorated: DecoratedArticle[] = 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): number { + if (d.promoted_kind === "premium") return 0; + if (d.promoted_kind === "client") return 1; + return 2; + } + function dateTs(d: DecoratedArticle): 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; +} diff --git a/src/lib/supabase/admin.ts b/src/lib/supabase/admin.ts index 5c61b38..4531110 100644 --- a/src/lib/supabase/admin.ts +++ b/src/lib/supabase/admin.ts @@ -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" }, }); }