From c6a7b2e08d2ca81f879ced6518db499939664f82 Mon Sep 17 00:00:00 2001 From: root Date: Sat, 16 May 2026 00:56:13 +0000 Subject: [PATCH] Fix all critical/high/medium/low audit items MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- server.ts | 114 +++++++++++++++------- src/app/api/cards/route.ts | 2 +- src/app/api/games/prediction/route.ts | 95 +++++++++++++----- src/app/api/leaderboard/route.ts | 2 +- src/app/api/polls/next-president/route.ts | 79 ++++++++------- src/app/billboard/page.tsx | 11 ++- src/app/cards/page.tsx | 7 +- src/app/faq-board/page.tsx | 107 +++++++++++++++++--- src/app/raised/page.tsx | 5 +- src/app/spotlight/page.tsx | 15 +-- src/app/wallet/WalletActions.tsx | 9 +- 11 files changed, 313 insertions(+), 133 deletions(-) diff --git a/server.ts b/server.ts index d1f2e90..de99ac4 100644 --- a/server.ts +++ b/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() } }, diff --git a/src/app/api/cards/route.ts b/src/app/api/cards/route.ts index 9b47a0b..41f3158 100644 --- a/src/app/api/cards/route.ts +++ b/src/app/api/cards/route.ts @@ -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, }), diff --git a/src/app/api/games/prediction/route.ts b/src/app/api/games/prediction/route.ts index df5ccc1..a2f633e 100644 --- a/src/app/api/games/prediction/route.ts +++ b/src/app/api/games/prediction/route.ts @@ -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 }); + } } diff --git a/src/app/api/leaderboard/route.ts b/src/app/api/leaderboard/route.ts index 67308a3..fd0480b 100644 --- a/src/app/api/leaderboard/route.ts +++ b/src/app/api/leaderboard/route.ts @@ -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" } }, diff --git a/src/app/api/polls/next-president/route.ts b/src/app/api/polls/next-president/route.ts index 61cbdbb..75b462c 100644 --- a/src/app/api/polls/next-president/route.ts +++ b/src/app/api/polls/next-president/route.ts @@ -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(); - const counts = new Map(); - - 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(); - 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; diff --git a/src/app/billboard/page.tsx b/src/app/billboard/page.tsx index 4da056c..11d9324 100644 --- a/src/app/billboard/page.tsx +++ b/src/app/billboard/page.tsx @@ -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() {

Community

Democracy Billboard

- 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.

{loadError && (

@@ -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} ))} @@ -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}`} )} @@ -181,7 +184,7 @@ export default function BillboardPage() {

{m.displayName}

"{m.message}"

- {m.creditsSpent} BWT + {m.creditsSpent} {TICKER} · {timeLeft(m.expiresAt)}
diff --git a/src/app/cards/page.tsx b/src/app/cards/page.tsx index 157584f..424548d 100644 --- a/src/app/cards/page.tsx +++ b/src/app/cards/page.tsx @@ -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 }) {

{s.creditsEarned.toLocaleString()}

-

BWT Earned

+

{TICKER} Earned

@@ -151,7 +154,7 @@ export default function CardsPage() {

{t.label}

-

{t.cost} BWT

+

{t.cost} {TICKER}

{"★".repeat(t.tier)}{"☆".repeat(3 - t.tier)}

))} diff --git a/src/app/faq-board/page.tsx b/src/app/faq-board/page.tsx index 79cc976..6a07db8 100644 --- a/src/app/faq-board/page.tsx +++ b/src/app/faq-board/page.tsx @@ -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([]); const [approved, setApproved] = useState([]); - 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(null); + const [adminBusy, setAdminBusy] = useState(null); + const [adminAnswers, setAdminAnswers] = useState>({}); 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 ( <>
@@ -88,7 +124,7 @@ export default function FaqBoardPage() {

Community

Community FAQ Board

- 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.

{loadError && (

@@ -101,15 +137,15 @@ export default function FaqBoardPage() { {/* Tabs */}

- {(["vote", "ask", "approved"] as const).map((t) => ( + {tabs.map((t) => ( ))}
@@ -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 ? "…" : "▲"} {s.voteTotal} - 1 BWT + {voteCost} {TICKER} )) @@ -157,7 +193,9 @@ export default function FaqBoardPage() { {tab === "ask" && (
{!session ? ( -

Sign in to submit a question (costs 10 BWT).

+

+ Sign in to submit a question (costs {submitCost} {TICKER}). +

) : ( <>