diff --git a/src/app/admin/AdminSidebar.tsx b/src/app/admin/AdminSidebar.tsx index d758e76..2f74abb 100644 --- a/src/app/admin/AdminSidebar.tsx +++ b/src/app/admin/AdminSidebar.tsx @@ -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: "πŸ‘Ά" }, diff --git a/src/app/admin/activity/page.tsx b/src/app/admin/activity/page.tsx new file mode 100644 index 0000000..7cd2907 --- /dev/null +++ b/src/app/admin/activity/page.tsx @@ -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 = { + 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(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 ( +
+
+
+
+ {[1, 2, 3, 4].map(i =>
)} +
+
+
+ ); + } + + 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 ( +
+ {/* Header */} +
+
+

Activity Monitor

+

Real-time login events and session tracking

+
+
+
+
+ +
+ +
+
+ + {/* Stat Cards */} +
+
+
{stats.activeSessions}
+
Active Sessions
+
Currently online
+
+
+
{stats.loginsToday}
+
Logins Today
+
Last 24 hours
+
+
+
0 ? "text-rose-400" : "text-gray-400"}`}> + {stats.failedToday} +
+
Failed Attempts
+
Last 24 hours
+
+
+
{stats.signupsWeek}
+
New Signups
+
Last 7 days
+
+
+ + {/* Charts */} +
+
+

LOGIN ACTIVITY β€” LAST 30 DAYS

+
+ {loginsByDay.slice(-30).map((d, i) => ( +
+
0 ? "3px" : "0" }} + /> +
{d.date?.slice(5) || ""}
+
+ ))} + {loginsByDay.length === 0 && ( +
+ No login data yet +
+ )} +
+
+
+

FAILED LOGINS β€” LAST 30 DAYS

+
+ {failedByDay.slice(-30).map((d, i) => ( +
+
0 ? "3px" : "0" }} + /> +
{d.date?.slice(5) || ""}
+
+ ))} + {failedByDay.length === 0 && ( +
+ No failed logins +
+ )} +
+
+
+ + {/* Events Table */} +
+ {/* Filter tabs */} +
+ {[ + { key: "all", label: "All Events" }, + { key: "login", label: "Logins" }, + { key: "login_failed", label: "Failures" }, + { key: "signup", label: "Signups" }, + ].map(({ key, label }) => ( + + ))} +
+ {events.length} events +
+
+ +
+ + + + + + + + + + + + + {events.map((event) => { + const config = ACTION_CONFIG[event.action] || { label: event.action, color: "text-gray-400", badge: "default" as const }; + return ( + + + + + + + + + ); + })} + +
TIMEEVENTUSERFAMILYIPDEVICE
+ {timeAgo(event.createdAt)} + + {config.label} + +
{event.email}
+ {event.userName && event.userName !== event.email && ( +
{event.userName}
+ )} +
{event.familyName || "β€”"}{event.ipAddress || "β€”"}{parseDevice(event.userAgent)}
+ {events.length === 0 && ( +
+ No events recorded yet +
+ )} +
+
+
+ ); +} diff --git a/src/app/admin/analytics/page.tsx b/src/app/admin/analytics/page.tsx index 806d8cf..c5b83b5 100644 --- a/src/app/admin/analytics/page.tsx +++ b/src/app/admin/analytics/page.tsx @@ -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() {
Avg Logs per Family - {stats.totalLogs > 0 ? (stats.totalLogs / 1).toFixed(1) : "0"} + {stats.totalLogs > 0 && stats.familyCount > 0 + ? (stats.totalLogs / stats.familyCount).toFixed(1) + : "0"}
diff --git a/src/app/admin/families/page.tsx b/src/app/admin/families/page.tsx index 2eeca15..f04fd77 100644 --- a/src/app/admin/families/page.tsx +++ b/src/app/admin/families/page.tsx @@ -29,7 +29,8 @@ export default function AdminFamilies() { const [search, setSearch] = useState(""); const [tierFilter, setTierFilter] = useState("all"); const [showMembers, setShowMembers] = useState(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(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 ( -
Loading...
- ); - } + if (loading) return
Loading...
; + + const proCount = families.filter(f => f.tier === "pro").length; + const freeCount = families.filter(f => f.tier === "free").length; return (

Families

-

{families.length} total families

+

+ {families.length} total Β· {proCount} Pro Β· {freeCount} Free +

@@ -142,7 +161,7 @@ export default function AdminFamilies() {
{/* Filters */} -
+
setSearch(e.target.value)} className="flex-1" /> - setTierFilter(e.target.value)}> @@ -167,10 +183,11 @@ export default function AdminFamilies() { Family Tier - Users + Members Children Limits Created + Actions @@ -179,49 +196,84 @@ export default function AdminFamilies() {
{family.name}
-
{family.id}
+
{family.id.slice(0, 8)}…
- {family.tier} +
+ {family.tier} +
- - {family.childCount} - - {family.maxChildren} kids, {family.maxMembers} members + {family.childCount} + + {family.maxChildren} kids Β· {family.maxMembers} members {family.createdAt?.slice(0, 10)} + + + {showMembers === family.id && ( - - + +
+
Members
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" />
- {(family.members || []).map((m) => ( -
- {m.email} ({m.role}) - -
- ))} +
+ {(family.members || []).map((m) => ( +
+
+ {m.email} + {m.role} +
+ +
+ ))} + {(family.members || []).length === 0 && ( +
No members yet
+ )} +
diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx index 4912345..74fe9cf 100644 --- a/src/app/admin/page.tsx +++ b/src/app/admin/page.tsx @@ -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 ( -
-
Loading...
+
+
+
+ {[1, 2, 3, 4, 5, 6].map(i =>
)} +
); } + const { overview, conversions, growth, childrenByAge } = stats; + return (
{/* Header */} @@ -67,68 +77,70 @@ export default function AdminDashboard() {
- {/* Overview Cards */} -
+ {/* Primary Stat Cards */} +
+ + + + - - - + 0 ? "rose" : "gray"} + href="/admin/activity" />
- {/* Revenue & Tier Stats */} + {/* Revenue & Conversions */}
-

Revenue Overview

+

REVENUE OVERVIEW

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

Conversions

-
-
-
{stats.conversions.conversionRate}%
-
Free β†’ Pro Conversion Rate
+

CONVERSION & ACTIVITY

+
+
+
{conversions.conversionRate}%
+
Pro Adoption Rate
+
+
+
+
{overview.loginsLast24h}
+
Logins (24h)
+
+
+
0 ? "text-rose-400" : "text-gray-500"}`}> + {overview.failedLoginsLast24h} +
+
Failed (24h)
+
@@ -136,84 +148,126 @@ export default function AdminDashboard() { {/* Growth Charts */}
- - + +
{/* Children by Age */} - {stats.childrenByAge.length > 0 && ( + {childrenByAge.length > 0 && (
-

Children by Age Group

-
- {stats.childrenByAge.map((item) => ( -
-
{item.count}
-
{item.ageGroup} years
+

CHILDREN BY AGE GROUP

+
+ {childrenByAge.map((item) => ( +
+
{item.count}
+
{item.ageGroup}
))}
)} + + {/* Quick Links */} +
+ {[ + { 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 }) => ( + + {icon} + {label} + + ))} +
); } -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 = { rose: "text-rose-400", blue: "text-blue-400", amber: "text-amber-400", emerald: "text-emerald-400", + gray: "text-gray-500", }; - const content = ( -
-
- {icon} -
-
{value}
-
{label}
+ const card = ( +
+
{icon}
+
{value}
+
{label}
); if (href) { - return {content}; + return {card}; } - 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 (
-

{icon} {title}

-
+

+ {icon} {title.toUpperCase()} +

+
{data.slice(-14).map((d, i) => ( -
-
0 ? "4px" : "0" }} - /> -
+
+
+ {d.count > 0 && ( +
+ {d.count} +
+ )} +
0 ? "4px" : "0" }} + /> +
+
{d.date?.slice(5) || ""}
))}
{data.length === 0 && ( -
- No data available +
+ No activity in this period
)}
); -} \ No newline at end of file +} diff --git a/src/app/admin/settings/page.tsx b/src/app/admin/settings/page.tsx index 5e1c042..7b68663 100644 --- a/src/app/admin/settings/page.tsx +++ b/src/app/admin/settings/page.tsx @@ -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({ - proPrice: 9.99, - freeMaxChildren: 1, - freeMaxMembers: 2, - aiModel: "minimax-2.7", - aiBaseUrl: "https://llm.manohargupta.com", - }); + const [info, setInfo] = useState(null); + const [loading, setLoading] = useState(true); + const [freeMaxChildren, setFreeMaxChildren] = useState(1); + const [freeMaxMembers, setFreeMaxMembers] = useState(2); + const [saving, setSaving] = useState(false); const [saved, setSaved] = useState(false); - const handleSave = async () => { - setSaved(true); - setTimeout(() => setSaved(false), 2000); + useEffect(() => { + fetchInfo(); + }, []); + + const fetchInfo = async () => { + try { + const res = await fetch("/api/admin/stats", { credentials: "include" }); + const data = await res.json(); + setFreeMaxChildren(1); + setFreeMaxMembers(2); + setInfo({ + proFamilies: data.overview?.proFamilies || 0, + freeFamilies: data.overview?.freeFamilies || 0, + totalUsers: data.overview?.totalUsers || 0, + freeMaxChildren: 1, + freeMaxMembers: 2, + aiModel: "minimax-2.7", + aiBaseUrl: "https://llm.manohargupta.com", + }); + } catch (err) { + console.error("Failed to fetch:", err); + } + setLoading(false); }; + const handleSaveLimits = async () => { + setSaving(true); + try { + // Apply new limits to all free-tier families + const res = await fetch("/api/admin/families/limits", { + method: "POST", + headers: { "Content-Type": "application/json" }, + credentials: "include", + body: JSON.stringify({ maxChildren: freeMaxChildren, maxMembers: freeMaxMembers }), + }); + if (res.ok) { + setSaved(true); + setTimeout(() => setSaved(false), 3000); + } + } catch (err) { + console.error("Failed to save:", err); + } + setSaving(false); + }; + + if (loading || !info) return
Loading...
; + return (

Settings

-

Platform configuration

+

Platform configuration and limits

- {/* Pricing */} -
-

Pricing

-
-
- -
- $ - setSettings({ ...settings, proPrice: Number(e.target.value) })} - className="flex-1" - /> -
-
+ {/* Platform Summary */} +
+
+
{info.proFamilies}
+
Pro Families
+
+
+
{info.freeFamilies}
+
Free Families
+
+
+
{info.totalUsers}
+
Total Users
- {/* Limits */} + {/* Pricing β€” read-only display */} +
+
+

Pricing

+ Set via env var +
+
+
+
Pro Monthly Price
+
$9.99 / month
+
+
+
Annual Run Rate
+
+ ${(info.proFamilies * 9.99 * 12).toFixed(0)} / year +
+
+
+

+ To change pricing, update PRO_PRICE in Dokploy dashboard and redeploy. +

+
+ + {/* Free Tier Limits β€” editable */}
-

Free Tier Limits

+
+

Free Tier Limits

+ Editable +
+

+ These limits are applied to all free-tier families. Existing families will be updated. +

- + setSettings({ ...settings, freeMaxChildren: Number(e.target.value) })} + min={1} + max={10} + value={freeMaxChildren} + onChange={(e) => setFreeMaxChildren(Number(e.target.value))} />
setSettings({ ...settings, freeMaxMembers: Number(e.target.value) })} + min={1} + max={10} + value={freeMaxMembers} + onChange={(e) => setFreeMaxMembers(Number(e.target.value))} />
+
- {/* AI Settings */} -
-

AI Configuration

+ {/* AI Configuration β€” read-only */} +
+
+

AI Configuration

+ Set via env var +
-
- - setSettings({ ...settings, aiModel: e.target.value })} - /> +
+
Model
+
{info.aiModel}
-
- - setSettings({ ...settings, aiBaseUrl: e.target.value })} - /> +
+
LiteLLM Gateway
+
{info.aiBaseUrl}
+
+
+

