feat: ko-fi integration for credit purchases
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user