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:
@@ -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",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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}
|
||||
|
||||
177
src/lib/promoted-articles.ts
Normal file
177
src/lib/promoted-articles.ts
Normal 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;
|
||||
}
|
||||
@@ -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" },
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user