- Main nav background → brand-blue #1D4ED8 (sampled from logo emblem). - Nav link text → white with darker-blue hover bg so contrast holds. - Article card title fixed from #e0e0e0 to navy #0F172A (was unreadable on the new white surface). - Industry News card date row removed. - /news Topics filter chip row removed. - /news article hover from stark #111 to light-blue #EFF6FF tint matching the brand. - Tagline 'News & Intelligence for Pro AV, Live Production & Display Tech' renamed to 'Pro AV, Display and Live Production Tech' across Header, RSS feed, newsletter welcome template, root layout title, page title, OG/Twitter tags. - Homepage page.tsx now has `export const dynamic = 'force-dynamic'` + `revalidate = 0` so the hero (FeaturedBentoFromDb) actually re-renders against the live supabase row (previously the page was being statically prerendered which masked DB-driven hero swaps).
188 lines
7.5 KiB
TypeScript
188 lines
7.5 KiB
TypeScript
import { createClient } from '@/lib/supabase/server';
|
|
import { NextRequest, NextResponse } from 'next/server';
|
|
import nodemailer from 'nodemailer';
|
|
|
|
// GET: list all subscribers
|
|
export async function GET(req: NextRequest) {
|
|
const supabase = await createClient();
|
|
const { data: { user } } = await supabase.auth.getUser();
|
|
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
|
|
|
const { searchParams } = new URL(req.url);
|
|
const status = searchParams.get('status') || 'all';
|
|
const search = searchParams.get('search') || '';
|
|
|
|
let query = supabase
|
|
.from('newsletter_subscribers')
|
|
.select('*')
|
|
.order('subscribed_at', { ascending: false });
|
|
|
|
if (status !== 'all') query = query.eq('status', status);
|
|
if (search) query = query.ilike('email', `%${search}%`);
|
|
|
|
const { data, error } = await query;
|
|
if (error) return NextResponse.json({ error: error.message }, { status: 500 });
|
|
|
|
return NextResponse.json({ subscribers: data || [] });
|
|
}
|
|
|
|
// DELETE: unsubscribe a subscriber
|
|
export async function DELETE(req: NextRequest) {
|
|
const supabase = await createClient();
|
|
const { data: { user } } = await supabase.auth.getUser();
|
|
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
|
|
|
const { id } = await req.json();
|
|
if (!id) return NextResponse.json({ error: 'ID required' }, { status: 400 });
|
|
|
|
const { error } = await supabase
|
|
.from('newsletter_subscribers')
|
|
.update({ status: 'unsubscribed', unsubscribed_at: new Date().toISOString() })
|
|
.eq('id', id);
|
|
|
|
if (error) return NextResponse.json({ error: error.message }, { status: 500 });
|
|
return NextResponse.json({ success: true });
|
|
}
|
|
|
|
// PATCH: bulk actions (bulk_unsubscribe, bulk_delete)
|
|
export async function PATCH(req: NextRequest) {
|
|
const supabase = await createClient();
|
|
const { data: { user } } = await supabase.auth.getUser();
|
|
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
|
|
|
const { action, ids } = await req.json();
|
|
if (!action || !Array.isArray(ids) || ids.length === 0) {
|
|
return NextResponse.json({ error: 'action and ids[] required' }, { status: 400 });
|
|
}
|
|
|
|
if (action === 'bulk_unsubscribe') {
|
|
const { error } = await supabase
|
|
.from('newsletter_subscribers')
|
|
.update({ status: 'unsubscribed', unsubscribed_at: new Date().toISOString() })
|
|
.in('id', ids)
|
|
.eq('status', 'active');
|
|
if (error) return NextResponse.json({ error: error.message }, { status: 500 });
|
|
return NextResponse.json({ success: true, count: ids.length });
|
|
}
|
|
|
|
if (action === 'bulk_delete') {
|
|
const { error } = await supabase
|
|
.from('newsletter_subscribers')
|
|
.delete()
|
|
.in('id', ids);
|
|
if (error) return NextResponse.json({ error: error.message }, { status: 500 });
|
|
return NextResponse.json({ success: true, count: ids.length });
|
|
}
|
|
|
|
return NextResponse.json({ error: 'Unknown action' }, { status: 400 });
|
|
}
|
|
|
|
// POST: send digest email to all active subscribers
|
|
export async function POST(req: NextRequest) {
|
|
const supabase = await createClient();
|
|
const { data: { user } } = await supabase.auth.getUser();
|
|
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
|
|
|
const { subject, previewText, articles, customMessage } = await req.json();
|
|
|
|
if (!subject) return NextResponse.json({ error: 'Subject is required' }, { status: 400 });
|
|
if (!articles || articles.length === 0) {
|
|
return NextResponse.json({ error: 'At least one article is required' }, { status: 400 });
|
|
}
|
|
|
|
const smtpHost = process.env.SMTP_HOST;
|
|
const smtpPort = parseInt(process.env.SMTP_PORT || '587');
|
|
const smtpUser = process.env.SMTP_USER;
|
|
const smtpPass = process.env.SMTP_PASS;
|
|
const fromEmail = process.env.SMTP_FROM_EMAIL || smtpUser;
|
|
const fromName = process.env.SMTP_FROM_NAME || 'AV Beat';
|
|
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL || 'https://avbeat.com';
|
|
|
|
if (!smtpHost || !smtpUser || !smtpPass) {
|
|
return NextResponse.json({ error: 'SMTP credentials not configured' }, { status: 500 });
|
|
}
|
|
|
|
// Fetch active subscribers
|
|
const { data: subscribers, error: subError } = await supabase
|
|
.from('newsletter_subscribers')
|
|
.select('email')
|
|
.eq('status', 'active');
|
|
|
|
if (subError) return NextResponse.json({ error: subError.message }, { status: 500 });
|
|
if (!subscribers || subscribers.length === 0) {
|
|
return NextResponse.json({ error: 'No active subscribers found' }, { status: 400 });
|
|
}
|
|
|
|
const transporter = nodemailer.createTransport({
|
|
host: smtpHost,
|
|
port: smtpPort,
|
|
secure: smtpPort === 465,
|
|
auth: { user: smtpUser, pass: smtpPass },
|
|
});
|
|
|
|
// Build article HTML blocks
|
|
const articlesHtml = articles
|
|
.map(
|
|
(a: { title: string; excerpt?: string; url?: string; image?: string; category?: string }) => `
|
|
<div style="border-bottom:1px solid #DCE6F2;padding:20px 0;">
|
|
${a.category ? `<span style="color:#1D4ED8;font-size:11px;font-weight:bold;text-transform:uppercase;letter-spacing:1px;">${a.category}</span>` : ''}
|
|
<h2 style="color:#e0e0e0;font-size:18px;margin:8px 0 6px;">
|
|
${a.url ? `<a href="${siteUrl}${a.url}" style="color:#e0e0e0;text-decoration:none;">${a.title}</a>` : a.title}
|
|
</h2>
|
|
${a.excerpt ? `<p style="color:#888;font-size:14px;margin:0 0 10px;">${a.excerpt}</p>` : ''}
|
|
${a.url ? `<a href="${siteUrl}${a.url}" style="color:#1D4ED8;font-size:13px;text-decoration:none;">Read more →</a>` : ''}
|
|
</div>
|
|
`
|
|
)
|
|
.join('');
|
|
|
|
const emailHtml = `
|
|
<div style="background:#F8FAFC;padding:0;font-family:sans-serif;max-width:600px;margin:0 auto;">
|
|
<!-- Header -->
|
|
<div style="background:#111;border-bottom:2px solid #1D4ED8;padding:24px 32px;">
|
|
<h1 style="color:#1D4ED8;font-size:22px;margin:0;font-weight:bold;">AV Beat</h1>
|
|
<p style="color:#555;font-size:12px;margin:4px 0 0;">Pro AV, Display and Live Production Tech</p>
|
|
</div>
|
|
<!-- Body -->
|
|
<div style="padding:32px;">
|
|
${previewText ? `<p style="color:#aaa;font-size:15px;margin:0 0 24px;">${previewText}</p>` : ''}
|
|
${customMessage ? `<div style="background:#FFFFFF;border-left:3px solid #1D4ED8;padding:16px;margin-bottom:24px;"><p style="color:#e0e0e0;font-size:14px;margin:0;">${customMessage}</p></div>` : ''}
|
|
${articlesHtml}
|
|
</div>
|
|
<!-- Footer -->
|
|
<div style="background:#F8FAFC;padding:24px 32px;border-top:1px solid #DCE6F2;">
|
|
<p style="color:#555;font-size:12px;margin:0;">You're receiving this because you subscribed at avbeat.com.</p>
|
|
<p style="color:#555;font-size:12px;margin:8px 0 0;">
|
|
<a href="${siteUrl}" style="color:#1D4ED8;">Visit AV Beat</a> ·
|
|
<a href="${siteUrl}/unsubscribe?email={{email}}" style="color:#1D4ED8;">Unsubscribe</a>
|
|
</p>
|
|
</div>
|
|
</div>
|
|
`;
|
|
|
|
// Send to all subscribers (batch with BCC or individual)
|
|
const recipientEmails = subscribers.map((s) => s.email);
|
|
let sent = 0;
|
|
let failed = 0;
|
|
|
|
// Send in batches of 50 to avoid rate limits
|
|
const batchSize = 50;
|
|
for (let i = 0; i < recipientEmails.length; i += batchSize) {
|
|
const batch = recipientEmails.slice(i, i + batchSize);
|
|
try {
|
|
await transporter.sendMail({
|
|
from: `"${fromName}" <${fromEmail}>`,
|
|
bcc: batch,
|
|
subject,
|
|
html: emailHtml,
|
|
});
|
|
sent += batch.length;
|
|
} catch (err) {
|
|
console.error('Batch send error:', err);
|
|
failed += batch.length;
|
|
}
|
|
}
|
|
|
|
return NextResponse.json({ success: true, sent, failed, total: recipientEmails.length });
|
|
}
|