- Replaces the prior Rocket scaffold (saved on pre-bb-clone-rollback branch) - Domain: broadcastbeat.com -> avbeat.com - Brand: Broadcast Beat -> AV Beat - Slug: broadcastbeat -> avbeat (package, identifiers) - Schema fallback: bb -> av (env var name unchanged) - Placeholder AV BEAT logo (gold->red gradient) at /assets/logos/av.svg - All BB/RMP logo references repointed Outstanding before public DNS swap: - Supabase av schema bootstrap (mirror of bb tables) - WordPress archive import (avbeat-com -> av.articles) - Coolify env vars - dev-avbeat.onsethost.com staging deploy + visual review
129 lines
4.3 KiB
TypeScript
129 lines
4.3 KiB
TypeScript
import { NextResponse, type NextRequest } from "next/server";
|
|
|
|
// PR firms / writers authenticate at the distribute platform, not here.
|
|
// Legacy WordPress URLs still bounce there. /login is now a real BB login
|
|
// page so the admin backend can use its own session (the BB Supabase cookie
|
|
// doesn't transfer from distribute's domain).
|
|
const AUTH_TARGET = "https://distribution.relevantmediaproperties.com/login";
|
|
|
|
const REDIRECT_PATHS = new Set<string>([
|
|
"/wp-login",
|
|
"/wp-login.php",
|
|
"/wp-admin",
|
|
"/client-login",
|
|
]);
|
|
|
|
// ─── Server-side impression beacon (AdSanity-equivalent) ────────────────────
|
|
//
|
|
// Why: client-side beacons drop 20-50% to ad blockers, JS-disabled clients,
|
|
// and lost beacons on fast navigation. AdSanity counted server-side — every
|
|
// WP page render = 1 impression per zone. To get parity with the historical
|
|
// numbers our advertisers expect, we fire impressions here on every HTML
|
|
// page request.
|
|
//
|
|
// For each slot (728x90, 300x600, 300x250) we pick ONE banner from the
|
|
// rotation pool per page render — same model AdSanity used. The rotation
|
|
// pool is fetched from advertising-rmp and cached per-edge-worker for 5min.
|
|
|
|
const RMP_BASE =
|
|
(process.env.NEXT_PUBLIC_RMP_ADS_API
|
|
|| "https://advertising.relevantmediaproperties.com").replace(/\/$/, "");
|
|
|
|
const PROPERTY = "avbeat";
|
|
const SLOTS = ["728x90", "300x600", "300x250"] as const;
|
|
type Slot = typeof SLOTS[number];
|
|
|
|
// Edge worker-local cache. Each region's edge will refresh independently.
|
|
let poolCache: Record<Slot, string[]> = { "728x90": [], "300x600": [], "300x250": [] };
|
|
let poolLoadedAt = 0;
|
|
const POOL_TTL_MS = 5 * 60 * 1000;
|
|
|
|
async function loadPool() {
|
|
if (Date.now() - poolLoadedAt < POOL_TTL_MS) return;
|
|
try {
|
|
const results = await Promise.all(
|
|
SLOTS.map(async (size) => {
|
|
const res = await fetch(`${RMP_BASE}/api/banners/${PROPERTY}/${size}`, {
|
|
cache: "no-store",
|
|
});
|
|
if (!res.ok) return [size, [] as string[]] as const;
|
|
const d = (await res.json()) as { banners?: Array<{ campaign_id?: string }> };
|
|
return [size, (d.banners || []).map((b) => b.campaign_id).filter(Boolean) as string[]] as const;
|
|
})
|
|
);
|
|
const next: Record<Slot, string[]> = { "728x90": [], "300x600": [], "300x250": [] };
|
|
for (const [size, ids] of results) next[size] = ids;
|
|
poolCache = next;
|
|
poolLoadedAt = Date.now();
|
|
} catch {
|
|
// keep stale cache on failure
|
|
}
|
|
}
|
|
|
|
// Paths that don't count as a "page view" — assets, APIs, edge probes.
|
|
const SKIP_RE = /^\/(api|_next|legacy|favicon|robots\.txt|sitemap|opensearch|.*\.(?:css|js|map|png|jpg|jpeg|gif|webp|svg|ico|woff|woff2|ttf|eot|mp4|webm|xml|txt|json))$/i;
|
|
|
|
function fireImpression(req: NextRequest, campaign_id: string) {
|
|
const url = new URL(req.url);
|
|
fetch(`${RMP_BASE}/api/track/impression`, {
|
|
method: "POST",
|
|
headers: {
|
|
"content-type": "application/json",
|
|
"user-agent": req.headers.get("user-agent") || "",
|
|
"x-forwarded-for":
|
|
req.headers.get("x-forwarded-for") ||
|
|
req.headers.get("cf-connecting-ip") ||
|
|
"",
|
|
"referer": req.headers.get("referer") || "",
|
|
},
|
|
body: JSON.stringify({
|
|
campaign_id,
|
|
page_path: url.pathname,
|
|
page_url: req.url,
|
|
viewport: false,
|
|
}),
|
|
cache: "no-store",
|
|
keepalive: true,
|
|
}).catch(() => undefined);
|
|
}
|
|
|
|
export async function middleware(req: NextRequest) {
|
|
const path = req.nextUrl.pathname;
|
|
|
|
// 1) auth redirects (legacy WP-style logins)
|
|
if (
|
|
REDIRECT_PATHS.has(path) ||
|
|
path.startsWith("/wp-admin/") ||
|
|
path.startsWith("/wp-login/")
|
|
) {
|
|
return NextResponse.redirect(AUTH_TARGET, 301);
|
|
}
|
|
|
|
// 2) server-side impression beacons per page render
|
|
if (!SKIP_RE.test(path)) {
|
|
await loadPool();
|
|
for (const slot of SLOTS) {
|
|
const pool = poolCache[slot];
|
|
if (pool.length === 0) continue;
|
|
const picked = pool[Math.floor(Math.random() * pool.length)];
|
|
fireImpression(req, picked);
|
|
}
|
|
}
|
|
|
|
return NextResponse.next();
|
|
}
|
|
|
|
// Match every path (impression beacon needs site-wide coverage) — the
|
|
// SKIP_RE inside the handler filters out asset/API requests so we only
|
|
// count real page views.
|
|
export const config = {
|
|
matcher: [
|
|
"/((?!api/|_next/|legacy/|.*\\.).*)",
|
|
"/wp-login",
|
|
"/wp-login.php",
|
|
"/wp-admin/:path*",
|
|
"/wp-login/:path*",
|
|
"/client-login",
|
|
],
|
|
};
|