feat: add Ko-fi donations tab to admin dashboard
This commit is contained in:
@@ -1,13 +1,13 @@
|
|||||||
import { Router } from "express";
|
import { Router } from "express";
|
||||||
|
import { io } from "../index";
|
||||||
import {
|
import {
|
||||||
|
type AuthenticatedRequest,
|
||||||
authenticate,
|
authenticate,
|
||||||
AuthenticatedRequest,
|
|
||||||
requireAdmin,
|
requireAdmin,
|
||||||
} from "../middleware/auth";
|
} from "../middleware/auth";
|
||||||
import { prisma } from "../utils/prisma";
|
|
||||||
import { asyncHandler } from "../middleware/errorHandler";
|
import { asyncHandler } from "../middleware/errorHandler";
|
||||||
import { io } from "../index";
|
|
||||||
import { backfillUserHistory } from "../services/tautulli";
|
import { backfillUserHistory } from "../services/tautulli";
|
||||||
|
import { prisma } from "../utils/prisma";
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
@@ -229,4 +229,22 @@ router.get(
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
router.get(
|
||||||
|
"/kofi-payments",
|
||||||
|
authenticate,
|
||||||
|
requireAdmin,
|
||||||
|
asyncHandler(async (_req: AuthenticatedRequest, res) => {
|
||||||
|
const payments = await prisma.kofiPayment.findMany({
|
||||||
|
orderBy: { createdAt: "desc" },
|
||||||
|
take: 100,
|
||||||
|
include: {
|
||||||
|
user: {
|
||||||
|
select: { plexUsername: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
res.json({ payments });
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
export { router as adminRouter };
|
export { router as adminRouter };
|
||||||
|
|||||||
@@ -1,19 +1,20 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
|
||||||
import { useRouter } from "next/navigation";
|
|
||||||
import {
|
import {
|
||||||
Users,
|
|
||||||
Database,
|
|
||||||
Settings,
|
|
||||||
TrendingUp,
|
|
||||||
ShieldAlert,
|
|
||||||
Search,
|
|
||||||
RefreshCcw,
|
|
||||||
ArrowLeft,
|
ArrowLeft,
|
||||||
Save,
|
Coffee,
|
||||||
|
Database,
|
||||||
Gift,
|
Gift,
|
||||||
|
RefreshCcw,
|
||||||
|
Save,
|
||||||
|
Search,
|
||||||
|
Settings,
|
||||||
|
ShieldAlert,
|
||||||
|
TrendingUp,
|
||||||
|
Users,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
@@ -25,7 +26,7 @@ import {
|
|||||||
} from "@/components/ui/card";
|
} from "@/components/ui/card";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
import { api, adminApi } from "@/lib/api";
|
import { adminApi, api } from "@/lib/api";
|
||||||
import { useStore } from "@/lib/store";
|
import { useStore } from "@/lib/store";
|
||||||
import { formatNumber } from "@/lib/utils";
|
import { formatNumber } from "@/lib/utils";
|
||||||
|
|
||||||
@@ -61,12 +62,25 @@ interface SystemSettings {
|
|||||||
minWatchMinutes: number;
|
minWatchMinutes: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface KofiPayment {
|
||||||
|
id: string;
|
||||||
|
kofiTransactionId: string;
|
||||||
|
email: string;
|
||||||
|
fromName: string;
|
||||||
|
amount: number;
|
||||||
|
creditsGranted: number;
|
||||||
|
status: string;
|
||||||
|
createdAt: string;
|
||||||
|
user?: { plexUsername: string } | null;
|
||||||
|
}
|
||||||
|
|
||||||
export default function AdminPage() {
|
export default function AdminPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { user, isAuthenticated } = useStore();
|
const { user, isAuthenticated } = useStore();
|
||||||
const [analytics, setAnalytics] = useState<Analytics | null>(null);
|
const [analytics, setAnalytics] = useState<Analytics | null>(null);
|
||||||
const [users, setUsers] = useState<User[]>([]);
|
const [users, setUsers] = useState<User[]>([]);
|
||||||
const [settings, setSettings] = useState<SystemSettings | null>(null);
|
const [settings, setSettings] = useState<SystemSettings | null>(null);
|
||||||
|
const [kofiPayments, setKofiPayments] = useState<KofiPayment[]>([]);
|
||||||
const [searchQuery, setSearchQuery] = useState("");
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
const [isSavingSettings, setIsSavingSettings] = useState(false);
|
const [isSavingSettings, setIsSavingSettings] = useState(false);
|
||||||
@@ -82,14 +96,16 @@ export default function AdminPage() {
|
|||||||
|
|
||||||
const loadData = async () => {
|
const loadData = async () => {
|
||||||
try {
|
try {
|
||||||
const [analyticsRes, usersRes, settingsRes] = await Promise.all([
|
const [analyticsRes, usersRes, settingsRes, kofiRes] = await Promise.all([
|
||||||
adminApi.getAnalytics(),
|
adminApi.getAnalytics(),
|
||||||
adminApi.getUsers(),
|
adminApi.getUsers(),
|
||||||
adminApi.getSettings(),
|
adminApi.getSettings(),
|
||||||
|
adminApi.getKofiPayments(),
|
||||||
]);
|
]);
|
||||||
setAnalytics(analyticsRes.data);
|
setAnalytics(analyticsRes.data);
|
||||||
setUsers(usersRes.data.users || []);
|
setUsers(usersRes.data.users || []);
|
||||||
setSettings(settingsRes.data);
|
setSettings(settingsRes.data);
|
||||||
|
setKofiPayments(kofiRes.data.payments || []);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Admin load error:", err);
|
console.error("Admin load error:", err);
|
||||||
toast.error("Failed to load coop secrets");
|
toast.error("Failed to load coop secrets");
|
||||||
@@ -185,7 +201,7 @@ export default function AdminPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Tabs defaultValue="flock" className="w-full">
|
<Tabs defaultValue="flock" className="w-full">
|
||||||
<TabsList className="grid w-full grid-cols-3 gap-2 rounded-full border-4 border-foreground bg-muted/30 p-2 mb-8 h-auto">
|
<TabsList className="grid w-full grid-cols-4 gap-2 rounded-full border-4 border-foreground bg-muted/30 p-2 mb-8 h-auto">
|
||||||
<TabsTrigger
|
<TabsTrigger
|
||||||
value="flock"
|
value="flock"
|
||||||
className="rounded-full py-3 font-black uppercase data-[state=active]:bg-primary data-[state=active]:text-primary-foreground transition-all"
|
className="rounded-full py-3 font-black uppercase data-[state=active]:bg-primary data-[state=active]:text-primary-foreground transition-all"
|
||||||
@@ -198,6 +214,12 @@ export default function AdminPage() {
|
|||||||
>
|
>
|
||||||
<Settings className="mr-2 h-4 w-4" /> BARN RULES
|
<Settings className="mr-2 h-4 w-4" /> BARN RULES
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
|
<TabsTrigger
|
||||||
|
value="donations"
|
||||||
|
className="rounded-full py-3 font-black uppercase data-[state=active]:bg-primary data-[state=active]:text-primary-foreground transition-all"
|
||||||
|
>
|
||||||
|
<Coffee className="mr-2 h-4 w-4" /> DONATIONS
|
||||||
|
</TabsTrigger>
|
||||||
<TabsTrigger
|
<TabsTrigger
|
||||||
value="stats"
|
value="stats"
|
||||||
className="rounded-full py-3 font-black uppercase data-[state=active]:bg-primary data-[state=active]:text-primary-foreground transition-all"
|
className="rounded-full py-3 font-black uppercase data-[state=active]:bg-primary data-[state=active]:text-primary-foreground transition-all"
|
||||||
@@ -427,6 +449,59 @@ export default function AdminPage() {
|
|||||||
</Card>
|
</Card>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="donations" className="focus-visible:outline-none">
|
||||||
|
<div className="rounded-[2.5rem] border-4 border-foreground bg-card p-6 shadow-[10px_10px_0px_0px_rgba(0,0,0,0.05)]">
|
||||||
|
<div className="mb-6">
|
||||||
|
<h2 className="font-display text-3xl uppercase italic">Ko-fi Donations</h2>
|
||||||
|
<p className="text-sm font-bold text-muted-foreground">Incoming feed purchases</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{kofiPayments.length === 0 ? (
|
||||||
|
<div className="text-center py-10 opacity-40">
|
||||||
|
<div className="text-6xl mb-4">☕</div>
|
||||||
|
<div className="font-black uppercase tracking-widest text-sm">No donations yet. The well is dry.</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{kofiPayments.map((p) => (
|
||||||
|
<div
|
||||||
|
key={p.id}
|
||||||
|
className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 rounded-2xl border-4 border-foreground bg-accent/5 p-4"
|
||||||
|
>
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="font-black text-lg">{p.fromName || "Anonymous"}</span>
|
||||||
|
<span className={`rounded-full px-2 py-0.5 text-[10px] font-black uppercase ${p.status === "CREDITED" ? "bg-primary text-white" : "bg-destructive/20 text-destructive"}`}>
|
||||||
|
{p.status}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-sm font-bold text-muted-foreground mt-1">
|
||||||
|
{p.email}
|
||||||
|
</div>
|
||||||
|
{p.user && (
|
||||||
|
<div className="text-xs font-bold text-primary mt-0.5">
|
||||||
|
Linked: {p.user.plexUsername}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="text-right">
|
||||||
|
<div className="text-2xl font-black text-primary">
|
||||||
|
${p.amount.toFixed(2)}
|
||||||
|
</div>
|
||||||
|
<div className="text-sm font-bold text-muted-foreground">
|
||||||
|
{formatNumber(p.creditsGranted)} $COOP
|
||||||
|
</div>
|
||||||
|
<div className="text-[10px] font-bold text-muted-foreground uppercase">
|
||||||
|
{new Date(p.createdAt).toLocaleDateString()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value="stats" className="focus-visible:outline-none">
|
<TabsContent value="stats" className="focus-visible:outline-none">
|
||||||
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-4">
|
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-4">
|
||||||
<div className="rounded-3xl border-4 border-foreground bg-card p-6 shadow-[6px_6px_0px_0px_rgba(0,0,0,1)]">
|
<div className="rounded-3xl border-4 border-foreground bg-card p-6 shadow-[6px_6px_0px_0px_rgba(0,0,0,1)]">
|
||||||
|
|||||||
@@ -94,6 +94,7 @@ export const adminApi = {
|
|||||||
api.get(`/transactions/admin/all?page=${page}&limit=${limit}`),
|
api.get(`/transactions/admin/all?page=${page}&limit=${limit}`),
|
||||||
getSystemStats: () => api.get("/transactions/admin/stats"),
|
getSystemStats: () => api.get("/transactions/admin/stats"),
|
||||||
backfillUser: (userId: string) => api.post(`/admin/users/${userId}/backfill`),
|
backfillUser: (userId: string) => api.post(`/admin/users/${userId}/backfill`),
|
||||||
|
getKofiPayments: () => api.get("/admin/kofi-payments"),
|
||||||
};
|
};
|
||||||
|
|
||||||
// Overseer API
|
// Overseer API
|
||||||
|
|||||||
Reference in New Issue
Block a user