av: clone broadcastbeat sources + AV Beat rebrand (Phase 1)

- 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
This commit is contained in:
Claude
2026-06-02 15:32:56 +00:00
parent dd93a74bdf
commit d43f78b161
137 changed files with 4728 additions and 4541 deletions

View File

@@ -1,7 +1,7 @@
// Shared lib for RMP accounting — Supabase helpers and types
import { createClient } from '@/lib/supabase/server';
export const RMP_SITES = ['broadcastbeat', 'avbeat', 'backlotbeat', 'broadcastengineering'] as const;
export const RMP_SITES = ['avbeat', 'avbeat', 'backlotbeat', 'broadcastengineering'] as const;
export type RmpSite = typeof RMP_SITES[number];
export const EXPENSE_CATEGORIES = [

View File

@@ -3,13 +3,13 @@
* Publications, placement zones, sender whitelist, rules
*/
export const ADOPS_MONITORED_EMAIL = 'adops@broadcastbeat.com';
export const ADOPS_MONITORED_EMAIL = 'adops@avbeat.com';
export const RYAN_EMAIL = 'ryan.salazar@relevantmediaproperties.com';
export const JEFF_EMAIL = 'jeff@broadcastbeat.com';
export const JEFF_EMAIL = 'jeff@avbeat.com';
export const SENDER_WHITELIST = [
'jeff@broadcastbeat.com',
'sales@broadcastbeat.com',
'jeff@avbeat.com',
'sales@avbeat.com',
'ryan.salazar@relevantmediaproperties.com',
];
@@ -47,9 +47,9 @@ export interface Publication {
export const PUBLICATIONS: Publication[] = [
{
id: 'broadcast-beat',
name: 'Broadcast Beat',
name: 'AV Beat',
shortName: 'BB',
domain: 'broadcastbeat.com',
domain: 'avbeat.com',
enabled: true,
zones: [
{ id: 'bb_leaderboard_top', label: 'Leaderboard Top', width: 728, height: 90 },

View File

@@ -63,7 +63,7 @@ export async function sendToJeffAndRyan(params: {
attachments?: SendEmailParams['attachments'];
}): Promise<{ success: boolean; error?: string }> {
return sendAdOpsEmail({
to: 'jeff@broadcastbeat.com',
to: 'jeff@avbeat.com',
cc: [RYAN_EMAIL],
subject: params.subject,
html: params.html,

View File

@@ -289,15 +289,15 @@ export function postSendReportEmailJeff(params: {
export function jeffWelcomeEmail(): { subject: string; html: string; to: string; cc: string } {
return {
to: 'jeff@broadcastbeat.com',
to: 'jeff@avbeat.com',
cc: 'ryan.salazar@relevantmediaproperties.com',
subject: 'Your New Ad Ops System is Ready',
html: emailWrapper(`
<p>Hi Jeff — your new advertising operations system is ready. Here's how to use it:</p>
<h3 style="color: #2c3e50; margin-top: 20px;">BANNER ADS</h3>
<p>Email <a href="mailto:adops@broadcastbeat.com">adops@broadcastbeat.com</a> with the banner image attached and tell me: which publication, which placement, the click-through URL, and the campaign dates. I'll handle the rest and send you a screenshot for approval before anything goes live.</p>
<p>Email <a href="mailto:adops@avbeat.com">adops@avbeat.com</a> with the banner image attached and tell me: which publication, which placement, the click-through URL, and the campaign dates. I'll handle the rest and send you a screenshot for approval before anything goes live.</p>
<h3 style="color: #2c3e50; margin-top: 20px;">EMAIL DISTRIBUTIONS</h3>
<p>Email <a href="mailto:adops@broadcastbeat.com">adops@broadcastbeat.com</a> with the HTML file attached along with the subject line, send date, and recipient list. I'll send you a test email for approval before sending to the full list.</p>
<p>Email <a href="mailto:adops@avbeat.com">adops@avbeat.com</a> with the HTML file attached along with the subject line, send date, and recipient list. I'll send you a test email for approval before sending to the full list.</p>
<h3 style="color: #2c3e50; margin-top: 20px;">INSERTION ORDERS</h3>
<p>Please attach the signed IO to your campaign email. I'll store it automatically and create the invoice.</p>
<p style="margin-top: 20px;">Questions? Just reply to this email and I'll take care of everything.</p>

View File

@@ -1,6 +1,6 @@
/**
* IMAP Email Monitor
* Polls adops@broadcastbeat.com via Kerio Connect IMAP every 5 minutes.
* Polls adops@avbeat.com via Kerio Connect IMAP every 5 minutes.
* Credentials stored as env vars (to be added after deployment).
*/
@@ -216,7 +216,7 @@ async function handleBannerRequest(
const requiredFields = [
{ key: 'clientName', label: 'Client/advertiser name' },
{ key: 'publication', label: 'Which publication(s) to run on (Broadcast Beat / AV Beat)' },
{ key: 'publication', label: 'Which publication(s) to run on (AV Beat / AV Beat)' },
{ key: 'placementZone', label: 'Ad placement zone (leaderboard, sidebar, in-article, etc.)' },
{ key: 'destinationUrl', label: 'Click-through URL' },
{ key: 'startDate', label: 'Campaign start date' },
@@ -351,7 +351,7 @@ async function handleDistributionRequest(
const requiredFields = [
{ key: 'subject', label: 'Email subject line' },
{ key: 'listName', label: 'Recipient list (e.g. "Broadcast Beat full list")' },
{ key: 'listName', label: 'Recipient list (e.g. "AV Beat full list")' },
{ key: 'sendDate', label: 'Preferred send date and time (default: Thursday 11:00 AM ET)' },
{ key: 'clientName', label: 'Client name (for tracking and invoicing)' },
];

View File

@@ -23,7 +23,7 @@ export interface Ad {
const SUPABASE_URL = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const SUPABASE_ANON = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
const SCHEMA = process.env.NEXT_PUBLIC_SUPABASE_SCHEMA || "bb";
const SCHEMA = process.env.NEXT_PUBLIC_SUPABASE_SCHEMA || "av";
// 30s in-process cache so admin uploads show on the site within ~30s.
// The /api/banners endpoint also caps at 30s, so worst-case from upload
@@ -80,7 +80,7 @@ interface DbRow {
// We fetch all three sizes once and merge.
const RMP_API = process.env.NEXT_PUBLIC_RMP_ADS_API
|| "https://advertising.relevantmediaproperties.com";
const PROPERTY = process.env.NEXT_PUBLIC_RMP_PROPERTY || "broadcastbeat";
const PROPERTY = process.env.NEXT_PUBLIC_RMP_PROPERTY || "avbeat";
interface RmpBanner {
campaign_id: string;

View File

@@ -75,16 +75,16 @@ Length:
export function buildSystemPrompt(p: Persona): string {
const writingStyle = p.writing_style || "Trade-press, plain English, AP style.";
const bio = p.bio || "Broadcast Beat staff writer.";
const bio = p.bio || "AV Beat staff writer.";
const beat = p.beat || "broadcast technology";
return `You are ${p.name}, a staff journalist for Broadcast Beat covering ${beat}.
return `You are ${p.name}, a staff journalist for AV Beat covering ${beat}.
Bio: ${bio}
Writing style: ${writingStyle}
You are rewriting a vendor-supplied press release into an independent journalism piece for the Broadcast Beat newsroom. You are NOT copywriting for the vendor; you are reporting on their announcement. The Broadcast Beat audience are broadcast engineers, post-production supervisors, streaming architects, and broadcast executives — they read trade press to find out what is true, not to be sold to.
You are rewriting a vendor-supplied press release into an independent journalism piece for the AV Beat newsroom. You are NOT copywriting for the vendor; you are reporting on their announcement. The AV Beat audience are broadcast engineers, post-production supervisors, streaming architects, and broadcast executives — they read trade press to find out what is true, not to be sold to.
Hard rules:
- Never use the literal text from the source as the lede. Rewrite the lede.

View File

@@ -170,7 +170,7 @@ export async function rewriteSubmission(opts: RewriteOptions): Promise<RewriteOu
// so the rewrite doesn't collide with WP-imported sequence values.
try {
const featuredImage = sub.metadata?.featured_image_url || null;
const authorName = persona.name || sub.contributor_name || "Broadcast Beat";
const authorName = persona.name || sub.contributor_name || "AV Beat";
const finalSlug = `${slug}-rw-${(art as any).id.toString().slice(0, 6)}`;
const wpRow = {
wp_id: -Math.floor(Date.now() / 1000) - attempt,

View File

@@ -4,7 +4,7 @@ import { rewriteLegacyImageUrl, cleanLegacyImages } from "@/lib/legacy-image";
const SUPABASE_URL = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const SUPABASE_ANON = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
const SCHEMA = process.env.NEXT_PUBLIC_SUPABASE_SCHEMA || "bb";
const SCHEMA = process.env.NEXT_PUBLIC_SUPABASE_SCHEMA || "av";
function client() {
return createClient(SUPABASE_URL, SUPABASE_ANON, {
@@ -129,7 +129,7 @@ function rowToArticle(row: ImportedPostRow, section: Section = "news"): Article
? rewriteLegacyImageUrl(row.featured_image)
: FALLBACK_IMAGE;
const content = cleanLegacyImages(row.content || "");
const author = row.author_name || "Broadcast Beat";
const author = row.author_name || "AV Beat";
return {
slug: row.wp_slug,
section,
@@ -162,7 +162,7 @@ function inferSectionFromCategory(cat: string | null): Section {
function rewriteRowToArticle(row: RewriteRow, sectionOverride?: Section): Article {
const sortedPublishedAt = row.published_at || row.created_at;
const persona = Array.isArray(row.persona) ? row.persona[0] || null : row.persona || null;
const author = persona?.name || "Broadcast Beat";
const author = persona?.name || "AV Beat";
const personaSlug = persona?.slug || "staff-reporter";
const avatar = persona?.avatar_url || FALLBACK_IMAGE;
return {
@@ -174,7 +174,7 @@ function rewriteRowToArticle(row: RewriteRow, sectionOverride?: Section): Articl
date: formatDate(sortedPublishedAt),
author,
authorSlug: personaSlug,
authorTitle: persona?.beat ? humanBeat(persona.beat) : "Broadcast Beat",
authorTitle: persona?.beat ? humanBeat(persona.beat) : "AV Beat",
authorAvatar: avatar,
image: REWRITE_FALLBACK_IMAGE,
alt: row.title,

View File

@@ -1,5 +1,5 @@
/**
* Role-based permission utilities for Broadcast Beat
* Role-based permission utilities for AV Beat
* Mirrors the WordPress role structure exactly.
*/
@@ -224,7 +224,7 @@ export function getCategoriesForSite(role: string, siteId: number): string[] {
}
export const SITE_CONFIG = {
[SITE_IDS.BROADCAST_BEAT]: { name: 'Broadcast Beat', badge: 'BB', color: '#0ea5e9' },
[SITE_IDS.BROADCAST_BEAT]: { name: 'AV Beat', badge: 'BB', color: '#0ea5e9' },
[SITE_IDS.AV_BEAT]: { name: 'AV Beat', badge: 'AV', color: '#f97316' },
} as const;

View File

@@ -2,7 +2,7 @@
* RMP Content Pipeline — Article Generation Engine
*
* Handles article generation for all 4 RMP sites:
* - Broadcast Beat (15-25/day)
* - AV Beat (15-25/day)
* - AV Beat (10-15/day)
* - Backlot Beat (10-15/day)
* - BroadcastEngineering.com (10-15/day)
@@ -47,7 +47,7 @@ export interface PressReleaseRewrite {
export const SITE_CONFIGS: Record<RMPSite, SiteConfig> = {
'broadcast-beat': {
name: 'broadcast-beat',
displayName: 'Broadcast Beat',
displayName: 'AV Beat',
dailyTarget: { min: 15, max: 25 },
topics: [
'broadcast technology', 'production gear', 'industry news', 'NAB Show coverage',

View File

@@ -49,6 +49,6 @@ export function verifyEmailToken(token: string): string | null {
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
}
export function pixelUrl(trackedEmailId: string, baseUrl = "https://broadcastbeat.com"): string {
export function pixelUrl(trackedEmailId: string, baseUrl = "https://avbeat.com"): string {
return `${baseUrl.replace(/\/+$/, "")}/pix/${signEmailToken(trackedEmailId)}.png`;
}

File diff suppressed because it is too large Load Diff

View File

@@ -3,22 +3,22 @@ import map from "./legacy-image-map.json";
const LEGACY_MAP = map as Record<string, string>;
const LEGACY_HOSTS = [
"https://www.broadcastbeat.com",
"https://broadcastbeat.com",
"http://www.broadcastbeat.com",
"http://broadcastbeat.com",
"https://www.avbeat.com",
"https://avbeat.com",
"http://www.avbeat.com",
"http://avbeat.com",
];
export function rewriteLegacyImageUrl(url: string | null | undefined): string {
if (!url) return "";
// Direct map hit (canonical https://www.broadcastbeat.com/...)
// Direct map hit (canonical https://www.avbeat.com/...)
const direct = LEGACY_MAP[url];
if (direct) return direct;
// Try alternate-host normalization
for (const host of LEGACY_HOSTS) {
if (url.startsWith(host)) {
const path = url.slice(host.length);
const canonical = `https://www.broadcastbeat.com${path}`;
const canonical = `https://www.avbeat.com${path}`;
const hit = LEGACY_MAP[canonical];
if (hit) return hit;
}
@@ -28,7 +28,7 @@ export function rewriteLegacyImageUrl(url: string | null | undefined): string {
export function rewriteLegacyImageUrlsInHtml(html: string): string {
let out = html.replace(
/(https?:\/\/(?:www\.)?broadcastbeat\.com\/wp-content\/uploads\/[^"'\s)]+)/g,
/(https?:\/\/(?:www\.)?avbeat\.com\/wp-content\/uploads\/[^"'\s)]+)/g,
(m) => rewriteLegacyImageUrl(m),
);
// Rewrite raw Supabase Storage URLs to clean first-party /img/<bucket>/<path>
@@ -51,21 +51,21 @@ export function stripDeadLegacyImages(html: string): string {
if (!html) return html;
// Match <img> tags whose src still hits the dead WP host.
const DEAD_HOST_RE = /https?:\/\/(?:www\.)?broadcastbeat\.com\/wp-content\/uploads\//i;
const DEAD_HOST_RE = /https?:\/\/(?:www\.)?avbeat\.com\/wp-content\/uploads\//i;
// Drop entire <figure>...</figure> blocks whose only inner content is a
// dead-image <img>. Preserves figures that wrap working images.
let out = html.replace(/<figure\b[^>]*>([\s\S]*?)<\/figure>/gi, (full, inner) => {
if (!/<img\b[^>]*\bsrc\s*=\s*["'][^"']*broadcastbeat\.com\/wp-content\/uploads\/[^"']*["']/i.test(inner)) return full;
if (!/<img\b[^>]*\bsrc\s*=\s*["'][^"']*avbeat\.com\/wp-content\/uploads\/[^"']*["']/i.test(inner)) return full;
// figure contains a dead image — drop entirely
return "";
});
// Drop <a>…<img dead …>…</a> wrappers
out = out.replace(/<a\b[^>]*>\s*<img\b[^>]*\bsrc\s*=\s*["'][^"']*broadcastbeat\.com\/wp-content\/uploads\/[^"']*["'][^>]*\/?>\s*<\/a>/gi, "");
out = out.replace(/<a\b[^>]*>\s*<img\b[^>]*\bsrc\s*=\s*["'][^"']*avbeat\.com\/wp-content\/uploads\/[^"']*["'][^>]*\/?>\s*<\/a>/gi, "");
// Drop bare <img dead …>
out = out.replace(/<img\b[^>]*\bsrc\s*=\s*["'][^"']*broadcastbeat\.com\/wp-content\/uploads\/[^"']*["'][^>]*\/?>/gi, "");
out = out.replace(/<img\b[^>]*\bsrc\s*=\s*["'][^"']*avbeat\.com\/wp-content\/uploads\/[^"']*["'][^>]*\/?>/gi, "");
// Drop empty <figure></figure> left over after image removal.
out = out.replace(/<figure\b[^>]*>\s*(?:<figcaption\b[^>]*>\s*<\/figcaption>\s*)?<\/figure>/gi, "");

View File

@@ -22,7 +22,7 @@ async function sendEmail(payload: MarketplaceEmailPayload): Promise<void> {
export async function sendProfileApprovedEmail(to: string, name: string): Promise<void> {
await sendEmail({
to,
subject: 'Your Broadcast Beat Marketplace Profile is Approved!',
subject: 'Your AV Beat Marketplace Profile is Approved!',
html: `
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto; background: #0d0d0d; color: #fff; padding: 32px; border-radius: 8px;">
<div style="text-align: center; margin-bottom: 24px;">
@@ -32,7 +32,7 @@ export async function sendProfileApprovedEmail(to: string, name: string): Promis
<h2 style="color: #fff; font-size: 20px;">Welcome to the Marketplace, ${name}!</h2>
<p style="color: #ccc; line-height: 1.6;">
Your profile has been reviewed and approved by our team. You are now live in the
Broadcast Beat Production Marketplace and visible to clients, employers, and production companies.
AV Beat Production Marketplace and visible to clients, employers, and production companies.
</p>
<div style="background: #1a1a1a; border-left: 4px solid #CC0000; padding: 16px; margin: 24px 0; border-radius: 4px;">
<p style="color: #fff; margin: 0; font-weight: bold;">Next Steps:</p>
@@ -47,8 +47,8 @@ export async function sendProfileApprovedEmail(to: string, name: string): Promis
View Your Profile
</a>
<p style="color: #666; font-size: 12px; margin-top: 32px;">
Broadcast Beat — Official NAB Show Media Partner<br>
<a href="${process.env.NEXT_PUBLIC_SITE_URL}" style="color: #CC0000;">broadcastbeat.com</a>
AV Beat — Official NAB Show Media Partner<br>
<a href="${process.env.NEXT_PUBLIC_SITE_URL}" style="color: #CC0000;">avbeat.com</a>
</p>
</div>
`,
@@ -62,7 +62,7 @@ export async function sendProfileRejectedEmail(
): Promise<void> {
await sendEmail({
to,
subject: 'Update Required: Your Broadcast Beat Marketplace Profile',
subject: 'Update Required: Your AV Beat Marketplace Profile',
html: `
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto; background: #0d0d0d; color: #fff; padding: 32px; border-radius: 8px;">
<div style="text-align: center; margin-bottom: 24px;">
@@ -86,7 +86,7 @@ export async function sendProfileRejectedEmail(
Update My Profile
</a>
<p style="color: #666; font-size: 12px; margin-top: 32px;">
Questions? Reply to this email or contact us at <a href="mailto:marketplace@broadcastbeat.com" style="color: #CC0000;">marketplace@broadcastbeat.com</a>
Questions? Reply to this email or contact us at <a href="mailto:marketplace@avbeat.com" style="color: #CC0000;">marketplace@avbeat.com</a>
</p>
</div>
`,
@@ -112,7 +112,7 @@ export async function sendListingConfirmationEmail(
Your ${listingType === 'job' ? 'Job Post' : 'Gig Request'} is Live!
</h2>
<p style="color: #ccc; line-height: 1.6;">
<strong style="color: #fff;">"${title}"</strong> has been posted to the Broadcast Beat Marketplace
<strong style="color: #fff;">"${title}"</strong> has been posted to the AV Beat Marketplace
and is now visible to our network of broadcast and production professionals.
</p>
<a href="${process.env.NEXT_PUBLIC_SITE_URL}/marketplace/${listingType === 'job' ? 'jobs' : 'gigs'}"
@@ -120,7 +120,7 @@ export async function sendListingConfirmationEmail(
View ${listingType === 'job' ? 'Job Board' : 'Gig Board'}
</a>
<p style="color: #666; font-size: 12px; margin-top: 32px;">
Broadcast Beat — Official NAB Show Media Partner
AV Beat — Official NAB Show Media Partner
</p>
</div>
`,

View File

@@ -18,6 +18,6 @@ export function createAdminClient(schema?: string) {
}
return createClient(url, key, {
auth: { persistSession: false, autoRefreshToken: false },
db: { schema: schema || process.env.NEXT_PUBLIC_SUPABASE_SCHEMA || "avb" },
db: { schema: schema || process.env.NEXT_PUBLIC_SUPABASE_SCHEMA || "av" },
});
}

View File

@@ -5,7 +5,7 @@ export function createClient() {
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
{
db: { schema: process.env.NEXT_PUBLIC_SUPABASE_SCHEMA || 'avb' },
db: { schema: process.env.NEXT_PUBLIC_SUPABASE_SCHEMA || 'bb' },
cookies: {
getAll() {
if (typeof document === 'undefined') return [];

View File

@@ -8,7 +8,7 @@ export async function createClient() {
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
{
db: { schema: process.env.NEXT_PUBLIC_SUPABASE_SCHEMA || 'avb' },
db: { schema: process.env.NEXT_PUBLIC_SUPABASE_SCHEMA || 'bb' },
cookies: {
getAll() {
return cookieStore.getAll();