Compare commits

...

2 Commits

Author SHA1 Message Date
Timo Knuth 6aa3267f26 Newsletter comming soon 2025-12-18 15:54:53 +01:00
Timo Knuth f1d1f4291b Analytics 2025-12-15 20:35:50 +01:00
19 changed files with 1303 additions and 33 deletions

10
package-lock.json generated
View File

@ -21,6 +21,7 @@
"i18next": "^23.7.6",
"ioredis": "^5.3.2",
"jszip": "^3.10.1",
"lucide-react": "^0.562.0",
"next": "14.2.18",
"next-auth": "^4.24.5",
"papaparse": "^5.4.1",
@ -5412,6 +5413,15 @@
"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": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",

View File

@ -37,6 +37,7 @@
"i18next": "^23.7.6",
"ioredis": "^5.3.2",
"jszip": "^3.10.1",
"lucide-react": "^0.562.0",
"next": "14.2.18",
"next-auth": "^4.24.5",
"papaparse": "^5.4.1",

View File

@ -150,3 +150,15 @@ model Integration {
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])
}

View File

@ -219,8 +219,14 @@ export default function AnalyticsPage() {
<p className="text-2xl font-bold text-gray-900">
{analyticsData?.summary.totalScans.toLocaleString() || '0'}
</p>
<p className={`text-sm mt-2 ${analyticsData?.summary.totalScans > 0 ? 'text-green-600' : 'text-gray-500'}`}>
{analyticsData?.summary.totalScans > 0 ? '+12.5%' : 'No data'} from last period
<p className={`text-sm mt-2 ${
analyticsData?.summary.scansTrend?.trend === 'up' ? 'text-green-600' :
analyticsData?.summary.scansTrend?.trend === 'down' ? 'text-red-600' :
'text-gray-500'
}`}>
{analyticsData?.summary.scansTrend
? `${analyticsData.summary.scansTrend.isNegative ? '-' : '+'}${analyticsData.summary.scansTrend.percentage}%${analyticsData.summary.scansTrend.isNew ? ' (new)' : ''} from last ${analyticsData.summary.comparisonPeriod || 'period'}`
: 'No data'}
</p>
</CardContent>
</Card>
@ -231,8 +237,14 @@ export default function AnalyticsPage() {
<p className="text-2xl font-bold text-gray-900">
{analyticsData?.summary.avgScansPerQR || '0'}
</p>
<p className={`text-sm mt-2 ${analyticsData?.summary.avgScansPerQR > 0 ? 'text-green-600' : 'text-gray-500'}`}>
{analyticsData?.summary.avgScansPerQR > 0 ? '+8.3%' : 'No data'} from last period
<p className={`text-sm mt-2 ${
analyticsData?.summary.avgScansTrend?.trend === 'up' ? 'text-green-600' :
analyticsData?.summary.avgScansTrend?.trend === 'down' ? 'text-red-600' :
'text-gray-500'
}`}>
{analyticsData?.summary.avgScansTrend
? `${analyticsData.summary.avgScansTrend.isNegative ? '-' : '+'}${analyticsData.summary.avgScansTrend.percentage}%${analyticsData.summary.avgScansTrend.isNew ? ' (new)' : ''} from last ${analyticsData.summary.comparisonPeriod || 'period'}`
: 'No data'}
</p>
</CardContent>
</Card>
@ -349,7 +361,7 @@ export default function AnalyticsPage() {
country.trend === 'down' ? 'destructive' :
'default'
}>
{country.trend === 'up' ? '↑' : country.trend === 'down' ? '↓' : '→'} {country.trendPercentage}%
{country.trend === 'up' ? '↑' : country.trend === 'down' ? '↓' : '→'} {country.trendPercentage}%{country.isNew ? ' (new)' : ''}
</Badge>
</td>
</tr>
@ -398,7 +410,7 @@ export default function AnalyticsPage() {
qr.trend === 'down' ? 'destructive' :
'default'
}>
{qr.trend === 'up' ? '↑' : qr.trend === 'down' ? '↓' : '→'} {qr.trendPercentage}%
{qr.trend === 'up' ? '↑' : qr.trend === 'down' ? '↓' : '→'} {qr.trendPercentage}%{qr.isNew ? ' (new)' : ''}
</Badge>
</td>
</tr>

View File

@ -41,6 +41,7 @@ export default function DashboardPage() {
activeQRCodes: 0,
conversionRate: 0,
});
const [analyticsData, setAnalyticsData] = useState<any>(null);
const mockQRCodes = [
{
@ -239,6 +240,13 @@ export default function DashboardPage() {
const userData = await userResponse.json();
setUserPlan(userData.plan || 'FREE');
}
// Fetch analytics data for trends (last 30 days = month comparison)
const analyticsResponse = await fetch('/api/analytics/summary?range=30');
if (analyticsResponse.ok) {
const analytics = await analyticsResponse.json();
setAnalyticsData(analytics);
}
} catch (error) {
console.error('Error fetching data:', error);
setQrCodes([]);
@ -357,7 +365,13 @@ export default function DashboardPage() {
</div>
{/* Stats Grid */}
<StatsGrid stats={stats} />
<StatsGrid
stats={stats}
trends={{
totalScans: analyticsData?.summary.scansTrend,
comparisonPeriod: analyticsData?.summary.comparisonPeriod || 'month'
}}
/>
{/* Recent QR Codes */}
<div>

View File

@ -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>&copy; 2025 QR Master. All rights reserved.</p>
<div className="w-12"></div>
</div>
</div>
</footer>

View 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>
);
}

View File

@ -2,21 +2,41 @@ import { NextRequest, NextResponse } from 'next/server';
import { cookies } from 'next/headers';
import { db } from '@/lib/db';
import { rateLimit, getClientIdentifier, RateLimits } from '@/lib/rateLimit';
import { TrendData } from '@/types/analytics';
export const dynamic = 'force-dynamic';
// Helper function to calculate trend
function calculateTrend(current: number, previous: number): { trend: 'up' | 'down' | 'flat'; percentage: number } {
if (previous === 0) {
return current > 0 ? { trend: 'up', percentage: 100 } : { trend: 'flat', percentage: 0 };
// Helper function to calculate trend with proper edge case handling
function calculateTrend(current: number, previous: number): TrendData {
// Handle edge case: no data in either period
if (previous === 0 && current === 0) {
return { trend: 'flat', percentage: 0 };
}
const change = ((current - previous) / previous) * 100;
const percentage = Math.round(Math.abs(change));
// Handle new growth from zero - mark as "new" to distinguish from actual 100% growth
if (previous === 0 && current > 0) {
return { trend: 'up', percentage: 100, isNew: true };
}
if (change > 5) return { trend: 'up', percentage };
if (change < -5) return { trend: 'down', percentage };
return { trend: 'flat', percentage };
// Calculate actual percentage change
const change = ((current - previous) / previous) * 100;
const roundedChange = Math.round(change);
// Determine trend direction (use threshold of 5% to filter noise)
let trend: 'up' | 'down' | 'flat';
if (roundedChange > 5) {
trend = 'up';
} else if (roundedChange < -5) {
trend = 'down';
} else {
trend = 'flat';
}
return {
trend,
percentage: Math.abs(roundedChange),
isNegative: roundedChange < 0
};
}
export async function GET(request: NextRequest) {
@ -52,14 +72,18 @@ export async function GET(request: NextRequest) {
const range = searchParams.get('range') || '30';
const daysInRange = parseInt(range, 10);
// Standardize to week (7 days) or month (30 days) for clear comparison labels
const comparisonDays = daysInRange <= 7 ? 7 : 30;
const comparisonPeriod: 'week' | 'month' = comparisonDays === 7 ? 'week' : 'month';
// Calculate current and previous period dates
const now = new Date();
const currentPeriodStart = new Date();
currentPeriodStart.setDate(now.getDate() - daysInRange);
currentPeriodStart.setDate(now.getDate() - comparisonDays);
const previousPeriodEnd = new Date(currentPeriodStart);
const previousPeriodStart = new Date(previousPeriodEnd);
previousPeriodStart.setDate(previousPeriodEnd.getDate() - daysInRange);
previousPeriodStart.setDate(previousPeriodEnd.getDate() - comparisonDays);
// Get user's QR codes with scans filtered by period
const qrCodes = await db.qRCode.findMany({
@ -102,6 +126,22 @@ export async function GET(request: NextRequest) {
sum + qr.scans.filter(s => s.isUnique).length, 0
);
// Calculate average scans per QR code (only count QR codes with scans)
const qrCodesWithScans = qrCodes.filter(qr => qr.scans.length > 0).length;
const avgScansPerQR = qrCodesWithScans > 0
? Math.round(totalScans / qrCodesWithScans)
: 0;
// Calculate previous period average scans per QR
const previousQrCodesWithScans = qrCodesWithPreviousScans.filter(qr => qr.scans.length > 0).length;
const previousAvgScansPerQR = previousQrCodesWithScans > 0
? Math.round(previousTotalScans / previousQrCodesWithScans)
: 0;
// Calculate trends
const scansTrend = calculateTrend(totalScans, previousTotalScans);
const avgScansTrend = calculateTrend(avgScansPerQR, previousAvgScansPerQR);
// Device stats
const deviceStats = qrCodes.flatMap(qr => qr.scans)
.reduce((acc, scan) => {
@ -118,7 +158,7 @@ export async function GET(request: NextRequest) {
// Country stats (current period)
const countryStats = qrCodes.flatMap(qr => qr.scans)
.reduce((acc, scan) => {
const country = scan.country || 'Unknown';
const country = scan.country ?? 'Unknown Location';
acc[country] = (acc[country] || 0) + 1;
return acc;
}, {} as Record<string, number>);
@ -126,7 +166,7 @@ export async function GET(request: NextRequest) {
// Country stats (previous period)
const previousCountryStats = qrCodesWithPreviousScans.flatMap(qr => qr.scans)
.reduce((acc, scan) => {
const country = scan.country || 'Unknown';
const country = scan.country ?? 'Unknown Location';
acc[country] = (acc[country] || 0) + 1;
return acc;
}, {} as Record<string, number>);
@ -166,6 +206,7 @@ export async function GET(request: NextRequest) {
: 0,
trend: trendData.trend,
trendPercentage: trendData.percentage,
...(trendData.isNew && { isNew: true }),
};
})
.sort((a, b) => b.totalScans - a.totalScans);
@ -174,14 +215,16 @@ export async function GET(request: NextRequest) {
summary: {
totalScans,
uniqueScans,
avgScansPerQR: qrCodes.length > 0
? Math.round(totalScans / qrCodes.length)
: 0,
avgScansPerQR,
mobilePercentage,
topCountry: topCountry ? topCountry[0] : 'N/A',
topCountryPercentage: topCountry && totalScans > 0
? Math.round((topCountry[1] / totalScans) * 100)
: 0,
scansTrend,
avgScansTrend,
comparisonPeriod,
comparisonDays,
},
deviceStats,
countryStats: Object.entries(countryStats)
@ -199,6 +242,7 @@ export async function GET(request: NextRequest) {
: 0,
trend: trendData.trend,
trendPercentage: trendData.percentage,
...(trendData.isNew && { isNew: true }),
};
}),
dailyScans,

View 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 }
);
}
}

View 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 }
);
}
}

View 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 }
);
}
}

