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:
parent
bcc167d7c2
commit
23010e9d90
12 changed files with 1075 additions and 283 deletions
|
|
@ -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: "👶" },
|
||||
|
|
|
|||
263
src/app/admin/activity/page.tsx
Normal file
263
src/app/admin/activity/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ export default function AdminFamilies() {
|
|||
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 [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">
|
||||
<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>
|
||||
<div className="space-y-1 mt-2">
|
||||
{(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 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>
|
||||
|
|
|
|||
|
|
@ -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 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 bg-rose-500 rounded-t"
|
||||
className={`w-full ${barColor} rounded-t opacity-80`}
|
||||
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>
|
||||
<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>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
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);
|
||||
|
||||
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",
|
||||
});
|
||||
const [saved, setSaved] = useState(false);
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaved(true);
|
||||
setTimeout(() => setSaved(false), 2000);
|
||||
} 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">
|
||||
{/* 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>
|
||||
|
||||
{/* 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>
|
||||
<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 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>
|
||||
|
||||
{/* Limits */}
|
||||
{/* Free Tier Limits — editable */}
|
||||
<div className="bg-gray-800 p-6 rounded-xl space-y-4">
|
||||
<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>
|
||||
</div>
|
||||
|
||||
{/* AI Settings */}
|
||||
<div className="bg-gray-800 p-6 rounded-xl space-y-4">
|
||||
<h3 className="text-lg font-semibold">AI Configuration</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm text-gray-400 mb-1">Model</label>
|
||||
<Input
|
||||
type="text"
|
||||
value={settings.aiModel}
|
||||
onChange={(e) => setSettings({ ...settings, aiModel: e.target.value })}
|
||||
/>
|
||||
</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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Save */}
|
||||
<Button fullWidth size="lg" onClick={handleSave}>
|
||||
{saved ? "Saved!" : "Save Settings"}
|
||||
<Button onClick={handleSaveLimits} disabled={saving}>
|
||||
{saved ? "✓ Saved" : saving ? "Saving…" : "Apply to All Free Families"}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 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 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 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>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,95 +48,154 @@ 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">
|
||||
<>
|
||||
{/* Ticket header */}
|
||||
<div className="p-4 border-b border-gray-700">
|
||||
<div className="flex justify-between items-start">
|
||||
<div>
|
||||
<div className="font-medium text-lg">{selectedTicket.subject}</div>
|
||||
<div className="font-semibold">{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}
|
||||
{selectedTicket.familyName && (
|
||||
<div className="text-xs text-gray-500 mt-0.5">Family: {selectedTicket.familyName}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-3 bg-gray-700 rounded-lg">
|
||||
{selectedTicket.description}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<div className="flex gap-2 flex-shrink-0">
|
||||
{selectedTicket.status === "open" && (
|
||||
<Button size="sm" variant="secondary" onClick={() => updateStatus(selectedTicket.id, "in_progress")}>
|
||||
Start
|
||||
|
|
@ -139,11 +211,63 @@ export default function AdminSupport() {
|
|||
Close
|
||||
</Button>
|
||||
)}
|
||||
{selectedTicket.status === "closed" && (
|
||||
<Button size="sm" variant="secondary" onClick={() => updateStatus(selectedTicket.id, "open")}>
|
||||
Reopen
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 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>
|
||||
|
||||
{/* 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>
|
||||
|
|
|
|||
99
src/app/api/admin/activity/route.ts
Normal file
99
src/app/api/admin/activity/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
|
|
@ -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),
|
||||
|
|
|
|||
30
src/app/api/admin/families/limits/route.ts
Normal file
30
src/app/api/admin/families/limits/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -2,32 +2,43 @@ 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`
|
||||
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
|
||||
`;
|
||||
} else {
|
||||
tickets = await sql`
|
||||
`
|
||||
: 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) => ({
|
||||
|
|
@ -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 });
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue