chore: final solana purge

This commit is contained in:
2026-04-22 13:37:22 -04:00
parent 4bfcbb6b7e
commit f944b1f9b4
15 changed files with 374 additions and 1938 deletions
+7 -30
View File
@@ -1,12 +1,12 @@
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 { asyncHandler } from "../middleware/errorHandler";
import { prisma } from "../utils/prisma"; import { prisma } from "../utils/prisma";
import { asyncHandler } from "../middleware/errorHandler";
import { io } from "../index";
const router = Router(); const router = Router();
@@ -20,7 +20,6 @@ router.get(
res.json(settings); res.json(settings);
}), }),
); );
router.put( router.put(
"/settings", "/settings",
authenticate, authenticate,
@@ -44,7 +43,6 @@ router.put(
res.json(settings); res.json(settings);
}), }),
); );
router.post( router.post(
"/pause", "/pause",
authenticate, authenticate,
@@ -54,10 +52,9 @@ router.post(
where: { id: "default" }, where: { id: "default" },
data: { mintingPaused: true, updatedBy: req.user!.id }, data: { mintingPaused: true, updatedBy: req.user!.id },
}); });
res.json({ message: "Minting paused" }); res.json({ message: "Paused" });
}), }),
); );
router.post( router.post(
"/resume", "/resume",
authenticate, authenticate,
@@ -67,10 +64,9 @@ router.post(
where: { id: "default" }, where: { id: "default" },
data: { mintingPaused: false, updatedBy: req.user!.id }, data: { mintingPaused: false, updatedBy: req.user!.id },
}); });
res.json({ message: "Minting resumed" }); res.json({ message: "Resumed" });
}), }),
); );
router.get( router.get(
"/users", "/users",
authenticate, authenticate,
@@ -80,15 +76,12 @@ router.get(
const pageNum = parseInt(page as string); const pageNum = parseInt(page as string);
const limitNum = Math.min(parseInt(limit as string), 100); const limitNum = Math.min(parseInt(limit as string), 100);
const skip = (pageNum - 1) * limitNum; const skip = (pageNum - 1) * limitNum;
const where: any = {}; const where: any = {};
if (search) { if (search)
where.OR = [ where.OR = [
{ plexUsername: { contains: search as string, mode: "insensitive" } }, { plexUsername: { contains: search as string, mode: "insensitive" } },
{ email: { contains: search as string, mode: "insensitive" } }, { email: { contains: search as string, mode: "insensitive" } },
]; ];
}
const [users, total] = await Promise.all([ const [users, total] = await Promise.all([
prisma.user.findMany({ prisma.user.findMany({
where, where,
@@ -102,7 +95,6 @@ router.get(
email: true, email: true,
isAdmin: true, isAdmin: true,
isActive: true, isActive: true,
walletAddress: true,
totalEarned: true, totalEarned: true,
totalSpent: true, totalSpent: true,
watchTimeMinutes: true, watchTimeMinutes: true,
@@ -111,7 +103,6 @@ router.get(
}), }),
prisma.user.count({ where }), prisma.user.count({ where }),
]); ]);
res.json({ res.json({
users, users,
pagination: { pagination: {
@@ -123,7 +114,6 @@ router.get(
}); });
}), }),
); );
router.put( router.put(
"/users/:id", "/users/:id",
authenticate, authenticate,
@@ -137,7 +127,6 @@ router.put(
res.json(user); res.json(user);
}), }),
); );
router.post( router.post(
"/users/:id/bonus", "/users/:id/bonus",
authenticate, authenticate,
@@ -146,10 +135,8 @@ router.post(
const { amount, reason } = req.body; const { amount, reason } = req.body;
if (!amount || amount <= 0) if (!amount || amount <= 0)
return res.status(400).json({ error: "Valid amount required" }); return res.status(400).json({ error: "Valid amount required" });
const user = await prisma.user.findUnique({ where: { id: req.params.id } }); const user = await prisma.user.findUnique({ where: { id: req.params.id } });
if (!user) return res.status(404).json({ error: "User not found" }); if (!user) return res.status(404).json({ error: "User not found" });
const transaction = await prisma.transaction.create({ const transaction = await prisma.transaction.create({
data: { data: {
userId: user.id, userId: user.id,
@@ -159,7 +146,6 @@ router.post(
contentTitle: reason || "Admin bonus", contentTitle: reason || "Admin bonus",
}, },
}); });
await prisma.user.update({ await prisma.user.update({
where: { id: user.id }, where: { id: user.id },
data: { totalEarned: { increment: amount } }, data: { totalEarned: { increment: amount } },
@@ -172,7 +158,6 @@ router.post(
res.json({ success: true, amount, transaction }); res.json({ success: true, amount, transaction });
}), }),
); );
router.post( router.post(
"/users/:id/adjust", "/users/:id/adjust",
authenticate, authenticate,
@@ -181,10 +166,8 @@ router.post(
const { amount, reason } = req.body; const { amount, reason } = req.body;
if (!amount || Number.isNaN(Number(amount)) || Number(amount) === 0) if (!amount || Number.isNaN(Number(amount)) || Number(amount) === 0)
return res.status(400).json({ error: "Valid amount required" }); return res.status(400).json({ error: "Valid amount required" });
const user = await prisma.user.findUnique({ where: { id: req.params.id } }); const user = await prisma.user.findUnique({ where: { id: req.params.id } });
if (!user) return res.status(404).json({ error: "User not found" }); if (!user) return res.status(404).json({ error: "User not found" });
const delta = Number(amount); const delta = Number(amount);
await prisma.transaction.create({ await prisma.transaction.create({
data: { data: {
@@ -195,7 +178,6 @@ router.post(
contentTitle: reason || "Admin adjustment", contentTitle: reason || "Admin adjustment",
}, },
}); });
await prisma.user.update({ await prisma.user.update({
where: { id: user.id }, where: { id: user.id },
data: data:
@@ -203,11 +185,9 @@ router.post(
? { totalEarned: { increment: delta } } ? { totalEarned: { increment: delta } }
: { totalSpent: { increment: Math.abs(delta) } }, : { totalSpent: { increment: Math.abs(delta) } },
}); });
res.json({ success: true }); res.json({ success: true });
}), }),
); );
router.get( router.get(
"/analytics", "/analytics",
authenticate, authenticate,
@@ -217,15 +197,13 @@ router.get(
sevenDaysAgo.setDate(sevenDaysAgo.getDate() - 7); sevenDaysAgo.setDate(sevenDaysAgo.getDate() - 7);
const thirtyDaysAgo = new Date(); const thirtyDaysAgo = new Date();
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30); thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
const [userStats, transactionStats, watchStats, dailyActivity] = const [userStats, transactionStats, watchStats, dailyActivity] =
await Promise.all([ await Promise.all([
prisma.$queryRaw`SELECT COUNT(*) as total, COUNT(CASE WHEN wallet_address IS NOT NULL THEN 1 END) as with_wallet, COUNT(CASE WHEN created_at >= ${sevenDaysAgo} THEN 1 END) as new_this_week FROM users`, prisma.$queryRaw`SELECT COUNT(*) as total, COUNT(CASE WHEN created_at >= ${sevenDaysAgo} THEN 1 END) as new_this_week FROM users`,
prisma.$queryRaw`SELECT SUM(CASE WHEN type = 'EARN' THEN amount ELSE 0 END) as total_earned, SUM(CASE WHEN type = 'SPEND' THEN amount ELSE 0 END) as total_spent, COUNT(*) as total_transactions FROM transactions`, prisma.$queryRaw`SELECT SUM(CASE WHEN type = 'EARN' THEN amount ELSE 0 END) as total_earned, SUM(CASE WHEN type = 'SPEND' THEN amount ELSE 0 END) as total_spent, COUNT(*) as total_transactions FROM transactions`,
prisma.$queryRaw`SELECT SUM(duration) as total_seconds, SUM(credits_earned) as total_credits, COUNT(*) as total_events FROM watch_events WHERE is_processed = true`, prisma.$queryRaw`SELECT SUM(duration) as total_seconds, SUM(credits_earned) as total_credits, COUNT(*) as total_events FROM watch_events WHERE is_processed = true`,
prisma.$queryRaw`SELECT DATE(created_at) as date, COUNT(DISTINCT user_id) as active_users, SUM(CASE WHEN type = 'EARN' THEN amount ELSE 0 END) as earned, SUM(CASE WHEN type = 'SPEND' THEN amount ELSE 0 END) as spent FROM transactions WHERE created_at >= ${thirtyDaysAgo} GROUP BY DATE(created_at) ORDER BY date DESC LIMIT 30`, prisma.$queryRaw`SELECT DATE(created_at) as date, COUNT(DISTINCT user_id) as active_users, SUM(CASE WHEN type = 'EARN' THEN amount ELSE 0 END) as earned, SUM(CASE WHEN type = 'SPEND' THEN amount ELSE 0 END) as spent FROM transactions WHERE created_at >= ${thirtyDaysAgo} GROUP BY DATE(created_at) ORDER BY date DESC LIMIT 30`,
]); ]);
res.json({ res.json({
users: userStats[0], users: userStats[0],
transactions: transactionStats[0], transactions: transactionStats[0],
@@ -234,5 +212,4 @@ router.get(
}); });
}), }),
); );
export { router as adminRouter }; export { router as adminRouter };
+21 -95
View File
@@ -6,24 +6,18 @@ import { asyncHandler } from "../middleware/errorHandler";
import { prisma } from "../utils/prisma"; import { prisma } from "../utils/prisma";
const router = Router(); const router = Router();
const OVERSEER_URL = process.env.OVERSEER_URL || ""; const OVERSEER_URL = process.env.OVERSEER_URL || "";
const OVERSEER_API_KEY = process.env.OVERSEER_API_KEY || ""; const OVERSEER_API_KEY = process.env.OVERSEER_API_KEY || "";
const overseerClient = axios.create({ const overseerClient = axios.create({
baseURL: `${OVERSEER_URL}/api/v1`, baseURL: `${OVERSEER_URL}/api/v1`,
headers: { headers: { "X-Api-Key": OVERSEER_API_KEY },
"X-Api-Key": OVERSEER_API_KEY,
},
}); });
// Get request costs
router.get( router.get(
"/costs", "/costs",
authenticate, authenticate,
asyncHandler(async (_req: AuthenticatedRequest, res) => { asyncHandler(async (_req: AuthenticatedRequest, res) => {
const settings = await prisma.systemSettings.findFirst(); const settings = await prisma.systemSettings.findFirst();
res.json({ res.json({
movie: settings?.movieRequestCost || 500, movie: settings?.movieRequestCost || 500,
tv: settings?.tvRequestCost || 1000, tv: settings?.tvRequestCost || 1000,
@@ -31,27 +25,20 @@ router.get(
}); });
}), }),
); );
// Get user's balance and request availability
router.get( router.get(
"/balance", "/balance",
authenticate, authenticate,
asyncHandler(async (req: AuthenticatedRequest, res) => { asyncHandler(async (req: AuthenticatedRequest, res) => {
const user = await prisma.user.findUnique({ const user = await prisma.user.findUnique({ where: { id: req.user!.id } });
where: { id: req.user!.id }, if (!user)
});
if (!user?.walletAddress) {
return res.json({ return res.json({
hasWallet: false, hasWallet: false,
balance: 0, balance: 0,
canRequest: false, canRequestMovie: false,
canRequestTV: false,
}); });
}
const settings = await prisma.systemSettings.findFirst(); const settings = await prisma.systemSettings.findFirst();
const dbBalance = user.totalEarned - user.totalSpent; const dbBalance = user.totalEarned - user.totalSpent;
res.json({ res.json({
hasWallet: true, hasWallet: true,
balance: dbBalance, balance: dbBalance,
@@ -64,76 +51,44 @@ router.get(
}); });
}), }),
); );
// Search for content
router.get( router.get(
"/search", "/search",
authenticate, authenticate,
asyncHandler(async (req: AuthenticatedRequest, res) => { asyncHandler(async (req: AuthenticatedRequest, res) => {
const { query } = req.query; const { query } = req.query;
if (!query) return res.status(400).json({ error: "Query required" });
if (!query) { const response = await overseerClient.get("/search", { params: { query } });
return res.status(400).json({ error: "Query required" });
}
const response = await overseerClient.get("/search", {
params: { query },
});
res.json(response.data); res.json(response.data);
}), }),
); );
// Request content
router.post( router.post(
"/request", "/request",
authenticate, authenticate,
asyncHandler(async (req: AuthenticatedRequest, res) => { asyncHandler(async (req: AuthenticatedRequest, res) => {
const { mediaType, mediaId, title, seasons } = req.body; const { mediaType, mediaId, title, seasons } = req.body;
if (!mediaType || !mediaId || !title)
if (!mediaType || !mediaId || !title) {
return res.status(400).json({ error: "Missing required fields" }); return res.status(400).json({ error: "Missing required fields" });
} const user = await prisma.user.findUnique({ where: { id: req.user!.id } });
if (!user) return res.status(400).json({ error: "User required" });
const user = await prisma.user.findUnique({
where: { id: req.user!.id },
});
if (!user?.walletAddress) {
return res.status(400).json({ error: "Wallet required" });
}
const settings = await prisma.systemSettings.findFirst(); const settings = await prisma.systemSettings.findFirst();
let cost =
// Calculate cost mediaType === "movie"
let cost = 0; ? settings?.movieRequestCost || 500
if (mediaType === "movie") { : settings?.tvRequestCost || 1000;
cost = settings?.movieRequestCost || 500; if (mediaType === "tv" && seasons && seasons.length > 1)
} else if (mediaType === "tv") {
cost = settings?.tvRequestCost || 1000;
if (seasons && seasons.length > 1) {
cost += (seasons.length - 1) * (settings?.tvPerSeasonCost || 250); cost += (seasons.length - 1) * (settings?.tvPerSeasonCost || 250);
}
}
// Check balance
const dbBalance = user.totalEarned - user.totalSpent; const dbBalance = user.totalEarned - user.totalSpent;
if (dbBalance < cost) { if (dbBalance < cost)
return res.status(400).json({ return res.status(400).json({
error: "Insufficient balance", error: "Insufficient balance",
required: cost, required: cost,
current: dbBalance, current: dbBalance,
}); });
}
// Create request in Overseer first
const overseerRequest = await overseerClient.post("/request", { const overseerRequest = await overseerClient.post("/request", {
mediaType, mediaType,
mediaId, mediaId,
...(seasons && { seasons }), ...(seasons && { seasons }),
}); });
// Create local request record
const request = await prisma.contentRequest.create({ const request = await prisma.contentRequest.create({
data: { data: {
userId: user.id, userId: user.id,
@@ -146,8 +101,6 @@ router.post(
requestedAt: new Date(), requestedAt: new Date(),
}, },
}); });
// Deduct credits immediately to prevent race condition
const transaction = await prisma.transaction.create({ const transaction = await prisma.transaction.create({
data: { data: {
userId: user.id, userId: user.id,
@@ -158,21 +111,15 @@ router.post(
contentTitle: title, contentTitle: title,
}, },
}); });
await prisma.user.update({ await prisma.user.update({
where: { id: user.id }, where: { id: user.id },
data: { data: { totalSpent: { increment: cost } },
totalSpent: { increment: cost },
},
}); });
// Emit real-time spend update
io.to(`user:${user.id}`).emit("credits_spent", { io.to(`user:${user.id}`).emit("credits_spent", {
amount: cost, amount: cost,
title, title,
transaction, transaction,
}); });
res.json({ res.json({
success: true, success: true,
request, request,
@@ -181,38 +128,23 @@ router.post(
}); });
}), }),
); );
// Webhook: Handle Overseer request status changes
router.post( router.post(
"/webhook", "/webhook",
asyncHandler(async (req, res) => { asyncHandler(async (req, res) => {
const { request_id, status } = req.body; const { request_id, status } = req.body;
if (!request_id || !status)
if (!request_id || !status) {
return res.status(400).json({ error: "Missing fields" }); return res.status(400).json({ error: "Missing fields" });
}
// Find local request
const request = await prisma.contentRequest.findFirst({ const request = await prisma.contentRequest.findFirst({
where: { overseerRequestId: request_id }, where: { overseerRequestId: request_id },
include: { user: true }, include: { user: true },
}); });
if (!request) return res.status(404).json({ error: "Request not found" });
if (!request) {
return res.status(404).json({ error: "Request not found" });
}
// Update status
const updatedRequest = await prisma.contentRequest.update({ const updatedRequest = await prisma.contentRequest.update({
where: { id: request.id }, where: { id: request.id },
data: { status }, data: { status },
}); });
// Credits were already deducted at request time.
// On approval: just confirm and link transaction to request.
// On decline: refund credits back to user.
if (status === "DECLINED") { if (status === "DECLINED") {
const refundTransaction = await prisma.transaction.create({ await prisma.transaction.create({
data: { data: {
userId: request.userId, userId: request.userId,
type: "ADJUSTMENT", type: "ADJUSTMENT",
@@ -221,21 +153,15 @@ router.post(
contentTitle: request.title, contentTitle: request.title,
}, },
}); });
await prisma.user.update({ await prisma.user.update({
where: { id: request.userId }, where: { id: request.userId },
data: { data: { totalSpent: { decrement: request.creditsCost } },
totalSpent: { decrement: request.creditsCost },
},
}); });
io.to(`user:${request.userId}`).emit("bonus_received", { io.to(`user:${request.userId}`).emit("bonus_received", {
amount: request.creditsCost, amount: request.creditsCost,
reason: `Refunded: ${request.title}`, reason: `Refunded: ${request.title}`,
transaction: refundTransaction,
}); });
} }
res.json({ success: true, request: updatedRequest }); res.json({ success: true, request: updatedRequest });
}), }),
); );
+71 -113
View File
@@ -1,193 +1,151 @@
import { Router } from 'express'; import { Router } from "express";
import { authenticate, AuthenticatedRequest, requireAdmin } from '../middleware/auth'; import {
import { prisma } from '../utils/prisma'; type AuthenticatedRequest,
import { asyncHandler } from '../middleware/errorHandler'; authenticate,
requireAdmin,
} from "../middleware/auth";
import { asyncHandler } from "../middleware/errorHandler";
import { prisma } from "../utils/prisma";
const router = Router(); const router = Router();
// Get user's transactions router.get(
router.get('/', "/",
authenticate, authenticate,
asyncHandler(async (req: AuthenticatedRequest, res) => { asyncHandler(async (req: AuthenticatedRequest, res) => {
const { page = '1', limit = '20', type } = req.query; const { page = "1", limit = "20", type } = req.query;
const pageNum = parseInt(page as string); const pageNum = parseInt(page as string);
const limitNum = Math.min(parseInt(limit as string), 100); const limitNum = Math.min(parseInt(limit as string), 100);
const skip = (pageNum - 1) * limitNum; const skip = (pageNum - 1) * limitNum;
const where: any = { userId: req.user!.id }; const where: any = { userId: req.user!.id };
if (type) { if (type) where.type = type;
where.type = type;
}
const [transactions, total] = await Promise.all([ const [transactions, total] = await Promise.all([
prisma.transaction.findMany({ prisma.transaction.findMany({
where, where,
orderBy: { createdAt: 'desc' }, orderBy: { createdAt: "desc" },
skip, skip,
take: limitNum, take: limitNum,
include: { include: {
watchEvent: { watchEvent: { select: { duration: true, percentComplete: true } },
select: { request: { select: { mediaType: true, status: true } },
duration: true,
percentComplete: true
}
}, },
request: {
select: {
mediaType: true,
status: true
}
}
}
}), }),
prisma.transaction.count({ where }) prisma.transaction.count({ where }),
]); ]);
res.json({ res.json({
transactions, transactions,
pagination: { pagination: {
page: pageNum, page: pageNum,
limit: limitNum, limit: limitNum,
total, total,
totalPages: Math.ceil(total / limitNum) totalPages: Math.ceil(total / limitNum),
} },
}); });
}) }),
); );
router.get(
// Get transaction stats "/stats",
router.get('/stats',
authenticate, authenticate,
asyncHandler(async (req: AuthenticatedRequest, res) => { asyncHandler(async (req: AuthenticatedRequest, res) => {
const thirtyDaysAgo = new Date(); const thirtyDaysAgo = new Date();
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30); thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
const [totalStats, recentStats, byType] = await Promise.all([
const [
totalStats,
recentStats,
byType
] = await Promise.all([
// All time stats
prisma.transaction.groupBy({ prisma.transaction.groupBy({
by: ['type'], by: ["type"],
where: { userId: req.user!.id }, where: { userId: req.user!.id },
_sum: { amount: true }, _sum: { amount: true },
_count: { id: true } _count: { id: true },
}), }),
// Last 30 days
prisma.transaction.groupBy({ prisma.transaction.groupBy({
by: ['type'], by: ["type"],
where: { where: { userId: req.user!.id, createdAt: { gte: thirtyDaysAgo } },
userId: req.user!.id,
createdAt: { gte: thirtyDaysAgo }
},
_sum: { amount: true }, _sum: { amount: true },
_count: { id: true } _count: { id: true },
}), }),
// By type breakdown
prisma.transaction.findMany({ prisma.transaction.findMany({
where: { userId: req.user!.id }, where: { userId: req.user!.id },
select: { select: { type: true, amount: true, createdAt: true },
type: true, orderBy: { createdAt: "desc" },
amount: true, take: 100,
createdAt: true }),
},
orderBy: { createdAt: 'desc' },
take: 100
})
]); ]);
const dailyEarnings =
// Calculate daily earnings for chart await prisma.$queryRaw`SELECT DATE(created_at) as date, SUM(CASE WHEN type = 'EARN' THEN amount ELSE 0 END) as earned, SUM(CASE WHEN type = 'SPEND' THEN amount ELSE 0 END) as spent FROM transactions WHERE user_id = ${req.user!.id} AND created_at >= ${thirtyDaysAgo} GROUP BY DATE(created_at) ORDER BY date DESC`;
const dailyEarnings = await prisma.$queryRaw`
SELECT
DATE(created_at) as date,
SUM(CASE WHEN type = 'EARN' THEN amount ELSE 0 END) as earned,
SUM(CASE WHEN type = 'SPEND' THEN amount ELSE 0 END) as spent
FROM transactions
WHERE user_id = ${req.user!.id}
AND created_at >= ${thirtyDaysAgo}
GROUP BY DATE(created_at)
ORDER BY date DESC
`;
res.json({ res.json({
total: totalStats, total: totalStats,
recent: recentStats, recent: recentStats,
dailyEarnings, dailyEarnings,
recentTransactions: byType.slice(0, 10) recentTransactions: byType.slice(0, 10),
}); });
}) }),
); );
router.get(
// Admin: Get all transactions "/admin/all",
router.get('/admin/all',
authenticate, authenticate,
requireAdmin, requireAdmin,
asyncHandler(async (req: AuthenticatedRequest, res) => { asyncHandler(async (req: AuthenticatedRequest, res) => {
const { page = '1', limit = '50', userId, type } = req.query; const { page = "1", limit = "50", userId, type } = req.query;
const pageNum = parseInt(page as string); const pageNum = parseInt(page as string);
const limitNum = Math.min(parseInt(limit as string), 100); const limitNum = Math.min(parseInt(limit as string), 100);
const skip = (pageNum - 1) * limitNum; const skip = (pageNum - 1) * limitNum;
const where: any = {}; const where: any = {};
if (userId) where.userId = userId; if (userId) where.userId = userId;
if (type) where.type = type; if (type) where.type = type;
const [transactions, total] = await Promise.all([ const [transactions, total] = await Promise.all([
prisma.transaction.findMany({ prisma.transaction.findMany({
where, where,
orderBy: { createdAt: 'desc' }, orderBy: { createdAt: "desc" },
skip, skip,
take: limitNum, take: limitNum,
include: { include: { user: { select: { plexUsername: true } } },
user: {
select: {
plexUsername: true,
walletAddress: true
}
}
}
}), }),
prisma.transaction.count({ where }) prisma.transaction.count({ where }),
]); ]);
res.json({ res.json({
transactions, transactions,
pagination: { pagination: {
page: pageNum, page: pageNum,
limit: limitNum, limit: limitNum,
total, total,
totalPages: Math.ceil(total / limitNum) totalPages: Math.ceil(total / limitNum),
} },
}); });
}) }),
); );
router.get(
// Get system-wide stats "/admin/stats",
router.get('/admin/stats', authenticate,
// ... (keeping existing code) requireAdmin,
asyncHandler(async (_req: AuthenticatedRequest, res) => {
const [total, recent] = await Promise.all([
prisma.transaction.groupBy({
by: ["type"],
_sum: { amount: true },
_count: { id: true },
}),
prisma.transaction.groupBy({
by: ["type"],
where: {
createdAt: { gte: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000) },
},
_sum: { amount: true },
_count: { id: true },
}),
]);
res.json({ total, recent });
}),
); );
router.get(
// Get recent global activity "/recent",
router.get('/recent',
authenticate, authenticate,
asyncHandler(async (_req: AuthenticatedRequest, res) => { asyncHandler(async (_req: AuthenticatedRequest, res) => {
const transactions = await prisma.transaction.findMany({ const transactions = await prisma.transaction.findMany({
orderBy: { createdAt: 'desc' }, orderBy: { createdAt: "desc" },
take: 10, take: 10,
include: { include: { user: { select: { plexUsername: true } } },
user: {
select: {
plexUsername: true
}
}
}
}); });
res.json({ transactions }); res.json({ transactions });
}) }),
); );
export { router as transactionsRouter }; export { router as transactionsRouter };
+42 -67
View File
@@ -1,12 +1,12 @@
import { Router } from 'express'; import { Router } from "express";
import { authenticate, AuthenticatedRequest } from '../middleware/auth'; import { type AuthenticatedRequest, authenticate } from "../middleware/auth";
import { prisma } from '../utils/prisma'; import { asyncHandler } from "../middleware/errorHandler";
import { asyncHandler } from '../middleware/errorHandler'; import { prisma } from "../utils/prisma";
const router = Router(); const router = Router();
// Get current user profile router.get(
router.get('/me', "/me",
authenticate, authenticate,
asyncHandler(async (req: AuthenticatedRequest, res) => { asyncHandler(async (req: AuthenticatedRequest, res) => {
const user = await prisma.user.findUnique({ const user = await prisma.user.findUnique({
@@ -17,57 +17,41 @@ router.get('/me',
plexUsername: true, plexUsername: true,
email: true, email: true,
isAdmin: true, isAdmin: true,
walletAddress: true,
totalEarned: true, totalEarned: true,
totalSpent: true, totalSpent: true,
watchTimeMinutes: true, watchTimeMinutes: true,
createdAt: true createdAt: true,
} },
}); });
if (!user) return res.status(404).json({ error: "User not found" });
if (!user) {
return res.status(404).json({ error: 'User not found' });
}
res.json(user); res.json(user);
}) }),
); );
router.put(
// Update user profile "/me",
router.put('/me',
authenticate, authenticate,
asyncHandler(async (req: AuthenticatedRequest, res) => { asyncHandler(async (req: AuthenticatedRequest, res) => {
const { email } = req.body; const { email } = req.body;
const user = await prisma.user.update({ const user = await prisma.user.update({
where: { id: req.user!.id }, where: { id: req.user!.id },
data: { email }, data: { email },
select: { select: { id: true, plexUsername: true, email: true },
id: true,
plexUsername: true,
email: true,
walletAddress: true
}
}); });
res.json(user); res.json(user);
}) }),
); );
router.get(
// Get user's watch history "/me/watch-history",
router.get('/me/watch-history',
authenticate, authenticate,
asyncHandler(async (req: AuthenticatedRequest, res) => { asyncHandler(async (req: AuthenticatedRequest, res) => {
const { page = '1', limit = '20' } = req.query; const { page = "1", limit = "20" } = req.query;
const pageNum = parseInt(page as string); const pageNum = parseInt(page as string);
const limitNum = Math.min(parseInt(limit as string), 50); const limitNum = Math.min(parseInt(limit as string), 50);
const skip = (pageNum - 1) * limitNum; const skip = (pageNum - 1) * limitNum;
const [events, total] = await Promise.all([ const [events, total] = await Promise.all([
prisma.watchEvent.findMany({ prisma.watchEvent.findMany({
where: { userId: req.user!.id }, where: { userId: req.user!.id },
orderBy: { watchedAt: 'desc' }, orderBy: { watchedAt: "desc" },
skip, skip,
take: limitNum, take: limitNum,
select: { select: {
@@ -79,87 +63,78 @@ router.get('/me/watch-history',
percentComplete: true, percentComplete: true,
creditsEarned: true, creditsEarned: true,
isProcessed: true, isProcessed: true,
watchedAt: true watchedAt: true,
} },
}), }),
prisma.watchEvent.count({ where: { userId: req.user!.id } }) prisma.watchEvent.count({ where: { userId: req.user!.id } }),
]); ]);
res.json({ res.json({
events, events,
pagination: { pagination: {
page: pageNum, page: pageNum,
limit: limitNum, limit: limitNum,
total, total,
totalPages: Math.ceil(total / limitNum) totalPages: Math.ceil(total / limitNum),
} },
}); });
}) }),
); );
router.get(
// Get user's content requests "/me/requests",
router.get('/me/requests',
authenticate, authenticate,
asyncHandler(async (req: AuthenticatedRequest, res) => { asyncHandler(async (req: AuthenticatedRequest, res) => {
const { page = '1', limit = '20', status } = req.query; const { page = "1", limit = "20", status } = req.query;
const pageNum = parseInt(page as string); const pageNum = parseInt(page as string);
const limitNum = Math.min(parseInt(limit as string), 50); const limitNum = Math.min(parseInt(limit as string), 50);
const skip = (pageNum - 1) * limitNum; const skip = (pageNum - 1) * limitNum;
const where: any = { userId: req.user!.id }; const where: any = { userId: req.user!.id };
if (status) where.status = status; if (status) where.status = status;
const [requests, total] = await Promise.all([ const [requests, total] = await Promise.all([
prisma.contentRequest.findMany({ prisma.contentRequest.findMany({
where, where,
orderBy: { requestedAt: 'desc' }, orderBy: { requestedAt: "desc" },
skip, skip,
take: limitNum take: limitNum,
}), }),
prisma.contentRequest.count({ where }) prisma.contentRequest.count({ where }),
]); ]);
res.json({ res.json({
requests, requests,
pagination: { pagination: {
page: pageNum, page: pageNum,
limit: limitNum, limit: limitNum,
total, total,
totalPages: Math.ceil(total / limitNum) totalPages: Math.ceil(total / limitNum),
} },
}); });
}) }),
); );
router.get(
// Get leaderboard "/leaderboard",
router.get('/leaderboard',
authenticate, authenticate,
asyncHandler(async (_req: AuthenticatedRequest, res) => { asyncHandler(async (_req: AuthenticatedRequest, res) => {
const topEarners = await prisma.user.findMany({ const topEarners = await prisma.user.findMany({
orderBy: { totalEarned: 'desc' }, orderBy: { totalEarned: "desc" },
take: 10, take: 10,
select: { select: {
id: true, id: true,
plexUsername: true, plexUsername: true,
totalEarned: true, totalEarned: true,
watchTimeMinutes: true watchTimeMinutes: true,
} },
}); });
const topWatchers = await prisma.user.findMany({ const topWatchers = await prisma.user.findMany({
orderBy: { watchTimeMinutes: 'desc' }, orderBy: { watchTimeMinutes: "desc" },
take: 10, take: 10,
select: { select: {
id: true, id: true,
plexUsername: true, plexUsername: true,
totalEarned: true, totalEarned: true,
watchTimeMinutes: true watchTimeMinutes: true,
} },
}); });
res.json({ topEarners, topWatchers }); res.json({ topEarners, topWatchers });
}) }),
); );
export { router as userRouter }; export { router as userRouter };
+3 -27
View File
@@ -21,55 +21,37 @@ router.post(
asyncHandler(async (req, res) => { asyncHandler(async (req, res) => {
const webhookSignature = req.headers["x-tautulli-signature"] as string; const webhookSignature = req.headers["x-tautulli-signature"] as string;
const payload = JSON.stringify(req.body); const payload = JSON.stringify(req.body);
if ( if (
WEBHOOK_SECRET && WEBHOOK_SECRET &&
webhookSignature && webhookSignature &&
!verifyWebhookSignature(payload, webhookSignature) !verifyWebhookSignature(payload, webhookSignature)
) { )
return res.status(401).json({ error: "Invalid signature" }); return res.status(401).json({ error: "Invalid signature" });
}
const event = req.body; const event = req.body;
if (event.action !== "watched") if (event.action !== "watched")
return res.json({ message: "Event type not processed" }); return res.json({ message: "Event type not processed" });
if (!event.user_id || !event.rating_key || !event.session_key) if (!event.user_id || !event.rating_key || !event.session_key)
return res.status(400).json({ error: "Missing required fields" }); return res.status(400).json({ error: "Missing required fields" });
const user = await prisma.user.findUnique({ const user = await prisma.user.findUnique({
where: { plexId: event.user_id.toString() }, where: { plexId: event.user_id.toString() },
}); });
if (!user) return res.status(404).json({ error: "User not found" }); if (!user) return res.status(404).json({ error: "User not found" });
if (!user.walletAddress)
return res.status(400).json({ error: "User has no wallet" });
const existing = await prisma.watchEvent.findUnique({ const existing = await prisma.watchEvent.findUnique({
where: { sessionId: event.session_key.toString() }, where: { sessionId: event.session_key.toString() },
}); });
if (existing) return res.json({ message: "Event already processed" }); if (existing) return res.json({ message: "Event already processed" });
const settings = await prisma.systemSettings.findFirst(); const settings = await prisma.systemSettings.findFirst();
const creditsPerMinute = settings?.creditsPerMinute || 2; const creditsPerMinute = settings?.creditsPerMinute || 2;
const minWatchPercent = settings?.minWatchPercent || 80; const minWatchPercent = settings?.minWatchPercent || 80;
const minWatchMinutes = settings?.minWatchMinutes || 5; const minWatchMinutes = settings?.minWatchMinutes || 5;
const watchDurationMinutes = Math.floor( const watchDurationMinutes = Math.floor(
(event.stopped - event.started) / 60, (event.stopped - event.started) / 60,
); );
const percentComplete = event.percent_complete || 0; const percentComplete = event.percent_complete || 0;
if (percentComplete < minWatchPercent) if (percentComplete < minWatchPercent)
return res.json({ return res.json({ message: "Watch percentage too low" });
message: "Watch percentage too low",
percentComplete,
required: minWatchPercent,
});
if (watchDurationMinutes < minWatchMinutes) if (watchDurationMinutes < minWatchMinutes)
return res.json({ return res.json({ message: "Watch duration too short" });
message: "Watch duration too short",
watchDurationMinutes,
required: minWatchMinutes,
});
let creditsEarned = watchDurationMinutes * creditsPerMinute; let creditsEarned = watchDurationMinutes * creditsPerMinute;
if (settings?.newReleaseMultiplier && event.is_new) if (settings?.newReleaseMultiplier && event.is_new)
creditsEarned = Math.floor( creditsEarned = Math.floor(
@@ -79,7 +61,6 @@ router.post(
creditsEarned = Math.floor( creditsEarned = Math.floor(
creditsEarned * Number(settings.bonusMultiplier), creditsEarned * Number(settings.bonusMultiplier),
); );
const watchEvent = await prisma.watchEvent.create({ const watchEvent = await prisma.watchEvent.create({
data: { data: {
userId: user.id, userId: user.id,
@@ -94,7 +75,6 @@ router.post(
watchedAt: new Date(event.stopped * 1000), watchedAt: new Date(event.stopped * 1000),
}, },
}); });
const transaction = await prisma.transaction.create({ const transaction = await prisma.transaction.create({
data: { data: {
userId: user.id, userId: user.id,
@@ -105,7 +85,6 @@ router.post(
contentTitle: event.title, contentTitle: event.title,
}, },
}); });
await prisma.user.update({ await prisma.user.update({
where: { id: user.id }, where: { id: user.id },
data: { data: {
@@ -113,12 +92,10 @@ router.post(
watchTimeMinutes: { increment: watchDurationMinutes }, watchTimeMinutes: { increment: watchDurationMinutes },
}, },
}); });
await prisma.watchEvent.update({ await prisma.watchEvent.update({
where: { id: watchEvent.id }, where: { id: watchEvent.id },
data: { isProcessed: true }, data: { isProcessed: true },
}); });
io.to(`user:${user.id}`).emit("credits_earned", { io.to(`user:${user.id}`).emit("credits_earned", {
amount: creditsEarned, amount: creditsEarned,
title: event.title, title: event.title,
@@ -130,7 +107,6 @@ router.post(
createdAt: transaction.createdAt, createdAt: transaction.createdAt,
}, },
}); });
res.json({ res.json({
success: true, success: true,
creditsEarned, creditsEarned,
@@ -1,108 +1 @@
'use client'; export function ActivityFeed() { return null; }
import { useEffect, useState } from 'react';
import { transactionApi } from '@/lib/api';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { formatNumber } from '@/lib/utils';
import { Zap, TrendingUp, TrendingDown, Gift } from 'lucide-react';
interface Activity {
id: string;
type: 'EARN' | 'SPEND' | 'BONUS' | 'ADJUSTMENT';
amount: number;
contentTitle: string | null;
user: {
plexUsername: string;
};
createdAt: string;
}
export function ActivityFeed() {
const [activities, setActivities] = useState<Activity[]>([]);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
loadActivity();
// Refresh every minute
const interval = setInterval(loadActivity, 60000);
return () => clearInterval(interval);
}, []);
const loadActivity = async () => {
try {
const response = await transactionApi.getRecentActivity();
setActivities(response.data.transactions);
} catch (error) {
console.error('Failed to load global activity:', error);
} finally {
setIsLoading(false);
}
};
const getIcon = (type: string) => {
switch (type) {
case 'EARN': return <TrendingUp className="h-3 w-3 text-green-500" />;
case 'SPEND': return <TrendingDown className="h-3 w-3 text-red-500" />;
case 'BONUS': return <Gift className="h-3 w-3 text-purple-500" />;
default: return <Zap className="h-3 w-3 text-primary" />;
}
};
if (isLoading && activities.length === 0) {
return (
<Card className="h-full">
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium flex items-center gap-2">
<Zap className="h-4 w-4 text-primary" />
Global Activity
</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-4 animate-pulse">
{[1, 2, 3, 4, 5].map((i) => (
<div key={i} className="h-10 bg-muted rounded-md" />
))}
</div>
</CardContent>
</Card>
);
}
return (
<Card className="h-full overflow-hidden border-none bg-muted/30 shadow-none">
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium flex items-center gap-2">
<Zap className="h-4 w-4 text-primary" />
Global Activity
</CardTitle>
</CardHeader>
<CardContent className="px-4">
<div className="space-y-3">
{activities.map((activity) => (
<div key={activity.id} className="flex items-start gap-3 text-xs">
<div className="mt-1">
{getIcon(activity.type)}
</div>
<div className="flex-1 min-w-0">
<p className="font-semibold truncate text-foreground/90">
{activity.user.plexUsername}
</p>
<p className="text-muted-foreground truncate">
{activity.type === 'EARN' ? 'earned' : activity.type === 'SPEND' ? 'spent' : 'received'} {' '}
<span className={activity.type === 'EARN' || activity.type === 'BONUS' ? 'text-green-500' : 'text-red-500'}>
{formatNumber(activity.amount)} $COOP
</span>
</p>
{activity.contentTitle && (
<p className="text-[10px] text-muted-foreground/60 truncate italic">
{activity.contentTitle}
</p>
)}
</div>
</div>
))}
</div>
</CardContent>
</Card>
);
}
@@ -1,162 +0,0 @@
'use client';
import { useState } from 'react';
import { walletApi } from '@/lib/api';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { Copy, Check, AlertTriangle, Loader2 } from 'lucide-react';
import { toast } from 'sonner';
interface CreateWalletModalProps {
open: boolean;
onClose: () => void;
onCreated: () => void;
}
export function CreateWalletModal({ open, onClose, onCreated }: CreateWalletModalProps) {
const [step, setStep] = useState<'create' | 'backup' | 'success'>('create');
const [privateKey, setPrivateKey] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [copied, setCopied] = useState(false);
const handleCreate = async () => {
setIsLoading(true);
try {
const response = await walletApi.createWallet();
setPrivateKey(response.data.privateKey || '');
setStep('backup');
toast.success('Wallet created successfully!');
onCreated();
} catch (error) {
toast.error('Failed to create wallet');
} finally {
setIsLoading(false);
}
};
const handleBackup = async () => {
try {
const response = await walletApi.backupWallet();
setPrivateKey(response.data.privateKey);
setStep('backup');
} catch (error) {
toast.error('Failed to get backup');
}
};
const copyToClipboard = () => {
navigator.clipboard.writeText(privateKey);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
toast.success('Copied to clipboard');
};
const handleClose = () => {
setStep('create');
setPrivateKey('');
setCopied(false);
onClose();
};
return (
<Dialog open={open} onOpenChange={handleClose}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>
{step === 'create' && 'Create Wallet'}
{step === 'backup' && 'Backup Your Wallet'}
{step === 'success' && 'Wallet Ready!'}
</DialogTitle>
<DialogDescription>
{step === 'create' && 'Create a new Solana wallet to store your $COOP tokens'}
{step === 'backup' && 'Save this private key securely. You will need it to recover your wallet.'}
{step === 'success' && 'Your wallet is ready to use!'}
</DialogDescription>
</DialogHeader>
{step === 'create' && (
<div className="space-y-4">
<Alert>
<AlertTriangle className="h-4 w-4" />
<AlertDescription>
You will be shown a private key. Store it securely - it cannot be recovered!
</AlertDescription>
</Alert>
<Button onClick={handleCreate} disabled={isLoading} className="w-full">
{isLoading ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Creating...
</>
) : (
'Create New Wallet'
)}
</Button>
<div className="text-center">
<span className="text-sm text-muted-foreground">or</span>
</div>
<Button variant="outline" onClick={handleBackup} className="w-full">
Show Existing Backup
</Button>
</div>
)}
{step === 'backup' && (
<div className="space-y-4">
<Alert variant="destructive">
<AlertTriangle className="h-4 w-4" />
<AlertDescription>
Never share this private key with anyone. Store it in a secure password manager.
</AlertDescription>
</Alert>
<div className="space-y-2">
<Label>Private Key</Label>
<div className="flex gap-2">
<Input
type="password"
value={privateKey}
readOnly
className="font-mono"
/>
<Button
size="icon"
variant="outline"
onClick={copyToClipboard}
>
{copied ? <Check className="h-4 w-4" /> : <Copy className="h-4 w-4" />}
</Button>
</div>
</div>
<Button onClick={() => setStep('success')} className="w-full">
I have saved my private key
</Button>
</div>
)}
{step === 'success' && (
<div className="space-y-4 text-center">
<div className="mx-auto w-12 h-12 bg-green-500/10 rounded-full flex items-center justify-center">
<Check className="h-6 w-6 text-green-500" />
</div>
<p className="text-muted-foreground">
Your wallet has been created and funded with 2 SOL for transaction fees.
Start watching content on Plex to earn $COOP!
</p>
<Button onClick={handleClose} className="w-full">
Start Earning
</Button>
</div>
)}
</DialogContent>
</Dialog>
);
}
@@ -1,144 +1 @@
"use client"; export function Leaderboard() { return null; }
import { Clock, Coins, Medal, Star, Trophy } from "lucide-react";
import { useEffect, useState } from "react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { userApi } from "@/lib/api";
import { formatDuration, formatNumber } from "@/lib/utils";
interface LeaderboardUser {
id: string;
plexUsername: string;
totalEarned: number;
watchTimeMinutes: number;
}
export function Leaderboard() {
const [data, setData] = useState<{
topEarners: LeaderboardUser[];
topWatchers: LeaderboardUser[];
}>({
topEarners: [],
topWatchers: [],
});
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
loadLeaderboard();
}, []);
const loadLeaderboard = async () => {
try {
const response = await userApi.getLeaderboard();
setData(response.data);
} catch (error) {
console.error("Failed to load leaderboard:", error);
} finally {
setIsLoading(false);
}
};
const getRankIcon = (index: number) => {
switch (index) {
case 0:
return <Trophy className="h-4 w-4 text-yellow-500" />;
case 1:
return <Medal className="h-4 w-4 text-muted-foreground" />;
case 2:
return <Medal className="h-4 w-4 text-amber-600" />;
default:
return (
<span className="w-4 text-center text-xs text-muted-foreground">
{index + 1}
</span>
);
}
};
if (isLoading) {
return (
<Card>
<CardContent className="py-8 text-center text-muted-foreground">
<div className="animate-pulse space-y-4">
{[1, 2, 3, 4, 5].map((i) => (
<div key={i} className="h-10 bg-muted rounded-md" />
))}
</div>
</CardContent>
</Card>
);
}
return (
<Card className="border-none bg-muted/30 shadow-none">
<CardHeader className="pb-2">
<CardTitle className="text-lg font-bold flex items-center gap-2">
<Trophy className="h-5 w-5 text-yellow-500" />
Leaderboard
</CardTitle>
</CardHeader>
<CardContent>
<Tabs defaultValue="earners" className="w-full">
<TabsList className="grid w-full grid-cols-2 mb-4 h-8 bg-background/50">
<TabsTrigger value="earners" className="text-xs py-1">
Top Earners
</TabsTrigger>
<TabsTrigger value="watchers" className="text-xs py-1">
Top Watchers
</TabsTrigger>
</TabsList>
<TabsContent value="earners" className="mt-0">
<div className="space-y-2">
{data.topEarners.map((user, index) => (
<div
key={user.id}
className="flex items-center justify-between p-2 rounded-lg bg-background/40 hover:bg-background/60 transition-colors"
>
<div className="flex items-center gap-3">
<div className="w-6 flex justify-center">
{getRankIcon(index)}
</div>
<span className="text-sm font-medium">
{user.plexUsername}
</span>
</div>
<div className="flex items-center gap-1 text-xs font-bold text-green-500">
<Coins className="h-3 w-3" />
{formatNumber(user.totalEarned)}
</div>
</div>
))}
</div>
</TabsContent>
<TabsContent value="watchers" className="mt-0">
<div className="space-y-2">
{data.topWatchers.map((user, index) => (
<div
key={user.id}
className="flex items-center justify-between p-2 rounded-lg bg-background/40 hover:bg-background/60 transition-colors"
>
<div className="flex items-center gap-3">
<div className="w-6 flex justify-center">
{getRankIcon(index)}
</div>
<span className="text-sm font-medium">
{user.plexUsername}
</span>
</div>
<div className="flex items-center gap-1 text-xs font-medium text-muted-foreground">
<Clock className="h-3 w-3" />
{Math.floor(user.watchTimeMinutes / 60)}h{" "}
{user.watchTimeMinutes % 60}m
</div>
</div>
))}
</div>
</TabsContent>
</Tabs>
</CardContent>
</Card>
);
}
@@ -1,120 +1 @@
'use client'; export function RequestHistory() { return null; }
import { useEffect, useState } from 'react';
import { userApi } from '@/lib/api';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { formatDate } from '@/lib/utils';
import { Film, Tv, Clock, CheckCircle2, AlertCircle, Loader2 } from 'lucide-react';
interface ContentRequest {
id: string;
mediaType: string;
mediaId: number;
title: string;
status: 'PENDING' | 'APPROVED' | 'PROCESSING' | 'AVAILABLE' | 'DECLINED' | 'FAILED';
creditsCost: number;
requestedAt: string;
}
export function RequestHistory() {
const [requests, setRequests] = useState<ContentRequest[]>([]);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
loadRequests();
}, []);
const loadRequests = async () => {
try {
const response = await userApi.getRequests();
setRequests(response.data.requests);
} catch (error) {
console.error('Failed to load requests:', error);
} finally {
setIsLoading(false);
}
};
const getStatusIcon = (status: string) => {
switch (status) {
case 'AVAILABLE': return <CheckCircle2 className="h-4 w-4 text-green-500" />;
case 'DECLINED':
case 'FAILED': return <AlertCircle className="h-4 w-4 text-red-500" />;
case 'PROCESSING': return <Loader2 className="h-4 w-4 text-yellow-500 animate-spin" />;
default: return <Clock className="h-4 w-4 text-muted-foreground" />;
}
};
const getStatusBadge = (status: string) => {
switch (status) {
case 'AVAILABLE': return <Badge className="bg-green-500/10 text-green-500 border-green-500/20">Available</Badge>;
case 'PROCESSING': return <Badge className="bg-yellow-500/10 text-yellow-500 border-yellow-500/20">Processing</Badge>;
case 'APPROVED': return <Badge className="bg-blue-500/10 text-blue-500 border-blue-500/20">Approved</Badge>;
case 'DECLINED': return <Badge className="bg-red-500/10 text-red-500 border-red-500/20">Declined</Badge>;
default: return <Badge variant="outline">{status}</Badge>;
}
};
if (isLoading) {
return (
<Card>
<CardContent className="py-8 text-center text-muted-foreground">
Loading requests...
</CardContent>
</Card>
);
}
if (requests.length === 0) {
return (
<Card>
<CardContent className="py-8 text-center text-muted-foreground">
No requests yet. Use your $COOP to add content to the server!
</CardContent>
</Card>
);
}
return (
<Card>
<CardHeader>
<CardTitle>My Content Requests</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-4">
{requests.map((request) => (
<div
key={request.id}
className="flex items-center justify-between p-4 rounded-lg bg-muted/50"
>
<div className="flex items-center gap-4">
<div className="p-2 rounded-full bg-primary/10 text-primary">
{request.mediaType === 'movie' ? <Film className="h-4 w-4" /> : <Tv className="h-4 w-4" />}
</div>
<div>
<p className="font-medium">{request.title}</p>
<div className="flex items-center gap-4 mt-1 text-sm text-muted-foreground">
<span className="flex items-center gap-1">
{getStatusIcon(request.status)}
{getStatusBadge(request.status)}
</span>
<span>Requested {formatDate(request.requestedAt)}</span>
</div>
</div>
</div>
<div className="text-right">
<p className="font-bold text-primary">
{request.creditsCost} $COOP
</p>
<p className="text-[10px] text-muted-foreground uppercase tracking-widest font-bold">
Cost
</p>
</div>
</div>
))}
</div>
</CardContent>
</Card>
);
}
@@ -1,222 +0,0 @@
'use client';
import { useState, useEffect } from 'react';
import { overseerApi } from '@/lib/api';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Badge } from '@/components/ui/badge';
import { Search, Loader2, Film, Tv, Plus, CheckCircle2, AlertCircle } from 'lucide-react';
import { toast } from 'sonner';
interface SearchResult {
id: number;
mediaType: 'movie' | 'tv';
title?: string;
name?: string;
overview: string;
posterPath: string;
releaseDate?: string;
firstAirDate?: string;
mediaInfo?: {
status: number; // 1 = unknown, 2 = pending, 3 = processing, 4 = partially available, 5 = available
};
}
interface SearchRequestModalProps {
open: boolean;
onClose: () => void;
onRequested: () => void;
costs: { movie: number; tv: number };
balance: number;
}
const OVERSEER_IMAGE_BASE = 'https://image.tmdb.org/t/p/w200';
export function SearchRequestModal({ open, onClose, onRequested, costs, balance }: SearchRequestModalProps) {
const [query, setQuery] = useState('');
const [results, setResults] = useState<SearchResult[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [isRequesting, setIsRequesting] = useState<number | null>(null);
useEffect(() => {
const timer = setTimeout(() => {
if (query.length > 2) {
handleSearch();
}
}, 500);
return () => clearTimeout(timer);
}, [query]);
const handleSearch = async () => {
setIsLoading(true);
try {
const response = await overseerApi.search(query);
setResults(response.data.results || []);
} catch (error) {
console.error('Search failed:', error);
} finally {
setIsLoading(false);
}
};
const handleRequest = async (item: SearchResult) => {
const cost = item.mediaType === 'movie' ? costs.movie : costs.tv;
if (balance < cost) {
toast.error('Insufficient balance', {
description: `You need ${cost} $COOP to request this ${item.mediaType}.`,
});
return;
}
setIsRequesting(item.id);
try {
await overseerApi.request({
mediaType: item.mediaType,
mediaId: item.id,
title: item.title || item.name || 'Unknown',
});
toast.success('Request submitted!', {
description: `${item.title || item.name} has been added to the queue.`,
});
onRequested();
// Optionally close or clear results
} catch (error: any) {
toast.error(error.response?.data?.error || 'Failed to submit request');
} finally {
setIsRequesting(null);
}
};
const getStatusBadge = (status?: number) => {
switch (status) {
case 5: return <Badge className="bg-green-500">Available</Badge>;
case 4: return <Badge className="bg-blue-500">Partially Available</Badge>;
case 3: return <Badge className="bg-yellow-500">Processing</Badge>;
case 2: return <Badge className="bg-purple-500">Pending</Badge>;
default: return null;
}
};
return (
<Dialog open={open} onOpenChange={onClose}>
<DialogContent className="sm:max-w-[600px] h-[80vh] flex flex-col p-0 overflow-hidden">
<DialogHeader className="p-6 pb-0">
<DialogTitle>Request Content</DialogTitle>
<DialogDescription>
Search for movies or TV shows to add to the server.
<span className="block mt-1 font-semibold text-primary">
Costs: {costs.movie} $COOP (Movie) / {costs.tv} $COOP (TV)
</span>
</DialogDescription>
<div className="relative mt-4">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Search for movies or shows..."
value={query}
onChange={(e) => setQuery(e.target.value)}
className="pl-10 h-11"
autoFocus
/>
</div>
</DialogHeader>
<div className="flex-1 p-6 overflow-y-auto">
{isLoading ? (
<div className="flex flex-col items-center justify-center py-12 gap-2 text-muted-foreground">
<Loader2 className="h-8 w-8 animate-spin" />
<p>Searching Overseer...</p>
</div>
) : results.length > 0 ? (
<div className="space-y-4">
{results.map((item) => (
<div key={`${item.mediaType}-${item.id}`} className="flex gap-4 p-3 rounded-xl bg-muted/30 border border-transparent hover:border-primary/20 transition-colors group">
<div className="flex-none w-20 h-30 bg-muted rounded-md overflow-hidden relative">
{item.posterPath ? (
<img
src={`${OVERSEER_IMAGE_BASE}${item.posterPath}`}
alt={item.title || item.name}
className="w-full h-full object-cover"
/>
) : (
<div className="w-full h-full flex items-center justify-center">
{item.mediaType === 'movie' ? <Film className="h-8 w-8 opacity-20" /> : <Tv className="h-8 w-8 opacity-20" />}
</div>
)}
</div>
<div className="flex-1 min-w-0 flex flex-col justify-between py-1">
<div>
<div className="flex items-start justify-between gap-2">
<h4 className="font-bold truncate group-hover:text-primary transition-colors">
{item.title || item.name}
</h4>
{getStatusBadge(item.mediaInfo?.status)}
</div>
<div className="flex items-center gap-2 mt-1 text-xs text-muted-foreground uppercase font-semibold">
{item.mediaType === 'movie' ? <Film className="h-3 w-3" /> : <Tv className="h-3 w-3" />}
{item.mediaType}
<span></span>
{item.releaseDate || item.firstAirDate || 'N/A'}
</div>
<p className="text-xs text-muted-foreground line-clamp-2 mt-2">
{item.overview}
</p>
</div>
<div className="mt-3 flex items-center justify-between">
<div className="text-xs font-bold text-primary">
{item.mediaType === 'movie' ? costs.movie : costs.tv} $COOP
</div>
{item.mediaInfo?.status && item.mediaInfo.status >= 4 ? (
<Button disabled size="sm" variant="ghost" className="h-8 px-3 text-green-500">
<CheckCircle2 className="mr-2 h-4 w-4" />
In Library
</Button>
) : (
<Button
size="sm"
onClick={() => handleRequest(item)}
disabled={isRequesting === item.id || balance < (item.mediaType === 'movie' ? costs.movie : costs.tv)}
className="h-8 px-4 rounded-full"
>
{isRequesting === item.id ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<>
<Plus className="mr-2 h-4 w-4" />
Request
</>
)}
</Button>
)}
</div>
</div>
</div>
))}
</div>
) : query.length > 2 ? (
<div className="flex flex-col items-center justify-center py-12 text-muted-foreground">
<AlertCircle className="h-12 w-12 opacity-20 mb-4" />
<p>No results found for "{query}"</p>
</div>
) : (
<div className="flex flex-col items-center justify-center py-12 text-muted-foreground">
<Search className="h-12 w-12 opacity-10 mb-4" />
<p>Type to search for movies and shows</p>
</div>
)}
</div>
</DialogContent>
</Dialog>
);
}
@@ -1,139 +0,0 @@
'use client';
import { useEffect, useState } from 'react';
import { transactionApi } from '@/lib/api';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { formatNumber, formatDate } from '@/lib/utils';
import { TrendingUp, TrendingDown, Gift, ArrowRightLeft } from 'lucide-react';
interface Transaction {
id: string;
type: 'EARN' | 'SPEND' | 'BONUS' | 'ADJUSTMENT';
amount: number;
description: string | null;
contentTitle: string | null;
createdAt: string;
solanaSignature: string | null;
}
export function TransactionList() {
const [transactions, setTransactions] = useState<Transaction[]>([]);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
loadTransactions();
}, []);
const loadTransactions = async () => {
try {
const response = await transactionApi.getTransactions();
setTransactions(response.data.transactions);
} catch (error) {
console.error('Failed to load transactions:', error);
} finally {
setIsLoading(false);
}
};
const getTransactionIcon = (type: string) => {
switch (type) {
case 'EARN':
return <TrendingUp className="h-4 w-4 text-green-500" />;
case 'SPEND':
return <TrendingDown className="h-4 w-4 text-red-500" />;
case 'BONUS':
return <Gift className="h-4 w-4 text-purple-500" />;
default:
return <ArrowRightLeft className="h-4 w-4 text-gray-500" />;
}
};
const getTransactionColor = (type: string) => {
switch (type) {
case 'EARN':
return 'bg-green-500/10 text-green-500';
case 'SPEND':
return 'bg-red-500/10 text-red-500';
case 'BONUS':
return 'bg-purple-500/10 text-purple-500';
default:
return 'bg-gray-500/10 text-gray-500';
}
};
if (isLoading) {
return (
<Card>
<CardContent className="py-8 text-center text-muted-foreground">
Loading transactions...
</CardContent>
</Card>
);
}
if (transactions.length === 0) {
return (
<Card>
<CardContent className="py-8 text-center text-muted-foreground">
No transactions yet. Start watching content to earn $COOP!
</CardContent>
</Card>
);
}
return (
<Card>
<CardHeader>
<CardTitle>Recent Transactions</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-4">
{transactions.map((tx) => (
<div
key={tx.id}
className="flex items-center justify-between p-4 rounded-lg bg-muted/50"
>
<div className="flex items-center gap-4">
<div className={`p-2 rounded-full ${getTransactionColor(tx.type)}`}>
{getTransactionIcon(tx.type)}
</div>
<div>
<p className="font-medium">
{tx.contentTitle || tx.description || tx.type}
</p>
<p className="text-sm text-muted-foreground">
{formatDate(tx.createdAt)}
</p>
{tx.solanaSignature && (
<a
href={`https://explorer.solana.com/tx/${tx.solanaSignature}?cluster=devnet`}
target="_blank"
rel="noopener noreferrer"
className="text-xs text-blue-500 hover:underline"
>
View on Explorer
</a>
)}
</div>
</div>
<div className="text-right">
<p className={`font-bold ${
tx.type === 'EARN' || tx.type === 'BONUS'
? 'text-green-500'
: 'text-red-500'
}`}>
{tx.type === 'EARN' || tx.type === 'BONUS' ? '+' : '-'}
{formatNumber(tx.amount)} $COOP
</p>
<Badge variant="outline" className="text-xs">
{tx.type}
</Badge>
</div>
</div>
))}
</div>
</CardContent>
</Card>
);
}
@@ -1,123 +1 @@
'use client'; export function WatchHistory() { return null; }
import { useEffect, useState } from 'react';
import { userApi } from '@/lib/api';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { formatDuration, formatDate, formatNumber } from '@/lib/utils';
import { Film, Tv, CheckCircle, XCircle, Clock } from 'lucide-react';
interface WatchEvent {
id: string;
contentType: string;
title: string;
grandparentTitle: string | null;
duration: number;
percentComplete: number;
creditsEarned: number;
isProcessed: boolean;
watchedAt: string;
}
export function WatchHistory() {
const [events, setEvents] = useState<WatchEvent[]>([]);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
loadHistory();
}, []);
const loadHistory = async () => {
try {
const response = await userApi.getWatchHistory();
setEvents(response.data.events);
} catch (error) {
console.error('Failed to load watch history:', error);
} finally {
setIsLoading(false);
}
};
const getContentIcon = (type: string) => {
return type === 'movie' ? (
<Film className="h-4 w-4" />
) : (
<Tv className="h-4 w-4" />
);
};
if (isLoading) {
return (
<Card>
<CardContent className="py-8 text-center text-muted-foreground">
Loading watch history...
</CardContent>
</Card>
);
}
if (events.length === 0) {
return (
<Card>
<CardContent className="py-8 text-center text-muted-foreground">
No watch history yet. Start watching on Plex!
</CardContent>
</Card>
);
}
return (
<Card>
<CardHeader>
<CardTitle>Watch History</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-4">
{events.map((event) => (
<div
key={event.id}
className="flex items-center justify-between p-4 rounded-lg bg-muted/50"
>
<div className="flex items-center gap-4">
<div className="p-2 rounded-full bg-primary/10 text-primary">
{getContentIcon(event.contentType)}
</div>
<div>
<p className="font-medium">{event.title}</p>
{event.grandparentTitle && (
<p className="text-sm text-muted-foreground">
{event.grandparentTitle}
</p>
)}
<div className="flex items-center gap-4 mt-1 text-sm text-muted-foreground">
<span className="flex items-center gap-1">
<Clock className="h-3 w-3" />
{formatDuration(event.duration)}
</span>
<span>{event.percentComplete}% watched</span>
<span>{formatDate(event.watchedAt)}</span>
</div>
</div>
</div>
<div className="text-right">
{event.isProcessed ? (
<div className="flex items-center gap-2 text-green-500">
<CheckCircle className="h-4 w-4" />
<span className="font-bold">
+{formatNumber(event.creditsEarned)} $COOP
</span>
</div>
) : (
<div className="flex items-center gap-2 text-yellow-500">
<XCircle className="h-4 w-4" />
<span className="text-sm">Pending</span>
</div>
)}
</div>
</div>
))}
</div>
</CardContent>
</Card>
);
}
@@ -1,116 +0,0 @@
"use client";
import { useWallet } from "@solana/wallet-adapter-react";
import { WalletMultiButton } from "@solana/wallet-adapter-react-ui";
import {
Egg,
ExternalLink,
ShieldCheck,
Sparkles,
Wallet,
Zap,
} from "lucide-react";
import { useEffect } from "react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { walletApi } from "@/lib/api";
interface WelcomeOnboardingProps {
onStart: () => void;
onConnected: () => void;
}
export function WelcomeOnboarding({
onStart,
onConnected,
}: WelcomeOnboardingProps) {
const { publicKey, connected } = useWallet();
useEffect(() => {
if (connected && publicKey) {
handleConnectWallet(publicKey.toString());
}
}, [connected, publicKey]);
const handleConnectWallet = async (address: string) => {
try {
await walletApi.connectWallet(address);
toast.success("Wallet connected to your account!");
onConnected();
} catch (error) {
toast.error("Failed to link wallet");
}
};
return (
<Card className="mb-8 overflow-hidden border-2 border-primary/20 bg-gradient-to-br from-primary/5 via-background to-background">
<CardContent className="p-0">
<div className="flex flex-col md:flex-row">
<div className="flex-1 p-8 space-y-6">
<div className="space-y-2">
<h2 className="font-display text-3xl tracking-tight">
Welcome to the Coop!
</h2>
<p className="text-muted-foreground text-lg">
You are one step away from earning golden eggs for your watch
time.
</p>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-6">
<div className="flex gap-3">
<div className="mt-1 bg-primary/10 p-2 rounded-lg h-fit">
<Zap className="h-4 w-4 text-primary" />
</div>
<div>
<h4 className="font-semibold">Automatic Rewards</h4>
<p className="text-sm text-muted-foreground">
Credits are minted directly to your wallet while you watch.
</p>
</div>
</div>
<div className="flex gap-3">
<div className="mt-1 bg-primary/10 p-2 rounded-lg h-fit">
<ShieldCheck className="h-4 w-4 text-primary" />
</div>
<div>
<h4 className="font-semibold">Secure & Private</h4>
<p className="text-sm text-muted-foreground">
Your wallet is personal and secured on the Solana
blockchain.
</p>
</div>
</div>
</div>
<div className="flex flex-wrap gap-4 pt-2">
<Button
size="lg"
onClick={onStart}
className="h-12 px-8 rounded-full shadow-lg shadow-primary/20"
>
<Wallet className="mr-2 h-5 w-5" />
Create Managed Wallet
</Button>
<div className="wallet-adapter-custom-wrapper">
<WalletMultiButton className="h-12 !rounded-full !bg-secondary !text-secondary-foreground hover:!bg-secondary/80" />
</div>
</div>
<p className="text-xs text-muted-foreground italic">
Choose &ldquo;Create Managed Wallet&rdquo; for an easy start, or
&ldquo;Select Wallet&rdquo; to use your own (Phantom, Solflare,
etc.)
</p>
</div>
<div className="hidden md:flex flex-none w-72 bg-primary/10 items-center justify-center border-l border-primary/10">
<Egg className="h-32 w-32 text-primary opacity-20 animate-pulse" />
</div>
</div>
</CardContent>
</Card>
);
}
+53 -240
View File
@@ -1,20 +1,8 @@
"use client"; "use client";
import { WalletMultiButton } from "@solana/wallet-adapter-react-ui";
import {
Clock,
Egg,
ExternalLink,
Film,
History,
TrendingDown,
TrendingUp
} from "lucide-react";
import { useRouter } from "next/navigation";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { toast } from "sonner"; import { toast } from "sonner";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { import {
Card, Card,
CardContent, CardContent,
@@ -23,33 +11,26 @@ import {
CardTitle, CardTitle,
} from "@/components/ui/card"; } from "@/components/ui/card";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { overseerApi, transactionApi } from "@/lib/api"; import { overseerApi } from "@/lib/api";
import { useSocket } from "@/lib/socket"; import { useSocket } from "@/lib/socket";
import { useStore } from "@/lib/store"; import { useStore } from "@/lib/store";
import { formatNumber, truncateAddress } from "@/lib/utils"; import { formatNumber } from "@/lib/utils";
import { ActivityFeed } from "./components/ActivityFeed"; import { ActivityFeed } from "./components/ActivityFeed";
import { CreateWalletModal } from "./components/CreateWalletModal";
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 { TransactionList } from "./components/TransactionList";
import { WatchHistory } from "./components/WatchHistory"; import { WatchHistory } from "./components/WatchHistory";
import { WelcomeOnboarding } from "./components/WelcomeOnboarding";
interface WalletData { interface WalletData {
hasWallet: boolean;
address?: string;
balance: number; balance: number;
totalEarned: number; totalEarned: number;
totalSpent: number; totalSpent: number;
explorerUrl?: string;
} }
export default function DashboardPage() { export default function DashboardPage() {
const router = useRouter(); const router = useRouter();
const { user, isAuthenticated, logout } = useStore(); const { isAuthenticated } = useStore();
const [wallet, setWallet] = useState<WalletData | null>(null); const [wallet, setWallet] = useState<WalletData | null>(null);
const [requestCosts, setRequestCosts] = useState({ movie: 100, tv: 200 }); const [isSearchModalOpen, setIsSearchModalOpen] = useState(false); const [requestCosts, setRequestCosts] = useState({ movie: 500, tv: 1000 });
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
const socket = useSocket(); const socket = useSocket();
@@ -58,258 +39,90 @@ export default function DashboardPage() {
router.push("/login"); router.push("/login");
return; return;
} }
loadData(); loadData();
}, [isAuthenticated, router]); }, [isAuthenticated, router]);
useEffect(() => { useEffect(() => {
if (socket) { if (!socket) return;
socket.on("credits_earned", (data) => { const refresh = () => loadData();
toast.success(`You earned ${data.amount} $COOP!`, { socket.on("credits_earned", refresh);
description: `Watched: ${data.title}`, socket.on("bonus_received", refresh);
}); socket.on("credits_spent", refresh);
loadData();
});
socket.on("bonus_received", (data) => {
toast.success(`Bonus Received: ${data.amount} $COOP!`, {
description: data.reason,
});
loadData();
});
socket.on("credits_spent", (data) => {
toast.info(`Requested: ${data.title}`, {
description: `Spent ${data.amount} $COOP`,
});
loadData();
});
return () => { return () => {
socket.off("credits_earned"); socket.off("credits_earned", refresh);
socket.off("bonus_received"); socket.off("bonus_received", refresh);
socket.off("credits_spent"); socket.off("credits_spent", refresh);
}; };
}
}, [socket]); }, [socket]);
const loadData = async () => { const loadData = async () => {
try { try {
const [walletRes, costsRes] = await Promise.all([ const [walletRes, costsRes] = await Promise.all([
walletApi.getWallet(), fetch("/api/wallet").then((r) => r.json()),
overseerApi.getCosts().catch(() => ({ data: { movie: 100, tv: 200 } })), overseerApi.getCosts(),
]); ]);
setWallet(walletRes);
setWallet(walletRes.data);
setRequestCosts(costsRes.data); setRequestCosts(costsRes.data);
} catch (error) { } catch {
toast.error("Failed to load wallet data"); toast.error("Failed to load dashboard");
} finally { } finally {
setIsLoading(false); setIsLoading(false);
} }
}; };
if (!isAuthenticated || !user) { if (isLoading) return <div className="p-8">Loading...</div>;
return null;
}
return ( return (
<div className="min-h-screen bg-background"> <div className="p-8 space-y-6">
{/* Header */} <Card>
<header className="border-b-2 bg-card">
<div className="max-w-7xl mx-auto px-4 py-4 flex items-center justify-between">
<div className="flex items-center gap-4">
<div className="flex items-center gap-2">
<Egg className="w-6 h-6 text-primary" />
<h1 className="font-display text-2xl">The Coop</h1>
</div>
<Badge variant="secondary">{user.plexUsername}</Badge>
{user.isAdmin && (
<Button
variant="outline"
size="sm"
onClick={() => router.push("/admin")}
>
Admin
</Button>
)}
</div>
<div className="flex items-center gap-4">
<div className="wallet-adapter-custom-wrapper hidden md:block"> </div>
<Button variant="ghost" onClick={logout}>
Sign Out
</Button>
</div>
</div>
</header>
<main className="max-w-7xl mx-auto px-4 py-8">
{/* Onboarding for new users */}
{!wallet?.hasWallet && !isLoading && (
<WelcomeOnboarding
onStart={() => setIsCreateModalOpen(true)}
onConnected={loadData}
/>
)}
{/* Stats Cards */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 mb-8">
<Card className="border-2">
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-sm font-medium">Nest Egg</CardTitle>
<Wallet className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{wallet ? formatNumber(wallet.balance) : "--"} $COOP
</div>
<p className="text-xs text-muted-foreground">
{wallet?.hasWallet
? "Ready to spend"
: "Create wallet to start"}
</p>
</CardContent>
</Card>
<Card className="border-2">
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-sm font-medium">
Total Gathered
</CardTitle>
<TrendingUp className="h-4 w-4 text-green-500" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{wallet ? formatNumber(wallet.totalEarned) : "--"} $COOP
</div>
<p className="text-xs text-muted-foreground">
From watching content
</p>
</CardContent>
</Card>
<Card className="border-2">
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-sm font-medium">Total Spent</CardTitle>
<TrendingDown className="h-4 w-4 text-red-500" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{wallet ? formatNumber(wallet.totalSpent) : "--"} $COOP
</div>
<p className="text-xs text-muted-foreground">
On content requests
</p>
</CardContent>
</Card>
<Card
className="border-2 cursor-pointer hover:border-primary/50 transition-colors group"
onClick={() => setIsSearchModalOpen(true)}
>
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-sm font-medium">
Request Cost
</CardTitle>
<Film className="h-4 w-4 text-muted-foreground group-hover:text-primary transition-colors" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{requestCosts.movie} $COOP
</div>
<p className="text-xs text-muted-foreground">
Click to request content
</p>
</CardContent>
</Card>
</div>
{/* Wallet Section */}
{wallet?.hasWallet && (
<Card className="mb-8 border-2">
<CardHeader> <CardHeader>
<CardTitle className="flex items-center gap-2"> <CardTitle>Coop</CardTitle>
<Wallet className="h-5 w-5" /> <CardDescription>DB-only credits</CardDescription>
Your Coop Wallet
</CardTitle>
<CardDescription>
Manage your Solana wallet and $COOP tokens
</CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="flex items-center justify-between p-4 bg-muted rounded-lg"> <div className="grid gap-4 md:grid-cols-3">
<div> <div>
<p className="text-sm text-muted-foreground">Address</p> <div className="text-sm text-muted-foreground">Nest Egg</div>
<p className="font-mono font-medium"> <div className="text-3xl font-bold">
{truncateAddress(wallet.address!)} {formatNumber(wallet?.balance || 0)} $COOP
</p> </div>
</div>
<div>
<div className="text-sm text-muted-foreground">
Total Gathered
</div>
<div className="text-3xl font-bold">
{formatNumber(wallet?.totalEarned || 0)}
</div>
</div>
<div>
<div className="text-sm text-muted-foreground">Spent</div>
<div className="text-3xl font-bold">
{formatNumber(wallet?.totalSpent || 0)}
</div> </div>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={() => setIsCreateModalOpen(true)}
>
<History className="mr-2 h-4 w-4" />
Backup
</Button>
<Button variant="outline" size="sm" asChild>
<a
href={wallet.explorerUrl}
target="_blank"
rel="noopener noreferrer"
>
<ExternalLink className="mr-2 h-4 w-4" />
Explorer
</a>
</Button>
</div> </div>
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
)} <div className="text-sm text-muted-foreground">
Request costs: movie {requestCosts.movie}, tv {requestCosts.tv}
{/* Tabs */} </div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8"> <Tabs defaultValue="history">
<div className="lg:col-span-2"> <TabsList>
<Tabs defaultValue="transactions" className="space-y-4"> <TabsTrigger value="history">History</TabsTrigger>
<TabsList className="border-2"> <TabsTrigger value="requests">Requests</TabsTrigger>
<TabsTrigger value="transactions">Transactions</TabsTrigger> <TabsTrigger value="leaderboard">Leaderboard</TabsTrigger>
<TabsTrigger value="history">Watch History</TabsTrigger>
<TabsTrigger value="requests">My Requests</TabsTrigger>
</TabsList> </TabsList>
<TabsContent value="transactions">
<TransactionList />
</TabsContent>
<TabsContent value="history"> <TabsContent value="history">
<WatchHistory /> <WatchHistory />
</TabsContent> </TabsContent>
<TabsContent value="requests"> <TabsContent value="requests">
<RequestHistory /> <RequestHistory />
</TabsContent> </TabsContent>
</Tabs> <TabsContent value="leaderboard">
</div>
<div className="lg:col-span-1 space-y-8">
<ActivityFeed />
<Leaderboard /> <Leaderboard />
</div> </TabsContent>
</div> </Tabs>
</main> <ActivityFeed />
<CreateWalletModal
open={isCreateModalOpen}
onClose={() => setIsCreateModalOpen(false)}
onCreated={loadData}
/>
<SearchRequestModal
open={isSearchModalOpen}
onClose={() => setIsSearchModalOpen(false)}
onRequested={loadData}
costs={requestCosts}
balance={wallet?.balance || 0}
/>
</div> </div>
); );
} }
+1 -60
View File
@@ -1,60 +1 @@
'use client'; export function Providers({ children }: { children: React.ReactNode }) { return <>{children}</>; }
import { ReactNode, useEffect, useMemo, useState } from 'react';
import { ThemeProvider } from 'next-themes';
import { useStore } from '@/lib/store';
import { authApi } from '@/lib/api';
import { ConnectionProvider, WalletProvider } from '@solana/wallet-adapter-react';
import { WalletAdapterNetwork } from '@solana/wallet-adapter-base';
import { PhantomWalletAdapter, SolflareWalletAdapter } from '@solana/wallet-adapter-wallets';
import { WalletModalProvider } from '@solana/wallet-adapter-react-ui';
import { clusterApiUrl } from '@solana/web3.js';
export function Providers({ children }: { children: ReactNode }) {
const [mounted, setMounted] = useState(false);
const { setUser, setToken } = useStore();
const network = WalletAdapterNetwork.Devnet;
const endpoint = useMemo(() => clusterApiUrl(network), [network]);
const wallets = useMemo(
() => [
new PhantomWalletAdapter(),
new SolflareWalletAdapter(),
],
[]
);
useEffect(() => {
setMounted(true);
// Check for stored token on mount
const token = localStorage.getItem('token');
if (token) {
// Verify token and get user data
authApi.verify()
.then((res) => {
setUser(res.data.user);
setToken(token);
})
.catch(() => {
localStorage.removeItem('token');
});
}
}, [setUser, setToken]);
if (!mounted) {
return <>{children}</>;
}
return (
<ThemeProvider attribute="class" defaultTheme="dark" enableSystem>
<ConnectionProvider endpoint={endpoint}>
<WalletProvider wallets={wallets} autoConnect>
<WalletModalProvider>
{children}
</WalletModalProvider>
</WalletProvider>
</ConnectionProvider>
</ThemeProvider>
);
}