Pluggable rewrite client (Anthropic default, ollama/openrouter stubs)
using claude-opus-4-7 with prompt caching on system prompt + style guide.
Routes the raw submission through:
1. bb.ai_original_submissions — raw never edited
2. bb.ai_rewrite_jobs — every attempt logged with token counts
3. quality gates — AI-tell banned-word check + heuristic
AI-detector score (threshold 0.65)
4. regenerate up to 3x — on banned words or detector trip
5. bb.ai_rewritten_articles — status ai_rewritten_pending_review
6. /admin/review-queue — list + detail page with approve / reject /
needs-human actions; on approve, the
original submission is FK-linked to the
published rewrite
New files only — no existing routes or contributor flow modified.
Persona auto-selection routes by beat keywords; 6 personas seeded
(marcus-halverson, elena-vasquez, darren-okafor, sarah-quinn, mike-trayton,
priya-rao) with SVG monogram avatars in public/assets/images/personas/.
Smoke test (Matrox / ST 2110 sample press release):
- persona auto-routed to darren-okafor (protocols-specs)
- anthropic 22.3s, in=2616 / out=1372 tokens
- gates passed first try, score=0.000, no banned words
- review_queue row created
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Header.tsx: remove "SIGN IN" and "REGISTER" buttons from the top
utility bar and the mobile drawer. Auth for PR firms / writers lives
at distribution.relevantmediaproperties.com now; the local /client-login
and /register routes are no longer the right entry point.
- src/middleware.ts (new): 301-redirect /wp-login, /wp-login.php,
/wp-admin, /wp-admin/*, /login, /client-login, and /register to
https://distribution.relevantmediaproperties.com/login so existing
bookmarks and old WP-era links land somewhere useful instead of 404.
- Newsletter signup form, social icons, country selector all retained.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
P0-1 (featured-image fallback path):
- AppImage.tsx: default fallbackSrc changed from /assets/images/no_image.png
to /assets/images/article-placeholder.svg, so any article image that
loads but 404s gets the new clean dark gradient placeholder instead of
the old grey BB_Gray-style box. The smoke-test article
(matrox-video-and-amagi-collaborate-...) had a featured_image URL that
404s on broadcastbeat.com; it now renders the new placeholder.
P0-1 batch (Wayback recovery) is NOT done — see PENDING_ads.md. The
attempted backfill via Wayback found no snapshots for the Matrox smoke
article (it is a 2026 NAB story Wayback never crawled), and the batch
loop tripped a 403 from one of its outbound calls. Proper backfill
needs per-vendor press-kit scraping and a new bb-media storage bucket.
PENDING_ads.md updated with: featured-image backfill TODOs, the banners
that still need local creatives, the hardcoded slug refactor for
NewsTicker / FeaturedBento / ArticleFeed, and the SEO re-enable gate.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
P0-2 (Adsanity iframe path removed):
- AdImage.tsx: deleted the iframe branch and the ADSANITY_CAMPAIGN_MAP
fallback. AdImage now renders local images only; banners without a
src render nothing.
- ads.ts: removed Ad.adsanity_id from the interface. Pruned AJA, Zixi,
Lectrosonics, Opengear, Studio Hero, Magewell 300x250 and Blackmagic
300x600 — they relied on ads.broadcastbeat.com which does not resolve.
Surviving live banners: LiveU 728x90, LiveU PAYG 300x250, Telycam 300x250.
- SidebarAdStack: key no longer references the removed field.
- No banner_analytics rows reference ads.broadcastbeat.com.
Phase A (SEO baseline):
- robots.ts: fallback base URL now broadcastbeat.com (was rocket staging URL).
- sitemap.ts: fallback base URL now broadcastbeat.com (was bb-staging).
- news/[slug]/page.tsx and articles/[slug]/page.tsx: same fallback fix.
JSON-LD now uses ISO 8601 for datePublished, absolute image URL via
SITE_URL prefix, and publisher.logo points at /assets/images/logo.png
instead of the 404-ing /legacy/site/broadcastbeat-logo.png.
- layout.tsx: removed the /en /es /pt /fr /de /zh /ja /ar /hi hreflang
alternates - those routes 404 today; Phase D will reinstate. Replaced
rocket.new Organization schema URL with broadcastbeat.com. Set
robots index/follow=false until Phase B-D land and Rich Results passes.
Featured-image fallback hardening:
- legacy-source: rowToArticle now treats BB_Gray, no_image and placeholder
URLs as null and falls back to the new placeholder so dead images do not
leak through to the page.
- public/assets/images/article-placeholder.svg: new clean dark gradient
with BroadcastBeat wordmark, NOT no_image.png style.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Deploy-1 build failed because NewsPageClient now calls useSearchParams.
- src/app/news/page.tsx: wrap NewsPageClient in <Suspense> so static
generation can bail out to client rendering cleanly.
P0-6 (Featured carousel arrows):
- FeaturedBento: brighten arrow buttons (text-[#ccc] over bg-[#1a1a1a]
with #3a3a3a border; previously near-invisible #666 over the same
background), enlarge to w-8 h-8, hover fills to blue. Wrap the
arrow group with role/aria + Left/Right keyboard nav.
P0-7 (728x90 reposition):
- Header: move the leaderboard block from above the main nav to AFTER
the sticky nav, so it sits between the nav and The Beat ticker.
P0-8 (remove Latest search box):
- ArticleFeed: drop the in-section search input + clear button. The
top-right header search is now the sole search UI. searchQuery state
remains since the active-filter chip code still references it.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
P0-3 (category page crash):
- Add src/app/error.tsx and src/app/global-error.tsx so any future render
error shows a real ref id instead of the bare client-exception toast.
- NewsPageClient: null-guard every field accessed during search/category/
sort filtering and article render (title, excerpt, author, tags,
category, date, image, alt). The user-visible link
/news?category=live-production now also hydrates filter state from
?category= and ?search= search-params, so the dropdown actually filters.
P0-4 (/articles/[slug] 404s):
- /articles/[slug] and /news/[slug]: on slug miss, redirect("/news")
instead of notFound(). Dead links from the hardcoded NewsTicker /
FeaturedBento / ArticleFeed arrays now land on the news index instead
of dead-ending on 404.
- legacy-source: add searchLegacyArticles() (title/excerpt/author ilike).
P0-5 (header search):
- Header: wire Enter on the desktop and mobile search inputs and make
both search icons clickable buttons. Submits to /search?q=<query>.
- New /search route: server component that runs searchLegacyArticles
and renders results in the same card style as /news.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Banner spec from Ryan:
- 728x90 header (every page): pool is now LiveU only; rotates when more
728x90 entries are added to ads.ts.
- 728x90 below main menu on article pages: removed (was the article-top
AdSlot stub).
- 728x90 footer (every page): replaces the old 300x250 rocket.new
placeholder in Footer.tsx. Rotates from the same ADS_728X90 pool.
- 300x250 sidebar: now renders EVERY entry in ADS_300X250 stacked
vertically, shuffled per page-view. Pool: AJA, Zixi, Telycam, LiveU,
Lectrosonics, Opengear, Studio Hero, Magewell. Adding more entries to
ads.ts auto-grows the stack.
- 300x600 fixed slot: Blackmagic (Adsanity ad-210571), top of sidebar,
never rotated.
Implementation:
- ads.ts: Ad interface now allows either src (local image) or adsanity_id
(iframe). New rotateAll() returns ALL ads of a size shuffled; pickAds
kept for ArticleBody bounded slots.
- AdImage.tsx: dropped the legacy ADSANITY_CAMPAIGN_MAP label fallback —
ads.ts is now the single source of truth, so Telycam (no adsanity_id)
renders its local GIF instead of the iframe.
- SidebarAdStack.tsx: rotateAll(300x250) instead of pickAds with a
count cap. count prop removed; callers updated.
- NewsArticleDetailClient.tsx: removed article-top 728x90 stub.
- Footer.tsx: 728x90 AdImage in place of the 300x250 placeholder.
- LeaderboardAd.tsx: deleted (was unused after the earlier home-page
cleanup).
- PENDING_ads.md: rewritten — Adsanity IDs to verify (Studio Hero,
Blackmagic) and optional local-file upgrades.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Remove duplicate 728x90 LeaderboardAd below news ticker (per Ryan)
- Footer: remove NAB Official Media Partner badge
- Header: replace AdSlot stub with real AdImage rotation (3 verified 728x90s)
- Header: fix logo path from dead WP URL to local /assets/images/logo.png
- ads.ts: only include ads with verified real images (3x728x90, 2x300x250)
- ads.ts: remove 22 unrecovered ads — see PENDING_ads.md for upload list
- SidebarAdStack: handle null FIXED_300X600 gracefully (Blackmagic not recovered)
- public/legacy/ads/: 5 real ad images committed (Tower, Sony, LiveU 728x90; LiveU PAYG, Telycam 300x250)
- public/assets/images/logo.png: recovered from Wayback Machine
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Migration creates bb.banner_analytics (mirrors article_analytics: public
insert, admin read), applied separately to Supabase.
AdImage becomes a client component that:
- Fires one 'impression' event per render via IntersectionObserver at 50%
visibility (avoids counting ads scrolled past at the fold).
- Fires a 'click' event on anchor click before the new tab opens.
Renders the same <a><AppImage/></a> as before — visually unchanged. Session
ID stored in sessionStorage as bb_session_id (random uuid, lazily created).
Both parents (SidebarAdStack, ArticleBody) were already 'use client', so no
boundary shifts. Tracking is best-effort and never blocks the user.
Top-bar nav changes:
- Authenticated user previously saw two separate links 'My Account' (-> /account)
and 'My Profile' (-> /profile/{userId}). Replaced with a single 'My Profile'
link -> /account.
- Admin block previously rendered 14 inline links (WP Import, Articles, Editorial,
Users, Analytics, Audit Log, Notifications, Newsletter, Forum Seed, Company
Tracker, Show Calendar, AI Console, Banned Terms, Dashboard) which sprawled
horizontally past the available width. 9 of those are now grouped under a
single TOOLS hover dropdown matching the existing NEWS/GEAR/SHOW
COVERAGE/TECHNOLOGY pattern (Tailwind group-hover + group-focus-within for
keyboard accessibility, role=menu / menuitem, aria-haspopup=true).
- Inline admin links retained per spec: WP Import, Articles, Editorial,
Newsletter.
- TOOLS submenu: Users, Analytics, Audit Log, Notifications, Forum Seed,
Company Tracker, Show Calendar, AI Console, Banned Terms.
The whole admin tier (inline + dropdown) remains gated by the existing isAdmin
check, so anonymous and non-admin users see nothing.
Logs success payload (sans secrets — just success/migrated/legacy/uid/
email/hasToken booleans) and the post-login document.cookie state so
DevTools can show whether the auth cookie actually landed in the
browser before the /contributor redirect runs.
The browser-side createBrowserClient was writing cookies with
'Secure; SameSite=None' while the server-side createServerClient
writes the same cookie names with 'SameSite=Lax' (no Secure flag —
Next.js cookies().set() default).
When the wp-login API set the auth cookie via NextResponse, the browser
stored it with SameSite=Lax. Then the browser supabase client (via
AuthContext or any subsequent auth call) tried to re-write the cookie
via document.cookie with Secure;SameSite=None. Browsers can't reconcile
this — depending on Chrome/Safari/Firefox version, you get either:
- duplicate cookies that confuse session detection
- the JS write silently failing because Secure;SameSite=None has
additional constraints
- the existing Lax cookie getting clobbered with attributes that
conflict with same-site navigation
Net effect: AuthContext on /contributor doesn't reliably see the
session even though the cookie exists, so /contributor's
'!loading && !user → router.push(/login)' check bounces the user
back to /client-login. Looks like login failed.
Fix: align the JS-side cookie attributes with the server-side defaults
(SameSite=Lax, no Secure). Both writes now produce the same cookie
shape, no conflict.
The previous attempt at hydrating the Supabase browser client via
supabase.auth.setSession() was racing against the server-set cookie.
The server cookie was written with Path=/; SameSite=Lax (no Secure
flag — Next.js cookies().set defaults), but the browser supabase
client's setAll handler writes cookies with Secure; SameSite=None.
The two writes for the same cookie name produced inconsistent state.
Drop setSession + router.push entirely. Instead, do a hard
window.location.href = '/contributor' redirect after a successful
POST. The server renders /contributor with the freshly-set auth
cookie; the browser supabase client boots up reading it from
document.cookie on page load. No client-side hydration race.
The wp-login API sets the auth cookie via NextResponse, but a
client-side router.push() doesn't always pick up the new cookie before
the destination page's AuthContext snapshots auth state — leading to a
brief 'logged out' flash on /contributor right after a successful
login.
Fix: after a successful POST to /api/auth/wp-login, call
supabase.auth.setSession({ access_token, refresh_token }) using the
session payload returned by the API. That hydrates the Supabase
browser client (and therefore AuthContext) immediately, so the next
render is authenticated. router.refresh() is also called to invalidate
the Next.js Router Cache for any stale RSC payloads.
Also adds console.error on both the !res.ok branch and the catch block
so login failures are inspectable in DevTools.
Previously the route ran phpass validation against wp_legacy_users.
wp_hashed_password before checking auth_user_id, even for users who had
already been migrated. That meant any user whose Supabase password
diverged from the original WP hash (e.g., curated migration users like
ryan.salazar@relevantmediaproperties.com — wp_id=1 'broadcastbeat',
where the hash on the legacy row is from the migration template, not
the user's current password) would get 401 even with the correct
Supabase password.
Fix: if auth_user_id is set OR password_upgraded is true, skip phpass
and go directly to supabase.auth.signInWithPassword. The legacy hash
is only authoritative for the first-ever login (when neither flag is
set). Returns legacy:true so the client can distinguish from the
fully-native signInWithPassword fallback (legacy:false).
The /api/auth/wp-login route previously returned 401 immediately if the
email had no bb.wp_legacy_users entry. That made it impossible for
modern staff accounts (created directly via the Supabase Auth Admin
API, no WP migration history) to sign in via /client-login.
When the legacy lookup misses, fall through to a regular
supabase.auth.signInWithPassword call. If that succeeds, return a
success response with legacy=false so the client can distinguish
modern auth from a phpass-migrated session.
Existing legacy WP users keep working — the fallback only fires when
the legacy lookup returns no rows.
Reorganize the navigation:
- navLinks now carries an optional dropdown[] per section. NEWS / GEAR &
REVIEWS / SHOW COVERAGE / TECHNOLOGY get sub-items pulled from the old
flat categoryLinks list.
- Desktop main nav renders each top-level item as a hover-triggered
dropdown (CSS-only via Tailwind group-hover + group-focus-within for
keyboard accessibility). aria-haspopup advertises the popup; the panel
is role=menu with menuitem children.
- Mobile drawer's category list now groups by parent section instead of
showing a flat list.
- Removed the secondary category sub-nav row that previously sat below
the main nav (its content is in the dropdowns now).
- Added a site-wide 728x90 ad slot between the top utility bar and the
main nav (md+ only). Uses <AdSlot zone='homepage-top'> so the existing
ad-rotation pool serves it.
Net: 566 → ~600 lines (markup heavier than what was removed because the
dropdown panels are inline).
Desktop nav (and mobile drawer) now show different account-related
links depending on auth state:
- Anonymous: Sign In (-> /client-login) + Register
- Authenticated: My Account + My Profile
- Admin-tier (administrator/admin/super_admin/editor/support): plus the
full admin block (already gated by isAdmin from earlier commit)
Previously a single 'Sign In / My Account' link pointed at /account
regardless of auth state, which forced anonymous users through a
server-side bounce to the login page. Now Sign In links straight to the
canonical /client-login.
- Add src/lib/supabase/admin.ts: server-only Supabase client constructed
with SUPABASE_SERVICE_ROLE_KEY. Uses persistSession=false. Throws if
the env var is missing.
- src/app/api/auth/wp-login/route.ts: switch the auth.admin.createUser
call (step 5, transparent migration on first login) to use the new
admin client. The anon-key client cannot call admin.createUser, which
would have made first-login attempts for legacy WP users fail at
step 5 with a permission error.
- src/app/login/page.tsx: replaced the old WP-style login form with a
server-side redirect to /client-login so the new client-login is the
canonical PR-firm/contributor entry point. Existing bookmarks for
/login keep working.
Coolify app env: SUPABASE_SERVICE_ROLE_KEY added (set out-of-band; not
NEXT_PUBLIC_-prefixed so it never ships to the client bundle).
Replace the placeholder Unsplash banner array with the live AdSanity
inventory served from /legacy/ads/ (same 27 active creatives currently
on broadcastbeat.com).
New:
- src/lib/ads.ts: ad inventory (1 fixed Blackmagic 300x600, 22 rotating
300x250s, 4 leaderboard 728x90s) + Fisher-Yates shuffle + pickAds()
helper that draws N unique ads excluding any caller-supplied src set.
- src/components/AdImage.tsx: renders one ad as <a target=_blank rel=
sponsored noopener noreferrer><AppImage>.
- src/components/SidebarAdStack.tsx: Blackmagic 300x600 fixed on top,
then count rotating 300x250s. Memoizes the rotation per render so a
refresh re-shuffles. Accepts excludeSrcs to coordinate with other
slots on the same page.
- src/components/ArticleBody.tsx: splits article HTML at </p>
boundaries, splices a 300x250 ad after every Nth paragraph (default
4). Same exclude-set pattern.
Wiring:
- ArticleFeed.tsx: remove the inline sidebarAds array (was 5 Unsplash
placeholders + 1 LiveU 300x250). Sidebar now <SidebarAdStack count=5/>.
- NewsArticleDetailClient.tsx: sidebar 300x600 + 300x250 AdSlot
placeholders replaced with <SidebarAdStack count=4/>. Article body
innerHTML replaced with <ArticleBody html=... everyN=4/> so
300x250 ads slot in between paragraphs.
Same banners as the live broadcastbeat.com (sourced from the existing
WP AdSanity install we cached locally during Phase 2). Each <a> opens
in a new tab and uses rel='sponsored noopener noreferrer'.
- src/app/home-page/components/LeaderboardAd.tsx: drop the hardcoded
img.rocket.new image (rocket_gen_img_1bebfd3c7) at the top of the
homepage. Replaced with <AdSlot zone='homepage-top' size='728x90' />
to keep the slot for the eventual ad-server wiring while removing the
rocket.new dependency entirely.
- src/components/Header.tsx: re-add the isAdmin gate that was reverted
in 0d51dad. Reads bb.user_profiles.role and only renders the desktop
admin nav block (WP Import, Articles, Editorial, Users, Analytics,
Audit Log, Notifications, Newsletter, Forum Seed, Company Tracker,
Show Calendar, AI Console, Banned Terms, Dashboard) and the mobile WP
Import link when role is in (administrator, admin, super_admin,
editor, support). 'support' is admin-tier per the new RBAC matrix.
Page-level auth checks on each /admin/* route remain authoritative;
this only suppresses menu visibility for non-admin/anonymous.
Eliminates 72 hardcoded rocket.new image refs in one shot. Section pages
(/gear, /technology, /show-coverage, /news) now render from
bb.wp_imported_posts via getLegacyArticlesBySection(section), which maps
each non-news section to a curated tag set (e.g. gear -> Audio, Cameras,
lighting, Blackmagic Design, monitoring; technology -> AI, Streaming,
OTT, IP, Cloud, AV; show-coverage -> NAB, IBC, BroadcastAsia).
Also:
- src/lib/articles/types.ts: Article interface lifted out of the deleted
seed file. Type-only consumers (NewsArticleDetailClient,
ArticleDetailClient, legacy-source) re-imported from here.
- /news (client component) now wrapped: server fetches articles from DB
and passes to NewsPageClient. Filter UI unchanged.
- generateStaticParams on /news/[slug] and /articles/[slug] no longer
references seed slugs; pre-renders 50 most-recent imported slugs and
relies on dynamicParams + revalidate=3600 for the rest.
- sitemap.ts now sources article URLs from
getLegacyRecentSitemapEntries() (up to 5000 most-recent imported posts).
Default BASE_URL fallback updated from the rocket.new placeholder to
bb-staging.onsethost.com.
- src/app/api/ai/article-suggestions/route.ts now pulls candidates from
the DB (top 100 recent news) and resolves AI-returned slugs via DB
lookup if not in the candidate window.
Inline rocket.new image refs in homepage components (ArticleFeed,
FeaturedBento) are unchanged in this commit; those are inline seed
arrays in the components, not imports of sampleArticles.
- new src/lib/articles/legacy-source.ts: server-side Supabase reader
(bb schema, anon key, RLS public_read policy) that maps imported rows
to the existing Article shape, applies rewriteLegacyImageUrl for the
featured image and rewriteLegacyImageUrlsInHtml across the body.
- both [slug]/page.tsx routes now: try seed sampleArticles first; on
miss fall back to getLegacyArticleBySlug. Related articles match via
same-category recent posts when imported.
- generateStaticParams pre-renders seed slugs + 50 most recent imported
posts. dynamicParams=true means anything else server-renders on first
request and ISR caches it for 1h (revalidate=3600). Avoids a 4577-page
build.
- Hardcoded builtwithrocket.new URLs in JSON-LD replaced with
process.env.NEXT_PUBLIC_SITE_URL.
Adds <AdSlot zone size /> server component that renders a labeled
placeholder for now (size + zone exposed as data attrs for the future
bb.ad_placements lookup).
Inserted on news/[slug] article template:
- 728×90 leaderboard above the <article>
- 300×600 half-page at top of sidebar
- 300×250 mid-rectangle in sidebar (replaces the old hand-rolled
placeholder div)
- 728×90 leaderboard below the </article>, before related-articles
Same scheme should be applied to articles/[slug]/page.tsx in a follow-up
once that template's body is finalized.
Drops the rendered date span from:
- ArticleFeed.tsx (homepage feed)
- news/page.tsx (news list — also drops the orphan separator)
- news/[slug]/NewsArticleDetailClient.tsx (article header + related-articles row, drops paired separators)
- AISuggestedArticles.tsx (sidebar — keeps readTime alone)
JSON-LD datePublished/publishedTime in news/[slug]/page.tsx and
articles/[slug]/page.tsx is unchanged so search engines still see the
publication date.
src/lib/legacy-image.ts exports rewriteLegacyImageUrl(url) and
rewriteLegacyImageUrlsInHtml(html) which translate WP origin URLs
(https://www.broadcastbeat.com/wp-content/uploads/...) to local paths
under /legacy/... served from the new persistent storage volume mounted
at /app/public/legacy/.
src/lib/legacy-image-map.json holds 3,856 URL→path entries built in
Phase 2: 3,839 featured images (mostly converted to webp, q=85) plus
27 ad creatives plus the site logo.
Use this helper at server-render time when emitting <img>/<Image> for
imported WP articles, so the 707 MB on-host cache replaces external
fetches to the origin.
Header was rendering all admin links unconditionally to every visitor
(including unauthenticated). Add an isAdmin state derived from
user_profiles.role (administrator|admin|super_admin) and wrap both the
desktop admin block and the mobile WP Import link in the guard.
Note: page-level auth checks on each /admin/* route remain authoritative;
this only suppresses menu visibility.