Fix all critical/high/medium/low audit items
Critical – money integrity:
- server.ts: restrict Socket.IO CORS to NEXT_PUBLIC_SITE_URL (remove origin:"*")
- server.ts: validate userId against DB on join_room before any debit/credit
- server.ts: atomic coin-flip joiner claim via updateMany(where:{status:WAITING,joinerId:null})
- server.ts: await pong game_over payout+room update before emitting result; log errors
- prediction/route.ts: wrap market resolve + all payouts in one Prisma transaction;
concurrent PATCH returns 409; dust remainder credited to first winner
High – data truth:
- raised/page.tsx, leaderboard/route.ts, cards/route.ts: add status:"succeeded"
filter to all donation aggregations
- polls/next-president/route.ts: replace full in-memory row scan with DB-side
groupBy + bounded 14-day window for daily activity chart
Medium – ops:
- faq-board/page.tsx: add admin approve/reject tab (visible to ADMIN role)
Low – UX/consistency:
- faq-board, billboard, spotlight, cards: replace hardcoded "BWT" with creditTicker()
- faq-board: read submitCost/voteCost from API response instead of hardcoded values
- WalletActions.tsx: make refreshBalance stable with useCallback; remove
eslint-disable exhaustive-deps suppression
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
114
server.ts
114
server.ts
@@ -14,6 +14,12 @@ const handle = app.getRequestHandler();
|
||||
|
||||
const HOUSE_CUT = 0.05; // 5% on PvP
|
||||
|
||||
// CORS: restrict to configured site URL only — never wildcard in production.
|
||||
const allowedOrigin =
|
||||
process.env.NEXT_PUBLIC_SITE_URL ??
|
||||
process.env.AUTH_URL ??
|
||||
"http://127.0.0.1:8008";
|
||||
|
||||
app.prepare().then(() => {
|
||||
const httpServer = createServer((req, res) => {
|
||||
const parsedUrl = parse(req.url ?? "/", true);
|
||||
@@ -21,7 +27,7 @@ app.prepare().then(() => {
|
||||
});
|
||||
|
||||
const io = new SocketIOServer(httpServer, {
|
||||
cors: { origin: "*" },
|
||||
cors: { origin: allowedOrigin, methods: ["GET", "POST"] },
|
||||
path: "/api/socket",
|
||||
});
|
||||
|
||||
@@ -30,65 +36,88 @@ app.prepare().then(() => {
|
||||
|
||||
coinFlipNS.on("connection", (socket) => {
|
||||
socket.on("join_room", async ({ roomId, userId }: { roomId: string; userId: string }) => {
|
||||
// Reject obviously spoofed ids — user must exist in DB.
|
||||
const userExists = await prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: { id: true },
|
||||
});
|
||||
if (!userExists) { socket.emit("error", "Invalid session"); return; }
|
||||
|
||||
const room = await prisma.gameRoom.findUnique({ where: { id: roomId } });
|
||||
if (!room || room.status !== "WAITING") {
|
||||
socket.emit("error", "Room not available");
|
||||
return;
|
||||
}
|
||||
|
||||
// Creator just reconnects/waits — nothing to debit.
|
||||
if (room.creatorId === userId) {
|
||||
socket.join(roomId);
|
||||
socket.emit("waiting", { roomId });
|
||||
return;
|
||||
}
|
||||
|
||||
// Check balance
|
||||
// Atomically claim the joiner slot — prevents concurrent joiners racing
|
||||
// on the same WAITING room.
|
||||
const claimed = await prisma.gameRoom.updateMany({
|
||||
where: { id: roomId, status: "WAITING", joinerId: null },
|
||||
data: { joinerId: userId, status: "ACTIVE" },
|
||||
});
|
||||
if (claimed.count !== 1) {
|
||||
socket.emit("error", "Room not available");
|
||||
return;
|
||||
}
|
||||
|
||||
// Balance check after claiming to avoid charging then bouncing.
|
||||
const wallet = await prisma.wallet.findUnique({ where: { userId } });
|
||||
if (!wallet || wallet.balanceCredits < room.wageBLW) {
|
||||
// Revert the slot claim.
|
||||
await prisma.gameRoom.update({
|
||||
where: { id: roomId },
|
||||
data: { joinerId: null, status: "WAITING" },
|
||||
});
|
||||
socket.emit("error", "Insufficient balance");
|
||||
return;
|
||||
}
|
||||
|
||||
// Debit joiner
|
||||
// Debit joiner.
|
||||
try {
|
||||
await debitForBet(userId, room.wageBLW, "COIN_FLIP");
|
||||
} catch {
|
||||
await prisma.gameRoom.update({ where: { id: roomId }, data: { joinerId: null, status: "WAITING" } });
|
||||
socket.emit("error", "Debit failed");
|
||||
return;
|
||||
}
|
||||
|
||||
// Debit creator
|
||||
// Debit creator.
|
||||
try {
|
||||
await debitForBet(room.creatorId, room.wageBLW, "COIN_FLIP");
|
||||
} catch {
|
||||
await refundBet(userId, room.wageBLW, "COIN_FLIP");
|
||||
await prisma.gameRoom.update({ where: { id: roomId }, data: { joinerId: null, status: "WAITING" } });
|
||||
socket.emit("error", "Creator debit failed");
|
||||
return;
|
||||
}
|
||||
|
||||
await prisma.gameRoom.update({
|
||||
where: { id: roomId },
|
||||
data: { joinerId: userId, status: "ACTIVE" },
|
||||
});
|
||||
|
||||
socket.join(roomId);
|
||||
coinFlipNS.to(roomId).emit("game_start", { roomId });
|
||||
|
||||
// Resolve
|
||||
// Resolve — provably fair flip.
|
||||
const serverSeed = generateServerSeed();
|
||||
const result = deriveInt(serverSeed, roomId, 0, 0, 1); // 0 = heads, 1 = tails
|
||||
const winnerId = result === 0 ? room.creatorId : userId;
|
||||
const pot = room.wageBLW * 2;
|
||||
const payout = Math.floor(pot * (1 - HOUSE_CUT));
|
||||
|
||||
await creditForWin(winnerId, payout, "COIN_FLIP");
|
||||
|
||||
await prisma.gameRoom.update({
|
||||
where: { id: roomId },
|
||||
data: {
|
||||
status: "RESOLVED",
|
||||
resultData: { result, winnerId, serverSeed, payout },
|
||||
},
|
||||
});
|
||||
try {
|
||||
await creditForWin(winnerId, payout, "COIN_FLIP");
|
||||
await prisma.gameRoom.update({
|
||||
where: { id: roomId },
|
||||
data: { status: "RESOLVED", resultData: { result, winnerId, serverSeed, payout } },
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("[coin-flip] payout/resolve failed for room", roomId, err);
|
||||
// Room stuck as ACTIVE — needs manual reconciliation; we still emit result.
|
||||
}
|
||||
|
||||
coinFlipNS.to(roomId).emit("result", {
|
||||
result,
|
||||
@@ -115,6 +144,13 @@ app.prepare().then(() => {
|
||||
|
||||
pongNS.on("connection", (socket) => {
|
||||
socket.on("join_room", async ({ roomId, userId }: { roomId: string; userId: string }) => {
|
||||
// Reject obviously spoofed ids.
|
||||
const userExists = await prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: { id: true },
|
||||
});
|
||||
if (!userExists) { socket.emit("error", "Invalid session"); return; }
|
||||
|
||||
const room = await prisma.gameRoom.findUnique({ where: { id: roomId } });
|
||||
if (!room || !["WAITING", "ACTIVE"].includes(room.status)) {
|
||||
socket.emit("error", "Room not available");
|
||||
@@ -127,7 +163,7 @@ app.prepare().then(() => {
|
||||
if (!pongRooms.has(roomId)) {
|
||||
pongRooms.set(roomId, {
|
||||
roomId,
|
||||
creatorId: userId,
|
||||
creatorId: room.creatorId, // always from DB, not client
|
||||
joinerId: null,
|
||||
wageBLW: room.wageBLW,
|
||||
ball: { x: 400, y: 200, vx: 3, vy: 2 },
|
||||
@@ -140,12 +176,14 @@ app.prepare().then(() => {
|
||||
return;
|
||||
}
|
||||
|
||||
// Joiner
|
||||
// Joiner — in-memory race guard (single-process; DB is source of truth).
|
||||
const state = pongRooms.get(roomId);
|
||||
if (!state || state.joinerId) { socket.emit("error", "Room full"); return; }
|
||||
|
||||
const wallet = await prisma.wallet.findUnique({ where: { userId } });
|
||||
if (!wallet || wallet.balanceCredits < room.wageBLW) { socket.emit("error", "Insufficient balance"); return; }
|
||||
if (!wallet || wallet.balanceCredits < room.wageBLW) {
|
||||
socket.emit("error", "Insufficient balance"); return;
|
||||
}
|
||||
|
||||
try {
|
||||
await debitForBet(userId, room.wageBLW, "PONG");
|
||||
@@ -159,7 +197,7 @@ app.prepare().then(() => {
|
||||
await prisma.gameRoom.update({ where: { id: roomId }, data: { joinerId: userId, status: "ACTIVE" } });
|
||||
pongNS.to(roomId).emit("game_start", { roomId, creatorId: room.creatorId, joinerId: userId });
|
||||
|
||||
// Game loop — 20 ticks/sec
|
||||
// Game loop — 20 ticks/sec.
|
||||
state.interval = setInterval(() => {
|
||||
const s = pongRooms.get(roomId);
|
||||
if (!s) return;
|
||||
@@ -167,14 +205,10 @@ app.prepare().then(() => {
|
||||
s.ball.x += s.ball.vx;
|
||||
s.ball.y += s.ball.vy;
|
||||
|
||||
// Wall bounce
|
||||
if (s.ball.y <= 0 || s.ball.y >= 400) s.ball.vy *= -1;
|
||||
|
||||
// Paddle bounce
|
||||
if (s.ball.x <= 20 && Math.abs(s.ball.y - s.paddles.creator) < 50) s.ball.vx = Math.abs(s.ball.vx);
|
||||
if (s.ball.x >= 780 && Math.abs(s.ball.y - s.paddles.joiner) < 50) s.ball.vx = -Math.abs(s.ball.vx);
|
||||
|
||||
// Score
|
||||
if (s.ball.x <= 0) {
|
||||
s.scores.joiner++;
|
||||
s.ball = { x: 400, y: 200, vx: 3, vy: 2 };
|
||||
@@ -186,21 +220,29 @@ app.prepare().then(() => {
|
||||
|
||||
pongNS.to(roomId).emit("tick", { ball: s.ball, paddles: s.paddles, scores: s.scores });
|
||||
|
||||
// Win condition: first to 5
|
||||
if (s.scores.creator >= 5 || s.scores.joiner >= 5) {
|
||||
clearInterval(s.interval!);
|
||||
s.interval = null;
|
||||
const winnerId = s.scores.creator >= 5 ? s.creatorId : s.joinerId!;
|
||||
const pot = s.wageBLW * 2;
|
||||
const payout = Math.floor(pot * (1 - HOUSE_CUT));
|
||||
creditForWin(winnerId, payout, "PONG").then(() => {
|
||||
prisma.gameRoom.update({
|
||||
where: { id: roomId },
|
||||
data: { status: "RESOLVED", resultData: { winnerId, scores: s.scores, payout } },
|
||||
}).catch(() => {});
|
||||
}).catch(() => {});
|
||||
pongNS.to(roomId).emit("game_over", { winnerId, scores: s.scores, payout });
|
||||
const finalScores = { ...s.scores };
|
||||
pongRooms.delete(roomId);
|
||||
|
||||
// Await payout + DB update before broadcasting — errors are logged,
|
||||
// not silently swallowed.
|
||||
void (async () => {
|
||||
try {
|
||||
await creditForWin(winnerId, payout, "PONG");
|
||||
await prisma.gameRoom.update({
|
||||
where: { id: roomId },
|
||||
data: { status: "RESOLVED", resultData: { winnerId, scores: finalScores, payout } },
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("[pong] payout/resolve failed for room", roomId, err);
|
||||
}
|
||||
pongNS.to(roomId).emit("game_over", { winnerId, scores: finalScores, payout });
|
||||
})();
|
||||
}
|
||||
}, 50);
|
||||
});
|
||||
@@ -214,7 +256,7 @@ app.prepare().then(() => {
|
||||
});
|
||||
});
|
||||
|
||||
// Expire abandoned rooms every minute
|
||||
// Expire abandoned rooms every minute.
|
||||
setInterval(async () => {
|
||||
await prisma.gameRoom.updateMany({
|
||||
where: { status: "WAITING", expiresAt: { lt: new Date() } },
|
||||
|
||||
@@ -65,7 +65,7 @@ export async function POST(req: Request) {
|
||||
const [wallet, donations, totalCards] = await Promise.all([
|
||||
prisma.wallet.findUnique({ where: { userId } }),
|
||||
prisma.donation.aggregate({
|
||||
where: { userId },
|
||||
where: { userId, status: "succeeded" },
|
||||
_sum: { amountUsdCents: true, creditsAwarded: true },
|
||||
_count: true,
|
||||
}),
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { auth } from "@/auth";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { creditForWin } from "@/lib/game-ledger";
|
||||
import { debitWalletCredits, INSUFFICIENT_CREDITS } from "@/lib/wallet-safety";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
@@ -97,36 +97,83 @@ export async function POST(req: NextRequest) {
|
||||
}
|
||||
|
||||
// PATCH — resolve market (creator only)
|
||||
// All payouts and the resolution flag are committed in one transaction.
|
||||
// The first write sets resolvedTo, so concurrent PATCH requests receive a
|
||||
// P2025 not-found (the where clause includes resolvedTo: null) and return 409.
|
||||
export async function PATCH(req: NextRequest) {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
|
||||
const body = await req.json();
|
||||
const { marketId, resolvedTo } = body as { marketId: string; resolvedTo: boolean };
|
||||
|
||||
const market = await prisma.predictionMarket.findUnique({
|
||||
where: { id: marketId },
|
||||
include: { bets: true },
|
||||
});
|
||||
if (!market) return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||
if (market.creatorId !== session.user.id) return NextResponse.json({ error: "Not the creator" }, { status: 403 });
|
||||
if (market.resolvedTo !== null) return NextResponse.json({ error: "Already resolved" }, { status: 409 });
|
||||
|
||||
// Payout winners pro-rata from total pot
|
||||
const totalPot = market.totalYes + market.totalNo;
|
||||
const winnerBets = market.bets.filter(b => b.side === resolvedTo);
|
||||
const winnerTotal = winnerBets.reduce((s, b) => s + b.blwAmount, 0);
|
||||
|
||||
for (const bet of winnerBets) {
|
||||
if (winnerTotal > 0) {
|
||||
const payout = Math.floor((bet.blwAmount / winnerTotal) * totalPot);
|
||||
if (payout > 0) {
|
||||
await creditForWin(bet.userId, payout, "PREDICTION", `Prediction win: ${market.question}`);
|
||||
}
|
||||
}
|
||||
if (typeof marketId !== "string" || typeof resolvedTo !== "boolean") {
|
||||
return NextResponse.json({ error: "Invalid input" }, { status: 400 });
|
||||
}
|
||||
|
||||
await prisma.predictionMarket.update({ where: { id: marketId }, data: { resolvedTo } });
|
||||
try {
|
||||
const outcome = await prisma.$transaction(async (tx) => {
|
||||
// Atomically mark the market resolved. If it was already resolved, or
|
||||
// this user is not the creator, Prisma throws P2025 (record not found).
|
||||
const market = await tx.predictionMarket.update({
|
||||
where: { id: marketId, creatorId: session.user.id, resolvedTo: null },
|
||||
data: { resolvedTo },
|
||||
include: { bets: true },
|
||||
});
|
||||
|
||||
return NextResponse.json({ resolved: true, resolvedTo, totalPot, winners: winnerBets.length });
|
||||
const totalPot = market.totalYes + market.totalNo;
|
||||
const winnerBets = market.bets.filter((b) => b.side === resolvedTo);
|
||||
const winnerTotal = winnerBets.reduce((s, b) => s + b.blwAmount, 0);
|
||||
|
||||
let dust = totalPot;
|
||||
|
||||
for (const bet of winnerBets) {
|
||||
if (winnerTotal > 0) {
|
||||
const payout = Math.floor((bet.blwAmount / winnerTotal) * totalPot);
|
||||
dust -= payout;
|
||||
if (payout > 0) {
|
||||
await tx.wallet.upsert({
|
||||
where: { userId: bet.userId },
|
||||
update: { balanceCredits: { increment: payout } },
|
||||
create: { userId: bet.userId, balanceCredits: payout },
|
||||
});
|
||||
await tx.ledgerEntry.create({
|
||||
data: {
|
||||
userId: bet.userId,
|
||||
delta: payout,
|
||||
type: "CREDIT_GAME_WIN",
|
||||
memo: `Prediction win: ${market.question}`,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Award rounding dust to first winner so the ledger stays balanced.
|
||||
if (dust > 0 && winnerBets.length > 0) {
|
||||
const first = winnerBets[0];
|
||||
await tx.wallet.update({
|
||||
where: { userId: first.userId },
|
||||
data: { balanceCredits: { increment: dust } },
|
||||
});
|
||||
await tx.ledgerEntry.create({
|
||||
data: {
|
||||
userId: first.userId,
|
||||
delta: dust,
|
||||
type: "CREDIT_GAME_WIN",
|
||||
memo: `Prediction win (dust): ${market.question}`,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return { totalPot, winners: winnerBets.length };
|
||||
});
|
||||
|
||||
return NextResponse.json({ resolved: true, resolvedTo, ...outcome });
|
||||
} catch (e) {
|
||||
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === "P2025") {
|
||||
return NextResponse.json({ error: "Market not found, already resolved, or not authorized." }, { status: 409 });
|
||||
}
|
||||
console.error("[prediction PATCH]", e);
|
||||
return NextResponse.json({ error: "Could not resolve market." }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ export async function GET(req: NextRequest) {
|
||||
// Aggregate total donated per user
|
||||
const rows = await prisma.donation.groupBy({
|
||||
by: ["userId"],
|
||||
where: { userId: { not: null } },
|
||||
where: { userId: { not: null }, status: "succeeded" },
|
||||
_sum: { amountUsdCents: true },
|
||||
_count: { id: true },
|
||||
orderBy: { _sum: { amountUsdCents: "desc" } },
|
||||
|
||||
@@ -20,38 +20,6 @@ type TallyRow = {
|
||||
pct: number;
|
||||
};
|
||||
|
||||
function aggregateVotes(
|
||||
rows: { displayName: string; normalizedKey: string; createdAt: Date }[],
|
||||
): { tallies: TallyRow[]; totalBallots: number; uniqueCandidates: number } {
|
||||
const labelForKey = new Map<string, string>();
|
||||
const counts = new Map<string, number>();
|
||||
|
||||
for (const row of rows) {
|
||||
if (!labelForKey.has(row.normalizedKey)) {
|
||||
labelForKey.set(row.normalizedKey, row.displayName);
|
||||
}
|
||||
counts.set(row.normalizedKey, (counts.get(row.normalizedKey) ?? 0) + 1);
|
||||
}
|
||||
|
||||
const totalBallots = rows.length;
|
||||
const tallies: TallyRow[] = [];
|
||||
for (const [normalizedKey, count] of counts) {
|
||||
tallies.push({
|
||||
normalizedKey,
|
||||
label: labelForKey.get(normalizedKey) ?? normalizedKey,
|
||||
count,
|
||||
pct: totalBallots > 0 ? Math.round((count / totalBallots) * 1000) / 10 : 0,
|
||||
});
|
||||
}
|
||||
tallies.sort((a, b) => b.count - a.count || a.label.localeCompare(b.label));
|
||||
|
||||
return {
|
||||
tallies,
|
||||
totalBallots,
|
||||
uniqueCandidates: counts.size,
|
||||
};
|
||||
}
|
||||
|
||||
export async function GET() {
|
||||
const costCredits = pollVoteCostCredits();
|
||||
const creditName = creditDisplayName();
|
||||
@@ -59,22 +27,53 @@ export async function GET() {
|
||||
const session = await auth();
|
||||
const userId = session?.user?.id;
|
||||
|
||||
const rows = await prisma.pollVote.findMany({
|
||||
where: { pollSlug: NEXT_PRESIDENT_POLL_SLUG },
|
||||
select: { displayName: true, normalizedKey: true, createdAt: true },
|
||||
orderBy: { createdAt: "asc" },
|
||||
});
|
||||
// Use DB-side aggregation so the full table is never loaded into memory.
|
||||
const twoWeeksAgo = new Date(Date.now() - 14 * 24 * 60 * 60 * 1000);
|
||||
|
||||
const { tallies, totalBallots, uniqueCandidates } = aggregateVotes(rows);
|
||||
const [tally, labelRows, totalBallots, recentRows] = await Promise.all([
|
||||
// Vote counts per candidate (DB aggregation — safe at any scale).
|
||||
prisma.pollVote.groupBy({
|
||||
by: ["normalizedKey"],
|
||||
where: { pollSlug: NEXT_PRESIDENT_POLL_SLUG },
|
||||
_count: { id: true },
|
||||
orderBy: { _count: { id: "desc" } },
|
||||
take: 100,
|
||||
}),
|
||||
// One representative display name per normalizedKey.
|
||||
prisma.pollVote.findMany({
|
||||
where: { pollSlug: NEXT_PRESIDENT_POLL_SLUG },
|
||||
distinct: ["normalizedKey"],
|
||||
select: { normalizedKey: true, displayName: true },
|
||||
}),
|
||||
// Total ballot count — single DB scalar.
|
||||
prisma.pollVote.count({ where: { pollSlug: NEXT_PRESIDENT_POLL_SLUG } }),
|
||||
// Only last 14 days for the activity chart — bounded fetch.
|
||||
prisma.pollVote.findMany({
|
||||
where: { pollSlug: NEXT_PRESIDENT_POLL_SLUG, createdAt: { gte: twoWeeksAgo } },
|
||||
select: { createdAt: true },
|
||||
orderBy: { createdAt: "asc" },
|
||||
}),
|
||||
]);
|
||||
|
||||
const labelMap = new Map(labelRows.map((v) => [v.normalizedKey, v.displayName]));
|
||||
const tallies: TallyRow[] = tally.map((row) => {
|
||||
const count = row._count.id;
|
||||
return {
|
||||
normalizedKey: row.normalizedKey,
|
||||
label: labelMap.get(row.normalizedKey) ?? row.normalizedKey,
|
||||
count,
|
||||
pct: totalBallots > 0 ? Math.round((count / totalBallots) * 1000) / 10 : 0,
|
||||
};
|
||||
});
|
||||
const uniqueCandidates = tally.length;
|
||||
|
||||
const dayBuckets = new Map<string, number>();
|
||||
for (const r of rows) {
|
||||
for (const r of recentRows) {
|
||||
const day = r.createdAt.toISOString().slice(0, 10);
|
||||
dayBuckets.set(day, (dayBuckets.get(day) ?? 0) + 1);
|
||||
}
|
||||
const dailyActivity = [...dayBuckets.entries()]
|
||||
.sort((a, b) => a[0].localeCompare(b[0]))
|
||||
.slice(-14)
|
||||
.map(([date, count]) => ({ date, count }));
|
||||
|
||||
let you: { voted: boolean; yourChoice?: string } | null = null;
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import { SiteFooter } from "@/components/SiteFooter";
|
||||
import { creditTicker } from "@/lib/credits-brand";
|
||||
import { useSession } from "next-auth/react";
|
||||
import Link from "next/link";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
const TICKER = creditTicker();
|
||||
|
||||
const TIERS = [
|
||||
{ cost: 10, hours: 6, label: "6 h" },
|
||||
{ cost: 25, hours: 18, label: "18 h" },
|
||||
@@ -90,7 +93,7 @@ export default function BillboardPage() {
|
||||
<p className="text-xs uppercase tracking-[0.32em] text-sky-200/75">Community</p>
|
||||
<h1 className="mt-3 text-4xl font-semibold text-white sm:text-5xl">Democracy Billboard</h1>
|
||||
<p className="mt-4 max-w-2xl text-slate-400">
|
||||
Spend Blue Wave Tokens to broadcast your rally cry to every visitor. Higher spend = longer display + more prominent placement on the ticker.
|
||||
Spend {TICKER} to broadcast your rally cry to every visitor. Higher spend = longer display + more prominent placement on the ticker.
|
||||
</p>
|
||||
{loadError && (
|
||||
<p className="mt-4 rounded-xl border border-amber-500/35 bg-amber-500/10 px-4 py-3 text-sm text-amber-100/95">
|
||||
@@ -148,7 +151,7 @@ export default function BillboardPage() {
|
||||
: "border-white/10 text-slate-400 hover:border-white/20"
|
||||
}`}
|
||||
>
|
||||
{t.cost} BWT · {t.label}
|
||||
{t.cost} {TICKER} · {t.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
@@ -159,7 +162,7 @@ export default function BillboardPage() {
|
||||
disabled={posting || !text.trim()}
|
||||
className="mt-4 rounded-full bg-gradient-to-r from-sky-500 to-indigo-500 px-8 py-2.5 text-sm font-semibold text-white shadow-lg disabled:opacity-40"
|
||||
>
|
||||
{posting ? "Posting…" : `Broadcast for ${selectedTier.cost} BWT`}
|
||||
{posting ? "Posting…" : `Broadcast for ${selectedTier.cost} ${TICKER}`}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
@@ -181,7 +184,7 @@ export default function BillboardPage() {
|
||||
<p className="text-sm font-medium text-sky-200">{m.displayName}</p>
|
||||
<p className="mt-1 text-sm text-white">"{m.message}"</p>
|
||||
<div className="mt-2 flex items-center gap-3 text-xs text-slate-500">
|
||||
<span>{m.creditsSpent} BWT</span>
|
||||
<span>{m.creditsSpent} {TICKER}</span>
|
||||
<span>·</span>
|
||||
<span>{timeLeft(m.expiresAt)}</span>
|
||||
</div>
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import { SiteFooter } from "@/components/SiteFooter";
|
||||
import { creditTicker } from "@/lib/credits-brand";
|
||||
import { useSession } from "next-auth/react";
|
||||
import Link from "next/link";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
|
||||
const TICKER = creditTicker();
|
||||
|
||||
const TIERS = [
|
||||
{ tier: 1, label: "Supporter", cost: 20, gradient: "from-sky-400 to-cyan-500", glow: "rgba(56,189,248,0.35)" },
|
||||
{ tier: 2, label: "Champion", cost: 50, gradient: "from-indigo-400 to-purple-500", glow: "rgba(129,140,248,0.35)" },
|
||||
@@ -72,7 +75,7 @@ function TradingCard({ card, showUser }: { card: Card; showUser?: boolean }) {
|
||||
</div>
|
||||
<div className="rounded-xl bg-white/5 p-3 text-center">
|
||||
<p className="text-lg font-bold text-white">{s.creditsEarned.toLocaleString()}</p>
|
||||
<p className="text-xs text-slate-400">BWT Earned</p>
|
||||
<p className="text-xs text-slate-400">{TICKER} Earned</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -151,7 +154,7 @@ export default function CardsPage() {
|
||||
<p className={`font-bold text-sm bg-gradient-to-r ${t.gradient} bg-clip-text text-transparent uppercase tracking-widest`}>
|
||||
{t.label}
|
||||
</p>
|
||||
<p className="mt-1 text-lg font-semibold text-white">{t.cost} BWT</p>
|
||||
<p className="mt-1 text-lg font-semibold text-white">{t.cost} {TICKER}</p>
|
||||
<p className="text-xs text-slate-500">{"★".repeat(t.tier)}{"☆".repeat(3 - t.tier)}</p>
|
||||
</button>
|
||||
))}
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import { SiteFooter } from "@/components/SiteFooter";
|
||||
import { creditTicker } from "@/lib/credits-brand";
|
||||
import { useSession } from "next-auth/react";
|
||||
import Link from "next/link";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
|
||||
const TICKER = creditTicker();
|
||||
|
||||
type Submission = {
|
||||
id: string;
|
||||
displayName: string;
|
||||
@@ -17,17 +20,25 @@ type Approved = { id: string; question: string; answer: string | null; voteTotal
|
||||
type FaqBoardPayload = {
|
||||
pending: Submission[];
|
||||
approved: Approved[];
|
||||
submitCost?: number;
|
||||
voteCost?: number;
|
||||
signedIn?: boolean;
|
||||
};
|
||||
|
||||
export default function FaqBoardPage() {
|
||||
const { data: session } = useSession();
|
||||
const isAdmin = (session?.user as { role?: string } | undefined)?.role === "ADMIN";
|
||||
|
||||
const [pending, setPending] = useState<Submission[]>([]);
|
||||
const [approved, setApproved] = useState<Approved[]>([]);
|
||||
const [tab, setTab] = useState<"vote" | "ask" | "approved">("vote");
|
||||
const [submitCost, setSubmitCost] = useState(10);
|
||||
const [voteCost, setVoteCost] = useState(1);
|
||||
const [tab, setTab] = useState<"vote" | "ask" | "approved" | "admin">("vote");
|
||||
const [question, setQuestion] = useState("");
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [voting, setVoting] = useState<string | null>(null);
|
||||
const [adminBusy, setAdminBusy] = useState<string | null>(null);
|
||||
const [adminAnswers, setAdminAnswers] = useState<Record<string, string>>({});
|
||||
const [error, setError] = useState("");
|
||||
const [success, setSuccess] = useState("");
|
||||
const [loadError, setLoadError] = useState("");
|
||||
@@ -42,6 +53,8 @@ export default function FaqBoardPage() {
|
||||
const d = (await res.json()) as FaqBoardPayload;
|
||||
setPending(Array.isArray(d.pending) ? d.pending : []);
|
||||
setApproved(Array.isArray(d.approved) ? d.approved : []);
|
||||
if (typeof d.submitCost === "number") setSubmitCost(d.submitCost);
|
||||
if (typeof d.voteCost === "number") setVoteCost(d.voteCost);
|
||||
setLoadError("");
|
||||
} catch {
|
||||
setLoadError("Could not load the FAQ board.");
|
||||
@@ -80,6 +93,29 @@ export default function FaqBoardPage() {
|
||||
fetchData();
|
||||
}
|
||||
|
||||
async function adminAction(submissionId: string, action: "approve" | "reject") {
|
||||
setAdminBusy(submissionId); setError(""); setSuccess("");
|
||||
const answer = adminAnswers[submissionId] ?? "";
|
||||
const res = await fetch("/api/faq", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ action, submissionId, answer: answer.trim() || undefined }),
|
||||
});
|
||||
const d = await res.json();
|
||||
setAdminBusy(null);
|
||||
if (!res.ok) { setError(d.error ?? "Failed"); return; }
|
||||
setSuccess(`Question ${action}d.`);
|
||||
setAdminAnswers((prev) => { const n = { ...prev }; delete n[submissionId]; return n; });
|
||||
fetchData();
|
||||
}
|
||||
|
||||
const tabs = [
|
||||
{ key: "vote" as const, label: `Vote (${pending.length})` },
|
||||
{ key: "ask" as const, label: "Ask a Question" },
|
||||
{ key: "approved" as const, label: `Answered (${approved.length})` },
|
||||
...(isAdmin ? [{ key: "admin" as const, label: `Admin (${pending.length})` }] : []),
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<main className="min-h-screen border-b border-white/10 py-16">
|
||||
@@ -88,7 +124,7 @@ export default function FaqBoardPage() {
|
||||
<p className="text-xs uppercase tracking-[0.32em] text-purple-300/80">Community</p>
|
||||
<h1 className="mt-3 text-4xl font-semibold text-white sm:text-5xl">Community FAQ Board</h1>
|
||||
<p className="mt-4 max-w-2xl text-slate-400">
|
||||
Spend 10 BWT to submit a question for the team to answer publicly. Spend 1 BWT to upvote questions you want answered most — top questions get answered first and added to the live FAQ.
|
||||
Spend {submitCost} {TICKER} to submit a question for the team to answer publicly. Spend {voteCost} {TICKER} to upvote questions you want answered most — top questions get answered first.
|
||||
</p>
|
||||
{loadError && (
|
||||
<p className="mt-4 rounded-xl border border-amber-500/35 bg-amber-500/10 px-4 py-3 text-sm text-amber-100/95">
|
||||
@@ -101,15 +137,15 @@ export default function FaqBoardPage() {
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="mt-8 flex gap-4 border-b border-white/10">
|
||||
{(["vote", "ask", "approved"] as const).map((t) => (
|
||||
{tabs.map((t) => (
|
||||
<button
|
||||
key={t}
|
||||
onClick={() => setTab(t)}
|
||||
key={t.key}
|
||||
onClick={() => setTab(t.key)}
|
||||
className={`pb-3 text-sm font-medium capitalize transition ${
|
||||
tab === t ? "border-b-2 border-purple-400 text-white" : "text-slate-500 hover:text-slate-300"
|
||||
tab === t.key ? "border-b-2 border-purple-400 text-white" : "text-slate-500 hover:text-slate-300"
|
||||
}`}
|
||||
>
|
||||
{t === "vote" ? `Vote (${pending.length})` : t === "ask" ? "Ask a Question" : `Answered (${approved.length})`}
|
||||
{t.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
@@ -140,12 +176,12 @@ export default function FaqBoardPage() {
|
||||
onClick={() => vote(s.id)}
|
||||
disabled={!session || voting === s.id}
|
||||
className="flex h-9 w-9 items-center justify-center rounded-xl bg-purple-500/20 text-purple-300 transition hover:bg-purple-500/30 disabled:opacity-40"
|
||||
title={session ? "Upvote (1 BWT)" : "Sign in to vote"}
|
||||
title={session ? `Upvote (${voteCost} ${TICKER})` : "Sign in to vote"}
|
||||
>
|
||||
{voting === s.id ? "…" : "▲"}
|
||||
</button>
|
||||
<span className="text-xs font-semibold text-white">{s.voteTotal}</span>
|
||||
<span className="text-xs text-slate-600">1 BWT</span>
|
||||
<span className="text-xs text-slate-600">{voteCost} {TICKER}</span>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
@@ -157,7 +193,9 @@ export default function FaqBoardPage() {
|
||||
{tab === "ask" && (
|
||||
<div className="mt-6">
|
||||
{!session ? (
|
||||
<p className="text-slate-400"><Link href="/login?callbackUrl=%2Ffaq-board" className="text-sky-300 hover:underline">Sign in</Link> to submit a question (costs 10 BWT).</p>
|
||||
<p className="text-slate-400">
|
||||
<Link href="/login?callbackUrl=%2Ffaq-board" className="text-sky-300 hover:underline">Sign in</Link> to submit a question (costs {submitCost} {TICKER}).
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
<textarea
|
||||
@@ -168,7 +206,7 @@ export default function FaqBoardPage() {
|
||||
className="w-full resize-none rounded-xl border border-white/10 bg-white/5 px-4 py-3 text-sm text-white placeholder-slate-500 focus:outline-none focus:ring-1 focus:ring-purple-500"
|
||||
/>
|
||||
<p className="mt-1 flex justify-between text-xs text-slate-600">
|
||||
<span>10 BWT to submit</span>
|
||||
<span>{submitCost} {TICKER} to submit</span>
|
||||
<span>{question.length}/280</span>
|
||||
</p>
|
||||
<button
|
||||
@@ -176,7 +214,7 @@ export default function FaqBoardPage() {
|
||||
disabled={submitting || question.trim().length < 10}
|
||||
className="mt-4 rounded-full bg-gradient-to-r from-purple-500 to-indigo-500 px-8 py-2.5 text-sm font-semibold text-white shadow-lg disabled:opacity-40"
|
||||
>
|
||||
{submitting ? "Submitting…" : "Submit for 10 BWT"}
|
||||
{submitting ? "Submitting…" : `Submit for ${submitCost} ${TICKER}`}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
@@ -202,6 +240,51 @@ export default function FaqBoardPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Admin tab — visible only to ADMIN role */}
|
||||
{tab === "admin" && isAdmin && (
|
||||
<div className="mt-6 space-y-4">
|
||||
{pending.length === 0 ? (
|
||||
<p className="text-slate-500">No pending questions.</p>
|
||||
) : (
|
||||
pending.map((s) => (
|
||||
<div key={s.id} className="rounded-2xl border border-yellow-500/20 bg-yellow-950/10 p-5 space-y-3">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-white">{s.question}</p>
|
||||
<p className="mt-1 text-xs text-slate-500">
|
||||
Asked by {s.displayName} · {s.voteTotal} vote{s.voteTotal !== 1 ? "s" : ""}
|
||||
</p>
|
||||
</div>
|
||||
<textarea
|
||||
value={adminAnswers[s.id] ?? ""}
|
||||
onChange={(e) =>
|
||||
setAdminAnswers((prev) => ({ ...prev, [s.id]: e.target.value.slice(0, 1000) }))
|
||||
}
|
||||
placeholder="Optional answer (required for approve to add to FAQ)…"
|
||||
rows={3}
|
||||
className="w-full resize-none rounded-xl border border-white/10 bg-white/5 px-4 py-2 text-sm text-white placeholder-slate-500 focus:outline-none focus:ring-1 focus:ring-yellow-500"
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => adminAction(s.id, "approve")}
|
||||
disabled={adminBusy === s.id}
|
||||
className="rounded-full bg-emerald-600/80 px-5 py-2 text-sm font-semibold text-white hover:bg-emerald-600 disabled:opacity-40"
|
||||
>
|
||||
{adminBusy === s.id ? "…" : "Approve"}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => adminAction(s.id, "reject")}
|
||||
disabled={adminBusy === s.id}
|
||||
className="rounded-full bg-red-600/60 px-5 py-2 text-sm font-semibold text-white hover:bg-red-600/80 disabled:opacity-40"
|
||||
>
|
||||
{adminBusy === s.id ? "…" : "Reject"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
</main>
|
||||
<SiteFooter />
|
||||
|
||||
@@ -40,15 +40,16 @@ export const metadata: Metadata = {
|
||||
export default async function RaisedPage() {
|
||||
const [agg, donorRows, guestCount] = await Promise.all([
|
||||
prisma.donation.aggregate({
|
||||
where: { status: "succeeded" },
|
||||
_sum: { amountUsdCents: true },
|
||||
_count: true,
|
||||
}),
|
||||
prisma.donation.groupBy({
|
||||
by: ["userId"],
|
||||
where: { userId: { not: null } },
|
||||
where: { userId: { not: null }, status: "succeeded" },
|
||||
_count: true,
|
||||
}),
|
||||
prisma.donation.count({ where: { userId: null } }),
|
||||
prisma.donation.count({ where: { userId: null, status: "succeeded" } }),
|
||||
]);
|
||||
|
||||
const raisedUsdForLd = (agg._sum.amountUsdCents ?? 0) / 100;
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import { SiteFooter } from "@/components/SiteFooter";
|
||||
import { creditTicker } from "@/lib/credits-brand";
|
||||
import { useSession } from "next-auth/react";
|
||||
import Link from "next/link";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
|
||||
const TICKER = creditTicker();
|
||||
|
||||
type IssueTotal = { issueSlug: string; issueTitle: string; total: number; backers: number };
|
||||
type CatalogItem = { slug: string; title: string };
|
||||
|
||||
@@ -65,7 +68,7 @@ export default function SpotlightPage() {
|
||||
const d = await res.json();
|
||||
setLoading(false);
|
||||
if (!res.ok) { setError(d.error ?? "Failed"); return; }
|
||||
setSuccess(`Bid placed! ${bidAmount} BWT toward "${d.issue.title}"`);
|
||||
setSuccess(`Bid placed! ${bidAmount} ${TICKER} toward "${d.issue.title}"`);
|
||||
fetchData();
|
||||
}
|
||||
|
||||
@@ -79,7 +82,7 @@ export default function SpotlightPage() {
|
||||
<p className="text-xs uppercase tracking-[0.32em] text-indigo-300/80">Weekly Auction</p>
|
||||
<h1 className="mt-3 text-4xl font-semibold text-white sm:text-5xl">Issue Spotlight</h1>
|
||||
<p className="mt-4 max-w-2xl text-slate-400">
|
||||
Bid Blue Wave Tokens on the policy issue you want featured this week. The issue with the most BWT by Sunday midnight earns the homepage spotlight — and all backers get credited.
|
||||
Bid {TICKER} on the policy issue you want featured this week. The issue with the most {TICKER} by Sunday midnight earns the homepage spotlight — and all backers get credited.
|
||||
</p>
|
||||
{pageLoading && (
|
||||
<div className="mt-8 animate-pulse space-y-3">
|
||||
@@ -107,7 +110,7 @@ export default function SpotlightPage() {
|
||||
<p className="text-xs uppercase tracking-widest text-indigo-300">🏆 This week's leader</p>
|
||||
<p className="mt-2 text-2xl font-semibold text-white">{leader.issueTitle}</p>
|
||||
<p className="mt-1 text-slate-400">
|
||||
<span className="font-semibold text-indigo-300">{leader.total.toLocaleString()} BWT</span>
|
||||
<span className="font-semibold text-indigo-300">{leader.total.toLocaleString()} {TICKER}</span>
|
||||
{" "}from {leader.backers} backer{leader.backers !== 1 ? "s" : ""}
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-slate-600">Week of {weekOf}</p>
|
||||
@@ -124,7 +127,7 @@ export default function SpotlightPage() {
|
||||
<span className={`font-medium ${i === 0 ? "text-indigo-300" : "text-white"}`}>
|
||||
{i === 0 ? "🥇 " : i === 1 ? "🥈 " : i === 2 ? "🥉 " : ""}{t.issueTitle}
|
||||
</span>
|
||||
<span className="text-slate-400">{t.total.toLocaleString()} BWT · {t.backers} backer{t.backers !== 1 ? "s" : ""}</span>
|
||||
<span className="text-slate-400">{t.total.toLocaleString()} {TICKER} · {t.backers} backer{t.backers !== 1 ? "s" : ""}</span>
|
||||
</div>
|
||||
<div className="mt-1 h-2.5 overflow-hidden rounded-full bg-white/5">
|
||||
<div
|
||||
@@ -164,7 +167,7 @@ export default function SpotlightPage() {
|
||||
</div>
|
||||
|
||||
<div className="mt-5">
|
||||
<p className="text-sm text-slate-400 mb-2">BWT amount</p>
|
||||
<p className="text-sm text-slate-400 mb-2">{TICKER} amount</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{presets.map((p) => (
|
||||
<button
|
||||
@@ -198,7 +201,7 @@ export default function SpotlightPage() {
|
||||
disabled={loading || !selected}
|
||||
className="mt-5 rounded-full bg-gradient-to-r from-indigo-500 to-sky-500 px-8 py-2.5 text-sm font-semibold text-white shadow-lg disabled:opacity-40"
|
||||
>
|
||||
{loading ? "Bidding…" : `Bid ${bidAmount} BWT`}
|
||||
{loading ? "Bidding…" : `Bid ${bidAmount} ${TICKER}`}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -5,7 +5,7 @@ import { usdValueOfBlwCredits } from "@/lib/exchange";
|
||||
import Link from "next/link";
|
||||
import { signOut } from "next-auth/react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
type LedgerItem = {
|
||||
id: string;
|
||||
@@ -66,13 +66,13 @@ export function WalletActions({
|
||||
const pollingRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
const pollCountRef = useRef(0);
|
||||
|
||||
const refreshBalance = async () => {
|
||||
const refreshBalance = useCallback(async () => {
|
||||
const res = await fetch("/api/wallet", { cache: "no-store" });
|
||||
if (!res.ok) return;
|
||||
const data = await res.json();
|
||||
setBalance(data.balanceCredits ?? 0);
|
||||
setInfiniteCredits(!!data.infiniteCredits);
|
||||
};
|
||||
}, []); // setters from useState are stable — no deps needed
|
||||
|
||||
// Supporter credit spot index (USD per credit unit)
|
||||
useEffect(() => {
|
||||
@@ -106,8 +106,7 @@ export function WalletActions({
|
||||
return () => {
|
||||
if (pollingRef.current) clearInterval(pollingRef.current);
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [justDonated]);
|
||||
}, [justDonated, refreshBalance]);
|
||||
|
||||
const loadLedger = async (cursor?: string) => {
|
||||
setLedgerLoading(true);
|
||||
|
||||
Reference in New Issue
Block a user