Add comprehensive admin panel with analytics, families, users, children, revenue, support, settings

This commit is contained in:
Manohar Gupta 2026-05-10 22:43:20 +05:30
parent d5b07078ae
commit cda25b04ca
15 changed files with 1704 additions and 77 deletions

View file

@ -0,0 +1,140 @@
"use client";
import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
interface EngagementStats {
totalLogs: number;
totalMedicines: number;
totalVaccinations: number;
totalGrowth: number;
totalMemories: number;
totalChatSessions: number;
logsByDay: { date: string; count: number }[];
topFeatures: { name: string; count: number }[];
}
export default function AdminAnalytics() {
const router = useRouter();
const [stats, setStats] = useState<EngagementStats | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
const token = localStorage.getItem("admin_token");
if (!token) {
router.push("/admin/login");
return;
}
fetchAnalytics();
}, []);
const fetchAnalytics = async () => {
try {
const res = await fetch("/api/admin/analytics", {
headers: { Authorization: `Bearer ${localStorage.getItem("admin_token")}` },
});
const data = await res.json();
setStats(data);
} catch (err) {
console.error("Failed to fetch analytics:", err);
}
setLoading(false);
};
if (loading || !stats) {
return <div className="p-6 text-white">Loading...</div>;
}
return (
<div className="p-6 space-y-6">
<div>
<h1 className="text-2xl font-bold">Analytics</h1>
<p className="text-gray-400">Feature usage and engagement</p>
</div>
{/* Usage Overview */}
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-4">
<StatCard label="Activity Logs" value={stats.totalLogs} icon="📝" />
<StatCard label="Medicines" value={stats.totalMedicines} icon="💊" />
<StatCard label="Vaccinations" value={stats.totalVaccinations} icon="💉" />
<StatCard label="Growth" value={stats.totalGrowth} icon="📏" />
<StatCard label="Memories" value={stats.totalMemories} icon="📸" />
<StatCard label="Chat Sessions" value={stats.totalChatSessions} icon="💬" />
</div>
{/* Activity Chart */}
<div className="bg-gray-800 p-6 rounded-xl">
<h3 className="text-lg font-semibold mb-4">Daily Activity (Last 30 days)</h3>
<div className="h-48 flex items-end gap-1">
{stats.logsByDay.slice(-30).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: `${Math.min((d.count / Math.max(...stats.logsByDay.map(l => l.count), 1)) * 100, 100)}%`,
minHeight: d.count > 0 ? "4px" : "0"
}}
/>
<div className="text-[8px] text-gray-500">{d.date?.slice(5) || ""}</div>
</div>
))}
</div>
{stats.logsByDay.length === 0 && (
<div className="h-48 flex items-center justify-center text-gray-500">
No activity data yet
</div>
)}
</div>
{/* Feature Breakdown */}
<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">Top Features</h3>
<div className="space-y-3">
{stats.topFeatures.map((feature, i) => (
<div key={i} className="flex justify-between items-center">
<span>{feature.name}</span>
<span className="font-bold text-rose-400">{feature.count}</span>
</div>
))}
{stats.topFeatures.length === 0 && (
<div className="text-gray-500">No data yet</div>
)}
</div>
</div>
<div className="bg-gray-800 p-6 rounded-xl">
<h3 className="text-lg font-semibold mb-4">Engagement Summary</h3>
<div className="space-y-3">
<div className="flex justify-between items-center">
<span className="text-gray-400">Avg Logs per Family</span>
<span className="font-bold">
{stats.totalLogs > 0 ? (stats.totalLogs / 1).toFixed(1) : "0"}
</span>
</div>
<div className="flex justify-between items-center">
<span className="text-gray-400">Avg Children per Family</span>
<span className="font-bold">1.0</span>
</div>
<div className="flex justify-between items-center">
<span className="text-gray-400">Chat Adoption</span>
<span className="font-bold text-emerald-400">
{stats.totalChatSessions > 0 ? "Active" : "None"}
</span>
</div>
</div>
</div>
</div>
</div>
);
}
function StatCard({ label, value, icon }: { label: string; value: number; icon: string }) {
return (
<div className="bg-gray-800 p-4 rounded-xl">
<div className="text-2xl mb-1">{icon}</div>
<div className="text-xl font-bold">{value}</div>
<div className="text-xs text-gray-400">{label}</div>
</div>
);
}

View file

