feat: add newsletter broadcast system with admin login and dynamic QR code redirect service with scan tracking.
This commit is contained in:
@@ -60,7 +60,7 @@ export default function MarketingLayout({
|
||||
|
||||
{/* Mobile Menu Button */}
|
||||
<button
|
||||
className="md:hidden"
|
||||
className="md:hidden text-gray-900"
|
||||
onClick={() => setMobileMenuOpen(!mobileMenuOpen)}
|
||||
>
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
@@ -129,7 +129,7 @@ export default function MarketingLayout({
|
||||
<div>
|
||||
<h3 className="font-semibold mb-4">Resources</h3>
|
||||
<ul className="space-y-2 text-gray-400">
|
||||
<li><Link href="/pricing" className="hover:text-white">Full Pricing</Link></li>
|
||||
<li><Link href="/#features" className="hover:text-white">Full Pricing</Link></li>
|
||||
<li><Link href="/faq" className="hover:text-white">All Questions</Link></li>
|
||||
<li><Link href="/blog" className="hover:text-white">Blog</Link></li>
|
||||
<li><Link href="/signup" className="hover:text-white">Get Started</Link></li>
|
||||
|
||||
@@ -23,6 +23,8 @@ export async function POST(request: NextRequest) {
|
||||
|
||||
// SECURITY: Only allow support@qrmaster.net to access newsletter admin
|
||||
const ALLOWED_ADMIN_EMAIL = 'support@qrmaster.net';
|
||||
const ALLOWED_ADMIN_PASSWORD = 'Timo.16092005';
|
||||
|
||||
if (email.toLowerCase() !== ALLOWED_ADMIN_EMAIL) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Access denied. Only authorized accounts can access this area.' },
|
||||
@@ -30,40 +32,21 @@ export async function POST(request: NextRequest) {
|
||||
);
|
||||
}
|
||||
|
||||
// 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) {
|
||||
// Verify password with hardcoded value
|
||||
if (password !== ALLOWED_ADMIN_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
|
||||
// Set auth cookie with a simple session identifier
|
||||
const response = NextResponse.json({
|
||||
success: true,
|
||||
message: 'Login successful',
|
||||
});
|
||||
|
||||
response.cookies.set('userId', user.id, getAuthCookieOptions());
|
||||
response.cookies.set('newsletter-admin', 'authenticated', getAuthCookieOptions());
|
||||
|
||||
return response;
|
||||
} catch (error) {
|
||||
|
||||
@@ -11,10 +11,10 @@ import { rateLimit, RateLimits } from '@/lib/rateLimit';
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
// Check authentication
|
||||
const userId = cookies().get('userId')?.value;
|
||||
// Check authentication using newsletter-admin cookie
|
||||
const adminCookie = cookies().get('newsletter-admin')?.value;
|
||||
|
||||
if (!userId) {
|
||||
if (adminCookie !== 'authenticated') {
|
||||
return NextResponse.json(
|
||||
{ error: 'Unauthorized. Please log in.' },
|
||||
{ status: 401 }
|
||||
@@ -28,7 +28,7 @@ export async function POST(request: NextRequest) {
|
||||
// }
|
||||
|
||||
// Rate limiting (prevent accidental spam)
|
||||
const rateLimitResult = rateLimit(userId, {
|
||||
const rateLimitResult = rateLimit('newsletter-admin', {
|
||||
name: 'newsletter-broadcast',
|
||||
maxRequests: 2, // Only 2 broadcasts per hour
|
||||
windowSeconds: 60 * 60,
|
||||
@@ -119,10 +119,10 @@ export async function POST(request: NextRequest) {
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
// Check authentication
|
||||
const userId = cookies().get('userId')?.value;
|
||||
// Check authentication using newsletter-admin cookie
|
||||
const adminCookie = cookies().get('newsletter-admin')?.value;
|
||||
|
||||
if (!userId) {
|
||||
if (adminCookie !== 'authenticated') {
|
||||
return NextResponse.json(
|
||||
{ error: 'Unauthorized. Please log in.' },
|
||||
{ status: 401 }
|
||||
|
||||
@@ -8,7 +8,7 @@ export async function GET(
|
||||
) {
|
||||
try {
|
||||
const { slug } = await params;
|
||||
|
||||
|
||||
// Fetch QR code by slug
|
||||
const qrCode = await db.qRCode.findUnique({
|
||||
where: { slug },
|
||||
@@ -29,7 +29,7 @@ export async function GET(
|
||||
// Determine destination URL
|
||||
let destination = '';
|
||||
const content = qrCode.content as any;
|
||||
|
||||
|
||||
switch (qrCode.contentType) {
|
||||
case 'URL':
|
||||
destination = content.url || 'https://example.com';
|
||||
@@ -67,7 +67,7 @@ export async function GET(
|
||||
const searchParams = request.nextUrl.searchParams;
|
||||
const utmParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content'];
|
||||
const preservedParams = new URLSearchParams();
|
||||
|
||||
|
||||
utmParams.forEach(param => {
|
||||
const value = searchParams.get(param);
|
||||
if (value) {
|
||||
@@ -94,8 +94,8 @@ async function trackScan(qrId: string, request: NextRequest) {
|
||||
const userAgent = request.headers.get('user-agent') || '';
|
||||
const referer = request.headers.get('referer') || '';
|
||||
const ip = request.headers.get('x-forwarded-for') ||
|
||||
request.headers.get('x-real-ip') ||
|
||||
'unknown';
|
||||
request.headers.get('x-real-ip') ||
|
||||
'unknown';
|
||||
|
||||
// Check DNT header
|
||||
const dnt = request.headers.get('dnt');
|
||||
@@ -113,12 +113,11 @@ async function trackScan(qrId: string, request: NextRequest) {
|
||||
|
||||
// Hash IP for privacy
|
||||
const ipHash = hashIP(ip);
|
||||
|
||||
// Parse user agent for device info
|
||||
|
||||
const isTablet = /tablet|ipad|playbook|silk|android(?!.*mobile)/i.test(userAgent);
|
||||
const isMobile = /mobile|android|iphone/i.test(userAgent);
|
||||
const isTablet = /tablet|ipad/i.test(userAgent);
|
||||
const device = isTablet ? 'tablet' : isMobile ? 'mobile' : 'desktop';
|
||||
|
||||
|
||||
// Detect OS
|
||||
let os = 'unknown';
|
||||
if (/windows/i.test(userAgent)) os = 'Windows';
|
||||
@@ -126,22 +125,22 @@ async function trackScan(qrId: string, request: NextRequest) {
|
||||
else if (/linux/i.test(userAgent)) os = 'Linux';
|
||||
else if (/android/i.test(userAgent)) os = 'Android';
|
||||
else if (/ios|iphone|ipad/i.test(userAgent)) os = 'iOS';
|
||||
|
||||
|
||||
// Get country from header (Vercel/Cloudflare provide this)
|
||||
const country = request.headers.get('x-vercel-ip-country') ||
|
||||
request.headers.get('cf-ipcountry') ||
|
||||
'unknown';
|
||||
|
||||
request.headers.get('cf-ipcountry') ||
|
||||
'unknown';
|
||||
|
||||
// Extract UTM parameters
|
||||
const searchParams = request.nextUrl.searchParams;
|
||||
const utmSource = searchParams.get('utm_source');
|
||||
const utmMedium = searchParams.get('utm_medium');
|
||||
const utmCampaign = searchParams.get('utm_campaign');
|
||||
|
||||
|
||||
// Check if this is a unique scan (first scan from this IP today)
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
|
||||
|
||||
const existingScan = await db.qRScan.findFirst({
|
||||
where: {
|
||||
qrId,
|
||||
@@ -151,9 +150,9 @@ async function trackScan(qrId: string, request: NextRequest) {
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
const isUnique = !existingScan;
|
||||
|
||||
|
||||
// Create scan record
|
||||
await db.qRScan.create({
|
||||
data: {
|
||||
|
||||
Reference in New Issue
Block a user