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. +

+
+ +
+ +
+ + {data && } +
+ + {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 ( +