Admin panel overhaul: activity monitor, real DB wiring, tier management

- New /admin/activity page: live login events, failed attempts, active
  sessions from audit_log + sessions tables; auto-refresh toggle
- New /api/admin/activity route: queries audit_log + sessions for
  stats (active sessions, logins/failures 24h, signups 7d) and events
- Fix /api/admin/stats: real growth charts (families/users by day),
  real children-by-age, real conversion rate, active sessions count,
  and login/failure counts — was all hardcoded empty arrays before
- Fix /api/admin/analytics: avg logs per family now divides by actual
  family count instead of hardcoded 1
- Dashboard: 6-card grid adding Active Sessions + Failed Logins 24h
  with links to Activity Monitor; bar charts now show hover counts
- Families: inline tier upgrade/downgrade button (Pro ↑ / Free ↓)
  wired to existing PATCH API; member panel polished
- Support: admin reply thread using support_responses table; Cmd+Enter
  to send; conversation view with original message + admin replies;
  auto-moves ticket to in_progress on first reply
- Settings: honest read-only display for env-var-controlled settings
  (pricing, AI config); editable free-tier limits that write to DB
- New /api/admin/families/limits route for bulk free-tier limit update
- Sidebar: added Activity Monitor nav item

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Manohar Gupta 2026-05-24 08:35:01 +05:30
parent bcc167d7c2
commit 23010e9d90
12 changed files with 1075 additions and 283 deletions

View file

