homepage: remove top bar + fix subscribe + Staff Editorial header

1. Remove SystemStatusBar from root layout. The "29,220 articles
   indexed / 13 events tracked / [v2.0.0]" strip ate vertical space
   above the fold without serving readers.

2. /api/newsletter/subscribe was returning 500 "Failed to save
   subscription" because:
   - The route used the anon-session client and tried to UPSERT on
     bb.newsletter_subscribers, but the table's RLS only has an
     INSERT policy for the public role. Upsert needs INSERT+UPDATE,
     so the second branch failed RLS.
   - Switched to createAdminClient (service-role) — bypasses RLS,
     which is fine because this is a server-side endpoint with
     server-side email validation.
   - Welcome email now sends via nodemailer in a non-blocking
     background task so a slow SMTP doesn't hold up the response.
     Newer HTML template, brand-aligned, with a real CTA back to
     /news.

   Side work to wire SMTP (done out-of-band):
   - Created newsletter@broadcastbeat.com mailbox in Mailcow via
     the API (2GB quota, active, no force-pw-update).
   - Set SMTP_HOST=mail.onsethost.com, SMTP_PORT=587,
     SMTP_USER/PASS/FROM_EMAIL/FROM_NAME in Coolify env so the
     welcome email actually fires after the next deploy.

3. FeaturedBentoFromDb section header: "Featured · staff editorial"
   (small mono caps) → "Staff Editorial" (font-serif 2xl/3xl, bold,
   with a thin metadata line "Selected by our newsroom" beside it
   and a 2px bottom rule). Matches the editorial typography on the
   article pages.
This commit is contained in:
Ryan Salazar
2026-05-20 18:51:25 +00:00
parent 8f8eaa5fe8
commit 74677a80b8
3 changed files with 106 additions and 56 deletions

View File

