Add comprehensive admin panel with analytics, families, users, children, revenue, support, settings
This commit is contained in:
parent
d5b07078ae
commit
cda25b04ca
15 changed files with 1704 additions and 77 deletions
140
src/app/admin/analytics/page.tsx
Normal file
140
src/app/admin/analytics/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
113
src/app/admin/children/page.tsx
Normal file
113
src/app/admin/children/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
158
src/app/admin/families/page.tsx
Normal file
158
src/app/admin/families/page.tsx
Normal 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
104
src/app/admin/layout.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,65 +1,58 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useEffect } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
|
|
||||||
interface Stats {
|
interface Stats {
|
||||||
totalFamilies: number;
|
overview: {
|
||||||
totalUsers: number;
|
totalFamilies: number;
|
||||||
totalChildren: number;
|
totalUsers: number;
|
||||||
freeFamilies: number;
|
totalChildren: number;
|
||||||
proFamilies: 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() {
|
export default function AdminDashboard() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const [stats, setStats] = useState<Stats | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [stats, setStats] = useState<Stats>({
|
const [period, setPeriod] = useState("30");
|
||||||
totalFamilies: 0,
|
|
||||||
totalUsers: 0,
|
|
||||||
totalChildren: 0,
|
|
||||||
freeFamilies: 0,
|
|
||||||
proFamilies: 0,
|
|
||||||
});
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Check auth
|
|
||||||
const token = localStorage.getItem("admin_token");
|
const token = localStorage.getItem("admin_token");
|
||||||
if (!token) {
|
if (!token) {
|
||||||
router.push("/admin/login");
|
router.push("/admin/login");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
fetchStats();
|
fetchStats();
|
||||||
}, [router]);
|
}, [period]);
|
||||||
|
|
||||||
const fetchStats = async () => {
|
const fetchStats = async () => {
|
||||||
try {
|
try {
|
||||||
// Get stats from database
|
const res = await fetch(`/api/admin/stats?period=${period}`, {
|
||||||
const familiesRes = await fetch("/api/admin/stats?family=all", {
|
|
||||||
headers: { Authorization: `Bearer ${localStorage.getItem("admin_token")}` },
|
headers: { Authorization: `Bearer ${localStorage.getItem("admin_token")}` },
|
||||||
});
|
});
|
||||||
// For now, show mock data
|
const data = await res.json();
|
||||||
setStats({
|
setStats(data);
|
||||||
totalFamilies: 1,
|
|
||||||
totalUsers: 2,
|
|
||||||
totalChildren: 1,
|
|
||||||
freeFamilies: 1,
|
|
||||||
proFamilies: 0,
|
|
||||||
});
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Failed to fetch stats:", err);
|
console.error("Failed to fetch stats:", err);
|
||||||
}
|
}
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleLogout = () => {
|
if (loading || !stats) {
|
||||||
localStorage.removeItem("admin_token");
|
|
||||||
localStorage.removeItem("admin_user");
|
|
||||||
router.push("/admin/login");
|
|
||||||
};
|
|
||||||
|
|
||||||
if (loading) {
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen flex items-center justify-center bg-gray-900">
|
<div className="min-h-screen flex items-center justify-center bg-gray-900">
|
||||||
<div className="text-white">Loading...</div>
|
<div className="text-white">Loading...</div>
|
||||||
|
|
@ -68,58 +61,162 @@ export default function AdminDashboard() {
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gray-900 text-white">
|
<div className="p-6 space-y-6">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="p-4 flex justify-between items-center bg-gray-800">
|
<div className="flex justify-between items-center">
|
||||||
<h1 className="text-xl font-bold">Tia Admin Panel</h1>
|
<div>
|
||||||
<button onClick={handleLogout} className="text-gray-400 hover:text-white">
|
<h1 className="text-2xl font-bold">Dashboard</h1>
|
||||||
Logout
|
<p className="text-gray-400">Platform overview and analytics</p>
|
||||||
</button>
|
</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>
|
</div>
|
||||||
|
|
||||||
{/* Stats */}
|
{/* Overview Cards */}
|
||||||
<div className="p-6">
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||||
<h2 className="text-lg font-semibold mb-4">Platform Overview</h2>
|
<StatCard
|
||||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
label="Total Families"
|
||||||
<div className="bg-gray-800 p-6 rounded-xl">
|
value={stats.overview.totalFamilies}
|
||||||
<div className="text-3xl font-bold text-rose-400">{stats.totalFamilies}</div>
|
icon="🏠"
|
||||||
<div className="text-gray-400 text-sm">Total Families</div>
|
color="rose"
|
||||||
</div>
|
/>
|
||||||
<div className="bg-gray-800 p-6 rounded-xl">
|
<StatCard
|
||||||
<div className="text-3xl font-bold text-rose-400">{stats.totalUsers}</div>
|
label="Total Users"
|
||||||
<div className="text-gray-400 text-sm">Total Users</div>
|
value={stats.overview.totalUsers}
|
||||||
</div>
|
icon="👥"
|
||||||
<div className="bg-gray-800 p-6 rounded-xl">
|
color="blue"
|
||||||
<div className="text-3xl font-bold text-rose-400">{stats.totalChildren}</div>
|
/>
|
||||||
<div className="text-gray-400 text-sm">Total Children</div>
|
<StatCard
|
||||||
</div>
|
label="Total Children"
|
||||||
<div className="bg-gray-800 p-6 rounded-xl">
|
value={stats.overview.totalChildren}
|
||||||
<div className="text-3xl font-bold text-rose-400">{stats.proFamilies}</div>
|
icon="👶"
|
||||||
<div className="text-gray-400 text-sm">Pro Families</div>
|
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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Quick Actions */}
|
<div className="bg-gray-800 p-6 rounded-xl">
|
||||||
<h2 className="text-lg font-semibold mt-8 mb-4">Quick Actions</h2>
|
<h3 className="text-lg font-semibold mb-4">Conversions</h3>
|
||||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
<div className="flex items-center justify-center h-32">
|
||||||
<a href="/admin/families" className="bg-gray-800 p-4 rounded-xl hover:bg-gray-700">
|
<div className="text-center">
|
||||||
<div className="text-xl mb-2">👨👩👧</div>
|
<div className="text-4xl font-bold text-rose-400">{stats.conversions.conversionRate}%</div>
|
||||||
<div>Manage Families</div>
|
<div className="text-gray-400">Free → Pro Conversion Rate</div>
|
||||||
</a>
|
</div>
|
||||||
<a href="/admin/users" className="bg-gray-800 p-4 rounded-xl hover:bg-gray-700">
|
</div>
|
||||||
<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>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
153
src/app/admin/revenue/page.tsx
Normal file
153
src/app/admin/revenue/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
124
src/app/admin/settings/page.tsx
Normal file
124
src/app/admin/settings/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
180
src/app/admin/support/page.tsx
Normal file
180
src/app/admin/support/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
114
src/app/admin/users/page.tsx
Normal file
114
src/app/admin/users/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
74
src/app/api/admin/analytics/route.ts
Normal file
74
src/app/api/admin/analytics/route.ts
Normal 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: [],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
39
src/app/api/admin/children/route.ts
Normal file
39
src/app/api/admin/children/route.ts
Normal 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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
74
src/app/api/admin/families/route.ts
Normal file
74
src/app/api/admin/families/route.ts
Normal 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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
124
src/app/api/admin/stats/route.ts
Normal file
124
src/app/api/admin/stats/route.ts
Normal 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: [],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
94
src/app/api/admin/support/route.ts
Normal file
94
src/app/api/admin/support/route.ts
Normal 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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
39
src/app/api/admin/users/route.ts
Normal file
39
src/app/api/admin/users/route.ts
Normal 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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue