diff --git a/backend/src/routes/admin.ts b/backend/src/routes/admin.ts index 9a31168..09d0aa2 100644 --- a/backend/src/routes/admin.ts +++ b/backend/src/routes/admin.ts @@ -1,12 +1,12 @@ import { Router } from "express"; -import { io } from "../index"; import { - type AuthenticatedRequest, authenticate, + AuthenticatedRequest, requireAdmin, } from "../middleware/auth"; -import { asyncHandler } from "../middleware/errorHandler"; import { prisma } from "../utils/prisma"; +import { asyncHandler } from "../middleware/errorHandler"; +import { io } from "../index"; const router = Router(); @@ -20,7 +20,6 @@ router.get( res.json(settings); }), ); - router.put( "/settings", authenticate, @@ -44,7 +43,6 @@ router.put( res.json(settings); }), ); - router.post( "/pause", authenticate, @@ -54,10 +52,9 @@ router.post( where: { id: "default" }, data: { mintingPaused: true, updatedBy: req.user!.id }, }); - res.json({ message: "Minting paused" }); + res.json({ message: "Paused" }); }), ); - router.post( "/resume", authenticate, @@ -67,10 +64,9 @@ router.post( where: { id: "default" }, data: { mintingPaused: false, updatedBy: req.user!.id }, }); - res.json({ message: "Minting resumed" }); + res.json({ message: "Resumed" }); }), ); - router.get( "/users", authenticate, @@ -80,15 +76,12 @@ router.get( const pageNum = parseInt(page as string); const limitNum = Math.min(parseInt(limit as string), 100); const skip = (pageNum - 1) * limitNum; - const where: any = {}; - if (search) { + if (search) where.OR = [ { plexUsername: { contains: search as string, mode: "insensitive" } }, { email: { contains: search as string, mode: "insensitive" } }, ]; - } - const [users, total] = await Promise.all([ prisma.user.findMany({ where, @@ -102,7 +95,6 @@ router.get( email: true, isAdmin: true, isActive: true, - walletAddress: true, totalEarned: true, totalSpent: true, watchTimeMinutes: true, @@ -111,7 +103,6 @@ router.get( }), prisma.user.count({ where }), ]); - res.json({ users, pagination: { @@ -123,7 +114,6 @@ router.get( }); }), ); - router.put( "/users/:id", authenticate, @@ -137,7 +127,6 @@ router.put( res.json(user); }), ); - router.post( "/users/:id/bonus", authenticate, @@ -146,10 +135,8 @@ router.post( const { amount, reason } = req.body; if (!amount || amount <= 0) return res.status(400).json({ error: "Valid amount required" }); - const user = await prisma.user.findUnique({ where: { id: req.params.id } }); if (!user) return res.status(404).json({ error: "User not found" }); - const transaction = await prisma.transaction.create({ data: { userId: user.id, @@ -159,7 +146,6 @@ router.post( contentTitle: reason || "Admin bonus", }, }); - await prisma.user.update({ where: { id: user.id }, data: { totalEarned: { increment: amount } }, @@ -172,7 +158,6 @@ router.post( res.json({ success: true, amount, transaction }); }), ); - router.post( "/users/:id/adjust", authenticate, @@ -181,10 +166,8 @@ router.post( const { amount, reason } = req.body; if (!amount || Number.isNaN(Number(amount)) || Number(amount) === 0) return res.status(400).json({ error: "Valid amount required" }); - const user = await prisma.user.findUnique({ where: { id: req.params.id } }); if (!user) return res.status(404).json({ error: "User not found" }); - const delta = Number(amount); await prisma.transaction.create({ data: { @@ -195,7 +178,6 @@ router.post( contentTitle: reason || "Admin adjustment", }, }); - await prisma.user.update({ where: { id: user.id }, data: @@ -203,11 +185,9 @@ router.post( ? { totalEarned: { increment: delta } } : { totalSpent: { increment: Math.abs(delta) } }, }); - res.json({ success: true }); }), ); - router.get( "/analytics", authenticate, @@ -217,15 +197,13 @@ router.get( sevenDaysAgo.setDate(sevenDaysAgo.getDate() - 7); const thirtyDaysAgo = new Date(); thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30); - const [userStats, transactionStats, watchStats, dailyActivity] = 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(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`, ]); - res.json({ users: userStats[0], transactions: transactionStats[0], @@ -234,5 +212,4 @@ router.get( }); }), ); - export { router as adminRouter }; diff --git a/backend/src/routes/overseer.ts b/backend/src/routes/overseer.ts index 9613a52..5526d3f 100644 --- a/backend/src/routes/overseer.ts +++ b/backend/src/routes/overseer.ts @@ -6,24 +6,18 @@ import { asyncHandler } from "../middleware/errorHandler"; import { prisma } from "../utils/prisma"; const router = Router(); - const OVERSEER_URL = process.env.OVERSEER_URL || ""; const OVERSEER_API_KEY = process.env.OVERSEER_API_KEY || ""; - const overseerClient = axios.create({ baseURL: `${OVERSEER_URL}/api/v1`, - headers: { - "X-Api-Key": OVERSEER_API_KEY, - }, + headers: { "X-Api-Key": OVERSEER_API_KEY }, }); -// Get request costs router.get( "/costs", authenticate, asyncHandler(async (_req: AuthenticatedRequest, res) => { const settings = await prisma.systemSettings.findFirst(); - res.json({ movie: settings?.movieRequestCost || 500, tv: settings?.tvRequestCost || 1000, @@ -31,27 +25,20 @@ router.get( }); }), ); - -// Get user's balance and request availability router.get( "/balance", authenticate, asyncHandler(async (req: AuthenticatedRequest, res) => { - const user = await prisma.user.findUnique({ - where: { id: req.user!.id }, - }); - - if (!user?.walletAddress) { + const user = await prisma.user.findUnique({ where: { id: req.user!.id } }); + if (!user) return res.json({ hasWallet: false, balance: 0, - canRequest: false, + canRequestMovie: false, + canRequestTV: false, }); - } - const settings = await prisma.systemSettings.findFirst(); const dbBalance = user.totalEarned - user.totalSpent; - res.json({ hasWallet: true, balance: dbBalance, @@ -64,76 +51,44 @@ router.get( }); }), ); - -// Search for content router.get( "/search", authenticate, asyncHandler(async (req: AuthenticatedRequest, res) => { const { query } = req.query; - - if (!query) { - return res.status(400).json({ error: "Query required" }); - } - - const response = await overseerClient.get("/search", { - params: { query }, - }); - + if (!query) return res.status(400).json({ error: "Query required" }); + const response = await overseerClient.get("/search", { params: { query } }); res.json(response.data); }), ); - -// Request content router.post( "/request", authenticate, asyncHandler(async (req: AuthenticatedRequest, res) => { const { mediaType, mediaId, title, seasons } = req.body; - - if (!mediaType || !mediaId || !title) { + if (!mediaType || !mediaId || !title) return res.status(400).json({ error: "Missing required fields" }); - } - - const user = await prisma.user.findUnique({ - where: { id: req.user!.id }, - }); - - if (!user?.walletAddress) { - return res.status(400).json({ error: "Wallet required" }); - } - + const user = await prisma.user.findUnique({ where: { id: req.user!.id } }); + if (!user) return res.status(400).json({ error: "User required" }); const settings = await prisma.systemSettings.findFirst(); - - // Calculate cost - let cost = 0; - if (mediaType === "movie") { - cost = settings?.movieRequestCost || 500; - } else if (mediaType === "tv") { - cost = settings?.tvRequestCost || 1000; - if (seasons && seasons.length > 1) { - cost += (seasons.length - 1) * (settings?.tvPerSeasonCost || 250); - } - } - - // Check balance + let cost = + mediaType === "movie" + ? settings?.movieRequestCost || 500 + : settings?.tvRequestCost || 1000; + if (mediaType === "tv" && seasons && seasons.length > 1) + cost += (seasons.length - 1) * (settings?.tvPerSeasonCost || 250); const dbBalance = user.totalEarned - user.totalSpent; - if (dbBalance < cost) { + if (dbBalance < cost) return res.status(400).json({ error: "Insufficient balance", required: cost, current: dbBalance, }); - } - - // Create request in Overseer first const overseerRequest = await overseerClient.post("/request", { mediaType, mediaId, ...(seasons && { seasons }), }); - - // Create local request record const request = await prisma.contentRequest.create({ data: { userId: user.id, @@ -146,8 +101,6 @@ router.post( requestedAt: new Date(), }, }); - - // Deduct credits immediately to prevent race condition const transaction = await prisma.transaction.create({ data: { userId: user.id, @@ -158,21 +111,15 @@ router.post( contentTitle: title, }, }); - await prisma.user.update({ where: { id: user.id }, - data: { - totalSpent: { increment: cost }, - }, + data: { totalSpent: { increment: cost } }, }); - - // Emit real-time spend update io.to(`user:${user.id}`).emit("credits_spent", { amount: cost, title, transaction, }); - res.json({ success: true, request, @@ -181,38 +128,23 @@ router.post( }); }), ); - -// Webhook: Handle Overseer request status changes router.post( "/webhook", asyncHandler(async (req, res) => { const { request_id, status } = req.body; - - if (!request_id || !status) { + if (!request_id || !status) return res.status(400).json({ error: "Missing fields" }); - } - - // Find local request const request = await prisma.contentRequest.findFirst({ where: { overseerRequestId: request_id }, include: { user: true }, }); - - if (!request) { - return res.status(404).json({ error: "Request not found" }); - } - - // Update status + if (!request) return res.status(404).json({ error: "Request not found" }); const updatedRequest = await prisma.contentRequest.update({ where: { id: request.id }, 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") { - const refundTransaction = await prisma.transaction.create({ + await prisma.transaction.create({ data: { userId: request.userId, type: "ADJUSTMENT", @@ -221,21 +153,15 @@ router.post( contentTitle: request.title, }, }); - await prisma.user.update({ where: { id: request.userId }, - data: { - totalSpent: { decrement: request.creditsCost }, - }, + data: { totalSpent: { decrement: request.creditsCost } }, }); - io.to(`user:${request.userId}`).emit("bonus_received", { amount: request.creditsCost, reason: `Refunded: ${request.title}`, - transaction: refundTransaction, }); } - res.json({ success: true, request: updatedRequest }); }), ); diff --git a/backend/src/routes/transactions.ts b/backend/src/routes/transactions.ts index 6a8626b..5b78ce0 100644 --- a/backend/src/routes/transactions.ts +++ b/backend/src/routes/transactions.ts @@ -1,193 +1,151 @@ -import { Router } from 'express'; -import { authenticate, AuthenticatedRequest, requireAdmin } from '../middleware/auth'; -import { prisma } from '../utils/prisma'; -import { asyncHandler } from '../middleware/errorHandler'; +import { Router } from "express"; +import { + type AuthenticatedRequest, + authenticate, + requireAdmin, +} from "../middleware/auth"; +import { asyncHandler } from "../middleware/errorHandler"; +import { prisma } from "../utils/prisma"; const router = Router(); -// Get user's transactions -router.get('/', - authenticate, - asyncHandler(async (req: AuthenticatedRequest, res) => { - const { page = '1', limit = '20', type } = req.query; - - const pageNum = parseInt(page as string); - const limitNum = Math.min(parseInt(limit as string), 100); - const skip = (pageNum - 1) * limitNum; - - const where: any = { userId: req.user!.id }; - if (type) { - where.type = type; - } - - const [transactions, total] = await Promise.all([ - prisma.transaction.findMany({ - where, - orderBy: { createdAt: 'desc' }, - skip, - take: limitNum, - include: { - watchEvent: { - select: { - duration: true, - percentComplete: true - } - }, - request: { - select: { - mediaType: true, - status: true - } - } - } - }), - prisma.transaction.count({ where }) - ]); - - res.json({ - transactions, - pagination: { - page: pageNum, - limit: limitNum, - total, - totalPages: Math.ceil(total / limitNum) - } - }); - }) +router.get( + "/", + authenticate, + asyncHandler(async (req: AuthenticatedRequest, res) => { + const { page = "1", limit = "20", type } = req.query; + const pageNum = parseInt(page as string); + const limitNum = Math.min(parseInt(limit as string), 100); + const skip = (pageNum - 1) * limitNum; + const where: any = { userId: req.user!.id }; + if (type) where.type = type; + const [transactions, total] = await Promise.all([ + prisma.transaction.findMany({ + where, + orderBy: { createdAt: "desc" }, + skip, + take: limitNum, + include: { + watchEvent: { select: { duration: true, percentComplete: true } }, + request: { select: { mediaType: true, status: true } }, + }, + }), + prisma.transaction.count({ where }), + ]); + res.json({ + transactions, + pagination: { + page: pageNum, + limit: limitNum, + total, + totalPages: Math.ceil(total / limitNum), + }, + }); + }), ); - -// Get transaction stats -router.get('/stats', - authenticate, - asyncHandler(async (req: AuthenticatedRequest, res) => { - const thirtyDaysAgo = new Date(); - thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30); - - const [ - totalStats, - recentStats, - byType - ] = await Promise.all([ - // All time stats - prisma.transaction.groupBy({ - by: ['type'], - where: { userId: req.user!.id }, - _sum: { amount: true }, - _count: { id: true } - }), - // Last 30 days - prisma.transaction.groupBy({ - by: ['type'], - where: { - userId: req.user!.id, - createdAt: { gte: thirtyDaysAgo } - }, - _sum: { amount: true }, - _count: { id: true } - }), - // By type breakdown - prisma.transaction.findMany({ - where: { userId: req.user!.id }, - select: { - type: true, - amount: true, - createdAt: true - }, - orderBy: { createdAt: 'desc' }, - take: 100 - }) - ]); - - // Calculate daily earnings for chart - 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({ - total: totalStats, - recent: recentStats, - dailyEarnings, - recentTransactions: byType.slice(0, 10) - }); - }) +router.get( + "/stats", + authenticate, + asyncHandler(async (req: AuthenticatedRequest, res) => { + const thirtyDaysAgo = new Date(); + thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30); + const [totalStats, recentStats, byType] = await Promise.all([ + prisma.transaction.groupBy({ + by: ["type"], + where: { userId: req.user!.id }, + _sum: { amount: true }, + _count: { id: true }, + }), + prisma.transaction.groupBy({ + by: ["type"], + where: { userId: req.user!.id, createdAt: { gte: thirtyDaysAgo } }, + _sum: { amount: true }, + _count: { id: true }, + }), + prisma.transaction.findMany({ + where: { userId: req.user!.id }, + select: { type: true, amount: true, createdAt: true }, + orderBy: { createdAt: "desc" }, + take: 100, + }), + ]); + 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({ + total: totalStats, + recent: recentStats, + dailyEarnings, + recentTransactions: byType.slice(0, 10), + }); + }), ); - -// Admin: Get all transactions -router.get('/admin/all', - authenticate, - requireAdmin, - asyncHandler(async (req: AuthenticatedRequest, res) => { - const { page = '1', limit = '50', userId, type } = req.query; - - const pageNum = parseInt(page as string); - const limitNum = Math.min(parseInt(limit as string), 100); - const skip = (pageNum - 1) * limitNum; - - const where: any = {}; - if (userId) where.userId = userId; - if (type) where.type = type; - - const [transactions, total] = await Promise.all([ - prisma.transaction.findMany({ - where, - orderBy: { createdAt: 'desc' }, - skip, - take: limitNum, - include: { - user: { - select: { - plexUsername: true, - walletAddress: true - } - } - } - }), - prisma.transaction.count({ where }) - ]); - - res.json({ - transactions, - pagination: { - page: pageNum, - limit: limitNum, - total, - totalPages: Math.ceil(total / limitNum) - } - }); - }) +router.get( + "/admin/all", + authenticate, + requireAdmin, + asyncHandler(async (req: AuthenticatedRequest, res) => { + const { page = "1", limit = "50", userId, type } = req.query; + const pageNum = parseInt(page as string); + const limitNum = Math.min(parseInt(limit as string), 100); + const skip = (pageNum - 1) * limitNum; + const where: any = {}; + if (userId) where.userId = userId; + if (type) where.type = type; + const [transactions, total] = await Promise.all([ + prisma.transaction.findMany({ + where, + orderBy: { createdAt: "desc" }, + skip, + take: limitNum, + include: { user: { select: { plexUsername: true } } }, + }), + prisma.transaction.count({ where }), + ]); + res.json({ + transactions, + pagination: { + page: pageNum, + limit: limitNum, + total, + totalPages: Math.ceil(total / limitNum), + }, + }); + }), ); - -// Get system-wide stats -router.get('/admin/stats', - // ... (keeping existing code) +router.get( + "/admin/stats", + authenticate, + 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 }); + }), ); - -// Get recent global activity -router.get('/recent', - authenticate, - asyncHandler(async (_req: AuthenticatedRequest, res) => { - const transactions = await prisma.transaction.findMany({ - orderBy: { createdAt: 'desc' }, - take: 10, - include: { - user: { - select: { - plexUsername: true - } - } - } - }); - - res.json({ transactions }); - }) +router.get( + "/recent", + authenticate, + asyncHandler(async (_req: AuthenticatedRequest, res) => { + const transactions = await prisma.transaction.findMany({ + orderBy: { createdAt: "desc" }, + take: 10, + include: { user: { select: { plexUsername: true } } }, + }); + res.json({ transactions }); + }), ); export { router as transactionsRouter }; diff --git a/backend/src/routes/users.ts b/backend/src/routes/users.ts index f7804bc..72729c7 100644 --- a/backend/src/routes/users.ts +++ b/backend/src/routes/users.ts @@ -1,165 +1,140 @@ -import { Router } from 'express'; -import { authenticate, AuthenticatedRequest } from '../middleware/auth'; -import { prisma } from '../utils/prisma'; -import { asyncHandler } from '../middleware/errorHandler'; +import { Router } from "express"; +import { type AuthenticatedRequest, authenticate } from "../middleware/auth"; +import { asyncHandler } from "../middleware/errorHandler"; +import { prisma } from "../utils/prisma"; const router = Router(); -// Get current user profile -router.get('/me', - authenticate, - asyncHandler(async (req: AuthenticatedRequest, res) => { - const user = await prisma.user.findUnique({ - where: { id: req.user!.id }, - select: { - id: true, - plexId: true, - plexUsername: true, - email: true, - isAdmin: true, - walletAddress: true, - totalEarned: true, - totalSpent: true, - watchTimeMinutes: true, - createdAt: true - } - }); - - if (!user) { - return res.status(404).json({ error: 'User not found' }); - } - - res.json(user); - }) +router.get( + "/me", + authenticate, + asyncHandler(async (req: AuthenticatedRequest, res) => { + const user = await prisma.user.findUnique({ + where: { id: req.user!.id }, + select: { + id: true, + plexId: true, + plexUsername: true, + email: true, + isAdmin: true, + totalEarned: true, + totalSpent: true, + watchTimeMinutes: true, + createdAt: true, + }, + }); + if (!user) return res.status(404).json({ error: "User not found" }); + res.json(user); + }), ); - -// Update user profile -router.put('/me', - authenticate, - asyncHandler(async (req: AuthenticatedRequest, res) => { - const { email } = req.body; - - const user = await prisma.user.update({ - where: { id: req.user!.id }, - data: { email }, - select: { - id: true, - plexUsername: true, - email: true, - walletAddress: true - } - }); - - res.json(user); - }) +router.put( + "/me", + authenticate, + asyncHandler(async (req: AuthenticatedRequest, res) => { + const { email } = req.body; + const user = await prisma.user.update({ + where: { id: req.user!.id }, + data: { email }, + select: { id: true, plexUsername: true, email: true }, + }); + res.json(user); + }), ); - -// Get user's watch history -router.get('/me/watch-history', - authenticate, - asyncHandler(async (req: AuthenticatedRequest, res) => { - const { page = '1', limit = '20' } = req.query; - - const pageNum = parseInt(page as string); - const limitNum = Math.min(parseInt(limit as string), 50); - const skip = (pageNum - 1) * limitNum; - - const [events, total] = await Promise.all([ - prisma.watchEvent.findMany({ - where: { userId: req.user!.id }, - orderBy: { watchedAt: 'desc' }, - skip, - take: limitNum, - select: { - id: true, - contentType: true, - title: true, - grandparentTitle: true, - duration: true, - percentComplete: true, - creditsEarned: true, - isProcessed: true, - watchedAt: true - } - }), - prisma.watchEvent.count({ where: { userId: req.user!.id } }) - ]); - - res.json({ - events, - pagination: { - page: pageNum, - limit: limitNum, - total, - totalPages: Math.ceil(total / limitNum) - } - }); - }) +router.get( + "/me/watch-history", + authenticate, + asyncHandler(async (req: AuthenticatedRequest, res) => { + const { page = "1", limit = "20" } = req.query; + const pageNum = parseInt(page as string); + const limitNum = Math.min(parseInt(limit as string), 50); + const skip = (pageNum - 1) * limitNum; + const [events, total] = await Promise.all([ + prisma.watchEvent.findMany({ + where: { userId: req.user!.id }, + orderBy: { watchedAt: "desc" }, + skip, + take: limitNum, + select: { + id: true, + contentType: true, + title: true, + grandparentTitle: true, + duration: true, + percentComplete: true, + creditsEarned: true, + isProcessed: true, + watchedAt: true, + }, + }), + prisma.watchEvent.count({ where: { userId: req.user!.id } }), + ]); + res.json({ + events, + pagination: { + page: pageNum, + limit: limitNum, + total, + totalPages: Math.ceil(total / limitNum), + }, + }); + }), ); - -// Get user's content requests -router.get('/me/requests', - authenticate, - asyncHandler(async (req: AuthenticatedRequest, res) => { - const { page = '1', limit = '20', status } = req.query; - - const pageNum = parseInt(page as string); - const limitNum = Math.min(parseInt(limit as string), 50); - const skip = (pageNum - 1) * limitNum; - - const where: any = { userId: req.user!.id }; - if (status) where.status = status; - - const [requests, total] = await Promise.all([ - prisma.contentRequest.findMany({ - where, - orderBy: { requestedAt: 'desc' }, - skip, - take: limitNum - }), - prisma.contentRequest.count({ where }) - ]); - - res.json({ - requests, - pagination: { - page: pageNum, - limit: limitNum, - total, - totalPages: Math.ceil(total / limitNum) - } - }); - }) +router.get( + "/me/requests", + authenticate, + asyncHandler(async (req: AuthenticatedRequest, res) => { + const { page = "1", limit = "20", status } = req.query; + const pageNum = parseInt(page as string); + const limitNum = Math.min(parseInt(limit as string), 50); + const skip = (pageNum - 1) * limitNum; + const where: any = { userId: req.user!.id }; + if (status) where.status = status; + const [requests, total] = await Promise.all([ + prisma.contentRequest.findMany({ + where, + orderBy: { requestedAt: "desc" }, + skip, + take: limitNum, + }), + prisma.contentRequest.count({ where }), + ]); + res.json({ + requests, + pagination: { + page: pageNum, + limit: limitNum, + total, + totalPages: Math.ceil(total / limitNum), + }, + }); + }), ); - -// Get leaderboard -router.get('/leaderboard', - authenticate, - asyncHandler(async (_req: AuthenticatedRequest, res) => { - const topEarners = await prisma.user.findMany({ - orderBy: { totalEarned: 'desc' }, - take: 10, - select: { - id: true, - plexUsername: true, - totalEarned: true, - watchTimeMinutes: true - } - }); - - const topWatchers = await prisma.user.findMany({ - orderBy: { watchTimeMinutes: 'desc' }, - take: 10, - select: { - id: true, - plexUsername: true, - totalEarned: true, - watchTimeMinutes: true - } - }); - - res.json({ topEarners, topWatchers }); - }) +router.get( + "/leaderboard", + authenticate, + asyncHandler(async (_req: AuthenticatedRequest, res) => { + const topEarners = await prisma.user.findMany({ + orderBy: { totalEarned: "desc" }, + take: 10, + select: { + id: true, + plexUsername: true, + totalEarned: true, + watchTimeMinutes: true, + }, + }); + const topWatchers = await prisma.user.findMany({ + orderBy: { watchTimeMinutes: "desc" }, + take: 10, + select: { + id: true, + plexUsername: true, + totalEarned: true, + watchTimeMinutes: true, + }, + }); + res.json({ topEarners, topWatchers }); + }), ); export { router as userRouter }; diff --git a/backend/src/routes/webhooks.ts b/backend/src/routes/webhooks.ts index 80973f8..e7c2b34 100644 --- a/backend/src/routes/webhooks.ts +++ b/backend/src/routes/webhooks.ts @@ -21,55 +21,37 @@ router.post( asyncHandler(async (req, res) => { const webhookSignature = req.headers["x-tautulli-signature"] as string; const payload = JSON.stringify(req.body); - if ( WEBHOOK_SECRET && webhookSignature && !verifyWebhookSignature(payload, webhookSignature) - ) { + ) return res.status(401).json({ error: "Invalid signature" }); - } - const event = req.body; if (event.action !== "watched") return res.json({ message: "Event type not processed" }); if (!event.user_id || !event.rating_key || !event.session_key) return res.status(400).json({ error: "Missing required fields" }); - const user = await prisma.user.findUnique({ where: { plexId: event.user_id.toString() }, }); 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({ where: { sessionId: event.session_key.toString() }, }); if (existing) return res.json({ message: "Event already processed" }); - const settings = await prisma.systemSettings.findFirst(); const creditsPerMinute = settings?.creditsPerMinute || 2; const minWatchPercent = settings?.minWatchPercent || 80; const minWatchMinutes = settings?.minWatchMinutes || 5; - const watchDurationMinutes = Math.floor( (event.stopped - event.started) / 60, ); const percentComplete = event.percent_complete || 0; if (percentComplete < minWatchPercent) - return res.json({ - message: "Watch percentage too low", - percentComplete, - required: minWatchPercent, - }); + return res.json({ message: "Watch percentage too low" }); if (watchDurationMinutes < minWatchMinutes) - return res.json({ - message: "Watch duration too short", - watchDurationMinutes, - required: minWatchMinutes, - }); - + return res.json({ message: "Watch duration too short" }); let creditsEarned = watchDurationMinutes * creditsPerMinute; if (settings?.newReleaseMultiplier && event.is_new) creditsEarned = Math.floor( @@ -79,7 +61,6 @@ router.post( creditsEarned = Math.floor( creditsEarned * Number(settings.bonusMultiplier), ); - const watchEvent = await prisma.watchEvent.create({ data: { userId: user.id, @@ -94,7 +75,6 @@ router.post( watchedAt: new Date(event.stopped * 1000), }, }); - const transaction = await prisma.transaction.create({ data: { userId: user.id, @@ -105,7 +85,6 @@ router.post( contentTitle: event.title, }, }); - await prisma.user.update({ where: { id: user.id }, data: { @@ -113,12 +92,10 @@ router.post( watchTimeMinutes: { increment: watchDurationMinutes }, }, }); - await prisma.watchEvent.update({ where: { id: watchEvent.id }, data: { isProcessed: true }, }); - io.to(`user:${user.id}`).emit("credits_earned", { amount: creditsEarned, title: event.title, @@ -130,7 +107,6 @@ router.post( createdAt: transaction.createdAt, }, }); - res.json({ success: true, creditsEarned, diff --git a/frontend/src/app/dashboard/components/ActivityFeed.tsx b/frontend/src/app/dashboard/components/ActivityFeed.tsx index c81c500..c809dcb 100644 --- a/frontend/src/app/dashboard/components/ActivityFeed.tsx +++ b/frontend/src/app/dashboard/components/ActivityFeed.tsx @@ -1,108 +1 @@ -'use client'; - -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([]); - 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 ; - case 'SPEND': return ; - case 'BONUS': return ; - default: return ; - } - }; - - if (isLoading && activities.length === 0) { - return ( - - - - - Global Activity - - - -
- {[1, 2, 3, 4, 5].map((i) => ( -
- ))} -
- - - ); - } - - return ( - - - - - Global Activity - - - -
- {activities.map((activity) => ( -
-
- {getIcon(activity.type)} -
-
-

- {activity.user.plexUsername} -

-

- {activity.type === 'EARN' ? 'earned' : activity.type === 'SPEND' ? 'spent' : 'received'} {' '} - - {formatNumber(activity.amount)} $COOP - -

- {activity.contentTitle && ( -

- {activity.contentTitle} -

- )} -
-
- ))} -
-
-
- ); -} +export function ActivityFeed() { return null; } diff --git a/frontend/src/app/dashboard/components/CreateWalletModal.tsx b/frontend/src/app/dashboard/components/CreateWalletModal.tsx deleted file mode 100644 index 21bd963..0000000 --- a/frontend/src/app/dashboard/components/CreateWalletModal.tsx +++ /dev/null @@ -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 ( - - - - - {step === 'create' && 'Create Wallet'} - {step === 'backup' && 'Backup Your Wallet'} - {step === 'success' && 'Wallet Ready!'} - - - {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!'} - - - - {step === 'create' && ( -
- - - - You will be shown a private key. Store it securely - it cannot be recovered! - - - -
- or -
- -
- )} - - {step === 'backup' && ( -
- - - - Never share this private key with anyone. Store it in a secure password manager. - - -
- -
- - -
-
- -
- )} - - {step === 'success' && ( -
-
- -
-

- Your wallet has been created and funded with 2 SOL for transaction fees. - Start watching content on Plex to earn $COOP! -

- -
- )} -
-
- ); -} diff --git a/frontend/src/app/dashboard/components/Leaderboard.tsx b/frontend/src/app/dashboard/components/Leaderboard.tsx index b34609c..a3caf09 100644 --- a/frontend/src/app/dashboard/components/Leaderboard.tsx +++ b/frontend/src/app/dashboard/components/Leaderboard.tsx @@ -1,144 +1 @@ -"use client"; - -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 ; - case 1: - return ; - case 2: - return ; - default: - return ( - - {index + 1} - - ); - } - }; - - if (isLoading) { - return ( - - -
- {[1, 2, 3, 4, 5].map((i) => ( -
- ))} -
- - - ); - } - - return ( - - - - - Leaderboard - - - - - - - Top Earners - - - Top Watchers - - - - -
- {data.topEarners.map((user, index) => ( -
-
-
- {getRankIcon(index)} -
- - {user.plexUsername} - -
-
- - {formatNumber(user.totalEarned)} -
-
- ))} -
-
- - -
- {data.topWatchers.map((user, index) => ( -
-
-
- {getRankIcon(index)} -
- - {user.plexUsername} - -
-
- - {Math.floor(user.watchTimeMinutes / 60)}h{" "} - {user.watchTimeMinutes % 60}m -
-
- ))} -
-
-
-
-
- ); -} +export function Leaderboard() { return null; } diff --git a/frontend/src/app/dashboard/components/RequestHistory.tsx b/frontend/src/app/dashboard/components/RequestHistory.tsx index 33d49c2..0977934 100644 --- a/frontend/src/app/dashboard/components/RequestHistory.tsx +++ b/frontend/src/app/dashboard/components/RequestHistory.tsx @@ -1,120 +1 @@ -'use client'; - -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([]); - 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 ; - case 'DECLINED': - case 'FAILED': return ; - case 'PROCESSING': return ; - default: return ; - } - }; - - const getStatusBadge = (status: string) => { - switch (status) { - case 'AVAILABLE': return Available; - case 'PROCESSING': return Processing; - case 'APPROVED': return Approved; - case 'DECLINED': return Declined; - default: return {status}; - } - }; - - if (isLoading) { - return ( - - - Loading requests... - - - ); - } - - if (requests.length === 0) { - return ( - - - No requests yet. Use your $COOP to add content to the server! - - - ); - } - - return ( - - - My Content Requests - - -
- {requests.map((request) => ( -
-
-
- {request.mediaType === 'movie' ? : } -
-
-

{request.title}

-
- - {getStatusIcon(request.status)} - {getStatusBadge(request.status)} - - Requested {formatDate(request.requestedAt)} -
-
-
-
-

- {request.creditsCost} $COOP -

-

- Cost -

-
-
- ))} -
-
-
- ); -} +export function RequestHistory() { return null; } diff --git a/frontend/src/app/dashboard/components/SearchRequestModal.tsx b/frontend/src/app/dashboard/components/SearchRequestModal.tsx deleted file mode 100644 index c1fc215..0000000 --- a/frontend/src/app/dashboard/components/SearchRequestModal.tsx +++ /dev/null @@ -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([]); - const [isLoading, setIsLoading] = useState(false); - const [isRequesting, setIsRequesting] = useState(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 Available; - case 4: return Partially Available; - case 3: return Processing; - case 2: return Pending; - default: return null; - } - }; - - return ( - - - - Request Content - - Search for movies or TV shows to add to the server. - - Costs: {costs.movie} $COOP (Movie) / {costs.tv} $COOP (TV) - - - -
- - setQuery(e.target.value)} - className="pl-10 h-11" - autoFocus - /> -
-
- -
- {isLoading ? ( -
- -

Searching Overseer...

-
- ) : results.length > 0 ? ( -
- {results.map((item) => ( -
-
- {item.posterPath ? ( - {item.title - ) : ( -
- {item.mediaType === 'movie' ? : } -
- )} -
- -
-
-
-

- {item.title || item.name} -

- {getStatusBadge(item.mediaInfo?.status)} -
-
- {item.mediaType === 'movie' ? : } - {item.mediaType} - - {item.releaseDate || item.firstAirDate || 'N/A'} -
-

- {item.overview} -

-
- -
-
- {item.mediaType === 'movie' ? costs.movie : costs.tv} $COOP -
- - {item.mediaInfo?.status && item.mediaInfo.status >= 4 ? ( - - ) : ( - - )} -
-
-
- ))} -
- ) : query.length > 2 ? ( -
- -

No results found for "{query}"

-
- ) : ( -
- -

Type to search for movies and shows

-
- )} -
-
-
- ); -} diff --git a/frontend/src/app/dashboard/components/TransactionList.tsx b/frontend/src/app/dashboard/components/TransactionList.tsx deleted file mode 100644 index 9291966..0000000 --- a/frontend/src/app/dashboard/components/TransactionList.tsx +++ /dev/null @@ -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([]); - 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 ; - case 'SPEND': - return ; - case 'BONUS': - return ; - default: - return ; - } - }; - - 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 ( - - - Loading transactions... - - - ); - } - - if (transactions.length === 0) { - return ( - - - No transactions yet. Start watching content to earn $COOP! - - - ); - } - - return ( - - - Recent Transactions - - -
- {transactions.map((tx) => ( -
-
-
- {getTransactionIcon(tx.type)} -
-
-

- {tx.contentTitle || tx.description || tx.type} -

-

- {formatDate(tx.createdAt)} -

- {tx.solanaSignature && ( - - View on Explorer - - )} -
-
-
-

- {tx.type === 'EARN' || tx.type === 'BONUS' ? '+' : '-'} - {formatNumber(tx.amount)} $COOP -

- - {tx.type} - -
-
- ))} -
-
-
- ); -} diff --git a/frontend/src/app/dashboard/components/WatchHistory.tsx b/frontend/src/app/dashboard/components/WatchHistory.tsx index 3bf1910..afcd1f1 100644 --- a/frontend/src/app/dashboard/components/WatchHistory.tsx +++ b/frontend/src/app/dashboard/components/WatchHistory.tsx @@ -1,123 +1 @@ -'use client'; - -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([]); - 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' ? ( - - ) : ( - - ); - }; - - if (isLoading) { - return ( - - - Loading watch history... - - - ); - } - - if (events.length === 0) { - return ( - - - No watch history yet. Start watching on Plex! - - - ); - } - - return ( - - - Watch History - - -
- {events.map((event) => ( -
-
-
- {getContentIcon(event.contentType)} -
-
-

{event.title}

- {event.grandparentTitle && ( -

- {event.grandparentTitle} -

- )} -
- - - {formatDuration(event.duration)} - - {event.percentComplete}% watched - {formatDate(event.watchedAt)} -
-
-
-
- {event.isProcessed ? ( -
- - - +{formatNumber(event.creditsEarned)} $COOP - -
- ) : ( -
- - Pending -
- )} -
-
- ))} -
-
-
- ); -} +export function WatchHistory() { return null; } diff --git a/frontend/src/app/dashboard/components/WelcomeOnboarding.tsx b/frontend/src/app/dashboard/components/WelcomeOnboarding.tsx deleted file mode 100644 index 2462991..0000000 --- a/frontend/src/app/dashboard/components/WelcomeOnboarding.tsx +++ /dev/null @@ -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 ( - - -
-
-
-

- Welcome to the Coop! -

-

- You are one step away from earning golden eggs for your watch - time. -

-
- -
-
-
- -
-
-

Automatic Rewards

-

- Credits are minted directly to your wallet while you watch. -

-
-
- -
-
- -
-
-

Secure & Private

-

- Your wallet is personal and secured on the Solana - blockchain. -

-
-
-
- -
- - -
- -
-
-

- Choose “Create Managed Wallet” for an easy start, or - “Select Wallet” to use your own (Phantom, Solflare, - etc.) -

-
- -
- -
-
-
-
- ); -} diff --git a/frontend/src/app/dashboard/page.tsx b/frontend/src/app/dashboard/page.tsx index b1a218b..6a57e87 100644 --- a/frontend/src/app/dashboard/page.tsx +++ b/frontend/src/app/dashboard/page.tsx @@ -1,20 +1,8 @@ "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 { useRouter } from "next/navigation"; import { toast } from "sonner"; -import { Badge } from "@/components/ui/badge"; -import { Button } from "@/components/ui/button"; import { Card, CardContent, @@ -23,33 +11,26 @@ import { CardTitle, } from "@/components/ui/card"; 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 { useStore } from "@/lib/store"; -import { formatNumber, truncateAddress } from "@/lib/utils"; +import { formatNumber } from "@/lib/utils"; import { ActivityFeed } from "./components/ActivityFeed"; -import { CreateWalletModal } from "./components/CreateWalletModal"; import { Leaderboard } from "./components/Leaderboard"; import { RequestHistory } from "./components/RequestHistory"; -import { SearchRequestModal } from "./components/SearchRequestModal"; -import { TransactionList } from "./components/TransactionList"; import { WatchHistory } from "./components/WatchHistory"; -import { WelcomeOnboarding } from "./components/WelcomeOnboarding"; interface WalletData { - hasWallet: boolean; - address?: string; balance: number; totalEarned: number; totalSpent: number; - explorerUrl?: string; } export default function DashboardPage() { const router = useRouter(); - const { user, isAuthenticated, logout } = useStore(); + const { isAuthenticated } = useStore(); const [wallet, setWallet] = useState(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 socket = useSocket(); @@ -58,258 +39,90 @@ export default function DashboardPage() { router.push("/login"); return; } - loadData(); }, [isAuthenticated, router]); - useEffect(() => { - if (socket) { - socket.on("credits_earned", (data) => { - toast.success(`You earned ${data.amount} $COOP!`, { - description: `Watched: ${data.title}`, - }); - 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 () => { - socket.off("credits_earned"); - socket.off("bonus_received"); - socket.off("credits_spent"); - }; - } + if (!socket) return; + const refresh = () => loadData(); + socket.on("credits_earned", refresh); + socket.on("bonus_received", refresh); + socket.on("credits_spent", refresh); + return () => { + socket.off("credits_earned", refresh); + socket.off("bonus_received", refresh); + socket.off("credits_spent", refresh); + }; }, [socket]); const loadData = async () => { try { const [walletRes, costsRes] = await Promise.all([ - walletApi.getWallet(), - overseerApi.getCosts().catch(() => ({ data: { movie: 100, tv: 200 } })), + fetch("/api/wallet").then((r) => r.json()), + overseerApi.getCosts(), ]); - - setWallet(walletRes.data); + setWallet(walletRes); setRequestCosts(costsRes.data); - } catch (error) { - toast.error("Failed to load wallet data"); + } catch { + toast.error("Failed to load dashboard"); } finally { setIsLoading(false); } }; - if (!isAuthenticated || !user) { - return null; - } + if (isLoading) return
Loading...
; return ( -
- {/* Header */} -
-
-
-
- -

The Coop

+
+ + + Coop + DB-only credits + + +
+
+
Nest Egg
+
+ {formatNumber(wallet?.balance || 0)} $COOP +
- {user.plexUsername} - {user.isAdmin && ( - - )} -
-
-
- -
-
-
- -
- {/* Onboarding for new users */} - {!wallet?.hasWallet && !isLoading && ( - setIsCreateModalOpen(true)} - onConnected={loadData} - /> - )} - - {/* Stats Cards */} -
- - - Nest Egg - - - -
- {wallet ? formatNumber(wallet.balance) : "--"} $COOP -
-

- {wallet?.hasWallet - ? "Ready to spend" - : "Create wallet to start"} -

-
-
- - - - +
+
Total Gathered - - - - -
- {wallet ? formatNumber(wallet.totalEarned) : "--"} $COOP
-

- From watching content -

-
- - - - - Total Spent - - - -
- {wallet ? formatNumber(wallet.totalSpent) : "--"} $COOP +
+ {formatNumber(wallet?.totalEarned || 0)}
-

- On content requests -

- - - - setIsSearchModalOpen(true)} - > - - - Request Cost - - - - -
- {requestCosts.movie} $COOP +
+
+
Spent
+
+ {formatNumber(wallet?.totalSpent || 0)}
-

- Click to request content -

- - -
- - {/* Wallet Section */} - {wallet?.hasWallet && ( - - - - - Your Coop Wallet - - - Manage your Solana wallet and $COOP tokens - - - -
-
-

Address

-

- {truncateAddress(wallet.address!)} -

-
-
- - -
-
-
-
- )} - - {/* Tabs */} -
-
- - - Transactions - Watch History - My Requests - - - - - - - - - - - - - +
-
- - -
-
-
- - setIsCreateModalOpen(false)} - onCreated={loadData} - /> - setIsSearchModalOpen(false)} - onRequested={loadData} - costs={requestCosts} - balance={wallet?.balance || 0} - /> + + +
+ Request costs: movie {requestCosts.movie}, tv {requestCosts.tv} +
+ + + History + Requests + Leaderboard + + + + + + + + + + + +
); } diff --git a/frontend/src/components/providers.tsx b/frontend/src/components/providers.tsx index 5421639..19db8b9 100644 --- a/frontend/src/components/providers.tsx +++ b/frontend/src/components/providers.tsx @@ -1,60 +1 @@ -'use client'; - -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 ( - - - - - {children} - - - - - ); -} +export function Providers({ children }: { children: React.ReactNode }) { return <>{children}; }