Files
QR-master/src/app/(main)/(marketing)/newsletter/NewsletterClient.tsx
Timo Knuth 30c1e57eab Shema
2026-01-25 14:59:25 +01:00

755 lines
30 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use client';
import React, { useState, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import Link from 'next/link';
import { Card } from '@/components/ui/Card';
import { Button } from '@/components/ui/Button';
import { Badge } from '@/components/ui/Badge';
import {
Mail,
Users,
QrCode,
BarChart3,
TrendingUp,
Crown,
Activity,
Loader2,
Lock,
LogOut,
Zap,
Send,
CheckCircle2,
FileDown,
DollarSign,
} from 'lucide-react';
interface AdminStats {
users: {
total: number;
premium: number;
newThisWeek: number;
newThisMonth: number;
recent: Array<{
email: string;
name: string | null;
plan: string;
createdAt: string;
}>;
};
qrCodes: {
total: number;
dynamic: number;
static: number;
active: number;
};
scans: {
total: number;
dynamicOnly: number;
avgPerDynamicQR: string;
};
newsletter: {
subscribers: number;
};
topQRCodes: Array<{
id: string;
title: string;
type: string;
scans: number;
owner: string;
createdAt: string;
}>;
}
export default function NewsletterClient() {
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 [stats, setStats] = useState<AdminStats | null>(null);
const [loading, setLoading] = useState(true);
// Newsletter management state
const [newsletterData, setNewsletterData] = useState<{
total: number;
recent: Array<{ email: string; createdAt: string }>;
} | null>(null);
const [sendingBroadcast, setSendingBroadcast] = useState(false);
const [broadcastResult, setBroadcastResult] = useState<{
success: boolean;
message: string;
} | null>(null);
// Lead management state
const [leadData, setLeadData] = useState<{
total: number;
recent: Array<{
id: string;
email: string;
source: string;
reprintCost: number | null;
updatesPerYear: number | null;
annualSavings: number | null;
createdAt: string;
}>;
} | null>(null);
useEffect(() => {
checkAuth();
}, []);
const checkAuth = async () => {
try {
const response = await fetch('/api/admin/stats');
if (response.ok) {
setIsAuthenticated(true);
const data = await response.json();
setStats(data);
setLoading(false);
// Also fetch newsletter and lead data
fetchNewsletterData();
fetchLeadsData();
} else {
setIsAuthenticated(false);
}
} catch (error) {
setIsAuthenticated(false);
} finally {
setIsAuthenticating(false);
}
};
const fetchNewsletterData = async () => {
try {
const response = await fetch('/api/newsletter/broadcast');
if (response.ok) {
const data = await response.json();
setNewsletterData(data);
}
} catch (error) {
console.error('Failed to fetch newsletter data:', error);
}
};
const fetchLeadsData = async () => {
try {
const response = await fetch('/api/leads');
if (response.ok) {
const data = await response.json();
setLeadData(data);
}
} catch (error) {
console.error('Failed to fetch leads data:', error);
}
};
const handleSendBroadcast = async () => {
if (!confirm(`Are you sure you want to send the AI Feature Launch email to all ${newsletterData?.total || 0} subscribers?`)) {
return;
}
setSendingBroadcast(true);
setBroadcastResult(null);
try {
const response = await fetch('/api/newsletter/broadcast', {
method: 'POST',
});
const data = await response.json();
if (response.ok) {
setBroadcastResult({
success: true,
message: data.message || `Successfully sent to ${data.sent} subscribers!`,
});
} else {
setBroadcastResult({
success: false,
message: data.error || 'Failed to send broadcast',
});
}
} catch (error) {
setBroadcastResult({
success: false,
message: 'Network error. Please try again.',
});
} finally {
setSendingBroadcast(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('/');
};
// 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">Admin Dashboard</h1>
<p className="text-muted-foreground text-sm">
Sign in to access admin panel
</p>
<Link href="/" className="text-sm text-slate-500 hover:text-slate-900 block mt-2">
Back to Home
</Link>
</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="admin@example.com"
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-7xl">
{/* Header */}
<div className="flex items-center justify-between mb-8">
<div>
<h1 className="text-3xl font-bold mb-2">Admin Dashboard</h1>
<p className="text-muted-foreground">
Platform overview and statistics
</p>
</div>
<Button
onClick={handleLogout}
variant="outline"
className="flex items-center gap-2"
>
<LogOut className="w-4 h-4" />
Logout
</Button>
</div>
{/* Main Stats Grid */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
{/* All Time Users */}
<Card className="p-6 hover:shadow-lg transition-shadow">
<div className="flex items-start justify-between mb-4">
<div className="w-12 h-12 bg-blue-100 dark:bg-blue-900/20 rounded-lg flex items-center justify-center">
<Users className="w-6 h-6 text-blue-600 dark:text-blue-400" />
</div>
<Badge className="bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300">
All Time
</Badge>
</div>
<h3 className="text-3xl font-bold mb-1">{stats?.users.total || 0}</h3>
<p className="text-sm text-muted-foreground">Total Users</p>
<div className="mt-3 pt-3 border-t space-y-2">
<div className="flex items-center justify-between">
<span className="text-xs text-muted-foreground">This Month</span>
<span className="text-sm font-semibold text-green-600 dark:text-green-400">
+{stats?.users.newThisMonth || 0}
</span>
</div>
<div className="flex items-center justify-between">
<span className="text-xs text-muted-foreground">This Week</span>
<span className="text-sm font-semibold text-green-600 dark:text-green-400">
+{stats?.users.newThisWeek || 0}
</span>
</div>
</div>
</Card>
{/* Dynamic QR Codes */}
<Card className="p-6 hover:shadow-lg transition-shadow">
<div className="flex items-start justify-between mb-4">
<div className="w-12 h-12 bg-purple-100 dark:bg-purple-900/20 rounded-lg flex items-center justify-center">
<QrCode className="w-6 h-6 text-purple-600 dark:text-purple-400" />
</div>
<Badge className="bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-300">
Dynamic
</Badge>
</div>
<h3 className="text-3xl font-bold mb-1">{stats?.qrCodes.dynamic || 0}</h3>
<p className="text-sm text-muted-foreground">Dynamic QR Codes</p>
<div className="mt-3 pt-3 border-t flex items-center justify-between">
<span className="text-xs text-muted-foreground">Static</span>
<span className="text-sm font-semibold">{stats?.qrCodes.static || 0}</span>
</div>
</Card>
{/* Total Scans */}
<Card className="p-6 hover:shadow-lg transition-shadow">
<div className="flex items-start justify-between mb-4">
<div className="w-12 h-12 bg-green-100 dark:bg-green-900/20 rounded-lg flex items-center justify-center">
<BarChart3 className="w-6 h-6 text-green-600 dark:text-green-400" />
</div>
<Badge className="bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-300">
All Time
</Badge>
</div>
<h3 className="text-3xl font-bold mb-1">
{stats?.scans.dynamicOnly.toLocaleString() || 0}
</h3>
<p className="text-sm text-muted-foreground">Dynamic QR Scans</p>
<div className="mt-3 pt-3 border-t flex items-center justify-between">
<span className="text-xs text-muted-foreground">Avg per QR</span>
<span className="text-sm font-semibold">{stats?.scans.avgPerDynamicQR || 0}</span>
</div>
</Card>
{/* Total QR Codes */}
<Card className="p-6 hover:shadow-lg transition-shadow">
<div className="flex items-start justify-between mb-4">
<div className="w-12 h-12 bg-amber-100 dark:bg-amber-900/20 rounded-lg flex items-center justify-center">
<QrCode className="w-6 h-6 text-amber-600 dark:text-amber-400" />
</div>
<Badge className="bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300">
All Time
</Badge>
</div>
<h3 className="text-3xl font-bold mb-1">{stats?.qrCodes.total || 0}</h3>
<p className="text-sm text-muted-foreground">Total QR Codes</p>
<div className="mt-3 pt-3 border-t space-y-2">
<div className="flex items-center justify-between">
<span className="text-xs text-muted-foreground">Dynamic</span>
<span className="text-sm font-semibold">{stats?.qrCodes.dynamic || 0}</span>
</div>
<div className="flex items-center justify-between">
<span className="text-xs text-muted-foreground">Static</span>
<span className="text-sm font-semibold">{stats?.qrCodes.static || 0}</span>
</div>
</div>
</Card>
</div>
{/* Secondary Stats Row */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
{/* Total All Scans */}
<Card className="p-6">
<div className="flex items-center gap-4">
<div className="w-12 h-12 bg-indigo-100 dark:bg-indigo-900/20 rounded-lg flex items-center justify-center">
<Zap className="w-6 h-6 text-indigo-600 dark:text-indigo-400" />
</div>
<div>
<h3 className="text-2xl font-bold">
{stats?.scans.total.toLocaleString() || 0}
</h3>
<p className="text-sm text-muted-foreground">Total All Scans</p>
</div>
</div>
</Card>
{/* Total QR Codes */}
<Card className="p-6">
<div className="flex items-center gap-4">
<div className="w-12 h-12 bg-pink-100 dark:bg-pink-900/20 rounded-lg flex items-center justify-center">
<QrCode className="w-6 h-6 text-pink-600 dark:text-pink-400" />
</div>
<div>
<h3 className="text-2xl font-bold">{stats?.qrCodes.total || 0}</h3>
<p className="text-sm text-muted-foreground">Total QR Codes</p>
</div>
</div>
</Card>
{/* Premium Users */}
<Card className="p-6">
<div className="flex items-center gap-4">
<div className="w-12 h-12 bg-amber-100 dark:bg-amber-900/20 rounded-lg flex items-center justify-center">
<Crown className="w-6 h-6 text-amber-600 dark:text-amber-400" />
</div>
<div>
<h3 className="text-2xl font-bold">{stats?.users.premium || 0}</h3>
<p className="text-sm text-muted-foreground">Premium Users</p>
</div>
</div>
</Card>
</div>
{/* Bottom Grid */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Top QR Codes */}
<Card className="p-6">
<div className="flex items-center gap-3 mb-6">
<div className="w-10 h-10 bg-gradient-to-br from-purple-100 to-pink-100 dark:from-purple-900/30 dark:to-pink-900/30 rounded-lg flex items-center justify-center">
<TrendingUp className="w-5 h-5 text-purple-600 dark:text-purple-400" />
</div>
<div>
<h3 className="font-semibold text-lg">Top QR Codes</h3>
<p className="text-xs text-muted-foreground">Most scanned</p>
</div>
</div>
{stats?.topQRCodes && stats.topQRCodes.length > 0 ? (
<div className="space-y-3">
{stats.topQRCodes.map((qr, index) => (
<div
key={qr.id}
className="flex items-center justify-between py-3 border-b border-border last:border-0"
>
<div className="flex items-center gap-3 flex-1 min-w-0">
<div className="w-8 h-8 bg-gradient-to-br from-purple-500 to-pink-500 rounded-lg flex items-center justify-center flex-shrink-0">
<span className="text-white text-sm font-bold">
#{index + 1}
</span>
</div>
<div className="min-w-0 flex-1">
<p className="text-sm font-medium truncate">{qr.title}</p>
<p className="text-xs text-muted-foreground truncate">
{qr.owner}
</p>
</div>
</div>
<div className="text-right flex-shrink-0 ml-4">
<p className="text-lg font-bold">{qr.scans.toLocaleString()}</p>
<p className="text-xs text-muted-foreground">scans</p>
</div>
</div>
))}
</div>
) : (
<p className="text-sm text-muted-foreground">No QR codes yet</p>
)}
</Card>
{/* Recent Users */}
<Card className="p-6">
<div className="flex items-center gap-3 mb-6">
<div className="w-10 h-10 bg-blue-100 dark:bg-blue-900/20 rounded-lg flex items-center justify-center">
<Users className="w-5 h-5 text-blue-600 dark:text-blue-400" />
</div>
<div>
<h3 className="font-semibold text-lg">Recent Users</h3>
<p className="text-xs text-muted-foreground">Latest signups</p>
</div>
</div>
{stats?.users.recent && stats.users.recent.length > 0 ? (
<div className="space-y-3">
{stats.users.recent.map((user, index) => (
<div
key={index}
className="flex items-center justify-between py-3 border-b border-border last:border-0"
>
<div className="flex items-center gap-3 flex-1 min-w-0">
<div className="w-8 h-8 bg-gradient-to-br from-blue-500 to-cyan-500 rounded-full flex items-center justify-center flex-shrink-0">
<span className="text-white text-xs font-bold">
{(user.name || user.email).charAt(0).toUpperCase()}
</span>
</div>
<div className="min-w-0 flex-1">
<p className="text-sm font-medium truncate">
{user.name || user.email}
</p>
<p className="text-xs text-muted-foreground truncate">
{new Date(user.createdAt).toLocaleDateString()}
</p>
</div>
</div>
<Badge
className={
user.plan === 'FREE'
? 'bg-gray-100 text-gray-700 dark:bg-gray-900/30 dark:text-gray-300'
: 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300'
}
>
{user.plan === 'PRO' && <Crown className="w-3 h-3 mr-1" />}
{user.plan}
</Badge>
</div>
))}
</div>
) : (
<p className="text-sm text-muted-foreground">No users yet</p>
)}
</Card>
</div>
{/* Newsletter Management Section */}
<div className="mt-8">
<Card className="p-6">
<div className="flex items-center gap-3 mb-6">
<div className="w-10 h-10 bg-gradient-to-br from-purple-100 to-pink-100 dark:from-purple-900/30 dark:to-pink-900/30 rounded-lg flex items-center justify-center">
<Users className="w-5 h-5 text-purple-600 dark:text-purple-400" />
</div>
<div className="flex-1">
<h3 className="font-semibold text-lg">Newsletter Management</h3>
<p className="text-xs text-muted-foreground">Manage AI feature launch notifications</p>
</div>
<div className="text-right">
<span className="text-2xl font-bold">{newsletterData?.total || 0}</span>
<p className="text-xs text-muted-foreground">Total Subscribers</p>
</div>
<Badge className="bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-300">
Active
</Badge>
</div>
{/* Broadcast Section */}
<div className="p-4 bg-gray-50 dark:bg-gray-900/50 rounded-xl mb-6">
<div className="flex items-start gap-3 mb-3">
<Send className="w-5 h-5 text-purple-600 dark:text-purple-400 mt-0.5" />
<div>
<h4 className="font-medium">Broadcast AI Feature Launch</h4>
<p className="text-sm text-muted-foreground">
Send the AI feature launch announcement to all {newsletterData?.total || 0} subscribers.
This will inform them that the features are now available.
</p>
</div>
</div>
{/* Resend Free Tier Warning */}
{(newsletterData?.total || 0) > 100 && (
<div className="p-3 rounded-lg mb-3 bg-amber-100 dark:bg-amber-900/30 text-amber-800 dark:text-amber-200 flex items-start gap-2">
<Activity className="w-5 h-5 flex-shrink-0 mt-0.5" />
<div className="text-sm">
<strong>Warning: Resend Free Limit</strong>
<p>You have more than 100 subscribers. The Resend Free Tier only allows 100 emails per day. Sending this broadcast might fail for some users or block your account.</p>
</div>
</div>
)}
{broadcastResult && (
<div className={`p-3 rounded-lg mb-3 flex items-center gap-2 ${broadcastResult.success
? 'bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-300'
: 'bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-300'
}`}>
{broadcastResult.success && <CheckCircle2 className="w-4 h-4" />}
<span className="text-sm">{broadcastResult.message}</span>
</div>
)}
<Button
onClick={handleSendBroadcast}
disabled={sendingBroadcast || (newsletterData?.total || 0) === 0 || (newsletterData?.total || 0) > 100}
className="bg-gradient-to-r from-purple-600 to-pink-600 hover:from-purple-700 hover:to-pink-700 text-white"
>
{sendingBroadcast ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
Sending...
</>
) : (
<>
<Mail className="w-4 h-4 mr-2" />
Send Launch Notification to All
</>
)}
</Button>
</div>
{/* Recent Subscribers */}
<div>
<h4 className="font-medium mb-3">Recent Subscribers</h4>
{newsletterData?.recent && newsletterData.recent.length > 0 ? (
<div className="space-y-2">
{newsletterData.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>
{/* Tip */}
<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-purple-600 dark:text-purple-400 hover:underline"
>
Prisma Studio
</a>
{' '}(NewsletterSubscription table)
</p>
</div>
</Card>
</div>
{/* Lead Management Section */}
<div className="mt-8">
<Card className="p-6">
<div className="flex items-center gap-3 mb-6">
<div className="w-10 h-10 bg-gradient-to-br from-emerald-100 to-teal-100 dark:from-emerald-900/30 dark:to-teal-900/30 rounded-lg flex items-center justify-center">
<FileDown className="w-5 h-5 text-emerald-600 dark:text-emerald-400" />
</div>
<div className="flex-1">
<h3 className="font-semibold text-lg">Lead Management</h3>
<p className="text-xs text-muted-foreground">Reprint Calculator PDF downloads</p>
</div>
<div className="text-right">
<span className="text-2xl font-bold">{leadData?.total || 0}</span>
<p className="text-xs text-muted-foreground">Total Leads</p>
</div>
<Badge className="bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-300">
Active
</Badge>
</div>
{/* Recent Leads */}
<div>
<h4 className="font-medium mb-3">Recent Leads</h4>
{leadData?.recent && leadData.recent.length > 0 ? (
<div className="space-y-2">
{leadData.recent.map((lead) => (
<div
key={lead.id}
className="flex items-center justify-between py-3 px-4 border border-border rounded-lg bg-gray-50/50 dark:bg-gray-900/30"
>
<div className="flex items-center gap-3 flex-1 min-w-0">
<Mail className="w-4 h-4 text-muted-foreground flex-shrink-0" />
<div className="min-w-0">
<span className="text-sm font-medium block truncate">{lead.email}</span>
{lead.annualSavings && (
<span className="text-xs text-emerald-600 flex items-center gap-1">
<DollarSign className="w-3 h-3" />
{lead.annualSavings.toLocaleString()} potential savings
</span>
)}
</div>
</div>
<div className="text-right flex-shrink-0 ml-4">
<span className="text-xs text-muted-foreground block">
{new Date(lead.createdAt).toLocaleDateString()}
</span>
{lead.reprintCost && lead.updatesPerYear && (
<span className="text-xs text-slate-500">
{lead.reprintCost} × {lead.updatesPerYear}/yr
</span>
)}
</div>
</div>
))}
</div>
) : (
<p className="text-sm text-muted-foreground">No leads yet. Leads appear when users download a PDF report from the Reprint Calculator.</p>
)}
</div>
{/* Tip */}
<div className="mt-4 pt-4 border-t">
<p className="text-xs text-muted-foreground">
💡 Tip: View all leads in{' '}
<a
href="http://localhost:5555"
target="_blank"
rel="noopener noreferrer"
className="text-emerald-600 dark:text-emerald-400 hover:underline"
>
Prisma Studio
</a>
{' '}(Lead table)
</p>
</div>
</Card>
</div>
</div>
</div>
);
}