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:
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