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:
root
2026-05-20 06:43:26 +00:00
parent c6a7b2e08d
commit 7eac42e820
48 changed files with 3005 additions and 227 deletions

View 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;
}
}

View File

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

View File

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

View File

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

View File

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

View 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 });
}

View File

@@ -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>
) : (
<>

View File

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

View File

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

View File

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

View 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: &quot;Data deletion request&quot;</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>
);
}

View 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 &quot;Last updated&quot; 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>
);
}

View File

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

View File

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

View 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>
);
}

View 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>
);
}

View File

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

View File

@@ -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&apos;ve spent {t}</p>
{summary.spendBreakdown.length === 0 ? (

View File

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

View File

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

View 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>
);
}

View 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>
);
}

View File

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

View File

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

View 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>
);
}

View File

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

View File

@@ -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 &amp; 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">

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 &amp; 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">

View File

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

View File

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

View File

@@ -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) => (

View File

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

View 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 };
}

View File

@@ -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
View 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";
}

View File

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