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:
root
2026-05-16 00:56:13 +00:00
parent 391d90a754
commit c6a7b2e08d
11 changed files with 313 additions and 133 deletions

106
server.ts
View File

@@ -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));
try {
await creditForWin(winnerId, payout, "COIN_FLIP");
await prisma.gameRoom.update({
where: { id: roomId },
data: {
status: "RESOLVED",
resultData: { result, winnerId, serverSeed, payout },
},
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() } },

View File

@@ -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,
}),

View File

@@ -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 };
if (typeof marketId !== "string" || typeof resolvedTo !== "boolean") {
return NextResponse.json({ error: "Invalid input" }, { status: 400 });
}
const market = await prisma.predictionMarket.findUnique({
where: { id: marketId },
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 },
});
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 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 creditForWin(bet.userId, payout, "PREDICTION", `Prediction win: ${market.question}`);
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}`,
},
});
}
}
}
await prisma.predictionMarket.update({ where: { id: marketId }, data: { resolvedTo } });
return NextResponse.json({ resolved: true, resolvedTo, totalPot, winners: winnerBets.length });
// 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 });
}
}

View File

@@ -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" } },

View File

@@ -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;

View File

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

View File

@@ -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>
))}

View File

@@ -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 />

View File

@@ -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;

View File

@@ -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&apos;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>
</>
)}

View File

@@ -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);