feat: ko-fi integration for credit purchases

This commit is contained in:
2026-04-23 11:59:05 -04:00
parent 4a68cc85f3
commit 3dcece5c67
4 changed files with 233 additions and 0 deletions
+23
View File
@@ -23,6 +23,7 @@ model User {
watchEvents WatchEvent[]
contentRequests ContentRequest[]
sessions Session[]
kofiPayments KofiPayment[]
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
@@ -118,11 +119,33 @@ model SystemSettings {
@@map("system_settings")
}
model KofiPayment {
id String @id @default(uuid())
kofiTransactionId String @unique @map("kofi_transaction_id")
email String
fromName String @map("from_name")
amount Float
creditsGranted Int @map("credits_granted")
message String?
isPublic Boolean @default(true) @map("is_public")
currency String @default("USD")
userId String? @map("user_id")
user User? @relation(fields: [userId], references: [id])
status String @default("PENDING")
createdAt DateTime @default(now()) @map("created_at")
@@index([userId])
@@index([email])
@@index([status])
@@map("kofi_payments")
}
enum TransactionType {
EARN
SPEND
BONUS
ADJUSTMENT
PURCHASE
}
enum RequestStatus {
+112
View File
@@ -6,6 +6,7 @@ import { prisma } from "../utils/prisma";
const router = Router();
const WEBHOOK_SECRET = process.env.TAUTULLI_WEBHOOK_SECRET || "";
const KOFI_VERIFICATION_TOKEN = process.env.KOFI_VERIFICATION_TOKEN || "";
function verifyWebhookSignature(payload: string, signature: string): boolean {
if (!WEBHOOK_SECRET) return true;
@@ -115,4 +116,115 @@ router.post(
}),
);
router.post(
"/kofi",
asyncHandler(async (req, res) => {
const rawData = req.body.data;
if (!rawData) return res.status(400).json({ error: "Missing data" });
let payload: any;
try {
payload = JSON.parse(rawData);
} catch {
return res.status(400).json({ error: "Invalid JSON" });
}
if (payload.verification_token !== KOFI_VERIFICATION_TOKEN) {
return res.status(403).json({ error: "Invalid verification token" });
}
if (payload.type !== "Donation") {
return res.json({ message: "Non-donation event ignored" });
}
const kofiTxId = payload.kofi_transaction_id;
if (!kofiTxId) {
return res.status(400).json({ error: "Missing transaction id" });
}
const existing = await prisma.kofiPayment.findUnique({
where: { kofiTransactionId: kofiTxId },
});
if (existing) {
return res.json({ success: true, alreadyProcessed: true });
}
const amount = parseFloat(payload.amount) || 0;
const creditsGranted = Math.floor(amount * 100);
const email = (payload.email || "").toLowerCase().trim();
if (creditsGranted <= 0) {
return res.status(400).json({ error: "Invalid amount" });
}
const user = email
? await prisma.user.findFirst({
where: {
email: { equals: email, mode: "insensitive" },
},
})
: null;
if (user) {
await prisma.$transaction([
prisma.kofiPayment.create({
data: {
kofiTransactionId: kofiTxId,
email: payload.email || "",
fromName: payload.from_name || "",
amount,
creditsGranted,
message: payload.message || null,
isPublic: payload.is_public ?? true,
currency: payload.currency || "USD",
userId: user.id,
status: "CREDITED",
},
}),
prisma.transaction.create({
data: {
userId: user.id,
type: "PURCHASE",
amount: creditsGranted,
description: `Ko-fi $${amount.toFixed(2)}`,
contentTitle: "Ko-fi Credit Purchase",
},
}),
prisma.user.update({
where: { id: user.id },
data: { totalEarned: { increment: creditsGranted } },
}),
]);
io.to(`user:${user.id}`).emit("credits_earned", {
amount: creditsGranted,
title: "Ko-fi Purchase",
transaction: {
id: kofiTxId,
type: "PURCHASE",
amount: creditsGranted,
contentTitle: "Ko-fi Credit Purchase",
createdAt: new Date(),
},
});
} else {
await prisma.kofiPayment.create({
data: {
kofiTransactionId: kofiTxId,
email: payload.email || "",
fromName: payload.from_name || "",
amount,
creditsGranted,
message: payload.message || null,
isPublic: payload.is_public ?? true,
currency: payload.currency || "USD",
status: "UNCLAIMED",
},
});
}
res.json({ success: true, credited: !!user, creditsGranted });
}),
);
export { router as webhookRouter };
@@ -0,0 +1,86 @@
"use client";
import { Coffee, ExternalLink, X } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
interface BuyCreditsModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
}
const PRESETS = [
{ dollars: 5, credits: 500, label: "Small Feed", emoji: "🌾" },
{ dollars: 10, credits: 1000, label: "Big Feed", emoji: "🌽" },
{ dollars: 20, credits: 2000, label: "Feast", emoji: "🍗" },
];
export function BuyCreditsModal({ open, onOpenChange }: BuyCreditsModalProps) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="rounded-[2rem] border-4 border-foreground bg-card p-0 shadow-[12px_12px_0px_0px_rgba(0,0,0,0.1)] max-w-md">
<DialogHeader className="bg-primary/10 border-b-4 border-foreground p-6">
<div className="flex items-center justify-between">
<DialogTitle className="font-display text-3xl uppercase italic text-primary">
Stock the Coop
</DialogTitle>
<button
onClick={() => onOpenChange(false)}
className="rounded-full p-2 hover:bg-background transition-colors"
>
<X className="h-6 w-6" />
</button>
</div>
<p className="text-sm font-bold text-muted-foreground mt-2">
100 $COOP per dollar. Use same email as Plex for auto-delivery.
</p>
</DialogHeader>
<div className="p-6 space-y-4">
{PRESETS.map((preset) => (
<a
key={preset.dollars}
href={`https://ko-fi.com/dustinnewkirk`}
target="_blank"
rel="noopener noreferrer"
className="flex items-center justify-between rounded-2xl border-4 border-foreground bg-accent/10 p-4 shadow-[4px_4px_0px_0px_rgba(0,0,0,1)] hover:translate-x-[2px] hover:translate-y-[2px] hover:shadow-none transition-all"
onClick={() => onOpenChange(false)}
>
<div className="flex items-center gap-4">
<span className="text-3xl">{preset.emoji}</span>
<div>
<div className="font-black text-lg">{preset.label}</div>
<div className="text-sm font-bold text-muted-foreground">
${preset.dollars} {preset.credits} $COOP
</div>
</div>
</div>
<ExternalLink className="h-5 w-5 text-muted-foreground" />
</a>
))}
<div className="rounded-2xl border-2 border-dashed border-foreground/30 bg-background p-4 text-center">
<p className="text-xs font-bold text-muted-foreground uppercase tracking-widest">
Any amount works. $1 = 100 $COOP.
</p>
<a
href="https://ko-fi.com/dustinnewkirk"
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-2 mt-3 rounded-full bg-[#FF5E5B] px-6 py-3 font-black text-white shadow-[4px_4px_0px_0px_rgba(0,0,0,1)] hover:translate-x-[2px] hover:translate-y-[2px] hover:shadow-none transition-all"
onClick={() => onOpenChange(false)}
>
<Coffee className="h-5 w-5" />
Support on Ko-fi
</a>
</div>
</div>
</DialogContent>
</Dialog>
);
}
+12
View File
@@ -2,6 +2,7 @@
import {
Activity,
Coffee,
Egg,
ExternalLink,
History,
@@ -19,6 +20,7 @@ import { api, authApi, overseerApi, userApi } from "@/lib/api";
import { useStore } from "@/lib/store";
import { formatNumber } from "@/lib/utils";
import { ActivityFeed } from "./components/ActivityFeed";
import { BuyCreditsModal } from "./components/BuyCreditsModal";
import { Leaderboard } from "./components/Leaderboard";
import { RequestHistory } from "./components/RequestHistory";
import { SearchRequestModal } from "./components/SearchRequestModal";
@@ -38,6 +40,7 @@ export default function DashboardPage() {
const [pendingRequests, setPendingRequests] = useState<any[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [isSearchModalOpen, setIsSearchModalOpen] = useState(false);
const [isBuyModalOpen, setIsBuyModalOpen] = useState(false);
useEffect(() => {
if (!isAuthenticated) {
@@ -122,6 +125,14 @@ export default function DashboardPage() {
<Search className="mr-2 h-5 w-5" />
PECK FOR FEED
</Button>
<Button
variant="outline"
onClick={() => setIsBuyModalOpen(true)}
className="rounded-full border-2 border-[#FF5E5B] bg-background font-bold text-[#FF5E5B] hover:bg-[#FF5E5B]/10"
>
<Coffee className="mr-2 h-5 w-5" />
BUY FEED
</Button>
<Button
variant="outline"
onClick={handleRefresh}
@@ -258,6 +269,7 @@ export default function DashboardPage() {
onOpenChange={setIsSearchModalOpen}
costs={requestCosts}
/>
<BuyCreditsModal open={isBuyModalOpen} onOpenChange={setIsBuyModalOpen} />
</div>
);
}