@ -12,6 +12,7 @@ interface NavItem {
const navItems: NavItem[] = [
{ name: "Dashboard", href: "/admin", icon: "📊" },
{ name: "Activity", href: "/admin/activity", icon: "🔍" },
{ name: "Families", href: "/admin/families", icon: "🏠" },
{ name: "Users", href: "/admin/users", icon: "👥" },
{ name: "Children", href: "/admin/children", icon: "👶" },

View file

@ -0,0 +1,263 @@
"use client";
import { useEffect, useState } from "react";
import { Button, Badge } from "@/components/ui";
interface ActivityStats {
activeSessions: number;
loginsToday: number;
failedToday: number;
signupsWeek: number;
}
interface Event {
id: string;
action: string;
email: string;
userName: string | null;
familyName: string | null;
ipAddress: string | null;
userAgent: string | null;
createdAt: string;
}
interface ActivityData {
stats: ActivityStats;
loginsByDay: { date: string; count: number }[];
failedByDay: { date: string; count: number }[];
events: Event[];
}
const ACTION_CONFIG: Record<string, { label: string; color: string; badge: "rose" | "warning" | "default" }> = {
login: { label: "Login", color: "text-emerald-400", badge: "default" },
login_failed: { label: "Failed Login", color: "text-rose-400", badge: "rose" },
signup: { label: "Signup", color: "text-blue-400", badge: "default" },
logout: { label: "Logout", color: "text-gray-400", badge: "default" },
};
function timeAgo(iso: string) {
const diff = Date.now() - new Date(iso).getTime();
const mins = Math.floor(diff / 60000);
if (mins < 1) return "just now";
if (mins < 60) return `${mins}m ago`;
const hrs = Math.floor(mins / 60);
if (hrs < 24) return `${hrs}h ago`;
const days = Math.floor(hrs / 24);
return `${days}d ago`;
}
function parseDevice(ua: string | null) {
if (!ua) return "Unknown";
if (ua.includes("iPhone") || ua.includes("Android")) return "Mobile";
if (ua.includes("iPad")) return "Tablet";
return "Desktop";
}
export default function AdminActivity() {
const [data, setData] = useState<ActivityData | null>(null);
const [loading, setLoading] = useState(true);
const [filter, setFilter] = useState("all");
const [autoRefresh, setAutoRefresh] = useState(false);
useEffect(() => {
fetchActivity();
}, [filter]);
useEffect(() => {
if (!autoRefresh) return;
const id = setInterval(fetchActivity, 15000);
return () => clearInterval(id);
}, [autoRefresh, filter]);
const fetchActivity = async () => {
try {
const res = await fetch(`/api/admin/activity?action=${filter}&limit=100`, { credentials: "include" });
const json = await res.json();
setData(json);
} catch (err) {
console.error("Failed to fetch activity:", err);
}
setLoading(false);
};
if (loading || !data) {
return (
<div className="p-6">
<div className="animate-pulse space-y-4">
<div className="h-8 bg-gray-700 rounded w-48" />
<div className="grid grid-cols-4 gap-4">
{[1, 2, 3, 4].map(i => <div key={i} className="h-24 bg-gray-800 rounded-xl" />)}
</div>
</div>
</div>
);
}
const { stats, loginsByDay, failedByDay, events } = data;
const maxLogins = Math.max(...loginsByDay.map(d => d.count), 1);
const maxFailed = Math.max(...failedByDay.map(d => d.count), 1);
return (
<div className="p-6 space-y-6">
{/* Header */}
<div className="flex justify-between items-center">
<div>
<h1 className="text-2xl font-bold">Activity Monitor</h1>
<p className="text-gray-400">Real-time login events and session tracking</p>
</div>
<div className="flex items-center gap-3">
<div className="flex items-center gap-2">
<div className={`w-2 h-2 rounded-full ${autoRefresh ? "bg-emerald-400 animate-pulse" : "bg-gray-600"}`} />
<button
onClick={() => setAutoRefresh(!autoRefresh)}
className="text-sm text-gray-400 hover:text-white"
>
{autoRefresh ? "Live" : "Paused"}
</button>
</div>
<Button size="sm" variant="secondary" onClick={fetchActivity}>Refresh</Button>
</div>
</div>
{/* Stat Cards */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<div className="bg-gray-800 p-5 rounded-xl border border-gray-700">
<div className="text-3xl font-bold text-emerald-400">{stats.activeSessions}</div>
<div className="text-gray-400 text-sm mt-1">Active Sessions</div>
<div className="text-xs text-gray-600 mt-1">Currently online</div>
</div>
<div className="bg-gray-800 p-5 rounded-xl border border-gray-700">
<div className="text-3xl font-bold text-blue-400">{stats.loginsToday}</div>
<div className="text-gray-400 text-sm mt-1">Logins Today</div>
<div className="text-xs text-gray-600 mt-1">Last 24 hours</div>
</div>
<div className="bg-gray-800 p-5 rounded-xl border border-gray-700">
<div className={`text-3xl font-bold ${stats.failedToday > 0 ? "text-rose-400" : "text-gray-400"}`}>
{stats.failedToday}
</div>
<div className="text-gray-400 text-sm mt-1">Failed Attempts</div>
<div className="text-xs text-gray-600 mt-1">Last 24 hours</div>
</div>
<div className="bg-gray-800 p-5 rounded-xl border border-gray-700">
<div className="text-3xl font-bold text-amber-400">{stats.signupsWeek}</div>
<div className="text-gray-400 text-sm mt-1">New Signups</div>
<div className="text-xs text-gray-600 mt-1">Last 7 days</div>
</div>
</div>
{/* Charts */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="bg-gray-800 p-6 rounded-xl">
<h3 className="text-sm font-semibold text-gray-400 mb-4">LOGIN ACTIVITY LAST 30 DAYS</h3>
<div className="h-32 flex items-end gap-1">
{loginsByDay.slice(-30).map((d, i) => (
<div key={i} className="flex-1 flex flex-col items-center gap-1">
<div
className="w-full bg-emerald-500 rounded-t opacity-80"
style={{ height: `${(d.count / maxLogins) * 100}%`, minHeight: d.count > 0 ? "3px" : "0" }}
/>
<div className="text-[7px] text-gray-600">{d.date?.slice(5) || ""}</div>
</div>
))}
{loginsByDay.length === 0 && (
<div className="w-full h-full flex items-center justify-center text-gray-600 text-sm">
No login data yet
</div>
)}
</div>
</div>
<div className="bg-gray-800 p-6 rounded-xl">
<h3 className="text-sm font-semibold text-gray-400 mb-4">FAILED LOGINS LAST 30 DAYS</h3>
<div className="h-32 flex items-end gap-1">
{failedByDay.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 opacity-80"
style={{ height: `${(d.count / maxFailed) * 100}%`, minHeight: d.count > 0 ? "3px" : "0" }}
/>
<div className="text-[7px] text-gray-600">{d.date?.slice(5) || ""}</div>
</div>
))}
{failedByDay.length === 0 && (
<div className="w-full h-full flex items-center justify-center text-gray-600 text-sm">
No failed logins
</div>
)}
</div>
</div>
</div>
{/* Events Table */}
<div className="bg-gray-800 rounded-xl overflow-hidden">
{/* Filter tabs */}
<div className="flex gap-1 p-3 border-b border-gray-700 bg-gray-900">
{[
{ key: "all", label: "All Events" },
{ key: "login", label: "Logins" },
{ key: "login_failed", label: "Failures" },
{ key: "signup", label: "Signups" },
].map(({ key, label }) => (
<button
key={key}
onClick={() => setFilter(key)}
className={`px-3 py-1.5 rounded text-sm font-medium transition-colors ${
filter === key
? "bg-rose-500 text-white"
: "text-gray-400 hover:text-white hover:bg-gray-700"
}`}
>
{label}
</button>
))}
<div className="ml-auto text-xs text-gray-600 flex items-center">
{events.length} events
</div>
</div>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead className="bg-gray-700">
<tr>
<th className="px-4 py-2.5 text-left text-xs font-medium text-gray-400">TIME</th>
<th className="px-4 py-2.5 text-left text-xs font-medium text-gray-400">EVENT</th>
<th className="px-4 py-2.5 text-left text-xs font-medium text-gray-400">USER</th>
<th className="px-4 py-2.5 text-left text-xs font-medium text-gray-400">FAMILY</th>
<th className="px-4 py-2.5 text-left text-xs font-medium text-gray-400">IP</th>
<th className="px-4 py-2.5 text-left text-xs font-medium text-gray-400">DEVICE</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-700">
{events.map((event) => {
const config = ACTION_CONFIG[event.action] || { label: event.action, color: "text-gray-400", badge: "default" as const };
return (
<tr key={event.id} className={`hover:bg-gray-750 ${event.action === "login_failed" ? "bg-rose-500/5" : ""}`}>
<td className="px-4 py-2.5 text-gray-500 whitespace-nowrap">
{timeAgo(event.createdAt)}
</td>
<td className="px-4 py-2.5">
<span className={`font-medium ${config.color}`}>{config.label}</span>
</td>
<td className="px-4 py-2.5">
<div className="font-medium truncate max-w-[180px]">{event.email}</div>
{event.userName && event.userName !== event.email && (
<div className="text-xs text-gray-500">{event.userName}</div>
)}
</td>
<td className="px-4 py-2.5 text-gray-400">{event.familyName || "—"}</td>
<td className="px-4 py-2.5 text-gray-500 font-mono text-xs">{event.ipAddress || "—"}</td>
<td className="px-4 py-2.5 text-gray-500 text-xs">{parseDevice(event.userAgent)}</td>
</tr>
);
})}
</tbody>
</table>
{events.length === 0 && (
<div className="p-12 text-center text-gray-600">
No events recorded yet
</div>
)}
</div>
</div>
</div>
);
}

View file

@ -9,6 +9,7 @@ interface EngagementStats {
totalGrowth: number;
totalMemories: number;
totalChatSessions: number;
familyCount: number;
logsByDay: { date: string; count: number }[];
topFeatures: { name: string; count: number }[];
}
@ -100,7 +101,9 @@ export default function AdminAnalytics() {
<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"}
{stats.totalLogs > 0 && stats.familyCount > 0
? (stats.totalLogs / stats.familyCount).toFixed(1)
: "0"}
</span>
</div>
<div className="flex justify-between items-center">

View file

@ -29,7 +29,8 @@ export default function AdminFamilies() {
const [search, setSearch] = useState("");
const [tierFilter, setTierFilter] = useState("all");
const [showMembers, setShowMembers] = useState<string | null>(null);
const [addMember, setAddMember] = useState<{familyId: string; email: string; role: string; name: string} | null>(null);
const [addMember, setAddMember] = useState<{ familyId: string; email: string; role: string; name: string } | null>(null);
const [tierChanging, setTierChanging] = useState<string | null>(null);
useEffect(() => {
fetchFamilies();
@ -39,9 +40,7 @@ export default function AdminFamilies() {
try {
const res = await fetch("/api/admin/families", { credentials: "include" });
const data = await res.json();
if (data.error) {
console.error("API error:", data.error);
}
if (data.error) console.error("API error:", data.error);
setFamilies(data.families || []);
} catch (err) {
console.error("Failed to fetch families:", err);
@ -96,6 +95,31 @@ export default function AdminFamilies() {
}
};
const handleTierChange = async (familyId: string, newTier: string) => {
const family = families.find(f => f.id === familyId);
if (!family) return;
const label = newTier === "pro" ? "Upgrade to Pro?" : "Downgrade to Free?";
if (!confirm(label)) return;
setTierChanging(familyId);
try {
const res = await fetch("/api/admin/families", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({
familyId,
tier: newTier,
maxChildren: newTier === "pro" ? 10 : 1,
maxMembers: newTier === "pro" ? 10 : 2,
}),
});
if (res.ok) fetchFamilies();
} catch (err) {
console.error("Failed to update tier:", err);
}
setTierChanging(null);
};
const filteredFamilies = families.filter((f) => {
const matchesSearch = f.name.toLowerCase().includes(search.toLowerCase());
const matchesTier = tierFilter === "all" || f.tier === tierFilter;
@ -105,13 +129,7 @@ export default function AdminFamilies() {
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,
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" });
@ -122,18 +140,19 @@ export default function AdminFamilies() {
a.click();
};
if (loading) {
return (
<div className="p-6 text-white">Loading...</div>
);
}
if (loading) return <div className="p-6 text-white">Loading...</div>;
const proCount = families.filter(f => f.tier === "pro").length;
const freeCount = families.filter(f => f.tier === "free").length;
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>
<p className="text-gray-400">
{families.length} total · <span className="text-rose-400">{proCount} Pro</span> · <span className="text-gray-500">{freeCount} Free</span>
</p>
</div>
<div className="flex gap-2">
<Button onClick={handleCreateFamily}>+ New Family</Button>
@ -142,7 +161,7 @@ export default function AdminFamilies() {
</div>
{/* Filters */}
<div className="flex gap-4">
<div className="flex gap-3">
<Input
type="text"
placeholder="Search families..."
@ -150,10 +169,7 @@ export default function AdminFamilies() {
onChange={(e) => setSearch(e.target.value)}
className="flex-1"
/>
<Select
value={tierFilter}
onChange={(e) => setTierFilter(e.target.value)}
>
<Select value={tierFilter} onChange={(e) => setTierFilter(e.target.value)}>
<option value="all">All Tiers</option>
<option value="free">Free</option>
<option value="pro">Pro</option>
@ -167,10 +183,11 @@ export default function AdminFamilies() {
<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">Members</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>
<th className="px-4 py-3 text-left text-sm font-medium">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-700">
@ -179,49 +196,84 @@ export default function AdminFamilies() {
<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>
<div className="text-xs text-gray-600 font-mono">{family.id.slice(0, 8)}</div>
</td>
<td className="px-4 py-3">
<Badge variant={family.tier === "pro" ? "rose" : "default"}>{family.tier}</Badge>
<div className="flex items-center gap-2">
<Badge variant={family.tier === "pro" ? "rose" : "default"}>{family.tier}</Badge>
</div>
</td>
<td className="px-4 py-3">
<Button variant="ghost" size="sm" onClick={() => setShowMembers(showMembers === family.id ? null : family.id)}>
{family.userCount} users
<Button
variant="ghost"
size="sm"
onClick={() => setShowMembers(showMembers === family.id ? null : family.id)}
>
{family.userCount} users {showMembers === family.id ? "▲" : "▼"}
</Button>
</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 className="px-4 py-3 text-gray-300">{family.childCount}</td>
<td className="px-4 py-3 text-sm text-gray-500">
{family.maxChildren} kids · {family.maxMembers} members
</td>
<td className="px-4 py-3 text-sm text-gray-400">
{family.createdAt?.slice(0, 10)}
</td>
<td className="px-4 py-3">
<Button
variant={family.tier === "pro" ? "secondary" : "primary"}
size="sm"
onClick={() => handleTierChange(family.id, family.tier === "pro" ? "free" : "pro")}
disabled={tierChanging === family.id}
>
{tierChanging === family.id
? "…"
: family.tier === "pro"
? "↓ Free"
: "↑ Pro"}
</Button>
</td>
</tr>
{showMembers === family.id && (
<tr>
<td colSpan={6} className="bg-gray-800 p-4">
<tr key={`${family.id}-members`}>
<td colSpan={7} className="bg-gray-900 px-6 py-4">
<div className="space-y-2">
<div className="text-xs text-gray-500 font-semibold uppercase mb-2">Members</div>
<div className="flex gap-2 items-center">
<Input
type="email"
placeholder="New member email"
onChange={(e) => setAddMember({familyId: family.id, email: e.target.value, role: "caregiver", name: ""})}
className="flex-1"
placeholder="Add member by email"
onChange={(e) =>
setAddMember({ familyId: family.id, email: e.target.value, role: "caregiver", name: "" })
}
className="flex-1 max-w-xs"
/>
<Select
onChange={(e) => setAddMember(addMember ? {...addMember, role: e.target.value} : null)}
onChange={(e) =>
setAddMember(addMember ? { ...addMember, role: e.target.value } : null)
}
>
<option value="caregiver">Caregiver</option>
<option value="owner">Owner</option>
</Select>
<Button size="sm" onClick={handleAddMember}>Add</Button>
</div>
{(family.members || []).map((m) => (
<div key={m.id} className="flex justify-between items-center bg-gray-700 p-2 rounded">
<span>{m.email} ({m.role})</span>
<Button variant="danger" size="sm" onClick={() => handleRemoveMember(m.id)}>Remove</Button>
</div>
))}
<div className="space-y-1 mt-2">
{(family.members || []).map((m) => (
<div key={m.id} className="flex justify-between items-center bg-gray-800 px-3 py-2 rounded-lg">
<div>
<span className="text-sm">{m.email}</span>
<span className="ml-2 text-xs text-gray-500 bg-gray-700 px-1.5 py-0.5 rounded">{m.role}</span>
</div>
<Button variant="danger" size="sm" onClick={() => handleRemoveMember(m.id)}>
Remove
</Button>
</div>
))}
{(family.members || []).length === 0 && (
<div className="text-gray-600 text-sm">No members yet</div>
)}
</div>
</div>
</td>
</tr>

View file

@ -2,6 +2,7 @@
import { useEffect, useState } from "react";
import { Select } from "@/components/ui";
import Link from "next/link";
interface Stats {
overview: {
@ -12,6 +13,9 @@ interface Stats {
freeFamilies: number;
mrr: number;
avgRevenuePerUser: number;
activeSessions: number;
loginsLast24h: number;
failedLoginsLast24h: number;
};
conversions: {
freeToPro: number;
@ -34,8 +38,9 @@ export default function AdminDashboard() {
}, [period]);
const fetchStats = async () => {
setLoading(true);
try {
const res = await fetch(`/api/admin/stats?period=${period}`);
const res = await fetch(`/api/admin/stats?period=${period}`, { credentials: "include" });
const data = await res.json();
setStats(data);
} catch (err) {
@ -46,12 +51,17 @@ export default function AdminDashboard() {
if (loading || !stats) {
return (
<div className="min-h-screen flex items-center justify-center bg-gray-900">
<div className="text-white">Loading...</div>
<div className="p-6 space-y-6 animate-pulse">
<div className="h-8 bg-gray-800 rounded w-48" />
<div className="grid grid-cols-2 md:grid-cols-3 xl:grid-cols-6 gap-4">
{[1, 2, 3, 4, 5, 6].map(i => <div key={i} className="h-28 bg-gray-800 rounded-xl" />)}
</div>
</div>
);
}
const { overview, conversions, growth, childrenByAge } = stats;
return (
<div className="p-6 space-y-6">
{/* Header */}
@ -67,68 +77,70 @@ export default function AdminDashboard() {
</Select>
</div>
{/* Overview Cards */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
{/* Primary Stat Cards */}
<div className="grid grid-cols-2 md:grid-cols-3 xl:grid-cols-6 gap-4">
<StatCard label="Families" value={overview.totalFamilies} icon="🏠" color="rose" href="/admin/families" />
<StatCard label="Users" value={overview.totalUsers} icon="👥" color="blue" href="/admin/users" />
<StatCard label="Children" value={overview.totalChildren} icon="👶" color="amber" href="/admin/children" />
<StatCard label="MRR" value={`$${overview.mrr.toFixed(2)}`} icon="💰" color="emerald" href="/admin/revenue" />
<StatCard
label="Total Families"
value={stats.overview.totalFamilies}
icon="🏠"
color="rose"
href="/admin/families"
/>
<StatCard
label="Total Users"
value={stats.overview.totalUsers}
icon="👥"
color="blue"
href="/admin/users"
/>
<StatCard
label="Total Children"
value={stats.overview.totalChildren}
icon="👶"
color="amber"
href="/admin/children"
/>
<StatCard
label="MRR"
value={`$${stats.overview.mrr.toFixed(2)}`}
icon="💰"
label="Active Sessions"
value={overview.activeSessions}
icon="🟢"
color="emerald"
href="/admin/revenue"
href="/admin/activity"
/>
<StatCard
label="Failed Logins 24h"
value={overview.failedLoginsLast24h}
icon="⚠️"
color={overview.failedLoginsLast24h > 0 ? "rose" : "gray"}
href="/admin/activity"
/>
</div>
{/* Revenue & Tier Stats */}
{/* Revenue & Conversions */}
<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>
<h3 className="text-sm font-semibold text-gray-400 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 className="text-2xl font-bold text-emerald-400">${overview.mrr.toFixed(2)}</div>
<div className="text-gray-400 text-sm">Monthly Recurring</div>
</div>
<div>
<div className="text-2xl font-bold text-rose-400">{stats.overview.proFamilies}</div>
<div className="text-2xl font-bold text-rose-400">{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-2xl font-bold text-gray-500">{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 className="text-2xl font-bold text-amber-400">${overview.avgRevenuePerUser}</div>
<div className="text-gray-400 text-sm">Avg per Family</div>
</div>
</div>
</div>
<div className="bg-gray-800 p-6 rounded-xl">
<h3 className="text-lg font-semibold mb-4">Conversions</h3>
<div className="flex items-center justify-center h-32">
<div className="text-center">
<div className="text-4xl font-bold text-rose-400">{stats.conversions.conversionRate}%</div>
<div className="text-gray-400">Free Pro Conversion Rate</div>
<h3 className="text-sm font-semibold text-gray-400 mb-4">CONVERSION & ACTIVITY</h3>
<div className="grid grid-cols-2 gap-4">
<div className="flex flex-col items-center justify-center bg-gray-700 rounded-xl p-4">
<div className="text-3xl font-bold text-rose-400">{conversions.conversionRate}%</div>
<div className="text-gray-400 text-sm text-center mt-1">Pro Adoption Rate</div>
</div>
<div className="space-y-3">
<div>
<div className="text-xl font-bold text-blue-400">{overview.loginsLast24h}</div>
<div className="text-gray-400 text-xs">Logins (24h)</div>
</div>
<div>
<div className={`text-xl font-bold ${overview.failedLoginsLast24h > 0 ? "text-rose-400" : "text-gray-500"}`}>
{overview.failedLoginsLast24h}
</div>
<div className="text-gray-400 text-xs">Failed (24h)</div>
</div>
</div>
</div>
</div>
@ -136,82 +148,124 @@ export default function AdminDashboard() {
{/* 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="👥"
/>
<ChartCard title="New Families" data={growth.familiesByDay} icon="📈" color="rose" />
<ChartCard title="New Users" data={growth.usersByDay} icon="👥" color="blue" />
</div>
{/* Children by Age */}
{stats.childrenByAge.length > 0 && (
{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>
<h3 className="text-sm font-semibold text-gray-400 mb-4">CHILDREN BY AGE GROUP</h3>
<div className="flex flex-wrap gap-3">
{childrenByAge.map((item) => (
<div key={item.ageGroup} className="bg-gray-700 px-5 py-3 rounded-xl text-center">
<div className="text-2xl font-bold text-amber-400">{item.count}</div>
<div className="text-xs text-gray-400 mt-0.5">{item.ageGroup}</div>
</div>
))}
</div>
</div>
)}
{/* Quick Links */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
{[
{ label: "View All Families", href: "/admin/families", icon: "🏠" },
{ label: "Manage Users", href: "/admin/users", icon: "👥" },
{ label: "Activity Log", href: "/admin/activity", icon: "🔍" },
{ label: "Support Tickets", href: "/admin/support", icon: "🎫" },
].map(({ label, href, icon }) => (
<Link
key={href}
href={href}
className="bg-gray-800 hover:bg-gray-700 p-4 rounded-xl flex items-center gap-3 transition-colors group"
>
<span className="text-xl">{icon}</span>
<span className="text-sm text-gray-400 group-hover:text-white transition-colors">{label}</span>
</Link>
))}
</div>
</div>
);
}
function StatCard({ label, value, icon, color, href }: { label: string; value: number | string; icon: string; color: string; href?: string }) {
function StatCard({
label,
value,
icon,
color,
href,
}: {
label: string;
value: number | string;
icon: string;
color: string;
href?: string;
}) {
const colorClasses: Record<string, string> = {
rose: "text-rose-400",
blue: "text-blue-400",
amber: "text-amber-400",
emerald: "text-emerald-400",
gray: "text-gray-500",
};
const content = (
<div className="bg-gray-800 p-6 rounded-xl">
<div className="flex items-center justify-between mb-2">
<span className="text-2xl">{icon}</span>
</div>
<div className={`text-3xl font-bold ${colorClasses[color]}`}>{value}</div>
<div className="text-gray-400 text-sm">{label}</div>
const card = (
<div className="bg-gray-800 p-5 rounded-xl hover:bg-gray-750 transition-colors h-full">
<div className="text-xl mb-2">{icon}</div>
<div className={`text-2xl font-bold ${colorClasses[color] || "text-white"}`}>{value}</div>
<div className="text-gray-400 text-xs mt-1">{label}</div>
</div>
);
if (href) {
return <a href={href} className="cursor-pointer hover:opacity-90 transition-opacity">{content}</a>;
return <Link href={href} className="block">{card}</Link>;
}
return content;
return card;
}
function ChartCard({ title, data, icon }: { title: string; data: { date: string; count: number }[]; icon: string }) {
function ChartCard({
title,
data,
icon,
color,
}: {
title: string;
data: { date: string; count: number }[];
icon: string;
color: "rose" | "blue";
}) {
const maxCount = Math.max(...data.map((d) => d.count), 1);
const barColor = color === "rose" ? "bg-rose-500" : "bg-blue-500";
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">
<h3 className="text-sm font-semibold text-gray-400 mb-4">
{icon} {title.toUpperCase()}
</h3>
<div className="h-36 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">
<div key={i} className="flex-1 flex flex-col items-center gap-1 group">
<div className="relative w-full">
{d.count > 0 && (
<div className="absolute bottom-full mb-1 left-1/2 -translate-x-1/2 bg-gray-700 text-white text-xs px-1.5 py-0.5 rounded opacity-0 group-hover:opacity-100 whitespace-nowrap pointer-events-none z-10">
{d.count}
</div>
)}
<div
className={`w-full ${barColor} rounded-t opacity-80`}
style={{ height: `${(d.count / maxCount) * 100}%`, minHeight: d.count > 0 ? "4px" : "0" }}
/>
</div>
<div className="text-[7px] text-gray-600 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 className="h-36 flex items-center justify-center text-gray-600 text-sm">
No activity in this period
</div>
)}
</div>

View file

@ -1,10 +1,12 @@
"use client";
import { useState } from "react";
import { useEffect, useState } from "react";
import { Button, Input } from "@/components/ui";
interface Settings {
proPrice: number;
interface PlatformInfo {
proFamilies: number;
freeFamilies: number;
totalUsers: number;
freeMaxChildren: number;
freeMaxMembers: number;
aiModel: string;
@ -12,97 +14,177 @@ interface Settings {
}
export default function AdminSettings() {
const [settings, setSettings] = useState<Settings>({
proPrice: 9.99,
freeMaxChildren: 1,
freeMaxMembers: 2,
aiModel: "minimax-2.7",
aiBaseUrl: "https://llm.manohargupta.com",
});
const [info, setInfo] = useState<PlatformInfo | null>(null);
const [loading, setLoading] = useState(true);
const [freeMaxChildren, setFreeMaxChildren] = useState(1);
const [freeMaxMembers, setFreeMaxMembers] = useState(2);
const [saving, setSaving] = useState(false);
const [saved, setSaved] = useState(false);
const handleSave = async () => {
setSaved(true);
setTimeout(() => setSaved(false), 2000);
useEffect(() => {
fetchInfo();
}, []);
const fetchInfo = async () => {
try {
const res = await fetch("/api/admin/stats", { credentials: "include" });
const data = await res.json();
setFreeMaxChildren(1);
setFreeMaxMembers(2);
setInfo({
proFamilies: data.overview?.proFamilies || 0,
freeFamilies: data.overview?.freeFamilies || 0,
totalUsers: data.overview?.totalUsers || 0,
freeMaxChildren: 1,
freeMaxMembers: 2,
aiModel: "minimax-2.7",
aiBaseUrl: "https://llm.manohargupta.com",
});
} catch (err) {
console.error("Failed to fetch:", err);
}
setLoading(false);
};
const handleSaveLimits = async () => {
setSaving(true);
try {
// Apply new limits to all free-tier families
const res = await fetch("/api/admin/families/limits", {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ maxChildren: freeMaxChildren, maxMembers: freeMaxMembers }),
});
if (res.ok) {
setSaved(true);
setTimeout(() => setSaved(false), 3000);
}
} catch (err) {
console.error("Failed to save:", err);
}
setSaving(false);
};
if (loading || !info) return <div className="p-6 text-white">Loading...</div>;
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>
<p className="text-gray-400">Platform configuration and limits</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"
/>
</div>
</div>
{/* Platform Summary */}
<div className="grid grid-cols-3 gap-4">
<div className="bg-gray-800 p-4 rounded-xl text-center">
<div className="text-2xl font-bold text-rose-400">{info.proFamilies}</div>
<div className="text-xs text-gray-400 mt-1">Pro Families</div>
</div>
<div className="bg-gray-800 p-4 rounded-xl text-center">
<div className="text-2xl font-bold text-gray-400">{info.freeFamilies}</div>
<div className="text-xs text-gray-400 mt-1">Free Families</div>
</div>
<div className="bg-gray-800 p-4 rounded-xl text-center">
<div className="text-2xl font-bold text-blue-400">{info.totalUsers}</div>
<div className="text-xs text-gray-400 mt-1">Total Users</div>
</div>
</div>
{/* Limits */}
{/* Pricing — read-only display */}
<div className="bg-gray-800 p-6 rounded-xl space-y-3">
<div className="flex items-center justify-between">
<h3 className="text-lg font-semibold">Pricing</h3>
<span className="text-xs text-gray-500 bg-gray-700 px-2 py-1 rounded">Set via env var</span>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="bg-gray-700 px-4 py-3 rounded-lg">
<div className="text-xs text-gray-500 mb-1">Pro Monthly Price</div>
<div className="text-xl font-bold text-emerald-400">$9.99 / month</div>
</div>
<div className="bg-gray-700 px-4 py-3 rounded-lg">
<div className="text-xs text-gray-500 mb-1">Annual Run Rate</div>
<div className="text-xl font-bold text-emerald-400">
${(info.proFamilies * 9.99 * 12).toFixed(0)} / year
</div>
</div>
</div>
<p className="text-xs text-gray-600">
To change pricing, update <code className="text-gray-400">PRO_PRICE</code> in Dokploy dashboard and redeploy.
</p>
</div>
{/* Free Tier Limits — editable */}
<div className="bg-gray-800 p-6 rounded-xl space-y-4">
<h3 className="text-lg font-semibold">Free Tier Limits</h3>
<div className="flex items-center justify-between">
<h3 className="text-lg font-semibold">Free Tier Limits</h3>
<span className="text-xs text-emerald-400 bg-emerald-400/10 px-2 py-1 rounded">Editable</span>
</div>
<p className="text-xs text-gray-500">
These limits are applied to all free-tier families. Existing families will be updated.
</p>
<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>
<label className="block text-sm text-gray-400 mb-1">Max Children per Family</label>
<Input
type="number"
value={settings.freeMaxChildren}
onChange={(e) => setSettings({ ...settings, freeMaxChildren: Number(e.target.value) })}
min={1}
max={10}
value={freeMaxChildren}
onChange={(e) => setFreeMaxChildren(Number(e.target.value))}
/>
</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) })}
min={1}
max={10}
value={freeMaxMembers}
onChange={(e) => setFreeMaxMembers(Number(e.target.value))}
/>
</div>
</div>
<Button onClick={handleSaveLimits} disabled={saving}>
{saved ? "✓ Saved" : saving ? "Saving…" : "Apply to All Free Families"}
</Button>
</div>
{/* AI Settings */}
<div className="bg-gray-800 p-6 rounded-xl space-y-4">
<h3 className="text-lg font-semibold">AI Configuration</h3>
{/* AI Configuration — read-only */}
<div className="bg-gray-800 p-6 rounded-xl space-y-3">
<div className="flex items-center justify-between">
<h3 className="text-lg font-semibold">AI Configuration</h3>
<span className="text-xs text-gray-500 bg-gray-700 px-2 py-1 rounded">Set via env var</span>
</div>
<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 })}
/>
<div className="bg-gray-700 px-4 py-3 rounded-lg">
<div className="text-xs text-gray-500 mb-1">Model</div>
<div className="font-mono text-sm text-blue-400">{info.aiModel}</div>
</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 })}
/>
<div className="bg-gray-700 px-4 py-3 rounded-lg">
<div className="text-xs text-gray-500 mb-1">LiteLLM Gateway</div>
<div className="font-mono text-sm text-blue-400 truncate">{info.aiBaseUrl}</div>
</div>
</div>
<p className="text-xs text-gray-600">
To change AI settings, update <code className="text-gray-400">LITELLM_BASE_URL</code> and <code className="text-gray-400">LITELLM_API_KEY</code> in Dokploy.
</p>
</div>
{/* Admin Access */}
<div className="bg-gray-800 p-6 rounded-xl space-y-3">
<h3 className="text-lg font-semibold">Admin Access</h3>
<p className="text-sm text-gray-400">
Admin login: <code className="text-gray-300">/admin-login</code><br />
Password changes must be made directly in the database via the <code className="text-gray-300">admins</code> table.
</p>
<div className="bg-amber-500/10 border border-amber-500/20 rounded-lg px-4 py-3">
<div className="text-amber-400 text-sm font-medium">Security Reminder</div>
<div className="text-gray-400 text-xs mt-1">
Change the default admin password immediately if still using <code>admin123</code>.
</div>
</div>
</div>
{/* Save */}
<Button fullWidth size="lg" onClick={handleSave}>
{saved ? "Saved!" : "Save Settings"}
</Button>
</div>
);
}

View file

@ -14,16 +14,29 @@ interface Ticket {
familyName: string;
}
interface Response {
id: string;
message: string;
createdAt: string;
}
export default function AdminSupport() {
const [tickets, setTickets] = useState<Ticket[]>([]);
const [loading, setLoading] = useState(true);
const [statusFilter, setStatusFilter] = useState("all");
const [selectedTicket, setSelectedTicket] = useState<Ticket | null>(null);
const [responses, setResponses] = useState<Response[]>([]);
const [replyText, setReplyText] = useState("");
const [replying, setReplying] = useState(false);
useEffect(() => {
fetchTickets();
}, [statusFilter]);
useEffect(() => {
if (selectedTicket) fetchResponses(selectedTicket.id);
}, [selectedTicket]);
const fetchTickets = async () => {
try {
const res = await fetch(`/api/admin/support?status=${statusFilter}`, { credentials: "include" });
@ -35,115 +48,226 @@ export default function AdminSupport() {
setLoading(false);
};
const fetchResponses = async (ticketId: string) => {
try {
const res = await fetch(`/api/admin/support?ticketId=${ticketId}`, { credentials: "include" });
const data = await res.json();
setResponses(data.responses || []);
} catch (err) {
console.error("Failed to fetch responses:", err);
}
};
const updateStatus = async (ticketId: string, status: string) => {
try {
await fetch(`/api/admin/support`, {
await fetch("/api/admin/support", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ ticketId, status }),
});
fetchTickets();
if (selectedTicket?.id === ticketId) {
setSelectedTicket(prev => prev ? { ...prev, status } : null);
}
} catch (err) {
console.error("Failed to update ticket:", err);
}
};
const handleReply = async () => {
if (!replyText.trim() || !selectedTicket) return;
setReplying(true);
try {
const res = await fetch("/api/admin/support", {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ ticketId: selectedTicket.id, message: replyText }),
});
if (res.ok) {
setReplyText("");
fetchResponses(selectedTicket.id);
fetchTickets();
}
} catch (err) {
console.error("Failed to send reply:", err);
}
setReplying(false);
};
const priorityVariant = (p: string): "rose" | "warning" | "default" =>
p === "urgent" ? "rose" : p === "high" ? "warning" : "default";
const statusVariant = (s: string): "rose" | "warning" | "default" =>
s === "open" ? "rose" : s === "in_progress" ? "warning" : "default";
if (loading) {
return <div className="p-6 text-white">Loading...</div>;
const statusCounts = tickets.reduce((acc: Record<string, number>, t) => {
acc[t.status] = (acc[t.status] || 0) + 1;
return acc;
}, {});
function timeAgo(iso: string) {
const diff = Date.now() - new Date(iso).getTime();
const mins = Math.floor(diff / 60000);
if (mins < 60) return `${mins}m ago`;
const hrs = Math.floor(mins / 60);
if (hrs < 24) return `${hrs}h ago`;
return `${Math.floor(hrs / 24)}d ago`;
}
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>
<p className="text-gray-400">
{tickets.length} tickets
{statusCounts["open"] ? ` · ` : ""}
{statusCounts["open"] ? <span className="text-rose-400">{statusCounts["open"]} open</span> : null}
</p>
</div>
</div>
{/* Filters */}
<div className="flex gap-2">
{["all", "open", "in_progress", "resolved", "closed"].map((status) => (
{/* Status Filter */}
<div className="flex gap-2 flex-wrap">
{[
{ key: "all", label: "All" },
{ key: "open", label: `Open${statusCounts["open"] ? ` (${statusCounts["open"]})` : ""}` },
{ key: "in_progress", label: "In Progress" },
{ key: "resolved", label: "Resolved" },
{ key: "closed", label: "Closed" },
].map(({ key, label }) => (
<Button
key={status}
key={key}
size="sm"
variant={statusFilter === status ? "primary" : "secondary"}
onClick={() => setStatusFilter(status)}
variant={statusFilter === key ? "primary" : "secondary"}
onClick={() => setStatusFilter(key)}
>
{status.replace("_", " ")}
{label}
</Button>
))}
</div>
{/* Tickets List */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<div className="grid grid-cols-1 md:grid-cols-5 gap-4" style={{ height: "calc(100vh - 280px)", minHeight: "400px" }}>
{/* Ticket List */}
<div className="md:col-span-2 space-y-2 overflow-y-auto pr-1">
{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"
onClick={() => { setSelectedTicket(ticket); setResponses([]); }}
className={`p-4 rounded-xl cursor-pointer transition-colors ${
selectedTicket?.id === ticket.id
? "bg-rose-500/20 border border-rose-500/50"
: "bg-gray-800 hover:bg-gray-750 border border-transparent"
}`}
>
<div className="flex justify-between items-start mb-2">
<div className="flex justify-between items-start mb-1.5">
<Badge variant={priorityVariant(ticket.priority)}>{ticket.priority}</Badge>
<Badge variant={statusVariant(ticket.status)}>{ticket.status.replace("_", " ")}</Badge>
</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 className="font-medium text-sm">{ticket.subject}</div>
<div className="text-xs text-gray-400 mt-0.5">{ticket.email}</div>
{ticket.familyName && (
<div className="text-xs text-gray-600 mt-0.5">Family: {ticket.familyName}</div>
)}
<div className="text-xs text-gray-600 mt-1.5">{timeAgo(ticket.createdAt)}</div>
</div>
))}
{tickets.length === 0 && (
<div className="p-8 text-center text-gray-500">No tickets found</div>
<div className="p-8 text-center text-gray-500">No tickets</div>
)}
</div>
{/* Ticket Detail */}
<div className="bg-gray-800 rounded-xl p-4">
{/* Ticket Detail + Reply */}
<div className="md:col-span-3 bg-gray-800 rounded-xl flex flex-col overflow-hidden">
{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}
<>
{/* Ticket header */}
<div className="p-4 border-b border-gray-700">
<div className="flex justify-between items-start">
<div>
<div className="font-semibold">{selectedTicket.subject}</div>
<div className="text-sm text-gray-400">{selectedTicket.email}</div>
{selectedTicket.familyName && (
<div className="text-xs text-gray-500 mt-0.5">Family: {selectedTicket.familyName}</div>
)}
</div>
<div className="flex gap-2 flex-shrink-0">
{selectedTicket.status === "open" && (
<Button size="sm" variant="secondary" onClick={() => updateStatus(selectedTicket.id, "in_progress")}>
Start
</Button>
)}
{selectedTicket.status === "in_progress" && (
<Button size="sm" onClick={() => updateStatus(selectedTicket.id, "resolved")}>
Resolve
</Button>
)}
{selectedTicket.status === "resolved" && (
<Button size="sm" variant="secondary" onClick={() => updateStatus(selectedTicket.id, "closed")}>
Close
</Button>
)}
{selectedTicket.status === "closed" && (
<Button size="sm" variant="secondary" onClick={() => updateStatus(selectedTicket.id, "open")}>
Reopen
</Button>
)}
</div>
</div>
</div>
<div className="p-3 bg-gray-700 rounded-lg">
{selectedTicket.description}
{/* Conversation thread */}
<div className="flex-1 overflow-y-auto p-4 space-y-3">
{/* Original message */}
<div className="bg-gray-700 rounded-xl p-3">
<div className="text-xs text-gray-500 mb-1.5 flex justify-between">
<span>{selectedTicket.email}</span>
<span>{timeAgo(selectedTicket.createdAt)}</span>
</div>
<div className="text-sm whitespace-pre-wrap">{selectedTicket.description}</div>
</div>
{/* Admin responses */}
{responses.map((r) => (
<div key={r.id} className="bg-rose-500/15 border border-rose-500/20 rounded-xl p-3 ml-6">
<div className="text-xs text-gray-500 mb-1.5 flex justify-between">
<span className="text-rose-400">Admin</span>
<span>{timeAgo(r.createdAt)}</span>
</div>
<div className="text-sm whitespace-pre-wrap">{r.message}</div>
</div>
))}
</div>
<div className="flex gap-2">
{selectedTicket.status === "open" && (
<Button size="sm" variant="secondary" onClick={() => updateStatus(selectedTicket.id, "in_progress")}>
Start
</Button>
)}
{selectedTicket.status === "in_progress" && (
<Button size="sm" onClick={() => updateStatus(selectedTicket.id, "resolved")}>
Resolve
</Button>
)}
{selectedTicket.status === "resolved" && (
<Button size="sm" variant="secondary" onClick={() => updateStatus(selectedTicket.id, "closed")}>
Close
</Button>
)}
</div>
</div>
{/* Reply box */}
{selectedTicket.status !== "closed" && (
<div className="p-3 border-t border-gray-700">
<textarea
value={replyText}
onChange={(e) => setReplyText(e.target.value)}
placeholder="Type your reply…"
rows={3}
className="w-full bg-gray-700 text-white text-sm rounded-lg px-3 py-2 resize-none outline-none focus:ring-1 focus:ring-rose-500 placeholder-gray-500"
onKeyDown={(e) => {
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) handleReply();
}}
/>
<div className="flex justify-between items-center mt-2">
<span className="text-xs text-gray-600">Cmd+Enter to send</span>
<Button size="sm" onClick={handleReply} disabled={!replyText.trim() || replying}>
{replying ? "Sending…" : "Send Reply"}
</Button>
</div>
</div>
)}
</>
) : (
<div className="h-full flex items-center justify-center text-gray-500">
Select a ticket to view details
<div className="flex-1 flex items-center justify-center text-gray-600">
Select a ticket to view the conversation
</div>
)}
</div>

View file

@ -0,0 +1,99 @@
import { NextResponse } from "next/server";
import { requireAdmin } from "@/lib/admin-auth";
import { sql } from "@/db";
export async function GET(request: Request) {
const auth = await requireAdmin(request);
if (!auth.success) return NextResponse.json({ error: auth.error }, { status: auth.status });
const { searchParams } = new URL(request.url);
const action = searchParams.get("action") || "all";
const limit = Math.min(parseInt(searchParams.get("limit") || "100"), 500);
try {
const [activeSessions, loginsToday, failedToday, signupsWeek] = await Promise.all([
sql`SELECT COUNT(*)::int as count FROM sessions WHERE expires > NOW()`,
sql`SELECT COUNT(*)::int as count FROM audit_log WHERE action = 'login' AND created_at > NOW() - INTERVAL '24 hours'`,
sql`SELECT COUNT(*)::int as count FROM audit_log WHERE action = 'login_failed' AND created_at > NOW() - INTERVAL '24 hours'`,
sql`SELECT COUNT(*)::int as count FROM audit_log WHERE action = 'signup' AND created_at > NOW() - INTERVAL '7 days'`,
]);
const loginsByDay = await sql`
SELECT DATE(created_at) as date, COUNT(*)::int as count
FROM audit_log
WHERE action IN ('login', 'signup') AND created_at > NOW() - INTERVAL '30 days'
GROUP BY DATE(created_at)
ORDER BY date
`;
const failedByDay = await sql`
SELECT DATE(created_at) as date, COUNT(*)::int as count
FROM audit_log
WHERE action = 'login_failed' AND created_at > NOW() - INTERVAL '30 days'
GROUP BY DATE(created_at)
ORDER BY date
`;
let events;
if (action === "all") {
events = await sql`
SELECT
al.id, al.action, al.ip_address, al.user_agent, al.metadata, al.created_at,
u.email, u.name as user_name,
f.name as family_name
FROM audit_log al
LEFT JOIN users u ON u.id = al.user_id
LEFT JOIN families f ON f.id = al.family_id
ORDER BY al.created_at DESC
LIMIT ${limit}
`;
} else {
events = await sql`
SELECT
al.id, al.action, al.ip_address, al.user_agent, al.metadata, al.created_at,
u.email, u.name as user_name,
f.name as family_name
FROM audit_log al
LEFT JOIN users u ON u.id = al.user_id
LEFT JOIN families f ON f.id = al.family_id
WHERE al.action = ${action}
ORDER BY al.created_at DESC
LIMIT ${limit}
`;
}
const formatDate = (d: any) =>
d instanceof Date ? d.toISOString().split("T")[0] : String(d);
return NextResponse.json({
stats: {
activeSessions: activeSessions[0]?.count || 0,
loginsToday: loginsToday[0]?.count || 0,
failedToday: failedToday[0]?.count || 0,
signupsWeek: signupsWeek[0]?.count || 0,
},
loginsByDay: loginsByDay.map((r: any) => ({
date: formatDate(r.date),
count: r.count,
})),
failedByDay: failedByDay.map((r: any) => ({
date: formatDate(r.date),
count: r.count,
})),
events: events.map((e: any) => ({
id: e.id,
action: e.action,
email: e.email || "unknown",
userName: e.user_name,
familyName: e.family_name,
ipAddress: e.ip_address,
userAgent: e.user_agent,
metadata: e.metadata,
createdAt: e.created_at instanceof Date ? e.created_at.toISOString() : e.created_at,
})),
});
} catch (error) {
console.error("Admin activity error:", error);
return NextResponse.json({ error: String(error) }, { status: 500 });
}
}

View file

@ -26,6 +26,10 @@ export async function GET(request: Request) {
// Get chat session counts
const chatSessionsCount = await sql`SELECT COUNT(*) as count FROM chat_sessions`;
// Get family count for averages
const familyCountResult = await sql`SELECT COUNT(*)::int as count FROM families`;
const familyCount = familyCountResult[0]?.count || 1;
// Get logs by day
const logsByDay = await sql`
SELECT DATE(created_at) as date, COUNT(*) as count
@ -52,6 +56,7 @@ export async function GET(request: Request) {
totalGrowth: Number(growthCount[0]?.count || 0),
totalMemories: Number(memoriesCount[0]?.count || 0),
totalChatSessions: Number(chatSessionsCount[0]?.count || 0),
familyCount,
logsByDay: logsByDay.map((r: any) => ({
date: r.date?.toISOString().split("T")[0],
count: Number(r.count),

View file

@ -0,0 +1,30 @@
import { NextResponse } from "next/server";
import { requireAdmin } from "@/lib/admin-auth";
import { sql } from "@/db";
// Apply default limits to all free-tier families
export async function POST(request: Request) {
const auth = await requireAdmin(request);
if (!auth.success) return NextResponse.json({ error: auth.error }, { status: auth.status });
try {
const body = await request.json();
const { maxChildren, maxMembers } = body;
if (!maxChildren || !maxMembers) {
return NextResponse.json({ error: "maxChildren and maxMembers required" }, { status: 400 });
}
const result = await sql`
UPDATE families
SET max_children = ${maxChildren}, max_members = ${maxMembers}, updated_at = NOW()
WHERE tier = 'free'
RETURNING id
`;
return NextResponse.json({ success: true, updated: result.length });
} catch (error) {
console.error("Limits update error:", error);
return NextResponse.json({ error: String(error) }, { status: 500 });
}
}

View file

@ -6,29 +6,93 @@ export async function GET(request: Request) {
const auth = await requireAdmin(request);
if (!auth.success) return NextResponse.json({ error: auth.error }, { status: auth.status });
const { searchParams } = new URL(request.url);
const period = parseInt(searchParams.get("period") || "30");
try {
const familyCount = await sql`SELECT COUNT(*)::int as count FROM families`;
const userCount = await sql`SELECT COUNT(*)::int as count FROM users`;
const childCount = await sql`SELECT COUNT(*)::int as count FROM children`;
const tierStats = await sql`SELECT tier, COUNT(*)::int as count FROM families GROUP BY tier`;
const [familyCount, userCount, childCount, tierStats, activeSessionsResult] = await Promise.all([
sql`SELECT COUNT(*)::int as count FROM families`,
sql`SELECT COUNT(*)::int as count FROM users`,
sql`SELECT COUNT(*)::int as count FROM children`,
sql`SELECT tier, COUNT(*)::int as count FROM families GROUP BY tier`,
sql`SELECT COUNT(*)::int as count FROM sessions WHERE expires > NOW()`,
]);
const proFamilies = tierStats.find((t: any) => t.tier === "pro")?.count || 0;
const freeFamilies = tierStats.find((t: any) => t.tier === "free")?.count || 0;
const totalFamilies = familyCount[0]?.count || 0;
const mrr = proFamilies * 9.99;
const [familiesByDay, usersByDay, childrenByAge, recentLogins, failedLogins] = await Promise.all([
sql`
SELECT DATE(created_at) as date, COUNT(*)::int as count
FROM families
WHERE created_at > NOW() - (${period}::int * INTERVAL '1 day')
GROUP BY DATE(created_at)
ORDER BY date
`,
sql`
SELECT DATE(created_at) as date, COUNT(*)::int as count
FROM users
WHERE created_at > NOW() - (${period}::int * INTERVAL '1 day')
GROUP BY DATE(created_at)
ORDER BY date
`,
sql`
SELECT
EXTRACT(YEAR FROM AGE(birth_date))::int as age_years,
COUNT(*)::int as count
FROM children
WHERE birth_date IS NOT NULL
GROUP BY EXTRACT(YEAR FROM AGE(birth_date))::int
ORDER BY age_years
`,
sql`SELECT COUNT(*)::int as count FROM audit_log WHERE action = 'login' AND created_at > NOW() - INTERVAL '24 hours'`,
sql`SELECT COUNT(*)::int as count FROM audit_log WHERE action = 'login_failed' AND created_at > NOW() - INTERVAL '24 hours'`,
]);
const formatDate = (d: any) =>
d instanceof Date ? d.toISOString().split("T")[0] : String(d);
const ageLabel = (years: number) => {
if (years === 0) return "< 1 yr";
if (years === 1) return "1 yr";
return `${years} yrs`;
};
return NextResponse.json({
overview: {
totalFamilies: familyCount[0]?.count || 0,
totalFamilies,
totalUsers: userCount[0]?.count || 0,
totalChildren: childCount[0]?.count || 0,
proFamilies,
freeFamilies,
mrr,
avgRevenuePerUser: 0,
avgRevenuePerUser: totalFamilies > 0 ? parseFloat((mrr / totalFamilies).toFixed(2)) : 0,
activeSessions: activeSessionsResult[0]?.count || 0,
loginsLast24h: recentLogins[0]?.count || 0,
failedLoginsLast24h: failedLogins[0]?.count || 0,
},
conversions: { freeToPro: 0, conversionRate: 0 },
growth: { familiesByDay: [], usersByDay: [] },
childrenByAge: [],
conversions: {
freeToPro: proFamilies,
conversionRate: totalFamilies > 0
? parseFloat(((proFamilies / totalFamilies) * 100).toFixed(1))
: 0,
},
growth: {
familiesByDay: familiesByDay.map((r: any) => ({
date: formatDate(r.date),
count: r.count,
})),
usersByDay: usersByDay.map((r: any) => ({
date: formatDate(r.date),
count: r.count,
})),
},
childrenByAge: childrenByAge.map((r: any) => ({
ageGroup: ageLabel(r.age_years),
count: r.count,
})),
});
} catch (error) {
console.error("Admin stats error:", error);

View file

@ -2,33 +2,44 @@ import { NextResponse } from "next/server";
import { requireAdmin } from "@/lib/admin-auth";
import { sql } from "@/db";
// Get tickets or responses for a specific ticket
export async function GET(request: Request) {
const auth = await requireAdmin(request);
if (!auth.success) return NextResponse.json({ error: auth.error }, { status: auth.status });
try {
const { searchParams } = new URL(request.url);
const ticketId = searchParams.get("ticketId");
const status = searchParams.get("status") || "all";
let tickets;
if (status === "all") {
tickets = await sql`
SELECT t.*, f.name as family_name
FROM support_tickets t
LEFT JOIN families f ON f.id = t.family_id
ORDER BY t.created_at DESC
`;
} else {
tickets = await sql`
SELECT t.*, f.name as family_name
FROM support_tickets t
LEFT JOIN families f ON f.id = t.family_id
WHERE t.status = ${status}
ORDER BY t.created_at DESC
if (ticketId) {
const responses = await sql`
SELECT * FROM support_responses WHERE ticket_id = ${ticketId} ORDER BY created_at ASC
`;
return NextResponse.json({
responses: responses.map((r: any) => ({
id: r.id,
message: r.message,
createdAt: r.created_at instanceof Date ? r.created_at.toISOString() : r.created_at,
})),
});
}
const tickets = status === "all"
? await sql`
SELECT t.*, f.name as family_name
FROM support_tickets t
LEFT JOIN families f ON f.id = t.family_id
ORDER BY t.created_at DESC
`
: await sql`
SELECT t.*, f.name as family_name
FROM support_tickets t
LEFT JOIN families f ON f.id = t.family_id
WHERE t.status = ${status}
ORDER BY t.created_at DESC
`;
return NextResponse.json({
tickets: tickets.map((t: any) => ({
id: t.id,
@ -37,7 +48,7 @@ export async function GET(request: Request) {
description: t.description,
status: t.status,
priority: t.priority,
createdAt: t.created_at?.toISOString(),
createdAt: t.created_at instanceof Date ? t.created_at.toISOString() : t.created_at,
familyName: t.family_name,
})),
});
@ -53,44 +64,48 @@ export async function PATCH(request: Request) {
if (!auth.success) return NextResponse.json({ error: auth.error }, { status: auth.status });
try {
const body = await request.json();
const { ticketId, status } = body;
if (!ticketId) return NextResponse.json({ error: "ticketId required" }, { status: 400 });
if (!ticketId) {
return NextResponse.json({ error: "ticketId required" }, { status: 400 });
}
await sql`
UPDATE support_tickets
SET status = ${status}, updated_at = NOW()
WHERE id = ${ticketId}
`;
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)
// Create ticket or admin response
export async function POST(request: Request) {
const auth = await requireAdmin(request);
if (!auth.success) return NextResponse.json({ error: auth.error }, { status: auth.status });
try {
const body = await request.json();
const { familyId, userId, email, subject, description, priority } = body;
// Admin reply to a ticket
if (body.ticketId && body.message) {
const { ticketId, message } = body;
await sql`
INSERT INTO support_responses (id, ticket_id, message, created_at)
VALUES (${crypto.randomUUID()}, ${ticketId}, ${message}, NOW())
`;
await sql`
UPDATE support_tickets SET status = 'in_progress', updated_at = NOW()
WHERE id = ${ticketId} AND status = 'open'
`;
return NextResponse.json({ success: true });
}
// Create a new ticket
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")}
INSERT INTO support_tickets (id, family_id, user_id, email, subject, description, priority, created_at, updated_at)
VALUES (${crypto.randomUUID()}, ${familyId || null}, ${userId || null}, ${email}, ${subject}, ${description || ""}, ${priority || "normal"}, NOW(), NOW())
`;
return NextResponse.json({ success: true });
} catch (error) {
return NextResponse.json({ error: String(error) }, { status: 500 });