diff --git a/app/privy/page.tsx b/app/privy/page.tsx
new file mode 100644
index 0000000..585b62a
--- /dev/null
+++ b/app/privy/page.tsx
@@ -0,0 +1,10 @@
+import type { Metadata } from "next";
+import PrivyLoginsPage from "@/components/PrivyLogins/PrivyLoginsPage";
+
+export const metadata: Metadata = {
+ title: "Privy Logins — Recoup Admin",
+};
+
+export default function Page() {
+ return ;
+}
diff --git a/components/Home/AdminDashboard.tsx b/components/Home/AdminDashboard.tsx
index 63e9617..32ac9e8 100644
--- a/components/Home/AdminDashboard.tsx
+++ b/components/Home/AdminDashboard.tsx
@@ -11,6 +11,7 @@ export default function AdminDashboard() {
);
diff --git a/components/PrivyLogins/ChartSkeleton.tsx b/components/PrivyLogins/ChartSkeleton.tsx
new file mode 100644
index 0000000..bbbfc8a
--- /dev/null
+++ b/components/PrivyLogins/ChartSkeleton.tsx
@@ -0,0 +1,8 @@
+export default function ChartSkeleton() {
+ return (
+
+ );
+}
diff --git a/components/PrivyLogins/PrivyLastSeenChart.tsx b/components/PrivyLogins/PrivyLastSeenChart.tsx
new file mode 100644
index 0000000..e501b1c
--- /dev/null
+++ b/components/PrivyLogins/PrivyLastSeenChart.tsx
@@ -0,0 +1,69 @@
+"use client";
+
+import { CartesianGrid, Line, LineChart, XAxis, YAxis } from "recharts";
+import {
+ ChartContainer,
+ ChartTooltip,
+ ChartTooltipContent,
+ type ChartConfig,
+} from "@/components/ui/chart";
+import type { PrivyUser } from "@/types/privy";
+import { getLastSeenByDate } from "@/lib/privy/getLastSeenByDate";
+
+const chartConfig = {
+ count: {
+ label: "Last Seen",
+ color: "#345A5D",
+ },
+} satisfies ChartConfig;
+
+interface PrivyLastSeenChartProps {
+ logins: PrivyUser[];
+}
+
+export default function PrivyLastSeenChart({ logins }: PrivyLastSeenChartProps) {
+ const data = getLastSeenByDate(logins);
+
+ if (data.length === 0) return null;
+
+ return (
+
+
+ Last Seen Activity
+
+
+
+
+ {
+ const d = new Date(value + "T00:00:00");
+ return d.toLocaleDateString("en-US", { month: "short", day: "numeric" });
+ }}
+ />
+
+ {
+ const d = new Date(String(value) + "T00:00:00");
+ return d.toLocaleDateString("en-US", { month: "long", day: "numeric", year: "numeric" });
+ }}
+ />
+ }
+ />
+
+
+
+
+ );
+}
diff --git a/components/PrivyLogins/PrivyLoginsPage.tsx b/components/PrivyLogins/PrivyLoginsPage.tsx
new file mode 100644
index 0000000..e96faec
--- /dev/null
+++ b/components/PrivyLogins/PrivyLoginsPage.tsx
@@ -0,0 +1,66 @@
+"use client";
+
+import { useState } from "react";
+import PageBreadcrumb from "@/components/Sandboxes/PageBreadcrumb";
+import ApiDocsLink from "@/components/ApiDocsLink";
+import { usePrivyLogins } from "@/hooks/usePrivyLogins";
+import PrivyLoginsTable from "@/components/PrivyLogins/PrivyLoginsTable";
+import PrivyPeriodSelector from "@/components/PrivyLogins/PrivyPeriodSelector";
+import PrivyLoginsStats from "@/components/PrivyLogins/PrivyLoginsStats";
+import TableSkeleton from "@/components/Sandboxes/TableSkeleton";
+import ChartSkeleton from "@/components/PrivyLogins/ChartSkeleton";
+import PrivyLastSeenChart from "@/components/PrivyLogins/PrivyLastSeenChart";
+import type { PrivyLoginsPeriod } from "@/types/privy";
+
+export default function PrivyLoginsPage() {
+ const [period, setPeriod] = useState("all");
+ const { data, isLoading, error } = usePrivyLogins(period);
+
+ return (
+
+
+
+
+
+ Privy Logins
+
+
+ User sign-ins via Privy, grouped by time period.
+
+
+
+
+
+
+
+ {isLoading && (
+ <>
+
+
+ >
+ )}
+
+ {error && (
+
+ {error instanceof Error ? error.message : "Failed to load Privy logins"}
+
+ )}
+
+ {!isLoading && !error && data && data.logins.length === 0 && (
+
+ No logins found for this period.
+
+ )}
+
+ {!isLoading && !error && data && data.logins.length > 0 && (
+ <>
+
+
+ >
+ )}
+
+ );
+}
diff --git a/components/PrivyLogins/PrivyLoginsStats.tsx b/components/PrivyLogins/PrivyLoginsStats.tsx
new file mode 100644
index 0000000..bb6d03e
--- /dev/null
+++ b/components/PrivyLogins/PrivyLoginsStats.tsx
@@ -0,0 +1,18 @@
+import type { PrivyLoginsResponse } from "@/types/privy";
+
+interface PrivyLoginsStatsProps {
+ data: PrivyLoginsResponse;
+}
+
+export default function PrivyLoginsStats({ data }: PrivyLoginsStatsProps) {
+ return (
+
+
+ {data.total_new} new
+
+
+ {data.total_active} active
+
+
+ );
+}
diff --git a/components/PrivyLogins/PrivyLoginsTable.tsx b/components/PrivyLogins/PrivyLoginsTable.tsx
new file mode 100644
index 0000000..b036e1d
--- /dev/null
+++ b/components/PrivyLogins/PrivyLoginsTable.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 { privyLoginsColumns } from "@/components/PrivyLogins/privyLoginsColumns";
+import type { PrivyUser } from "@/types/privy";
+
+interface PrivyLoginsTableProps {
+ logins: PrivyUser[];
+}
+
+export default function PrivyLoginsTable({ logins }: PrivyLoginsTableProps) {
+ const [sorting, setSorting] = useState([
+ { id: "created_at", desc: true },
+ ]);
+
+ const table = useReactTable({
+ data: logins,
+ columns: privyLoginsColumns,
+ 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/PrivyLogins/PrivyPeriodSelector.tsx b/components/PrivyLogins/PrivyPeriodSelector.tsx
new file mode 100644
index 0000000..5295113
--- /dev/null
+++ b/components/PrivyLogins/PrivyPeriodSelector.tsx
@@ -0,0 +1,33 @@
+import type { PrivyLoginsPeriod } from "@/types/privy";
+
+const PERIODS: { value: PrivyLoginsPeriod; label: string }[] = [
+ { value: "all", label: "All Time" },
+ { value: "daily", label: "Daily" },
+ { value: "weekly", label: "Weekly" },
+ { value: "monthly", label: "Monthly" },
+];
+
+interface PrivyPeriodSelectorProps {
+ period: PrivyLoginsPeriod;
+ onPeriodChange: (period: PrivyLoginsPeriod) => void;
+}
+
+export default function PrivyPeriodSelector({ period, onPeriodChange }: PrivyPeriodSelectorProps) {
+ return (
+
+ {PERIODS.map(({ value, label }) => (
+
+ ))}
+
+ );
+}
diff --git a/components/PrivyLogins/privyLoginsColumns.tsx b/components/PrivyLogins/privyLoginsColumns.tsx
new file mode 100644
index 0000000..b9dc1a4
--- /dev/null
+++ b/components/PrivyLogins/privyLoginsColumns.tsx
@@ -0,0 +1,34 @@
+import { type ColumnDef } from "@tanstack/react-table";
+import { SortableHeader } from "@/components/SandboxOrgs/SortableHeader";
+import { getEmail } from "@/lib/privy/getEmail";
+import { getLastSeen } from "@/lib/privy/getLastSeen";
+import type { PrivyUser } from "@/types/privy";
+
+export const privyLoginsColumns: ColumnDef[] = [
+ {
+ id: "email",
+ accessorFn: (row) => getEmail(row),
+ header: "Email",
+ cell: ({ getValue }) => {
+ const email = getValue();
+ return email ?? No email;
+ },
+ },
+ {
+ id: "created_at",
+ accessorKey: "created_at",
+ header: ({ column }) => ,
+ cell: ({ getValue }) => new Date(getValue() * 1000).toLocaleString(),
+ sortingFn: "basic",
+ },
+ {
+ id: "last_seen",
+ accessorFn: (row) => getLastSeen(row),
+ header: ({ column }) => ,
+ cell: ({ getValue }) => {
+ const ts = getValue();
+ return ts ? new Date(ts * 1000).toLocaleString() : Never;
+ },
+ sortingFn: "basic",
+ },
+];
diff --git a/components/ui/card.tsx b/components/ui/card.tsx
new file mode 100644
index 0000000..acf57dc
--- /dev/null
+++ b/components/ui/card.tsx
@@ -0,0 +1,92 @@
+import * as React from "react"
+
+import { cn } from "@/lib/utils"
+
+function Card({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function CardAction({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function CardContent({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+export {
+ Card,
+ CardHeader,
+ CardFooter,
+ CardTitle,
+ CardAction,
+ CardDescription,
+ CardContent,
+}
diff --git a/components/ui/chart.tsx b/components/ui/chart.tsx
new file mode 100644
index 0000000..1cdfc92
--- /dev/null
+++ b/components/ui/chart.tsx
@@ -0,0 +1,357 @@
+"use client"
+
+import * as React from "react"
+import * as RechartsPrimitive from "recharts"
+
+import { cn } from "@/lib/utils"
+
+// Format: { THEME_NAME: CSS_SELECTOR }
+const THEMES = { light: "", dark: ".dark" } as const
+
+export type ChartConfig = {
+ [k in string]: {
+ label?: React.ReactNode
+ icon?: React.ComponentType
+ } & (
+ | { color?: string; theme?: never }
+ | { color?: never; theme: Record }
+ )
+}
+
+type ChartContextProps = {
+ config: ChartConfig
+}
+
+const ChartContext = React.createContext(null)
+
+function useChart() {
+ const context = React.useContext(ChartContext)
+
+ if (!context) {
+ throw new Error("useChart must be used within a ")
+ }
+
+ return context
+}
+
+function ChartContainer({
+ id,
+ className,
+ children,
+ config,
+ ...props
+}: React.ComponentProps<"div"> & {
+ config: ChartConfig
+ children: React.ComponentProps<
+ typeof RechartsPrimitive.ResponsiveContainer
+ >["children"]
+}) {
+ const uniqueId = React.useId()
+ const chartId = `chart-${id || uniqueId.replace(/:/g, "")}`
+
+ return (
+
+
+
+
+ {children}
+
+
+
+ )
+}
+
+const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
+ const colorConfig = Object.entries(config).filter(
+ ([, config]) => config.theme || config.color
+ )
+
+ if (!colorConfig.length) {
+ return null
+ }
+
+ return (
+