@@ -1,45 +1,82 @@
import { createClient } from '@/lib/supabase/server'; import { createAdminClient } from '@/lib/supabase/admin';
import { NextRequest, NextResponse } from 'next/server'; import { NextRequest, NextResponse } from 'next/server';
import nodemailer from 'nodemailer'; import nodemailer from 'nodemailer';
export const runtime = 'nodejs';
// Public newsletter subscribe endpoint. Used by:
// - NewsletterModal (site-wide popup)
// - Header subscribe widget
// - Homepage NewsletterSignup section
// - Footer signup
//
// Why service-role (not the session client):
// bb.newsletter_subscribers' RLS only has an INSERT policy for anon
// ('public_subscribe_newsletter') — upsert needs both INSERT+UPDATE
// to handle re-subscribers. Service-role bypasses RLS cleanly.
export async function POST(req: NextRequest) { export async function POST(req: NextRequest) {
try { try {
const { email, topics } = await req.json(); const { email, topics, source } = await req.json();
if (!email || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) { if (!email || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
return NextResponse.json({ error: 'Valid email is required' }, { status: 400 }); return NextResponse.json({ error: 'Please enter a valid email address.' }, { status: 400 });
} }
const supabase = await createClient(); const normalized = email.toLowerCase().trim();
const supabase = createAdminClient();
// Upsert subscriber (re-subscribe if previously unsubscribed) // Upsert subscriber (re-subscribe if previously unsubscribed)
const { error } = await supabase const { error } = await supabase
.from('newsletter_subscribers') .from('newsletter_subscribers')
.upsert( .upsert(
{ {
email: email.toLowerCase().trim(), email: normalized,
topics: topics || [], topics: topics && Array.isArray(topics) ? topics : [],
status: 'active', status: 'active',
subscribed_at: new Date().toISOString(), subscribed_at: new Date().toISOString(),
unsubscribed_at: null, unsubscribed_at: null,
}, },
{ onConflict: 'email' } { onConflict: 'email' },
); );
if (error) { if (error) {
console.error('Supabase error:', error); console.error('newsletter_subscribers upsert failed:', error.message, error.details);
return NextResponse.json({ error: 'Failed to save subscription' }, { status: 500 }); return NextResponse.json(
{ error: 'Could not save subscription. Please try again in a moment.' },
{ status: 500 },
);
} }
// Send confirmation email via SMTP // Best-effort welcome email — never block the API response on SMTP.
// The signup is already saved to the DB, so we return 200 either way
// and let the email fire in the background.
void sendWelcome(normalized, topics).catch((e) =>
console.error('newsletter welcome email failed:', e?.message || e),
);
return NextResponse.json({ success: true, source: source || null });
} catch (err: any) {
console.error('Subscribe handler error:', err?.message || err);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
async function sendWelcome(email: string, topics?: string[]) {
const smtpHost = process.env.SMTP_HOST; const smtpHost = process.env.SMTP_HOST;
const smtpPort = parseInt(process.env.SMTP_PORT || '587'); const smtpPort = parseInt(process.env.SMTP_PORT || '587', 10);
const smtpUser = process.env.SMTP_USER; const smtpUser = process.env.SMTP_USER;
const smtpPass = process.env.SMTP_PASS; const smtpPass = process.env.SMTP_PASS;
const fromEmail = process.env.SMTP_FROM_EMAIL || smtpUser; const fromEmail = process.env.SMTP_FROM_EMAIL || smtpUser;
const fromName = process.env.SMTP_FROM_NAME || 'Broadcast Beat'; const fromName = process.env.SMTP_FROM_NAME || 'Broadcast Beat';
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL || 'https://broadcastbeat.com';
if (!smtpHost || !smtpUser || !smtpPass || !fromEmail) {
// SMTP not configured — signal that to logs but don't blow up.
console.warn('newsletter welcome: SMTP env not set; skipping send');
return;
}
if (smtpHost && smtpUser && smtpPass) {
const transporter = nodemailer.createTransport({ const transporter = nodemailer.createTransport({
host: smtpHost, host: smtpHost,
port: smtpPort, port: smtpPort,
@@ -47,30 +84,40 @@ export async function POST(req: NextRequest) {
auth: { user: smtpUser, pass: smtpPass }, auth: { user: smtpUser, pass: smtpPass },
}); });
const topicsList = topics && topics.length > 0 const topicsBlock =
? `<p style="color:#aaa;font-size:14px;">You'll receive updates on: <strong style="color:#3b82f6;">${topics.join(', ')}</strong></p>` topics && topics.length > 0
? `<p style="color:#aaa;font-size:14px;line-height:1.5;margin:14px 0 0;">You'll receive coverage on: <strong style="color:#3b82f6;">${topics.join(', ')}</strong></p>`
: ''; : '';
await transporter.sendMail({ await transporter.sendMail({
from: `"${fromName}" <${fromEmail}>`, from: `"${fromName}" <${fromEmail}>`,
to: email, to: email,
subject: `Welcome to Broadcast Beat — You're subscribed!`, subject: 'Welcome to Broadcast Beat',
html: ` html: `
<div style="background:#0d1520;padding:40px 20px;font-family:sans-serif;max-width:600px;margin:0 auto;"> <!DOCTYPE html>
<h1 style="color:#3b82f6;font-size:24px;margin-bottom:8px;">You're in!</h1> <html><head><meta charset="utf-8" /><meta name="viewport" content="width=device-width,initial-scale=1" /><title>Welcome to Broadcast Beat</title></head>
<p style="color:#e0e0e0;font-size:16px;">Thanks for subscribing to <strong>Broadcast Beat</strong> — the digital platform for broadcast engineering.</p> <body style="margin:0;padding:0;background:#0a0e15;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Helvetica,Arial,sans-serif;">
${topicsList} <table role="presentation" cellpadding="0" cellspacing="0" width="100%" style="background:#0a0e15;padding:40px 16px;">
<p style="color:#aaa;font-size:14px;margin-top:24px;">You'll be the first to know about the latest news, product launches, and industry insights.</p> <tr><td align="center">
<hr style="border:none;border-top:1px solid #1e3a5f;margin:32px 0;" /> <table role="presentation" cellpadding="0" cellspacing="0" width="600" style="max-width:600px;background:#11192a;border:1px solid #1e3a5f;border-radius:10px;overflow:hidden;">
<p style="color:#555;font-size:12px;">You're receiving this because you signed up at broadcastbeat.com. <a href="${process.env.NEXT_PUBLIC_SITE_URL}/unsubscribe?email=${encodeURIComponent(email)}" style="color:#3b82f6;">Unsubscribe</a></p> <tr><td style="padding:32px 36px 8px;">
</div> <div style="font-family:'Courier New',monospace;font-size:10px;font-weight:700;letter-spacing:0.22em;text-transform:uppercase;color:#3b82f6;">Broadcast Beat Newsletter</div>
`, <h1 style="margin:14px 0 8px;font-size:24px;line-height:1.2;color:#ffffff;font-weight:800;">You're in</h1>
<p style="margin:0;color:#cfd6e1;font-size:15px;line-height:1.55;">Thanks for subscribing to <strong style="color:#ffffff;">Broadcast Beat</strong> — the digital platform for broadcast engineering professionals.</p>
${topicsBlock}
</td></tr>
<tr><td style="padding:18px 36px 8px;">
<p style="margin:0;color:#98a3b3;font-size:14px;line-height:1.6;">You'll get one editorial dispatch a week — industry news, product launches, NAB/IBC coverage, and deep-dives. No spam, no sales calls.</p>
</td></tr>
<tr><td style="padding:18px 36px 26px;" align="center">
<a href="${siteUrl}/news" style="display:inline-block;background:#3b82f6;color:#ffffff;text-decoration:none;padding:11px 22px;border-radius:6px;font-weight:700;font-size:13px;letter-spacing:0.05em;text-transform:uppercase;">Browse The Latest News</a>
</td></tr>
<tr><td style="padding:18px 36px 28px;border-top:1px solid #1e3a5f;">
<p style="margin:0;color:#5a6470;font-size:11px;line-height:1.5;">You're receiving this because you signed up at <a href="${siteUrl}" style="color:#3b82f6;text-decoration:none;">broadcastbeat.com</a>. <a href="${siteUrl}/unsubscribe?email=${encodeURIComponent(email)}" style="color:#98a3b3;">Unsubscribe anytime</a>.</p>
</td></tr>
</table>
</td></tr>
</table>
</body></html>`,
}); });
} }
return NextResponse.json({ success: true });
} catch (err: unknown) {
console.error('Subscribe error:', err);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}

View File

@@ -1,7 +1,8 @@
import React, { Suspense } from 'react'; import React, { Suspense } from 'react';
import type { Metadata, Viewport } from 'next'; import type { Metadata, Viewport } from 'next';
import '../styles/tailwind.css'; import '../styles/tailwind.css';
import SystemStatusBar from '@/components/SystemStatusBar'; // SystemStatusBar removed — the "articles indexed / events tracked / [v2.0.0]"
// strip was eating vertical space without serving readers.
import AskBBAI from '@/components/AskBBAI'; import AskBBAI from '@/components/AskBBAI';
import GoogleAnalytics from '@/components/GoogleAnalytics'; import GoogleAnalytics from '@/components/GoogleAnalytics';
import CookieConsent from '@/components/CookieConsent'; import CookieConsent from '@/components/CookieConsent';
@@ -131,7 +132,6 @@ export default function RootLayout({
<Suspense fallback={null}> <Suspense fallback={null}>
<GoogleAnalytics /> <GoogleAnalytics />
</Suspense> </Suspense>
<SystemStatusBar />
<div className="page-transition" id="main-content"> <div className="page-transition" id="main-content">
{children} {children}
</div> </div>

View File

@@ -60,13 +60,16 @@ export default async function FeaturedBento() {
return ( return (
<section className="max-w-container mx-auto px-4 my-8" aria-label="Featured stories"> <section className="max-w-container mx-auto px-4 my-8" aria-label="Featured stories">
<div className="flex items-center gap-3 mb-3 flex-wrap"> <div className="flex items-end gap-4 mb-4 flex-wrap pb-2 border-b-2 border-[#2a2a2a]">
<h2 className="font-mono text-xs uppercase tracking-wider text-[#6b7280] whitespace-nowrap"> <h2 className="font-serif text-2xl md:text-3xl font-bold tracking-tight text-white whitespace-nowrap leading-none">
Featured · staff editorial Staff Editorial
</h2> </h2>
<div className="flex-1 h-px bg-[#2a2a2a]" /> <span className="font-mono text-[10px] uppercase tracking-[0.2em] text-[#6b7280] pb-1">
<Link href="/news?category=Featured" className="text-[11px] font-mono text-[var(--color-text-info,#60a5fa)] hover:underline whitespace-nowrap"> Selected by our newsroom
All featured </span>
<div className="flex-1" />
<Link href="/news?category=Featured" className="text-[11px] font-mono text-[var(--color-text-info,#60a5fa)] hover:underline whitespace-nowrap pb-1">
See all
</Link> </Link>
</div> </div>