Replace raw <input>/<button>/<select> elements with Button, Card, Input, Select, Modal, Badge, and ConfirmDialog from @/components/ui in all non-admin and admin pages. Removes ~550 lines of inline Tailwind utility classes from form elements while keeping all business logic intact. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
219 lines
No EOL
7 KiB
TypeScript
219 lines
No EOL
7 KiB
TypeScript
"use client";
|
|
|
|
import { useEffect, useState } from "react";
|
|
import { Select } from "@/components/ui";
|
|
|
|
interface Stats {
|
|
overview: {
|
|
totalFamilies: number;
|
|
totalUsers: number;
|
|
totalChildren: number;
|
|
proFamilies: number;
|
|
freeFamilies: number;
|
|
mrr: number;
|
|
avgRevenuePerUser: number;
|
|
};
|
|
conversions: {
|
|
freeToPro: number;
|
|
conversionRate: number;
|
|
};
|
|
growth: {
|
|
familiesByDay: { date: string; count: number }[];
|
|
usersByDay: { date: string; count: number }[];
|
|
};
|
|
childrenByAge: { ageGroup: string; count: number }[];
|
|
}
|
|
|
|
export default function AdminDashboard() {
|
|
const [stats, setStats] = useState<Stats | null>(null);
|
|
const [loading, setLoading] = useState(true);
|
|
const [period, setPeriod] = useState("30");
|
|
|
|
useEffect(() => {
|
|
fetchStats();
|
|
}, [period]);
|
|
|
|
const fetchStats = async () => {
|
|
try {
|
|
const res = await fetch(`/api/admin/stats?period=${period}`);
|
|
const data = await res.json();
|
|
setStats(data);
|
|
} catch (err) {
|
|
console.error("Failed to fetch stats:", err);
|
|
}
|
|
setLoading(false);
|
|
};
|
|
|
|
if (loading || !stats) {
|
|
return (
|
|
<div className="min-h-screen flex items-center justify-center bg-gray-900">
|
|
<div className="text-white">Loading...</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="p-6 space-y-6">
|
|
{/* Header */}
|
|
<div className="flex justify-between items-center">
|
|
<div>
|
|
<h1 className="text-2xl font-bold">Dashboard</h1>
|
|
<p className="text-gray-400">Platform overview and analytics</p>
|
|
</div>
|
|
<Select value={period} onChange={(e) => setPeriod(e.target.value)}>
|
|
<option value="7">Last 7 days</option>
|
|
<option value="30">Last 30 days</option>
|
|
<option value="90">Last 90 days</option>
|
|
</Select>
|
|
</div>
|
|
|
|
{/* Overview Cards */}
|
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
|
<StatCard
|
|
label="Total Families"
|
|
value={stats.overview.totalFamilies}
|
|
icon="🏠"
|
|
color="rose"
|
|
href="/admin/families"
|
|
/>
|
|
<StatCard
|
|
label="Total Users"
|
|
value={stats.overview.totalUsers}
|
|
icon="👥"
|
|
color="blue"
|
|
href="/admin/users"
|
|
/>
|
|
<StatCard
|
|
label="Total Children"
|
|
value={stats.overview.totalChildren}
|
|
icon="👶"
|
|
color="amber"
|
|
href="/admin/children"
|
|
/>
|
|
<StatCard
|
|
label="MRR"
|
|
value={`$${stats.overview.mrr.toFixed(2)}`}
|
|
icon="💰"
|
|
color="emerald"
|
|
href="/admin/revenue"
|
|
/>
|
|
</div>
|
|
|
|
{/* Revenue & Tier Stats */}
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
<div className="bg-gray-800 p-6 rounded-xl">
|
|
<h3 className="text-lg font-semibold mb-4">Revenue Overview</h3>
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<div>
|
|
<div className="text-2xl font-bold text-emerald-400">${stats.overview.mrr.toFixed(2)}</div>
|
|
<div className="text-gray-400 text-sm">Monthly Recurring Revenue</div>
|
|
</div>
|
|
<div>
|
|
<div className="text-2xl font-bold text-rose-400">{stats.overview.proFamilies}</div>
|
|
<div className="text-gray-400 text-sm">Pro Families</div>
|
|
</div>
|
|
<div>
|
|
<div className="text-2xl font-bold text-gray-400">{stats.overview.freeFamilies}</div>
|
|
<div className="text-gray-400 text-sm">Free Families</div>
|
|
</div>
|
|
<div>
|
|
<div className="text-2xl font-bold text-amber-400">${stats.overview.avgRevenuePerUser}</div>
|
|
<div className="text-gray-400 text-sm">Avg Revenue per Family</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="bg-gray-800 p-6 rounded-xl">
|
|
<h3 className="text-lg font-semibold mb-4">Conversions</h3>
|
|
<div className="flex items-center justify-center h-32">
|
|
<div className="text-center">
|
|
<div className="text-4xl font-bold text-rose-400">{stats.conversions.conversionRate}%</div>
|
|
<div className="text-gray-400">Free → Pro Conversion Rate</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Growth Charts */}
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
<ChartCard
|
|
title="New Families"
|
|
data={stats.growth.familiesByDay}
|
|
icon="📈"
|
|
/>
|
|
<ChartCard
|
|
title="New Users"
|
|
data={stats.growth.usersByDay}
|
|
icon="👥"
|
|
/>
|
|
</div>
|
|
|
|
{/* Children by Age */}
|
|
{stats.childrenByAge.length > 0 && (
|
|
<div className="bg-gray-800 p-6 rounded-xl">
|
|
<h3 className="text-lg font-semibold mb-4">Children by Age Group</h3>
|
|
<div className="flex flex-wrap gap-4">
|
|
{stats.childrenByAge.map((item) => (
|
|
<div key={item.ageGroup} className="bg-gray-700 px-4 py-2 rounded-lg">
|
|
<div className="text-xl font-bold">{item.count}</div>
|
|
<div className="text-xs text-gray-400">{item.ageGroup} years</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function StatCard({ label, value, icon, color, href }: { label: string; value: number | string; icon: string; color: string; href?: string }) {
|
|
const colorClasses: Record<string, string> = {
|
|
rose: "text-rose-400",
|
|
blue: "text-blue-400",
|
|
amber: "text-amber-400",
|
|
emerald: "text-emerald-400",
|
|
};
|
|
|
|
const content = (
|
|
<div className="bg-gray-800 p-6 rounded-xl">
|
|
<div className="flex items-center justify-between mb-2">
|
|
<span className="text-2xl">{icon}</span>
|
|
</div>
|
|
<div className={`text-3xl font-bold ${colorClasses[color]}`}>{value}</div>
|
|
<div className="text-gray-400 text-sm">{label}</div>
|
|
</div>
|
|
);
|
|
|
|
if (href) {
|
|
return <a href={href} className="cursor-pointer hover:opacity-90 transition-opacity">{content}</a>;
|
|
}
|
|
return content;
|
|
}
|
|
|
|
function ChartCard({ title, data, icon }: { title: string; data: { date: string; count: number }[]; icon: string }) {
|
|
const maxCount = Math.max(...data.map((d) => d.count), 1);
|
|
|
|
return (
|
|
<div className="bg-gray-800 p-6 rounded-xl">
|
|
<h3 className="text-lg font-semibold mb-4">{icon} {title}</h3>
|
|
<div className="h-40 flex items-end gap-1">
|
|
{data.slice(-14).map((d, i) => (
|
|
<div key={i} className="flex-1 flex flex-col items-center gap-1">
|
|
<div
|
|
className="w-full bg-rose-500 rounded-t"
|
|
style={{ height: `${(d.count / maxCount) * 100}%`, minHeight: d.count > 0 ? "4px" : "0" }}
|
|
/>
|
|
<div className="text-[8px] text-gray-500 truncate w-full text-center">
|
|
{d.date?.slice(5) || ""}
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
{data.length === 0 && (
|
|
<div className="h-40 flex items-center justify-center text-gray-500">
|
|
No data available
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
} |