View File

@ -4,6 +4,7 @@ import React from 'react';
import { Card, CardContent } from '@/components/ui/Card';
import { formatNumber } from '@/lib/utils';
import { useTranslation } from '@/hooks/useTranslation';
import { TrendData } from '@/types/analytics';
interface StatsGridProps {
stats: {
@ -11,19 +12,42 @@ interface StatsGridProps {
activeQRCodes: number;
conversionRate: number;
};
trends?: {
totalScans?: TrendData;
comparisonPeriod?: 'week' | 'month';
};
}
export const StatsGrid: React.FC<StatsGridProps> = ({ stats }) => {
export const StatsGrid: React.FC<StatsGridProps> = ({ stats, trends }) => {
const { t } = useTranslation();
// Only show growth if there are actual scans
const showGrowth = stats.totalScans > 0;
// Build trend display text
const getTrendText = () => {
if (!trends?.totalScans) {
return 'No data yet';
}
const trend = trends.totalScans;
const sign = trend.isNegative ? '-' : '+';
const period = trends.comparisonPeriod || 'period';
const newLabel = trend.isNew ? ' (new)' : '';
return `${sign}${trend.percentage}%${newLabel} from last ${period}`;
};
const getTrendType = (): 'positive' | 'negative' | 'neutral' => {
if (!trends?.totalScans) return 'neutral';
if (trends.totalScans.trend === 'up') return 'positive';
if (trends.totalScans.trend === 'down') return 'negative';
return 'neutral';
};
const cards = [
{
title: t('dashboard.stats.total_scans'),
value: formatNumber(stats.totalScans),
change: showGrowth ? '+12%' : 'No data yet',
changeType: showGrowth ? 'positive' : 'neutral' as 'positive' | 'negative' | 'neutral',
change: getTrendText(),
changeType: getTrendType(),
icon: (
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
@ -69,7 +93,7 @@ export const StatsGrid: React.FC<StatsGridProps> = ({ stats }) => {
card.changeType === 'negative' ? 'text-red-600' :
'text-gray-500'
}`}>
{card.changeType === 'neutral' ? card.change : `${card.change} from last month`}
{card.change}
</p>
</div>
<div className="w-12 h-12 bg-primary-100 rounded-lg flex items-center justify-center text-primary-600">

View File

@ -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;

View File

@ -2,6 +2,7 @@
import React from 'react';
import { Hero } from '@/components/marketing/Hero';
import AIComingSoonBanner from '@/components/marketing/AIComingSoonBanner';
import { StatsStrip } from '@/components/marketing/StatsStrip';
import { TemplateCards } from '@/components/marketing/TemplateCards';
import { InstantGenerator } from '@/components/marketing/InstantGenerator';
@ -29,6 +30,7 @@ export default function HomePageClient() {
return (
<>
<Hero t={t} />
<AIComingSoonBanner />
<InstantGenerator t={t} />
<StaticVsDynamic t={t} />
<Features t={t} />

View File

@ -73,7 +73,10 @@ export const InstantGenerator: React.FC<InstantGeneratorProps> = ({ t }) => {
};
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="text-center mb-12">
<h2 className="text-3xl lg:text-4xl font-bold text-gray-900 mb-4">

View File

@ -103,3 +103,180 @@ export async function sendPasswordResetEmail(email: string, resetToken: string)
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');
}
}

View File

@ -212,6 +212,14 @@ export const RateLimits = {
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
API: {
name: 'api',

View File

@ -140,6 +140,18 @@ export const createCheckoutSchema = z.object({
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
// ==========================================

50
src/types/analytics.ts Normal file
View File

@ -0,0 +1,50 @@
export type TrendType = 'up' | 'down' | 'flat';
export interface TrendData {
trend: TrendType;
percentage: number;
isNegative?: boolean;
isNew?: boolean; // When growing from 0 previous data
}
export interface AnalyticsSummary {
totalScans: number;
uniqueScans: number;
avgScansPerQR: number;
mobilePercentage: number;
topCountry: string;
topCountryPercentage: number;
scansTrend?: TrendData;
avgScansTrend?: TrendData;
comparisonPeriod: 'week' | 'month';
comparisonDays: number;
}
export interface CountryStats {
country: string;
count: number;
percentage: number;
trend: TrendType;
trendPercentage: number;
isNew?: boolean;
}
export interface QRPerformance {
id: string;
title: string;
type: string;
totalScans: number;
uniqueScans: number;
conversion: number;
trend: TrendType;
trendPercentage: number;
isNew?: boolean;
}
export interface AnalyticsResponse {
summary: AnalyticsSummary;
deviceStats: Record<string, number>;
countryStats: CountryStats[];
dailyScans: Record<string, number>;
qrPerformance: QRPerformance[];
}