From cda25b04ca50a450def6e886f57d1314384df1dc Mon Sep 17 00:00:00 2001 From: Mannu Date: Sun, 10 May 2026 22:43:20 +0530 Subject: [PATCH] Add comprehensive admin panel with analytics, families, users, children, revenue, support, settings --- src/app/admin/analytics/page.tsx | 140 +++++++++++++++ src/app/admin/children/page.tsx | 113 ++++++++++++ src/app/admin/families/page.tsx | 158 +++++++++++++++++ src/app/admin/layout.tsx | 104 +++++++++++ src/app/admin/page.tsx | 251 +++++++++++++++++++-------- src/app/admin/revenue/page.tsx | 153 ++++++++++++++++ src/app/admin/settings/page.tsx | 124 +++++++++++++ src/app/admin/support/page.tsx | 180 +++++++++++++++++++ src/app/admin/users/page.tsx | 114 ++++++++++++ src/app/api/admin/analytics/route.ts | 74 ++++++++ src/app/api/admin/children/route.ts | 39 +++++ src/app/api/admin/families/route.ts | 74 ++++++++ src/app/api/admin/stats/route.ts | 124 +++++++++++++ src/app/api/admin/support/route.ts | 94 ++++++++++ src/app/api/admin/users/route.ts | 39 +++++ 15 files changed, 1704 insertions(+), 77 deletions(-) create mode 100644 src/app/admin/analytics/page.tsx create mode 100644 src/app/admin/children/page.tsx create mode 100644 src/app/admin/families/page.tsx create mode 100644 src/app/admin/layout.tsx create mode 100644 src/app/admin/revenue/page.tsx create mode 100644 src/app/admin/settings/page.tsx create mode 100644 src/app/admin/support/page.tsx create mode 100644 src/app/admin/users/page.tsx create mode 100644 src/app/api/admin/analytics/route.ts create mode 100644 src/app/api/admin/children/route.ts create mode 100644 src/app/api/admin/families/route.ts create mode 100644 src/app/api/admin/stats/route.ts create mode 100644 src/app/api/admin/support/route.ts create mode 100644 src/app/api/admin/users/route.ts diff --git a/src/app/admin/analytics/page.tsx b/src/app/admin/analytics/page.tsx new file mode 100644 index 0000000..17fd765 --- /dev/null +++ b/src/app/admin/analytics/page.tsx @@ -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(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
Loading...
; + } + + return ( +
+
+

Analytics

+

Feature usage and engagement

+
+ + {/* Usage Overview */} +
+ + + + + + +
+ + {/* Activity Chart */} +
+

Daily Activity (Last 30 days)

+
+ {stats.logsByDay.slice(-30).map((d, i) => ( +
+
l.count), 1)) * 100, 100)}%`, + minHeight: d.count > 0 ? "4px" : "0" + }} + /> +
{d.date?.slice(5) || ""}
+
+ ))} +
+ {stats.logsByDay.length === 0 && ( +
+ No activity data yet +
+ )} +
+ + {/* Feature Breakdown */} +
+
+

Top Features

+
+ {stats.topFeatures.map((feature, i) => ( +
+ {feature.name} + {feature.count} +
+ ))} + {stats.topFeatures.length === 0 && ( +
No data yet
+ )} +
+
+ +
+

Engagement Summary

+
+
+ Avg Logs per Family + + {stats.totalLogs > 0 ? (stats.totalLogs / 1).toFixed(1) : "0"} + +
+
+ Avg Children per Family + 1.0 +
+
+ Chat Adoption + + {stats.totalChatSessions > 0 ? "Active" : "None"} + +
+
+
+
+
+ ); +} + +function StatCard({ label, value, icon }: { label: string; value: number; icon: string }) { + return ( +
+
{icon}
+
{value}
+
{label}
+
+ ); +} \ No newline at end of file diff --git a/src/app/admin/children/page.tsx b/src/app/admin/children/page.tsx new file mode 100644 index 0000000..655df1e --- /dev/null +++ b/src/app/admin/children/page.tsx @@ -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([]); + 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
Loading...
; + } + + return ( +
+
+
+

Children

+

{children.length} total children

+
+ +
+ + setSearch(e.target.value)} + className="w-full bg-gray-800 border border-gray-700 rounded-lg px-4 py-2 text-white" + /> + +
+ + + + + + + + + + + {filteredChildren.map((child) => ( + + + + + + + ))} + +
ChildBirth DateAgeFamily
{child.name} + {child.birthDate?.slice(0, 10)} + {child.age}{child.familyName}
+ {filteredChildren.length === 0 && ( +
No children found
+ )} +
+
+ ); +} \ No newline at end of file diff --git a/src/app/admin/families/page.tsx b/src/app/admin/families/page.tsx new file mode 100644 index 0000000..c14e71a --- /dev/null +++ b/src/app/admin/families/page.tsx @@ -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([]); + 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 ( +
Loading...
+ ); + } + + return ( +
+
+
+

Families

+

{families.length} total families

+
+ +
+ + {/* Filters */} +
+ setSearch(e.target.value)} + className="flex-1 bg-gray-800 border border-gray-700 rounded-lg px-4 py-2 text-white" + /> + +
+ + {/* Table */} +
+ + + + + + + + + + + + + {filteredFamilies.map((family) => ( + + + + + + + + + ))} + +
FamilyTierUsersChildrenLimitsCreated
+
{family.name}
+
{family.id}
+
+ + {family.tier} + + {family.userCount}{family.childCount} + {family.maxChildren} kids, {family.maxMembers} members + + {family.createdAt?.slice(0, 10)} +
+ {filteredFamilies.length === 0 && ( +
No families found
+ )} +
+
+ ); +} \ No newline at end of file diff --git a/src/app/admin/layout.tsx b/src/app/admin/layout.tsx new file mode 100644 index 0000000..4be0fa6 --- /dev/null +++ b/src/app/admin/layout.tsx @@ -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 ( +
+ {/* Sidebar */} + + + {/* Main Content */} +
{children}
+
+ ); +} \ No newline at end of file diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx index eb1bc7f..2c535a3 100644 --- a/src/app/admin/page.tsx +++ b/src/app/admin/page.tsx @@ -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(null); const [loading, setLoading] = useState(true); - const [stats, setStats] = useState({ - 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 (
Loading...
@@ -68,58 +61,162 @@ export default function AdminDashboard() { } return ( -
+
{/* Header */} -
-

Tia Admin Panel

- +
+
+

Dashboard

+

Platform overview and analytics

+
+
- {/* Stats */} -
-

Platform Overview

-
-
-
{stats.totalFamilies}
-
Total Families
-
-
-
{stats.totalUsers}
-
Total Users
-
-
-
{stats.totalChildren}
-
Total Children
-
-
-
{stats.proFamilies}
-
Pro Families
+ {/* Overview Cards */} +
+ + + + +
+ + {/* Revenue & Tier Stats */} +
+
+

Revenue Overview

+
+
+
${stats.overview.mrr.toFixed(2)}
+
Monthly Recurring Revenue
+
+
+
{stats.overview.proFamilies}
+
Pro Families
+
+
+
{stats.overview.freeFamilies}
+
Free Families
+
+
+
${stats.overview.avgRevenuePerUser}
+
Avg Revenue per Family
+
- {/* Quick Actions */} -

Quick Actions

- + + {/* Growth Charts */} +
+ + +
+ + {/* Children by Age */} + {stats.childrenByAge.length > 0 && ( +
+

Children by Age Group

+
+ {stats.childrenByAge.map((item) => ( +
+
{item.count}
+
{item.ageGroup} years
+
+ ))} +
+
+ )} +
+ ); +} + +function StatCard({ label, value, icon, color }: { label: string; value: number | string; icon: string; color: string }) { + const colorClasses: Record = { + rose: "text-rose-400", + blue: "text-blue-400", + amber: "text-amber-400", + emerald: "text-emerald-400", + }; + + return ( +
+
+ {icon} +
+
{value}
+
{label}
+
+ ); +} + +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 ( +
+

{icon} {title}

+
+ {data.slice(-14).map((d, i) => ( +
+
0 ? "4px" : "0" }} + /> +
+ {d.date?.slice(5) || ""} +
+
+ ))} +
+ {data.length === 0 && ( +
+ No data available +
+ )}
); } \ No newline at end of file diff --git a/src/app/admin/revenue/page.tsx b/src/app/admin/revenue/page.tsx new file mode 100644 index 0000000..efe6e1b --- /dev/null +++ b/src/app/admin/revenue/page.tsx @@ -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(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
Loading...
; + } + + return ( +
+
+

Revenue

+

Subscription revenue analytics

+

Pro: ${PRO_PRICE}/month per family

+
+ + {/* Key Metrics */} +
+
+
${data.mrr.toFixed(2)}
+
Monthly Recurring Revenue
+
+
+
${(data.mrr * 12).toFixed(2)}
+
Annual Run Rate
+
+
+
{data.proFamilies}
+
Pro Families
+
+
+
{data.freeFamilies}
+
Free Families
+
+
+ + {/* Revenue Chart */} +
+

Monthly Revenue

+
+ {data.history.map((h, i) => ( +
+
0 ? `${(parseFloat(h.revenue) / (data.mrr * 1.2)) * 100}%` : "0%", + minHeight: parseFloat(h.revenue) > 0 ? "4px" : "0" + }} + /> +
{h.month}
+
+ ))} +
+
+ + {/* Revenue Breakdown */} +
+
+

Revenue by Tier

+
+
+ Pro + ${(data.proFamilies * PRO_PRICE).toFixed(2)}/mo +
+
+ Free + $0.00/mo +
+
+
+ +
+

Growth Potential

+
+
+ If 10% convert + + ${((data.freeFamilies * 0.1) * PRO_PRICE).toFixed(2)}/mo + +
+
+ If 25% convert + + ${((data.freeFamilies * 0.25) * PRO_PRICE).toFixed(2)}/mo + +
+
+ If 50% convert + + ${((data.freeFamilies * 0.5) * PRO_PRICE).toFixed(2)}/mo + +
+
+
+
+
+ ); +} \ No newline at end of file diff --git a/src/app/admin/settings/page.tsx b/src/app/admin/settings/page.tsx new file mode 100644 index 0000000..74fb0e9 --- /dev/null +++ b/src/app/admin/settings/page.tsx @@ -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({ + 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 ( +
+
+

Settings

+

Platform configuration

+
+ + {/* Pricing */} +
+

Pricing

+
+
+ +
+ $ + setSettings({ ...settings, proPrice: Number(e.target.value) })} + className="flex-1 bg-gray-700 border border-gray-600 rounded-lg px-3 py-2" + /> +
+
+
+
+ + {/* Limits */} +
+

Free Tier Limits

+
+
+ + setSettings({ ...settings, freeMaxChildren: Number(e.target.value) })} + className="w-full bg-gray-700 border border-gray-600 rounded-lg px-3 py-2" + /> +
+
+ + setSettings({ ...settings, freeMaxMembers: Number(e.target.value) })} + className="w-full bg-gray-700 border border-gray-600 rounded-lg px-3 py-2" + /> +
+
+
+ + {/* AI Settings */} +
+

AI Configuration

+
+
+ + setSettings({ ...settings, aiModel: e.target.value })} + className="w-full bg-gray-700 border border-gray-600 rounded-lg px-3 py-2" + /> +
+
+ + setSettings({ ...settings, aiBaseUrl: e.target.value })} + className="w-full bg-gray-700 border border-gray-600 rounded-lg px-3 py-2" + /> +
+
+
+ + {/* Save */} + +
+ ); +} \ No newline at end of file diff --git a/src/app/admin/support/page.tsx b/src/app/admin/support/page.tsx new file mode 100644 index 0000000..a9080cd --- /dev/null +++ b/src/app/admin/support/page.tsx @@ -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([]); + const [loading, setLoading] = useState(true); + const [statusFilter, setStatusFilter] = useState("all"); + const [selectedTicket, setSelectedTicket] = useState(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
Loading...
; + } + + return ( +
+
+
+

Support

+

{tickets.length} tickets

+
+
+ + {/* Filters */} +
+ {["all", "open", "in_progress", "resolved", "closed"].map((status) => ( + + ))} +
+ + {/* Tickets List */} +
+
+ {tickets.map((ticket) => ( +
setSelectedTicket(ticket)} + className={`p-4 rounded-xl cursor-pointer ${ + selectedTicket?.id === ticket.id ? "bg-rose-500/20 border border-rose-500" : "bg-gray-800" + }`} + > +
+ + {ticket.priority} + + + {ticket.status.replace("_", " ")} + +
+
{ticket.subject}
+
{ticket.email}
+
+ {ticket.createdAt?.slice(0, 10)} +
+
+ ))} + {tickets.length === 0 && ( +
No tickets found
+ )} +
+ + {/* Ticket Detail */} +
+ {selectedTicket ? ( +
+
+
{selectedTicket.subject}
+
{selectedTicket.email}
+
+ {selectedTicket.createdAt?.slice(0, 10)} ยท {selectedTicket.familyName} +
+
+
+ {selectedTicket.description} +
+
+ {selectedTicket.status === "open" && ( + + )} + {selectedTicket.status === "in_progress" && ( + + )} + {selectedTicket.status === "resolved" && ( + + )} +
+
+ ) : ( +
+ Select a ticket to view details +
+ )} +
+
+
+ ); +} \ No newline at end of file diff --git a/src/app/admin/users/page.tsx b/src/app/admin/users/page.tsx new file mode 100644 index 0000000..76f5ba3 --- /dev/null +++ b/src/app/admin/users/page.tsx @@ -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([]); + 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
Loading...
; + } + + return ( +
+
+
+

Users

+

{users.length} total users

+
+ +
+ + setSearch(e.target.value)} + className="w-full bg-gray-800 border border-gray-700 rounded-lg px-4 py-2 text-white" + /> + +
+ + + + + + + + + + {filteredUsers.map((user) => ( + + + + + + ))} + +
UserFamilyJoined
+
{user.name || user.email}
+
{user.email}
+
{user.familyName} + {user.createdAt?.slice(0, 10)} +
+ {filteredUsers.length === 0 && ( +
No users found
+ )} +
+
+ ); +} \ No newline at end of file diff --git a/src/app/api/admin/analytics/route.ts b/src/app/api/admin/analytics/route.ts new file mode 100644 index 0000000..8548682 --- /dev/null +++ b/src/app/api/admin/analytics/route.ts @@ -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: [], + }); + } +} \ No newline at end of file diff --git a/src/app/api/admin/children/route.ts b/src/app/api/admin/children/route.ts new file mode 100644 index 0000000..39f3fa8 --- /dev/null +++ b/src/app/api/admin/children/route.ts @@ -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 }); + } +} \ No newline at end of file diff --git a/src/app/api/admin/families/route.ts b/src/app/api/admin/families/route.ts new file mode 100644 index 0000000..baaba59 --- /dev/null +++ b/src/app/api/admin/families/route.ts @@ -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 }); + } +} \ No newline at end of file diff --git a/src/app/api/admin/stats/route.ts b/src/app/api/admin/stats/route.ts new file mode 100644 index 0000000..1d7b1fd --- /dev/null +++ b/src/app/api/admin/stats/route.ts @@ -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: [], + }); + } +} \ No newline at end of file diff --git a/src/app/api/admin/support/route.ts b/src/app/api/admin/support/route.ts new file mode 100644 index 0000000..c9726b0 --- /dev/null +++ b/src/app/api/admin/support/route.ts @@ -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 }); + } +} \ No newline at end of file diff --git a/src/app/api/admin/users/route.ts b/src/app/api/admin/users/route.ts new file mode 100644 index 0000000..e1b1d3f --- /dev/null +++ b/src/app/api/admin/users/route.ts @@ -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 }); + } +} \ No newline at end of file