Legal pages, Threads OAuth, live wallet pill, PvP money flow + resume
- Legal: /privacy-policy and /datadeletion (shared LegalPageLayout, footer links, sitemap + integration tests, NEXT_PUBLIC_LEGAL_CONTACT_EMAIL). - Threads OAuth: server-side /api/threads-exchange and /threads-callback page (Suspense + client component, noindex, no leaked secrets). - Username + live wallet pill in top nav. New NavWalletBalance component polls /api/wallet, refreshes on tab focus, and listens to the `wallet:refresh` event bus so cash-outs and refunds update the nav in real time. Flash animation on balance changes. - useLiveWalletBalance hook now broadcasts `wallet:refresh` after every fetch so games, exchange panel, wallet actions, and nav all stay in sync without extra polling. - PvP fund-locking (`POST /api/games/rooms`): creator funds debited atomically with room creation; ledger entry tagged with `gameRoomId`. Joiner debit happens at join. Old double-debit of the creator is gone. - DELETE /api/games/rooms?id=... lets a creator cancel a WAITING room and get an idempotent refund. Coin Flip + Pong waiting screens show a Cancel & Refund button. - Pong/Coin Flip recovery: expiry sweep + boot-time `recoverOrphaned RoomsOnBoot()` (runs before listen()) refund both parties for any ACTIVE/expired rooms so a server restart never strands locked credits. - Schema migration `20260520000000_game_ledger_links` adds optional `gameSessionId` + `gameRoomId` FKs to LedgerEntry (with indexes) and extra indexes on GameSession/GameRoom for resume + sweep queries. - GET /api/games/active returns a user's active solo session + open rooms (sanitized — no mine/bomb positions). Mines and Tower clients rehydrate on mount so a refresh mid-round resumes instead of dropping. - ActiveGamesBanner surfaces unfinished rounds on /wallet and /casino with Resume / Rejoin / Cancel & refund actions. - ExchangePanel unified with useLiveWalletBalance; per-game header gets an "Open wallet →" chip; Dice clears stale result on roll; Blackjack reveals full dealer hand on natural blackjack/push; Mines refund label fixed; Tower final multiplier fixed; GameHistory error path; Prediction "Resolved" tab. - Site audit + redmeFIXES triage notes (REDME-FIXSES-TRIAGE.txt, SITE-AUDIT.txt). Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
115
src/app/api/games/active/route.ts
Normal file
115
src/app/api/games/active/route.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { auth } from "@/auth";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { hashServerSeed } from "@/lib/provably-fair";
|
||||
import type { GameType } from "@prisma/client";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/**
|
||||
* Active-state endpoint used for:
|
||||
* - Solo game resume (single gameType — returns one session + sanitized state)
|
||||
* - Wallet/lobby "you have unfinished games" banner (no gameType — bulk)
|
||||
*
|
||||
* Never returns serverSeed or hidden board contents (mines/bomb positions) for
|
||||
* still-active rounds; only a hash and player-visible progress.
|
||||
*/
|
||||
export async function GET(req: NextRequest) {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
|
||||
const url = new URL(req.url);
|
||||
const gameType = url.searchParams.get("gameType");
|
||||
|
||||
if (gameType) {
|
||||
const gs = await prisma.gameSession.findFirst({
|
||||
where: {
|
||||
userId: session.user.id,
|
||||
gameType: gameType as GameType,
|
||||
outcome: "active",
|
||||
},
|
||||
orderBy: { createdAt: "desc" },
|
||||
});
|
||||
const room = await prisma.gameRoom.findFirst({
|
||||
where: {
|
||||
gameType: gameType as GameType,
|
||||
OR: [
|
||||
{ creatorId: session.user.id, status: "WAITING" },
|
||||
{ creatorId: session.user.id, status: "ACTIVE" },
|
||||
{ joinerId: session.user.id, status: "ACTIVE" },
|
||||
],
|
||||
},
|
||||
orderBy: { createdAt: "desc" },
|
||||
});
|
||||
return NextResponse.json({
|
||||
session: gs ? formatSession(gs) : null,
|
||||
room,
|
||||
});
|
||||
}
|
||||
|
||||
// Bulk: every active solo session + every live room for this user.
|
||||
const [sessions, rooms] = await Promise.all([
|
||||
prisma.gameSession.findMany({
|
||||
where: { userId: session.user.id, outcome: "active" },
|
||||
orderBy: { createdAt: "desc" },
|
||||
}),
|
||||
prisma.gameRoom.findMany({
|
||||
where: {
|
||||
OR: [
|
||||
{ creatorId: session.user.id, status: { in: ["WAITING", "ACTIVE"] } },
|
||||
{ joinerId: session.user.id, status: "ACTIVE" },
|
||||
],
|
||||
},
|
||||
orderBy: { createdAt: "desc" },
|
||||
}),
|
||||
]);
|
||||
|
||||
return NextResponse.json({
|
||||
sessions: sessions.map(formatSession),
|
||||
rooms,
|
||||
});
|
||||
}
|
||||
|
||||
function formatSession(gs: {
|
||||
id: string;
|
||||
gameType: GameType;
|
||||
wageredBLW: number;
|
||||
serverSeed: string;
|
||||
clientSeed: string | null;
|
||||
resultData: unknown;
|
||||
createdAt: Date;
|
||||
}) {
|
||||
return {
|
||||
id: gs.id,
|
||||
gameType: gs.gameType,
|
||||
wageredBLW: gs.wageredBLW,
|
||||
serverSeedHash: hashServerSeed(gs.serverSeed),
|
||||
clientSeed: gs.clientSeed,
|
||||
resultData: sanitizeResultData(gs.gameType, gs.resultData),
|
||||
createdAt: gs.createdAt,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Strip server-only info (e.g. mine/bomb positions) from resultData before
|
||||
* sending back to the client. Players must never see unrevealed positions.
|
||||
*/
|
||||
function sanitizeResultData(gameType: GameType, raw: unknown): unknown {
|
||||
if (!raw || typeof raw !== "object") return null;
|
||||
const d = raw as Record<string, unknown>;
|
||||
switch (gameType) {
|
||||
case "MINES": {
|
||||
return {
|
||||
revealed: Array.isArray(d.revealed) ? d.revealed : [],
|
||||
mineCount: typeof d.mineCount === "number" ? d.mineCount : 0,
|
||||
};
|
||||
}
|
||||
case "TOWER": {
|
||||
return {
|
||||
currentFloor: typeof d.currentFloor === "number" ? d.currentFloor : 0,
|
||||
};
|
||||
}
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -64,11 +64,12 @@ export async function POST(req: NextRequest) {
|
||||
}
|
||||
|
||||
const playerVal = handValue(playerHand);
|
||||
let dealerVal: number | undefined;
|
||||
let outcome = "active";
|
||||
|
||||
if (playerVal === 21) {
|
||||
// Natural blackjack — check dealer
|
||||
const dealerVal = handValue(dealerHand);
|
||||
dealerVal = handValue(dealerHand);
|
||||
if (dealerVal === 21) {
|
||||
outcome = "push";
|
||||
} else {
|
||||
@@ -92,12 +93,12 @@ export async function POST(req: NextRequest) {
|
||||
const payout = Math.floor(wageBLW * 2.5);
|
||||
await creditForWin(session.user.id, payout, "BLACKJACK");
|
||||
await prisma.gameSession.update({ where: { id: gs.id }, data: { outcome: "blackjack", multiplier: 2.5, payoutBLW: payout } });
|
||||
return NextResponse.json({ roundId: gs.id, playerHand, dealerVisible: [dealerHand[0]], outcome: "blackjack", payout, playerVal });
|
||||
return NextResponse.json({ roundId: gs.id, playerHand, dealerVisible: [dealerHand[0]], dealerHand, outcome: "blackjack", payout, playerVal, dealerVal });
|
||||
}
|
||||
if (outcome === "push") {
|
||||
await refundBet(session.user.id, wageBLW, "BLACKJACK");
|
||||
await prisma.gameSession.update({ where: { id: gs.id }, data: { outcome: "push", multiplier: 1, payoutBLW: wageBLW } });
|
||||
return NextResponse.json({ roundId: gs.id, playerHand, dealerVisible: [dealerHand[0]], outcome: "push", payout: wageBLW, playerVal });
|
||||
return NextResponse.json({ roundId: gs.id, playerHand, dealerVisible: [dealerHand[0]], dealerHand, outcome: "push", payout: wageBLW, playerVal, dealerVal });
|
||||
}
|
||||
|
||||
return NextResponse.json({ roundId: gs.id, playerHand, dealerVisible: [dealerHand[0]], outcome: "active", playerVal });
|
||||
|
||||
@@ -7,7 +7,7 @@ import { creditWalletCredits } from "@/lib/wallet-safety";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
// POST /api/games/crash — start a crash round, returns serverSeedHash + roundId
|
||||
// POST /api/games/crash — start a round, returns commit hash + round id.
|
||||
export async function POST(req: NextRequest) {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
@@ -42,16 +42,32 @@ export async function POST(req: NextRequest) {
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json({ roundId: gs.id, serverSeedHash: seedHash, crashAt });
|
||||
return NextResponse.json({ roundId: gs.id, serverSeedHash: seedHash });
|
||||
}
|
||||
|
||||
// PATCH /api/games/crash — cash out at current multiplier
|
||||
async function settleCrashLoss(roundId: string, userId: string, crashAt: number, serverSeed: string) {
|
||||
const settled = await prisma.gameSession.updateMany({
|
||||
where: { id: roundId, userId, outcome: "active" },
|
||||
data: { outcome: "loss", multiplier: crashAt, payoutBLW: 0 },
|
||||
});
|
||||
if (settled.count !== 1) {
|
||||
return NextResponse.json({ error: "Round not found or already settled" }, { status: 404 });
|
||||
}
|
||||
return NextResponse.json({ outcome: "loss", crashAt, payout: 0, serverSeed });
|
||||
}
|
||||
|
||||
// PATCH /api/games/crash — tick status or cash out.
|
||||
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 { roundId, cashoutAt } = body as { roundId: string; cashoutAt: number };
|
||||
const { roundId, cashoutAt, action, currentMultiplier } = body as {
|
||||
roundId: string;
|
||||
cashoutAt?: number;
|
||||
action?: "tick" | "cashout";
|
||||
currentMultiplier?: number;
|
||||
};
|
||||
|
||||
const gs = await prisma.gameSession.findUnique({ where: { id: roundId } });
|
||||
if (!gs || gs.userId !== session.user.id || gs.outcome !== "active") {
|
||||
@@ -60,13 +76,25 @@ export async function PATCH(req: NextRequest) {
|
||||
|
||||
const { crashAt } = gs.resultData as { crashAt: number };
|
||||
|
||||
if (cashoutAt > crashAt) {
|
||||
// Player cashed out after crash — they lose
|
||||
await prisma.gameSession.update({
|
||||
where: { id: roundId },
|
||||
data: { outcome: "loss", multiplier: crashAt, payoutBLW: 0 },
|
||||
});
|
||||
return NextResponse.json({ outcome: "loss", crashAt, payout: 0 });
|
||||
const mode: "tick" | "cashout" = action ?? "cashout";
|
||||
|
||||
if (mode === "tick") {
|
||||
if (typeof currentMultiplier !== "number" || !Number.isFinite(currentMultiplier) || currentMultiplier < 1) {
|
||||
return NextResponse.json({ error: "Invalid multiplier probe" }, { status: 400 });
|
||||
}
|
||||
if (currentMultiplier >= crashAt) {
|
||||
return settleCrashLoss(roundId, session.user.id, crashAt, gs.serverSeed ?? "");
|
||||
}
|
||||
return NextResponse.json({ outcome: "active", crashed: false });
|
||||
}
|
||||
|
||||
if (typeof cashoutAt !== "number" || !Number.isFinite(cashoutAt) || cashoutAt < 1) {
|
||||
return NextResponse.json({ error: "Invalid cashout value" }, { status: 400 });
|
||||
}
|
||||
|
||||
if (cashoutAt >= crashAt) {
|
||||
// Player cashed out after crash — settle as a loss.
|
||||
return settleCrashLoss(roundId, session.user.id, crashAt, gs.serverSeed ?? "");
|
||||
}
|
||||
|
||||
const multiplier = Math.max(1.0, cashoutAt);
|
||||
|
||||
@@ -120,6 +120,10 @@ export async function PATCH(req: NextRequest) {
|
||||
include: { bets: true },
|
||||
});
|
||||
|
||||
if (market.endsAt > new Date()) {
|
||||
throw new Error("MARKET_NOT_ENDED");
|
||||
}
|
||||
|
||||
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);
|
||||
@@ -170,6 +174,9 @@ export async function PATCH(req: NextRequest) {
|
||||
|
||||
return NextResponse.json({ resolved: true, resolvedTo, ...outcome });
|
||||
} catch (e) {
|
||||
if (e instanceof Error && e.message === "MARKET_NOT_ENDED") {
|
||||
return NextResponse.json({ error: "Market cannot be resolved before its close time." }, { status: 400 });
|
||||
}
|
||||
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === "P2025") {
|
||||
return NextResponse.json({ error: "Market not found, already resolved, or not authorized." }, { status: 409 });
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { auth } from "@/auth";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { debitWalletCredits, INSUFFICIENT_CREDITS } from "@/lib/wallet-safety";
|
||||
import { refundRoomPartyIfNotRefunded } from "@/lib/game-ledger";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -31,22 +33,64 @@ export async function POST(req: NextRequest) {
|
||||
if (!Number.isInteger(wageBLW) || wageBLW < 1) return NextResponse.json({ error: "Invalid wager" }, { status: 400 });
|
||||
if (!["COIN_FLIP", "PONG"].includes(gameType)) return NextResponse.json({ error: "Invalid game type" }, { status: 400 });
|
||||
|
||||
// Check balance
|
||||
const wallet = await prisma.wallet.findUnique({ where: { userId: session.user.id } });
|
||||
if (!wallet || wallet.balanceCredits < wageBLW) {
|
||||
return NextResponse.json({ error: "Insufficient balance" }, { status: 402 });
|
||||
}
|
||||
|
||||
const userId = session.user.id;
|
||||
const expiresAt = new Date(Date.now() + 5 * 60 * 1000); // 5 min
|
||||
|
||||
const room = await prisma.gameRoom.create({
|
||||
data: {
|
||||
gameType,
|
||||
creatorId: session.user.id,
|
||||
wageBLW,
|
||||
expiresAt,
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json({ room });
|
||||
// Lock the creator's funds atomically with room creation so two browser tabs
|
||||
// can't double-spend the same balance. The ledger entry references the room
|
||||
// for later refund / reconciliation.
|
||||
try {
|
||||
const room = await prisma.$transaction(async (tx) => {
|
||||
await debitWalletCredits(tx, userId, wageBLW);
|
||||
const r = await tx.gameRoom.create({
|
||||
data: { gameType, creatorId: userId, wageBLW, expiresAt },
|
||||
});
|
||||
await tx.ledgerEntry.create({
|
||||
data: {
|
||||
userId,
|
||||
delta: -wageBLW,
|
||||
type: "DEBIT_GAME_BET",
|
||||
memo: `${gameType} room lock`,
|
||||
gameRoomId: r.id,
|
||||
},
|
||||
});
|
||||
return r;
|
||||
});
|
||||
return NextResponse.json({ room });
|
||||
} catch (e) {
|
||||
if (e instanceof Error && e.message === INSUFFICIENT_CREDITS) {
|
||||
return NextResponse.json({ error: "Insufficient balance" }, { status: 402 });
|
||||
}
|
||||
console.error("[rooms.POST] failed:", e);
|
||||
return NextResponse.json({ error: "Could not create room" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
// Creator-initiated cancel — only works while the room is still WAITING.
|
||||
// Refunds the locked stake idempotently.
|
||||
export async function DELETE(req: NextRequest) {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
|
||||
const url = new URL(req.url);
|
||||
const roomId = url.searchParams.get("id");
|
||||
if (!roomId) return NextResponse.json({ error: "Missing room id" }, { status: 400 });
|
||||
|
||||
const room = await prisma.gameRoom.findUnique({ where: { id: roomId } });
|
||||
if (!room) return NextResponse.json({ error: "Room not found" }, { status: 404 });
|
||||
if (room.creatorId !== session.user.id) {
|
||||
return NextResponse.json({ error: "Not your room" }, { status: 403 });
|
||||
}
|
||||
|
||||
// Atomically move WAITING → EXPIRED; only one cancel wins the race.
|
||||
const cancelled = await prisma.gameRoom.updateMany({
|
||||
where: { id: roomId, status: "WAITING" },
|
||||
data: { status: "EXPIRED" },
|
||||
});
|
||||
if (cancelled.count !== 1) {
|
||||
return NextResponse.json({ error: "Room not cancellable" }, { status: 409 });
|
||||
}
|
||||
|
||||
await refundRoomPartyIfNotRefunded(roomId, room.creatorId, room.wageBLW, room.gameType);
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
|
||||
62
src/app/api/threads-exchange/route.ts
Normal file
62
src/app/api/threads-exchange/route.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
const THREADS_TOKEN_URL = "https://graph.threads.net/oauth/access_token";
|
||||
const THREADS_LONG_LIVED_TOKEN_URL = "https://graph.threads.net/access_token";
|
||||
const DEFAULT_REDIRECT_URI = "https://bwt.democracyrisingbwt.us/threads-callback";
|
||||
|
||||
export async function POST(req: Request) {
|
||||
const clientId = process.env.THREADS_CLIENT_ID;
|
||||
const clientSecret = process.env.THREADS_CLIENT_SECRET;
|
||||
const redirectUri = process.env.THREADS_REDIRECT_URI ?? DEFAULT_REDIRECT_URI;
|
||||
|
||||
if (!clientId || !clientSecret) {
|
||||
return NextResponse.json({ error: "Threads OAuth is not configured." }, { status: 500 });
|
||||
}
|
||||
|
||||
let code: unknown;
|
||||
try {
|
||||
const body = await req.json();
|
||||
code = body?.code;
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Invalid JSON body." }, { status: 400 });
|
||||
}
|
||||
|
||||
if (typeof code !== "string" || !code.trim()) {
|
||||
return NextResponse.json({ error: "Missing authorization code." }, { status: 400 });
|
||||
}
|
||||
|
||||
const tokenRes = await fetch(THREADS_TOKEN_URL, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
body: new URLSearchParams({
|
||||
client_id: clientId,
|
||||
client_secret: clientSecret,
|
||||
grant_type: "authorization_code",
|
||||
redirect_uri: redirectUri,
|
||||
code,
|
||||
}),
|
||||
});
|
||||
|
||||
const tokenData = await tokenRes.json();
|
||||
if (!tokenRes.ok || tokenData.error) {
|
||||
return NextResponse.json(tokenData, { status: tokenRes.status || 400 });
|
||||
}
|
||||
|
||||
const accessToken = tokenData.access_token;
|
||||
if (typeof accessToken !== "string" || !accessToken) {
|
||||
return NextResponse.json({ error: "Threads did not return an access token.", tokenData }, { status: 502 });
|
||||
}
|
||||
|
||||
const longUrl = new URL(THREADS_LONG_LIVED_TOKEN_URL);
|
||||
longUrl.searchParams.set("grant_type", "th_exchange_token");
|
||||
longUrl.searchParams.set("client_secret", clientSecret);
|
||||
longUrl.searchParams.set("access_token", accessToken);
|
||||
|
||||
const longRes = await fetch(longUrl);
|
||||
const longData = await longRes.json();
|
||||
if (!longRes.ok || longData.error) {
|
||||
return NextResponse.json(longData, { status: longRes.status || 400 });
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true, ...longData });
|
||||
}
|
||||
@@ -128,7 +128,7 @@ export default function BillboardPage() {
|
||||
<h2 className="text-lg font-semibold text-white">Post your message</h2>
|
||||
{!session ? (
|
||||
<p className="mt-4 text-slate-400">
|
||||
<Link href="/login" className="text-sky-300 hover:underline">Sign in</Link> to post to the Billboard.
|
||||
<Link href="/login?callbackUrl=%2Fbillboard" className="text-sky-300 hover:underline">Sign in</Link> to post to the Billboard.
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
|
||||
@@ -171,7 +171,7 @@ export default function CardsPage() {
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-10 rounded-3xl border border-white/10 bg-white/[0.03] p-6">
|
||||
<p className="text-slate-400"><Link href="/register" className="text-sky-300 hover:underline">Join Democracy Rising</Link> to mint your supporter card.</p>
|
||||
<p className="text-slate-400"><Link href="/register?callbackUrl=%2Fcards" className="text-sky-300 hover:underline">Join Democracy Rising</Link> to mint your supporter card.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -63,12 +63,20 @@ export default async function GamePage({ params }: { params: Promise<{ game: str
|
||||
<>
|
||||
<main className="min-h-screen bg-[#030712] px-4 py-8 sm:px-6">
|
||||
<div className="mx-auto max-w-6xl">
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<Link href="/casino" className="text-slate-400 hover:text-white transition-colors text-sm">
|
||||
← Casino
|
||||
<div className="mb-6 flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-3 text-sm">
|
||||
<Link href="/casino" className="text-slate-400 hover:text-white transition-colors">
|
||||
← Casino
|
||||
</Link>
|
||||
<span className="text-slate-600">/</span>
|
||||
<span className="text-white font-medium">{meta.name}</span>
|
||||
</div>
|
||||
<Link
|
||||
href="/wallet"
|
||||
className="rounded-full border border-white/15 px-3.5 py-1.5 text-xs font-medium text-slate-200 hover:bg-white/5"
|
||||
>
|
||||
Open wallet →
|
||||
</Link>
|
||||
<span className="text-slate-600">/</span>
|
||||
<span className="text-white font-medium">{meta.name}</span>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
|
||||
@@ -6,6 +6,7 @@ import { creditDisplayName, creditTicker } from "@/lib/credits-brand";
|
||||
import { SiteFooter } from "@/components/SiteFooter";
|
||||
import { ExchangePanel } from "@/components/casino/ExchangePanel";
|
||||
import { GameHistory } from "@/components/casino/GameHistory";
|
||||
import { ActiveGamesBanner } from "@/components/casino/ActiveGamesBanner";
|
||||
|
||||
const T = creditTicker();
|
||||
const CREDIT_LONG = creditDisplayName();
|
||||
@@ -53,6 +54,8 @@ export default async function CasinoLobby() {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<ActiveGamesBanner userId={session.user.id} />
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
{/* Exchange Panel + History */}
|
||||
<div className="space-y-5">
|
||||
|
||||
124
src/app/datadeletion/page.tsx
Normal file
124
src/app/datadeletion/page.tsx
Normal file
@@ -0,0 +1,124 @@
|
||||
import { LegalPageLayout, LegalSection } from "@/components/LegalPageLayout";
|
||||
import { legalContactEmail } from "@/lib/legal-contact";
|
||||
import { appTitle, siteUrl } from "@/lib/public-env";
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
|
||||
const LAST_UPDATED = "May 19, 2026";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Data Deletion",
|
||||
description: `How to request deletion of personal data from ${appTitle()}.`,
|
||||
alternates: { canonical: "/datadeletion" },
|
||||
robots: { index: true, follow: true },
|
||||
};
|
||||
|
||||
export default function DataDeletionPage() {
|
||||
const site = appTitle();
|
||||
const url = siteUrl();
|
||||
const email = legalContactEmail();
|
||||
|
||||
return (
|
||||
<LegalPageLayout
|
||||
title="Data Deletion Instructions"
|
||||
description={`This page explains how to request deletion of personal data associated with your use of ${site}.`}
|
||||
lastUpdated={LAST_UPDATED}
|
||||
>
|
||||
<LegalSection title="Overview">
|
||||
<p>
|
||||
We respect your right to control your personal information. You may request deletion of account-related data we hold,
|
||||
subject to exceptions described below. For broader information about how we handle data, see our{" "}
|
||||
<Link href="/privacy-policy" className="text-sky-300 hover:text-sky-200">
|
||||
Privacy Policy
|
||||
</Link>
|
||||
.
|
||||
</p>
|
||||
</LegalSection>
|
||||
|
||||
<LegalSection title="How to submit a deletion request">
|
||||
<p>Send an email to:</p>
|
||||
<p className="rounded-xl border border-white/10 bg-white/5 px-4 py-3 font-mono text-sky-200">
|
||||
<a href={`mailto:${email}?subject=Data%20deletion%20request`}>{email}</a>
|
||||
</p>
|
||||
<p>Include the following so we can locate your records:</p>
|
||||
<ul className="list-disc space-y-2 pl-5 text-slate-400">
|
||||
<li>Subject line: "Data deletion request"</li>
|
||||
<li>The email address associated with your account (if any)</li>
|
||||
<li>Your full name as shown on the account</li>
|
||||
<li>A brief description of what you want deleted (account, profile, activity history, etc.)</li>
|
||||
</ul>
|
||||
<p>
|
||||
If you signed in with a third-party provider, mention that provider and the email or identifier linked to your account.
|
||||
</p>
|
||||
</LegalSection>
|
||||
|
||||
<LegalSection title="What we delete">
|
||||
<p>When your request is verified and approved, we will delete or anonymize, where feasible:</p>
|
||||
<ul className="list-disc space-y-2 pl-5 text-slate-400">
|
||||
<li>Account profile information (name, email, preferences);</li>
|
||||
<li>Supporter wallet and in-platform activity tied to your user ID;</li>
|
||||
<li>Game play history and non-financial engagement logs linked to your account;</li>
|
||||
<li>Other personal data stored in our systems that is not subject to a retention exception.</li>
|
||||
</ul>
|
||||
</LegalSection>
|
||||
|
||||
<LegalSection title="What we may retain">
|
||||
<p>
|
||||
Some information may be retained when required or permitted by law, including for fraud prevention, security,
|
||||
dispute resolution, and compliance with legal or regulatory obligations (for example campaign finance record-keeping
|
||||
for processed contributions). Retained data is limited to what is necessary and protected appropriately.
|
||||
</p>
|
||||
<p>
|
||||
Payment processors may also retain transaction records under their own policies; contact them directly for
|
||||
processor-held data where applicable.
|
||||
</p>
|
||||
</LegalSection>
|
||||
|
||||
<LegalSection title="Processing time">
|
||||
<p>
|
||||
We aim to acknowledge requests within a reasonable period and complete verified deletion within approximately 30 days,
|
||||
unless a longer period is required by law or the complexity of the request. We will inform you if we need additional
|
||||
time or information.
|
||||
</p>
|
||||
</LegalSection>
|
||||
|
||||
<LegalSection title="Identity verification">
|
||||
<p>
|
||||
To protect your privacy, we may ask you to verify ownership of the account (for example by replying from the registered
|
||||
email address or providing information only the account holder would know). We will not fulfill deletion requests that
|
||||
we cannot reasonably verify.
|
||||
</p>
|
||||
</LegalSection>
|
||||
|
||||
<LegalSection title="Deleting your account yourself">
|
||||
<p>
|
||||
If the site offers in-app account closure, you may use that feature for immediate deactivation. You may still email us
|
||||
to confirm complete deletion of remaining personal data.
|
||||
</p>
|
||||
</LegalSection>
|
||||
|
||||
<LegalSection title="Third-party platforms">
|
||||
<p>
|
||||
If you connected this service through a third-party login or platform, you may also need to revoke access or delete
|
||||
data held by that third party through their own settings.
|
||||
</p>
|
||||
</LegalSection>
|
||||
|
||||
<LegalSection title="Questions">
|
||||
<p>
|
||||
For privacy questions or to appeal a decision regarding your request, contact{" "}
|
||||
<a href={`mailto:${email}`} className="text-sky-300 hover:text-sky-200">
|
||||
{email}
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
<p className="text-slate-500">
|
||||
{site} ·{" "}
|
||||
<a href={url} className="text-sky-300 hover:text-sky-200">
|
||||
{url}
|
||||
</a>
|
||||
</p>
|
||||
</LegalSection>
|
||||
</LegalPageLayout>
|
||||
);
|
||||
}
|
||||
165
src/app/privacy-policy/page.tsx
Normal file
165
src/app/privacy-policy/page.tsx
Normal file
@@ -0,0 +1,165 @@
|
||||
import { LegalPageLayout, LegalSection } from "@/components/LegalPageLayout";
|
||||
import { legalContactEmail } from "@/lib/legal-contact";
|
||||
import { appTitle, siteUrl } from "@/lib/public-env";
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
|
||||
const LAST_UPDATED = "May 19, 2026";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Privacy Policy",
|
||||
description: `How ${appTitle()} collects, uses, and protects personal information.`,
|
||||
alternates: { canonical: "/privacy-policy" },
|
||||
robots: { index: true, follow: true },
|
||||
};
|
||||
|
||||
export default function PrivacyPolicyPage() {
|
||||
const site = appTitle();
|
||||
const url = siteUrl();
|
||||
const email = legalContactEmail();
|
||||
|
||||
return (
|
||||
<LegalPageLayout
|
||||
title="Privacy Policy"
|
||||
description={`This policy describes how ${site} ("we", "us", or "our") handles information when you visit ${url} or use our services.`}
|
||||
lastUpdated={LAST_UPDATED}
|
||||
>
|
||||
<LegalSection title="1. Scope">
|
||||
<p>
|
||||
This Privacy Policy applies to visitors and registered users of our website and related online services. By using the
|
||||
site, you agree to the practices described here. If you do not agree, please discontinue use.
|
||||
</p>
|
||||
</LegalSection>
|
||||
|
||||
<LegalSection title="2. Information we collect">
|
||||
<p>Depending on how you interact with the site, we may collect:</p>
|
||||
<ul className="list-disc space-y-2 pl-5 text-slate-400">
|
||||
<li>
|
||||
<strong className="text-slate-200">Account information</strong> — such as name, email address, and credentials you
|
||||
provide when registering or signing in.
|
||||
</li>
|
||||
<li>
|
||||
<strong className="text-slate-200">Transaction information</strong> — such as donation amounts, payment status, and
|
||||
records required to process contributions through our payment processor.
|
||||
</li>
|
||||
<li>
|
||||
<strong className="text-slate-200">Usage information</strong> — such as pages viewed, features used, device type,
|
||||
browser, IP address, and approximate location derived from IP.
|
||||
</li>
|
||||
<li>
|
||||
<strong className="text-slate-200">Communications</strong> — such as messages you send to us for support or account
|
||||
assistance.
|
||||
</li>
|
||||
</ul>
|
||||
</LegalSection>
|
||||
|
||||
<LegalSection title="3. How we use information">
|
||||
<p>We use collected information to:</p>
|
||||
<ul className="list-disc space-y-2 pl-5 text-slate-400">
|
||||
<li>Operate, secure, and improve the website and its features;</li>
|
||||
<li>Process donations and maintain supporter accounts;</li>
|
||||
<li>Comply with legal, regulatory, and record-keeping obligations;</li>
|
||||
<li>Respond to inquiries and provide customer support;</li>
|
||||
<li>Detect fraud, abuse, and unauthorized access;</li>
|
||||
<li>Send service-related notices where permitted.</li>
|
||||
</ul>
|
||||
</LegalSection>
|
||||
|
||||
<LegalSection title="4. Legal bases (where applicable)">
|
||||
<p>
|
||||
Where privacy laws require a legal basis, we rely on one or more of: performance of a contract, legitimate interests
|
||||
(such as security and service improvement), compliance with legal obligations, and consent where required.
|
||||
</p>
|
||||
</LegalSection>
|
||||
|
||||
<LegalSection title="5. Sharing with service providers">
|
||||
<p>
|
||||
We use trusted third parties to help run the site — for example payment processing, hosting, analytics, and email
|
||||
delivery. These providers process data only on our instructions and are expected to protect it consistent with this
|
||||
policy and applicable law.
|
||||
</p>
|
||||
<p>We do not sell your personal information.</p>
|
||||
</LegalSection>
|
||||
|
||||
<LegalSection title="6. Cookies and similar technologies">
|
||||
<p>
|
||||
We may use cookies, local storage, and similar technologies for authentication, preferences, security, and basic
|
||||
analytics. You can control cookies through your browser settings; disabling some cookies may limit site functionality.
|
||||
</p>
|
||||
</LegalSection>
|
||||
|
||||
<LegalSection title="7. Data retention">
|
||||
<p>
|
||||
We retain personal information only as long as needed for the purposes described in this policy, including legal,
|
||||
accounting, and compliance requirements. Retention periods may vary by data type and obligation.
|
||||
</p>
|
||||
<p>
|
||||
To request deletion of personal data associated with your account, see our{" "}
|
||||
<Link href="/datadeletion" className="text-sky-300 hover:text-sky-200">
|
||||
Data Deletion
|
||||
</Link>{" "}
|
||||
page.
|
||||
</p>
|
||||
</LegalSection>
|
||||
|
||||
<LegalSection title="8. Your rights">
|
||||
<p>
|
||||
Depending on your location, you may have rights to access, correct, delete, restrict, or port your personal information,
|
||||
and to object to certain processing. You may also withdraw consent where processing is consent-based.
|
||||
</p>
|
||||
<p>
|
||||
To exercise these rights, contact us at{" "}
|
||||
<a href={`mailto:${email}`} className="text-sky-300 hover:text-sky-200">
|
||||
{email}
|
||||
</a>
|
||||
. We may need to verify your identity before fulfilling a request.
|
||||
</p>
|
||||
</LegalSection>
|
||||
|
||||
<LegalSection title="9. Security">
|
||||
<p>
|
||||
We implement reasonable administrative, technical, and organizational measures designed to protect personal
|
||||
information. No method of transmission or storage is completely secure; we cannot guarantee absolute security.
|
||||
</p>
|
||||
</LegalSection>
|
||||
|
||||
<LegalSection title="10. Children">
|
||||
<p>
|
||||
Our services are not directed to children under 13 (or the minimum age required in your jurisdiction). We do not
|
||||
knowingly collect personal information from children. If you believe we have done so, contact us and we will take
|
||||
appropriate steps.
|
||||
</p>
|
||||
</LegalSection>
|
||||
|
||||
<LegalSection title="11. International users">
|
||||
<p>
|
||||
If you access the site from outside the United States, your information may be processed in the United States or other
|
||||
locations where our service providers operate. Applicable laws in your region may provide additional rights.
|
||||
</p>
|
||||
</LegalSection>
|
||||
|
||||
<LegalSection title="12. Changes to this policy">
|
||||
<p>
|
||||
We may update this Privacy Policy from time to time. The "Last updated" date at the top reflects the latest
|
||||
revision. Material changes may be communicated through the site or other reasonable means.
|
||||
</p>
|
||||
</LegalSection>
|
||||
|
||||
<LegalSection title="13. Contact">
|
||||
<p>
|
||||
Questions about this policy or our privacy practices:{" "}
|
||||
<a href={`mailto:${email}`} className="text-sky-300 hover:text-sky-200">
|
||||
{email}
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
<p className="text-slate-500">
|
||||
Operator: {site} · Website:{" "}
|
||||
<a href={url} className="text-sky-300 hover:text-sky-200">
|
||||
{url}
|
||||
</a>
|
||||
</p>
|
||||
</LegalSection>
|
||||
</LegalPageLayout>
|
||||
);
|
||||
}
|
||||
@@ -36,6 +36,18 @@ export default function sitemap(): MetadataRoute.Sitemap {
|
||||
changeFrequency: "yearly",
|
||||
priority: 0.3,
|
||||
},
|
||||
{
|
||||
url: `${base}/privacy-policy`,
|
||||
lastModified: now,
|
||||
changeFrequency: "yearly",
|
||||
priority: 0.4,
|
||||
},
|
||||
{
|
||||
url: `${base}/datadeletion`,
|
||||
lastModified: now,
|
||||
changeFrequency: "yearly",
|
||||
priority: 0.4,
|
||||
},
|
||||
{
|
||||
url: `${base}/donate`,
|
||||
lastModified: now,
|
||||
|
||||
@@ -144,7 +144,7 @@ export default function SpotlightPage() {
|
||||
<div className="mt-10 rounded-3xl border border-white/10 bg-white/[0.03] p-6">
|
||||
<h2 className="text-lg font-semibold text-white">Place your bid</h2>
|
||||
{!session ? (
|
||||
<p className="mt-4 text-slate-400"><Link href="/login" className="text-sky-300 hover:underline">Sign in</Link> to bid.</p>
|
||||
<p className="mt-4 text-slate-400"><Link href="/login?callbackUrl=%2Fspotlight" className="text-sky-300 hover:underline">Sign in</Link> to bid.</p>
|
||||
) : (
|
||||
<>
|
||||
{catalog.length === 0 ? (
|
||||
|
||||
48
src/app/threads-callback/ThreadsCallbackClient.tsx
Normal file
48
src/app/threads-callback/ThreadsCallbackClient.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
|
||||
export function ThreadsCallbackClient() {
|
||||
const searchParams = useSearchParams();
|
||||
const [result, setResult] = useState("Processing...");
|
||||
|
||||
const code = useMemo(() => searchParams.get("code"), [searchParams]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!code) {
|
||||
setResult("Waiting for Threads authorization code...");
|
||||
return;
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
|
||||
async function exchangeCode() {
|
||||
try {
|
||||
setResult("Processing...");
|
||||
const response = await fetch("/api/threads-exchange", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ code }),
|
||||
signal: controller.signal,
|
||||
});
|
||||
const data = await response.json();
|
||||
setResult(JSON.stringify(data, null, 2));
|
||||
} catch (error) {
|
||||
if (controller.signal.aborted) return;
|
||||
const message = error instanceof Error ? error.message : "Unknown error";
|
||||
setResult(`Error: ${message}`);
|
||||
}
|
||||
}
|
||||
|
||||
exchangeCode();
|
||||
|
||||
return () => controller.abort();
|
||||
}, [code]);
|
||||
|
||||
return (
|
||||
<pre className="mt-6 overflow-x-auto rounded-2xl border border-white/10 bg-black/40 p-5 text-sm leading-relaxed text-slate-200">
|
||||
{result}
|
||||
</pre>
|
||||
);
|
||||
}
|
||||
26
src/app/threads-callback/page.tsx
Normal file
26
src/app/threads-callback/page.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import { Suspense } from "react";
|
||||
import type { Metadata } from "next";
|
||||
import { ThreadsCallbackClient } from "./ThreadsCallbackClient";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Threads Callback",
|
||||
robots: { index: false, follow: false },
|
||||
};
|
||||
|
||||
export default function ThreadsCallbackPage() {
|
||||
return (
|
||||
<main className="mx-auto max-w-3xl px-4 py-16 sm:px-6">
|
||||
<p className="text-xs uppercase tracking-[0.28em] text-sky-300/80">Threads OAuth</p>
|
||||
<h1 className="mt-3 text-3xl font-semibold text-white">Threads callback</h1>
|
||||
<Suspense
|
||||
fallback={
|
||||
<pre className="mt-6 overflow-x-auto rounded-2xl border border-white/10 bg-black/40 p-5 text-sm leading-relaxed text-slate-200">
|
||||
Processing...
|
||||
</pre>
|
||||
}
|
||||
>
|
||||
<ThreadsCallbackClient />
|
||||
</Suspense>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -74,6 +74,21 @@ export function WalletActions({
|
||||
setInfiniteCredits(!!data.infiniteCredits);
|
||||
}, []); // setters from useState are stable — no deps needed
|
||||
|
||||
// Keep this view in sync with games, donates, and other wallet UIs via the
|
||||
// shared `wallet:refresh` event bus + tab visibility.
|
||||
useEffect(() => {
|
||||
const onCustom = () => void refreshBalance();
|
||||
const onVisibility = () => {
|
||||
if (document.visibilityState === "visible") void refreshBalance();
|
||||
};
|
||||
window.addEventListener("wallet:refresh", onCustom);
|
||||
document.addEventListener("visibilitychange", onVisibility);
|
||||
return () => {
|
||||
window.removeEventListener("wallet:refresh", onCustom);
|
||||
document.removeEventListener("visibilitychange", onVisibility);
|
||||
};
|
||||
}, [refreshBalance]);
|
||||
|
||||
// Supporter credit spot index (USD per credit unit)
|
||||
useEffect(() => {
|
||||
if (infiniteCredits) return;
|
||||
|
||||
@@ -31,6 +31,14 @@ const SPEND_DESTINATIONS = [
|
||||
|
||||
const CHART_COLORS = ["#38bdf8", "#a78bfa", "#34d399", "#f472b6", "#fbbf24", "#fb7185", "#818cf8"];
|
||||
|
||||
function supporterTier(earned: number): { label: string; note: string } {
|
||||
if (earned >= 10_000) return { label: "Movement Whale", note: "Top-tier capital steering power" };
|
||||
if (earned >= 2_500) return { label: "Coalition Captain", note: "High-impact recurring backer" };
|
||||
if (earned >= 750) return { label: "Civic Builder", note: "Actively shaping priorities" };
|
||||
if (earned >= 100) return { label: "Verified Supporter", note: "Wallet is active and growing" };
|
||||
return { label: "Fresh Wallet", note: "First credits are the hardest" };
|
||||
}
|
||||
|
||||
function ImpactBar({ label, pct, credits, count, color }: { label: string; pct: number; credits: number; count: number; color: string }) {
|
||||
return (
|
||||
<div>
|
||||
@@ -101,6 +109,19 @@ export function WalletDashboard() {
|
||||
const spendTotal = summary.spent || 1;
|
||||
const initiativePct = summary.spent > 0 ? Math.round((summary.initiativePledges / spendTotal) * 100) : 0;
|
||||
const missionPct = summary.spent > 0 ? Math.round((summary.missionPledges / spendTotal) * 100) : 0;
|
||||
const tier = supporterTier(summary.earned);
|
||||
const utilizationPct = summary.earned > 0 ? Math.min(100, Math.round((summary.spent / summary.earned) * 100)) : 0;
|
||||
|
||||
const netFlowBars = useMemo(() => {
|
||||
const map = new Map<string, number>();
|
||||
for (const item of summary.recentActivity) {
|
||||
const day = item.at.slice(5, 10);
|
||||
map.set(day, (map.get(day) ?? 0) + item.delta);
|
||||
}
|
||||
const items = [...map.entries()].slice(-10).map(([day, delta]) => ({ day, delta }));
|
||||
const maxAbs = Math.max(1, ...items.map((i) => Math.abs(i.delta)));
|
||||
return { items, maxAbs };
|
||||
}, [summary.recentActivity]);
|
||||
|
||||
const sparkPath =
|
||||
historyPoints.length < 2
|
||||
@@ -122,6 +143,9 @@ export function WalletDashboard() {
|
||||
missionPct={missionPct}
|
||||
sparkPath={sparkPath}
|
||||
maxSpend={maxSpend}
|
||||
tier={tier}
|
||||
utilizationPct={utilizationPct}
|
||||
netFlowBars={netFlowBars}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -134,8 +158,11 @@ function WalletDashboardView(props: {
|
||||
missionPct: number;
|
||||
sparkPath: string;
|
||||
maxSpend: number;
|
||||
tier: { label: string; note: string };
|
||||
utilizationPct: number;
|
||||
netFlowBars: { items: { day: string; delta: number }[]; maxAbs: number };
|
||||
}) {
|
||||
const { summary, t, indexUsd, initiativePct, missionPct, sparkPath, maxSpend } = props;
|
||||
const { summary, t, indexUsd, initiativePct, missionPct, sparkPath, maxSpend, tier, utilizationPct, netFlowBars } = props;
|
||||
|
||||
return (
|
||||
<div className="mb-10 space-y-8">
|
||||
@@ -192,6 +219,55 @@ function WalletDashboardView(props: {
|
||||
</motion.div>
|
||||
|
||||
<div className="grid gap-6 lg:grid-cols-2">
|
||||
<div className="rounded-2xl border border-violet-400/25 bg-violet-950/20 p-5">
|
||||
<p className="text-sm font-medium text-white">Token profile</p>
|
||||
<p className="mt-3 text-2xl font-semibold text-violet-200">{tier.label}</p>
|
||||
<p className="mt-1 text-sm text-slate-400">{tier.note}</p>
|
||||
<div className="mt-4 grid grid-cols-2 gap-3">
|
||||
<div className="rounded-xl border border-white/10 bg-black/20 p-3">
|
||||
<p className="text-xs uppercase tracking-wide text-slate-500">Total earned</p>
|
||||
<p className="mt-1 font-mono text-lg text-emerald-300">+{summary.earned.toLocaleString()} {t}</p>
|
||||
</div>
|
||||
<div className="rounded-xl border border-white/10 bg-black/20 p-3">
|
||||
<p className="text-xs uppercase tracking-wide text-slate-500">Utilization</p>
|
||||
<p className="mt-1 font-mono text-lg text-sky-300">{utilizationPct}%</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 h-2 overflow-hidden rounded-full bg-white/10">
|
||||
<div className="h-full rounded-full bg-gradient-to-r from-sky-400 via-violet-400 to-fuchsia-400" style={{ width: `${utilizationPct}%` }} />
|
||||
</div>
|
||||
<p className="mt-2 text-xs text-slate-500">How much of your earned {t} is already deployed into movement actions.</p>
|
||||
</div>
|
||||
|
||||
<div className="rounded-2xl border border-white/10 bg-white/[0.03] p-5">
|
||||
<p className="text-sm font-medium text-white">Personal net flow (recent)</p>
|
||||
{netFlowBars.items.length === 0 ? (
|
||||
<p className="mt-4 text-sm text-slate-500">No flow yet — your first transactions will render here.</p>
|
||||
) : (
|
||||
<div className="mt-4 space-y-2">
|
||||
{netFlowBars.items.map((bar) => {
|
||||
const width = Math.round((Math.abs(bar.delta) / netFlowBars.maxAbs) * 100);
|
||||
const positive = bar.delta >= 0;
|
||||
return (
|
||||
<div key={bar.day} className="grid grid-cols-[52px_1fr_90px] items-center gap-3 text-xs">
|
||||
<span className="font-mono text-slate-500">{bar.day}</span>
|
||||
<div className="h-2.5 overflow-hidden rounded-full bg-white/10">
|
||||
<div
|
||||
className={`h-full rounded-full ${positive ? "bg-emerald-400" : "bg-rose-400"}`}
|
||||
style={{ width: `${Math.max(6, width)}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className={`text-right font-mono ${positive ? "text-emerald-300" : "text-rose-300"}`}>
|
||||
{positive ? "+" : ""}
|
||||
{bar.delta.toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="rounded-2xl border border-white/10 bg-white/[0.03] p-5">
|
||||
<p className="text-sm font-medium text-white">Where you've spent {t}</p>
|
||||
{summary.spendBreakdown.length === 0 ? (
|
||||
|
||||
@@ -9,6 +9,7 @@ import { Suspense } from "react";
|
||||
import { WalletActions } from "./WalletActions";
|
||||
import { WalletDashboard } from "./WalletDashboard";
|
||||
import { WalletGuestView } from "./WalletGuestView";
|
||||
import { ActiveGamesBanner } from "@/components/casino/ActiveGamesBanner";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: `Supporter wallet — ${appTitle()}`,
|
||||
@@ -44,6 +45,7 @@ export default async function WalletPage() {
|
||||
<div className="relative overflow-hidden">
|
||||
<div className="pointer-events-none absolute inset-0 bg-[radial-gradient(ellipse_70%_45%_at_50%_0%,rgba(139,92,246,0.12),transparent_55%)]" />
|
||||
<div className="relative mx-auto max-w-5xl px-4 py-10 sm:px-6 sm:py-12">
|
||||
<ActiveGamesBanner userId={userId} className="mb-8" />
|
||||
<WalletDashboard />
|
||||
<div id="wallet-perks" className="scroll-mt-28">
|
||||
<p className="mb-6 text-xs uppercase tracking-[0.28em] text-slate-400">Perks & ledger</p>
|
||||
|
||||
@@ -53,6 +53,7 @@ export const { handlers, auth, signIn, signOut } = NextAuth({
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
name: user.name ?? undefined,
|
||||
username: user.username,
|
||||
role: user.role,
|
||||
};
|
||||
},
|
||||
@@ -62,14 +63,18 @@ export const { handlers, auth, signIn, signOut } = NextAuth({
|
||||
jwt({ token, user }) {
|
||||
if (user) {
|
||||
token.id = user.id;
|
||||
token.role = (user as { role: string }).role;
|
||||
if (user.username) token.username = user.username;
|
||||
const role = (user as { role?: string }).role;
|
||||
token.role = role === "ADMIN" || role === "USER" ? role : "USER";
|
||||
}
|
||||
return token;
|
||||
},
|
||||
session({ session, token }) {
|
||||
if (session.user) {
|
||||
session.user.id = (token.id as string) ?? (token.sub as string);
|
||||
session.user.role = (token.role as "USER" | "ADMIN") ?? "USER";
|
||||
if (typeof token.username === "string") session.user.username = token.username;
|
||||
const role = token.role;
|
||||
session.user.role = role === "ADMIN" || role === "USER" ? role : "USER";
|
||||
}
|
||||
return session;
|
||||
},
|
||||
|
||||
64
src/components/LegalPageLayout.tsx
Normal file
64
src/components/LegalPageLayout.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
import Link from "next/link";
|
||||
import { SiteFooter } from "@/components/SiteFooter";
|
||||
import { appTitle } from "@/lib/public-env";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
type LegalPageLayoutProps = {
|
||||
title: string;
|
||||
description: string;
|
||||
lastUpdated: string;
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
export function LegalPageLayout({ title, description, lastUpdated, children }: LegalPageLayoutProps) {
|
||||
const site = appTitle();
|
||||
|
||||
return (
|
||||
<>
|
||||
<article className="mx-auto max-w-3xl px-4 py-16 sm:px-6 sm:py-20">
|
||||
<p className="text-xs uppercase tracking-[0.28em] text-sky-300/80">Legal</p>
|
||||
<h1 className="mt-3 text-3xl font-semibold text-white sm:text-4xl">{title}</h1>
|
||||
<p className="mt-3 text-sm text-slate-500">Last updated: {lastUpdated}</p>
|
||||
<p className="mt-4 text-slate-400">{description}</p>
|
||||
|
||||
<div className="mt-10 space-y-10 text-sm leading-relaxed text-slate-300">{children}</div>
|
||||
|
||||
<nav
|
||||
className="mt-12 flex flex-wrap gap-4 border-t border-white/10 pt-8 text-sm"
|
||||
aria-label="Related legal pages"
|
||||
>
|
||||
<Link href="/privacy-policy" className="text-sky-300 hover:text-sky-200">
|
||||
Privacy Policy
|
||||
</Link>
|
||||
<span className="text-slate-600" aria-hidden>
|
||||
·
|
||||
</span>
|
||||
<Link href="/datadeletion" className="text-sky-300 hover:text-sky-200">
|
||||
Data Deletion
|
||||
</Link>
|
||||
<span className="text-slate-600" aria-hidden>
|
||||
·
|
||||
</span>
|
||||
<Link href="/" className="text-slate-400 hover:text-slate-200">
|
||||
{site} home
|
||||
</Link>
|
||||
</nav>
|
||||
|
||||
<p className="mt-8 rounded-2xl border border-amber-500/20 bg-amber-950/20 px-4 py-3 text-xs leading-relaxed text-amber-100/80">
|
||||
This document is provided for general informational purposes only and does not constitute legal advice. Replace
|
||||
placeholders and have qualified counsel review before production use.
|
||||
</p>
|
||||
</article>
|
||||
<SiteFooter />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function LegalSection({ title, children }: { title: string; children: ReactNode }) {
|
||||
return (
|
||||
<section>
|
||||
<h2 className="text-lg font-semibold text-white">{title}</h2>
|
||||
<div className="mt-3 space-y-3">{children}</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
95
src/components/NavWalletBalance.tsx
Normal file
95
src/components/NavWalletBalance.tsx
Normal file
@@ -0,0 +1,95 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { creditTicker } from "@/lib/credits-brand";
|
||||
|
||||
const T = creditTicker();
|
||||
|
||||
interface Props {
|
||||
initialBalance: number;
|
||||
initialInfinite?: boolean;
|
||||
/** Optional className lets the nav pick tighter spacing on mobile. */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Live wallet pill mounted in the top nav.
|
||||
*
|
||||
* Refresh strategy:
|
||||
* - 15 s polling (cheap; same cadence as the per-game `useLiveWalletBalance`)
|
||||
* - Cross-tab + cross-component instant sync via the `wallet:refresh` custom
|
||||
* event on `window`. Any client code can call
|
||||
* `window.dispatchEvent(new Event("wallet:refresh"))` to force a re-fetch
|
||||
* (the wallet hook does this automatically after every game action).
|
||||
* - Tab visibility — refetch on tab focus so balance is fresh.
|
||||
*/
|
||||
export function NavWalletBalance({ initialBalance, initialInfinite = false, className = "" }: Props) {
|
||||
const [balance, setBalance] = useState<number>(initialBalance);
|
||||
const [infinite, setInfinite] = useState<boolean>(initialInfinite);
|
||||
const [flash, setFlash] = useState<"up" | "down" | null>(null);
|
||||
const lastBalanceRef = useRef<number>(initialBalance);
|
||||
|
||||
const fetchBalance = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch("/api/wallet", { cache: "no-store" });
|
||||
if (!res.ok) return;
|
||||
const data = await res.json();
|
||||
const next = typeof data.balanceCredits === "number" ? data.balanceCredits : 0;
|
||||
const isInf = !!data.infiniteCredits;
|
||||
setInfinite(isInf);
|
||||
if (!isInf) {
|
||||
setBalance((prev) => {
|
||||
if (next !== prev) {
|
||||
const dir = next > lastBalanceRef.current ? "up" : "down";
|
||||
setFlash(dir);
|
||||
window.setTimeout(() => setFlash(null), 700);
|
||||
lastBalanceRef.current = next;
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
/* ignore — keep last known */
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void fetchBalance();
|
||||
const id = window.setInterval(() => void fetchBalance(), 15_000);
|
||||
const onCustom = () => void fetchBalance();
|
||||
const onVisibility = () => { if (document.visibilityState === "visible") void fetchBalance(); };
|
||||
window.addEventListener("wallet:refresh", onCustom);
|
||||
document.addEventListener("visibilitychange", onVisibility);
|
||||
return () => {
|
||||
window.clearInterval(id);
|
||||
window.removeEventListener("wallet:refresh", onCustom);
|
||||
document.removeEventListener("visibilitychange", onVisibility);
|
||||
};
|
||||
}, [fetchBalance]);
|
||||
|
||||
const display = infinite
|
||||
? "∞"
|
||||
: balance.toLocaleString(undefined, { maximumFractionDigits: 0 });
|
||||
|
||||
const flashClass =
|
||||
flash === "up"
|
||||
? "ring-1 ring-emerald-400/60 bg-emerald-500/15 text-emerald-100"
|
||||
: flash === "down"
|
||||
? "ring-1 ring-rose-400/60 bg-rose-500/15 text-rose-100"
|
||||
: "border border-white/10 bg-white/5 text-sky-100";
|
||||
|
||||
return (
|
||||
<Link
|
||||
href="/wallet"
|
||||
title={`Wallet — ${display} ${T}`}
|
||||
className={`group inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-xs font-semibold tabular-nums transition-colors ${flashClass} ${className}`}
|
||||
>
|
||||
<span aria-hidden className="text-[10px] leading-none text-sky-300/80 group-hover:text-sky-200">
|
||||
◆
|
||||
</span>
|
||||
<span className="leading-none">{display}</span>
|
||||
<span className="leading-none text-[10px] text-sky-300/70">{T}</span>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
@@ -98,6 +98,16 @@ export function SiteFooter() {
|
||||
FAQ
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link href="/privacy-policy" className="hover:text-sky-300">
|
||||
Privacy Policy
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link href="/datadeletion" className="hover:text-sky-300">
|
||||
Data Deletion
|
||||
</Link>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import Link from "next/link";
|
||||
import { auth } from "@/auth";
|
||||
import { appTitle } from "@/lib/public-env";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { ADMIN_WALLET_DISPLAY, isAdminRole } from "@/lib/admin";
|
||||
import { MobileNav } from "./MobileNav";
|
||||
import { NavWalletBalance } from "./NavWalletBalance";
|
||||
|
||||
const primaryLinks = [
|
||||
{ href: "/raised", label: "Raised" },
|
||||
@@ -15,6 +18,24 @@ export async function SiteNav() {
|
||||
const session = await auth();
|
||||
const title = appTitle();
|
||||
|
||||
// Fetch username + balance in a single query for signed-in users so the nav
|
||||
// can render the live wallet pill with a correct server-side initial value.
|
||||
let username: string | null = null;
|
||||
let initialBalance = 0;
|
||||
let initialInfinite = false;
|
||||
if (session?.user?.id) {
|
||||
const [dbUser, wallet] = await Promise.all([
|
||||
prisma.user.findUnique({
|
||||
where: { id: session.user.id },
|
||||
select: { username: true, role: true },
|
||||
}),
|
||||
prisma.wallet.findUnique({ where: { userId: session.user.id } }),
|
||||
]);
|
||||
username = session.user.username ?? dbUser?.username ?? null;
|
||||
initialInfinite = isAdminRole(dbUser?.role ?? session.user.role);
|
||||
initialBalance = initialInfinite ? ADMIN_WALLET_DISPLAY : wallet?.balanceCredits ?? 0;
|
||||
}
|
||||
|
||||
return (
|
||||
<header
|
||||
id="site-header"
|
||||
@@ -30,6 +51,22 @@ export async function SiteNav() {
|
||||
{title}
|
||||
</span>
|
||||
</Link>
|
||||
{username ? (
|
||||
<Link
|
||||
href="/wallet"
|
||||
className="hidden max-w-[9rem] truncate rounded-full border border-white/10 bg-white/5 px-2.5 py-1 text-xs font-semibold text-slate-200 hover:bg-white/10 sm:inline-flex"
|
||||
title={`Signed in as @${username}`}
|
||||
>
|
||||
@{username}
|
||||
</Link>
|
||||
) : null}
|
||||
{session?.user?.id ? (
|
||||
<NavWalletBalance
|
||||
initialBalance={initialBalance}
|
||||
initialInfinite={initialInfinite}
|
||||
className="hidden sm:inline-flex"
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<div className="min-w-2 flex-1" aria-hidden />
|
||||
|
||||
@@ -73,12 +110,16 @@ export async function SiteNav() {
|
||||
</nav>
|
||||
|
||||
<div className="flex shrink-0 items-center gap-2 md:hidden">
|
||||
<Link
|
||||
href="/donate"
|
||||
className="rounded-full bg-gradient-to-r from-sky-500 to-indigo-500 px-3 py-1.5 text-sm font-semibold text-white shadow-md shadow-sky-500/20"
|
||||
>
|
||||
Donate
|
||||
</Link>
|
||||
{session?.user?.id ? (
|
||||
<NavWalletBalance initialBalance={initialBalance} initialInfinite={initialInfinite} />
|
||||
) : (
|
||||
<Link
|
||||
href="/donate"
|
||||
className="rounded-full bg-gradient-to-r from-sky-500 to-indigo-500 px-3 py-1.5 text-sm font-semibold text-white shadow-md shadow-sky-500/20"
|
||||
>
|
||||
Donate
|
||||
</Link>
|
||||
)}
|
||||
<MobileNav loggedIn={!!session?.user} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
168
src/components/casino/ActiveGamesBanner.tsx
Normal file
168
src/components/casino/ActiveGamesBanner.tsx
Normal file
@@ -0,0 +1,168 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { creditTicker } from "@/lib/credits-brand";
|
||||
|
||||
const T = creditTicker();
|
||||
|
||||
interface ActiveSession {
|
||||
id: string;
|
||||
gameType: string;
|
||||
wageredBLW: number;
|
||||
}
|
||||
|
||||
interface ActiveRoom {
|
||||
id: string;
|
||||
gameType: string;
|
||||
wageBLW: number;
|
||||
status: "WAITING" | "ACTIVE";
|
||||
creatorId: string;
|
||||
joinerId: string | null;
|
||||
}
|
||||
|
||||
const GAME_META: Record<string, { icon: string; slug: string; label: string }> = {
|
||||
CRASH: { icon: "📈", slug: "crash", label: "Crash" },
|
||||
DICE: { icon: "🎲", slug: "dice", label: "Dice" },
|
||||
MINES: { icon: "💣", slug: "mines", label: "Mines" },
|
||||
TOWER: { icon: "🏰", slug: "tower", label: "Tower" },
|
||||
SLOTS: { icon: "🎰", slug: "slots", label: "Slots" },
|
||||
BLACKJACK: { icon: "🃏", slug: "blackjack", label: "Blackjack" },
|
||||
ROULETTE: { icon: "🎡", slug: "roulette", label: "Roulette" },
|
||||
COIN_FLIP: { icon: "🪙", slug: "coinflip", label: "Coin Flip" },
|
||||
PONG: { icon: "🏓", slug: "pong", label: "Pong" },
|
||||
PREDICTION: { icon: "📊", slug: "prediction", label: "Prediction" },
|
||||
};
|
||||
|
||||
interface Props {
|
||||
/** Current user id — for distinguishing creator vs joiner rooms. */
|
||||
userId?: string;
|
||||
/** Optional className to position within parent layout. */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function ActiveGamesBanner({ userId, className = "" }: Props) {
|
||||
const [sessions, setSessions] = useState<ActiveSession[]>([]);
|
||||
const [rooms, setRooms] = useState<ActiveRoom[]>([]);
|
||||
const [busy, setBusy] = useState<string | null>(null);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
try {
|
||||
const r = await fetch("/api/games/active", { cache: "no-store" });
|
||||
if (!r.ok) return;
|
||||
const d = await r.json();
|
||||
setSessions(Array.isArray(d.sessions) ? d.sessions : []);
|
||||
setRooms(Array.isArray(d.rooms) ? d.rooms : []);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void refresh();
|
||||
const id = setInterval(() => void refresh(), 20_000);
|
||||
return () => clearInterval(id);
|
||||
}, [refresh]);
|
||||
|
||||
const total = sessions.length + rooms.length;
|
||||
if (total === 0) return null;
|
||||
|
||||
async function cancelRoom(roomId: string) {
|
||||
setBusy(roomId);
|
||||
try {
|
||||
const res = await fetch(`/api/games/rooms?id=${encodeURIComponent(roomId)}`, { method: "DELETE" });
|
||||
if (res.ok) await refresh();
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`rounded-2xl border border-amber-400/30 bg-gradient-to-r from-amber-500/10 via-orange-500/10 to-rose-500/10 p-4 ${className}`}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-[0.22em] text-amber-200/80">Unfinished games</p>
|
||||
<p className="mt-1 text-sm font-medium text-amber-50">
|
||||
You have {total} active {total === 1 ? "round" : "rounds"} — resume to keep your wager.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ul className="mt-4 space-y-2">
|
||||
{sessions.map((s) => {
|
||||
const meta = GAME_META[s.gameType] ?? { icon: "🎮", slug: s.gameType.toLowerCase(), label: s.gameType };
|
||||
return (
|
||||
<li
|
||||
key={s.id}
|
||||
className="flex items-center justify-between gap-3 rounded-xl border border-white/10 bg-black/30 px-3 py-2"
|
||||
>
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<span className="text-2xl" aria-hidden>
|
||||
{meta.icon}
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-sm font-medium text-white">{meta.label} round in progress</p>
|
||||
<p className="text-xs text-slate-400">
|
||||
Wager locked: <span className="font-mono">{s.wageredBLW.toLocaleString()}</span> {T}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Link
|
||||
href={`/casino/${meta.slug}`}
|
||||
className="shrink-0 rounded-lg bg-gradient-to-r from-amber-500 to-orange-500 px-3 py-1.5 text-xs font-semibold text-white hover:opacity-90"
|
||||
>
|
||||
Resume →
|
||||
</Link>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
|
||||
{rooms.map((r) => {
|
||||
const meta = GAME_META[r.gameType] ?? { icon: "🎮", slug: r.gameType.toLowerCase(), label: r.gameType };
|
||||
const isCreator = userId ? r.creatorId === userId : true;
|
||||
const canCancel = isCreator && r.status === "WAITING";
|
||||
return (
|
||||
<li
|
||||
key={r.id}
|
||||
className="flex items-center justify-between gap-3 rounded-xl border border-white/10 bg-black/30 px-3 py-2"
|
||||
>
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<span className="text-2xl" aria-hidden>
|
||||
{meta.icon}
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-sm font-medium text-white">
|
||||
{meta.label} {r.status === "WAITING" ? "room — waiting" : "room — in play"}
|
||||
</p>
|
||||
<p className="text-xs text-slate-400">
|
||||
Locked: <span className="font-mono">{r.wageBLW.toLocaleString()}</span> {T}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex shrink-0 gap-2">
|
||||
<Link
|
||||
href={`/casino/${meta.slug}`}
|
||||
className="rounded-lg bg-gradient-to-r from-sky-500 to-indigo-500 px-3 py-1.5 text-xs font-semibold text-white hover:opacity-90"
|
||||
>
|
||||
{r.status === "WAITING" ? "Resume" : "Rejoin"}
|
||||
</Link>
|
||||
{canCancel ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void cancelRoom(r.id)}
|
||||
disabled={busy === r.id}
|
||||
className="rounded-lg border border-white/15 px-3 py-1.5 text-xs font-medium text-slate-200 hover:bg-white/10 disabled:opacity-50"
|
||||
>
|
||||
{busy === r.id ? "…" : "Cancel & refund"}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
import { creditTicker } from "@/lib/credits-brand";
|
||||
import { useState } from "react";
|
||||
import { useLiveWalletBalance } from "./useLiveWalletBalance";
|
||||
|
||||
const T = creditTicker();
|
||||
|
||||
@@ -37,7 +38,8 @@ function Hand({ cards, label }: { cards: string[]; label: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
export function BlackjackGame({ balance }: { balance: number }) {
|
||||
export function BlackjackGame({ balance: initialBalance }: { balance: number }) {
|
||||
const { balance, refreshBalance } = useLiveWalletBalance(initialBalance);
|
||||
const [wager, setWager] = useState(10);
|
||||
const [clientSeed, setClientSeed] = useState("my-seed");
|
||||
const [phase, setPhase] = useState<"idle" | "playing" | "done">("idle");
|
||||
@@ -47,8 +49,10 @@ export function BlackjackGame({ balance }: { balance: number }) {
|
||||
const [dealerFull, setDealerFull] = useState<string[]>([]);
|
||||
const [result, setResult] = useState<{ outcome: string; payout?: number; playerVal?: number; dealerVal?: number } | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [errorMsg, setErrorMsg] = useState<string | null>(null);
|
||||
|
||||
async function startGame() {
|
||||
setErrorMsg(null);
|
||||
setLoading(true);
|
||||
const res = await fetch("/api/games/blackjack", {
|
||||
method: "POST",
|
||||
@@ -56,12 +60,17 @@ export function BlackjackGame({ balance }: { balance: number }) {
|
||||
body: JSON.stringify({ wageBLW: wager, clientSeed }),
|
||||
});
|
||||
setLoading(false);
|
||||
if (!res.ok) { const e = await res.json(); alert(e.error); return; }
|
||||
if (!res.ok) {
|
||||
const e = await res.json();
|
||||
setErrorMsg(e.error ?? "Could not deal");
|
||||
return;
|
||||
}
|
||||
const data = await res.json();
|
||||
setRoundId(data.roundId);
|
||||
setPlayerHand(data.playerHand);
|
||||
setDealerVisible(data.dealerVisible ?? []);
|
||||
setDealerFull([]);
|
||||
if (data.dealerHand) setDealerFull(data.dealerHand);
|
||||
else setDealerFull([]);
|
||||
setResult(null);
|
||||
if (data.outcome !== "active") {
|
||||
setResult(data);
|
||||
@@ -69,9 +78,11 @@ export function BlackjackGame({ balance }: { balance: number }) {
|
||||
} else {
|
||||
setPhase("playing");
|
||||
}
|
||||
void refreshBalance();
|
||||
}
|
||||
|
||||
async function action(act: "hit" | "stand" | "double") {
|
||||
setErrorMsg(null);
|
||||
setLoading(true);
|
||||
const res = await fetch("/api/games/blackjack", {
|
||||
method: "PATCH",
|
||||
@@ -79,13 +90,18 @@ export function BlackjackGame({ balance }: { balance: number }) {
|
||||
body: JSON.stringify({ roundId, action: act }),
|
||||
});
|
||||
setLoading(false);
|
||||
if (!res.ok) return;
|
||||
if (!res.ok) {
|
||||
const e = await res.json().catch(() => ({ error: "Action failed" }));
|
||||
setErrorMsg(e.error ?? "Action failed");
|
||||
return;
|
||||
}
|
||||
const data = await res.json();
|
||||
if (data.playerHand) setPlayerHand(data.playerHand);
|
||||
if (data.dealerHand) setDealerFull(data.dealerHand);
|
||||
if (data.outcome !== "active") {
|
||||
setResult(data);
|
||||
setPhase("done");
|
||||
void refreshBalance();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -102,6 +118,9 @@ export function BlackjackGame({ balance }: { balance: number }) {
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
{errorMsg ? (
|
||||
<div className="rounded-xl border border-rose-500/40 bg-rose-500/10 p-3 text-sm text-rose-200">{errorMsg}</div>
|
||||
) : null}
|
||||
{result && (
|
||||
<div className={`rounded-xl border p-4 text-center font-bold ${outcomeColor}`}>
|
||||
{outcomeLabel}
|
||||
|
||||
@@ -2,36 +2,66 @@
|
||||
import { creditTicker } from "@/lib/credits-brand";
|
||||
import { useState, useEffect } from "react";
|
||||
import { io, Socket } from "socket.io-client";
|
||||
import { useLiveWalletBalance } from "./useLiveWalletBalance";
|
||||
|
||||
const T = creditTicker();
|
||||
|
||||
interface Room { id: string; wageBLW: number; creatorId: string; }
|
||||
|
||||
export function CoinFlipRoom({ userId, balance }: { userId: string; balance: number }) {
|
||||
export function CoinFlipRoom({ userId, balance: initialBalance }: { userId: string; balance: number }) {
|
||||
const { balance, refreshBalance } = useLiveWalletBalance(initialBalance);
|
||||
const [rooms, setRooms] = useState<Room[]>([]);
|
||||
const [wager, setWager] = useState(50);
|
||||
const [phase, setPhase] = useState<"lobby" | "waiting" | "result">("lobby");
|
||||
const [socket, setSocket] = useState<Socket | null>(null);
|
||||
const [result, setResult] = useState<{ resultLabel: string; winnerId: string; payout: number; serverSeed: string } | null>(null);
|
||||
const [myRoomId, setMyRoomId] = useState<string | null>(null);
|
||||
const [errorMsg, setErrorMsg] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetchRooms();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
socket?.disconnect();
|
||||
};
|
||||
}, [socket]);
|
||||
|
||||
async function fetchRooms() {
|
||||
const r = await fetch("/api/games/rooms?gameType=COIN_FLIP");
|
||||
if (r.ok) { const d = await r.json(); setRooms(d.rooms); }
|
||||
}
|
||||
|
||||
function connect(roomId: string) {
|
||||
setErrorMsg(null);
|
||||
const s = io("/coin-flip", { path: "/api/socket" });
|
||||
setSocket(s);
|
||||
s.emit("join_room", { roomId, userId });
|
||||
s.emit("join_room", { roomId });
|
||||
s.on("waiting", () => setPhase("waiting"));
|
||||
s.on("game_start", () => setPhase("waiting"));
|
||||
s.on("result", (data) => { setResult(data); setPhase("result"); s.disconnect(); });
|
||||
s.on("error", (msg: string) => { alert(msg); s.disconnect(); setPhase("lobby"); });
|
||||
s.on("result", (data) => { setResult(data); setPhase("result"); void refreshBalance(); s.disconnect(); });
|
||||
s.on("error", (msg: string) => {
|
||||
setErrorMsg(msg);
|
||||
void refreshBalance();
|
||||
s.disconnect();
|
||||
setPhase("lobby");
|
||||
});
|
||||
}
|
||||
|
||||
async function cancelRoom() {
|
||||
if (!myRoomId) return;
|
||||
const res = await fetch(`/api/games/rooms?id=${encodeURIComponent(myRoomId)}`, { method: "DELETE" });
|
||||
socket?.disconnect();
|
||||
if (res.ok) {
|
||||
setMyRoomId(null);
|
||||
setPhase("lobby");
|
||||
void refreshBalance();
|
||||
fetchRooms();
|
||||
} else {
|
||||
const e = await res.json().catch(() => ({ error: "Could not cancel" }));
|
||||
setErrorMsg(e.error ?? "Could not cancel");
|
||||
}
|
||||
}
|
||||
|
||||
async function createRoom() {
|
||||
@@ -40,7 +70,11 @@ export function CoinFlipRoom({ userId, balance }: { userId: string; balance: num
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ gameType: "COIN_FLIP", wageBLW: wager }),
|
||||
});
|
||||
if (!res.ok) { const e = await res.json(); alert(e.error); return; }
|
||||
if (!res.ok) {
|
||||
const e = await res.json();
|
||||
setErrorMsg(e.error ?? "Could not create room");
|
||||
return;
|
||||
}
|
||||
const { room } = await res.json();
|
||||
setMyRoomId(room.id);
|
||||
connect(room.id);
|
||||
@@ -54,6 +88,14 @@ export function CoinFlipRoom({ userId, balance }: { userId: string; balance: num
|
||||
<p className="text-white font-semibold">Waiting for opponent…</p>
|
||||
<p className="text-slate-400 text-sm">Room ID: <span className="font-mono text-sky-300">{myRoomId}</span></p>
|
||||
<p className="text-slate-500 text-xs">Share this with a friend to flip instantly!</p>
|
||||
{myRoomId ? (
|
||||
<button
|
||||
onClick={cancelRoom}
|
||||
className="rounded-xl border border-white/15 bg-white/5 px-5 py-2 text-sm font-semibold text-slate-200 hover:bg-white/10"
|
||||
>
|
||||
Cancel & Refund
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -78,6 +120,11 @@ export function CoinFlipRoom({ userId, balance }: { userId: string; balance: num
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
{errorMsg ? (
|
||||
<div className="rounded-xl border border-rose-500/40 bg-rose-500/10 p-3 text-sm text-rose-200">
|
||||
{errorMsg}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="rounded-xl border border-white/10 bg-white/5 p-4 space-y-3">
|
||||
<p className="text-sm font-semibold text-white">Create a Room</p>
|
||||
<div className="flex gap-3">
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
"use client";
|
||||
import { creditTicker } from "@/lib/credits-brand";
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { useLiveWalletBalance } from "./useLiveWalletBalance";
|
||||
|
||||
const T = creditTicker();
|
||||
|
||||
export function CrashGame({ balance }: { balance: number }) {
|
||||
export function CrashGame({ balance: initialBalance }: { balance: number }) {
|
||||
const { balance, refreshBalance } = useLiveWalletBalance(initialBalance);
|
||||
const [wager, setWager] = useState(10);
|
||||
const [clientSeed, setClientSeed] = useState("my-seed");
|
||||
const [phase, setPhase] = useState<"idle" | "running" | "done">("idle");
|
||||
@@ -12,65 +14,102 @@ export function CrashGame({ balance }: { balance: number }) {
|
||||
const [multiplier, setMultiplier] = useState(1.0);
|
||||
const [crashAt, setCrashAt] = useState<number | null>(null);
|
||||
const [result, setResult] = useState<{ outcome: string; multiplier?: number; payout?: number; serverSeed?: string } | null>(null);
|
||||
const [errorMsg, setErrorMsg] = useState<string | null>(null);
|
||||
const rafRef = useRef<number | null>(null);
|
||||
const startRef = useRef<number>(0);
|
||||
const multiplierRef = useRef<number>(1);
|
||||
|
||||
function startAnimation(crash: number) {
|
||||
function startAnimation() {
|
||||
startRef.current = Date.now();
|
||||
function tick() {
|
||||
const elapsed = (Date.now() - startRef.current) / 1000;
|
||||
const current = Math.pow(Math.E, 0.2 * elapsed);
|
||||
setMultiplier(parseFloat(current.toFixed(2)));
|
||||
if (current < crash) {
|
||||
const rounded = parseFloat(current.toFixed(2));
|
||||
multiplierRef.current = rounded;
|
||||
setMultiplier(rounded);
|
||||
if (current < 100) {
|
||||
rafRef.current = requestAnimationFrame(tick);
|
||||
} else {
|
||||
setMultiplier(crash);
|
||||
multiplierRef.current = 100;
|
||||
setMultiplier(100);
|
||||
}
|
||||
}
|
||||
rafRef.current = requestAnimationFrame(tick);
|
||||
}
|
||||
|
||||
async function startRound() {
|
||||
setErrorMsg(null);
|
||||
const res = await fetch("/api/games/crash", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ wageBLW: wager, clientSeed }),
|
||||
});
|
||||
if (!res.ok) { const e = await res.json(); alert(e.error); return; }
|
||||
if (!res.ok) {
|
||||
const e = await res.json();
|
||||
setErrorMsg(e.error ?? "Could not start round");
|
||||
return;
|
||||
}
|
||||
const data = await res.json();
|
||||
void refreshBalance();
|
||||
setRoundId(data.roundId);
|
||||
setCrashAt(data.crashAt);
|
||||
setCrashAt(null);
|
||||
multiplierRef.current = 1;
|
||||
setMultiplier(1.0);
|
||||
setPhase("running");
|
||||
setResult(null);
|
||||
startAnimation(data.crashAt);
|
||||
startAnimation();
|
||||
}
|
||||
|
||||
async function cashOut() {
|
||||
if (!roundId || phase !== "running") return;
|
||||
setErrorMsg(null);
|
||||
if (rafRef.current) cancelAnimationFrame(rafRef.current);
|
||||
const res = await fetch("/api/games/crash", {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ roundId, cashoutAt: multiplier }),
|
||||
body: JSON.stringify({ roundId, action: "cashout", cashoutAt: multiplierRef.current }),
|
||||
});
|
||||
if (!res.ok) return;
|
||||
if (!res.ok) {
|
||||
const e = await res.json();
|
||||
setErrorMsg(e.error ?? "Cashout failed");
|
||||
setPhase("done");
|
||||
return;
|
||||
}
|
||||
const data = await res.json();
|
||||
void refreshBalance();
|
||||
if (typeof data.crashAt === "number") setCrashAt(data.crashAt);
|
||||
setResult(data);
|
||||
setPhase("done");
|
||||
}
|
||||
|
||||
// Auto-crash detection
|
||||
// Crash probe loop (server never leaks crash point before round ends).
|
||||
useEffect(() => {
|
||||
if (phase === "running" && crashAt !== null && multiplier >= crashAt) {
|
||||
if (rafRef.current) cancelAnimationFrame(rafRef.current);
|
||||
// Auto-resolve as loss if player didn't cash out
|
||||
fetch("/api/games/crash", {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ roundId, cashoutAt: crashAt + 1 }),
|
||||
}).then(r => r.json()).then(data => { setResult(data); setPhase("done"); });
|
||||
}
|
||||
}, [multiplier, crashAt, phase, roundId]);
|
||||
if (phase !== "running" || !roundId) return;
|
||||
const id = setInterval(async () => {
|
||||
try {
|
||||
const res = await fetch("/api/games/crash", {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ roundId, action: "tick", currentMultiplier: multiplierRef.current }),
|
||||
});
|
||||
if (!res.ok) return;
|
||||
const data = await res.json();
|
||||
if (data.outcome === "loss") {
|
||||
if (rafRef.current) cancelAnimationFrame(rafRef.current);
|
||||
if (typeof data.crashAt === "number") {
|
||||
setCrashAt(data.crashAt);
|
||||
setMultiplier(data.crashAt);
|
||||
}
|
||||
setResult(data);
|
||||
setPhase("done");
|
||||
void refreshBalance();
|
||||
}
|
||||
} catch {
|
||||
// keep UI running; transient probe errors should not break gameplay
|
||||
}
|
||||
}, 220);
|
||||
return () => clearInterval(id);
|
||||
}, [phase, roundId]);
|
||||
|
||||
useEffect(() => () => { if (rafRef.current) cancelAnimationFrame(rafRef.current); }, []);
|
||||
|
||||
@@ -79,6 +118,11 @@ export function CrashGame({ balance }: { balance: number }) {
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{errorMsg ? (
|
||||
<div className="rounded-xl border border-rose-500/40 bg-rose-500/10 p-3 text-sm text-rose-200">
|
||||
{errorMsg}
|
||||
</div>
|
||||
) : null}
|
||||
<div className={`relative rounded-2xl border flex flex-col items-center justify-center h-56 overflow-hidden transition-colors ${
|
||||
crashed ? "border-red-500/50 bg-red-900/20" : won ? "border-green-500/50 bg-green-900/20" : "border-white/10 bg-white/5"
|
||||
}`}>
|
||||
|
||||
@@ -1,34 +1,47 @@
|
||||
"use client";
|
||||
import { creditTicker } from "@/lib/credits-brand";
|
||||
import { useState } from "react";
|
||||
import { useLiveWalletBalance } from "./useLiveWalletBalance";
|
||||
|
||||
const T = creditTicker();
|
||||
|
||||
export function DiceGame({ balance }: { balance: number }) {
|
||||
export function DiceGame({ balance: initialBalance }: { balance: number }) {
|
||||
const { balance, refreshBalance } = useLiveWalletBalance(initialBalance);
|
||||
const [wager, setWager] = useState(10);
|
||||
const [threshold, setThreshold] = useState(50);
|
||||
const [direction, setDirection] = useState<"over" | "under">("over");
|
||||
const [clientSeed, setClientSeed] = useState("my-seed");
|
||||
const [result, setResult] = useState<{ roll: number; won: boolean; payout: number; multiplier: number; serverSeed: string } | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [errorMsg, setErrorMsg] = useState<string | null>(null);
|
||||
|
||||
const winProb = direction === "over" ? (99 - threshold) / 100 : threshold / 100;
|
||||
const multiplier = (0.98 / winProb).toFixed(4);
|
||||
|
||||
async function roll() {
|
||||
setErrorMsg(null);
|
||||
setLoading(true);
|
||||
setResult(null); // clear previous outcome so the result card animates in fresh
|
||||
const res = await fetch("/api/games/dice", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ wageBLW: wager, clientSeed, threshold, direction }),
|
||||
});
|
||||
setLoading(false);
|
||||
if (!res.ok) { const e = await res.json(); alert(e.error); return; }
|
||||
if (!res.ok) {
|
||||
const e = await res.json();
|
||||
setErrorMsg(e.error ?? "Could not roll");
|
||||
return;
|
||||
}
|
||||
setResult(await res.json());
|
||||
void refreshBalance();
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{errorMsg ? (
|
||||
<div className="rounded-xl border border-rose-500/40 bg-rose-500/10 p-3 text-sm text-rose-200">{errorMsg}</div>
|
||||
) : null}
|
||||
{result && (
|
||||
<div className={`rounded-2xl border p-6 text-center ${result.won ? "border-green-500/50 bg-green-900/20" : "border-red-500/50 bg-red-900/20"}`}>
|
||||
<p className="text-xs uppercase tracking-widest text-slate-400 mb-2">Roll</p>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { creditTicker } from "@/lib/credits-brand";
|
||||
import { useEffect, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useLiveWalletBalance } from "./useLiveWalletBalance";
|
||||
|
||||
const T = creditTicker();
|
||||
|
||||
@@ -16,57 +17,67 @@ interface Props {
|
||||
}
|
||||
|
||||
export function ExchangePanel({ initialBalance }: Props) {
|
||||
const [balance, setBalance] = useState(initialBalance);
|
||||
// Single source of truth for balance — same hook the games use, so the panel
|
||||
// and the active game always show the same number.
|
||||
const { balance } = useLiveWalletBalance(initialBalance);
|
||||
const [rate, setRate] = useState<RateData | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
async function fetchRate() {
|
||||
const r = await fetch("/api/exchange/rate");
|
||||
if (r.ok) setRate(await r.json());
|
||||
}
|
||||
async function fetchBalance() {
|
||||
const r = await fetch("/api/wallet");
|
||||
if (r.ok) {
|
||||
const d = await r.json();
|
||||
setBalance(d.balanceCredits ?? d.balance ?? 0);
|
||||
let alive = true;
|
||||
const fetchRate = async () => {
|
||||
try {
|
||||
const r = await fetch("/api/exchange/rate", { cache: "no-store" });
|
||||
if (r.ok && alive) setRate(await r.json());
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
fetchRate();
|
||||
fetchBalance();
|
||||
const iv = setInterval(() => { fetchRate(); fetchBalance(); }, 15_000);
|
||||
return () => clearInterval(iv);
|
||||
};
|
||||
void fetchRate();
|
||||
const iv = setInterval(() => void fetchRate(), 15_000);
|
||||
return () => {
|
||||
alive = false;
|
||||
clearInterval(iv);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const usdValue = rate ? (balance * rate.blwUsd).toFixed(2) : "—";
|
||||
|
||||
// Mini sparkline
|
||||
const spark = rate?.sparkline ?? [];
|
||||
const sparkMin = Math.min(...spark);
|
||||
const sparkMax = Math.max(...spark);
|
||||
const sparkMin = spark.length ? Math.min(...spark) : 0;
|
||||
const sparkMax = spark.length ? Math.max(...spark) : 0;
|
||||
const sparkRange = sparkMax - sparkMin || 1;
|
||||
|
||||
return (
|
||||
<div className="rounded-2xl border border-white/10 bg-white/5 p-5 backdrop-blur-sm">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div>
|
||||
<div className="rounded-2xl border border-white/10 bg-gradient-to-br from-white/[0.07] via-white/[0.03] to-transparent p-5 backdrop-blur-sm">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<p className="text-xs uppercase tracking-widest text-slate-400">Balance</p>
|
||||
<p className="text-3xl font-bold text-white tabular-nums">
|
||||
{balance.toLocaleString()} <span className="text-sky-400 text-lg">{rate?.symbol ?? T}</span>
|
||||
<p className="text-3xl font-bold text-white tabular-nums leading-tight">
|
||||
{balance.toLocaleString()}{" "}
|
||||
<span className="text-sky-400 text-lg">{rate?.symbol ?? T}</span>
|
||||
</p>
|
||||
<p className="text-sm text-slate-400 mt-0.5">≈ ${usdValue} USD</p>
|
||||
</div>
|
||||
<Link
|
||||
href="/donate"
|
||||
className="rounded-xl bg-gradient-to-r from-sky-500 to-indigo-500 px-4 py-2 text-sm font-semibold text-white shadow-lg shadow-sky-500/20 hover:opacity-90 transition-opacity"
|
||||
>
|
||||
Earn {T}
|
||||
</Link>
|
||||
<div className="flex shrink-0 flex-col items-stretch gap-1.5">
|
||||
<Link
|
||||
href="/donate"
|
||||
className="rounded-xl bg-gradient-to-r from-sky-500 to-indigo-500 px-4 py-2 text-center text-sm font-semibold text-white shadow-lg shadow-sky-500/20 hover:opacity-90 transition-opacity"
|
||||
>
|
||||
Earn {T}
|
||||
</Link>
|
||||
<Link
|
||||
href="/wallet"
|
||||
className="rounded-xl border border-white/15 px-4 py-1.5 text-center text-xs font-medium text-slate-200 hover:bg-white/5"
|
||||
>
|
||||
Wallet →
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{spark.length > 1 && (
|
||||
<div className="mt-3">
|
||||
<div className="mt-4">
|
||||
<p className="text-xs text-slate-500 mb-1">{T}/USD — 48h</p>
|
||||
<svg viewBox={`0 0 ${spark.length} 30`} className="w-full h-8" preserveAspectRatio="none">
|
||||
<svg viewBox={`0 0 ${spark.length} 30`} className="w-full h-8" preserveAspectRatio="none" aria-hidden>
|
||||
<polyline
|
||||
fill="none"
|
||||
stroke="url(#sg)"
|
||||
|
||||
@@ -22,14 +22,21 @@ const GAME_ICONS: Record<string, string> = {
|
||||
export function GameHistory() {
|
||||
const [sessions, setSessions] = useState<GameSession[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [errorMsg, setErrorMsg] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetch("/api/games/history?limit=15")
|
||||
.then(r => r.json())
|
||||
.then(d => { setSessions(d.sessions ?? []); setLoading(false); });
|
||||
.then(async (r) => {
|
||||
if (!r.ok) throw new Error("Could not load game history");
|
||||
return r.json();
|
||||
})
|
||||
.then(d => { setSessions(d.sessions ?? []); setErrorMsg(null); })
|
||||
.catch((e) => setErrorMsg(e instanceof Error ? e.message : "Could not load game history"))
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
if (loading) return <div className="animate-pulse h-32 rounded-xl bg-white/5" />;
|
||||
if (errorMsg) return <p className="text-center text-sm text-rose-300 py-6">{errorMsg}</p>;
|
||||
if (sessions.length === 0) return <p className="text-center text-slate-500 py-6">No games played yet.</p>;
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,12 +1,25 @@
|
||||
"use client";
|
||||
import { creditTicker } from "@/lib/credits-brand";
|
||||
import { useState } from "react";
|
||||
import { useState, useEffect } from "react";
|
||||
import { useLiveWalletBalance } from "./useLiveWalletBalance";
|
||||
|
||||
const T = creditTicker();
|
||||
|
||||
type TileState = "hidden" | "safe" | "mine";
|
||||
|
||||
export function MinesGame({ balance }: { balance: number }) {
|
||||
const GRID = 25;
|
||||
|
||||
function calcMultiplier(revealed: number, mines: number): number {
|
||||
let mult = 1.0;
|
||||
const safe = GRID - mines;
|
||||
for (let i = 0; i < revealed; i++) {
|
||||
mult *= ((safe - i) / (GRID - i)) * 0.99;
|
||||
}
|
||||
return parseFloat((1 / mult).toFixed(4));
|
||||
}
|
||||
|
||||
export function MinesGame({ balance: initialBalance }: { balance: number }) {
|
||||
const { balance, refreshBalance } = useLiveWalletBalance(initialBalance);
|
||||
const [wager, setWager] = useState(10);
|
||||
const [mineCount, setMineCount] = useState(5);
|
||||
const [clientSeed, setClientSeed] = useState("my-seed");
|
||||
@@ -15,20 +28,56 @@ export function MinesGame({ balance }: { balance: number }) {
|
||||
const [tiles, setTiles] = useState<TileState[]>(Array(25).fill("hidden"));
|
||||
const [multiplier, setMultiplier] = useState(1.0);
|
||||
const [result, setResult] = useState<{ outcome: string; payout?: number; minePositions?: number[] } | null>(null);
|
||||
const [errorMsg, setErrorMsg] = useState<string | null>(null);
|
||||
|
||||
// Resume an in-progress round after refresh/reconnect.
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
try {
|
||||
const r = await fetch("/api/games/active?gameType=MINES");
|
||||
if (!r.ok) return;
|
||||
const d = await r.json();
|
||||
if (cancelled || !d.session) return;
|
||||
const rd = d.session.resultData ?? {};
|
||||
const revealed: number[] = Array.isArray(rd.revealed) ? rd.revealed : [];
|
||||
const mines: number = typeof rd.mineCount === "number" ? rd.mineCount : 0;
|
||||
const tilesNext = Array<TileState>(GRID).fill("hidden");
|
||||
// We don't know which revealed tiles were mines — but a still-active
|
||||
// round can only contain safe reveals, so all are "safe".
|
||||
revealed.forEach((idx) => { if (idx >= 0 && idx < GRID) tilesNext[idx] = "safe"; });
|
||||
setRoundId(d.session.id);
|
||||
setTiles(tilesNext);
|
||||
setMineCount(mines || 5);
|
||||
setWager(d.session.wageredBLW);
|
||||
setMultiplier(calcMultiplier(revealed.length, mines));
|
||||
setPhase("playing");
|
||||
} catch {
|
||||
/* ignore — fall back to idle */
|
||||
}
|
||||
})();
|
||||
return () => { cancelled = true; };
|
||||
}, []);
|
||||
|
||||
async function startGame() {
|
||||
setErrorMsg(null);
|
||||
const res = await fetch("/api/games/mines", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ wageBLW: wager, clientSeed, mineCount }),
|
||||
});
|
||||
if (!res.ok) { const e = await res.json(); alert(e.error); return; }
|
||||
if (!res.ok) {
|
||||
const e = await res.json();
|
||||
setErrorMsg(e.error ?? "Could not start game");
|
||||
return;
|
||||
}
|
||||
const data = await res.json();
|
||||
setRoundId(data.roundId);
|
||||
setTiles(Array(25).fill("hidden"));
|
||||
setMultiplier(1.0);
|
||||
setResult(null);
|
||||
setPhase("playing");
|
||||
void refreshBalance();
|
||||
}
|
||||
|
||||
async function revealTile(idx: number) {
|
||||
@@ -38,7 +87,11 @@ export function MinesGame({ balance }: { balance: number }) {
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ roundId, action: "reveal", tile: idx }),
|
||||
});
|
||||
if (!res.ok) return;
|
||||
if (!res.ok) {
|
||||
const e = await res.json().catch(() => ({ error: "Move rejected" }));
|
||||
setErrorMsg(e.error ?? "Move rejected");
|
||||
return;
|
||||
}
|
||||
const data = await res.json();
|
||||
|
||||
const newTiles = [...tiles];
|
||||
@@ -50,11 +103,13 @@ export function MinesGame({ balance }: { balance: number }) {
|
||||
setTiles(newTiles);
|
||||
setResult(data);
|
||||
setPhase("done");
|
||||
void refreshBalance();
|
||||
} else if (data.outcome === "win" || data.outcome === "cashout") {
|
||||
setTiles(newTiles);
|
||||
setMultiplier(data.multiplier);
|
||||
setResult(data);
|
||||
setPhase("done");
|
||||
void refreshBalance();
|
||||
} else {
|
||||
setTiles(newTiles);
|
||||
setMultiplier(data.multiplier);
|
||||
@@ -63,12 +118,17 @@ export function MinesGame({ balance }: { balance: number }) {
|
||||
|
||||
async function cashOut() {
|
||||
if (phase !== "playing") return;
|
||||
setErrorMsg(null);
|
||||
const res = await fetch("/api/games/mines", {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ roundId, action: "cashout" }),
|
||||
});
|
||||
if (!res.ok) return;
|
||||
if (!res.ok) {
|
||||
const e = await res.json().catch(() => ({ error: "Cashout failed" }));
|
||||
setErrorMsg(e.error ?? "Cashout failed");
|
||||
return;
|
||||
}
|
||||
const data = await res.json();
|
||||
if (data.minePositions) {
|
||||
const newTiles = [...tiles];
|
||||
@@ -77,13 +137,21 @@ export function MinesGame({ balance }: { balance: number }) {
|
||||
}
|
||||
setResult(data);
|
||||
setPhase("done");
|
||||
void refreshBalance();
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
{errorMsg ? (
|
||||
<div className="rounded-xl border border-rose-500/40 bg-rose-500/10 p-3 text-sm text-rose-200">{errorMsg}</div>
|
||||
) : null}
|
||||
{result && phase === "done" && (
|
||||
<div className={`rounded-xl border p-4 text-center ${result.outcome === "loss" ? "border-red-500/50 bg-red-900/20 text-red-300" : "border-green-500/50 bg-green-900/20 text-green-300"}`}>
|
||||
{result.outcome === "loss" ? "💥 Hit a mine!" : `💰 +${result.payout} ${T} at ${multiplier.toFixed(2)}x`}
|
||||
{result.outcome === "loss"
|
||||
? "💥 Hit a mine!"
|
||||
: result.outcome === "push"
|
||||
? `↩ Refunded ${result.payout} ${T}`
|
||||
: `💰 +${result.payout} ${T} at ${multiplier.toFixed(2)}x`}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -2,12 +2,14 @@
|
||||
import { creditTicker } from "@/lib/credits-brand";
|
||||
import { useState, useEffect, useRef, useCallback } from "react";
|
||||
import { io, Socket } from "socket.io-client";
|
||||
import { useLiveWalletBalance } from "./useLiveWalletBalance";
|
||||
|
||||
const T = creditTicker();
|
||||
|
||||
interface Room { id: string; wageBLW: number; creatorId: string; }
|
||||
|
||||
export function PongGame({ userId, balance }: { userId: string; balance: number }) {
|
||||
export function PongGame({ userId, balance: initialBalance }: { userId: string; balance: number }) {
|
||||
const { balance, refreshBalance } = useLiveWalletBalance(initialBalance);
|
||||
const [rooms, setRooms] = useState<Room[]>([]);
|
||||
const [wager, setWager] = useState(100);
|
||||
const [phase, setPhase] = useState<"lobby" | "waiting" | "playing" | "done">("lobby");
|
||||
@@ -16,7 +18,7 @@ export function PongGame({ userId, balance }: { userId: string; balance: number
|
||||
const [scores, setScores] = useState({ creator: 0, joiner: 0 });
|
||||
const [winner, setWinner] = useState<{ winnerId: string; payout: number } | null>(null);
|
||||
const [role, setRole] = useState<"creator" | "joiner" | null>(null);
|
||||
const [creatorId, setCreatorId] = useState<string | null>(null);
|
||||
const [errorMsg, setErrorMsg] = useState<string | null>(null);
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const gameState = useRef({ ball: { x: 400, y: 200 }, paddles: { creator: 180, joiner: 180 } });
|
||||
|
||||
@@ -25,6 +27,12 @@ export function PongGame({ userId, balance }: { userId: string; balance: number
|
||||
|
||||
useEffect(() => { fetchRooms(); }, []);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
socket?.disconnect();
|
||||
};
|
||||
}, [socket]);
|
||||
|
||||
async function fetchRooms() {
|
||||
const r = await fetch("/api/games/rooms?gameType=PONG");
|
||||
if (r.ok) { const d = await r.json(); setRooms(d.rooms); }
|
||||
@@ -57,15 +65,16 @@ export function PongGame({ userId, balance }: { userId: string; balance: number
|
||||
ctx.fillRect(CANVAS_W - 20, paddles.joiner, 12, 80);
|
||||
}
|
||||
|
||||
function connectSocket(roomId: string, r: "creator" | "joiner") {
|
||||
function connectSocket(roomId: string) {
|
||||
setErrorMsg(null);
|
||||
const s = io("/pong", { path: "/api/socket" });
|
||||
setSocket(s);
|
||||
s.emit("join_room", { roomId, userId });
|
||||
s.emit("join_room", { roomId });
|
||||
|
||||
s.on("waiting", () => setPhase("waiting"));
|
||||
s.on("game_start", ({ creatorId: cid }: { creatorId: string; joinerId: string }) => {
|
||||
setCreatorId(cid);
|
||||
s.on("game_start", ({ creatorId: _cid }: { creatorId: string; joinerId: string }) => {
|
||||
setPhase("playing");
|
||||
void refreshBalance();
|
||||
});
|
||||
s.on("tick", ({ ball, paddles, scores: sc }: { ball: { x: number; y: number }; paddles: { creator: number; joiner: number }; scores: { creator: number; joiner: number } }) => {
|
||||
gameState.current = { ball, paddles };
|
||||
@@ -75,20 +84,36 @@ export function PongGame({ userId, balance }: { userId: string; balance: number
|
||||
s.on("game_over", (data: { winnerId: string; payout: number }) => {
|
||||
setWinner(data);
|
||||
setPhase("done");
|
||||
void refreshBalance();
|
||||
s.disconnect();
|
||||
});
|
||||
s.on("error", (msg: string) => { alert(msg); s.disconnect(); setPhase("lobby"); });
|
||||
s.on("error", (msg: string) => {
|
||||
setErrorMsg(msg);
|
||||
void refreshBalance();
|
||||
s.disconnect();
|
||||
setPhase("lobby");
|
||||
});
|
||||
}
|
||||
|
||||
// Mouse/touch paddle control
|
||||
const handleMouseMove = useCallback((e: React.MouseEvent<HTMLCanvasElement>) => {
|
||||
const emitPaddleMove = useCallback((clientY: number) => {
|
||||
if (phase !== "playing" || !socket || !myRoomId) return;
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const y = ((e.clientY - rect.top) / rect.height) * CANVAS_H - 40;
|
||||
socket.emit("paddle_move", { roomId: myRoomId, userId, y });
|
||||
}, [phase, socket, myRoomId, userId]);
|
||||
const y = ((clientY - rect.top) / rect.height) * CANVAS_H - 40;
|
||||
socket.emit("paddle_move", { roomId: myRoomId, y });
|
||||
}, [phase, socket, myRoomId]);
|
||||
|
||||
const handleMouseMove = useCallback((e: React.MouseEvent<HTMLCanvasElement>) => {
|
||||
emitPaddleMove(e.clientY);
|
||||
}, [emitPaddleMove]);
|
||||
|
||||
const handleTouchMove = useCallback((e: React.TouchEvent<HTMLCanvasElement>) => {
|
||||
e.preventDefault();
|
||||
const touch = e.touches[0];
|
||||
if (touch) emitPaddleMove(touch.clientY);
|
||||
}, [emitPaddleMove]);
|
||||
|
||||
async function createRoom() {
|
||||
const res = await fetch("/api/games/rooms", {
|
||||
@@ -96,18 +121,39 @@ export function PongGame({ userId, balance }: { userId: string; balance: number
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ gameType: "PONG", wageBLW: wager }),
|
||||
});
|
||||
if (!res.ok) { const e = await res.json(); alert(e.error); return; }
|
||||
if (!res.ok) {
|
||||
const e = await res.json();
|
||||
setErrorMsg(e.error ?? "Could not create room");
|
||||
return;
|
||||
}
|
||||
const { room } = await res.json();
|
||||
setMyRoomId(room.id);
|
||||
setRole("creator");
|
||||
connectSocket(room.id, "creator");
|
||||
connectSocket(room.id);
|
||||
setPhase("waiting");
|
||||
void refreshBalance();
|
||||
}
|
||||
|
||||
async function cancelRoom() {
|
||||
if (!myRoomId) return;
|
||||
const res = await fetch(`/api/games/rooms?id=${encodeURIComponent(myRoomId)}`, { method: "DELETE" });
|
||||
socket?.disconnect();
|
||||
if (res.ok) {
|
||||
setMyRoomId(null);
|
||||
setRole(null);
|
||||
setPhase("lobby");
|
||||
void refreshBalance();
|
||||
fetchRooms();
|
||||
} else {
|
||||
const e = await res.json().catch(() => ({ error: "Could not cancel" }));
|
||||
setErrorMsg(e.error ?? "Could not cancel");
|
||||
}
|
||||
}
|
||||
|
||||
function joinRoom(room: Room) {
|
||||
setMyRoomId(room.id);
|
||||
setRole("joiner");
|
||||
connectSocket(room.id, "joiner");
|
||||
connectSocket(room.id);
|
||||
}
|
||||
|
||||
if (phase === "waiting") {
|
||||
@@ -116,6 +162,14 @@ export function PongGame({ userId, balance }: { userId: string; balance: number
|
||||
<div className="text-5xl animate-bounce">🏓</div>
|
||||
<p className="text-white font-semibold">Waiting for opponent…</p>
|
||||
{myRoomId && <p className="text-slate-400 text-sm font-mono">Room: {myRoomId.slice(0, 12)}…</p>}
|
||||
{role === "creator" && myRoomId ? (
|
||||
<button
|
||||
onClick={cancelRoom}
|
||||
className="rounded-xl border border-white/15 bg-white/5 px-5 py-2 text-sm font-semibold text-slate-200 hover:bg-white/10"
|
||||
>
|
||||
Cancel & Refund
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -137,8 +191,8 @@ export function PongGame({ userId, balance }: { userId: string; balance: number
|
||||
</div>
|
||||
</div>
|
||||
<div className="relative rounded-xl overflow-hidden border border-white/10">
|
||||
<canvas ref={canvasRef} width={CANVAS_W} height={CANVAS_H} onMouseMove={handleMouseMove}
|
||||
className="w-full cursor-none" style={{ aspectRatio: `${CANVAS_W}/${CANVAS_H}` }} />
|
||||
<canvas ref={canvasRef} width={CANVAS_W} height={CANVAS_H} onMouseMove={handleMouseMove} onTouchMove={handleTouchMove}
|
||||
className="w-full cursor-none touch-none" style={{ aspectRatio: `${CANVAS_W}/${CANVAS_H}` }} />
|
||||
{phase === "done" && winner && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-black/70 rounded-xl">
|
||||
<div className={`text-center p-8 rounded-2xl border ${won ? "border-green-500/50 bg-green-900/30" : "border-red-500/50 bg-red-900/30"}`}>
|
||||
@@ -152,13 +206,18 @@ export function PongGame({ userId, balance }: { userId: string; balance: number
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-center text-slate-500">Move your mouse over the canvas to control your paddle</p>
|
||||
<p className="text-xs text-center text-slate-500">Move your mouse or drag on the canvas to control your paddle</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
{errorMsg ? (
|
||||
<div className="rounded-xl border border-rose-500/40 bg-rose-500/10 p-3 text-sm text-rose-200">
|
||||
{errorMsg}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="rounded-xl border border-white/10 bg-white/5 p-4 space-y-3">
|
||||
<p className="text-sm font-semibold text-white">Create a Room</p>
|
||||
<div className="flex gap-3">
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
import { creditTicker } from "@/lib/credits-brand";
|
||||
import { useState, useEffect } from "react";
|
||||
import { useLiveWalletBalance } from "./useLiveWalletBalance";
|
||||
|
||||
const T = creditTicker();
|
||||
|
||||
@@ -15,19 +16,20 @@ interface Market {
|
||||
_count?: { bets: number };
|
||||
}
|
||||
|
||||
export function PredictionMarket({ userId, balance }: { userId: string; balance: number }) {
|
||||
export function PredictionMarket({ userId, balance: initialBalance }: { userId: string; balance: number }) {
|
||||
const { balance, refreshBalance } = useLiveWalletBalance(initialBalance);
|
||||
const [markets, setMarkets] = useState<Market[]>([]);
|
||||
const [tab, setTab] = useState<"open" | "create">("open");
|
||||
const [tab, setTab] = useState<"open" | "resolved" | "create">("open");
|
||||
const [question, setQuestion] = useState("");
|
||||
const [endsAt, setEndsAt] = useState("");
|
||||
const [betAmounts, setBetAmounts] = useState<Record<string, number>>({});
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [msg, setMsg] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => { fetchMarkets(); }, []);
|
||||
useEffect(() => { fetchMarkets(); }, [tab]);
|
||||
|
||||
async function fetchMarkets() {
|
||||
const r = await fetch("/api/games/prediction");
|
||||
const r = await fetch(`/api/games/prediction${tab === "resolved" ? "?resolved=1" : ""}`);
|
||||
if (r.ok) { const d = await r.json(); setMarkets(d.markets); }
|
||||
}
|
||||
|
||||
@@ -57,6 +59,7 @@ export function PredictionMarket({ userId, balance }: { userId: string; balance:
|
||||
setLoading(false);
|
||||
if (!res.ok) { const e = await res.json(); setMsg(e.error); return; }
|
||||
setMsg(`Bet placed on ${side ? "YES" : "NO"}!`);
|
||||
void refreshBalance();
|
||||
fetchMarkets();
|
||||
}
|
||||
|
||||
@@ -68,10 +71,12 @@ export function PredictionMarket({ userId, balance }: { userId: string; balance:
|
||||
});
|
||||
if (!res.ok) { const e = await res.json(); setMsg(e.error); return; }
|
||||
setMsg("Market resolved!");
|
||||
void refreshBalance();
|
||||
fetchMarkets();
|
||||
}
|
||||
|
||||
const minDate = new Date(Date.now() + 60_000).toISOString().slice(0, 16);
|
||||
const visibleMarkets = tab === "resolved" ? markets.filter((m) => m.resolvedTo !== null) : markets;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
@@ -82,10 +87,10 @@ export function PredictionMarket({ userId, balance }: { userId: string; balance:
|
||||
)}
|
||||
|
||||
<div className="flex gap-2">
|
||||
{(["open", "create"] as const).map(t => (
|
||||
{(["open", "resolved", "create"] as const).map(t => (
|
||||
<button key={t} onClick={() => setTab(t)}
|
||||
className={`rounded-lg px-4 py-2 text-sm font-semibold transition-colors capitalize ${tab === t ? "bg-sky-600 text-white" : "border border-white/10 text-slate-400 hover:bg-white/5"}`}>
|
||||
{t === "open" ? "📊 Open Markets" : "➕ Create Market"}
|
||||
{t === "open" ? "📊 Open Markets" : t === "resolved" ? "✅ Resolved" : "➕ Create Market"}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
@@ -111,10 +116,10 @@ export function PredictionMarket({ userId, balance }: { userId: string; balance:
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tab === "open" && (
|
||||
{(tab === "open" || tab === "resolved") && (
|
||||
<div className="space-y-3">
|
||||
{markets.length === 0 && <p className="text-center text-slate-500 py-8">No open markets — create one!</p>}
|
||||
{markets.map(m => {
|
||||
{visibleMarkets.length === 0 && <p className="text-center text-slate-500 py-8">{tab === "resolved" ? "No resolved markets yet." : "No open markets — create one!"}</p>}
|
||||
{visibleMarkets.map(m => {
|
||||
const total = m.totalYes + m.totalNo;
|
||||
const yesPercent = total > 0 ? Math.round((m.totalYes / total) * 100) : 50;
|
||||
const isExpired = new Date(m.endsAt) < new Date();
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
import { creditTicker } from "@/lib/credits-brand";
|
||||
import { useState } from "react";
|
||||
import { useLiveWalletBalance } from "./useLiveWalletBalance";
|
||||
|
||||
const T = creditTicker();
|
||||
|
||||
@@ -21,7 +22,8 @@ const BET_TYPES = [
|
||||
|
||||
const RED_NUMS = new Set([1,3,5,7,9,12,14,16,18,19,21,23,25,27,30,32,34,36]);
|
||||
|
||||
export function RouletteGame({ balance }: { balance: number }) {
|
||||
export function RouletteGame({ balance: initialBalance }: { balance: number }) {
|
||||
const { balance, refreshBalance } = useLiveWalletBalance(initialBalance);
|
||||
const [wager, setWager] = useState(10);
|
||||
const [selectedBet, setSelectedBet] = useState(BET_TYPES[0]);
|
||||
const [straightNum, setStraightNum] = useState(7);
|
||||
@@ -29,8 +31,10 @@ export function RouletteGame({ balance }: { balance: number }) {
|
||||
const [result, setResult] = useState<{ result: number; color: string; won: boolean; payout: number; multiplier: number; serverSeed: string } | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [ballPos, setBallPos] = useState<number | null>(null);
|
||||
const [errorMsg, setErrorMsg] = useState<string | null>(null);
|
||||
|
||||
async function spin() {
|
||||
setErrorMsg(null);
|
||||
setLoading(true);
|
||||
setResult(null);
|
||||
setBallPos(null);
|
||||
@@ -45,16 +49,24 @@ export function RouletteGame({ balance }: { balance: number }) {
|
||||
body: JSON.stringify({ wageBLW: wager, clientSeed, betType, betValue }),
|
||||
});
|
||||
setLoading(false);
|
||||
if (!res.ok) { const e = await res.json(); alert(e.error); return; }
|
||||
if (!res.ok) {
|
||||
const e = await res.json();
|
||||
setErrorMsg(e.error ?? "Spin failed");
|
||||
return;
|
||||
}
|
||||
const data = await res.json();
|
||||
setBallPos(data.result);
|
||||
setResult(data);
|
||||
void refreshBalance();
|
||||
}
|
||||
|
||||
const numColor = (n: number) => n === 0 ? "bg-green-700" : RED_NUMS.has(n) ? "bg-red-700" : "bg-slate-800";
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
{errorMsg ? (
|
||||
<div className="rounded-xl border border-rose-500/40 bg-rose-500/10 p-3 text-sm text-rose-200">{errorMsg}</div>
|
||||
) : null}
|
||||
{result && (
|
||||
<div className={`rounded-2xl border p-4 flex items-center justify-between ${result.won ? "border-green-500/50 bg-green-900/20" : "border-red-500/50 bg-red-900/20"}`}>
|
||||
<div className={`w-14 h-14 rounded-full ${numColor(result.result)} flex items-center justify-center text-xl font-black text-white border-2 border-white/20`}>
|
||||
|
||||
@@ -1,31 +1,33 @@
|
||||
"use client";
|
||||
import { creditTicker } from "@/lib/credits-brand";
|
||||
import { useState, useRef } from "react";
|
||||
import { useState } from "react";
|
||||
import { useLiveWalletBalance } from "./useLiveWalletBalance";
|
||||
|
||||
const T = creditTicker();
|
||||
|
||||
export function SlotsGame({ balance }: { balance: number }) {
|
||||
export function SlotsGame({ balance: initialBalance }: { balance: number }) {
|
||||
const { balance, refreshBalance } = useLiveWalletBalance(initialBalance);
|
||||
const [wager, setWager] = useState(10);
|
||||
const [clientSeed, setClientSeed] = useState("my-seed");
|
||||
const [spinning, setSpinning] = useState(false);
|
||||
const [displayReels, setDisplayReels] = useState(["🎰", "🎰", "🎰"]);
|
||||
const [result, setResult] = useState<{ reels: string[]; multiplier: number; payout: number; outcome: string; serverSeed: string } | null>(null);
|
||||
const [errorMsg, setErrorMsg] = useState<string | null>(null);
|
||||
|
||||
const SYMBOLS = ["🍒", "🍋", "🍊", "🍇", "💎", "7️⃣"];
|
||||
|
||||
async function spin() {
|
||||
setErrorMsg(null);
|
||||
setSpinning(true);
|
||||
setResult(null);
|
||||
|
||||
// Animate reels
|
||||
let ticks = 0;
|
||||
// Animate reels while the server resolves the spin.
|
||||
const iv = setInterval(() => {
|
||||
setDisplayReels([
|
||||
SYMBOLS[Math.floor(Math.random() * SYMBOLS.length)],
|
||||
SYMBOLS[Math.floor(Math.random() * SYMBOLS.length)],
|
||||
SYMBOLS[Math.floor(Math.random() * SYMBOLS.length)],
|
||||
]);
|
||||
ticks++;
|
||||
}, 80);
|
||||
|
||||
const res = await fetch("/api/games/slots", {
|
||||
@@ -37,16 +39,24 @@ export function SlotsGame({ balance }: { balance: number }) {
|
||||
clearInterval(iv);
|
||||
setSpinning(false);
|
||||
|
||||
if (!res.ok) { const e = await res.json(); alert(e.error); return; }
|
||||
if (!res.ok) {
|
||||
const e = await res.json();
|
||||
setErrorMsg(e.error ?? "Spin failed");
|
||||
return;
|
||||
}
|
||||
const data = await res.json();
|
||||
setResult(data);
|
||||
setDisplayReels(data.reels);
|
||||
void refreshBalance();
|
||||
}
|
||||
|
||||
const won = result && result.payout > 0;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{errorMsg ? (
|
||||
<div className="rounded-xl border border-rose-500/40 bg-rose-500/10 p-3 text-sm text-rose-200">{errorMsg}</div>
|
||||
) : null}
|
||||
<div className={`rounded-2xl border p-6 transition-colors ${won ? "border-yellow-500/50 bg-yellow-900/20" : "border-white/10 bg-white/5"}`}>
|
||||
<div className="flex justify-center gap-4 mb-4">
|
||||
{displayReels.map((sym, i) => (
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
import { creditTicker } from "@/lib/credits-brand";
|
||||
import { useState } from "react";
|
||||
import { useState, useEffect } from "react";
|
||||
import { useLiveWalletBalance } from "./useLiveWalletBalance";
|
||||
|
||||
const T = creditTicker();
|
||||
|
||||
@@ -8,23 +9,56 @@ const FLOOR_MULTS = [1.4, 2.0, 2.8, 4.0, 5.6, 8.0, 12.0, 18.0];
|
||||
const FLOORS = 8;
|
||||
const TILES = 3;
|
||||
|
||||
export function TowerGame({ balance }: { balance: number }) {
|
||||
export function TowerGame({ balance: initialBalance }: { balance: number }) {
|
||||
const { balance, refreshBalance } = useLiveWalletBalance(initialBalance);
|
||||
const [wager, setWager] = useState(10);
|
||||
const [clientSeed, setClientSeed] = useState("my-seed");
|
||||
const [phase, setPhase] = useState<"idle" | "playing" | "done">("idle");
|
||||
const [roundId, setRoundId] = useState<string | null>(null);
|
||||
const [currentFloor, setCurrentFloor] = useState(0);
|
||||
const [revealedBombs, setRevealedBombs] = useState<number[][]>(Array(FLOORS).fill(null).map(() => []));
|
||||
const [result, setResult] = useState<{ outcome: string; payout?: number; bombPositions?: number[] } | null>(null);
|
||||
const [result, setResult] = useState<{ outcome: string; payout?: number; multiplier?: number; bombPositions?: number[] } | null>(null);
|
||||
const [lockedFloors, setLockedFloors] = useState<number[]>([]);
|
||||
const [errorMsg, setErrorMsg] = useState<string | null>(null);
|
||||
|
||||
// Resume an in-progress climb after refresh/reconnect.
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
try {
|
||||
const r = await fetch("/api/games/active?gameType=TOWER");
|
||||
if (!r.ok) return;
|
||||
const d = await r.json();
|
||||
if (cancelled || !d.session) return;
|
||||
const floor: number = typeof d.session.resultData?.currentFloor === "number"
|
||||
? d.session.resultData.currentFloor : 0;
|
||||
setRoundId(d.session.id);
|
||||
setCurrentFloor(floor);
|
||||
setWager(d.session.wageredBLW);
|
||||
// Cleared floors are visible as "locked" green rows; bombs stay hidden
|
||||
// since they haven't been picked.
|
||||
setLockedFloors(Array.from({ length: floor }, (_, i) => i));
|
||||
setRevealedBombs(Array(FLOORS).fill(null).map(() => []));
|
||||
setPhase("playing");
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
})();
|
||||
return () => { cancelled = true; };
|
||||
}, []);
|
||||
|
||||
async function startGame() {
|
||||
setErrorMsg(null);
|
||||
const res = await fetch("/api/games/tower", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ wageBLW: wager, clientSeed }),
|
||||
});
|
||||
if (!res.ok) { const e = await res.json(); alert(e.error); return; }
|
||||
if (!res.ok) {
|
||||
const e = await res.json();
|
||||
setErrorMsg(e.error ?? "Could not start climb");
|
||||
return;
|
||||
}
|
||||
const data = await res.json();
|
||||
setRoundId(data.roundId);
|
||||
setCurrentFloor(0);
|
||||
@@ -32,6 +66,7 @@ export function TowerGame({ balance }: { balance: number }) {
|
||||
setLockedFloors([]);
|
||||
setResult(null);
|
||||
setPhase("playing");
|
||||
void refreshBalance();
|
||||
}
|
||||
|
||||
async function pickTile(floor: number, tile: number) {
|
||||
@@ -41,7 +76,11 @@ export function TowerGame({ balance }: { balance: number }) {
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ roundId, action: "pick", tile }),
|
||||
});
|
||||
if (!res.ok) return;
|
||||
if (!res.ok) {
|
||||
const e = await res.json().catch(() => ({ error: "Move rejected" }));
|
||||
setErrorMsg(e.error ?? "Move rejected");
|
||||
return;
|
||||
}
|
||||
const data = await res.json();
|
||||
|
||||
const newBombs = revealedBombs.map((f, i) => i === floor ? [data.bombPos] : f);
|
||||
@@ -50,10 +89,12 @@ export function TowerGame({ balance }: { balance: number }) {
|
||||
if (data.outcome === "loss") {
|
||||
setResult(data);
|
||||
setPhase("done");
|
||||
void refreshBalance();
|
||||
} else if (data.outcome === "win") {
|
||||
setLockedFloors(prev => [...prev, floor]);
|
||||
setResult(data);
|
||||
setPhase("done");
|
||||
void refreshBalance();
|
||||
} else {
|
||||
setLockedFloors(prev => [...prev, floor]);
|
||||
setCurrentFloor(data.newFloor);
|
||||
@@ -62,23 +103,32 @@ export function TowerGame({ balance }: { balance: number }) {
|
||||
|
||||
async function cashOut() {
|
||||
if (phase !== "playing" || currentFloor === 0) return;
|
||||
setErrorMsg(null);
|
||||
const res = await fetch("/api/games/tower", {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ roundId, action: "cashout" }),
|
||||
});
|
||||
if (!res.ok) return;
|
||||
if (!res.ok) {
|
||||
const e = await res.json().catch(() => ({ error: "Cashout failed" }));
|
||||
setErrorMsg(e.error ?? "Cashout failed");
|
||||
return;
|
||||
}
|
||||
setResult(await res.json());
|
||||
setPhase("done");
|
||||
void refreshBalance();
|
||||
}
|
||||
|
||||
const multiplierAtFloor = (floor: number) => FLOOR_MULTS[floor] ?? FLOOR_MULTS[FLOORS - 1];
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{errorMsg ? (
|
||||
<div className="rounded-xl border border-rose-500/40 bg-rose-500/10 p-3 text-sm text-rose-200">{errorMsg}</div>
|
||||
) : null}
|
||||
{result && phase === "done" && (
|
||||
<div className={`rounded-xl border p-4 text-center ${result.outcome === "loss" ? "border-red-500/50 bg-red-900/20 text-red-300" : "border-green-500/50 bg-green-900/20 text-green-300"}`}>
|
||||
{result.outcome === "loss" ? "💥 Hit a bomb!" : `🏆 +${result.payout} ${T} at ${multiplierAtFloor(currentFloor - 1)}x`}
|
||||
{result.outcome === "loss" ? "💥 Hit a bomb!" : `🏆 +${result.payout} ${T} at ${(result.multiplier ?? multiplierAtFloor(currentFloor - 1)).toFixed(2)}x`}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
57
src/components/casino/useLiveWalletBalance.ts
Normal file
57
src/components/casino/useLiveWalletBalance.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
/**
|
||||
* Keeps a game component's balance UI in sync with the server.
|
||||
*
|
||||
* Sync sources:
|
||||
* - 15 s polling (covers passive drift, donations, refunds).
|
||||
* - The `wallet:refresh` window event — broadcast by any other game or
|
||||
* wallet UI when it knows the balance just changed.
|
||||
* - `visibilitychange` — refetch when the user returns to the tab.
|
||||
*
|
||||
* After any local mutation the consumer calls `refreshBalance()`, which both
|
||||
* re-fetches and broadcasts so the top-nav pill (and any other listening
|
||||
* component) updates instantly.
|
||||
*/
|
||||
export function useLiveWalletBalance(initialBalance: number) {
|
||||
const [balance, setBalance] = useState(initialBalance);
|
||||
const lastBroadcastRef = useRef<number>(initialBalance);
|
||||
|
||||
const refreshBalance = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch("/api/wallet", { cache: "no-store" });
|
||||
if (!res.ok) return;
|
||||
const data = await res.json();
|
||||
const next = data.balanceCredits ?? data.balance;
|
||||
if (typeof next === "number" && Number.isFinite(next)) {
|
||||
setBalance(next);
|
||||
if (next !== lastBroadcastRef.current && typeof window !== "undefined") {
|
||||
lastBroadcastRef.current = next;
|
||||
window.dispatchEvent(new Event("wallet:refresh"));
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Keep the last known balance; server-side wager validation remains final.
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void refreshBalance();
|
||||
const id = window.setInterval(() => void refreshBalance(), 15_000);
|
||||
const onCustom = () => void refreshBalance();
|
||||
const onVisibility = () => {
|
||||
if (document.visibilityState === "visible") void refreshBalance();
|
||||
};
|
||||
window.addEventListener("wallet:refresh", onCustom);
|
||||
document.addEventListener("visibilitychange", onVisibility);
|
||||
return () => {
|
||||
window.clearInterval(id);
|
||||
window.removeEventListener("wallet:refresh", onCustom);
|
||||
document.removeEventListener("visibilitychange", onVisibility);
|
||||
};
|
||||
}, [refreshBalance]);
|
||||
|
||||
return { balance, refreshBalance };
|
||||
}
|
||||
@@ -2,7 +2,18 @@ import { prisma } from "./prisma";
|
||||
import { creditWalletCredits, debitWalletCredits } from "./wallet-safety";
|
||||
import type { GameType } from "@prisma/client";
|
||||
|
||||
export async function debitForBet(userId: string, blwAmount: number, gameType: GameType | string, memo?: string): Promise<void> {
|
||||
interface GameLink {
|
||||
gameSessionId?: string;
|
||||
gameRoomId?: string;
|
||||
}
|
||||
|
||||
export async function debitForBet(
|
||||
userId: string,
|
||||
blwAmount: number,
|
||||
gameType: GameType | string,
|
||||
memo?: string,
|
||||
link?: GameLink,
|
||||
): Promise<void> {
|
||||
await prisma.$transaction(async (tx) => {
|
||||
await debitWalletCredits(tx, userId, blwAmount);
|
||||
await tx.ledgerEntry.create({
|
||||
@@ -11,12 +22,20 @@ export async function debitForBet(userId: string, blwAmount: number, gameType: G
|
||||
delta: -blwAmount,
|
||||
type: "DEBIT_GAME_BET",
|
||||
memo: memo ?? `${gameType} bet`,
|
||||
gameSessionId: link?.gameSessionId,
|
||||
gameRoomId: link?.gameRoomId,
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export async function creditForWin(userId: string, blwAmount: number, gameType: GameType | string, memo?: string): Promise<void> {
|
||||
export async function creditForWin(
|
||||
userId: string,
|
||||
blwAmount: number,
|
||||
gameType: GameType | string,
|
||||
memo?: string,
|
||||
link?: GameLink,
|
||||
): Promise<void> {
|
||||
await prisma.$transaction(async (tx) => {
|
||||
await creditWalletCredits(tx, userId, blwAmount);
|
||||
await tx.ledgerEntry.create({
|
||||
@@ -25,12 +44,20 @@ export async function creditForWin(userId: string, blwAmount: number, gameType:
|
||||
delta: blwAmount,
|
||||
type: "CREDIT_GAME_WIN",
|
||||
memo: memo ?? `${gameType} payout`,
|
||||
gameSessionId: link?.gameSessionId,
|
||||
gameRoomId: link?.gameRoomId,
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export async function refundBet(userId: string, blwAmount: number, gameType: GameType | string): Promise<void> {
|
||||
export async function refundBet(
|
||||
userId: string,
|
||||
blwAmount: number,
|
||||
gameType: GameType | string,
|
||||
memo?: string,
|
||||
link?: GameLink,
|
||||
): Promise<void> {
|
||||
await prisma.$transaction(async (tx) => {
|
||||
await creditWalletCredits(tx, userId, blwAmount);
|
||||
await tx.ledgerEntry.create({
|
||||
@@ -38,8 +65,50 @@ export async function refundBet(userId: string, blwAmount: number, gameType: Gam
|
||||
userId,
|
||||
delta: blwAmount,
|
||||
type: "CREDIT_GAME_REFUND",
|
||||
memo: `${gameType} refund`,
|
||||
memo: memo ?? `${gameType} refund`,
|
||||
gameSessionId: link?.gameSessionId,
|
||||
gameRoomId: link?.gameRoomId,
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Idempotent refund — credits a room participant (creator or joiner) only if
|
||||
* their original lock entry exists for this room and no refund has been recorded
|
||||
* yet. Returns true if it actually refunded, false otherwise.
|
||||
*
|
||||
* Used by the expiry sweep, cancel endpoint, and boot-time recovery.
|
||||
*/
|
||||
export async function refundRoomPartyIfNotRefunded(
|
||||
roomId: string,
|
||||
userId: string,
|
||||
blwAmount: number,
|
||||
gameType: GameType | string,
|
||||
): Promise<boolean> {
|
||||
return prisma.$transaction(async (tx) => {
|
||||
const existingRefund = await tx.ledgerEntry.findFirst({
|
||||
where: { gameRoomId: roomId, userId, type: "CREDIT_GAME_REFUND" },
|
||||
select: { id: true },
|
||||
});
|
||||
if (existingRefund) return false;
|
||||
|
||||
const originalLock = await tx.ledgerEntry.findFirst({
|
||||
where: { gameRoomId: roomId, userId, type: "DEBIT_GAME_BET" },
|
||||
select: { id: true },
|
||||
});
|
||||
if (!originalLock) return false;
|
||||
|
||||
await creditWalletCredits(tx, userId, blwAmount);
|
||||
await tx.ledgerEntry.create({
|
||||
data: {
|
||||
userId,
|
||||
delta: blwAmount,
|
||||
type: "CREDIT_GAME_REFUND",
|
||||
memo: `${gameType} room refund`,
|
||||
gameRoomId: roomId,
|
||||
},
|
||||
});
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
20
src/lib/legal-contact.ts
Normal file
20
src/lib/legal-contact.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { siteUrl } from "@/lib/public-env";
|
||||
|
||||
/** Public contact for privacy and data-deletion requests. */
|
||||
export function legalContactEmail(): string {
|
||||
const configured =
|
||||
process.env.NEXT_PUBLIC_LEGAL_CONTACT_EMAIL?.trim() ||
|
||||
process.env.LEGAL_CONTACT_EMAIL?.trim();
|
||||
if (configured) return configured;
|
||||
|
||||
try {
|
||||
const host = new URL(siteUrl()).hostname.replace(/^www\./, "");
|
||||
if (host && host !== "localhost" && !host.startsWith("127.")) {
|
||||
return `privacy@${host}`;
|
||||
}
|
||||
} catch {
|
||||
/* fall through */
|
||||
}
|
||||
|
||||
return "privacy@example.com";
|
||||
}
|
||||
7
src/types/next-auth.d.ts
vendored
7
src/types/next-auth.d.ts
vendored
@@ -2,7 +2,11 @@ import type { DefaultSession } from "next-auth";
|
||||
|
||||
declare module "next-auth" {
|
||||
interface Session {
|
||||
user: DefaultSession["user"] & { id: string; role: "USER" | "ADMIN" };
|
||||
user: DefaultSession["user"] & { id: string; role: "USER" | "ADMIN"; username?: string };
|
||||
}
|
||||
|
||||
interface User {
|
||||
username?: string;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,5 +14,6 @@ declare module "next-auth/jwt" {
|
||||
interface JWT {
|
||||
id?: string;
|
||||
role?: "USER" | "ADMIN";
|
||||
username?: string;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user