+ To change AI settings, update LITELLM_BASE_URL and LITELLM_API_KEY in Dokploy. +

+
+ + {/* Admin Access */} +
+

Admin Access

+

+ Admin login: /admin-login
+ Password changes must be made directly in the database via the admins table. +

+
+
Security Reminder
+
+ Change the default admin password immediately if still using admin123.
- - {/* Save */} -
); } diff --git a/src/app/admin/support/page.tsx b/src/app/admin/support/page.tsx index 19b9cac..6056df0 100644 --- a/src/app/admin/support/page.tsx +++ b/src/app/admin/support/page.tsx @@ -14,16 +14,29 @@ interface Ticket { familyName: string; } +interface Response { + id: string; + message: string; + createdAt: string; +} + export default function AdminSupport() { const [tickets, setTickets] = useState([]); const [loading, setLoading] = useState(true); const [statusFilter, setStatusFilter] = useState("all"); const [selectedTicket, setSelectedTicket] = useState(null); + const [responses, setResponses] = useState([]); + const [replyText, setReplyText] = useState(""); + const [replying, setReplying] = useState(false); useEffect(() => { fetchTickets(); }, [statusFilter]); + useEffect(() => { + if (selectedTicket) fetchResponses(selectedTicket.id); + }, [selectedTicket]); + const fetchTickets = async () => { try { const res = await fetch(`/api/admin/support?status=${statusFilter}`, { credentials: "include" }); @@ -35,115 +48,226 @@ export default function AdminSupport() { setLoading(false); }; + const fetchResponses = async (ticketId: string) => { + try { + const res = await fetch(`/api/admin/support?ticketId=${ticketId}`, { credentials: "include" }); + const data = await res.json(); + setResponses(data.responses || []); + } catch (err) { + console.error("Failed to fetch responses:", err); + } + }; + const updateStatus = async (ticketId: string, status: string) => { try { - await fetch(`/api/admin/support`, { + await fetch("/api/admin/support", { method: "PATCH", headers: { "Content-Type": "application/json" }, credentials: "include", body: JSON.stringify({ ticketId, status }), }); fetchTickets(); + if (selectedTicket?.id === ticketId) { + setSelectedTicket(prev => prev ? { ...prev, status } : null); + } } catch (err) { console.error("Failed to update ticket:", err); } }; + const handleReply = async () => { + if (!replyText.trim() || !selectedTicket) return; + setReplying(true); + try { + const res = await fetch("/api/admin/support", { + method: "POST", + headers: { "Content-Type": "application/json" }, + credentials: "include", + body: JSON.stringify({ ticketId: selectedTicket.id, message: replyText }), + }); + if (res.ok) { + setReplyText(""); + fetchResponses(selectedTicket.id); + fetchTickets(); + } + } catch (err) { + console.error("Failed to send reply:", err); + } + setReplying(false); + }; + const priorityVariant = (p: string): "rose" | "warning" | "default" => p === "urgent" ? "rose" : p === "high" ? "warning" : "default"; const statusVariant = (s: string): "rose" | "warning" | "default" => s === "open" ? "rose" : s === "in_progress" ? "warning" : "default"; - if (loading) { - return
Loading...
; + const statusCounts = tickets.reduce((acc: Record, 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
Loading...
; + return (

Support

-

{tickets.length} tickets

+

+ {tickets.length} tickets + {statusCounts["open"] ? ` Β· ` : ""} + {statusCounts["open"] ? {statusCounts["open"]} open : null} +

- {/* Filters */} -
- {["all", "open", "in_progress", "resolved", "closed"].map((status) => ( + {/* Status Filter */} +
+ {[ + { 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 }) => ( ))}
- {/* Tickets List */} -
-
+
+ {/* Ticket List */} +
{tickets.map((ticket) => (
setSelectedTicket(ticket)} - className={`p-4 rounded-xl cursor-pointer ${ - selectedTicket?.id === ticket.id ? "bg-rose-500/20 border border-rose-500" : "bg-gray-800" + 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" }`} > -
+
{ticket.priority} {ticket.status.replace("_", " ")}
-
{ticket.subject}
-
{ticket.email}
-
- {ticket.createdAt?.slice(0, 10)} -
+
{ticket.subject}
+
{ticket.email}
+ {ticket.familyName && ( +
Family: {ticket.familyName}
+ )} +
{timeAgo(ticket.createdAt)}
))} {tickets.length === 0 && ( -
No tickets found
+
No tickets
)}
- {/* Ticket Detail */} -
+ {/* Ticket Detail + Reply */} +
{selectedTicket ? ( -
-
-
{selectedTicket.subject}
-
{selectedTicket.email}
-
- {selectedTicket.createdAt?.slice(0, 10)} Β· {selectedTicket.familyName} + <> + {/* Ticket header */} +
+
+
+
{selectedTicket.subject}
+
{selectedTicket.email}
+ {selectedTicket.familyName && ( +
Family: {selectedTicket.familyName}
+ )} +
+
+ {selectedTicket.status === "open" && ( + + )} + {selectedTicket.status === "in_progress" && ( + + )} + {selectedTicket.status === "resolved" && ( + + )} + {selectedTicket.status === "closed" && ( + + )} +
-
- {selectedTicket.description} + + {/* Conversation thread */} +
+ {/* Original message */} +
+
+ {selectedTicket.email} + {timeAgo(selectedTicket.createdAt)} +
+
{selectedTicket.description}
+
+ + {/* Admin responses */} + {responses.map((r) => ( +
+
+ Admin + {timeAgo(r.createdAt)} +
+
{r.message}
+
+ ))}
-
- {selectedTicket.status === "open" && ( - - )} - {selectedTicket.status === "in_progress" && ( - - )} - {selectedTicket.status === "resolved" && ( - - )} -
-
+ + {/* Reply box */} + {selectedTicket.status !== "closed" && ( +
+