diff --git a/app/agent-signups/page.tsx b/app/agent-signups/page.tsx new file mode 100644 index 0000000..69fefe8 --- /dev/null +++ b/app/agent-signups/page.tsx @@ -0,0 +1,10 @@ +import type { Metadata } from "next"; +import AgentSignupsPage from "@/components/AgentSignups/AgentSignupsPage"; + +export const metadata: Metadata = { + title: "Agent Sign-Ups — Recoup Admin", +}; + +export default function Page() { + return ; +} diff --git a/components/AgentSignups/AgentSignupsPage.tsx b/components/AgentSignups/AgentSignupsPage.tsx new file mode 100644 index 0000000..47d9ab7 --- /dev/null +++ b/components/AgentSignups/AgentSignupsPage.tsx @@ -0,0 +1,73 @@ +"use client"; + +import { useState } from "react"; +import PageBreadcrumb from "@/components/Sandboxes/PageBreadcrumb"; +import ApiDocsLink from "@/components/ApiDocs/ApiDocsLink"; +import { useAgentSignups } from "@/hooks/useAgentSignups"; +import AgentSignupsTable from "@/components/AgentSignups/AgentSignupsTable"; +import AgentSignupsStats from "@/components/AgentSignups/AgentSignupsStats"; +import PeriodSelector from "@/components/Admin/PeriodSelector"; +import AdminLineChart from "@/components/Admin/AdminLineChart"; +import TableSkeleton from "@/components/Sandboxes/TableSkeleton"; +import ChartSkeleton from "@/components/PrivyLogins/ChartSkeleton"; +import { getSignupsByDate } from "@/lib/agent-signups/getSignupsByDate"; +import type { AdminPeriod } from "@/types/admin"; + +export default function AgentSignupsPage() { + const [period, setPeriod] = useState("all"); + const { data, isLoading, error } = useAgentSignups(period); + + const signupsByDate = data ? getSignupsByDate(data.signups) : []; + + return ( +
+
+
+ +

+ Agent Sign-Ups +

+

+ API key sign-ups from AI agents, grouped by time period. +

+
+ +
+ +
+ + {data && } +
+ + {isLoading && ( + <> + + + + )} + + {error && ( +
+ {error instanceof Error ? error.message : "Failed to load agent sign-ups"} +
+ )} + + {!isLoading && !error && data && data.signups.length === 0 && ( +
+ No agent sign-ups found for this period. +
+ )} + + {!isLoading && !error && data && data.signups.length > 0 && ( + <> + ({ date: d.date, count: d.count }))} + label="Sign-Ups" + /> + + + )} +
+ ); +} diff --git a/components/AgentSignups/AgentSignupsStats.tsx b/components/AgentSignups/AgentSignupsStats.tsx new file mode 100644 index 0000000..01ce793 --- /dev/null +++ b/components/AgentSignups/AgentSignupsStats.tsx @@ -0,0 +1,15 @@ +import type { AgentSignupsResponse } from "@/types/agentSignups"; + +interface AgentSignupsStatsProps { + data: AgentSignupsResponse; +} + +export default function AgentSignupsStats({ data }: AgentSignupsStatsProps) { + return ( +
+ + {data.total} sign-ups + +
+ ); +} diff --git a/components/AgentSignups/AgentSignupsTable.tsx b/components/AgentSignups/AgentSignupsTable.tsx new file mode 100644 index 0000000..4ef64e0 --- /dev/null +++ b/components/AgentSignups/AgentSignupsTable.tsx @@ -0,0 +1,78 @@ +"use client"; + +import { + flexRender, + getCoreRowModel, + getSortedRowModel, + useReactTable, + type SortingState, +} from "@tanstack/react-table"; +import { useState } from "react"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { agentSignupsColumns } from "@/components/AgentSignups/agentSignupsColumns"; +import type { AgentSignup } from "@/types/agentSignups"; + +interface AgentSignupsTableProps { + signups: AgentSignup[]; +} + +export default function AgentSignupsTable({ signups }: AgentSignupsTableProps) { + const [sorting, setSorting] = useState([ + { id: "created_at", desc: true }, + ]); + + const table = useReactTable({ + data: signups, + columns: agentSignupsColumns, + state: { sorting }, + onSortingChange: setSorting, + getCoreRowModel: getCoreRowModel(), + getSortedRowModel: getSortedRowModel(), + }); + + return ( +
+ + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => ( + + {header.isPlaceholder + ? null + : flexRender(header.column.columnDef.header, header.getContext())} + + ))} + + ))} + + + {table.getRowModel().rows.length ? ( + table.getRowModel().rows.map((row) => ( + + {row.getVisibleCells().map((cell) => ( + + {flexRender(cell.column.columnDef.cell, cell.getContext())} + + ))} + + )) + ) : ( + + + No results. + + + )} + +
+
+ ); +} diff --git a/components/AgentSignups/agentSignupsColumns.tsx b/components/AgentSignups/agentSignupsColumns.tsx new file mode 100644 index 0000000..054b1c9 --- /dev/null +++ b/components/AgentSignups/agentSignupsColumns.tsx @@ -0,0 +1,31 @@ +import { type ColumnDef } from "@tanstack/react-table"; +import { SortableHeader } from "@/components/SandboxOrgs/SortableHeader"; +import type { AgentSignup } from "@/types/agentSignups"; + +export const agentSignupsColumns: ColumnDef[] = [ + { + id: "email", + accessorKey: "email", + header: ({ column }) => , + cell: ({ getValue }) => ( + {getValue()} + ), + }, + { + id: "name", + accessorKey: "name", + header: "Key Name", + cell: ({ getValue }) => ( + ()}> + {getValue()} + + ), + }, + { + id: "created_at", + accessorKey: "created_at", + header: ({ column }) => , + cell: ({ getValue }) => new Date(getValue()).toLocaleString(), + sortingFn: "datetime", + }, +]; diff --git a/components/Home/AdminDashboard.tsx b/components/Home/AdminDashboard.tsx index 3633e68..2c23816 100644 --- a/components/Home/AdminDashboard.tsx +++ b/components/Home/AdminDashboard.tsx @@ -14,6 +14,7 @@ export default function AdminDashboard() { + ); diff --git a/hooks/useAgentSignups.ts b/hooks/useAgentSignups.ts new file mode 100644 index 0000000..99c69af --- /dev/null +++ b/hooks/useAgentSignups.ts @@ -0,0 +1,20 @@ +"use client"; + +import { useQuery } from "@tanstack/react-query"; +import { usePrivy } from "@privy-io/react-auth"; +import { fetchAgentSignups } from "@/lib/recoup/fetchAgentSignups"; +import type { AdminPeriod } from "@/types/admin"; + +export function useAgentSignups(period: AdminPeriod) { + const { ready, authenticated, getAccessToken } = usePrivy(); + + return useQuery({ + queryKey: ["admin", "agent-signups", period], + queryFn: async () => { + const token = await getAccessToken(); + if (!token) throw new Error("Not authenticated"); + return fetchAgentSignups(token, period); + }, + enabled: ready && authenticated, + }); +} diff --git a/lib/agent-signups/getSignupsByDate.ts b/lib/agent-signups/getSignupsByDate.ts new file mode 100644 index 0000000..c8270ce --- /dev/null +++ b/lib/agent-signups/getSignupsByDate.ts @@ -0,0 +1,22 @@ +import type { AgentSignup } from "@/types/agentSignups"; + +export interface SignupsByDateEntry { + date: string; + count: number; +} + +/** + * Aggregates agent sign-ups by UTC date (YYYY-MM-DD) for charting. + */ +export function getSignupsByDate(signups: AgentSignup[]): SignupsByDateEntry[] { + const counts: Record = {}; + + for (const signup of signups) { + const date = signup.created_at.slice(0, 10); + counts[date] = (counts[date] || 0) + 1; + } + + return Object.entries(counts) + .map(([date, count]) => ({ date, count })) + .sort((a, b) => a.date.localeCompare(b.date)); +} diff --git a/lib/agent-signups/getSignupsByEmail.ts b/lib/agent-signups/getSignupsByEmail.ts new file mode 100644 index 0000000..c4a63d1 --- /dev/null +++ b/lib/agent-signups/getSignupsByEmail.ts @@ -0,0 +1,22 @@ +import type { AgentSignup } from "@/types/agentSignups"; + +export interface SignupsByEmailEntry { + name: string; + value: number; +} + +/** + * Aggregates agent sign-ups by email address for pie chart display. + */ +export function getSignupsByEmail(signups: AgentSignup[]): SignupsByEmailEntry[] { + const counts: Record = {}; + + for (const signup of signups) { + const key = signup.email || "Unknown"; + counts[key] = (counts[key] || 0) + 1; + } + + return Object.entries(counts) + .map(([name, value]) => ({ name, value })) + .sort((a, b) => b.value - a.value); +} diff --git a/lib/recoup/fetchAgentSignups.ts b/lib/recoup/fetchAgentSignups.ts new file mode 100644 index 0000000..5fe3335 --- /dev/null +++ b/lib/recoup/fetchAgentSignups.ts @@ -0,0 +1,22 @@ +import { API_BASE_URL } from "@/lib/consts"; +import type { AdminPeriod } from "@/types/admin"; +import type { AgentSignupsResponse } from "@/types/agentSignups"; + +export async function fetchAgentSignups( + accessToken: string, + period: AdminPeriod, +): Promise { + const url = new URL(`${API_BASE_URL}/api/admins/agent-signups`); + url.searchParams.set("period", period); + + const res = await fetch(url.toString(), { + headers: { Authorization: `Bearer ${accessToken}` }, + }); + + if (!res.ok) { + const body = await res.json().catch(() => ({})); + throw new Error(body.error ?? body.message ?? `HTTP ${res.status}`); + } + + return res.json(); +} diff --git a/types/agentSignups.ts b/types/agentSignups.ts new file mode 100644 index 0000000..1d1cf52 --- /dev/null +++ b/types/agentSignups.ts @@ -0,0 +1,12 @@ +export type AgentSignup = { + id: string; + name: string; + email: string; + created_at: string; +}; + +export type AgentSignupsResponse = { + status: "success" | "error"; + total: number; + signups: AgentSignup[]; +};