Newsletter comming soon
This commit is contained in:
parent
f1d1f4291b
commit
6aa3267f26
|
|
@ -21,6 +21,7 @@
|
||||||
"i18next": "^23.7.6",
|
"i18next": "^23.7.6",
|
||||||
"ioredis": "^5.3.2",
|
"ioredis": "^5.3.2",
|
||||||
"jszip": "^3.10.1",
|
"jszip": "^3.10.1",
|
||||||
|
"lucide-react": "^0.562.0",
|
||||||
"next": "14.2.18",
|
"next": "14.2.18",
|
||||||
"next-auth": "^4.24.5",
|
"next-auth": "^4.24.5",
|
||||||
"papaparse": "^5.4.1",
|
"papaparse": "^5.4.1",
|
||||||
|
|
@ -5412,6 +5413,15 @@
|
||||||
"node": ">=10"
|
"node": ">=10"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/lucide-react": {
|
||||||
|
"version": "0.562.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.562.0.tgz",
|
||||||
|
"integrity": "sha512-82hOAu7y0dbVuFfmO4bYF1XEwYk/mEbM5E+b1jgci/udUBEE/R7LF5Ip0CCEmXe8AybRM8L+04eP+LGZeDvkiw==",
|
||||||
|
"license": "ISC",
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/math-intrinsics": {
|
"node_modules/math-intrinsics": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
||||||
|
|
|
||||||
|
|
@ -37,6 +37,7 @@
|
||||||
"i18next": "^23.7.6",
|
"i18next": "^23.7.6",
|
||||||
"ioredis": "^5.3.2",
|
"ioredis": "^5.3.2",
|
||||||
"jszip": "^3.10.1",
|
"jszip": "^3.10.1",
|
||||||
|
"lucide-react": "^0.562.0",
|
||||||
"next": "14.2.18",
|
"next": "14.2.18",
|
||||||
"next-auth": "^4.24.5",
|
"next-auth": "^4.24.5",
|
||||||
"papaparse": "^5.4.1",
|
"papaparse": "^5.4.1",
|
||||||
|
|
|
||||||
|
|
@ -150,3 +150,15 @@ model Integration {
|
||||||
|
|
||||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model NewsletterSubscription {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
email String @unique
|
||||||
|
source String @default("ai-coming-soon")
|
||||||
|
status String @default("subscribed")
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
@@index([email])
|
||||||
|
@@index([createdAt])
|
||||||
|
}
|
||||||
|
|
@ -145,8 +145,18 @@ export default function MarketingLayout({
|
||||||
|
|
||||||
</div>
|
</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>
|
<p>© 2025 QR Master. All rights reserved.</p>
|
||||||
|
<div className="w-12"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,203 @@
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
import { Sparkles, Brain, TrendingUp, MessageSquare, Palette, ArrowRight, Mail, CheckCircle2, Lock } from 'lucide-react';
|
||||||
|
import Link from 'next/link';
|
||||||
|
|
||||||
|
const AIComingSoonBanner = () => {
|
||||||
|
const [email, setEmail] = useState('');
|
||||||
|
const [submitted, setSubmitted] = useState(false);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setLoading(true);
|
||||||
|
setError('');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/newsletter/subscribe', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ email }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(data.error || 'Failed to subscribe');
|
||||||
|
}
|
||||||
|
|
||||||
|
setSubmitted(true);
|
||||||
|
setEmail('');
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Something went wrong. Please try again.');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const features = [
|
||||||
|
{
|
||||||
|
icon: Brain,
|
||||||
|
category: 'Smart QR Generation',
|
||||||
|
items: [
|
||||||
|
'AI-powered content optimization',
|
||||||
|
'Intelligent design suggestions',
|
||||||
|
'Auto-generate vCard from LinkedIn',
|
||||||
|
'Smart URL shortening with SEO',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: TrendingUp,
|
||||||
|
category: 'Advanced Analytics & Insights',
|
||||||
|
items: [
|
||||||
|
'AI-powered scan predictions',
|
||||||
|
'Anomaly detection',
|
||||||
|
'Natural language analytics queries',
|
||||||
|
'Automated insights reports',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: MessageSquare,
|
||||||
|
category: 'Smart Content Management',
|
||||||
|
items: [
|
||||||
|
'AI chatbot for instant support',
|
||||||
|
'Automated QR categorization',
|
||||||
|
'Smart bulk QR generation',
|
||||||
|
'Content recommendations',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: Palette,
|
||||||
|
category: 'Creative & Marketing',
|
||||||
|
items: [
|
||||||
|
'AI-generated custom QR designs',
|
||||||
|
'Marketing copy generation',
|
||||||
|
'A/B testing suggestions',
|
||||||
|
'Campaign optimization',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="relative overflow-hidden py-20 px-4 sm:px-6 lg:px-8 bg-gradient-to-br from-blue-50 via-white to-purple-50">
|
||||||
|
{/* Smooth Gradient Fade Transition from Hero */}
|
||||||
|
<div className="absolute top-0 left-0 w-full h-32 bg-gradient-to-b from-purple-50 via-blue-50/50 to-transparent pointer-events-none z-20" />
|
||||||
|
|
||||||
|
{/* Animated Background Orbs (matching Hero) */}
|
||||||
|
<div className="absolute inset-0 overflow-hidden pointer-events-none">
|
||||||
|
<div className="absolute top-0 left-1/4 w-96 h-96 bg-blue-400/20 rounded-full blur-3xl animate-blob" />
|
||||||
|
<div className="absolute top-1/2 right-1/4 w-80 h-80 bg-purple-400/20 rounded-full blur-3xl animate-blob animation-delay-2000" />
|
||||||
|
<div className="absolute bottom-0 left-1/2 w-96 h-96 bg-cyan-400/15 rounded-full blur-3xl animate-blob animation-delay-4000" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Smooth Gradient Fade Transition to Next Section */}
|
||||||
|
<div className="absolute bottom-0 left-0 w-full h-32 bg-gradient-to-b from-transparent to-gray-50 pointer-events-none z-20" />
|
||||||
|
|
||||||
|
<div className="max-w-6xl mx-auto relative z-10">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="text-center mb-12">
|
||||||
|
<div className="inline-flex items-center gap-2 px-3 py-1.5 rounded-full bg-blue-100 mb-4">
|
||||||
|
<Sparkles className="w-4 h-4 text-blue-600" />
|
||||||
|
<span className="text-sm font-medium text-blue-700">
|
||||||
|
Coming Soon
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2 className="text-3xl sm:text-4xl font-bold text-gray-900 mb-3">
|
||||||
|
The Future of QR Codes is{' '}
|
||||||
|
<span className="bg-gradient-to-r from-blue-600 to-purple-600 bg-clip-text text-transparent">
|
||||||
|
AI-Powered
|
||||||
|
</span>
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<p className="text-gray-600 text-lg max-w-2xl mx-auto">
|
||||||
|
Revolutionary AI features to transform how you create, manage, and optimize QR codes
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Features Grid */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-12">
|
||||||
|
{features.map((feature, index) => (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
className="bg-white/80 backdrop-blur rounded-xl p-6 border border-gray-100 hover:shadow-lg transition-all"
|
||||||
|
>
|
||||||
|
<div className="w-12 h-12 bg-gradient-to-br from-blue-100 to-purple-100 rounded-lg flex items-center justify-center mb-4">
|
||||||
|
<feature.icon className="w-6 h-6 text-blue-600" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3 className="font-semibold text-gray-900 mb-3">
|
||||||
|
{feature.category}
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<ul className="space-y-2">
|
||||||
|
{feature.items.map((item, itemIndex) => (
|
||||||
|
<li key={itemIndex} className="flex items-start gap-2 text-sm text-gray-600">
|
||||||
|
<div className="mt-1.5 w-1.5 h-1.5 rounded-full bg-blue-500 flex-shrink-0" />
|
||||||
|
<span>{item}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Email Capture */}
|
||||||
|
<div className="max-w-2xl mx-auto bg-gradient-to-br from-blue-50 to-purple-50 rounded-2xl p-8 border border-gray-100">
|
||||||
|
{!submitted ? (
|
||||||
|
<>
|
||||||
|
<form onSubmit={handleSubmit} className="flex flex-col sm:flex-row gap-3 mb-3">
|
||||||
|
<div className="flex-1 relative">
|
||||||
|
<Mail className="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400 pointer-events-none" />
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => {
|
||||||
|
setEmail(e.target.value);
|
||||||
|
setError('');
|
||||||
|
}}
|
||||||
|
placeholder="your@email.com"
|
||||||
|
required
|
||||||
|
disabled={loading}
|
||||||
|
className="w-full pl-12 pr-4 py-3 rounded-xl bg-white border border-gray-200 focus:border-blue-500 focus:ring-2 focus:ring-blue-500/20 outline-none transition-all"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={loading}
|
||||||
|
className="px-6 py-3 bg-gradient-to-r from-blue-600 to-purple-600 hover:from-blue-700 hover:to-purple-700 text-white font-semibold rounded-xl transition-all disabled:opacity-50 whitespace-nowrap flex items-center justify-center gap-2"
|
||||||
|
>
|
||||||
|
{loading ? 'Subscribing...' : (
|
||||||
|
<>
|
||||||
|
Notify Me
|
||||||
|
<ArrowRight className="w-4 h-4" />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
{error && (
|
||||||
|
<p className="text-sm text-red-600 mb-2">{error}</p>
|
||||||
|
)}
|
||||||
|
<p className="text-xs text-gray-500 text-center">
|
||||||
|
Be the first to know when AI features launch
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center justify-center gap-2 text-green-600">
|
||||||
|
<CheckCircle2 className="w-5 h-5" />
|
||||||
|
<span className="font-medium">
|
||||||
|
You're on the list! We'll notify you when AI features launch.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AIComingSoonBanner;
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Hero } from '@/components/marketing/Hero';
|
import { Hero } from '@/components/marketing/Hero';
|
||||||
|
import AIComingSoonBanner from '@/components/marketing/AIComingSoonBanner';
|
||||||
import { StatsStrip } from '@/components/marketing/StatsStrip';
|
import { StatsStrip } from '@/components/marketing/StatsStrip';
|
||||||
import { TemplateCards } from '@/components/marketing/TemplateCards';
|
import { TemplateCards } from '@/components/marketing/TemplateCards';
|
||||||
import { InstantGenerator } from '@/components/marketing/InstantGenerator';
|
import { InstantGenerator } from '@/components/marketing/InstantGenerator';
|
||||||
|
|
@ -29,6 +30,7 @@ export default function HomePageClient() {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Hero t={t} />
|
<Hero t={t} />
|
||||||
|
<AIComingSoonBanner />
|
||||||
<InstantGenerator t={t} />
|
<InstantGenerator t={t} />
|
||||||
<StaticVsDynamic t={t} />
|
<StaticVsDynamic t={t} />
|
||||||
<Features t={t} />
|
<Features t={t} />
|
||||||
|
|
|
||||||
|
|
@ -73,7 +73,10 @@ export const InstantGenerator: React.FC<InstantGeneratorProps> = ({ t }) => {
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="py-16 bg-gray-50">
|
<section className="relative py-16 bg-gray-50">
|
||||||
|
{/* Smooth Gradient Fade Transition from Previous Section */}
|
||||||
|
<div className="absolute top-0 left-0 w-full h-32 bg-gradient-to-b from-purple-50/50 via-gray-50 to-transparent pointer-events-none" />
|
||||||
|
|
||||||
<div className="container mx-auto px-4 sm:px-6 lg:px-8 max-w-7xl">
|
<div className="container mx-auto px-4 sm:px-6 lg:px-8 max-w-7xl">
|
||||||
<div className="text-center mb-12">
|
<div className="text-center mb-12">
|
||||||
<h2 className="text-3xl lg:text-4xl font-bold text-gray-900 mb-4">
|
<h2 className="text-3xl lg:text-4xl font-bold text-gray-900 mb-4">
|
||||||
|
|
|
||||||
177
src/lib/email.ts
177
src/lib/email.ts
|
|
@ -103,3 +103,180 @@ export async function sendPasswordResetEmail(email: string, resetToken: string)
|
||||||
throw new Error('Failed to send password reset email');
|
throw new Error('Failed to send password reset email');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function sendNewsletterWelcomeEmail(email: string) {
|
||||||
|
try {
|
||||||
|
await resend.emails.send({
|
||||||
|
from: 'QR Master <onboarding@resend.dev>',
|
||||||
|
replyTo: 'support@qrmaster.net',
|
||||||
|
to: email,
|
||||||
|
subject: '🎉 You\'re on the list! AI-Powered QR Features Coming Soon',
|
||||||
|
html: `
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Welcome to QR Master AI Newsletter</title>
|
||||||
|
</head>
|
||||||
|
<body style="margin: 0; padding: 0; font-family: Arial, sans-serif; background-color: #f4f4f4;">
|
||||||
|
<table width="100%" cellpadding="0" cellspacing="0" style="background-color: #f4f4f4; padding: 20px 0;">
|
||||||
|
<tr>
|
||||||
|
<td align="center">
|
||||||
|
<table width="600" cellpadding="0" cellspacing="0" style="background-color: #ffffff; border-radius: 8px; overflow: hidden; box-shadow: 0 2px 8px rgba(0,0,0,0.1);">
|
||||||
|
<!-- Header -->
|
||||||
|
<tr>
|
||||||
|
<td style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); padding: 40px 30px; text-align: center;">
|
||||||
|
<h1 style="margin: 0; color: #ffffff; font-size: 28px; font-weight: bold;">QR Master</h1>
|
||||||
|
<p style="margin: 10px 0 0 0; color: #ffffff; font-size: 16px; opacity: 0.9;">AI-Powered QR Features</p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<!-- Content -->
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 40px 30px;">
|
||||||
|
<h2 style="margin: 0 0 20px 0; color: #333333; font-size: 24px;">Welcome to the Future! 🚀</h2>
|
||||||
|
|
||||||
|
<p style="margin: 0 0 20px 0; color: #666666; font-size: 16px; line-height: 1.6;">
|
||||||
|
Thank you for signing up to be notified about our revolutionary AI-powered QR code features! You're among the first to know when these game-changing capabilities launch.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div style="background: linear-gradient(135deg, rgba(102, 126, 234, 0.1) 0%, rgba(118, 75, 162, 0.1) 100%); border-left: 4px solid #667eea; padding: 20px; margin: 30px 0; border-radius: 4px;">
|
||||||
|
<h3 style="margin: 0 0 15px 0; color: #667eea; font-size: 18px;">What's Coming:</h3>
|
||||||
|
<ul style="margin: 0; padding-left: 20px; color: #666666; font-size: 15px; line-height: 1.8;">
|
||||||
|
<li><strong>Smart QR Generation</strong> - AI-powered content optimization & intelligent design suggestions</li>
|
||||||
|
<li><strong>Advanced Analytics</strong> - Scan predictions, anomaly detection & natural language queries</li>
|
||||||
|
<li><strong>Smart Content Management</strong> - AI chatbot, auto-categorization & smart bulk generation</li>
|
||||||
|
<li><strong>Creative & Marketing</strong> - AI-generated designs, copy generation & campaign optimization</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p style="margin: 20px 0; color: #666666; font-size: 16px; line-height: 1.6;">
|
||||||
|
We're working hard to bring these features to life and can't wait to share them with you. We'll send you an email as soon as they're ready to use!
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p style="margin: 30px 0 0 0; color: #666666; font-size: 16px; line-height: 1.6;">
|
||||||
|
In the meantime, feel free to explore our existing features at <a href="https://www.qrmaster.net" style="color: #667eea; text-decoration: none;">qrmaster.net</a>
|
||||||
|
</p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<!-- Footer -->
|
||||||
|
<tr>
|
||||||
|
<td style="background-color: #f8f8f8; padding: 30px; text-align: center; border-top: 1px solid #eeeeee;">
|
||||||
|
<p style="margin: 0 0 10px 0; color: #999999; font-size: 12px;">
|
||||||
|
© 2025 QR Master. All rights reserved.
|
||||||
|
</p>
|
||||||
|
<p style="margin: 0; color: #999999; font-size: 12px;">
|
||||||
|
You're receiving this email because you signed up for AI feature notifications.
|
||||||
|
</p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
`,
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('Newsletter welcome email sent successfully to:', email);
|
||||||
|
return { success: true };
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error sending newsletter welcome email:', error);
|
||||||
|
throw new Error('Failed to send newsletter welcome email');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function sendAIFeatureLaunchEmail(email: string) {
|
||||||
|
try {
|
||||||
|
await resend.emails.send({
|
||||||
|
from: 'QR Master <onboarding@resend.dev>',
|
||||||
|
replyTo: 'support@qrmaster.net',
|
||||||
|
to: email,
|
||||||
|
subject: '🚀 AI-Powered Features Are Here! QR Master Gets Smarter',
|
||||||
|
html: `
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>AI Features Launched!</title>
|
||||||
|
</head>
|
||||||
|
<body style="margin: 0; padding: 0; font-family: Arial, sans-serif; background-color: #f4f4f4;">
|
||||||
|
<table width="100%" cellpadding="0" cellspacing="0" style="background-color: #f4f4f4; padding: 20px 0;">
|
||||||
|
<tr>
|
||||||
|
<td align="center">
|
||||||
|
<table width="600" cellpadding="0" cellspacing="0" style="background-color: #ffffff; border-radius: 8px; overflow: hidden; box-shadow: 0 2px 8px rgba(0,0,0,0.1);">
|
||||||
|
<!-- Header -->
|
||||||
|
<tr>
|
||||||
|
<td style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); padding: 40px 30px; text-align: center;">
|
||||||
|
<h1 style="margin: 0; color: #ffffff; font-size: 32px; font-weight: bold;">🚀 They're Here!</h1>
|
||||||
|
<p style="margin: 10px 0 0 0; color: #ffffff; font-size: 18px; opacity: 0.95;">AI-Powered QR Features Are Live</p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<!-- Content -->
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 40px 30px;">
|
||||||
|
<h2 style="margin: 0 0 20px 0; color: #333333; font-size: 24px;">The wait is over! ✨</h2>
|
||||||
|
|
||||||
|
<p style="margin: 0 0 20px 0; color: #666666; font-size: 16px; line-height: 1.6;">
|
||||||
|
We're excited to announce that the AI-powered features you've been waiting for are now live on QR Master! Your QR code creation just got a whole lot smarter.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div style="background: linear-gradient(135deg, rgba(102, 126, 234, 0.1) 0%, rgba(118, 75, 162, 0.1) 100%); border-left: 4px solid #667eea; padding: 20px; margin: 30px 0; border-radius: 4px;">
|
||||||
|
<h3 style="margin: 0 0 15px 0; color: #667eea; font-size: 18px;">✨ What's New:</h3>
|
||||||
|
<ul style="margin: 0; padding-left: 20px; color: #666666; font-size: 15px; line-height: 1.8;">
|
||||||
|
<li><strong>Smart QR Generation</strong> - AI optimizes your content and suggests the best designs automatically</li>
|
||||||
|
<li><strong>Advanced Analytics</strong> - Predictive insights and natural language queries for your scan data</li>
|
||||||
|
<li><strong>Auto-Organization</strong> - Your QR codes get categorized and tagged automatically</li>
|
||||||
|
<li><strong>AI Design Studio</strong> - Generate unique, custom QR code designs with AI</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- CTA Button -->
|
||||||
|
<table width="100%" cellpadding="0" cellspacing="0" style="margin: 30px 0;">
|
||||||
|
<tr>
|
||||||
|
<td align="center">
|
||||||
|
<a href="https://www.qrmaster.net/create" style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: #ffffff; text-decoration: none; padding: 16px 40px; border-radius: 8px; font-size: 16px; font-weight: bold; display: inline-block;">
|
||||||
|
Try AI Features Now
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<p style="margin: 20px 0 0 0; color: #666666; font-size: 16px; line-height: 1.6;">
|
||||||
|
Log in to your QR Master account and start exploring the new AI capabilities. We can't wait to see what you create!
|
||||||
|
</p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<!-- Footer -->
|
||||||
|
<tr>
|
||||||
|
<td style="background-color: #f8f8f8; padding: 30px; text-align: center; border-top: 1px solid #eeeeee;">
|
||||||
|
<p style="margin: 0 0 10px 0; color: #999999; font-size: 12px;">
|
||||||
|
© 2025 QR Master. All rights reserved.
|
||||||
|
</p>
|
||||||
|
<p style="margin: 0; color: #999999; font-size: 12px;">
|
||||||
|
You received this email because you subscribed to AI feature notifications.
|
||||||
|
</p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
`,
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('AI feature launch email sent successfully to:', email);
|
||||||
|
return { success: true };
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error sending AI feature launch email:', error);
|
||||||
|
throw new Error('Failed to send AI feature launch email');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -212,6 +212,14 @@ export const RateLimits = {
|
||||||
windowSeconds: 60 * 60,
|
windowSeconds: 60 * 60,
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Newsletter endpoints
|
||||||
|
// Newsletter subscribe: 5 per hour (prevent spam)
|
||||||
|
NEWSLETTER_SUBSCRIBE: {
|
||||||
|
name: 'newsletter-subscribe',
|
||||||
|
maxRequests: 5,
|
||||||
|
windowSeconds: 60 * 60,
|
||||||
|
},
|
||||||
|
|
||||||
// General API: 100 requests per minute
|
// General API: 100 requests per minute
|
||||||
API: {
|
API: {
|
||||||
name: 'api',
|
name: 'api',
|
||||||
|
|
|
||||||
|
|
@ -140,6 +140,18 @@ export const createCheckoutSchema = z.object({
|
||||||
priceId: z.string().min(1, 'Price ID is required'),
|
priceId: z.string().min(1, 'Price ID is required'),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// Newsletter Schemas
|
||||||
|
// ==========================================
|
||||||
|
|
||||||
|
export const newsletterSubscribeSchema = z.object({
|
||||||
|
email: z.string()
|
||||||
|
.email('Invalid email format')
|
||||||
|
.toLowerCase()
|
||||||
|
.trim()
|
||||||
|
.max(255, 'Email must be less than 255 characters'),
|
||||||
|
});
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
// Helper: Format Zod Errors
|
// Helper: Format Zod Errors
|
||||||
// ==========================================
|
// ==========================================
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue