feat: Add admin dashboard for platform statistics and newsletter management.
This commit is contained in:
parent
2a057ae3e3
commit
0774ff6f03
|
|
@ -5,19 +5,60 @@ import { useRouter } from 'next/navigation';
|
||||||
import { Card } from '@/components/ui/Card';
|
import { Card } from '@/components/ui/Card';
|
||||||
import { Button } from '@/components/ui/Button';
|
import { Button } from '@/components/ui/Button';
|
||||||
import { Badge } from '@/components/ui/Badge';
|
import { Badge } from '@/components/ui/Badge';
|
||||||
import { Mail, Users, Send, CheckCircle, AlertCircle, Loader2, Lock, LogOut } from 'lucide-react';
|
import {
|
||||||
|
Mail,
|
||||||
|
Users,
|
||||||
|
QrCode,
|
||||||
|
BarChart3,
|
||||||
|
TrendingUp,
|
||||||
|
Crown,
|
||||||
|
Activity,
|
||||||
|
Loader2,
|
||||||
|
Lock,
|
||||||
|
LogOut,
|
||||||
|
Zap,
|
||||||
|
Send,
|
||||||
|
CheckCircle2,
|
||||||
|
} from 'lucide-react';
|
||||||
|
|
||||||
interface Subscriber {
|
interface AdminStats {
|
||||||
email: string;
|
users: {
|
||||||
createdAt: string;
|
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;
|
||||||
|
}>;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface BroadcastInfo {
|
export default function AdminDashboard() {
|
||||||
total: number;
|
|
||||||
recent: Subscriber[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function NewsletterPage() {
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [isAuthenticated, setIsAuthenticated] = useState(false);
|
const [isAuthenticated, setIsAuthenticated] = useState(false);
|
||||||
const [isAuthenticating, setIsAuthenticating] = useState(true);
|
const [isAuthenticating, setIsAuthenticating] = useState(true);
|
||||||
|
|
@ -25,14 +66,18 @@ export default function NewsletterPage() {
|
||||||
const [email, setEmail] = useState('');
|
const [email, setEmail] = useState('');
|
||||||
const [password, setPassword] = useState('');
|
const [password, setPassword] = useState('');
|
||||||
|
|
||||||
const [info, setInfo] = useState<BroadcastInfo | null>(null);
|
const [stats, setStats] = useState<AdminStats | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [broadcasting, setBroadcasting] = useState(false);
|
|
||||||
const [result, setResult] = useState<{
|
// 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;
|
success: boolean;
|
||||||
message: string;
|
message: string;
|
||||||
sent?: number;
|
|
||||||
failed?: number;
|
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -41,12 +86,14 @@ export default function NewsletterPage() {
|
||||||
|
|
||||||
const checkAuth = async () => {
|
const checkAuth = async () => {
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/newsletter/broadcast');
|
const response = await fetch('/api/admin/stats');
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
setIsAuthenticated(true);
|
setIsAuthenticated(true);
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
setInfo(data);
|
setStats(data);
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
|
// Also fetch newsletter data
|
||||||
|
fetchNewsletterData();
|
||||||
} else {
|
} else {
|
||||||
setIsAuthenticated(false);
|
setIsAuthenticated(false);
|
||||||
}
|
}
|
||||||
|
|
@ -57,6 +104,54 @@ export default function NewsletterPage() {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
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 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) => {
|
const handleLogin = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setLoginError('');
|
setLoginError('');
|
||||||
|
|
@ -90,53 +185,6 @@ export default function NewsletterPage() {
|
||||||
router.push('/');
|
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
|
// Login Screen
|
||||||
if (!isAuthenticated) {
|
if (!isAuthenticated) {
|
||||||
return (
|
return (
|
||||||
|
|
@ -146,9 +194,9 @@ export default function NewsletterPage() {
|
||||||
<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">
|
<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" />
|
<Lock className="w-8 h-8 text-purple-600 dark:text-purple-400" />
|
||||||
</div>
|
</div>
|
||||||
<h1 className="text-2xl font-bold mb-2">Newsletter Admin</h1>
|
<h1 className="text-2xl font-bold mb-2">Admin Dashboard</h1>
|
||||||
<p className="text-muted-foreground text-sm">
|
<p className="text-muted-foreground text-sm">
|
||||||
Sign in to manage subscribers
|
Sign in to access admin panel
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -159,7 +207,7 @@ export default function NewsletterPage() {
|
||||||
type="email"
|
type="email"
|
||||||
value={email}
|
value={email}
|
||||||
onChange={(e) => setEmail(e.target.value)}
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
placeholder="Email"
|
placeholder="admin@example.com"
|
||||||
required
|
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"
|
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"
|
||||||
/>
|
/>
|
||||||
|
|
@ -171,7 +219,7 @@ export default function NewsletterPage() {
|
||||||
type="password"
|
type="password"
|
||||||
value={password}
|
value={password}
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
placeholder="Password"
|
placeholder="••••••••"
|
||||||
required
|
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"
|
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"
|
||||||
/>
|
/>
|
||||||
|
|
@ -219,12 +267,13 @@ export default function NewsletterPage() {
|
||||||
// Admin Dashboard
|
// Admin Dashboard
|
||||||
return (
|
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="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="container mx-auto px-4 py-8 max-w-7xl">
|
||||||
|
{/* Header */}
|
||||||
<div className="flex items-center justify-between mb-8">
|
<div className="flex items-center justify-between mb-8">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-3xl font-bold mb-2">Newsletter Management</h1>
|
<h1 className="text-3xl font-bold mb-2">Admin Dashboard</h1>
|
||||||
<p className="text-muted-foreground">
|
<p className="text-muted-foreground">
|
||||||
Manage AI feature launch notifications
|
Platform overview and statistics
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
|
|
@ -237,130 +286,357 @@ export default function NewsletterPage() {
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Stats Card */}
|
{/* Main Stats Grid */}
|
||||||
<Card className="p-6 mb-6">
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
|
||||||
<div className="flex items-center justify-between mb-6">
|
{/* All Time Users */}
|
||||||
<div className="flex items-center gap-3">
|
<Card className="p-6 hover:shadow-lg transition-shadow">
|
||||||
<div className="w-12 h-12 bg-purple-100 dark:bg-purple-900/20 rounded-lg flex items-center justify-center">
|
<div className="flex items-start justify-between mb-4">
|
||||||
<Users className="w-6 h-6 text-purple-600 dark:text-purple-400" />
|
<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>
|
</div>
|
||||||
<div>
|
<Badge className="bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300">
|
||||||
<h2 className="text-2xl font-bold">{info?.total || 0}</h2>
|
All Time
|
||||||
<p className="text-sm text-muted-foreground">Total Subscribers</p>
|
</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>
|
||||||
</div>
|
<div className="flex items-center justify-between">
|
||||||
<Badge className="bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-300">
|
<span className="text-xs text-muted-foreground">This Week</span>
|
||||||
Active
|
<span className="text-sm font-semibold text-green-600 dark:text-green-400">
|
||||||
</Badge>
|
+{stats?.users.newThisWeek || 0}
|
||||||
</div>
|
</span>
|
||||||
|
|
||||||
{/* 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>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Recent Subscribers */}
|
{/* Dynamic QR Codes */}
|
||||||
<Card className="p-6">
|
<Card className="p-6 hover:shadow-lg transition-shadow">
|
||||||
<h3 className="font-semibold mb-4">Recent Subscribers</h3>
|
<div className="flex items-start justify-between mb-4">
|
||||||
{info?.recent && info.recent.length > 0 ? (
|
<div className="w-12 h-12 bg-purple-100 dark:bg-purple-900/20 rounded-lg flex items-center justify-center">
|
||||||
<div className="space-y-3">
|
<QrCode className="w-6 h-6 text-purple-600 dark:text-purple-400" />
|
||||||
{info.recent.map((subscriber, index) => (
|
</div>
|
||||||
<div
|
<Badge className="bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-300">
|
||||||
key={index}
|
Dynamic
|
||||||
className="flex items-center justify-between py-2 border-b border-border last:border-0"
|
</Badge>
|
||||||
>
|
|
||||||
<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>
|
</div>
|
||||||
) : (
|
<h3 className="text-3xl font-bold mb-1">{stats?.qrCodes.dynamic || 0}</h3>
|
||||||
<p className="text-sm text-muted-foreground">No subscribers yet</p>
|
<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>
|
||||||
|
|
||||||
<div className="mt-4 pt-4 border-t">
|
{/* Total Scans */}
|
||||||
<p className="text-xs text-muted-foreground">
|
<Card className="p-6 hover:shadow-lg transition-shadow">
|
||||||
💡 Tip: View all subscribers in{' '}
|
<div className="flex items-start justify-between mb-4">
|
||||||
<a
|
<div className="w-12 h-12 bg-green-100 dark:bg-green-900/20 rounded-lg flex items-center justify-center">
|
||||||
href="http://localhost:5555"
|
<BarChart3 className="w-6 h-6 text-green-600 dark:text-green-400" />
|
||||||
target="_blank"
|
</div>
|
||||||
rel="noopener noreferrer"
|
<Badge className="bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-300">
|
||||||
className="text-primary hover:underline"
|
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"
|
||||||
>
|
>
|
||||||
Prisma Studio
|
{sendingBroadcast ? (
|
||||||
</a>
|
<>
|
||||||
{' '}(NewsletterSubscription table)
|
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||||
</p>
|
Sending...
|
||||||
</div>
|
</>
|
||||||
</Card>
|
) : (
|
||||||
|
<>
|
||||||
|
<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>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,216 @@
|
||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { cookies } from 'next/headers';
|
||||||
|
import { db } from '@/lib/db';
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
// Check newsletter-admin cookie authentication
|
||||||
|
const cookieStore = cookies();
|
||||||
|
const adminCookie = cookieStore.get('newsletter-admin');
|
||||||
|
|
||||||
|
if (!adminCookie || adminCookie.value !== 'authenticated') {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Unauthorized - Admin login required' },
|
||||||
|
{ status: 401 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get 30 days ago date
|
||||||
|
const thirtyDaysAgo = new Date();
|
||||||
|
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
|
||||||
|
|
||||||
|
// Get 7 days ago date
|
||||||
|
const sevenDaysAgo = new Date();
|
||||||
|
sevenDaysAgo.setDate(sevenDaysAgo.getDate() - 7);
|
||||||
|
|
||||||
|
// Get start of current month
|
||||||
|
const startOfMonth = new Date();
|
||||||
|
startOfMonth.setDate(1);
|
||||||
|
startOfMonth.setHours(0, 0, 0, 0);
|
||||||
|
|
||||||
|
// Fetch all statistics in parallel
|
||||||
|
const [
|
||||||
|
totalUsers,
|
||||||
|
premiumUsers,
|
||||||
|
newUsersThisWeek,
|
||||||
|
newUsersThisMonth,
|
||||||
|
totalQRCodes,
|
||||||
|
dynamicQRCodes,
|
||||||
|
staticQRCodes,
|
||||||
|
totalScans,
|
||||||
|
dynamicQRCodesWithScans,
|
||||||
|
activeQRCodes,
|
||||||
|
newsletterSubscribers,
|
||||||
|
] = await Promise.all([
|
||||||
|
// Total users
|
||||||
|
db.user.count(),
|
||||||
|
|
||||||
|
// Premium users (PRO or BUSINESS)
|
||||||
|
db.user.count({
|
||||||
|
where: {
|
||||||
|
plan: {
|
||||||
|
in: ['PRO', 'BUSINESS'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
|
||||||
|
// New users this week
|
||||||
|
db.user.count({
|
||||||
|
where: {
|
||||||
|
createdAt: {
|
||||||
|
gte: sevenDaysAgo,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
|
||||||
|
// New users this month
|
||||||
|
db.user.count({
|
||||||
|
where: {
|
||||||
|
createdAt: {
|
||||||
|
gte: startOfMonth,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
|
||||||
|
// Total QR codes
|
||||||
|
db.qRCode.count(),
|
||||||
|
|
||||||
|
// Dynamic QR codes
|
||||||
|
db.qRCode.count({
|
||||||
|
where: {
|
||||||
|
type: 'DYNAMIC',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
|
||||||
|
// Static QR codes
|
||||||
|
db.qRCode.count({
|
||||||
|
where: {
|
||||||
|
type: 'STATIC',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
|
||||||
|
// Total scans
|
||||||
|
db.qRScan.count(),
|
||||||
|
|
||||||
|
// Get all dynamic QR codes with their scan counts
|
||||||
|
db.qRCode.findMany({
|
||||||
|
where: {
|
||||||
|
type: 'DYNAMIC',
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
_count: {
|
||||||
|
select: {
|
||||||
|
scans: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
|
||||||
|
// Active QR codes (scanned in last 30 days)
|
||||||
|
db.qRCode.findMany({
|
||||||
|
where: {
|
||||||
|
scans: {
|
||||||
|
some: {
|
||||||
|
ts: {
|
||||||
|
gte: thirtyDaysAgo,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
distinct: ['id'],
|
||||||
|
}),
|
||||||
|
|
||||||
|
// Newsletter subscribers
|
||||||
|
db.newsletterSubscription.count({
|
||||||
|
where: {
|
||||||
|
status: 'subscribed',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Calculate dynamic QR scans
|
||||||
|
const dynamicQRScans = dynamicQRCodesWithScans.reduce(
|
||||||
|
(total, qr) => total + qr._count.scans,
|
||||||
|
0
|
||||||
|
);
|
||||||
|
|
||||||
|
// Calculate average scans per dynamic QR
|
||||||
|
const avgScansPerDynamicQR =
|
||||||
|
dynamicQRCodes > 0 ? (dynamicQRScans / dynamicQRCodes).toFixed(1) : '0';
|
||||||
|
|
||||||
|
// Get top 5 most scanned QR codes
|
||||||
|
const topQRCodes = await db.qRCode.findMany({
|
||||||
|
take: 5,
|
||||||
|
include: {
|
||||||
|
_count: {
|
||||||
|
select: {
|
||||||
|
scans: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
user: {
|
||||||
|
select: {
|
||||||
|
email: true,
|
||||||
|
name: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
orderBy: {
|
||||||
|
scans: {
|
||||||
|
_count: 'desc',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get recent users
|
||||||
|
const recentUsers = await db.user.findMany({
|
||||||
|
take: 5,
|
||||||
|
orderBy: {
|
||||||
|
createdAt: 'desc',
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
email: true,
|
||||||
|
name: true,
|
||||||
|
plan: true,
|
||||||
|
createdAt: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
users: {
|
||||||
|
total: totalUsers,
|
||||||
|
premium: premiumUsers,
|
||||||
|
newThisWeek: newUsersThisWeek,
|
||||||
|
newThisMonth: newUsersThisMonth,
|
||||||
|
recent: recentUsers,
|
||||||
|
},
|
||||||
|
qrCodes: {
|
||||||
|
total: totalQRCodes,
|
||||||
|
dynamic: dynamicQRCodes,
|
||||||
|
static: staticQRCodes,
|
||||||
|
active: activeQRCodes.length,
|
||||||
|
},
|
||||||
|
scans: {
|
||||||
|
total: totalScans,
|
||||||
|
dynamicOnly: dynamicQRScans,
|
||||||
|
avgPerDynamicQR: avgScansPerDynamicQR,
|
||||||
|
},
|
||||||
|
newsletter: {
|
||||||
|
subscribers: newsletterSubscribers,
|
||||||
|
},
|
||||||
|
topQRCodes: topQRCodes.map((qr) => ({
|
||||||
|
id: qr.id,
|
||||||
|
title: qr.title,
|
||||||
|
type: qr.type,
|
||||||
|
scans: qr._count.scans,
|
||||||
|
owner: qr.user.name || qr.user.email,
|
||||||
|
createdAt: qr.createdAt,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching admin stats:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Failed to fetch statistics' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue