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[] = [ const navItems: NavItem[] = [
{ name: "Dashboard", href: "/admin", icon: "📊" }, { name: "Dashboard", href: "/admin", icon: "📊" },
{ name: "Activity", href: "/admin/activity", icon: "🔍" },
{ name: "Families", href: "/admin/families", icon: "🏠" }, { name: "Families", href: "/admin/families", icon: "🏠" },
{ name: "Users", href: "/admin/users", icon: "👥" }, { name: "Users", href: "/admin/users", icon: "👥" },
{ name: "Children", href: "/admin/children", 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; totalGrowth: number;
totalMemories: number; totalMemories: number;
totalChatSessions: number; totalChatSessions: number;
familyCount: number;
logsByDay: { date: string; count: number }[]; logsByDay: { date: string; count: number }[];
topFeatures: { name: string; count: number }[]; topFeatures: { name: string; count: number }[];
} }
@ -100,7 +101,9 @@ export default function AdminAnalytics() {
<div className="flex justify-between items-center"> <div className="flex justify-between items-center">
<span className="text-gray-400">Avg Logs per Family</span> <span className="text-gray-400">Avg Logs per Family</span>
<span className="font-bold"> <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> </span>
</div> </div>
<div className="flex justify-between items-center"> <div className="flex justify-between items-center">

View file

@ -29,7 +29,8 @@ export default function AdminFamilies() {
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
const [tierFilter, setTierFilter] = useState("all"); const [tierFilter, setTierFilter] = useState("all");
const [showMembers, setShowMembers] = useState<string | null>(null); 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(() => { useEffect(() => {
fetchFamilies(); fetchFamilies();
@ -39,9 +40,7 @@ export default function AdminFamilies() {
try { try {
const res = await fetch("/api/admin/families", { credentials: "include" }); const res = await fetch("/api/admin/families", { credentials: "include" });
const data = await res.json(); const data = await res.json();
if (data.error) { if (data.error) console.error("API error:", data.error);
console.error("API error:", data.error);
}
setFamilies(data.families || []); setFamilies(data.families || []);
} catch (err) { } catch (err) {
console.error("Failed to fetch families:", 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 filteredFamilies = families.filter((f) => {
const matchesSearch = f.name.toLowerCase().includes(search.toLowerCase()); const matchesSearch = f.name.toLowerCase().includes(search.toLowerCase());
const matchesTier = tierFilter === "all" || f.tier === tierFilter; const matchesTier = tierFilter === "all" || f.tier === tierFilter;
@ -105,13 +129,7 @@ export default function AdminFamilies() {
const exportCSV = () => { const exportCSV = () => {
const headers = ["Name", "Tier", "Users", "Children", "Max Children", "Max Members", "Created"]; const headers = ["Name", "Tier", "Users", "Children", "Max Children", "Max Members", "Created"];
const rows = filteredFamilies.map((f) => [ const rows = filteredFamilies.map((f) => [
f.name, f.name, f.tier, f.userCount, f.childCount, f.maxChildren, f.maxMembers, f.createdAt,
f.tier,
f.userCount,
f.childCount,
f.maxChildren,
f.maxMembers,
f.createdAt,
]); ]);
const csv = [headers, ...rows].map((row) => row.join(",")).join("\n"); const csv = [headers, ...rows].map((row) => row.join(",")).join("\n");
const blob = new Blob([csv], { type: "text/csv" }); const blob = new Blob([csv], { type: "text/csv" });
@ -122,18 +140,19 @@ export default function AdminFamilies() {
a.click(); a.click();
}; };
if (loading) { if (loading) return <div className="p-6 text-white">Loading...</div>;
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 ( return (
<div className="p-6 space-y-4"> <div className="p-6 space-y-4">
<div className="flex justify-between items-center"> <div className="flex justify-between items-center">
<div> <div>
<h1 className="text-2xl font-bold">Families</h1> <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>
<div className="flex gap-2"> <div className="flex gap-2">
<Button onClick={handleCreateFamily}>+ New Family</Button> <Button onClick={handleCreateFamily}>+ New Family</Button>
@ -142,7 +161,7 @@ export default function AdminFamilies() {
</div> </div>
{/* Filters */} {/* Filters */}
<div className="flex gap-4"> <div className="flex gap-3">
<Input <Input
type="text" type="text"
placeholder="Search families..." placeholder="Search families..."
@ -150,10 +169,7 @@ export default function AdminFamilies() {
onChange={(e) => setSearch(e.target.value)} onChange={(e) => setSearch(e.target.value)}
className="flex-1" className="flex-1"
/> />
<Select <Select value={tierFilter} onChange={(e) => setTierFilter(e.target.value)}>
value={tierFilter}
onChange={(e) => setTierFilter(e.target.value)}
>
<option value="all">All Tiers</option> <option value="all">All Tiers</option>
<option value="free">Free</option> <option value="free">Free</option>
<option value="pro">Pro</option> <option value="pro">Pro</option>
@ -167,10 +183,11 @@ export default function AdminFamilies() {
<tr> <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">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">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">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">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">Created</th>
<th className="px-4 py-3 text-left text-sm font-medium">Actions</th>
</tr> </tr>
</thead> </thead>
<tbody className="divide-y divide-gray-700"> <tbody className="divide-y divide-gray-700">
@ -179,49 +196,84 @@ export default function AdminFamilies() {
<tr key={family.id} className="hover:bg-gray-750"> <tr key={family.id} className="hover:bg-gray-750">
<td className="px-4 py-3"> <td className="px-4 py-3">
<div className="font-medium">{family.name}</div> <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>
<td className="px-4 py-3"> <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>
<td className="px-4 py-3"> <td className="px-4 py-3">
<Button variant="ghost" size="sm" onClick={() => setShowMembers(showMembers === family.id ? null : family.id)}> <Button
{family.userCount} users variant="ghost"
size="sm"
onClick={() => setShowMembers(showMembers === family.id ? null : family.id)}
>
{family.userCount} users {showMembers === family.id ? "▲" : "▼"}
</Button> </Button>
</td> </td>
<td className="px-4 py-3">{family.childCount}</td> <td className="px-4 py-3 text-gray-300">{family.childCount}</td>
<td className="px-4 py-3 text-sm text-gray-400"> <td className="px-4 py-3 text-sm text-gray-500">
{family.maxChildren} kids, {family.maxMembers} members {family.maxChildren} kids · {family.maxMembers} members
</td> </td>
<td className="px-4 py-3 text-sm text-gray-400"> <td className="px-4 py-3 text-sm text-gray-400">
{family.createdAt?.slice(0, 10)} {family.createdAt?.slice(0, 10)}
</td> </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> </tr>
{showMembers === family.id && ( {showMembers === family.id && (
<tr> <tr key={`${family.id}-members`}>
<td colSpan={6} className="bg-gray-800 p-4"> <td colSpan={7} className="bg-gray-900 px-6 py-4">
<div className="space-y-2"> <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"> <div className="flex gap-2 items-center">
<Input <Input
type="email" type="email"
placeholder="New member email" placeholder="Add member by email"
onChange={(e) => setAddMember({familyId: family.id, email: e.target.value, role: "caregiver", name: ""})} onChange={(e) =>
className="flex-1" setAddMember({ familyId: family.id, email: e.target.value, role: "caregiver", name: "" })
}
className="flex-1 max-w-xs"
/> />
<Select <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="caregiver">Caregiver</option>
<option value="owner">Owner</option> <option value="owner">Owner</option>
</Select> </Select>
<Button size="sm" onClick={handleAddMember}>Add</Button> <Button size="sm" onClick={handleAddMember}>Add</Button>
</div> </div>
{(family.members || []).map((m) => ( <div className="space-y-1 mt-2">
<div key={m.id} className="flex justify-between items-center bg-gray-700 p-2 rounded"> {(family.members || []).map((m) => (
<span>{m.email} ({m.role})</span> <div key={m.id} className="flex justify-between items-center bg-gray-800 px-3 py-2 rounded-lg">
<Button variant="danger" size="sm" onClick={() => handleRemoveMember(m.id)}>Remove</Button> <div>
</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> </div>
</td> </td>
</tr> </tr>

View file

@ -2,6 +2,7 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { Select } from "@/components/ui"; import { Select } from "@/components/ui";
import Link from "next/link";
interface Stats { interface Stats {
overview: { overview: {
@ -12,6 +13,9 @@ interface Stats {
freeFamilies: number; freeFamilies: number;
mrr: number; mrr: number;
avgRevenuePerUser: number; avgRevenuePerUser: number;
activeSessions: number;
loginsLast24h: number;
failedLoginsLast24h: number;
}; };
conversions: { conversions: {
freeToPro: number; freeToPro: number;
@ -34,8 +38,9 @@ export default function AdminDashboard() {
}, [period]); }, [period]);
const fetchStats = async () => { const fetchStats = async () => {
setLoading(true);
try { 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(); const data = await res.json();
setStats(data); setStats(data);
} catch (err) { } catch (err) {
@ -46,12 +51,17 @@ export default function AdminDashboard() {
if (loading || !stats) { if (loading || !stats) {
return ( return (
<div className="min-h-screen flex items-center justify-center bg-gray-900"> <div className="p-6 space-y-6 animate-pulse">
<div className="text-white">Loading...</div> <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> </div>
); );
} }
const { overview, conversions, growth, childrenByAge } = stats;
return ( return (
<div className="p-6 space-y-6"> <div className="p-6 space-y-6">
{/* Header */} {/* Header */}
@ -67,68 +77,70 @@ export default function AdminDashboard() {
</Select> </Select>
</div> </div>
{/* Overview Cards */} {/* Primary Stat Cards */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4"> <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 <StatCard
label="Total Families" label="Active Sessions"
value={stats.overview.totalFamilies} value={overview.activeSessions}
icon="🏠" 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="💰"
color="emerald" 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> </div>
{/* Revenue & Tier Stats */} {/* Revenue & Conversions */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4"> <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="bg-gray-800 p-6 rounded-xl"> <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 className="grid grid-cols-2 gap-4">
<div> <div>
<div className="text-2xl font-bold text-emerald-400">${stats.overview.mrr.toFixed(2)}</div> <div className="text-2xl font-bold text-emerald-400">${overview.mrr.toFixed(2)}</div>
<div className="text-gray-400 text-sm">Monthly Recurring Revenue</div> <div className="text-gray-400 text-sm">Monthly Recurring</div>
</div> </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 className="text-gray-400 text-sm">Pro Families</div>
</div> </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 className="text-gray-400 text-sm">Free Families</div>
</div> </div>
<div> <div>
<div className="text-2xl font-bold text-amber-400">${stats.overview.avgRevenuePerUser}</div> <div className="text-2xl font-bold text-amber-400">${overview.avgRevenuePerUser}</div>
<div className="text-gray-400 text-sm">Avg Revenue per Family</div> <div className="text-gray-400 text-sm">Avg per Family</div>
</div> </div>
</div> </div>
</div> </div>
<div className="bg-gray-800 p-6 rounded-xl"> <div className="bg-gray-800 p-6 rounded-xl">
<h3 className="text-lg font-semibold mb-4">Conversions</h3> <h3 className="text-sm font-semibold text-gray-400 mb-4">CONVERSION & ACTIVITY</h3>
<div className="flex items-center justify-center h-32"> <div className="grid grid-cols-2 gap-4">
<div className="text-center"> <div className="flex flex-col items-center justify-center bg-gray-700 rounded-xl p-4">
<div className="text-4xl font-bold text-rose-400">{stats.conversions.conversionRate}%</div> <div className="text-3xl font-bold text-rose-400">{conversions.conversionRate}%</div>
<div className="text-gray-400">Free Pro Conversion Rate</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> </div>
</div> </div>
@ -136,82 +148,124 @@ export default function AdminDashboard() {
{/* Growth Charts */} {/* Growth Charts */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4"> <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<ChartCard <ChartCard title="New Families" data={growth.familiesByDay} icon="📈" color="rose" />
title="New Families" <ChartCard title="New Users" data={growth.usersByDay} icon="👥" color="blue" />
data={stats.growth.familiesByDay}
icon="📈"
/>
<ChartCard
title="New Users"
data={stats.growth.usersByDay}
icon="👥"
/>
</div> </div>
{/* Children by Age */} {/* Children by Age */}
{stats.childrenByAge.length > 0 && ( {childrenByAge.length > 0 && (
<div className="bg-gray-800 p-6 rounded-xl"> <div className="bg-gray-800 p-6 rounded-xl">
<h3 className="text-lg font-semibold mb-4">Children by Age Group</h3> <h3 className="text-sm font-semibold text-gray-400 mb-4">CHILDREN BY AGE GROUP</h3>
<div className="flex flex-wrap gap-4"> <div className="flex flex-wrap gap-3">
{stats.childrenByAge.map((item) => ( {childrenByAge.map((item) => (
<div key={item.ageGroup} className="bg-gray-700 px-4 py-2 rounded-lg"> <div key={item.ageGroup} className="bg-gray-700 px-5 py-3 rounded-xl text-center">
<div className="text-xl font-bold">{item.count}</div> <div className="text-2xl font-bold text-amber-400">{item.count}</div>
<div className="text-xs text-gray-400">{item.ageGroup} years</div> <div className="text-xs text-gray-400 mt-0.5">{item.ageGroup}</div>
</div> </div>
))} ))}
</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> </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> = { const colorClasses: Record<string, string> = {
rose: "text-rose-400", rose: "text-rose-400",
blue: "text-blue-400", blue: "text-blue-400",
amber: "text-amber-400", amber: "text-amber-400",
emerald: "text-emerald-400", emerald: "text-emerald-400",
gray: "text-gray-500",
}; };
const content = ( const card = (
<div className="bg-gray-800 p-6 rounded-xl"> <div className="bg-gray-800 p-5 rounded-xl hover:bg-gray-750 transition-colors h-full">
<div className="flex items-center justify-between mb-2"> <div className="text-xl mb-2">{icon}</div>
<span className="text-2xl">{icon}</span> <div className={`text-2xl font-bold ${colorClasses[color] || "text-white"}`}>{value}</div>
</div> <div className="text-gray-400 text-xs mt-1">{label}</div>
<div className={`text-3xl font-bold ${colorClasses[color]}`}>{value}</div>
<div className="text-gray-400 text-sm">{label}</div>
</div> </div>
); );
if (href) { 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 maxCount = Math.max(...data.map((d) => d.count), 1);
const barColor = color === "rose" ? "bg-rose-500" : "bg-blue-500";
return ( return (
<div className="bg-gray-800 p-6 rounded-xl"> <div className="bg-gray-800 p-6 rounded-xl">
<h3 className="text-lg font-semibold mb-4">{icon} {title}</h3> <h3 className="text-sm font-semibold text-gray-400 mb-4">
<div className="h-40 flex items-end gap-1"> {icon} {title.toUpperCase()}
</h3>
<div className="h-36 flex items-end gap-1">
{data.slice(-14).map((d, i) => ( {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 <div className="relative w-full">
className="w-full bg-rose-500 rounded-t" {d.count > 0 && (
style={{ height: `${(d.count / maxCount) * 100}%`, minHeight: d.count > 0 ? "4px" : "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 className="text-[8px] text-gray-500 truncate w-full text-center"> </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) || ""} {d.date?.slice(5) || ""}
</div> </div>
</div> </div>
))} ))}
</div> </div>
{data.length === 0 && ( {data.length === 0 && (
<div className="h-40 flex items-center justify-center text-gray-500"> <div className="h-36 flex items-center justify-center text-gray-600 text-sm">
No data available No activity in this period
</div> </div>
)} )}
</div> </div>

View file

@ -1,10 +1,12 @@
"use client"; "use client";
import { useState } from "react"; import { useEffect, useState } from "react";
import { Button, Input } from "@/components/ui"; import { Button, Input } from "@/components/ui";
interface Settings { interface PlatformInfo {
proPrice: number; proFamilies: number;
freeFamilies: number;
totalUsers: number;
freeMaxChildren: number; freeMaxChildren: number;
freeMaxMembers: number; freeMaxMembers: number;
aiModel: string; aiModel: string;
@ -12,97 +14,177 @@ interface Settings {
} }
export default function AdminSettings() { export default function AdminSettings() {
const [settings, setSettings] = useState<Settings>({ const [info, setInfo] = useState<PlatformInfo | null>(null);
proPrice: 9.99, const [loading, setLoading] = useState(true);
freeMaxChildren: 1, const [freeMaxChildren, setFreeMaxChildren] = useState(1);
freeMaxMembers: 2, const [freeMaxMembers, setFreeMaxMembers] = useState(2);
aiModel: "minimax-2.7", const [saving, setSaving] = useState(false);
aiBaseUrl: "https://llm.manohargupta.com",
});
const [saved, setSaved] = useState(false); const [saved, setSaved] = useState(false);
const handleSave = async () => { useEffect(() => {
setSaved(true); fetchInfo();
setTimeout(() => setSaved(false), 2000); }, []);
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 ( return (
<div className="p-6 space-y-6"> <div className="p-6 space-y-6">
<div> <div>
<h1 className="text-2xl font-bold">Settings</h1> <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> </div>
{/* Pricing */} {/* Platform Summary */}
<div className="bg-gray-800 p-6 rounded-xl space-y-4"> <div className="grid grid-cols-3 gap-4">
<h3 className="text-lg font-semibold">Pricing</h3> <div className="bg-gray-800 p-4 rounded-xl text-center">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4"> <div className="text-2xl font-bold text-rose-400">{info.proFamilies}</div>
<div> <div className="text-xs text-gray-400 mt-1">Pro Families</div>
<label className="block text-sm text-gray-400 mb-1">Pro Monthly Price</label> </div>
<div className="flex items-center gap-2"> <div className="bg-gray-800 p-4 rounded-xl text-center">
<span className="text-gray-400">$</span> <div className="text-2xl font-bold text-gray-400">{info.freeFamilies}</div>
<Input <div className="text-xs text-gray-400 mt-1">Free Families</div>
type="number" </div>
step="0.01" <div className="bg-gray-800 p-4 rounded-xl text-center">
value={settings.proPrice} <div className="text-2xl font-bold text-blue-400">{info.totalUsers}</div>
onChange={(e) => setSettings({ ...settings, proPrice: Number(e.target.value) })} <div className="text-xs text-gray-400 mt-1">Total Users</div>
className="flex-1"
/>
</div>
</div>
</div> </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"> <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 className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div> <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 <Input
type="number" type="number"
value={settings.freeMaxChildren} min={1}
onChange={(e) => setSettings({ ...settings, freeMaxChildren: Number(e.target.value) })} max={10}
value={freeMaxChildren}
onChange={(e) => setFreeMaxChildren(Number(e.target.value))}
/> />
</div> </div>
<div> <div>
<label className="block text-sm text-gray-400 mb-1">Max Family Members</label> <label className="block text-sm text-gray-400 mb-1">Max Family Members</label>
<Input <Input
type="number" type="number"
value={settings.freeMaxMembers} min={1}
onChange={(e) => setSettings({ ...settings, freeMaxMembers: Number(e.target.value) })} max={10}
value={freeMaxMembers}
onChange={(e) => setFreeMaxMembers(Number(e.target.value))}
/> />
</div> </div>
</div> </div>
<Button onClick={handleSaveLimits} disabled={saving}>
{saved ? "✓ Saved" : saving ? "Saving…" : "Apply to All Free Families"}
</Button>
</div> </div>
{/* AI Settings */} {/* AI Configuration — read-only */}
<div className="bg-gray-800 p-6 rounded-xl space-y-4"> <div className="bg-gray-800 p-6 rounded-xl space-y-3">
<h3 className="text-lg font-semibold">AI Configuration</h3> <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="grid grid-cols-1 md:grid-cols-2 gap-4">
<div> <div className="bg-gray-700 px-4 py-3 rounded-lg">
<label className="block text-sm text-gray-400 mb-1">Model</label> <div className="text-xs text-gray-500 mb-1">Model</div>
<Input <div className="font-mono text-sm text-blue-400">{info.aiModel}</div>
type="text"
value={settings.aiModel}
onChange={(e) => setSettings({ ...settings, aiModel: e.target.value })}
/>
</div> </div>
<div> <div className="bg-gray-700 px-4 py-3 rounded-lg">
<label className="block text-sm text-gray-400 mb-1">Base URL</label> <div className="text-xs text-gray-500 mb-1">LiteLLM Gateway</div>
<Input <div className="font-mono text-sm text-blue-400 truncate">{info.aiBaseUrl}</div>
type="text" </div>
value={settings.aiBaseUrl} </div>
onChange={(e) => setSettings({ ...settings, aiBaseUrl: e.target.value })} <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>
</div> </div>
{/* Save */}
<Button fullWidth size="lg" onClick={handleSave}>
{saved ? "Saved!" : "Save Settings"}
</Button>
</div> </div>
); );
} }

View file

@ -14,16 +14,29 @@ interface Ticket {
familyName: string; familyName: string;
} }
interface Response {
id: string;
message: string;
createdAt: string;
}
export default function AdminSupport() { export default function AdminSupport() {
const [tickets, setTickets] = useState<Ticket[]>([]); const [tickets, setTickets] = useState<Ticket[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [statusFilter, setStatusFilter] = useState("all"); const [statusFilter, setStatusFilter] = useState("all");
const [selectedTicket, setSelectedTicket] = useState<Ticket | null>(null); const [selectedTicket, setSelectedTicket] = useState<Ticket | null>(null);
const [responses, setResponses] = useState<Response[]>([]);
const [replyText, setReplyText] = useState("");
const [replying, setReplying] = useState(false);
useEffect(() => { useEffect(() => {
fetchTickets(); fetchTickets();
}, [statusFilter]); }, [statusFilter]);
useEffect(() => {
if (selectedTicket) fetchResponses(selectedTicket.id);
}, [selectedTicket]);
const fetchTickets = async () => { const fetchTickets = async () => {
try { try {
const res = await fetch(`/api/admin/support?status=${statusFilter}`, { credentials: "include" }); const res = await fetch(`/api/admin/support?status=${statusFilter}`, { credentials: "include" });
@ -35,115 +48,226 @@ export default function AdminSupport() {
setLoading(false); 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) => { const updateStatus = async (ticketId: string, status: string) => {
try { try {
await fetch(`/api/admin/support`, { await fetch("/api/admin/support", {
method: "PATCH", method: "PATCH",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
credentials: "include", credentials: "include",
body: JSON.stringify({ ticketId, status }), body: JSON.stringify({ ticketId, status }),
}); });
fetchTickets(); fetchTickets();
if (selectedTicket?.id === ticketId) {
setSelectedTicket(prev => prev ? { ...prev, status } : null);
}
} catch (err) { } catch (err) {
console.error("Failed to update ticket:", 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" => const priorityVariant = (p: string): "rose" | "warning" | "default" =>
p === "urgent" ? "rose" : p === "high" ? "warning" : "default"; p === "urgent" ? "rose" : p === "high" ? "warning" : "default";
const statusVariant = (s: string): "rose" | "warning" | "default" => const statusVariant = (s: string): "rose" | "warning" | "default" =>
s === "open" ? "rose" : s === "in_progress" ? "warning" : "default"; s === "open" ? "rose" : s === "in_progress" ? "warning" : "default";
if (loading) { const statusCounts = tickets.reduce((acc: Record<string, number>, t) => {
return <div className="p-6 text-white">Loading...</div>; 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 ( return (
<div className="p-6 space-y-4"> <div className="p-6 space-y-4">
<div className="flex justify-between items-center"> <div className="flex justify-between items-center">
<div> <div>
<h1 className="text-2xl font-bold">Support</h1> <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>
</div> </div>
{/* Filters */} {/* Status Filter */}
<div className="flex gap-2"> <div className="flex gap-2 flex-wrap">
{["all", "open", "in_progress", "resolved", "closed"].map((status) => ( {[
{ 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 <Button
key={status} key={key}
size="sm" size="sm"
variant={statusFilter === status ? "primary" : "secondary"} variant={statusFilter === key ? "primary" : "secondary"}
onClick={() => setStatusFilter(status)} onClick={() => setStatusFilter(key)}
> >
{status.replace("_", " ")} {label}
</Button> </Button>
))} ))}
</div> </div>
{/* Tickets List */} <div className="grid grid-cols-1 md:grid-cols-5 gap-4" style={{ height: "calc(100vh - 280px)", minHeight: "400px" }}>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4"> {/* Ticket List */}
<div className="space-y-2"> <div className="md:col-span-2 space-y-2 overflow-y-auto pr-1">
{tickets.map((ticket) => ( {tickets.map((ticket) => (
<div <div
key={ticket.id} key={ticket.id}
onClick={() => setSelectedTicket(ticket)} onClick={() => { setSelectedTicket(ticket); setResponses([]); }}
className={`p-4 rounded-xl cursor-pointer ${ className={`p-4 rounded-xl cursor-pointer transition-colors ${
selectedTicket?.id === ticket.id ? "bg-rose-500/20 border border-rose-500" : "bg-gray-800" 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={priorityVariant(ticket.priority)}>{ticket.priority}</Badge>
<Badge variant={statusVariant(ticket.status)}>{ticket.status.replace("_", " ")}</Badge> <Badge variant={statusVariant(ticket.status)}>{ticket.status.replace("_", " ")}</Badge>
</div> </div>
<div className="font-medium">{ticket.subject}</div> <div className="font-medium text-sm">{ticket.subject}</div>
<div className="text-sm text-gray-400">{ticket.email}</div> <div className="text-xs text-gray-400 mt-0.5">{ticket.email}</div>
<div className="text-xs text-gray-500 mt-2"> {ticket.familyName && (
{ticket.createdAt?.slice(0, 10)} <div className="text-xs text-gray-600 mt-0.5">Family: {ticket.familyName}</div>
</div> )}
<div className="text-xs text-gray-600 mt-1.5">{timeAgo(ticket.createdAt)}</div>
</div> </div>
))} ))}
{tickets.length === 0 && ( {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> </div>
{/* Ticket Detail */} {/* Ticket Detail + Reply */}
<div className="bg-gray-800 rounded-xl p-4"> <div className="md:col-span-3 bg-gray-800 rounded-xl flex flex-col overflow-hidden">
{selectedTicket ? ( {selectedTicket ? (
<div className="space-y-4"> <>
<div> {/* Ticket header */}
<div className="font-medium text-lg">{selectedTicket.subject}</div> <div className="p-4 border-b border-gray-700">
<div className="text-sm text-gray-400">{selectedTicket.email}</div> <div className="flex justify-between items-start">
<div className="text-xs text-gray-500"> <div>
{selectedTicket.createdAt?.slice(0, 10)} · {selectedTicket.familyName} <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> </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>
<div className="flex gap-2">
{selectedTicket.status === "open" && ( {/* Reply box */}
<Button size="sm" variant="secondary" onClick={() => updateStatus(selectedTicket.id, "in_progress")}> {selectedTicket.status !== "closed" && (
Start <div className="p-3 border-t border-gray-700">
</Button> <textarea
)} value={replyText}
{selectedTicket.status === "in_progress" && ( onChange={(e) => setReplyText(e.target.value)}
<Button size="sm" onClick={() => updateStatus(selectedTicket.id, "resolved")}> placeholder="Type your reply…"
Resolve rows={3}
</Button> 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) => {
{selectedTicket.status === "resolved" && ( if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) handleReply();
<Button size="sm" variant="secondary" onClick={() => updateStatus(selectedTicket.id, "closed")}> }}
Close />
</Button> <div className="flex justify-between items-center mt-2">
)} <span className="text-xs text-gray-600">Cmd+Enter to send</span>
</div> <Button size="sm" onClick={handleReply} disabled={!replyText.trim() || replying}>
</div> {replying ? "Sending…" : "Send Reply"}
</Button>
</div>
</div>
)}
</>
) : ( ) : (
<div className="h-full flex items-center justify-center text-gray-500"> <div className="flex-1 flex items-center justify-center text-gray-600">
Select a ticket to view details Select a ticket to view the conversation
</div> </div>
)} )}
</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 // Get chat session counts
const chatSessionsCount = await sql`SELECT COUNT(*) as count FROM chat_sessions`; 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 // Get logs by day
const logsByDay = await sql` const logsByDay = await sql`
SELECT DATE(created_at) as date, COUNT(*) as count 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), totalGrowth: Number(growthCount[0]?.count || 0),
totalMemories: Number(memoriesCount[0]?.count || 0), totalMemories: Number(memoriesCount[0]?.count || 0),
totalChatSessions: Number(chatSessionsCount[0]?.count || 0), totalChatSessions: Number(chatSessionsCount[0]?.count || 0),
familyCount,
logsByDay: logsByDay.map((r: any) => ({ logsByDay: logsByDay.map((r: any) => ({
date: r.date?.toISOString().split("T")[0], date: r.date?.toISOString().split("T")[0],
count: Number(r.count), 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); const auth = await requireAdmin(request);
if (!auth.success) return NextResponse.json({ error: auth.error }, { status: auth.status }); 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 { try {
const familyCount = await sql`SELECT COUNT(*)::int as count FROM families`; const [familyCount, userCount, childCount, tierStats, activeSessionsResult] = await Promise.all([
const userCount = await sql`SELECT COUNT(*)::int as count FROM users`; sql`SELECT COUNT(*)::int as count FROM families`,
const childCount = await sql`SELECT COUNT(*)::int as count FROM children`; sql`SELECT COUNT(*)::int as count FROM users`,
const tierStats = await sql`SELECT tier, COUNT(*)::int as count FROM families GROUP BY tier`; 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 proFamilies = tierStats.find((t: any) => t.tier === "pro")?.count || 0;
const freeFamilies = tierStats.find((t: any) => t.tier === "free")?.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 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({ return NextResponse.json({
overview: { overview: {
totalFamilies: familyCount[0]?.count || 0, totalFamilies,
totalUsers: userCount[0]?.count || 0, totalUsers: userCount[0]?.count || 0,
totalChildren: childCount[0]?.count || 0, totalChildren: childCount[0]?.count || 0,
proFamilies, proFamilies,
freeFamilies, freeFamilies,
mrr, 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 }, conversions: {
growth: { familiesByDay: [], usersByDay: [] }, freeToPro: proFamilies,
childrenByAge: [], 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) { } catch (error) {
console.error("Admin stats error:", 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 { requireAdmin } from "@/lib/admin-auth";
import { sql } from "@/db"; import { sql } from "@/db";
// Get tickets or responses for a specific ticket
export async function GET(request: Request) { export async function GET(request: Request) {
const auth = await requireAdmin(request); const auth = await requireAdmin(request);
if (!auth.success) return NextResponse.json({ error: auth.error }, { status: auth.status }); if (!auth.success) return NextResponse.json({ error: auth.error }, { status: auth.status });
try { try {
const { searchParams } = new URL(request.url); const { searchParams } = new URL(request.url);
const ticketId = searchParams.get("ticketId");
const status = searchParams.get("status") || "all"; const status = searchParams.get("status") || "all";
let tickets; if (ticketId) {
if (status === "all") { const responses = await sql`
tickets = await sql` SELECT * FROM support_responses WHERE ticket_id = ${ticketId} ORDER BY created_at ASC
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
`; `;
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({ return NextResponse.json({
tickets: tickets.map((t: any) => ({ tickets: tickets.map((t: any) => ({
id: t.id, id: t.id,
@ -37,7 +48,7 @@ export async function GET(request: Request) {
description: t.description, description: t.description,
status: t.status, status: t.status,
priority: t.priority, 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, 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 }); if (!auth.success) return NextResponse.json({ error: auth.error }, { status: auth.status });
try { try {
const body = await request.json(); const body = await request.json();
const { ticketId, status } = body; const { ticketId, status } = body;
if (!ticketId) return NextResponse.json({ error: "ticketId required" }, { status: 400 });
if (!ticketId) { await sql`UPDATE support_tickets SET status = ${status}, updated_at = NOW() WHERE id = ${ticketId}`;
return NextResponse.json({ error: "ticketId required" }, { status: 400 });
}
await sql`
UPDATE support_tickets
SET status = ${status}, updated_at = NOW()
WHERE id = ${ticketId}
`;
return NextResponse.json({ success: true }); return NextResponse.json({ success: true });
} catch (error) { } catch (error) {
return NextResponse.json({ error: String(error) }, { status: 500 }); return NextResponse.json({ error: String(error) }, { status: 500 });
} }
} }
// Create ticket (for users) // Create ticket or admin response
export async function POST(request: Request) { export async function POST(request: Request) {
const auth = await requireAdmin(request); const auth = await requireAdmin(request);
if (!auth.success) return NextResponse.json({ error: auth.error }, { status: auth.status }); if (!auth.success) return NextResponse.json({ error: auth.error }, { status: auth.status });
try { try {
const body = await request.json(); 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) { if (!email || !subject) {
return NextResponse.json({ error: "email and subject required" }, { status: 400 }); return NextResponse.json({ error: "email and subject required" }, { status: 400 });
} }
await sql` await sql`
INSERT INTO support_tickets (family_id, user_id, email, subject, description, priority) INSERT INTO support_tickets (id, family_id, user_id, email, subject, description, priority, created_at, updated_at)
VALUES ${sql(familyId, userId, email, subject, description, priority || "normal")} VALUES (${crypto.randomUUID()}, ${familyId || null}, ${userId || null}, ${email}, ${subject}, ${description || ""}, ${priority || "normal"}, NOW(), NOW())
`; `;
return NextResponse.json({ success: true }); return NextResponse.json({ success: true });
} catch (error) { } catch (error) {
return NextResponse.json({ error: String(error) }, { status: 500 }); return NextResponse.json({ error: String(error) }, { status: 500 });