@ -0,0 +1,113 @@
"use client";
import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
interface Child {
id: string;
name: string;
birthDate: string;
familyId: string;
familyName: string;
age: string;
}
export default function AdminChildren() {
const router = useRouter();
const [children, setChildren] = useState<Child[]>([]);
const [loading, setLoading] = useState(true);
const [search, setSearch] = useState("");
useEffect(() => {
const token = localStorage.getItem("admin_token");
if (!token) {
router.push("/admin/login");
return;
}
fetchChildren();
}, []);
const fetchChildren = async () => {
try {
const res = await fetch("/api/admin/children", {
headers: { Authorization: `Bearer ${localStorage.getItem("admin_token")}` },
});
const data = await res.json();
setChildren(data.children || []);
} catch (err) {
console.error("Failed to fetch children:", err);
}
setLoading(false);
};
const filteredChildren = children.filter((c) =>
c.name.toLowerCase().includes(search.toLowerCase()) ||
c.familyName.toLowerCase().includes(search.toLowerCase())
);
const exportCSV = () => {
const headers = ["Name", "Birth Date", "Family", "Age"];
const rows = filteredChildren.map((c) => [c.name, c.birthDate, c.familyName, c.age]);
const csv = [headers, ...rows].map((row) => row.join(",")).join("\n");
const blob = new Blob([csv], { type: "text/csv" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = "children.csv";
a.click();
};
if (loading) {
return <div className="p-6 text-white">Loading...</div>;
}
return (
<div className="p-6 space-y-4">
<div className="flex justify-between items-center">
<div>
<h1 className="text-2xl font-bold">Children</h1>
<p className="text-gray-400">{children.length} total children</p>
</div>
<button onClick={exportCSV} className="px-4 py-2 bg-emerald-600 hover:bg-emerald-700 rounded-lg">
Export CSV
</button>
</div>
<input
type="text"
placeholder="Search children..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-4 py-2 text-white"
/>
<div className="bg-gray-800 rounded-xl overflow-hidden">
<table className="w-full">
<thead className="bg-gray-700">
<tr>
<th className="px-4 py-3 text-left text-sm font-medium">Child</th>
<th className="px-4 py-3 text-left text-sm font-medium">Birth Date</th>
<th className="px-4 py-3 text-left text-sm font-medium">Age</th>
<th className="px-4 py-3 text-left text-sm font-medium">Family</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-700">
{filteredChildren.map((child) => (
<tr key={child.id} className="hover:bg-gray-750">
<td className="px-4 py-3 font-medium">{child.name}</td>
<td className="px-4 py-3 text-sm text-gray-400">
{child.birthDate?.slice(0, 10)}
</td>
<td className="px-4 py-3 text-sm text-gray-400">{child.age}</td>
<td className="px-4 py-3">{child.familyName}</td>
</tr>
))}
</tbody>
</table>
{filteredChildren.length === 0 && (
<div className="p-8 text-center text-gray-500">No children found</div>
)}
</div>
</div>
);
}

View file

@ -0,0 +1,158 @@
"use client";
import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
interface Family {
id: string;
name: string;
tier: string;
maxChildren: number;
maxMembers: number;
createdAt: string;
userCount: number;
childCount: number;
}
export default function AdminFamilies() {
const router = useRouter();
const [families, setFamilies] = useState<Family[]>([]);
const [loading, setLoading] = useState(true);
const [search, setSearch] = useState("");
const [tierFilter, setTierFilter] = useState("all");
useEffect(() => {
const token = localStorage.getItem("admin_token");
if (!token) {
router.push("/admin/login");
return;
}
fetchFamilies();
}, []);
const fetchFamilies = async () => {
try {
const res = await fetch("/api/admin/families", {
headers: { Authorization: `Bearer ${localStorage.getItem("admin_token")}` },
});
const data = await res.json();
setFamilies(data.families || []);
} catch (err) {
console.error("Failed to fetch families:", err);
}
setLoading(false);
};
const filteredFamilies = families.filter((f) => {
const matchesSearch = f.name.toLowerCase().includes(search.toLowerCase());
const matchesTier = tierFilter === "all" || f.tier === tierFilter;
return matchesSearch && matchesTier;
});
const exportCSV = () => {
const headers = ["Name", "Tier", "Users", "Children", "Max Children", "Max Members", "Created"];
const rows = filteredFamilies.map((f) => [
f.name,
f.tier,
f.userCount,
f.childCount,
f.maxChildren,
f.maxMembers,
f.createdAt,
]);
const csv = [headers, ...rows].map((row) => row.join(",")).join("\n");
const blob = new Blob([csv], { type: "text/csv" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = "families.csv";
a.click();
};
if (loading) {
return (
<div className="p-6 text-white">Loading...</div>
);
}
return (
<div className="p-6 space-y-4">
<div className="flex justify-between items-center">
<div>
<h1 className="text-2xl font-bold">Families</h1>
<p className="text-gray-400">{families.length} total families</p>
</div>
<button
onClick={exportCSV}
className="px-4 py-2 bg-emerald-600 hover:bg-emerald-700 rounded-lg"
>
Export CSV
</button>
</div>
{/* Filters */}
<div className="flex gap-4">
<input
type="text"
placeholder="Search families..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="flex-1 bg-gray-800 border border-gray-700 rounded-lg px-4 py-2 text-white"
/>
<select
value={tierFilter}
onChange={(e) => setTierFilter(e.target.value)}
className="bg-gray-800 border border-gray-700 rounded-lg px-4 py-2 text-white"
>
<option value="all">All Tiers</option>
<option value="free">Free</option>
<option value="pro">Pro</option>
</select>
</div>
{/* Table */}
<div className="bg-gray-800 rounded-xl overflow-hidden">
<table className="w-full">
<thead className="bg-gray-700">
<tr>
<th className="px-4 py-3 text-left text-sm font-medium">Family</th>
<th className="px-4 py-3 text-left text-sm font-medium">Tier</th>
<th className="px-4 py-3 text-left text-sm font-medium">Users</th>
<th className="px-4 py-3 text-left text-sm font-medium">Children</th>
<th className="px-4 py-3 text-left text-sm font-medium">Limits</th>
<th className="px-4 py-3 text-left text-sm font-medium">Created</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-700">
{filteredFamilies.map((family) => (
<tr key={family.id} className="hover:bg-gray-750">
<td className="px-4 py-3">
<div className="font-medium">{family.name}</div>
<div className="text-xs text-gray-500">{family.id}</div>
</td>
<td className="px-4 py-3">
<span className={`px-2 py-1 rounded text-xs ${
family.tier === "pro" ? "bg-emerald-900 text-emerald-400" : "bg-gray-600"
}`}>
{family.tier}
</span>
</td>
<td className="px-4 py-3">{family.userCount}</td>
<td className="px-4 py-3">{family.childCount}</td>
<td className="px-4 py-3 text-sm text-gray-400">
{family.maxChildren} kids, {family.maxMembers} members
</td>
<td className="px-4 py-3 text-sm text-gray-400">
{family.createdAt?.slice(0, 10)}
</td>
</tr>
))}
</tbody>
</table>
{filteredFamilies.length === 0 && (
<div className="p-8 text-center text-gray-500">No families found</div>
)}
</div>
</div>
);
}

104
src/app/admin/layout.tsx Normal file
View file

@ -0,0 +1,104 @@
"use client";
import { useEffect, useState } from "react";
import { useRouter, usePathname } from "next/navigation";
import Link from "next/link";
interface NavItem {
name: string;
href: string;
icon: string;
}
const navItems: NavItem[] = [
{ name: "Dashboard", href: "/admin", icon: "📊" },
{ name: "Families", href: "/admin/families", icon: "🏠" },
{ name: "Users", href: "/admin/users", icon: "👥" },
{ name: "Children", href: "/admin/children", icon: "👶" },
{ name: "Revenue", href: "/admin/revenue", icon: "💰" },
{ name: "Analytics", href: "/admin/analytics", icon: "📈" },
{ name: "Support", href: "/admin/support", icon: "🎫" },
{ name: "Settings", href: "/admin/settings", icon: "⚙️" },
];
export default function AdminLayout({ children }: { children: React.ReactNode }) {
const router = useRouter();
const pathname = usePathname();
const [sidebarOpen, setSidebarOpen] = useState(true);
const [admin, setAdmin] = useState<{ username: string; role: string } | null>(null);
useEffect(() => {
const token = localStorage.getItem("admin_token");
if (!token) {
router.push("/admin/login");
return;
}
const stored = localStorage.getItem("admin_user");
if (stored) {
setAdmin(JSON.parse(stored));
}
}, [router]);
const handleLogout = () => {
localStorage.removeItem("admin_token");
localStorage.removeItem("admin_user");
router.push("/admin/login");
};
return (
<div className="min-h-screen bg-gray-900 text-white flex">
{/* Sidebar */}
<aside className={`${sidebarOpen ? "w-64" : "w-16"} bg-gray-800 flex-shrink-0 transition-all duration-300`}>
{/* Header */}
<div className="p-4 flex items-center justify-between border-b border-gray-700">
{sidebarOpen && (
<Link href="/admin" className="text-lg font-bold text-rose-400">
Tia Admin
</Link>
)}
<button onClick={() => setSidebarOpen(!sidebarOpen)} className="text-gray-400 hover:text-white">
{sidebarOpen ? "◀" : "▶"}
</button>
</div>
{/* Navigation */}
<nav className="p-2 space-y-1">
{navItems.map((item) => {
const isActive = pathname === item.href || (item.href !== "/admin" && pathname.startsWith(item.href));
return (
<Link
key={item.name}
href={item.href}
className={`flex items-center gap-3 px-3 py-2.5 rounded-lg transition-colors ${
isActive ? "bg-rose-500/20 text-rose-400" : "text-gray-400 hover:bg-gray-700 hover:text-white"
}`}
>
<span className="text-lg">{item.icon}</span>
{sidebarOpen && <span className="font-medium">{item.name}</span>}
</Link>
);
})}
</nav>
{/* Footer */}
<div className="absolute bottom-0 w-64 p-4 border-t border-gray-700">
{sidebarOpen && admin && (
<div className="mb-3">
<div className="text-sm font-medium">{admin.username}</div>
<div className="text-xs text-gray-400">{admin.role}</div>
</div>
)}
<button
onClick={handleLogout}
className="w-full px-3 py-2 bg-gray-700 text-gray-400 hover:text-white rounded-lg text-sm"
>
{sidebarOpen ? "Logout" : "⬅"}
</button>
</div>
</aside>
{/* Main Content */}
<main className="flex-1 overflow-auto">{children}</main>
</div>
);
}

View file

@ -1,65 +1,58 @@
"use client";
import { useState, useEffect } from "react";
import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
interface Stats {
totalFamilies: number;
totalUsers: number;
totalChildren: number;
freeFamilies: number;
proFamilies: number;
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 router = useRouter();
const [stats, setStats] = useState<Stats | null>(null);
const [loading, setLoading] = useState(true);
const [stats, setStats] = useState<Stats>({
totalFamilies: 0,
totalUsers: 0,
totalChildren: 0,
freeFamilies: 0,
proFamilies: 0,
});
const [period, setPeriod] = useState("30");
useEffect(() => {
// Check auth
const token = localStorage.getItem("admin_token");
if (!token) {
router.push("/admin/login");
return;
}
fetchStats();
}, [router]);
}, [period]);
const fetchStats = async () => {
try {
// Get stats from database
const familiesRes = await fetch("/api/admin/stats?family=all", {
const res = await fetch(`/api/admin/stats?period=${period}`, {
headers: { Authorization: `Bearer ${localStorage.getItem("admin_token")}` },
});
// For now, show mock data
setStats({
totalFamilies: 1,
totalUsers: 2,
totalChildren: 1,
freeFamilies: 1,
proFamilies: 0,
});
const data = await res.json();
setStats(data);
} catch (err) {
console.error("Failed to fetch stats:", err);
}
setLoading(false);
};
const handleLogout = () => {
localStorage.removeItem("admin_token");
localStorage.removeItem("admin_user");
router.push("/admin/login");
};
if (loading) {
if (loading || !stats) {
return (
<div className="min-h-screen flex items-center justify-center bg-gray-900">
<div className="text-white">Loading...</div>
@ -68,58 +61,162 @@ export default function AdminDashboard() {
}
return (
<div className="min-h-screen bg-gray-900 text-white">
<div className="p-6 space-y-6">
{/* Header */}
<div className="p-4 flex justify-between items-center bg-gray-800">
<h1 className="text-xl font-bold">Tia Admin Panel</h1>
<button onClick={handleLogout} className="text-gray-400 hover:text-white">
Logout
</button>
<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)}
className="bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-white"
>
<option value="7">Last 7 days</option>
<option value="30">Last 30 days</option>
<option value="90">Last 90 days</option>
</select>
</div>
{/* Stats */}
<div className="p-6">
<h2 className="text-lg font-semibold mb-4">Platform Overview</h2>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<div className="bg-gray-800 p-6 rounded-xl">
<div className="text-3xl font-bold text-rose-400">{stats.totalFamilies}</div>
<div className="text-gray-400 text-sm">Total Families</div>
</div>
<div className="bg-gray-800 p-6 rounded-xl">
<div className="text-3xl font-bold text-rose-400">{stats.totalUsers}</div>
<div className="text-gray-400 text-sm">Total Users</div>
</div>
<div className="bg-gray-800 p-6 rounded-xl">
<div className="text-3xl font-bold text-rose-400">{stats.totalChildren}</div>
<div className="text-gray-400 text-sm">Total Children</div>
</div>
<div className="bg-gray-800 p-6 rounded-xl">
<div className="text-3xl font-bold text-rose-400">{stats.proFamilies}</div>
<div className="text-gray-400 text-sm">Pro Families</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"
/>
<StatCard
label="Total Users"
value={stats.overview.totalUsers}
icon="👥"
color="blue"
/>
<StatCard
label="Total Children"
value={stats.overview.totalChildren}
icon="👶"
color="amber"
/>
<StatCard
label="MRR"
value={`$${stats.overview.mrr.toFixed(2)}`}
icon="💰"
color="emerald"
/>
</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>
{/* Quick Actions */}
<h2 className="text-lg font-semibold mt-8 mb-4">Quick Actions</h2>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<a href="/admin/families" className="bg-gray-800 p-4 rounded-xl hover:bg-gray-700">
<div className="text-xl mb-2">👨👩👧</div>
<div>Manage Families</div>
</a>
<a href="/admin/users" className="bg-gray-800 p-4 rounded-xl hover:bg-gray-700">
<div className="text-xl mb-2">👥</div>
<div>Manage Users</div>
</a>
<a href="/admin/support" className="bg-gray-800 p-4 rounded-xl hover:bg-gray-700">
<div className="text-xl mb-2">🎫</div>
<div>Support Tickets</div>
</a>
<a href="/admin/settings" className="bg-gray-800 p-4 rounded-xl hover:bg-gray-700">
<div className="text-xl mb-2"></div>
<div>Settings</div>
</a>
<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 }: { label: string; value: number | string; icon: string; color: string }) {
const colorClasses: Record<string, string> = {
rose: "text-rose-400",
blue: "text-blue-400",
amber: "text-amber-400",
emerald: "text-emerald-400",
};
return (
<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>
);
}
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>
);
}

View file

@ -0,0 +1,153 @@
"use client";
import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
interface RevenueData {
proFamilies: number;
freeFamilies: number;
mrr: number;
history: { month: string; revenue: number }[];
}
const PRO_PRICE = 9.99;
export default function AdminRevenue() {
const router = useRouter();
const [data, setData] = useState<RevenueData | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
const token = localStorage.getItem("admin_token");
if (!token) {
router.push("/admin/login");
return;
}
fetchRevenue();
}, []);
const fetchRevenue = async () => {
try {
const res = await fetch("/api/admin/stats", {
headers: { Authorization: `Bearer ${localStorage.getItem("admin_token")}` },
});
const stats = await res.json();
setData({
proFamilies: stats.overview?.proFamilies || 0,
freeFamilies: stats.overview?.freeFamilies || 0,
mrr: stats.overview?.mrr || 0,
history: generateMonthlyHistory(stats.overview?.proFamilies || 0),
});
} catch (err) {
console.error("Failed to fetch revenue:", err);
}
setLoading(false);
};
const generateMonthlyHistory = (proCount: number) => {
const months = [];
for (let i = 11; i >= 0; i--) {
const date = new Date();
date.setMonth(date.getMonth() - i);
months.push({
month: date.toLocaleDateString("en-US", { month: "short", year: "2-digit" }),
revenue: proCount > 0 ? (proCount * PRO_PRICE).toFixed(2) : "0.00",
});
}
return months;
};
if (loading || !data) {
return <div className="p-6 text-white">Loading...</div>;
}
return (
<div className="p-6 space-y-6">
<div>
<h1 className="text-2xl font-bold">Revenue</h1>
<p className="text-gray-400">Subscription revenue analytics</p>
<p className="text-sm text-rose-400">Pro: ${PRO_PRICE}/month per family</p>
</div>
{/* Key Metrics */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<div className="bg-gray-800 p-6 rounded-xl">
<div className="text-3xl font-bold text-emerald-400">${data.mrr.toFixed(2)}</div>
<div className="text-gray-400 text-sm">Monthly Recurring Revenue</div>
</div>
<div className="bg-gray-800 p-6 rounded-xl">
<div className="text-3xl font-bold text-rose-400">${(data.mrr * 12).toFixed(2)}</div>
<div className="text-gray-400 text-sm">Annual Run Rate</div>
</div>
<div className="bg-gray-800 p-6 rounded-xl">
<div className="text-3xl font-bold text-rose-400">{data.proFamilies}</div>
<div className="text-gray-400 text-sm">Pro Families</div>
</div>
<div className="bg-gray-800 p-6 rounded-xl">
<div className="text-3xl font-bold text-gray-400">{data.freeFamilies}</div>
<div className="text-gray-400 text-sm">Free Families</div>
</div>
</div>
{/* Revenue Chart */}
<div className="bg-gray-800 p-6 rounded-xl">
<h3 className="text-lg font-semibold mb-4">Monthly Revenue</h3>
<div className="h-48 flex items-end gap-1">
{data.history.map((h, i) => (
<div key={i} className="flex-1 flex flex-col items-center gap-1">
<div
className="w-full bg-emerald-500 rounded-t"
style={{
height: data.mrr > 0 ? `${(parseFloat(h.revenue) / (data.mrr * 1.2)) * 100}%` : "0%",
minHeight: parseFloat(h.revenue) > 0 ? "4px" : "0"
}}
/>
<div className="text-[8px] text-gray-500">{h.month}</div>
</div>
))}
</div>
</div>
{/* Revenue Breakdown */}
<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 by Tier</h3>
<div className="space-y-3">
<div className="flex justify-between items-center">
<span className="text-emerald-400">Pro</span>
<span className="font-bold">${(data.proFamilies * PRO_PRICE).toFixed(2)}/mo</span>
</div>
<div className="flex justify-between items-center">
<span className="text-gray-400">Free</span>
<span className="font-bold">$0.00/mo</span>
</div>
</div>
</div>
<div className="bg-gray-800 p-6 rounded-xl">
<h3 className="text-lg font-semibold mb-4">Growth Potential</h3>
<div className="space-y-3">
<div className="flex justify-between items-center">
<span>If 10% convert</span>
<span className="font-bold text-amber-400">
${((data.freeFamilies * 0.1) * PRO_PRICE).toFixed(2)}/mo
</span>
</div>
<div className="flex justify-between items-center">
<span>If 25% convert</span>
<span className="font-bold text-amber-400">
${((data.freeFamilies * 0.25) * PRO_PRICE).toFixed(2)}/mo
</span>
</div>
<div className="flex justify-between items-center">
<span>If 50% convert</span>
<span className="font-bold text-emerald-400">
${((data.freeFamilies * 0.5) * PRO_PRICE).toFixed(2)}/mo
</span>
</div>
</div>
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,124 @@
"use client";
import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
interface Settings {
proPrice: number;
freeMaxChildren: number;
freeMaxMembers: number;
aiModel: string;
aiBaseUrl: string;
}
export default function AdminSettings() {
const router = useRouter();
const [settings, setSettings] = useState<Settings>({
proPrice: 9.99,
freeMaxChildren: 1,
freeMaxMembers: 2,
aiModel: "minimax-2.7",
aiBaseUrl: "https://llm.manohargupta.com",
});
const [saved, setSaved] = useState(false);
useEffect(() => {
const token = localStorage.getItem("admin_token");
if (!token) {
router.push("/admin/login");
return;
}
}, [router]);
const handleSave = async () => {
setSaved(true);
setTimeout(() => setSaved(false), 2000);
};
return (
<div className="p-6 space-y-6">
<div>
<h1 className="text-2xl font-bold">Settings</h1>
<p className="text-gray-400">Platform configuration</p>
</div>
{/* Pricing */}
<div className="bg-gray-800 p-6 rounded-xl space-y-4">
<h3 className="text-lg font-semibold">Pricing</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm text-gray-400 mb-1">Pro Monthly Price</label>
<div className="flex items-center gap-2">
<span className="text-gray-400">$</span>
<input
type="number"
step="0.01"
value={settings.proPrice}
onChange={(e) => setSettings({ ...settings, proPrice: Number(e.target.value) })}
className="flex-1 bg-gray-700 border border-gray-600 rounded-lg px-3 py-2"
/>
</div>
</div>
</div>
</div>
{/* Limits */}
<div className="bg-gray-800 p-6 rounded-xl space-y-4">
<h3 className="text-lg font-semibold">Free Tier Limits</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm text-gray-400 mb-1">Max Children</label>
<input
type="number"
value={settings.freeMaxChildren}
onChange={(e) => setSettings({ ...settings, freeMaxChildren: Number(e.target.value) })}
className="w-full bg-gray-700 border border-gray-600 rounded-lg px-3 py-2"
/>
</div>
<div>
<label className="block text-sm text-gray-400 mb-1">Max Family Members</label>
<input
type="number"
value={settings.freeMaxMembers}
onChange={(e) => setSettings({ ...settings, freeMaxMembers: Number(e.target.value) })}
className="w-full bg-gray-700 border border-gray-600 rounded-lg px-3 py-2"
/>
</div>
</div>
</div>
{/* AI Settings */}
<div className="bg-gray-800 p-6 rounded-xl space-y-4">
<h3 className="text-lg font-semibold">AI Configuration</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm text-gray-400 mb-1">Model</label>
<input
type="text"
value={settings.aiModel}
onChange={(e) => setSettings({ ...settings, aiModel: e.target.value })}
className="w-full bg-gray-700 border border-gray-600 rounded-lg px-3 py-2"
/>
</div>
<div>
<label className="block text-sm text-gray-400 mb-1">Base URL</label>
<input
type="text"
value={settings.aiBaseUrl}
onChange={(e) => setSettings({ ...settings, aiBaseUrl: e.target.value })}
className="w-full bg-gray-700 border border-gray-600 rounded-lg px-3 py-2"
/>
</div>
</div>
</div>
{/* Save */}
<button
onClick={handleSave}
className="w-full py-3 bg-rose-500 hover:bg-rose-600 rounded-xl font-medium"
>
{saved ? "Saved!" : "Save Settings"}
</button>
</div>
);
}

View file

@ -0,0 +1,180 @@
"use client";
import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
interface Ticket {
id: string;
email: string;
subject: string;
description: string;
status: string;
priority: string;
createdAt: string;
familyName: string;
}
export default function AdminSupport() {
const router = useRouter();
const [tickets, setTickets] = useState<Ticket[]>([]);
const [loading, setLoading] = useState(true);
const [statusFilter, setStatusFilter] = useState("all");
const [selectedTicket, setSelectedTicket] = useState<Ticket | null>(null);
const [replyMessage, setReplyMessage] = useState("");
useEffect(() => {
const token = localStorage.getItem("admin_token");
if (!token) {
router.push("/admin/login");
return;
}
fetchTickets();
}, [statusFilter]);
const fetchTickets = async () => {
try {
const res = await fetch(`/api/admin/support?status=${statusFilter}`, {
headers: { Authorization: `Bearer ${localStorage.getItem("admin_token")}` },
});
const data = await res.json();
setTickets(data.tickets || []);
} catch (err) {
console.error("Failed to fetch tickets:", err);
}
setLoading(false);
};
const updateStatus = async (ticketId: string, status: string) => {
try {
await fetch(`/api/admin/support`, {
method: "PATCH",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${localStorage.getItem("admin_token")}`,
},
body: JSON.stringify({ ticketId, status }),
});
fetchTickets();
} catch (err) {
console.error("Failed to update ticket:", err);
}
};
if (loading) {
return <div className="p-6 text-white">Loading...</div>;
}
return (
<div className="p-6 space-y-4">
<div className="flex justify-between items-center">
<div>
<h1 className="text-2xl font-bold">Support</h1>
<p className="text-gray-400">{tickets.length} tickets</p>
</div>
</div>
{/* Filters */}
<div className="flex gap-2">
{["all", "open", "in_progress", "resolved", "closed"].map((status) => (
<button
key={status}
onClick={() => setStatusFilter(status)}
className={`px-3 py-1.5 rounded-lg text-sm ${
statusFilter === status ? "bg-rose-500" : "bg-gray-800"
}`}
>
{status.replace("_", " ")}
</button>
))}
</div>
{/* Tickets List */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
{tickets.map((ticket) => (
<div
key={ticket.id}
onClick={() => setSelectedTicket(ticket)}
className={`p-4 rounded-xl cursor-pointer ${
selectedTicket?.id === ticket.id ? "bg-rose-500/20 border border-rose-500" : "bg-gray-800"
}`}
>
<div className="flex justify-between items-start mb-2">
<span className={`text-xs px-2 py-0.5 rounded ${
ticket.priority === "urgent" ? "bg-red-900 text-red-400" :
ticket.priority === "high" ? "bg-orange-900 text-orange-400" :
"bg-gray-700"
}`}>
{ticket.priority}
</span>
<span className={`text-xs px-2 py-0.5 rounded ${
ticket.status === "open" ? "bg-emerald-900 text-emerald-400" :
ticket.status === "in_progress" ? "bg-amber-900 text-amber-400" :
"bg-gray-700"
}`}>
{ticket.status.replace("_", " ")}
</span>
</div>
<div className="font-medium">{ticket.subject}</div>
<div className="text-sm text-gray-400">{ticket.email}</div>
<div className="text-xs text-gray-500 mt-2">
{ticket.createdAt?.slice(0, 10)}
</div>
</div>
))}
{tickets.length === 0 && (
<div className="p-8 text-center text-gray-500">No tickets found</div>
)}
</div>
{/* Ticket Detail */}
<div className="bg-gray-800 rounded-xl p-4">
{selectedTicket ? (
<div className="space-y-4">
<div>
<div className="font-medium text-lg">{selectedTicket.subject}</div>
<div className="text-sm text-gray-400">{selectedTicket.email}</div>
<div className="text-xs text-gray-500">
{selectedTicket.createdAt?.slice(0, 10)} · {selectedTicket.familyName}
</div>
</div>
<div className="p-3 bg-gray-700 rounded-lg">
{selectedTicket.description}
</div>
<div className="flex gap-2">
{selectedTicket.status === "open" && (
<button
onClick={() => updateStatus(selectedTicket.id, "in_progress")}
className="px-3 py-1.5 bg-amber-600 rounded-lg text-sm"
>
Start
</button>
)}
{selectedTicket.status === "in_progress" && (
<button
onClick={() => updateStatus(selectedTicket.id, "resolved")}
className="px-3 py-1.5 bg-emerald-600 rounded-lg text-sm"
>
Resolve
</button>
)}
{selectedTicket.status === "resolved" && (
<button
onClick={() => updateStatus(selectedTicket.id, "closed")}
className="px-3 py-1.5 bg-gray-600 rounded-lg text-sm"
>
Close
</button>
)}
</div>
</div>
) : (
<div className="h-full flex items-center justify-center text-gray-500">
Select a ticket to view details
</div>
)}
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,114 @@
"use client";
import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
interface User {
id: string;
email: string;
name: string;
familyId: string;
familyName: string;
createdAt: string;
}
export default function AdminUsers() {
const router = useRouter();
const [users, setUsers] = useState<User[]>([]);
const [loading, setLoading] = useState(true);
const [search, setSearch] = useState("");
useEffect(() => {
const token = localStorage.getItem("admin_token");
if (!token) {
router.push("/admin/login");
return;
}
fetchUsers();
}, []);
const fetchUsers = async () => {
try {
const res = await fetch("/api/admin/users", {
headers: { Authorization: `Bearer ${localStorage.getItem("admin_token")}` },
});
const data = await res.json();
setUsers(data.users || []);
} catch (err) {
console.error("Failed to fetch users:", err);
}
setLoading(false);
};
const filteredUsers = users.filter((u) =>
u.email.toLowerCase().includes(search.toLowerCase()) ||
u.name.toLowerCase().includes(search.toLowerCase())
);
const exportCSV = () => {
const headers = ["Email", "Name", "Family", "Created"];
const rows = filteredUsers.map((u) => [u.email, u.name, u.familyName, u.createdAt]);
const csv = [headers, ...rows].map((row) => row.join(",")).join("\n");
const blob = new Blob([csv], { type: "text/csv" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = "users.csv";
a.click();
};
if (loading) {
return <div className="p-6 text-white">Loading...</div>;
}
return (
<div className="p-6 space-y-4">
<div className="flex justify-between items-center">
<div>
<h1 className="text-2xl font-bold">Users</h1>
<p className="text-gray-400">{users.length} total users</p>
</div>
<button onClick={exportCSV} className="px-4 py-2 bg-emerald-600 hover:bg-emerald-700 rounded-lg">
Export CSV
</button>
</div>
<input
type="text"
placeholder="Search users..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-4 py-2 text-white"
/>
<div className="bg-gray-800 rounded-xl overflow-hidden">
<table className="w-full">
<thead className="bg-gray-700">
<tr>
<th className="px-4 py-3 text-left text-sm font-medium">User</th>
<th className="px-4 py-3 text-left text-sm font-medium">Family</th>
<th className="px-4 py-3 text-left text-sm font-medium">Joined</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-700">
{filteredUsers.map((user) => (
<tr key={user.id} className="hover:bg-gray-750">
<td className="px-4 py-3">
<div className="font-medium">{user.name || user.email}</div>
<div className="text-xs text-gray-500">{user.email}</div>
</td>
<td className="px-4 py-3">{user.familyName}</td>
<td className="px-4 py-3 text-sm text-gray-400">
{user.createdAt?.slice(0, 10)}
</td>
</tr>
))}
</tbody>
</table>
{filteredUsers.length === 0 && (
<div className="p-8 text-center text-gray-500">No users found</div>
)}
</div>
</div>
);
}

View file

@ -0,0 +1,74 @@
import { NextResponse } from "next/server";
import { sql } from "@/db";
export async function GET(request: Request) {
try {
const authHeader = request.headers.get("authorization");
if (!authHeader || !authHeader.startsWith("Bearer ")) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
// Get activity log counts
const logsCount = await sql`SELECT COUNT(*) as count FROM activity_logs`;
// Get medicine log counts
const medicinesCount = await sql`SELECT COUNT(*) as count FROM medicines`;
// Get vaccination counts
const vaccinationsCount = await sql`SELECT COUNT(*) as count FROM vaccinations`;
// Get growth record counts
const growthCount = await sql`SELECT COUNT(*) as count FROM growth_records`;
// Get memory counts
const memoriesCount = await sql`SELECT COUNT(*) as count FROM memories`;
// Get chat session counts
const chatSessionsCount = await sql`SELECT COUNT(*) as count FROM chat_sessions`;
// Get logs by day
const logsByDay = await sql`
SELECT DATE(created_at) as date, COUNT(*) as count
FROM activity_logs
WHERE created_at > NOW() - INTERVAL '30 days'
GROUP BY DATE(created_at)
ORDER BY date
`;
// Build feature usage
const topFeatures = [
{ name: "Activity Logging", count: Number(logsCount[0]?.count || 0) },
{ name: "Medicine Tracking", count: Number(medicinesCount[0]?.count || 0) },
{ name: "Vaccinations", count: Number(vaccinationsCount[0]?.count || 0) },
{ name: "Growth Charts", count: Number(growthCount[0]?.count || 0) },
{ name: "Photo Memories", count: Number(memoriesCount[0]?.count || 0) },
{ name: "AI Chat", count: Number(chatSessionsCount[0]?.count || 0) },
];
return NextResponse.json({
totalLogs: Number(logsCount[0]?.count || 0),
totalMedicines: Number(medicinesCount[0]?.count || 0),
totalVaccinations: Number(vaccinationsCount[0]?.count || 0),
totalGrowth: Number(growthCount[0]?.count || 0),
totalMemories: Number(memoriesCount[0]?.count || 0),
totalChatSessions: Number(chatSessionsCount[0]?.count || 0),
logsByDay: logsByDay.map((r: any) => ({
date: r.date?.toISOString().split("T")[0],
count: Number(r.count),
})),
topFeatures,
});
} catch (error) {
console.error("Admin analytics error:", error);
return NextResponse.json({
totalLogs: 0,
totalMedicines: 0,
totalVaccinations: 0,
totalGrowth: 0,
totalMemories: 0,
totalChatSessions: 0,
logsByDay: [],
topFeatures: [],
});
}
}

View file

@ -0,0 +1,39 @@
import { NextResponse } from "next/server";
import { sql } from "@/db";
export async function GET(request: Request) {
try {
const authHeader = request.headers.get("authorization");
if (!authHeader || !authHeader.startsWith("Bearer ")) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
// Get all children with family info
const children = await sql`
SELECT
c.id,
c.name,
c.birth_date,
c.family_id,
f.name as family_name,
AGE(c.birth_date) as age
FROM children c
LEFT JOIN families f ON f.id = c.family_id
ORDER BY c.created_at DESC
`;
return NextResponse.json({
children: children.map((c: any) => ({
id: c.id,
name: c.name,
birthDate: c.birth_date?.toISOString(),
familyId: c.family_id,
familyName: c.family_name,
age: c.age || "N/A",
})),
});
} catch (error) {
console.error("Admin children error:", error);
return NextResponse.json({ error: String(error) }, { status: 500 });
}
}

View file

@ -0,0 +1,74 @@
import { NextResponse } from "next/server";
import { sql } from "@/db";
export async function GET(request: Request) {
try {
const authHeader = request.headers.get("authorization");
if (!authHeader || !authHeader.startsWith("Bearer ")) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
// Get all families with user and child counts
const families = await sql`
SELECT
f.id,
f.name,
f.tier,
f.max_children,
f.max_members,
f.created_at,
COUNT(DISTINCT u.id) as user_count,
COUNT(DISTINCT c.id) as child_count
FROM families f
LEFT JOIN users u ON u.family_id = f.id
LEFT JOIN children c ON c.family_id = f.id
GROUP BY f.id
ORDER BY f.created_at DESC
`;
return NextResponse.json({
families: families.map((f: any) => ({
id: f.id,
name: f.name,
tier: f.tier,
maxChildren: f.max_children,
maxMembers: f.max_members,
createdAt: f.created_at?.toISOString(),
userCount: Number(f.user_count),
childCount: Number(f.child_count),
})),
});
} catch (error) {
console.error("Admin families error:", error);
return NextResponse.json({ error: String(error) }, { status: 500 });
}
}
// Update family tier
export async function PATCH(request: Request) {
try {
const authHeader = request.headers.get("authorization");
if (!authHeader || !authHeader.startsWith("Bearer ")) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const body = await request.json();
const { familyId, tier, maxChildren, maxMembers } = body;
if (!familyId) {
return NextResponse.json({ error: "familyId required" }, { status: 400 });
}
await sql`
UPDATE families
SET tier = COALESCE(${tier}, tier),
max_children = COALESCE(${maxChildren}, max_children),
max_members = COALESCE(${maxMembers}, max_members)
WHERE id = ${familyId}
`;
return NextResponse.json({ success: true });
} catch (error) {
return NextResponse.json({ error: String(error) }, { status: 500 });
}
}

View file

@ -0,0 +1,124 @@
import { NextResponse } from "next/server";
import { sql } from "@/db";
// Get stats from database
export async function GET(request: Request) {
try {
const { searchParams } = new URL(request.url);
const period = searchParams.get("period") || "30"; // days
// Get total counts
const familyCount = await sql`SELECT COUNT(*) as count FROM families`;
const userCount = await sql`SELECT COUNT(*) as count FROM users`;
const childCount = await sql`SELECT COUNT(*) as count FROM children`;
// Get tier breakdown
const tierStats = await sql`
SELECT tier, COUNT(*) as count
FROM families
GROUP BY tier
`;
const proFamilies = tierStats.find((t: any) => t.tier === "pro")?.count || 0;
const freeFamilies = tierStats.find((t: any) => t.tier === "free")?.count || 0;
// Get MRR (assuming $9.99/month for pro)
const PRO_PRICE = 9.99;
const mrr = proFamilies * PRO_PRICE;
// Get new families by day (last N days)
const newFamilies = await sql`
SELECT DATE(created_at) as date, COUNT(*) as count
FROM families
WHERE created_at > NOW() - INTERVAL '${sql(period)} days'
GROUP BY DATE(created_at)
ORDER BY date
`;
// Get new users by day
const newUsers = await sql`
SELECT DATE(created_at) as date, COUNT(*) as count
FROM users
WHERE created_at > NOW() - INTERVAL '${sql(period)} days'
GROUP BY DATE(created_at)
ORDER BY date
`;
// Get children by age group
const childrenByAge = await sql`
SELECT
CASE
WHEN AGE(birth_date) < INTERVAL '1 year' THEN '0-1'
WHEN AGE(birth_date) < INTERVAL '2 years' THEN '1-2'
WHEN AGE(birth_date) < INTERVAL '3 years' THEN '2-3'
WHEN AGE(birth_date) < INTERVAL '4 years' THEN '3-4'
WHEN AGE(birth_date) < INTERVAL '5 years' THEN '4-5'
ELSE '5+'
END as age_group,
COUNT(*) as count
FROM children
GROUP BY age_group
ORDER BY age_group
`;
// For now, return mock data structure if tables are empty
const stats = {
overview: {
totalFamilies: familyCount[0]?.count || 0,
totalUsers: userCount[0]?.count || 0,
totalChildren: childCount[0]?.count || 0,
proFamilies,
freeFamilies,
mrr: Number(mrr.toFixed(2)),
avgRevenuePerUser: proFamilies > 0 ? Number((mrr / (proFamilies + freeFamilies)).toFixed(2)) : 0,
},
conversions: {
freeToPro: 0, // Track upgrades
conversionRate: freeFamilies > 0 ? Number(((proFamilies / (proFamilies + freeFamilies)) * 100).toFixed(1)) : 0,
},
growth: {
familiesByDay: newFamilies.length > 0 ? newFamilies.map((r: any) => ({
date: r.date,
count: Number(r.count),
})) : [
{ date: new Date().toISOString().split("T")[0], count: 0 },
],
usersByDay: newUsers.length > 0 ? newUsers.map((r: any) => ({
date: r.date,
count: Number(r.count),
})) : [
{ date: new Date().toISOString().split("T")[0], count: 0 },
],
},
childrenByAge: childrenByAge.length > 0 ? childrenByAge.map((r: any) => ({
ageGroup: r.age_group,
count: Number(r.count),
})) : [],
};
return NextResponse.json(stats);
} catch (error) {
console.error("Admin stats error:", error);
// Return empty stats on error
return NextResponse.json({
overview: {
totalFamilies: 0,
totalUsers: 0,
totalChildren: 0,
proFamilies: 0,
freeFamilies: 0,
mrr: 0,
avgRevenuePerUser: 0,
},
conversions: {
freeToPro: 0,
conversionRate: 0,
},
growth: {
familiesByDay: [],
usersByDay: [],
},
childrenByAge: [],
});
}
}

View file

@ -0,0 +1,94 @@
import { NextResponse } from "next/server";
import { sql } from "@/db";
export async function GET(request: Request) {
try {
const authHeader = request.headers.get("authorization");
if (!authHeader || !authHeader.startsWith("Bearer ")) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const { searchParams } = new URL(request.url);
const status = searchParams.get("status") || "all";
let query = `
SELECT t.*, f.name as family_name
FROM support_tickets t
LEFT JOIN families f ON f.id = t.family_id
`;
const params: string[] = [];
if (status !== "all") {
query += ` WHERE t.status = $1`;
params.push(status);
}
query += ` ORDER BY t.created_at DESC`;
const tickets = await sql(query, ...params);
return NextResponse.json({
tickets: tickets.map((t: any) => ({
id: t.id,
email: t.email,
subject: t.subject,
description: t.description,
status: t.status,
priority: t.priority,
createdAt: t.created_at?.toISOString(),
familyName: t.family_name,
})),
});
} catch (error) {
console.error("Admin support error:", error);
return NextResponse.json({ error: String(error) }, { status: 500 });
}
}
// Create/update ticket
export async function PATCH(request: Request) {
try {
const authHeader = request.headers.get("authorization");
if (!authHeader || !authHeader.startsWith("Bearer ")) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const body = await request.json();
const { ticketId, status } = body;
if (!ticketId) {
return NextResponse.json({ error: "ticketId required" }, { status: 400 });
}
await sql`
UPDATE support_tickets
SET status = ${status}, updated_at = NOW()
WHERE id = ${ticketId}
`;
return NextResponse.json({ success: true });
} catch (error) {
return NextResponse.json({ error: String(error) }, { status: 500 });
}
}
// Create ticket (for users)
export async function POST(request: Request) {
try {
const body = await request.json();
const { familyId, userId, email, subject, description, priority } = body;
if (!email || !subject) {
return NextResponse.json({ error: "email and subject required" }, { status: 400 });
}
await sql`
INSERT INTO support_tickets (family_id, user_id, email, subject, description, priority)
VALUES ${sql(familyId, userId, email, subject, description, priority || "normal")}
`;
return NextResponse.json({ success: true });
} catch (error) {
return NextResponse.json({ error: String(error) }, { status: 500 });
}
}

View file

@ -0,0 +1,39 @@
import { NextResponse } from "next/server";
import { sql } from "@/db";
export async function GET(request: Request) {
try {
const authHeader = request.headers.get("authorization");
if (!authHeader || !authHeader.startsWith("Bearer ")) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
// Get all users with family info
const users = await sql`
SELECT
u.id,
u.email,
u.name,
u.family_id,
f.name as family_name,
u.created_at
FROM users u
LEFT JOIN families f ON f.id = u.family_id
ORDER BY u.created_at DESC
`;
return NextResponse.json({
users: users.map((u: any) => ({
id: u.id,
email: u.email,
name: u.name,
familyId: u.family_id,
familyName: u.family_name,
createdAt: u.created_at?.toISOString(),
})),
});
} catch (error) {
console.error("Admin users error:", error);
return NextResponse.json({ error: String(error) }, { status: 500 });
}
}