chore: final solana purge
This commit is contained in:
@@ -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 };
|
||||||
|
|||||||
@@ -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 });
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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
@@ -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 };
|
||||||
|
|||||||
@@ -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 “Create Managed Wallet” for an easy start, or
|
|
||||||
“Select Wallet” 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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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 +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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
Reference in New Issue
Block a user