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";
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
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