feat: ko-fi integration for credit purchases
This commit is contained in:
@@ -23,6 +23,7 @@ model User {
|
|||||||
watchEvents WatchEvent[]
|
watchEvents WatchEvent[]
|
||||||
contentRequests ContentRequest[]
|
contentRequests ContentRequest[]
|
||||||
sessions Session[]
|
sessions Session[]
|
||||||
|
kofiPayments KofiPayment[]
|
||||||
|
|
||||||
createdAt DateTime @default(now()) @map("created_at")
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
updatedAt DateTime @updatedAt @map("updated_at")
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
@@ -118,11 +119,33 @@ model SystemSettings {
|
|||||||
@@map("system_settings")
|
@@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 {
|
enum TransactionType {
|
||||||
EARN
|
EARN
|
||||||
SPEND
|
SPEND
|
||||||
BONUS
|
BONUS
|
||||||
ADJUSTMENT
|
ADJUSTMENT
|
||||||
|
PURCHASE
|
||||||
}
|
}
|
||||||
|
|
||||||
enum RequestStatus {
|
enum RequestStatus {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { prisma } from "../utils/prisma";
|
|||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
const WEBHOOK_SECRET = process.env.TAUTULLI_WEBHOOK_SECRET || "";
|
const WEBHOOK_SECRET = process.env.TAUTULLI_WEBHOOK_SECRET || "";
|
||||||
|
const KOFI_VERIFICATION_TOKEN = process.env.KOFI_VERIFICATION_TOKEN || "";
|
||||||
|
|
||||||
function verifyWebhookSignature(payload: string, signature: string): boolean {
|
function verifyWebhookSignature(payload: string, signature: string): boolean {
|
||||||
if (!WEBHOOK_SECRET) return true;
|
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 };
|
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 {
|
import {
|
||||||
Activity,
|
Activity,
|
||||||
|
Coffee,
|
||||||
Egg,
|
Egg,
|
||||||
ExternalLink,
|
ExternalLink,
|
||||||
History,
|
History,
|
||||||
@@ -19,6 +20,7 @@ import { api, authApi, overseerApi, userApi } from "@/lib/api";
|
|||||||
import { useStore } from "@/lib/store";
|
import { useStore } from "@/lib/store";
|
||||||
import { formatNumber } from "@/lib/utils";
|
import { formatNumber } from "@/lib/utils";
|
||||||
import { ActivityFeed } from "./components/ActivityFeed";
|
import { ActivityFeed } from "./components/ActivityFeed";
|
||||||
|
import { BuyCreditsModal } from "./components/BuyCreditsModal";
|
||||||
import { Leaderboard } from "./components/Leaderboard";
|
import { Leaderboard } from "./components/Leaderboard";
|
||||||
import { RequestHistory } from "./components/RequestHistory";
|
import { RequestHistory } from "./components/RequestHistory";
|
||||||
import { SearchRequestModal } from "./components/SearchRequestModal";
|
import { SearchRequestModal } from "./components/SearchRequestModal";
|
||||||
@@ -38,6 +40,7 @@ export default function DashboardPage() {
|
|||||||
const [pendingRequests, setPendingRequests] = useState<any[]>([]);
|
const [pendingRequests, setPendingRequests] = useState<any[]>([]);
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
const [isSearchModalOpen, setIsSearchModalOpen] = useState(false);
|
const [isSearchModalOpen, setIsSearchModalOpen] = useState(false);
|
||||||
|
const [isBuyModalOpen, setIsBuyModalOpen] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isAuthenticated) {
|
if (!isAuthenticated) {
|
||||||
@@ -122,6 +125,14 @@ export default function DashboardPage() {
|
|||||||
<Search className="mr-2 h-5 w-5" />
|
<Search className="mr-2 h-5 w-5" />
|
||||||
PECK FOR FEED
|
PECK FOR FEED
|
||||||
</Button>
|
</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
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={handleRefresh}
|
onClick={handleRefresh}
|
||||||
@@ -258,6 +269,7 @@ export default function DashboardPage() {
|
|||||||
onOpenChange={setIsSearchModalOpen}
|
onOpenChange={setIsSearchModalOpen}
|
||||||
costs={requestCosts}
|
costs={requestCosts}
|
||||||
/>
|
/>
|
||||||
|
<BuyCreditsModal open={isBuyModalOpen} onOpenChange={setIsBuyModalOpen} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user