Newsletter comming soon
This commit is contained in:
@@ -145,8 +145,18 @@ export default function MarketingLayout({
|
||||
|
||||
</div>
|
||||
|
||||
<div className="border-t border-gray-800 mt-8 pt-8 text-center text-gray-400">
|
||||
<div className="border-t border-gray-800 mt-8 pt-8 flex items-center justify-between text-gray-400">
|
||||
<Link
|
||||
href="/newsletter"
|
||||
className="text-xs hover:text-white transition-colors flex items-center gap-1.5 opacity-50 hover:opacity-100"
|
||||
>
|
||||
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
|
||||
</svg>
|
||||
Admin
|
||||
</Link>
|
||||
<p>© 2025 QR Master. All rights reserved.</p>
|
||||
<div className="w-12"></div>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
367
src/app/(marketing)/newsletter/page.tsx
Normal file
367
src/app/(marketing)/newsletter/page.tsx
Normal file
@@ -0,0 +1,367 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { Mail, Users, Send, CheckCircle, AlertCircle, Loader2, Lock, LogOut } from 'lucide-react';
|
||||
|
||||
interface Subscriber {
|
||||
email: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
interface BroadcastInfo {
|
||||
total: number;
|
||||
recent: Subscriber[];
|
||||
}
|
||||
|
||||
export default function NewsletterPage() {
|
||||
const router = useRouter();
|
||||
const [isAuthenticated, setIsAuthenticated] = useState(false);
|
||||
const [isAuthenticating, setIsAuthenticating] = useState(true);
|
||||
const [loginError, setLoginError] = useState('');
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
|
||||
const [info, setInfo] = useState<BroadcastInfo | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [broadcasting, setBroadcasting] = useState(false);
|
||||
const [result, setResult] = useState<{
|
||||
success: boolean;
|
||||
message: string;
|
||||
sent?: number;
|
||||
failed?: number;
|
||||
} | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
checkAuth();
|
||||
}, []);
|
||||
|
||||
const checkAuth = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/newsletter/broadcast');
|
||||
if (response.ok) {
|
||||
setIsAuthenticated(true);
|
||||
const data = await response.json();
|
||||
setInfo(data);
|
||||
setLoading(false);
|
||||
} else {
|
||||
setIsAuthenticated(false);
|
||||
}
|
||||
} catch (error) {
|
||||
setIsAuthenticated(false);
|
||||
} finally {
|
||||
setIsAuthenticating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleLogin = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setLoginError('');
|
||||
setIsAuthenticating(true);
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/newsletter/admin-login', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ email, password }),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
setIsAuthenticated(true);
|
||||
await checkAuth();
|
||||
} else {
|
||||
const data = await response.json();
|
||||
setLoginError(data.error || 'Invalid credentials');
|
||||
}
|
||||
} catch (error) {
|
||||
setLoginError('Login failed. Please try again.');
|
||||
} finally {
|
||||
setIsAuthenticating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleLogout = async () => {
|
||||
await fetch('/api/auth/logout', { method: 'POST' });
|
||||
router.push('/');
|
||||
};
|
||||
|
||||
const fetchSubscriberInfo = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/newsletter/broadcast');
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setInfo(data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch subscriber info:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleBroadcast = async () => {
|
||||
if (!confirm(`Are you sure you want to send the AI feature launch email to ${info?.total} subscribers?`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
setBroadcasting(true);
|
||||
setResult(null);
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/newsletter/broadcast', {
|
||||
method: 'POST',
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
setResult({
|
||||
success: response.ok,
|
||||
message: data.message || data.error,
|
||||
sent: data.sent,
|
||||
failed: data.failed,
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
await fetchSubscriberInfo();
|
||||
}
|
||||
} catch (error) {
|
||||
setResult({
|
||||
success: false,
|
||||
message: 'Failed to send broadcast. Please try again.',
|
||||
});
|
||||
} finally {
|
||||
setBroadcasting(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Login Screen
|
||||
if (!isAuthenticated) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-purple-50 to-pink-50 dark:from-purple-950/20 dark:to-pink-950/20 px-4">
|
||||
<Card className="w-full max-w-md p-8">
|
||||
<div className="text-center mb-6">
|
||||
<div className="w-16 h-16 bg-gradient-to-br from-purple-100 to-pink-100 dark:from-purple-900/30 dark:to-pink-900/30 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||
<Lock className="w-8 h-8 text-purple-600 dark:text-purple-400" />
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold mb-2">Newsletter Admin</h1>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Sign in to manage subscribers
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleLogin} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2">Email</label>
|
||||
<input
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="support@qrmaster.net"
|
||||
required
|
||||
className="w-full px-4 py-3 rounded-xl bg-background border border-border focus:border-purple-500 focus:ring-2 focus:ring-purple-500/20 outline-none transition-all"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2">Password</label>
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="••••••••"
|
||||
required
|
||||
className="w-full px-4 py-3 rounded-xl bg-background border border-border focus:border-purple-500 focus:ring-2 focus:ring-purple-500/20 outline-none transition-all"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{loginError && (
|
||||
<p className="text-sm text-red-600 dark:text-red-400">{loginError}</p>
|
||||
)}
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={isAuthenticating}
|
||||
className="w-full bg-gradient-to-r from-purple-600 to-pink-600 hover:from-purple-700 hover:to-pink-700 text-white"
|
||||
>
|
||||
{isAuthenticating ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
Signing in...
|
||||
</>
|
||||
) : (
|
||||
'Sign In'
|
||||
)}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
<div className="mt-6 pt-6 border-t text-center">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Admin credentials required
|
||||
</p>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Loading
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<Loader2 className="w-8 h-8 animate-spin text-primary" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Admin Dashboard
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-purple-50/30 to-pink-50/30 dark:from-purple-950/10 dark:to-pink-950/10">
|
||||
<div className="container mx-auto px-4 py-8 max-w-4xl">
|
||||
<div className="flex items-center justify-between mb-8">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold mb-2">Newsletter Management</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Manage AI feature launch notifications
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
onClick={handleLogout}
|
||||
variant="outline"
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<LogOut className="w-4 h-4" />
|
||||
Logout
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Stats Card */}
|
||||
<Card className="p-6 mb-6">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-12 h-12 bg-purple-100 dark:bg-purple-900/20 rounded-lg flex items-center justify-center">
|
||||
<Users className="w-6 h-6 text-purple-600 dark:text-purple-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold">{info?.total || 0}</h2>
|
||||
<p className="text-sm text-muted-foreground">Total Subscribers</p>
|
||||
</div>
|
||||
</div>
|
||||
<Badge className="bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-300">
|
||||
Active
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{/* Broadcast Button */}
|
||||
<div className="border-t pt-6">
|
||||
<div className="mb-4">
|
||||
<h3 className="font-semibold mb-2 flex items-center gap-2">
|
||||
<Send className="w-4 h-4" />
|
||||
Broadcast AI Feature Launch
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
Send the AI feature launch announcement to all {info?.total} subscribers.
|
||||
This will inform them that the features are now available.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
onClick={handleBroadcast}
|
||||
disabled={broadcasting || !info?.total}
|
||||
className="w-full sm:w-auto bg-gradient-to-r from-purple-600 to-pink-600 hover:from-purple-700 hover:to-pink-700 text-white"
|
||||
>
|
||||
{broadcasting ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
Sending Emails...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Mail className="w-4 h-4 mr-2" />
|
||||
Send Launch Notification to All
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Result Message */}
|
||||
{result && (
|
||||
<Card
|
||||
className={`p-4 mb-6 ${
|
||||
result.success
|
||||
? 'bg-green-50 dark:bg-green-950/20 border-green-200 dark:border-green-900'
|
||||
: 'bg-red-50 dark:bg-red-950/20 border-red-200 dark:border-red-900'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
{result.success ? (
|
||||
<CheckCircle className="w-5 h-5 text-green-600 dark:text-green-400 flex-shrink-0 mt-0.5" />
|
||||
) : (
|
||||
<AlertCircle className="w-5 h-5 text-red-600 dark:text-red-400 flex-shrink-0 mt-0.5" />
|
||||
)}
|
||||
<div className="flex-1">
|
||||
<p
|
||||
className={`font-medium ${
|
||||
result.success
|
||||
? 'text-green-900 dark:text-green-100'
|
||||
: 'text-red-900 dark:text-red-100'
|
||||
}`}
|
||||
>
|
||||
{result.message}
|
||||
</p>
|
||||
{result.sent !== undefined && (
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
Sent: {result.sent} | Failed: {result.failed}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Recent Subscribers */}
|
||||
<Card className="p-6">
|
||||
<h3 className="font-semibold mb-4">Recent Subscribers</h3>
|
||||
{info?.recent && info.recent.length > 0 ? (
|
||||
<div className="space-y-3">
|
||||
{info.recent.map((subscriber, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="flex items-center justify-between py-2 border-b border-border last:border-0"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Mail className="w-4 h-4 text-muted-foreground" />
|
||||
<span className="text-sm">{subscriber.email}</span>
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{new Date(subscriber.createdAt).toLocaleDateString()}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">No subscribers yet</p>
|
||||
)}
|
||||
|
||||
<div className="mt-4 pt-4 border-t">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
💡 Tip: View all subscribers in{' '}
|
||||
<a
|
||||
href="http://localhost:5555"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
Prisma Studio
|
||||
</a>
|
||||
{' '}(NewsletterSubscription table)
|
||||
</p>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
67
src/app/api/newsletter/admin-login/route.ts
Normal file
67
src/app/api/newsletter/admin-login/route.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { cookies } from 'next/headers';
|
||||
import bcrypt from 'bcryptjs';
|
||||
import { db } from '@/lib/db';
|
||||
import { getAuthCookieOptions } from '@/lib/cookieConfig';
|
||||
|
||||
/**
|
||||
* POST /api/newsletter/admin-login
|
||||
* Simple admin login for newsletter management (no CSRF required)
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { email, password } = body;
|
||||
|
||||
// Validate input
|
||||
if (!email || !password) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Email and password are required' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Check if user exists
|
||||
const user = await db.user.findUnique({
|
||||
where: { email: email.toLowerCase() },
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
password: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!user || !user.password) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid credentials' },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
// Verify password
|
||||
const isPasswordValid = await bcrypt.compare(password, user.password);
|
||||
|
||||
if (!isPasswordValid) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid credentials' },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
// Set auth cookie
|
||||
const response = NextResponse.json({
|
||||
success: true,
|
||||
message: 'Login successful',
|
||||
});
|
||||
|
||||
response.cookies.set('userId', user.id, getAuthCookieOptions());
|
||||
|
||||
return response;
|
||||
} catch (error) {
|
||||
console.error('Newsletter admin login error:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Login failed. Please try again.' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
163
src/app/api/newsletter/broadcast/route.ts
Normal file
163
src/app/api/newsletter/broadcast/route.ts
Normal file
@@ -0,0 +1,163 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { cookies } from 'next/headers';
|
||||
import { db } from '@/lib/db';
|
||||
import { sendAIFeatureLaunchEmail } from '@/lib/email';
|
||||
import { rateLimit, RateLimits } from '@/lib/rateLimit';
|
||||
|
||||
/**
|
||||
* POST /api/newsletter/broadcast
|
||||
* Send AI feature launch email to all subscribed users
|
||||
* PROTECTED: Only authenticated users can access (you may want to add admin check)
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
// Check authentication
|
||||
const userId = cookies().get('userId')?.value;
|
||||
|
||||
if (!userId) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Unauthorized. Please log in.' },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
// Optional: Add admin check here
|
||||
// const user = await db.user.findUnique({ where: { id: userId } });
|
||||
// if (user?.role !== 'ADMIN') {
|
||||
// return NextResponse.json({ error: 'Forbidden. Admin access required.' }, { status: 403 });
|
||||
// }
|
||||
|
||||
// Rate limiting (prevent accidental spam)
|
||||
const rateLimitResult = rateLimit(userId, {
|
||||
name: 'newsletter-broadcast',
|
||||
maxRequests: 2, // Only 2 broadcasts per hour
|
||||
windowSeconds: 60 * 60,
|
||||
});
|
||||
|
||||
if (!rateLimitResult.success) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Too many broadcast attempts. Please wait before trying again.',
|
||||
retryAfter: Math.ceil((rateLimitResult.reset - Date.now()) / 1000),
|
||||
},
|
||||
{ status: 429 }
|
||||
);
|
||||
}
|
||||
|
||||
// Get all subscribed users
|
||||
const subscribers = await db.newsletterSubscription.findMany({
|
||||
where: {
|
||||
status: 'subscribed',
|
||||
},
|
||||
select: {
|
||||
email: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (subscribers.length === 0) {
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: 'No subscribers found',
|
||||
sent: 0,
|
||||
});
|
||||
}
|
||||
|
||||
// Send emails in batches to avoid overwhelming Resend
|
||||
const batchSize = 10;
|
||||
const results = {
|
||||
sent: 0,
|
||||
failed: 0,
|
||||
errors: [] as string[],
|
||||
};
|
||||
|
||||
for (let i = 0; i < subscribers.length; i += batchSize) {
|
||||
const batch = subscribers.slice(i, i + batchSize);
|
||||
|
||||
// Send emails in parallel within batch
|
||||
const promises = batch.map(async (subscriber) => {
|
||||
try {
|
||||
await sendAIFeatureLaunchEmail(subscriber.email);
|
||||
results.sent++;
|
||||
} catch (error) {
|
||||
results.failed++;
|
||||
results.errors.push(`Failed to send to ${subscriber.email}`);
|
||||
console.error(`Failed to send to ${subscriber.email}:`, error);
|
||||
}
|
||||
});
|
||||
|
||||
await Promise.allSettled(promises);
|
||||
|
||||
// Small delay between batches to be nice to the email service
|
||||
if (i + batchSize < subscribers.length) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: `Broadcast completed. Sent to ${results.sent} subscribers.`,
|
||||
sent: results.sent,
|
||||
failed: results.failed,
|
||||
total: subscribers.length,
|
||||
errors: results.errors.length > 0 ? results.errors : undefined,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Newsletter broadcast error:', error);
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Failed to send broadcast emails. Please try again.',
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/newsletter/broadcast
|
||||
* Get subscriber count and preview
|
||||
* PROTECTED: Only authenticated users
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
// Check authentication
|
||||
const userId = cookies().get('userId')?.value;
|
||||
|
||||
if (!userId) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Unauthorized. Please log in.' },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
const subscriberCount = await db.newsletterSubscription.count({
|
||||
where: {
|
||||
status: 'subscribed',
|
||||
},
|
||||
});
|
||||
|
||||
const recentSubscribers = await db.newsletterSubscription.findMany({
|
||||
where: {
|
||||
status: 'subscribed',
|
||||
},
|
||||
select: {
|
||||
email: true,
|
||||
createdAt: true,
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: 'desc',
|
||||
},
|
||||
take: 5,
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
total: subscriberCount,
|
||||
recent: recentSubscribers,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error fetching subscriber info:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to fetch subscriber information' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
91
src/app/api/newsletter/subscribe/route.ts
Normal file
91
src/app/api/newsletter/subscribe/route.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { db } from '@/lib/db';
|
||||
import { newsletterSubscribeSchema, validateRequest } from '@/lib/validationSchemas';
|
||||
import { rateLimit, getClientIdentifier, RateLimits } from '@/lib/rateLimit';
|
||||
import { sendNewsletterWelcomeEmail } from '@/lib/email';
|
||||
|
||||
/**
|
||||
* POST /api/newsletter/subscribe
|
||||
* Subscribe to AI features newsletter
|
||||
* Public endpoint - no authentication required
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
// Get client identifier for rate limiting
|
||||
const clientId = getClientIdentifier(request);
|
||||
|
||||
// Apply rate limiting (5 per hour)
|
||||
const rateLimitResult = rateLimit(clientId, RateLimits.NEWSLETTER_SUBSCRIBE);
|
||||
|
||||
if (!rateLimitResult.success) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Too many subscription attempts. Please try again later.',
|
||||
retryAfter: Math.ceil((rateLimitResult.reset - Date.now()) / 1000),
|
||||
},
|
||||
{
|
||||
status: 429,
|
||||
headers: {
|
||||
'X-RateLimit-Limit': rateLimitResult.limit.toString(),
|
||||
'X-RateLimit-Remaining': rateLimitResult.remaining.toString(),
|
||||
'X-RateLimit-Reset': rateLimitResult.reset.toString(),
|
||||
'Retry-After': Math.ceil((rateLimitResult.reset - Date.now()) / 1000).toString(),
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// Parse and validate request body
|
||||
const body = await request.json();
|
||||
const validation = await validateRequest(newsletterSubscribeSchema, body);
|
||||
|
||||
if (!validation.success) {
|
||||
return NextResponse.json(validation.error, { status: 400 });
|
||||
}
|
||||
|
||||
const { email } = validation.data;
|
||||
|
||||
// Check if email already subscribed
|
||||
const existing = await db.newsletterSubscription.findUnique({
|
||||
where: { email },
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
// If already subscribed, return success (idempotent)
|
||||
// Don't reveal if email exists for privacy
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: 'Successfully subscribed to AI features newsletter!',
|
||||
alreadySubscribed: true,
|
||||
});
|
||||
}
|
||||
|
||||
// Create new subscription
|
||||
await db.newsletterSubscription.create({
|
||||
data: {
|
||||
email,
|
||||
source: 'ai-coming-soon',
|
||||
status: 'subscribed',
|
||||
},
|
||||
});
|
||||
|
||||
// Send welcome email (don't block response)
|
||||
sendNewsletterWelcomeEmail(email).catch((error) => {
|
||||
console.error('Failed to send welcome email (non-blocking):', error);
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: 'Successfully subscribed to AI features newsletter!',
|
||||
alreadySubscribed: false,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Newsletter subscription error:', error);
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Failed to subscribe to newsletter. Please try again.',
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user