feat: add newsletter broadcast system with admin login and dynamic QR code redirect service with scan tracking.
This commit is contained in:
parent
a15e3b67c2
commit
0302821f0f
|
|
@ -60,7 +60,7 @@ export default function MarketingLayout({
|
||||||
|
|
||||||
{/* Mobile Menu Button */}
|
{/* Mobile Menu Button */}
|
||||||
<button
|
<button
|
||||||
className="md:hidden"
|
className="md:hidden text-gray-900"
|
||||||
onClick={() => setMobileMenuOpen(!mobileMenuOpen)}
|
onClick={() => setMobileMenuOpen(!mobileMenuOpen)}
|
||||||
>
|
>
|
||||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
|
@ -129,7 +129,7 @@ export default function MarketingLayout({
|
||||||
<div>
|
<div>
|
||||||
<h3 className="font-semibold mb-4">Resources</h3>
|
<h3 className="font-semibold mb-4">Resources</h3>
|
||||||
<ul className="space-y-2 text-gray-400">
|
<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="/faq" className="hover:text-white">All Questions</Link></li>
|
||||||
<li><Link href="/blog" className="hover:text-white">Blog</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>
|
<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
|
// SECURITY: Only allow support@qrmaster.net to access newsletter admin
|
||||||
const ALLOWED_ADMIN_EMAIL = 'support@qrmaster.net';
|
const ALLOWED_ADMIN_EMAIL = 'support@qrmaster.net';
|
||||||
|
const ALLOWED_ADMIN_PASSWORD = 'Timo.16092005';
|
||||||
|
|
||||||
if (email.toLowerCase() !== ALLOWED_ADMIN_EMAIL) {
|
if (email.toLowerCase() !== ALLOWED_ADMIN_EMAIL) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: 'Access denied. Only authorized accounts can access this area.' },
|
{ error: 'Access denied. Only authorized accounts can access this area.' },
|
||||||
|
|
@ -30,40 +32,21 @@ export async function POST(request: NextRequest) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if user exists
|
// Verify password with hardcoded value
|
||||||
const user = await db.user.findUnique({
|
if (password !== ALLOWED_ADMIN_PASSWORD) {
|
||||||
where: { email: email.toLowerCase() },
|
|
||||||
select: {
|
|
||||||
id: true,
|
|
||||||
email: true,
|
|
||||||
password: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!user || !user.password) {
|
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: 'Invalid credentials' },
|
{ error: 'Invalid credentials' },
|
||||||
{ status: 401 }
|
{ status: 401 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify password
|
// Set auth cookie with a simple session identifier
|
||||||
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({
|
const response = NextResponse.json({
|
||||||
success: true,
|
success: true,
|
||||||
message: 'Login successful',
|
message: 'Login successful',
|
||||||
});
|
});
|
||||||
|
|
||||||
response.cookies.set('userId', user.id, getAuthCookieOptions());
|
response.cookies.set('newsletter-admin', 'authenticated', getAuthCookieOptions());
|
||||||
|
|
||||||
return response;
|
return response;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
|
||||||
|
|
@ -11,10 +11,10 @@ import { rateLimit, RateLimits } from '@/lib/rateLimit';
|
||||||
*/
|
*/
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
// Check authentication
|
// Check authentication using newsletter-admin cookie
|
||||||
const userId = cookies().get('userId')?.value;
|
const adminCookie = cookies().get('newsletter-admin')?.value;
|
||||||
|
|
||||||
if (!userId) {
|
if (adminCookie !== 'authenticated') {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: 'Unauthorized. Please log in.' },
|
{ error: 'Unauthorized. Please log in.' },
|
||||||
{ status: 401 }
|
{ status: 401 }
|
||||||
|
|
@ -28,7 +28,7 @@ export async function POST(request: NextRequest) {
|
||||||
// }
|
// }
|
||||||
|
|
||||||
// Rate limiting (prevent accidental spam)
|
// Rate limiting (prevent accidental spam)
|
||||||
const rateLimitResult = rateLimit(userId, {
|
const rateLimitResult = rateLimit('newsletter-admin', {
|
||||||
name: 'newsletter-broadcast',
|
name: 'newsletter-broadcast',
|
||||||
maxRequests: 2, // Only 2 broadcasts per hour
|
maxRequests: 2, // Only 2 broadcasts per hour
|
||||||
windowSeconds: 60 * 60,
|
windowSeconds: 60 * 60,
|
||||||
|
|
@ -119,10 +119,10 @@ export async function POST(request: NextRequest) {
|
||||||
*/
|
*/
|
||||||
export async function GET(request: NextRequest) {
|
export async function GET(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
// Check authentication
|
// Check authentication using newsletter-admin cookie
|
||||||
const userId = cookies().get('userId')?.value;
|
const adminCookie = cookies().get('newsletter-admin')?.value;
|
||||||
|
|
||||||
if (!userId) {
|
if (adminCookie !== 'authenticated') {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: 'Unauthorized. Please log in.' },
|
{ error: 'Unauthorized. Please log in.' },
|
||||||
{ status: 401 }
|
{ status: 401 }
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ export async function GET(
|
||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
const { slug } = await params;
|
const { slug } = await params;
|
||||||
|
|
||||||
// Fetch QR code by slug
|
// Fetch QR code by slug
|
||||||
const qrCode = await db.qRCode.findUnique({
|
const qrCode = await db.qRCode.findUnique({
|
||||||
where: { slug },
|
where: { slug },
|
||||||
|
|
@ -29,7 +29,7 @@ export async function GET(
|
||||||
// Determine destination URL
|
// Determine destination URL
|
||||||
let destination = '';
|
let destination = '';
|
||||||
const content = qrCode.content as any;
|
const content = qrCode.content as any;
|
||||||
|
|
||||||
switch (qrCode.contentType) {
|
switch (qrCode.contentType) {
|
||||||
case 'URL':
|
case 'URL':
|
||||||
destination = content.url || 'https://example.com';
|
destination = content.url || 'https://example.com';
|
||||||
|
|
@ -67,7 +67,7 @@ export async function GET(
|
||||||
const searchParams = request.nextUrl.searchParams;
|
const searchParams = request.nextUrl.searchParams;
|
||||||
const utmParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content'];
|
const utmParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content'];
|
||||||
const preservedParams = new URLSearchParams();
|
const preservedParams = new URLSearchParams();
|
||||||
|
|
||||||
utmParams.forEach(param => {
|
utmParams.forEach(param => {
|
||||||
const value = searchParams.get(param);
|
const value = searchParams.get(param);
|
||||||
if (value) {
|
if (value) {
|
||||||
|
|
@ -94,8 +94,8 @@ async function trackScan(qrId: string, request: NextRequest) {
|
||||||
const userAgent = request.headers.get('user-agent') || '';
|
const userAgent = request.headers.get('user-agent') || '';
|
||||||
const referer = request.headers.get('referer') || '';
|
const referer = request.headers.get('referer') || '';
|
||||||
const ip = request.headers.get('x-forwarded-for') ||
|
const ip = request.headers.get('x-forwarded-for') ||
|
||||||
request.headers.get('x-real-ip') ||
|
request.headers.get('x-real-ip') ||
|
||||||
'unknown';
|
'unknown';
|
||||||
|
|
||||||
// Check DNT header
|
// Check DNT header
|
||||||
const dnt = request.headers.get('dnt');
|
const dnt = request.headers.get('dnt');
|
||||||
|
|
@ -113,12 +113,11 @@ async function trackScan(qrId: string, request: NextRequest) {
|
||||||
|
|
||||||
// Hash IP for privacy
|
// Hash IP for privacy
|
||||||
const ipHash = hashIP(ip);
|
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 isMobile = /mobile|android|iphone/i.test(userAgent);
|
||||||
const isTablet = /tablet|ipad/i.test(userAgent);
|
|
||||||
const device = isTablet ? 'tablet' : isMobile ? 'mobile' : 'desktop';
|
const device = isTablet ? 'tablet' : isMobile ? 'mobile' : 'desktop';
|
||||||
|
|
||||||
// Detect OS
|
// Detect OS
|
||||||
let os = 'unknown';
|
let os = 'unknown';
|
||||||
if (/windows/i.test(userAgent)) os = 'Windows';
|
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 (/linux/i.test(userAgent)) os = 'Linux';
|
||||||
else if (/android/i.test(userAgent)) os = 'Android';
|
else if (/android/i.test(userAgent)) os = 'Android';
|
||||||
else if (/ios|iphone|ipad/i.test(userAgent)) os = 'iOS';
|
else if (/ios|iphone|ipad/i.test(userAgent)) os = 'iOS';
|
||||||
|
|
||||||
// Get country from header (Vercel/Cloudflare provide this)
|
// Get country from header (Vercel/Cloudflare provide this)
|
||||||
const country = request.headers.get('x-vercel-ip-country') ||
|
const country = request.headers.get('x-vercel-ip-country') ||
|
||||||
request.headers.get('cf-ipcountry') ||
|
request.headers.get('cf-ipcountry') ||
|
||||||
'unknown';
|
'unknown';
|
||||||
|
|
||||||
// Extract UTM parameters
|
// Extract UTM parameters
|
||||||
const searchParams = request.nextUrl.searchParams;
|
const searchParams = request.nextUrl.searchParams;
|
||||||
const utmSource = searchParams.get('utm_source');
|
const utmSource = searchParams.get('utm_source');
|
||||||
const utmMedium = searchParams.get('utm_medium');
|
const utmMedium = searchParams.get('utm_medium');
|
||||||
const utmCampaign = searchParams.get('utm_campaign');
|
const utmCampaign = searchParams.get('utm_campaign');
|
||||||
|
|
||||||
// Check if this is a unique scan (first scan from this IP today)
|
// Check if this is a unique scan (first scan from this IP today)
|
||||||
const today = new Date();
|
const today = new Date();
|
||||||
today.setHours(0, 0, 0, 0);
|
today.setHours(0, 0, 0, 0);
|
||||||
|
|
||||||
const existingScan = await db.qRScan.findFirst({
|
const existingScan = await db.qRScan.findFirst({
|
||||||
where: {
|
where: {
|
||||||
qrId,
|
qrId,
|
||||||
|
|
@ -151,9 +150,9 @@ async function trackScan(qrId: string, request: NextRequest) {
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const isUnique = !existingScan;
|
const isUnique = !existingScan;
|
||||||
|
|
||||||
// Create scan record
|
// Create scan record
|
||||||
await db.qRScan.create({
|
await db.qRScan.create({
|
||||||
data: {
|
data: {
|
||||||
|
|
|
||||||
|
|
@ -57,7 +57,7 @@ export async function sendPasswordResetEmail(email: string, resetToken: string)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await resend.emails.send({
|
await resend.emails.send({
|
||||||
from: 'QR Master Security <onboarding@resend.dev>',
|
from: 'QR Master Security <noreply@qrmaster.net>',
|
||||||
replyTo: 'support@qrmaster.net',
|
replyTo: 'support@qrmaster.net',
|
||||||
to: email,
|
to: email,
|
||||||
subject: '🔐 Reset Your QR Master Password (Expires in 1 Hour)',
|
subject: '🔐 Reset Your QR Master Password (Expires in 1 Hour)',
|
||||||
|
|
@ -189,7 +189,7 @@ export async function sendNewsletterWelcomeEmail(email: string) {
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await resend.emails.send({
|
await resend.emails.send({
|
||||||
from: 'Timo from QR Master <onboarding@resend.dev>',
|
from: 'Timo from QR Master <timo@qrmaster.net>',
|
||||||
replyTo: 'support@qrmaster.net',
|
replyTo: 'support@qrmaster.net',
|
||||||
to: email,
|
to: email,
|
||||||
subject: '🎉 You\'re In! Here\'s What Happens Next (AI QR Features)',
|
subject: '🎉 You\'re In! Here\'s What Happens Next (AI QR Features)',
|
||||||
|
|
@ -361,7 +361,7 @@ export async function sendAIFeatureLaunchEmail(email: string) {
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await resend.emails.send({
|
await resend.emails.send({
|
||||||
from: 'Timo from QR Master <onboarding@resend.dev>',
|
from: 'Timo from QR Master <timo@qrmaster.net>',
|
||||||
replyTo: 'support@qrmaster.net',
|
replyTo: 'support@qrmaster.net',
|
||||||
to: email,
|
to: email,
|
||||||
subject: '🚀 They\'re Live! Your AI QR Features Are Ready',
|
subject: '🚀 They\'re Live! Your AI QR Features Are Ready',
|
||||||
|
|
|
||||||
|
|
@ -24,11 +24,11 @@ export function parseUserAgent(userAgent: string | null): { device: string | nul
|
||||||
let device: string | null = null;
|
let device: string | null = null;
|
||||||
let os: string | null = null;
|
let os: string | null = null;
|
||||||
|
|
||||||
// Detect device
|
// Detect device - check tablet FIRST since iPad can match mobile patterns
|
||||||
if (/Mobile|Android|iPhone|iPad/.test(userAgent)) {
|
if (/Tablet|iPad/i.test(userAgent)) {
|
||||||
device = 'mobile';
|
|
||||||
} else if (/Tablet|iPad/.test(userAgent)) {
|
|
||||||
device = 'tablet';
|
device = 'tablet';
|
||||||
|
} else if (/Mobile|Android|iPhone/i.test(userAgent)) {
|
||||||
|
device = 'mobile';
|
||||||
} else {
|
} else {
|
||||||
device = 'desktop';
|
device = 'desktop';
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -46,6 +46,7 @@ export function organizationSchema() {
|
||||||
'@type': 'Organization',
|
'@type': 'Organization',
|
||||||
'@id': 'https://www.qrmaster.net/#organization',
|
'@id': 'https://www.qrmaster.net/#organization',
|
||||||
name: 'QR Master',
|
name: 'QR Master',
|
||||||
|
alternateName: 'QRMaster',
|
||||||
url: 'https://www.qrmaster.net',
|
url: 'https://www.qrmaster.net',
|
||||||
logo: {
|
logo: {
|
||||||
'@type': 'ImageObject',
|
'@type': 'ImageObject',
|
||||||
|
|
@ -53,6 +54,7 @@ export function organizationSchema() {
|
||||||
width: 1200,
|
width: 1200,
|
||||||
height: 630,
|
height: 630,
|
||||||
},
|
},
|
||||||
|
image: 'https://www.qrmaster.net/static/og-image.png',
|
||||||
sameAs: [
|
sameAs: [
|
||||||
'https://twitter.com/qrmaster',
|
'https://twitter.com/qrmaster',
|
||||||
],
|
],
|
||||||
|
|
@ -60,8 +62,45 @@ export function organizationSchema() {
|
||||||
'@type': 'ContactPoint',
|
'@type': 'ContactPoint',
|
||||||
contactType: 'Customer Support',
|
contactType: 'Customer Support',
|
||||||
email: 'support@qrmaster.net',
|
email: 'support@qrmaster.net',
|
||||||
|
availableLanguage: ['English', 'German'],
|
||||||
|
},
|
||||||
|
description: 'B2B SaaS platform for dynamic QR code generation with analytics, branding, and bulk generation for enterprise marketing campaigns.',
|
||||||
|
slogan: 'Dynamic QR codes that work smarter',
|
||||||
|
foundingDate: '2025',
|
||||||
|
areaServed: 'Worldwide',
|
||||||
|
serviceType: 'Software as a Service',
|
||||||
|
priceRange: '$0 - $29',
|
||||||
|
knowsAbout: [
|
||||||
|
'QR Code Generation',
|
||||||
|
'Marketing Analytics',
|
||||||
|
'Campaign Tracking',
|
||||||
|
'Dynamic QR Codes',
|
||||||
|
'Bulk QR Generation',
|
||||||
|
],
|
||||||
|
hasOfferCatalog: {
|
||||||
|
'@type': 'OfferCatalog',
|
||||||
|
name: 'QR Master Plans',
|
||||||
|
itemListElement: [
|
||||||
|
{
|
||||||
|
'@type': 'Offer',
|
||||||
|
itemOffered: {
|
||||||
|
'@type': 'SoftwareApplication',
|
||||||
|
name: 'QR Master Free',
|
||||||
|
applicationCategory: 'BusinessApplication',
|
||||||
|
operatingSystem: 'Web Browser',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'@type': 'Offer',
|
||||||
|
itemOffered: {
|
||||||
|
'@type': 'SoftwareApplication',
|
||||||
|
name: 'QR Master Pro',
|
||||||
|
applicationCategory: 'BusinessApplication',
|
||||||
|
operatingSystem: 'Web Browser',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
},
|
},
|
||||||
description: 'Dynamic QR code generator with analytics, branding, and bulk generation for modern marketing campaigns.',
|
|
||||||
inLanguage: 'en',
|
inLanguage: 'en',
|
||||||
mainEntityOfPage: 'https://www.qrmaster.net',
|
mainEntityOfPage: 'https://www.qrmaster.net',
|
||||||
};
|
};
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue