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:
@@ -24,6 +24,8 @@ NEXT_PUBLIC_COMMITTEE_LEGAL_NAME_PLACEHOLDER=Your Committee Legal Name Here
|
||||
COMMITTEE_LEGAL_NAME_PLACEHOLDER=Your Committee Legal Name Here
|
||||
NEXT_PUBLIC_DISCLAIMER_TEXT=Contributions are solicited by an authorized political committee. Federal law requires political committees to report contributor information and to retain records in accordance with FEC rules. This statement is general information only and not legal, FEC, or tax advice; consult qualified counsel for your committee obligations.
|
||||
DISCLAIMER_TEXT=Contributions are solicited by an authorized political committee. Federal law requires political committees to report contributor information and to retain records in accordance with FEC rules. This statement is general information only and not legal, FEC, or tax advice; consult qualified counsel for your committee obligations.
|
||||
# Privacy / data-deletion requests (shown on /privacy-policy and /datadeletion)
|
||||
NEXT_PUBLIC_LEGAL_CONTACT_EMAIL=privacy@example.com
|
||||
|
||||
# --- Stripe (test keys for development; use restricted keys in shared environments) ---
|
||||
STRIPE_SECRET_KEY=sk_test_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
|
||||
@@ -38,3 +40,8 @@ PUBLIC_POLL_VOTE_CREDITS=5
|
||||
|
||||
# Optional: Stripe CLI for local webhook forwarding:
|
||||
# stripe listen --forward-to 127.0.0.1:8008/api/webhooks/stripe
|
||||
|
||||
# --- Threads OAuth ---
|
||||
THREADS_CLIENT_ID=your_threads_client_id
|
||||
THREADS_CLIENT_SECRET=your_threads_client_secret
|
||||
THREADS_REDIRECT_URI=https://bwt.democracyrisingbwt.us/threads-callback
|
||||
|
||||
322
REDME-FIXSES-TRIAGE.txt
Normal file
322
REDME-FIXSES-TRIAGE.txt
Normal file
@@ -0,0 +1,322 @@
|
||||
================================================================================
|
||||
REDMEFIXSES CONSERVATIVE TRIAGE
|
||||
================================================================================
|
||||
Source reviewed: redmeFIXSES.md
|
||||
Date: 2026-05-19
|
||||
Mode: evaluation only; no application code changed.
|
||||
|
||||
Principle used:
|
||||
If the site works and a change is not clearly safer, smaller, and worth the
|
||||
risk, do not do it. Prefer no-op/defer over broad refactors.
|
||||
|
||||
Decision key:
|
||||
DO = worth doing later as a small, contained change
|
||||
DEFER = valid concern, but product/legal/infra/risky scope
|
||||
DO NOT = stale, already fixed, intentional, or too risky for current goal
|
||||
|
||||
================================================================================
|
||||
SUMMARY
|
||||
================================================================================
|
||||
|
||||
Items evaluated: 35
|
||||
|
||||
DO:
|
||||
1.3 Registration rate limiting
|
||||
5.2 Clarify dev vs dev:next because Socket.IO games need server.ts
|
||||
5.3 Wire ESLint separately from TypeScript checking
|
||||
6.1 Active nav state styling
|
||||
|
||||
DEFER:
|
||||
1.4 Password reset flow
|
||||
1.5 Admin bootstrap/audit log
|
||||
2.4 Ledger FK links to GameSession/GameRoom
|
||||
2.5 Pong multi-instance/recovery architecture
|
||||
3.4 Homepage meter vs boost meter product wording
|
||||
3.6 Static FAQ vs dynamic FAQ source of truth
|
||||
4.2 Casino/compliance review
|
||||
4.3 Prediction oracle/fairness model
|
||||
4.4 Initiative moderation queue
|
||||
4.5 Email verification enforcement
|
||||
6.2 Skeleton/loading polish
|
||||
7.1 Unit test suite
|
||||
7.2 Structured logging/APM
|
||||
7.3 Webhook alerting
|
||||
|
||||
DO NOT:
|
||||
1.1 Socket userId trust issue
|
||||
1.2 Socket CORS wildcard
|
||||
1.6 Anonymous FAQ pending leak
|
||||
2.1 Public stats vs treasury succeeded filter
|
||||
2.2 Prediction bet transaction
|
||||
2.3 Boost milestone race
|
||||
3.1 FAQ API has no UI
|
||||
3.2 Billboard API has no UI
|
||||
3.3 Spotlight API has no UI
|
||||
3.5 Cards API has no UI
|
||||
4.1 Committee/legal placeholders
|
||||
5.1 NEXT_PUBLIC_SITE_URL missing from .env.example
|
||||
5.4 HSTS preload
|
||||
5.5 Sitemap incomplete
|
||||
6.3 DonationCheckout .env wording
|
||||
|
||||
================================================================================
|
||||
ITEM-BY-ITEM DETERMINATION
|
||||
================================================================================
|
||||
|
||||
1.1 Socket.IO trusts browser userId
|
||||
Decision: DO NOT
|
||||
Current truth:
|
||||
This appears fixed. server.ts resolves the user from the auth token via
|
||||
resolveSocketUserId(), and CoinFlipRoom/PongGame emit only roomId on join.
|
||||
Why:
|
||||
The old redme claim is stale. Touching working socket auth now risks breaking
|
||||
coin flip and pong.
|
||||
|
||||
1.2 Socket.IO CORS origin "*"
|
||||
Decision: DO NOT
|
||||
Current truth:
|
||||
server.ts uses allowedOrigin from NEXT_PUBLIC_SITE_URL/AUTH_URL fallback, not
|
||||
wildcard.
|
||||
Why:
|
||||
Already fixed. No change.
|
||||
|
||||
1.3 Registration endpoint unthrottled
|
||||
Decision: DO
|
||||
Current truth:
|
||||
Still valid. /api/register is a plain POST handler.
|
||||
Worth it:
|
||||
Yes, but only as a small contained rate limiter. It reduces account spam and
|
||||
credential stuffing risk without changing normal user flows.
|
||||
Safe shape:
|
||||
Add an in-memory IP/email bucket first, or Cloudflare rate rule. Do not add
|
||||
CAPTCHA/email verification in the same change.
|
||||
|
||||
1.4 No automated password reset
|
||||
Decision: DEFER
|
||||
Current truth:
|
||||
Static forgot-password page only.
|
||||
Why defer:
|
||||
A real reset flow touches email delivery, tokens, auth UX, and support
|
||||
process. It is worth doing eventually, but not a safe quick patch while the
|
||||
site is working.
|
||||
|
||||
1.5 Admin bootstrap/audit log missing
|
||||
Decision: DEFER
|
||||
Current truth:
|
||||
Admin role exists; audit logging for admin actions is not a complete system.
|
||||
Why defer:
|
||||
Cross-cutting ops/security feature. Needs design. Do not bolt it on.
|
||||
|
||||
1.6 Public FAQ API leaks pending submissions
|
||||
Decision: DO NOT
|
||||
Current truth:
|
||||
Anonymous users get pending: []; signed-in users can see pending submissions
|
||||
for the FAQ board workflow.
|
||||
Why:
|
||||
The original public leak claim is stale. Making pending admin-only may break
|
||||
the current voting/board behavior unless product direction changes.
|
||||
|
||||
2.1 public stats vs treasury status filter mismatch
|
||||
Decision: DO NOT
|
||||
Current truth:
|
||||
Both current stats and treasury queries filter status: \"succeeded\".
|
||||
Why:
|
||||
Already fixed.
|
||||
|
||||
2.2 Prediction bets not transactional
|
||||
Decision: DO NOT
|
||||
Current truth:
|
||||
Prediction bet debit, ledger entry, bet create, and market total update are
|
||||
inside prisma.$transaction().
|
||||
Why:
|
||||
Already fixed.
|
||||
|
||||
2.3 Movement boost milestone race
|
||||
Decision: DO NOT
|
||||
Current truth:
|
||||
Current boost route uses Serializable isolation and handles P2034 conflicts.
|
||||
Why:
|
||||
Good enough for current scale. Idempotency rows would add schema complexity.
|
||||
|
||||
2.4 Ledger not linked to GameSession/GameRoom
|
||||
Decision: DEFER
|
||||
Current truth:
|
||||
Valid. Game ledger lines are memo-linked, not FK-linked.
|
||||
Why defer:
|
||||
Requires Prisma migration and touching every game settlement path. Good
|
||||
long-term reconciliation work, but not worth risking a working wallet today.
|
||||
|
||||
2.5 Pong in-memory server state
|
||||
Decision: DEFER
|
||||
Current truth:
|
||||
Valid. Pong state lives in server.ts memory and assumes one Node process.
|
||||
Why defer:
|
||||
Redis/recovery would be a significant architecture change. Current service is
|
||||
single-instance, so do not change now.
|
||||
|
||||
3.1 FAQ API has no UI
|
||||
Decision: DO NOT
|
||||
Current truth:
|
||||
Stale. /faq-board fetches /api/faq.
|
||||
|
||||
3.2 Billboard API has no UI
|
||||
Decision: DO NOT
|
||||
Current truth:
|
||||
Stale. /billboard fetches /api/billboard.
|
||||
|
||||
3.3 Spotlight API has no UI
|
||||
Decision: DO NOT
|
||||
Current truth:
|
||||
Stale. /spotlight fetches /api/spotlight.
|
||||
|
||||
3.4 Movement boost meter vs homepage meter
|
||||
Decision: DEFER
|
||||
Current truth:
|
||||
/boost is wired. Homepage ProgressSection is a USD fundraising meter, not the
|
||||
BWT boost meter.
|
||||
Why defer:
|
||||
Not broken. This is copy/product clarity, not a code defect.
|
||||
|
||||
3.5 Cards API has no UI
|
||||
Decision: DO NOT
|
||||
Current truth:
|
||||
Stale. /cards fetches /api/cards.
|
||||
|
||||
3.6 Static FAQ and dynamic FAQ both exist
|
||||
Decision: DEFER
|
||||
Current truth:
|
||||
Valid. Homepage marketing FAQ and /faq-board dynamic FAQ coexist.
|
||||
Why defer:
|
||||
Product/source-of-truth decision. Both can coexist without breaking the site.
|
||||
|
||||
4.1 Committee/legal placeholders
|
||||
Decision: DO NOT
|
||||
Current truth:
|
||||
Valid placeholders remain.
|
||||
Why:
|
||||
This is deployment/legal configuration, not code. Do not invent legal copy.
|
||||
|
||||
4.2 Casino / prediction / games compliance
|
||||
Decision: DEFER
|
||||
Current truth:
|
||||
Valid concern.
|
||||
Why defer:
|
||||
Legal/jurisdiction scope. No autonomous code change is safe.
|
||||
|
||||
4.3 Prediction creator resolves outcome
|
||||
Decision: DEFER
|
||||
Current truth:
|
||||
Valid. Creator resolves market after close.
|
||||
Why defer:
|
||||
Oracle design is product/fairness architecture. Do not change while working.
|
||||
|
||||
4.4 User-generated initiatives moderation
|
||||
Decision: DEFER
|
||||
Current truth:
|
||||
Valid. Initiatives can be submitted without a moderator queue.
|
||||
Why defer:
|
||||
New moderation workflow and policy decisions needed.
|
||||
|
||||
4.5 Email verification unused
|
||||
Decision: DEFER
|
||||
Current truth:
|
||||
Valid. emailVerified exists in schema but credentials login does not enforce it.
|
||||
Why defer:
|
||||
Enforcing verification changes registration/login behavior. Risky without an
|
||||
email delivery plan.
|
||||
|
||||
5.1 NEXT_PUBLIC_SITE_URL missing in .env.example
|
||||
Decision: DO NOT
|
||||
Current truth:
|
||||
Fixed. .env.example includes NEXT_PUBLIC_SITE_URL.
|
||||
|
||||
5.2 dev vs dev:next confusion
|
||||
Decision: DO
|
||||
Current truth:
|
||||
Valid. npm run dev uses server.ts with sockets. npm run dev:next runs Next
|
||||
only and would break coin flip/pong sockets.
|
||||
Worth it:
|
||||
Yes. A README/package script description change is low-risk and prevents dev
|
||||
confusion.
|
||||
|
||||
5.3 lint only runs tsc
|
||||
Decision: DO
|
||||
Current truth:
|
||||
Valid. npm run lint is tsc --noEmit despite ESLint being installed.
|
||||
Worth it:
|
||||
Yes, if done as a separate script (for example typecheck + lint:eslint) so the
|
||||
existing working tsc path is not broken.
|
||||
|
||||
5.4 HSTS preload globally
|
||||
Decision: DO NOT
|
||||
Current truth:
|
||||
Valid, but intentional production security header.
|
||||
Why:
|
||||
Removing or env-gating it is unnecessary unless it causes a proven deployment
|
||||
issue.
|
||||
|
||||
5.5 Sitemap incomplete
|
||||
Decision: DO NOT
|
||||
Current truth:
|
||||
Mostly stale. Sitemap includes many major pages now. Casino auth pages are
|
||||
intentionally less SEO-oriented.
|
||||
Why:
|
||||
Not worth touching right now.
|
||||
|
||||
6.1 Active nav state
|
||||
Decision: DO
|
||||
Current truth:
|
||||
Valid. SiteNav/MobileNav do not appear to show current route state.
|
||||
Worth it:
|
||||
Yes, small isolated UX improvement if done carefully.
|
||||
|
||||
6.2 Loading shimmer coverage
|
||||
Decision: DEFER
|
||||
Current truth:
|
||||
Partial. Some loading states exist; skeleton polish is inconsistent.
|
||||
Why defer:
|
||||
Cosmetic and broad. Not worth risking layout churn.
|
||||
|
||||
6.3 DonationCheckout .env copy
|
||||
Decision: DO NOT
|
||||
Current truth:
|
||||
Stale/low-value. DonationCheckout is not the active checkout path and copy is
|
||||
already more production-friendly than the old note suggests.
|
||||
|
||||
7.1 No unit test suite
|
||||
Decision: DEFER
|
||||
Current truth:
|
||||
Valid.
|
||||
Why defer:
|
||||
Valuable, but not a quick no-risk change. Add tests around future bug fixes
|
||||
instead of inventing a full harness now.
|
||||
|
||||
7.2 No structured logging / APM
|
||||
Decision: DEFER
|
||||
Current truth:
|
||||
Valid.
|
||||
Why defer:
|
||||
Ops choice. Needs tool/vendor decision.
|
||||
|
||||
7.3 Webhook 500 / alerting
|
||||
Decision: DEFER
|
||||
Current truth:
|
||||
Valid. Stripe retries on 500; no alerting layer found.
|
||||
Why defer:
|
||||
Alerting belongs in ops/monitoring setup. Do not fake it in app code.
|
||||
|
||||
================================================================================
|
||||
FINAL DETERMINATION
|
||||
================================================================================
|
||||
|
||||
Do not perform a broad cleanup/refactor now. The app works and many redme items
|
||||
are stale. The only changes worth doing next are small and low-risk:
|
||||
|
||||
1. Add conservative registration rate limiting.
|
||||
2. Clarify dev scripts so socket games are not started with dev:next.
|
||||
3. Add ESLint as a separate script without replacing the current typecheck.
|
||||
4. Add active nav styling.
|
||||
|
||||
Everything else should be deferred or left alone unless it becomes a proven bug,
|
||||
legal requirement, or planned product change.
|
||||
================================================================================
|
||||
740
SITE-AUDIT.txt
Normal file
740
SITE-AUDIT.txt
Normal file
@@ -0,0 +1,740 @@
|
||||
================================================================================
|
||||
DEMOCRACY RISING / FUNDRAISING PLATFORM — DEEP SITE AUDIT
|
||||
================================================================================
|
||||
Generated: 2026-05-19 (UTC)
|
||||
Scope: /root/fundraising-platform (source, config, Prisma, scripts, runtime)
|
||||
Machine: Linux (Proxmox LXC), 4 vCPU, ~7.8 GiB RAM, 64 GiB disk (~7% used)
|
||||
Service: fundraising-platform.service → npm run start → tsx server.ts :8008
|
||||
Public: https://bwt.democracyrisingbwt.us (Cloudflare proxy, not direct to origin)
|
||||
Typecheck: npm run lint (tsc --noEmit) — PASS at audit time
|
||||
Build: npm run build — PASS (includes /threads-callback, /api/threads-exchange)
|
||||
|
||||
Severity legend:
|
||||
[CRITICAL] Exploit, money loss, secret exposure, or production outage risk
|
||||
[HIGH] Broken feature, data corruption risk, or major UX failure
|
||||
[MEDIUM] Correctness edge case, maintainability, or meaningful perf waste
|
||||
[LOW] Polish, SEO, docs, minor duplication
|
||||
|
||||
================================================================================
|
||||
0. EXECUTIVE SUMMARY
|
||||
================================================================================
|
||||
|
||||
This is a Next.js 16 App Router political fundraising app with:
|
||||
- Stripe donations + webhooks
|
||||
- Supporter credits (BWT) wallet + ledger
|
||||
- 10 casino-style games (7 REST solo, 2 Socket.IO PvP, 1 prediction market)
|
||||
- Community features (billboard, spotlight, cards, FAQ board, boost meter)
|
||||
|
||||
Overall code quality: TypeScript compiles clean; core wallet debits use atomic
|
||||
updateMany; many game routes use optimistic locking (updateMany + outcome:"active").
|
||||
The largest risks are DEPLOYMENT/DNS (Cloudflare serving stale static pages),
|
||||
SOCKET/PONG in-memory state (single-process assumption), BLACKJACK transaction
|
||||
gaps, and CLIENT-SIDE performance (always-on canvas animations + polling).
|
||||
|
||||
Approximate codebase: ~15,246 lines in src/**/*.ts(x), 561M node_modules, 1.1G .next
|
||||
|
||||
Top 10 actions (ordered):
|
||||
1. Fix Cloudflare routing for bwt.democracyrisingbwt.us → origin :8008 (not static HTML)
|
||||
2. Align NEXT_PUBLIC_SITE_URL / AUTH_URL with real public hostname
|
||||
3. Rotate any secrets ever committed or logged (.env, old Threads static page)
|
||||
4. Wrap blackjack natural/push/resolve in single DB transactions
|
||||
5. Add gameSessionId FK on ledger entries for reconciliation
|
||||
6. Document/require npm run start (not dev:next) for Socket.IO games
|
||||
7. Reduce homepage/global canvas + polling load on this 8GB box
|
||||
8. Delete or wire DonationCheckout.tsx (~400 lines dead)
|
||||
9. Harden coin-flip/pong payout failure paths (room stuck ACTIVE)
|
||||
10. Add rate limits on /api/register and game POST endpoints
|
||||
|
||||
================================================================================
|
||||
1. DEPLOYMENT & INFRASTRUCTURE (CRITICAL FOR LIVE SITE)
|
||||
================================================================================
|
||||
|
||||
1.1 [CRITICAL] Cloudflare intercepts traffic before Next.js
|
||||
------------------------------------------------------------------------------
|
||||
Symptom verified on live host:
|
||||
- https://bwt.democracyrisingbwt.us/datadeletion → was 404 until service restart;
|
||||
may still vary by cache
|
||||
- https://bwt.democracyrisingbwt.us/threads-callback → serves OLD static HTML
|
||||
("Threads Bot Setup", client-side secret exchange) NOT the Next.js route
|
||||
- curl shows: server: cloudflare, cf-ray present
|
||||
- DNS: bwt.democracyrisingbwt.us → 104.21.29.229 / 172.67.171.225 (Cloudflare)
|
||||
- Origin direct :8008 on public IP → connection refused (port not exposed)
|
||||
|
||||
Local origin (correct):
|
||||
- http://127.0.0.1:8008/threads-callback → Next.js page "Threads callback"
|
||||
- http://127.0.0.1:8008/datadeletion → legal page
|
||||
|
||||
Old static /threads-callback HTML (served via Cloudflare) contains:
|
||||
- client_id in page JS
|
||||
- client_secret in browser fetch to graph.threads.net (SECURITY DISASTER)
|
||||
- Inline OAuth flow bypassing /api/threads-exchange
|
||||
|
||||
Action: In Cloudflare dashboard, remove Workers/Pages/static asset rules for
|
||||
/threads-callback, /datadeletion, /api/*; proxy pass-through to origin tunnel
|
||||
or open 8008 via cloudflared. Purge cache after fix.
|
||||
|
||||
Files (new, correct): src/app/threads-callback/*, src/app/api/threads-exchange/route.ts
|
||||
|
||||
1.2 [HIGH] Environment URL mismatch
|
||||
------------------------------------------------------------------------------
|
||||
.env currently has:
|
||||
NEXT_PUBLIC_SITE_URL=https://democratic.thetempleofdoom.com
|
||||
Public users use:
|
||||
https://bwt.democracyrisingbwt.us
|
||||
|
||||
Impact:
|
||||
- Canonical URLs, Open Graph, JSON-LD, sitemap base wrong for BWT subdomain
|
||||
- Socket.IO CORS allowedOrigin may reject browser if users hit bwt.* but env says democratic.*
|
||||
- Stripe/NextAuth redirect_uri mismatches if callbacks use SITE_URL
|
||||
|
||||
Action: Set NEXT_PUBLIC_SITE_URL, NEXTAUTH_URL, AUTH_URL to the hostname users
|
||||
actually use (or support multiple origins in server.ts CORS).
|
||||
|
||||
Reference: server.ts lines 18-22 (allowedOrigin), src/lib/public-env.ts
|
||||
|
||||
1.3 [MEDIUM] Single-process architecture
|
||||
------------------------------------------------------------------------------
|
||||
server.ts holds:
|
||||
- pongRooms Map + setInterval game loops (20 Hz per active room)
|
||||
- coin-flip resolution inline on join
|
||||
- No Redis, no horizontal scaling
|
||||
|
||||
If you scale to 2+ Node processes: PvP games break, rooms split-brain.
|
||||
|
||||
Action: Document "single instance only" or migrate room state to Redis.
|
||||
|
||||
1.4 [LOW] systemd unit note
|
||||
------------------------------------------------------------------------------
|
||||
/etc/systemd/system/fundraising-platform.service uses npm run start (good).
|
||||
Unknown key StartLimitIntervalSec ignored on older systemd — harmless.
|
||||
|
||||
NODE_OPTIONS=--max-old-space-size=8192 on an 7.8GB machine leaves little headroom
|
||||
for PostgreSQL + OS. Consider 4096 unless you see OOM during build.
|
||||
|
||||
================================================================================
|
||||
2. SECURITY
|
||||
================================================================================
|
||||
|
||||
2.1 [CRITICAL] Secrets exposure vectors
|
||||
------------------------------------------------------------------------------
|
||||
- Old Cloudflare-hosted threads-callback HTML embeds THREADS client_secret in JS
|
||||
- .env on server contains live Stripe sk_live_* (never commit; restrict file perms)
|
||||
- Threads credentials in .env (THREADS_CLIENT_ID, THREADS_CLIENT_SECRET) — OK if
|
||||
server-only; ensure .gitignore blocks .env (it does)
|
||||
|
||||
Action: Rotate Threads app secret + Stripe keys if static page was ever public.
|
||||
chmod 600 /root/fundraising-platform/.env
|
||||
|
||||
2.2 [HIGH] No global API rate limiting
|
||||
------------------------------------------------------------------------------
|
||||
Unauthenticated or session-authenticated endpoints can be spammed:
|
||||
- POST /api/register
|
||||
- POST /api/games/* (each debits DB)
|
||||
- POST /api/faq, /api/billboard, etc.
|
||||
|
||||
Action: nginx/cloudflare rate limits, or middleware with IP buckets.
|
||||
|
||||
2.3 [MEDIUM] Auth middleware disabled
|
||||
------------------------------------------------------------------------------
|
||||
src/middleware.ts → matcher: [] — no edge protection.
|
||||
All auth is per-route auth() calls. Easy to forget on new routes.
|
||||
|
||||
Wallet page protected in page.tsx; most /api/* check session — good pattern but
|
||||
not enforced centrally.
|
||||
|
||||
2.4 [MEDIUM] FAQ pending submissions
|
||||
------------------------------------------------------------------------------
|
||||
src/app/api/faq/route.ts GET:
|
||||
- pending[] only returned when signedIn (line 29-44) — IMPROVED vs old audit
|
||||
- Still: any logged-in user sees ALL pending submissions (moderation leak)
|
||||
|
||||
Action: pending visible to admin role only.
|
||||
|
||||
2.5 [MEDIUM] Socket.IO auth — PARTIALLY FIXED
|
||||
------------------------------------------------------------------------------
|
||||
server.ts resolveSocketUserId() uses getToken from cookie — GOOD.
|
||||
CoinFlipRoom no longer sends client userId in join_room — GOOD.
|
||||
|
||||
Remaining:
|
||||
- CORS origin single string; subdomains need array
|
||||
- No explicit origin check on Engine.IO handshake beyond cors config
|
||||
|
||||
2.6 [LOW] Prediction market trust model
|
||||
------------------------------------------------------------------------------
|
||||
Creator resolves outcome (PATCH). No oracle — fine for demo, risky for prod.
|
||||
|
||||
2.7 [LOW] No email verification enforced
|
||||
------------------------------------------------------------------------------
|
||||
User.emailVerified in schema unused in credentials flow.
|
||||
|
||||
================================================================================
|
||||
3. DEAD CODE, DUPLICATION, ORPHANS
|
||||
================================================================================
|
||||
|
||||
3.1 [HIGH] Unused component — DonationCheckout.tsx
|
||||
------------------------------------------------------------------------------
|
||||
Path: src/components/DonationCheckout.tsx (~400 lines)
|
||||
Imports: NONE in app (only EmbeddedDonationCheckout used)
|
||||
- src/app/donate/page.tsx → EmbeddedDonationCheckout
|
||||
- src/components/DonateSection.tsx → EmbeddedDonationCheckout
|
||||
|
||||
Action: Delete file or merge unique features into EmbeddedDonationCheckout.
|
||||
|
||||
3.2 [MEDIUM] Unused exports / dead helpers
|
||||
------------------------------------------------------------------------------
|
||||
- src/lib/auth-links.ts: LOGIN_RETURN_WALLET, LOGIN_RETURN_RAISED — no imports
|
||||
- src/app/api/games/dice/route.ts: imports hashServerSeed, never used
|
||||
- src/app/api/games/tower/route.ts: SAFE_PER_FLOOR constant was reported unused
|
||||
(verify before delete)
|
||||
- src/lib/exchange.ts: @deprecated BLW_* aliases — internal only
|
||||
|
||||
3.3 [MEDIUM] Duplicated game metadata
|
||||
------------------------------------------------------------------------------
|
||||
- src/app/casino/page.tsx — GAMES array (lobby cards)
|
||||
- src/app/casino/[game]/page.tsx — GAME_META record
|
||||
- src/components/casino/GameHistory.tsx — GAME_ICONS
|
||||
|
||||
Any new game requires 3 edits. Extract shared catalog module.
|
||||
|
||||
3.4 [MEDIUM] Duplicated settlement logic
|
||||
------------------------------------------------------------------------------
|
||||
mines/route.ts and tower/route.ts — nearly identical cashout/win transactions.
|
||||
dice, slots, roulette — copy-paste instant-game transaction blocks.
|
||||
crash/mines/tower use creditWalletCredits + manual ledger; dice uses same;
|
||||
blackjack uses creditForWin/refundBet from game-ledger — INCONSISTENT.
|
||||
|
||||
Action: Single settleGameWin() / settleGameLoss() in game-ledger.ts.
|
||||
|
||||
3.5 [LOW] redmeFIXSES.md partially stale
|
||||
------------------------------------------------------------------------------
|
||||
Section 3 claims API-only features have no UI — FALSE for:
|
||||
billboard, spotlight, cards, faq-board, boost (all have pages + fetch)
|
||||
Section 1.1 socket userId spoof — FIXED in current server.ts
|
||||
Section 1.2 CORS * — FIXED (restricted origin)
|
||||
Section 2.1 public stats — BOTH use status:"succeeded" now
|
||||
|
||||
Keep redmeFIXSES.md or merge into this audit after triage.
|
||||
|
||||
================================================================================
|
||||
4. GAME-BY-GAME AUDIT (LOGIC, BUGS, EXPLOITS)
|
||||
================================================================================
|
||||
|
||||
Shared infrastructure:
|
||||
- lib/provably-fair.ts — HMAC-SHA256, crash 3% edge, deriveMinePositions
|
||||
- lib/game-ledger.ts — debitForBet, creditForWin, refundBet
|
||||
- lib/wallet-safety.ts — atomic debit via updateMany gte check
|
||||
- prisma GameSession — @@index([userId]), @@index([userId, gameType])
|
||||
|
||||
------------------------------------------------------------------------------
|
||||
4.1 CRASH (REST: POST + PATCH) — src/app/api/games/crash/route.ts
|
||||
------------------------------------------------------------------------------
|
||||
Client: src/components/casino/CrashGame.tsx
|
||||
|
||||
Behavior:
|
||||
- POST debits wager, stores crashAt in resultData (hidden until loss/cashout)
|
||||
- PATCH tick: client sends currentMultiplier; server settles loss if >= crashAt
|
||||
- PATCH cashout: pays floor(wager * cashoutAt)
|
||||
|
||||
Issues:
|
||||
[MEDIUM] cashOut() sends `cashoutAt: multiplier` (React state) while tick probe
|
||||
uses multiplierRef.current — possible 1-frame desync under load.
|
||||
Fix: always send multiplierRef.current on cashout.
|
||||
|
||||
[MEDIUM] Tick interval every 220ms while running = ~4.5 req/s per active player.
|
||||
Many concurrent crash players = DB load. Consider server-side timer or WS.
|
||||
|
||||
[LOW] Animation runs to 100x client-side even if crashAt is 2x — cosmetic only
|
||||
until tick returns loss.
|
||||
|
||||
[LOW] No max wager cap server-side beyond integer >= 1.
|
||||
|
||||
Provably fair: serverSeed returned on end — good for verification.
|
||||
|
||||
------------------------------------------------------------------------------
|
||||
4.2 DICE (REST: POST instant) — src/app/api/games/dice/route.ts
|
||||
------------------------------------------------------------------------------
|
||||
Client: src/components/casino/DiceGame.tsx
|
||||
|
||||
Issues:
|
||||
[LOW] Unused import hashServerSeed (no commit hash returned to client)
|
||||
[LOW] No serverSeedHash at bet time — weaker commit-reveal than mines/crash
|
||||
|
||||
Logic: roll 0-99, threshold 2-98, multiplier = 0.98/winProb — OK.
|
||||
|
||||
Transaction: single prisma.$transaction — GOOD.
|
||||
|
||||
------------------------------------------------------------------------------
|
||||
4.3 MINES (REST: POST + PATCH) — src/app/api/games/mines/route.ts
|
||||
------------------------------------------------------------------------------
|
||||
Client: src/components/casino/MinesGame.tsx
|
||||
|
||||
Issues:
|
||||
[MEDIUM] Loss path uses prisma.gameSession.update (not updateMany) — double-tile
|
||||
race could overwrite outcome if two PATCHes in flight.
|
||||
|
||||
[MEDIUM] calcMultiplier() uses inverted product formula — verify house edge
|
||||
matches advertised 1%; not unit tested.
|
||||
|
||||
[LOW] Cashout with 0 reveals refunds full wager (push) — intentional?
|
||||
|
||||
[LOW] minePositions sent to client on loss/cashout — correct for transparency;
|
||||
during active play positions hidden — GOOD.
|
||||
|
||||
------------------------------------------------------------------------------
|
||||
4.4 TOWER (REST: POST + PATCH) — src/app/api/games/tower/route.ts
|
||||
------------------------------------------------------------------------------
|
||||
Client: src/components/casino/TowerGame.tsx
|
||||
|
||||
Same settlement duplication as mines. Pre-generated bomb positions per floor.
|
||||
|
||||
Issues:
|
||||
[MEDIUM] Same updateMany vs plain update race as mines on some paths
|
||||
[LOW] FLOOR_MULTIPLIERS hardcoded — not derived from provably-fair module
|
||||
|
||||
------------------------------------------------------------------------------
|
||||
4.5 SLOTS / ROULETTE (REST: POST instant)
|
||||
------------------------------------------------------------------------------
|
||||
Files: src/app/api/games/slots/route.ts, roulette/route.ts
|
||||
Clients: SlotsGame.tsx, RouletteGame.tsx
|
||||
|
||||
Pattern: transaction wraps debit + credit + GameSession — GOOD.
|
||||
|
||||
Issues:
|
||||
[LOW] No explicit max wager
|
||||
[LOW] Outcome math not documented in UI paytable linkage to server
|
||||
|
||||
------------------------------------------------------------------------------
|
||||
4.6 BLACKJACK (REST: POST + PATCH) — src/app/api/games/blackjack/route.ts
|
||||
------------------------------------------------------------------------------
|
||||
Client: src/components/casino/BlackjackGame.tsx
|
||||
|
||||
Issues:
|
||||
[HIGH] Natural blackjack / push on POST (lines 91-100):
|
||||
creditForWin/refundBet + gameSession.update are OUTSIDE initial transaction.
|
||||
Failure between them → wallet credited but session still "active" or inverse.
|
||||
|
||||
[HIGH] resolveStand() (lines 157-193):
|
||||
credit/refund then session update — not atomic.
|
||||
Partial failure → duplicate payout or lost payout.
|
||||
|
||||
[MEDIUM] Card rank parsing: handValue uses c.slice(0,-1) — works for "10♠"
|
||||
but fragile if format changes.
|
||||
|
||||
[MEDIUM] Double down debits second wager but doesn't link ledger memo to session.
|
||||
|
||||
Action: Wrap each resolution path in prisma.$transaction like dice route.
|
||||
|
||||
------------------------------------------------------------------------------
|
||||
4.7 COIN FLIP (REST rooms + Socket.IO) — rooms/route.ts + server.ts
|
||||
------------------------------------------------------------------------------
|
||||
Client: src/components/casino/CoinFlipRoom.tsx
|
||||
|
||||
Flow:
|
||||
- POST /api/games/rooms creates WAITING room (balance check only, no debit)
|
||||
- Creator connects socket, waits
|
||||
- Joiner connects → atomic claim → debit both → instant resolve
|
||||
|
||||
Issues:
|
||||
[HIGH] Payout failure (server.ts lines 129-132): logs error, still emits
|
||||
"result" to clients; room may stay ACTIVE — needs reconciliation job.
|
||||
|
||||
[MEDIUM] Creator never debited until joiner arrives — creator can spam WAITING
|
||||
rooms (DB clutter). Expire job runs every 60s — OK.
|
||||
|
||||
[LOW] Instant resolution — no rematch within room
|
||||
|
||||
[LOW] GameHistory does not show coin flip (no GameSession row)
|
||||
|
||||
Socket auth: server-derived userId — GOOD.
|
||||
|
||||
------------------------------------------------------------------------------
|
||||
4.8 PONG (REST rooms + Socket.IO + setInterval) — server.ts
|
||||
------------------------------------------------------------------------------
|
||||
Client: src/components/casino/PongGame.tsx
|
||||
|
||||
Issues:
|
||||
[HIGH] Game loop setInterval(50ms) per room in Node process — CPU scales with
|
||||
active pong rooms. On 4-core VPS, cap concurrent rooms.
|
||||
|
||||
[HIGH] pongRooms Map: if process crashes mid-game, debits already taken,
|
||||
room may stay ACTIVE, interval orphaned until process exit.
|
||||
|
||||
[MEDIUM] paddle_move has no rate limit — flood events
|
||||
|
||||
[MEDIUM] Client/server paddle collision logic duplicated — desync possible
|
||||
|
||||
[LOW] No GameSession history entry
|
||||
|
||||
Payout path (lines 244-255): improved with async IIFE + logging — still emits
|
||||
game_over even if payout fails.
|
||||
|
||||
------------------------------------------------------------------------------
|
||||
4.9 PREDICTION MARKET — src/app/api/games/prediction/route.ts
|
||||
------------------------------------------------------------------------------
|
||||
Client: src/components/casino/PredictionMarket.tsx
|
||||
|
||||
POST bet: NOW uses single $transaction (debit + ledger + bet + market increment) — GOOD.
|
||||
|
||||
PATCH resolve: transaction with resolvedTo null guard — GOOD.
|
||||
- Pays winners via tx.wallet.upsert — bypasses creditWalletCredits integer guard
|
||||
if payout is 0 skipped; dust handling to first winner — clever
|
||||
|
||||
Issues:
|
||||
[MEDIUM] GET markets: no auth required — OK for public markets
|
||||
[LOW] No GameSession — not in GameHistory
|
||||
[LOW] Creator can resolve early? — throws MARKET_NOT_ENDED if endsAt > now — GOOD
|
||||
|
||||
------------------------------------------------------------------------------
|
||||
4.10 GAME HISTORY — src/app/api/games/history/route.ts + GameHistory.tsx
|
||||
------------------------------------------------------------------------------
|
||||
Issues:
|
||||
[MEDIUM] Client: fetch without res.ok check — silent failure on 401
|
||||
[MEDIUM] Only GameSession rows — PvP and prediction invisible
|
||||
[LOW] No pagination UI (API may support cursor — verify route)
|
||||
|
||||
================================================================================
|
||||
5. API ROUTES — COMPLETE INVENTORY & GAPS
|
||||
================================================================================
|
||||
|
||||
37 HTTP route handlers under src/app/api/:
|
||||
|
||||
Auth: /api/auth/[...nextauth]
|
||||
Register: /api/register
|
||||
Public: /api/public/stats, /api/leaderboard, /api/exchange/rate
|
||||
Wallet: /api/wallet, summary, ledger, donations, community
|
||||
Stripe: create-checkout-session, create-payment-intent, webhooks/stripe
|
||||
Missions: /api/missions, /api/missions/spend
|
||||
Initiatives: /api/initiatives, [slug]/pledge
|
||||
Poll: /api/polls/next-president
|
||||
Rewards: catalog, redeem, raffle
|
||||
Community: /api/billboard, /api/spotlight, /api/cards, /api/faq, /api/boost
|
||||
Games: crash, dice, mines, tower, slots, blackjack, roulette, rooms,
|
||||
prediction, history
|
||||
Threads: /api/threads-exchange
|
||||
|
||||
Validation:
|
||||
[MEDIUM] Game routes use manual req.json() casts — no Zod. Malformed body → 500.
|
||||
[GOOD] FAQ, boost, cards, many others use Zod schemas.
|
||||
|
||||
Missing patterns:
|
||||
- No If-None-Match / caching on public stats (force-dynamic everywhere)
|
||||
- No request ID in logs
|
||||
- Inconsistent error JSON shape { error } vs { issues }
|
||||
|
||||
------------------------------------------------------------------------------
|
||||
5.1 Feature pages vs API (wiring status)
|
||||
------------------------------------------------------------------------------
|
||||
WIRED (page fetches API):
|
||||
/billboard → /api/billboard
|
||||
/spotlight → /api/spotlight
|
||||
/cards → /api/cards
|
||||
/faq-board → /api/faq
|
||||
/boost → /api/boost
|
||||
/casino/* → game APIs
|
||||
/missions → /api/missions
|
||||
/initiatives → /api/initiatives
|
||||
/vote/* → /api/polls/next-president
|
||||
/wallet → wallet APIs
|
||||
/donate → Stripe checkout
|
||||
|
||||
HOMEPAGE mismatch:
|
||||
[MEDIUM] ProgressSection → /api/public/stats (USD Stripe goal meter)
|
||||
Boost page → /api/boost (BWT community epoch meter)
|
||||
Marketing copy may conflate two different "meters" — clarify UX.
|
||||
|
||||
================================================================================
|
||||
6. DATABASE / PRISMA
|
||||
================================================================================
|
||||
|
||||
Schema: prisma/schema.prisma (515 lines, 8 migrations)
|
||||
|
||||
Indexes present:
|
||||
LedgerEntry userId
|
||||
GameSession userId, [userId, gameType]
|
||||
GameRoom status
|
||||
PollVote, Faq*, MovementBoost, etc.
|
||||
|
||||
Missing indexes (performance at scale):
|
||||
[MEDIUM] GameSession.createdAt — history orders by time, filter by user
|
||||
[MEDIUM] GameRoom [status, gameType, expiresAt] composite for lobby listing
|
||||
[MEDIUM] Donation [status, createdAt] for leaderboard aggregations
|
||||
[LOW] LedgerEntry [userId, createdAt] for wallet ledger pagination
|
||||
|
||||
Missing FKs:
|
||||
[MEDIUM] LedgerEntry has no gameSessionId / gameRoomId — hard to audit bets
|
||||
|
||||
Enums: GameType, RoomStatus, LedgerType — comprehensive
|
||||
|
||||
------------------------------------------------------------------------------
|
||||
6.1 Data integrity notes
|
||||
------------------------------------------------------------------------------
|
||||
[GOOD] Wallet debit uses conditional updateMany (balance >= amount)
|
||||
[GOOD] Boost uses Serializable isolation for epoch milestone
|
||||
[GOOD] public/stats and treasury both filter donation status succeeded
|
||||
[MEDIUM] Movement boost milestone race under concurrent POSTs (documented in redmeFIXSES)
|
||||
|
||||
================================================================================
|
||||
7. FRONTEND / UX / MALFORMED PATTERNS
|
||||
================================================================================
|
||||
|
||||
7.1 Global layout performance killers
|
||||
------------------------------------------------------------------------------
|
||||
src/app/layout.tsx loads on EVERY page:
|
||||
- CursorStreamers — full-screen canvas, mousemove listener, RAF loop
|
||||
- SiteNav
|
||||
- framer-motion on Hero and many sections
|
||||
|
||||
src/components/ParticleField.tsx — second full-section canvas on homepage Hero
|
||||
src/components/SmokeWisps.tsx — additional animation layer
|
||||
|
||||
Impact on 8GB Linux VPS:
|
||||
- Constant GPU/CPU use even on /datadeletion legal pages
|
||||
- Battery drain for mobile users
|
||||
- Competes with Node build (npm run build spikes memory)
|
||||
|
||||
Actions:
|
||||
- Load CursorStreamers only on homepage or casino
|
||||
- Respect prefers-reduced-motion (CursorStreamers already skips reduced motion)
|
||||
- Lazy-load framer-motion below fold
|
||||
|
||||
7.2 Polling / duplicate fetches
|
||||
------------------------------------------------------------------------------
|
||||
Endpoints polled from multiple components without shared SWR/React Query:
|
||||
|
||||
/api/exchange/rate — 9+ separate useEffects across site
|
||||
/api/wallet — ExchangePanel every 15s on casino pages
|
||||
/api/public/stats — ProgressSection, HeroLiveStats
|
||||
|
||||
Action: Single client provider for rate + wallet; stale-while-revalidate.
|
||||
|
||||
7.3 Casino page architecture
|
||||
------------------------------------------------------------------------------
|
||||
src/app/casino/[game]/page.tsx:
|
||||
- Server component fetches wallet once — GOOD
|
||||
- ExchangePanel re-fetches wallet every 15s — balance can drift from server
|
||||
- Game components receive static `balance` prop — not updated after wins/losses
|
||||
unless user refreshes or ExchangePanel poll updates (partial fix)
|
||||
|
||||
[MEDIUM] User sees stale balance in wager inputs after big win until poll.
|
||||
|
||||
7.4 Error handling gaps
|
||||
------------------------------------------------------------------------------
|
||||
- GameHistory: no res.ok check
|
||||
- Many fetch().then without .catch — silent network errors
|
||||
- CrashGame tick: empty catch {} swallows errors — intentional comment
|
||||
|
||||
7.5 Accessibility
|
||||
------------------------------------------------------------------------------
|
||||
- Canvas decorations aria-hidden — GOOD
|
||||
- Some icon-only buttons may lack aria-labels in casino grids
|
||||
- Focus management on game phase transitions not tested
|
||||
|
||||
7.6 SEO / metadata
|
||||
------------------------------------------------------------------------------
|
||||
[MEDIUM] layout.tsx alternates.canonical hardcoded to "/" — subpages should
|
||||
override (some do, e.g. privacy-policy)
|
||||
|
||||
[MEDIUM] sitemap.ts missing /casino, /missions, /boost, /billboard, etc.
|
||||
|
||||
[LOW] opengraph-image force-dynamic — regenerates every request
|
||||
|
||||
================================================================================
|
||||
8. STRIPE / PAYMENTS / WALLET
|
||||
================================================================================
|
||||
|
||||
- EmbeddedDonationCheckout → create-checkout-session (primary path)
|
||||
- DonationCheckout → create-payment-intent (UNUSED)
|
||||
- Webhook: src/app/api/webhooks/stripe/route.ts — signature verify — GOOD
|
||||
|
||||
Issues:
|
||||
[HIGH] Ensure webhook endpoint not cached by Cloudflare (POST body must reach origin)
|
||||
[MEDIUM] CREDIT_RATIO_CENTS_PER_USD — verify webhook grants match UI promises
|
||||
[LOW] Guest checkout path — document in FAQ
|
||||
|
||||
================================================================================
|
||||
9. AUTH
|
||||
================================================================================
|
||||
|
||||
- next-auth v5 beta with Credentials provider
|
||||
- Email OR username login — GOOD (lib/account-identifiers.ts)
|
||||
- bcrypt password hashing — GOOD
|
||||
- JWT session with role — GOOD
|
||||
- No OAuth providers configured
|
||||
|
||||
Issues:
|
||||
[MEDIUM] No password reset flow (forgot-password page is static)
|
||||
[MEDIUM] No brute-force lockout on /api/auth/callback/credentials
|
||||
[LOW] ADMIN role only via DB seed
|
||||
|
||||
================================================================================
|
||||
10. TESTING & OBSERVABILITY
|
||||
================================================================================
|
||||
|
||||
Scripts:
|
||||
scripts/smoke-integration.ts — Prisma/DB
|
||||
scripts/http-smoke.sh — curl stats + wallet 401
|
||||
scripts/full-site-test.ts — extensive HTTP checks (manual, server must run)
|
||||
scripts/seed-leaderboard.ts
|
||||
|
||||
Gaps:
|
||||
[HIGH] No automated tests for game math or wallet settlement
|
||||
[HIGH] No CI pipeline evident
|
||||
[MEDIUM] console.error only — no structured logs, no correlation IDs
|
||||
[MEDIUM] No health check endpoint (/api/health) for monitoring
|
||||
[LOW] eslint installed but npm run lint = tsc only
|
||||
|
||||
================================================================================
|
||||
11. LINUX / NODE PERFORMANCE TUNING (THIS MACHINE)
|
||||
================================================================================
|
||||
|
||||
Current resources (audit snapshot):
|
||||
RAM: 7.8 GiB total, ~1.6 GiB used, 6.2 GiB available
|
||||
CPU: 4 cores
|
||||
Disk: 64G volume, 7% used
|
||||
node_modules: 561M
|
||||
.next: 1.1G
|
||||
|
||||
Recommendations:
|
||||
|
||||
A. Process manager
|
||||
- Keep single fundraising-platform.service instance
|
||||
- Add MemoryMax=5G in systemd unit to avoid OOM killing Postgres
|
||||
- Lower NODE_OPTIONS to --max-old-space-size=4096 for steady-state
|
||||
|
||||
B. PostgreSQL (localhost:5432)
|
||||
- Ensure shared_buffers ~256MB, effective_cache_size ~2GB on this VM
|
||||
- Enable log_min_duration_statement = 500 for slow query discovery
|
||||
- Run EXPLAIN on leaderboard + poll aggregation queries
|
||||
|
||||
C. Next.js production
|
||||
- Run `npm run build` after every deploy (already required for new routes)
|
||||
- Consider output: 'standalone' in next.config for smaller runtime footprint
|
||||
- Disable turbopack for production build if instability observed (using default)
|
||||
|
||||
D. Reverse proxy
|
||||
- Prefer cloudflared tunnel → 127.0.0.1:8008 over exposing :8008
|
||||
- Enable gzip/brotli at edge; Next already compresses
|
||||
|
||||
E. Static assets
|
||||
- next.config.ts sets immutable cache for /_next/static — GOOD
|
||||
- Ensure Cloudflare respects cache for hashed assets only
|
||||
|
||||
F. Background jobs
|
||||
- server.ts setInterval 60s for room expiry — OK
|
||||
- Add cron for stuck ACTIVE GameRoom reconciliation
|
||||
|
||||
G. Build/deploy workflow
|
||||
- systemctl restart after git pull + npm run build
|
||||
- Avoid running build while service uses CPU (schedule maintenance window)
|
||||
|
||||
H. Monitoring
|
||||
- journalctl -u fundraising-platform -f
|
||||
- Add node_exporter or simple /api/health returning { ok, db, version }
|
||||
|
||||
================================================================================
|
||||
12. LEGAL / COMPLIANCE PAGES (RECENT)
|
||||
================================================================================
|
||||
|
||||
Added:
|
||||
/privacy-policy — src/app/privacy-policy/page.tsx
|
||||
/datadeletion — src/app/datadeletion/page.tsx
|
||||
/threads-callback — OAuth UI (blocked on live domain by Cloudflare static file)
|
||||
|
||||
Issues:
|
||||
[MEDIUM] legal-contact falls back to privacy@example.com if env unset
|
||||
[LOW] Template disclaimer on pages — counsel review still required
|
||||
[LOW] threads-callback not in sitemap (robots noindex — OK)
|
||||
|
||||
================================================================================
|
||||
13. PACKAGE / DEPENDENCY NOTES
|
||||
================================================================================
|
||||
|
||||
next@16.2.6, react@19.2.6, prisma@7.8.0, next-auth@5.0.0-beta.31
|
||||
socket.io@4.8.3 — matches client
|
||||
framer-motion@12 — large bundle; tree-shake or lazy load
|
||||
zod@4.4.3 — v4 API; ensure team knows breaking changes vs zod 3
|
||||
|
||||
[LOW] npm audit not run in this audit — run `npm audit` separately
|
||||
|
||||
================================================================================
|
||||
14. FILE-BY-FILE HOTSPOT INDEX (QUICK REFERENCE)
|
||||
================================================================================
|
||||
|
||||
server.ts Socket.IO, pong loop, coin flip, room expiry
|
||||
src/middleware.ts matcher [] — auth not global
|
||||
src/auth.ts Credentials only
|
||||
src/app/api/games/blackjack/route.ts NON-ATOMIC payouts — FIX FIRST
|
||||
src/app/api/games/crash/route.ts tick spam, cashout state sync
|
||||
src/app/api/games/mines/route.ts loss race, ledger bypass
|
||||
src/app/api/games/prediction/route.ts resolve logic OK; trust model
|
||||
src/components/DonationCheckout.tsx DELETE (dead)
|
||||
src/components/CursorStreamers.tsx perf — scope to homepage
|
||||
src/components/ParticleField.tsx perf — homepage only
|
||||
src/components/casino/ExchangePanel.tsx 15s polling
|
||||
src/components/casino/CrashGame.tsx 220ms PATCH polling
|
||||
src/components/casino/GameHistory.tsx no error handling
|
||||
src/app/casino/[game]/page.tsx stale balance prop
|
||||
src/lib/game-ledger.ts canonical money API
|
||||
src/lib/provably-fair.ts all RNG
|
||||
prisma/schema.prisma indexes + missing FKs
|
||||
next.config.ts security headers, HSTS preload
|
||||
.env / .env.example URL + Threads vars
|
||||
|
||||
================================================================================
|
||||
15. SUGGESTED FIX ROADMAP (PHASED)
|
||||
================================================================================
|
||||
|
||||
Phase 0 — Today (ops):
|
||||
[ ] Cloudflare: route all paths to Node origin; delete static threads HTML
|
||||
[ ] Fix NEXT_PUBLIC_SITE_URL for bwt.democracyrisingbwt.us
|
||||
[ ] Rotate Threads secret if static page was public
|
||||
[ ] systemctl restart after build
|
||||
|
||||
Phase 1 — Money safety (1-2 days dev):
|
||||
[ ] Blackjack transactions
|
||||
[ ] Coin-flip/pong stuck room reconciler
|
||||
[ ] LedgerEntry.gameSessionId optional FK + backfill
|
||||
|
||||
Phase 2 — Games UX (2-3 days):
|
||||
[ ] Unify game catalog
|
||||
[ ] Fix CrashGame cashout ref
|
||||
[ ] GameHistory error states + PvP rows (or document omission)
|
||||
[ ] Balance sync via context after each game
|
||||
|
||||
Phase 3 — Performance (1-2 days):
|
||||
[ ] Remove/limit CursorStreamers + ParticleField scope
|
||||
[ ] Shared exchange rate provider
|
||||
[ ] Add DB indexes listed above
|
||||
[ ] Lower Node heap to 4GB
|
||||
|
||||
Phase 4 — Hardening (ongoing):
|
||||
[ ] Rate limits
|
||||
[ ] Health endpoint
|
||||
[ ] Integration tests for dice/mines settlement
|
||||
[ ] Delete DonationCheckout.tsx
|
||||
|
||||
================================================================================
|
||||
16. AUDIT METHODOLOGY
|
||||
================================================================================
|
||||
|
||||
Performed:
|
||||
- Full tree listing src/, prisma/, scripts/
|
||||
- tsc --noEmit (pass)
|
||||
- npm run build route list (pass)
|
||||
- Manual read of all game API routes + server.ts + key components
|
||||
- Grep for TODO, console.*, fetch patterns, dead imports
|
||||
- Live HTTP comparison Cloudflare vs 127.0.0.1:8008
|
||||
- Subagent explore pass for architecture map
|
||||
- Cross-check against redmeFIXSES.md (partially outdated)
|
||||
|
||||
Not performed:
|
||||
- npm audit / dependency CVE scan
|
||||
- Load testing (k6/ab) on game endpoints
|
||||
- Stripe live transaction test
|
||||
- Full line-by-line read of all 15k lines (hot paths covered)
|
||||
- PostgreSQL EXPLAIN ANALYZE
|
||||
- Accessibility audit tooling (axe)
|
||||
|
||||
================================================================================
|
||||
END OF AUDIT — 16 sections, ~450+ line items reviewed
|
||||
================================================================================
|
||||
@@ -0,0 +1,26 @@
|
||||
-- Link LedgerEntry rows to their originating GameSession / GameRoom so we can
|
||||
-- reconcile money flow per round/room. Non-unique because a single session/room
|
||||
-- produces multiple ledger lines (debit + win/refund).
|
||||
|
||||
ALTER TABLE "LedgerEntry" ADD COLUMN "gameSessionId" TEXT;
|
||||
ALTER TABLE "LedgerEntry" ADD COLUMN "gameRoomId" TEXT;
|
||||
|
||||
CREATE INDEX "LedgerEntry_gameSessionId_idx" ON "LedgerEntry" ("gameSessionId");
|
||||
CREATE INDEX "LedgerEntry_gameRoomId_idx" ON "LedgerEntry" ("gameRoomId");
|
||||
|
||||
ALTER TABLE "LedgerEntry"
|
||||
ADD CONSTRAINT "LedgerEntry_gameSessionId_fkey"
|
||||
FOREIGN KEY ("gameSessionId") REFERENCES "GameSession"("id")
|
||||
ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
ALTER TABLE "LedgerEntry"
|
||||
ADD CONSTRAINT "LedgerEntry_gameRoomId_fkey"
|
||||
FOREIGN KEY ("gameRoomId") REFERENCES "GameRoom"("id")
|
||||
ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- Helper indexes for resume + sweep + admin queries.
|
||||
CREATE INDEX "GameSession_userId_gameType_outcome_idx"
|
||||
ON "GameSession" ("userId", "gameType", "outcome");
|
||||
|
||||
CREATE INDEX "GameRoom_status_expiresAt_idx" ON "GameRoom" ("status", "expiresAt");
|
||||
CREATE INDEX "GameRoom_creatorId_status_idx" ON "GameRoom" ("creatorId", "status");
|
||||
@@ -167,9 +167,18 @@ model LedgerEntry {
|
||||
movementBoostId String? @unique
|
||||
movementBoost MovementBoost? @relation(fields: [movementBoostId], references: [id])
|
||||
|
||||
/// Optional links for casino/PvP reconciliation — not @unique because a single
|
||||
/// session or room produces multiple ledger lines (debit + payout/refund).
|
||||
gameSessionId String?
|
||||
gameSession GameSession? @relation(fields: [gameSessionId], references: [id], onDelete: SetNull)
|
||||
gameRoomId String?
|
||||
gameRoom GameRoom? @relation(fields: [gameRoomId], references: [id], onDelete: SetNull)
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([userId])
|
||||
@@index([gameSessionId])
|
||||
@@index([gameRoomId])
|
||||
}
|
||||
|
||||
model PollVote {
|
||||
@@ -310,10 +319,12 @@ model GameSession {
|
||||
resultData Json?
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
ledgerEntries LedgerEntry[]
|
||||
|
||||
@@index([userId])
|
||||
@@index([userId, gameType])
|
||||
@@index([userId, gameType, outcome])
|
||||
}
|
||||
|
||||
model GameRoom {
|
||||
@@ -327,7 +338,11 @@ model GameRoom {
|
||||
createdAt DateTime @default(now())
|
||||
expiresAt DateTime
|
||||
|
||||
ledgerEntries LedgerEntry[]
|
||||
|
||||
@@index([status])
|
||||
@@index([status, expiresAt])
|
||||
@@index([creatorId, status])
|
||||
}
|
||||
|
||||
model PredictionMarket {
|
||||
|
||||
@@ -104,6 +104,8 @@ async function testPublicRoutes() {
|
||||
{ path: "/register", minLen: 500 },
|
||||
{ path: "/login", minLen: 300 },
|
||||
{ path: "/forgot-password", minLen: 200 },
|
||||
{ path: "/privacy-policy", minLen: 500 },
|
||||
{ path: "/datadeletion", minLen: 500 },
|
||||
{ path: "/donate", minLen: 500 },
|
||||
{ path: "/donate/thank-you", minLen: 200 },
|
||||
{ path: "/billboard", minLen: 200 },
|
||||
|
||||
175
server.ts
175
server.ts
@@ -3,9 +3,15 @@ import "dotenv/config";
|
||||
import { createServer } from "http";
|
||||
import { parse } from "url";
|
||||
import next from "next";
|
||||
import { getToken } from "next-auth/jwt";
|
||||
import { Server as SocketIOServer } from "socket.io";
|
||||
import { prisma } from "./src/lib/prisma";
|
||||
import { debitForBet, creditForWin, refundBet } from "./src/lib/game-ledger";
|
||||
import {
|
||||
debitForBet,
|
||||
creditForWin,
|
||||
refundBet,
|
||||
refundRoomPartyIfNotRefunded,
|
||||
} from "./src/lib/game-ledger";
|
||||
import { generateServerSeed, deriveInt } from "./src/lib/provably-fair";
|
||||
|
||||
const dev = process.env.NODE_ENV !== "production";
|
||||
@@ -34,14 +40,25 @@ app.prepare().then(() => {
|
||||
// ── COIN FLIP ──────────────────────────────────────────────────────────────
|
||||
const coinFlipNS = io.of("/coin-flip");
|
||||
|
||||
coinFlipNS.on("connection", (socket) => {
|
||||
socket.on("join_room", async ({ roomId, userId }: { roomId: string; userId: string }) => {
|
||||
// Reject obviously spoofed ids — user must exist in DB.
|
||||
const userExists = await prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: { id: true },
|
||||
async function resolveSocketUserId(socket: { request: unknown }): Promise<string | null> {
|
||||
try {
|
||||
const token = await getToken({
|
||||
req: socket.request as Parameters<typeof getToken>[0]["req"],
|
||||
secret: process.env.AUTH_SECRET,
|
||||
});
|
||||
if (!userExists) { socket.emit("error", "Invalid session"); return; }
|
||||
const uid = typeof token?.id === "string" ? token.id : typeof token?.sub === "string" ? token.sub : null;
|
||||
if (!uid) return null;
|
||||
const user = await prisma.user.findUnique({ where: { id: uid }, select: { id: true } });
|
||||
return user?.id ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
coinFlipNS.on("connection", (socket) => {
|
||||
socket.on("join_room", async ({ roomId }: { roomId: string }) => {
|
||||
const userId = await resolveSocketUserId({ request: socket.request });
|
||||
if (!userId) { socket.emit("error", "Invalid session"); return; }
|
||||
|
||||
const room = await prisma.gameRoom.findUnique({ where: { id: roomId } });
|
||||
if (!room || room.status !== "WAITING") {
|
||||
@@ -67,37 +84,16 @@ app.prepare().then(() => {
|
||||
return;
|
||||
}
|
||||
|
||||
// Balance check after claiming to avoid charging then bouncing.
|
||||
const wallet = await prisma.wallet.findUnique({ where: { userId } });
|
||||
if (!wallet || wallet.balanceCredits < room.wageBLW) {
|
||||
// Revert the slot claim.
|
||||
await prisma.gameRoom.update({
|
||||
where: { id: roomId },
|
||||
data: { joinerId: null, status: "WAITING" },
|
||||
});
|
||||
// Joiner is debited at join time. Creator was already debited when the
|
||||
// room was created (POST /api/games/rooms) — do NOT debit again here.
|
||||
try {
|
||||
await debitForBet(userId, room.wageBLW, "COIN_FLIP", undefined, { gameRoomId: roomId });
|
||||
} catch {
|
||||
await prisma.gameRoom.update({ where: { id: roomId }, data: { joinerId: null, status: "WAITING" } });
|
||||
socket.emit("error", "Insufficient balance");
|
||||
return;
|
||||
}
|
||||
|
||||
// Debit joiner.
|
||||
try {
|
||||
await debitForBet(userId, room.wageBLW, "COIN_FLIP");
|
||||
} catch {
|
||||
await prisma.gameRoom.update({ where: { id: roomId }, data: { joinerId: null, status: "WAITING" } });
|
||||
socket.emit("error", "Debit failed");
|
||||
return;
|
||||
}
|
||||
|
||||
// Debit creator.
|
||||
try {
|
||||
await debitForBet(room.creatorId, room.wageBLW, "COIN_FLIP");
|
||||
} catch {
|
||||
await refundBet(userId, room.wageBLW, "COIN_FLIP");
|
||||
await prisma.gameRoom.update({ where: { id: roomId }, data: { joinerId: null, status: "WAITING" } });
|
||||
socket.emit("error", "Creator debit failed");
|
||||
return;
|
||||
}
|
||||
|
||||
socket.join(roomId);
|
||||
coinFlipNS.to(roomId).emit("game_start", { roomId });
|
||||
|
||||
@@ -109,7 +105,7 @@ app.prepare().then(() => {
|
||||
const payout = Math.floor(pot * (1 - HOUSE_CUT));
|
||||
|
||||
try {
|
||||
await creditForWin(winnerId, payout, "COIN_FLIP");
|
||||
await creditForWin(winnerId, payout, "COIN_FLIP", undefined, { gameRoomId: roomId });
|
||||
await prisma.gameRoom.update({
|
||||
where: { id: roomId },
|
||||
data: { status: "RESOLVED", resultData: { result, winnerId, serverSeed, payout } },
|
||||
@@ -143,13 +139,11 @@ app.prepare().then(() => {
|
||||
}>();
|
||||
|
||||
pongNS.on("connection", (socket) => {
|
||||
socket.on("join_room", async ({ roomId, userId }: { roomId: string; userId: string }) => {
|
||||
// Reject obviously spoofed ids.
|
||||
const userExists = await prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: { id: true },
|
||||
});
|
||||
if (!userExists) { socket.emit("error", "Invalid session"); return; }
|
||||
socket.on("join_room", async ({ roomId }: { roomId: string }) => {
|
||||
const userId = await resolveSocketUserId({ request: socket.request });
|
||||
if (!userId) { socket.emit("error", "Invalid session"); return; }
|
||||
|
||||
socket.data.userId = userId;
|
||||
|
||||
const room = await prisma.gameRoom.findUnique({ where: { id: roomId } });
|
||||
if (!room || !["WAITING", "ACTIVE"].includes(room.status)) {
|
||||
@@ -180,21 +174,24 @@ app.prepare().then(() => {
|
||||
const state = pongRooms.get(roomId);
|
||||
if (!state || state.joinerId) { socket.emit("error", "Room full"); return; }
|
||||
|
||||
const wallet = await prisma.wallet.findUnique({ where: { userId } });
|
||||
if (!wallet || wallet.balanceCredits < room.wageBLW) {
|
||||
// Creator is already debited at room creation. Only debit joiner here.
|
||||
try {
|
||||
await debitForBet(userId, room.wageBLW, "PONG", undefined, { gameRoomId: roomId });
|
||||
} catch {
|
||||
socket.emit("error", "Insufficient balance"); return;
|
||||
}
|
||||
|
||||
try {
|
||||
await debitForBet(userId, room.wageBLW, "PONG");
|
||||
await debitForBet(room.creatorId, room.wageBLW, "PONG");
|
||||
} catch {
|
||||
await refundBet(userId, room.wageBLW, "PONG");
|
||||
socket.emit("error", "Debit failed"); return;
|
||||
// Claim the joiner slot atomically (DB is source of truth).
|
||||
const claimed = await prisma.gameRoom.updateMany({
|
||||
where: { id: roomId, status: "WAITING", joinerId: null },
|
||||
data: { joinerId: userId, status: "ACTIVE" },
|
||||
});
|
||||
if (claimed.count !== 1) {
|
||||
await refundBet(userId, room.wageBLW, "PONG", undefined, { gameRoomId: roomId });
|
||||
socket.emit("error", "Room not available"); return;
|
||||
}
|
||||
|
||||
state.joinerId = userId;
|
||||
await prisma.gameRoom.update({ where: { id: roomId }, data: { joinerId: userId, status: "ACTIVE" } });
|
||||
pongNS.to(roomId).emit("game_start", { roomId, creatorId: room.creatorId, joinerId: userId });
|
||||
|
||||
// Game loop — 20 ticks/sec.
|
||||
@@ -233,7 +230,7 @@ app.prepare().then(() => {
|
||||
// not silently swallowed.
|
||||
void (async () => {
|
||||
try {
|
||||
await creditForWin(winnerId, payout, "PONG");
|
||||
await creditForWin(winnerId, payout, "PONG", undefined, { gameRoomId: roomId });
|
||||
await prisma.gameRoom.update({
|
||||
where: { id: roomId },
|
||||
data: { status: "RESOLVED", resultData: { winnerId, scores: finalScores, payout } },
|
||||
@@ -247,24 +244,78 @@ app.prepare().then(() => {
|
||||
}, 50);
|
||||
});
|
||||
|
||||
socket.on("paddle_move", ({ roomId, userId, y }: { roomId: string; userId: string; y: number }) => {
|
||||
socket.on("paddle_move", ({ roomId, y }: { roomId: string; y: number }) => {
|
||||
const state = pongRooms.get(roomId);
|
||||
if (!state) return;
|
||||
const userId = typeof socket.data.userId === "string" ? socket.data.userId : null;
|
||||
if (!userId) return;
|
||||
const clampedY = Math.max(0, Math.min(360, y));
|
||||
if (state.creatorId === userId) state.paddles.creator = clampedY;
|
||||
else if (state.joinerId === userId) state.paddles.joiner = clampedY;
|
||||
});
|
||||
});
|
||||
|
||||
// Expire abandoned rooms every minute.
|
||||
setInterval(async () => {
|
||||
await prisma.gameRoom.updateMany({
|
||||
// ── Expire abandoned WAITING rooms and refund the creator's locked stake ──
|
||||
async function sweepExpiredRooms() {
|
||||
const stale = await prisma.gameRoom.findMany({
|
||||
where: { status: "WAITING", expiresAt: { lt: new Date() } },
|
||||
data: { status: "EXPIRED" },
|
||||
select: { id: true, creatorId: true, wageBLW: true, gameType: true },
|
||||
});
|
||||
}, 60_000);
|
||||
for (const r of stale) {
|
||||
// Move WAITING → EXPIRED atomically; only one sweep per room wins.
|
||||
const flipped = await prisma.gameRoom.updateMany({
|
||||
where: { id: r.id, status: "WAITING" },
|
||||
data: { status: "EXPIRED" },
|
||||
});
|
||||
if (flipped.count !== 1) continue;
|
||||
try {
|
||||
await refundRoomPartyIfNotRefunded(r.id, r.creatorId, r.wageBLW, r.gameType);
|
||||
} catch (err) {
|
||||
console.error("[room-expiry] refund failed for", r.id, err);
|
||||
}
|
||||
}
|
||||
}
|
||||
setInterval(() => { void sweepExpiredRooms(); }, 60_000);
|
||||
|
||||
httpServer.listen(8008, "0.0.0.0", () => {
|
||||
console.log(`> Ready on http://0.0.0.0:8008`);
|
||||
});
|
||||
// ── Boot recovery: any room that was ACTIVE/WAITING when the process died
|
||||
// has no in-memory state to resume from. Refund all parties and mark EXPIRED
|
||||
// so players get their BLW back and rooms don't appear as full/playable.
|
||||
async function recoverOrphanedRoomsOnBoot() {
|
||||
const orphans = await prisma.gameRoom.findMany({
|
||||
where: {
|
||||
OR: [
|
||||
{ status: "ACTIVE", gameType: "PONG" },
|
||||
// WAITING rooms past expiry caught here too.
|
||||
{ status: "WAITING", expiresAt: { lt: new Date() } },
|
||||
],
|
||||
},
|
||||
select: { id: true, creatorId: true, joinerId: true, wageBLW: true, gameType: true, status: true },
|
||||
});
|
||||
for (const r of orphans) {
|
||||
const flipped = await prisma.gameRoom.updateMany({
|
||||
where: { id: r.id, status: r.status },
|
||||
data: { status: "EXPIRED" },
|
||||
});
|
||||
if (flipped.count !== 1) continue;
|
||||
try {
|
||||
await refundRoomPartyIfNotRefunded(r.id, r.creatorId, r.wageBLW, r.gameType);
|
||||
if (r.joinerId) {
|
||||
// Joiner debit also points to gameRoomId — same idempotent guard works.
|
||||
await refundRoomPartyIfNotRefunded(r.id, r.joinerId, r.wageBLW, r.gameType);
|
||||
}
|
||||
console.log(`[boot-recovery] refunded room ${r.id} (${r.gameType})`);
|
||||
} catch (err) {
|
||||
console.error("[boot-recovery] refund failed for", r.id, err);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Run recovery to completion BEFORE we start accepting traffic — otherwise
|
||||
// new clients could race against rooms we're about to mark EXPIRED.
|
||||
recoverOrphanedRoomsOnBoot()
|
||||
.catch((err) => console.error("[boot-recovery] sweep failed:", err))
|
||||
.finally(() => {
|
||||
httpServer.listen(8008, "0.0.0.0", () => {
|
||||
console.log(`> Ready on http://0.0.0.0:8008`);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
115
src/app/api/games/active/route.ts
Normal file
115
src/app/api/games/active/route.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { auth } from "@/auth";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { hashServerSeed } from "@/lib/provably-fair";
|
||||
import type { GameType } from "@prisma/client";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/**
|
||||
* Active-state endpoint used for:
|
||||
* - Solo game resume (single gameType — returns one session + sanitized state)
|
||||
* - Wallet/lobby "you have unfinished games" banner (no gameType — bulk)
|
||||
*
|
||||
* Never returns serverSeed or hidden board contents (mines/bomb positions) for
|
||||
* still-active rounds; only a hash and player-visible progress.
|
||||
*/
|
||||
export async function GET(req: NextRequest) {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
|
||||
const url = new URL(req.url);
|
||||
const gameType = url.searchParams.get("gameType");
|
||||
|
||||
if (gameType) {
|
||||
const gs = await prisma.gameSession.findFirst({
|
||||
where: {
|
||||
userId: session.user.id,
|
||||
gameType: gameType as GameType,
|
||||
outcome: "active",
|
||||
},
|
||||
orderBy: { createdAt: "desc" },
|
||||
});
|
||||
const room = await prisma.gameRoom.findFirst({
|
||||
where: {
|
||||
gameType: gameType as GameType,
|
||||
OR: [
|
||||
{ creatorId: session.user.id, status: "WAITING" },
|
||||
{ creatorId: session.user.id, status: "ACTIVE" },
|
||||
{ joinerId: session.user.id, status: "ACTIVE" },
|
||||
],
|
||||
},
|
||||
orderBy: { createdAt: "desc" },
|
||||
});
|
||||
return NextResponse.json({
|
||||
session: gs ? formatSession(gs) : null,
|
||||
room,
|
||||
});
|
||||
}
|
||||
|
||||
// Bulk: every active solo session + every live room for this user.
|
||||
const [sessions, rooms] = await Promise.all([
|
||||
prisma.gameSession.findMany({
|
||||
where: { userId: session.user.id, outcome: "active" },
|
||||
orderBy: { createdAt: "desc" },
|
||||
}),
|
||||
prisma.gameRoom.findMany({
|
||||
where: {
|
||||
OR: [
|
||||
{ creatorId: session.user.id, status: { in: ["WAITING", "ACTIVE"] } },
|
||||
{ joinerId: session.user.id, status: "ACTIVE" },
|
||||
],
|
||||
},
|
||||
orderBy: { createdAt: "desc" },
|
||||
}),
|
||||
]);
|
||||
|
||||
return NextResponse.json({
|
||||
sessions: sessions.map(formatSession),
|
||||
rooms,
|
||||
});
|
||||
}
|
||||
|
||||
function formatSession(gs: {
|
||||
id: string;
|
||||
gameType: GameType;
|
||||
wageredBLW: number;
|
||||
serverSeed: string;
|
||||
clientSeed: string | null;
|
||||
resultData: unknown;
|
||||
createdAt: Date;
|
||||
}) {
|
||||
return {
|
||||
id: gs.id,
|
||||
gameType: gs.gameType,
|
||||
wageredBLW: gs.wageredBLW,
|
||||
serverSeedHash: hashServerSeed(gs.serverSeed),
|
||||
clientSeed: gs.clientSeed,
|
||||
resultData: sanitizeResultData(gs.gameType, gs.resultData),
|
||||
createdAt: gs.createdAt,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Strip server-only info (e.g. mine/bomb positions) from resultData before
|
||||
* sending back to the client. Players must never see unrevealed positions.
|
||||
*/
|
||||
function sanitizeResultData(gameType: GameType, raw: unknown): unknown {
|
||||
if (!raw || typeof raw !== "object") return null;
|
||||
const d = raw as Record<string, unknown>;
|
||||
switch (gameType) {
|
||||
case "MINES": {
|
||||
return {
|
||||
revealed: Array.isArray(d.revealed) ? d.revealed : [],
|
||||
mineCount: typeof d.mineCount === "number" ? d.mineCount : 0,
|
||||
};
|
||||
}
|
||||
case "TOWER": {
|
||||
return {
|
||||
currentFloor: typeof d.currentFloor === "number" ? d.currentFloor : 0,
|
||||
};
|
||||
}
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -64,11 +64,12 @@ export async function POST(req: NextRequest) {
|
||||
}
|
||||
|
||||
const playerVal = handValue(playerHand);
|
||||
let dealerVal: number | undefined;
|
||||
let outcome = "active";
|
||||
|
||||
if (playerVal === 21) {
|
||||
// Natural blackjack — check dealer
|
||||
const dealerVal = handValue(dealerHand);
|
||||
dealerVal = handValue(dealerHand);
|
||||
if (dealerVal === 21) {
|
||||
outcome = "push";
|
||||
} else {
|
||||
@@ -92,12 +93,12 @@ export async function POST(req: NextRequest) {
|
||||
const payout = Math.floor(wageBLW * 2.5);
|
||||
await creditForWin(session.user.id, payout, "BLACKJACK");
|
||||
await prisma.gameSession.update({ where: { id: gs.id }, data: { outcome: "blackjack", multiplier: 2.5, payoutBLW: payout } });
|
||||
return NextResponse.json({ roundId: gs.id, playerHand, dealerVisible: [dealerHand[0]], outcome: "blackjack", payout, playerVal });
|
||||
return NextResponse.json({ roundId: gs.id, playerHand, dealerVisible: [dealerHand[0]], dealerHand, outcome: "blackjack", payout, playerVal, dealerVal });
|
||||
}
|
||||
if (outcome === "push") {
|
||||
await refundBet(session.user.id, wageBLW, "BLACKJACK");
|
||||
await prisma.gameSession.update({ where: { id: gs.id }, data: { outcome: "push", multiplier: 1, payoutBLW: wageBLW } });
|
||||
return NextResponse.json({ roundId: gs.id, playerHand, dealerVisible: [dealerHand[0]], outcome: "push", payout: wageBLW, playerVal });
|
||||
return NextResponse.json({ roundId: gs.id, playerHand, dealerVisible: [dealerHand[0]], dealerHand, outcome: "push", payout: wageBLW, playerVal, dealerVal });
|
||||
}
|
||||
|
||||
return NextResponse.json({ roundId: gs.id, playerHand, dealerVisible: [dealerHand[0]], outcome: "active", playerVal });
|
||||
|
||||
@@ -7,7 +7,7 @@ import { creditWalletCredits } from "@/lib/wallet-safety";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
// POST /api/games/crash — start a crash round, returns serverSeedHash + roundId
|
||||
// POST /api/games/crash — start a round, returns commit hash + round id.
|
||||
export async function POST(req: NextRequest) {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
@@ -42,16 +42,32 @@ export async function POST(req: NextRequest) {
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json({ roundId: gs.id, serverSeedHash: seedHash, crashAt });
|
||||
return NextResponse.json({ roundId: gs.id, serverSeedHash: seedHash });
|
||||
}
|
||||
|
||||
// PATCH /api/games/crash — cash out at current multiplier
|
||||
async function settleCrashLoss(roundId: string, userId: string, crashAt: number, serverSeed: string) {
|
||||
const settled = await prisma.gameSession.updateMany({
|
||||
where: { id: roundId, userId, outcome: "active" },
|
||||
data: { outcome: "loss", multiplier: crashAt, payoutBLW: 0 },
|
||||
});
|
||||
if (settled.count !== 1) {
|
||||
return NextResponse.json({ error: "Round not found or already settled" }, { status: 404 });
|
||||
}
|
||||
return NextResponse.json({ outcome: "loss", crashAt, payout: 0, serverSeed });
|
||||
}
|
||||
|
||||
// PATCH /api/games/crash — tick status or cash out.
|
||||
export async function PATCH(req: NextRequest) {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
|
||||
const body = await req.json();
|
||||
const { roundId, cashoutAt } = body as { roundId: string; cashoutAt: number };
|
||||
const { roundId, cashoutAt, action, currentMultiplier } = body as {
|
||||
roundId: string;
|
||||
cashoutAt?: number;
|
||||
action?: "tick" | "cashout";
|
||||
currentMultiplier?: number;
|
||||
};
|
||||
|
||||
const gs = await prisma.gameSession.findUnique({ where: { id: roundId } });
|
||||
if (!gs || gs.userId !== session.user.id || gs.outcome !== "active") {
|
||||
@@ -60,13 +76,25 @@ export async function PATCH(req: NextRequest) {
|
||||
|
||||
const { crashAt } = gs.resultData as { crashAt: number };
|
||||
|
||||
if (cashoutAt > crashAt) {
|
||||
// Player cashed out after crash — they lose
|
||||
await prisma.gameSession.update({
|
||||
where: { id: roundId },
|
||||
data: { outcome: "loss", multiplier: crashAt, payoutBLW: 0 },
|
||||
});
|
||||
return NextResponse.json({ outcome: "loss", crashAt, payout: 0 });
|
||||
const mode: "tick" | "cashout" = action ?? "cashout";
|
||||
|
||||
if (mode === "tick") {
|
||||
if (typeof currentMultiplier !== "number" || !Number.isFinite(currentMultiplier) || currentMultiplier < 1) {
|
||||
return NextResponse.json({ error: "Invalid multiplier probe" }, { status: 400 });
|
||||
}
|
||||
if (currentMultiplier >= crashAt) {
|
||||
return settleCrashLoss(roundId, session.user.id, crashAt, gs.serverSeed ?? "");
|
||||
}
|
||||
return NextResponse.json({ outcome: "active", crashed: false });
|
||||
}
|
||||
|
||||
if (typeof cashoutAt !== "number" || !Number.isFinite(cashoutAt) || cashoutAt < 1) {
|
||||
return NextResponse.json({ error: "Invalid cashout value" }, { status: 400 });
|
||||
}
|
||||
|
||||
if (cashoutAt >= crashAt) {
|
||||
// Player cashed out after crash — settle as a loss.
|
||||
return settleCrashLoss(roundId, session.user.id, crashAt, gs.serverSeed ?? "");
|
||||
}
|
||||
|
||||
const multiplier = Math.max(1.0, cashoutAt);
|
||||
|
||||
@@ -120,6 +120,10 @@ export async function PATCH(req: NextRequest) {
|
||||
include: { bets: true },
|
||||
});
|
||||
|
||||
if (market.endsAt > new Date()) {
|
||||
throw new Error("MARKET_NOT_ENDED");
|
||||
}
|
||||
|
||||
const totalPot = market.totalYes + market.totalNo;
|
||||
const winnerBets = market.bets.filter((b) => b.side === resolvedTo);
|
||||
const winnerTotal = winnerBets.reduce((s, b) => s + b.blwAmount, 0);
|
||||
@@ -170,6 +174,9 @@ export async function PATCH(req: NextRequest) {
|
||||
|
||||
return NextResponse.json({ resolved: true, resolvedTo, ...outcome });
|
||||
} catch (e) {
|
||||
if (e instanceof Error && e.message === "MARKET_NOT_ENDED") {
|
||||
return NextResponse.json({ error: "Market cannot be resolved before its close time." }, { status: 400 });
|
||||
}
|
||||
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === "P2025") {
|
||||
return NextResponse.json({ error: "Market not found, already resolved, or not authorized." }, { status: 409 });
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { auth } from "@/auth";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { debitWalletCredits, INSUFFICIENT_CREDITS } from "@/lib/wallet-safety";
|
||||
import { refundRoomPartyIfNotRefunded } from "@/lib/game-ledger";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -31,22 +33,64 @@ export async function POST(req: NextRequest) {
|
||||
if (!Number.isInteger(wageBLW) || wageBLW < 1) return NextResponse.json({ error: "Invalid wager" }, { status: 400 });
|
||||
if (!["COIN_FLIP", "PONG"].includes(gameType)) return NextResponse.json({ error: "Invalid game type" }, { status: 400 });
|
||||
|
||||
// Check balance
|
||||
const wallet = await prisma.wallet.findUnique({ where: { userId: session.user.id } });
|
||||
if (!wallet || wallet.balanceCredits < wageBLW) {
|
||||
return NextResponse.json({ error: "Insufficient balance" }, { status: 402 });
|
||||
}
|
||||
|
||||
const userId = session.user.id;
|
||||
const expiresAt = new Date(Date.now() + 5 * 60 * 1000); // 5 min
|
||||
|
||||
const room = await prisma.gameRoom.create({
|
||||
data: {
|
||||
gameType,
|
||||
creatorId: session.user.id,
|
||||
wageBLW,
|
||||
expiresAt,
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json({ room });
|
||||
// Lock the creator's funds atomically with room creation so two browser tabs
|
||||
// can't double-spend the same balance. The ledger entry references the room
|
||||
// for later refund / reconciliation.
|
||||
try {
|
||||
const room = await prisma.$transaction(async (tx) => {
|
||||
await debitWalletCredits(tx, userId, wageBLW);
|
||||
const r = await tx.gameRoom.create({
|
||||
data: { gameType, creatorId: userId, wageBLW, expiresAt },
|
||||
});
|
||||
await tx.ledgerEntry.create({
|
||||
data: {
|
||||
userId,
|
||||
delta: -wageBLW,
|
||||
type: "DEBIT_GAME_BET",
|
||||
memo: `${gameType} room lock`,
|
||||
gameRoomId: r.id,
|
||||
},
|
||||
});
|
||||
return r;
|
||||
});
|
||||
return NextResponse.json({ room });
|
||||
} catch (e) {
|
||||
if (e instanceof Error && e.message === INSUFFICIENT_CREDITS) {
|
||||
return NextResponse.json({ error: "Insufficient balance" }, { status: 402 });
|
||||
}
|
||||
console.error("[rooms.POST] failed:", e);
|
||||
return NextResponse.json({ error: "Could not create room" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
// Creator-initiated cancel — only works while the room is still WAITING.
|
||||
// Refunds the locked stake idempotently.
|
||||
export async function DELETE(req: NextRequest) {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
|
||||
const url = new URL(req.url);
|
||||
const roomId = url.searchParams.get("id");
|
||||
if (!roomId) return NextResponse.json({ error: "Missing room id" }, { status: 400 });
|
||||
|
||||
const room = await prisma.gameRoom.findUnique({ where: { id: roomId } });
|
||||
if (!room) return NextResponse.json({ error: "Room not found" }, { status: 404 });
|
||||
if (room.creatorId !== session.user.id) {
|
||||
return NextResponse.json({ error: "Not your room" }, { status: 403 });
|
||||
}
|
||||
|
||||
// Atomically move WAITING → EXPIRED; only one cancel wins the race.
|
||||
const cancelled = await prisma.gameRoom.updateMany({
|
||||
where: { id: roomId, status: "WAITING" },
|
||||
data: { status: "EXPIRED" },
|
||||
});
|
||||
if (cancelled.count !== 1) {
|
||||
return NextResponse.json({ error: "Room not cancellable" }, { status: 409 });
|
||||
}
|
||||
|
||||
await refundRoomPartyIfNotRefunded(roomId, room.creatorId, room.wageBLW, room.gameType);
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
|
||||
62
src/app/api/threads-exchange/route.ts
Normal file
62
src/app/api/threads-exchange/route.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
const THREADS_TOKEN_URL = "https://graph.threads.net/oauth/access_token";
|
||||
const THREADS_LONG_LIVED_TOKEN_URL = "https://graph.threads.net/access_token";
|
||||
const DEFAULT_REDIRECT_URI = "https://bwt.democracyrisingbwt.us/threads-callback";
|
||||
|
||||
export async function POST(req: Request) {
|
||||
const clientId = process.env.THREADS_CLIENT_ID;
|
||||
const clientSecret = process.env.THREADS_CLIENT_SECRET;
|
||||
const redirectUri = process.env.THREADS_REDIRECT_URI ?? DEFAULT_REDIRECT_URI;
|
||||
|
||||
if (!clientId || !clientSecret) {
|
||||
return NextResponse.json({ error: "Threads OAuth is not configured." }, { status: 500 });
|
||||
}
|
||||
|
||||
let code: unknown;
|
||||
try {
|
||||
const body = await req.json();
|
||||
code = body?.code;
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Invalid JSON body." }, { status: 400 });
|
||||
}
|
||||
|
||||
if (typeof code !== "string" || !code.trim()) {
|
||||
return NextResponse.json({ error: "Missing authorization code." }, { status: 400 });
|
||||
}
|
||||
|
||||
const tokenRes = await fetch(THREADS_TOKEN_URL, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
body: new URLSearchParams({
|
||||
client_id: clientId,
|
||||
client_secret: clientSecret,
|
||||
grant_type: "authorization_code",
|
||||
redirect_uri: redirectUri,
|
||||
code,
|
||||
}),
|
||||
});
|
||||
|
||||
const tokenData = await tokenRes.json();
|
||||
if (!tokenRes.ok || tokenData.error) {
|
||||
return NextResponse.json(tokenData, { status: tokenRes.status || 400 });
|
||||
}
|
||||
|
||||
const accessToken = tokenData.access_token;
|
||||
if (typeof accessToken !== "string" || !accessToken) {
|
||||
return NextResponse.json({ error: "Threads did not return an access token.", tokenData }, { status: 502 });
|
||||
}
|
||||
|
||||
const longUrl = new URL(THREADS_LONG_LIVED_TOKEN_URL);
|
||||
longUrl.searchParams.set("grant_type", "th_exchange_token");
|
||||
longUrl.searchParams.set("client_secret", clientSecret);
|
||||
longUrl.searchParams.set("access_token", accessToken);
|
||||
|
||||
const longRes = await fetch(longUrl);
|
||||
const longData = await longRes.json();
|
||||
if (!longRes.ok || longData.error) {
|
||||
return NextResponse.json(longData, { status: longRes.status || 400 });
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true, ...longData });
|
||||
}
|
||||
@@ -128,7 +128,7 @@ export default function BillboardPage() {
|
||||
<h2 className="text-lg font-semibold text-white">Post your message</h2>
|
||||
{!session ? (
|
||||
<p className="mt-4 text-slate-400">
|
||||
<Link href="/login" className="text-sky-300 hover:underline">Sign in</Link> to post to the Billboard.
|
||||
<Link href="/login?callbackUrl=%2Fbillboard" className="text-sky-300 hover:underline">Sign in</Link> to post to the Billboard.
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
|
||||
@@ -171,7 +171,7 @@ export default function CardsPage() {
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-10 rounded-3xl border border-white/10 bg-white/[0.03] p-6">
|
||||
<p className="text-slate-400"><Link href="/register" className="text-sky-300 hover:underline">Join Democracy Rising</Link> to mint your supporter card.</p>
|
||||
<p className="text-slate-400"><Link href="/register?callbackUrl=%2Fcards" className="text-sky-300 hover:underline">Join Democracy Rising</Link> to mint your supporter card.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -63,12 +63,20 @@ export default async function GamePage({ params }: { params: Promise<{ game: str
|
||||
<>
|
||||
<main className="min-h-screen bg-[#030712] px-4 py-8 sm:px-6">
|
||||
<div className="mx-auto max-w-6xl">
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<Link href="/casino" className="text-slate-400 hover:text-white transition-colors text-sm">
|
||||
← Casino
|
||||
<div className="mb-6 flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-3 text-sm">
|
||||
<Link href="/casino" className="text-slate-400 hover:text-white transition-colors">
|
||||
← Casino
|
||||
</Link>
|
||||
<span className="text-slate-600">/</span>
|
||||
<span className="text-white font-medium">{meta.name}</span>
|
||||
</div>
|
||||
<Link
|
||||
href="/wallet"
|
||||
className="rounded-full border border-white/15 px-3.5 py-1.5 text-xs font-medium text-slate-200 hover:bg-white/5"
|
||||
>
|
||||
Open wallet →
|
||||
</Link>
|
||||
<span className="text-slate-600">/</span>
|
||||
<span className="text-white font-medium">{meta.name}</span>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
|
||||
@@ -6,6 +6,7 @@ import { creditDisplayName, creditTicker } from "@/lib/credits-brand";
|
||||
import { SiteFooter } from "@/components/SiteFooter";
|
||||
import { ExchangePanel } from "@/components/casino/ExchangePanel";
|
||||
import { GameHistory } from "@/components/casino/GameHistory";
|
||||
import { ActiveGamesBanner } from "@/components/casino/ActiveGamesBanner";
|
||||
|
||||
const T = creditTicker();
|
||||
const CREDIT_LONG = creditDisplayName();
|
||||
@@ -53,6 +54,8 @@ export default async function CasinoLobby() {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<ActiveGamesBanner userId={session.user.id} />
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
{/* Exchange Panel + History */}
|
||||
<div className="space-y-5">
|
||||
|
||||
124
src/app/datadeletion/page.tsx
Normal file
124
src/app/datadeletion/page.tsx
Normal file
@@ -0,0 +1,124 @@
|
||||
import { LegalPageLayout, LegalSection } from "@/components/LegalPageLayout";
|
||||
import { legalContactEmail } from "@/lib/legal-contact";
|
||||
import { appTitle, siteUrl } from "@/lib/public-env";
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
|
||||
const LAST_UPDATED = "May 19, 2026";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Data Deletion",
|
||||
description: `How to request deletion of personal data from ${appTitle()}.`,
|
||||
alternates: { canonical: "/datadeletion" },
|
||||
robots: { index: true, follow: true },
|
||||
};
|
||||
|
||||
export default function DataDeletionPage() {
|
||||
const site = appTitle();
|
||||
const url = siteUrl();
|
||||
const email = legalContactEmail();
|
||||
|
||||
return (
|
||||
<LegalPageLayout
|
||||
title="Data Deletion Instructions"
|
||||
description={`This page explains how to request deletion of personal data associated with your use of ${site}.`}
|
||||
lastUpdated={LAST_UPDATED}
|
||||
>
|
||||
<LegalSection title="Overview">
|
||||
<p>
|
||||
We respect your right to control your personal information. You may request deletion of account-related data we hold,
|
||||
subject to exceptions described below. For broader information about how we handle data, see our{" "}
|
||||
<Link href="/privacy-policy" className="text-sky-300 hover:text-sky-200">
|
||||
Privacy Policy
|
||||
</Link>
|
||||
.
|
||||
</p>
|
||||
</LegalSection>
|
||||
|
||||
<LegalSection title="How to submit a deletion request">
|
||||
<p>Send an email to:</p>
|
||||
<p className="rounded-xl border border-white/10 bg-white/5 px-4 py-3 font-mono text-sky-200">
|
||||
<a href={`mailto:${email}?subject=Data%20deletion%20request`}>{email}</a>
|
||||
</p>
|
||||
<p>Include the following so we can locate your records:</p>
|
||||
<ul className="list-disc space-y-2 pl-5 text-slate-400">
|
||||
<li>Subject line: "Data deletion request"</li>
|
||||
<li>The email address associated with your account (if any)</li>
|
||||
<li>Your full name as shown on the account</li>
|
||||
<li>A brief description of what you want deleted (account, profile, activity history, etc.)</li>
|
||||
</ul>
|
||||
<p>
|
||||
If you signed in with a third-party provider, mention that provider and the email or identifier linked to your account.
|
||||
</p>
|
||||
</LegalSection>
|
||||
|
||||
<LegalSection title="What we delete">
|
||||
<p>When your request is verified and approved, we will delete or anonymize, where feasible:</p>
|
||||
<ul className="list-disc space-y-2 pl-5 text-slate-400">
|
||||
<li>Account profile information (name, email, preferences);</li>
|
||||
<li>Supporter wallet and in-platform activity tied to your user ID;</li>
|
||||
<li>Game play history and non-financial engagement logs linked to your account;</li>
|
||||
<li>Other personal data stored in our systems that is not subject to a retention exception.</li>
|
||||
</ul>
|
||||
</LegalSection>
|
||||
|
||||
<LegalSection title="What we may retain">
|
||||
<p>
|
||||
Some information may be retained when required or permitted by law, including for fraud prevention, security,
|
||||
dispute resolution, and compliance with legal or regulatory obligations (for example campaign finance record-keeping
|
||||
for processed contributions). Retained data is limited to what is necessary and protected appropriately.
|
||||
</p>
|
||||
<p>
|
||||
Payment processors may also retain transaction records under their own policies; contact them directly for
|
||||
processor-held data where applicable.
|
||||
</p>
|
||||
</LegalSection>
|
||||
|
||||
<LegalSection title="Processing time">
|
||||
<p>
|
||||
We aim to acknowledge requests within a reasonable period and complete verified deletion within approximately 30 days,
|
||||
unless a longer period is required by law or the complexity of the request. We will inform you if we need additional
|
||||
time or information.
|
||||
</p>
|
||||
</LegalSection>
|
||||
|
||||
<LegalSection title="Identity verification">
|
||||
<p>
|
||||
To protect your privacy, we may ask you to verify ownership of the account (for example by replying from the registered
|
||||
email address or providing information only the account holder would know). We will not fulfill deletion requests that
|
||||
we cannot reasonably verify.
|
||||
</p>
|
||||
</LegalSection>
|
||||
|
||||
<LegalSection title="Deleting your account yourself">
|
||||
<p>
|
||||
If the site offers in-app account closure, you may use that feature for immediate deactivation. You may still email us
|
||||
to confirm complete deletion of remaining personal data.
|
||||
</p>
|
||||
</LegalSection>
|
||||
|
||||
<LegalSection title="Third-party platforms">
|
||||
<p>
|
||||
If you connected this service through a third-party login or platform, you may also need to revoke access or delete
|
||||
data held by that third party through their own settings.
|
||||
</p>
|
||||
</LegalSection>
|
||||
|
||||
<LegalSection title="Questions">
|
||||
<p>
|
||||
For privacy questions or to appeal a decision regarding your request, contact{" "}
|
||||
<a href={`mailto:${email}`} className="text-sky-300 hover:text-sky-200">
|
||||
{email}
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
<p className="text-slate-500">
|
||||
{site} ·{" "}
|
||||
<a href={url} className="text-sky-300 hover:text-sky-200">
|
||||
{url}
|
||||
</a>
|
||||
</p>
|
||||
</LegalSection>
|
||||
</LegalPageLayout>
|
||||
);
|
||||
}
|
||||
165
src/app/privacy-policy/page.tsx
Normal file
165
src/app/privacy-policy/page.tsx
Normal file
@@ -0,0 +1,165 @@
|
||||
import { LegalPageLayout, LegalSection } from "@/components/LegalPageLayout";
|
||||
import { legalContactEmail } from "@/lib/legal-contact";
|
||||
import { appTitle, siteUrl } from "@/lib/public-env";
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
|
||||
const LAST_UPDATED = "May 19, 2026";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Privacy Policy",
|
||||
description: `How ${appTitle()} collects, uses, and protects personal information.`,
|
||||
alternates: { canonical: "/privacy-policy" },
|
||||
robots: { index: true, follow: true },
|
||||
};
|
||||
|
||||
export default function PrivacyPolicyPage() {
|
||||
const site = appTitle();
|
||||
const url = siteUrl();
|
||||
const email = legalContactEmail();
|
||||
|
||||
return (
|
||||
<LegalPageLayout
|
||||
title="Privacy Policy"
|
||||
description={`This policy describes how ${site} ("we", "us", or "our") handles information when you visit ${url} or use our services.`}
|
||||
lastUpdated={LAST_UPDATED}
|
||||
>
|
||||
<LegalSection title="1. Scope">
|
||||
<p>
|
||||
This Privacy Policy applies to visitors and registered users of our website and related online services. By using the
|
||||
site, you agree to the practices described here. If you do not agree, please discontinue use.
|
||||
</p>
|
||||
</LegalSection>
|
||||
|
||||
<LegalSection title="2. Information we collect">
|
||||
<p>Depending on how you interact with the site, we may collect:</p>
|
||||
<ul className="list-disc space-y-2 pl-5 text-slate-400">
|
||||
<li>
|
||||
<strong className="text-slate-200">Account information</strong> — such as name, email address, and credentials you
|
||||
provide when registering or signing in.
|
||||
</li>
|
||||
<li>
|
||||
<strong className="text-slate-200">Transaction information</strong> — such as donation amounts, payment status, and
|
||||
records required to process contributions through our payment processor.
|
||||
</li>
|
||||
<li>
|
||||
<strong className="text-slate-200">Usage information</strong> — such as pages viewed, features used, device type,
|
||||
browser, IP address, and approximate location derived from IP.
|
||||
</li>
|
||||
<li>
|
||||
<strong className="text-slate-200">Communications</strong> — such as messages you send to us for support or account
|
||||
assistance.
|
||||
</li>
|
||||
</ul>
|
||||
</LegalSection>
|
||||
|
||||
<LegalSection title="3. How we use information">
|
||||
<p>We use collected information to:</p>
|
||||
<ul className="list-disc space-y-2 pl-5 text-slate-400">
|
||||
<li>Operate, secure, and improve the website and its features;</li>
|
||||
<li>Process donations and maintain supporter accounts;</li>
|
||||
<li>Comply with legal, regulatory, and record-keeping obligations;</li>
|
||||
<li>Respond to inquiries and provide customer support;</li>
|
||||
<li>Detect fraud, abuse, and unauthorized access;</li>
|
||||
<li>Send service-related notices where permitted.</li>
|
||||
</ul>
|
||||
</LegalSection>
|
||||
|
||||
<LegalSection title="4. Legal bases (where applicable)">
|
||||
<p>
|
||||
Where privacy laws require a legal basis, we rely on one or more of: performance of a contract, legitimate interests
|
||||
(such as security and service improvement), compliance with legal obligations, and consent where required.
|
||||
</p>
|
||||
</LegalSection>
|
||||
|
||||
<LegalSection title="5. Sharing with service providers">
|
||||
<p>
|
||||
We use trusted third parties to help run the site — for example payment processing, hosting, analytics, and email
|
||||
delivery. These providers process data only on our instructions and are expected to protect it consistent with this
|
||||
policy and applicable law.
|
||||
</p>
|
||||
<p>We do not sell your personal information.</p>
|
||||
</LegalSection>
|
||||
|
||||
<LegalSection title="6. Cookies and similar technologies">
|
||||
<p>
|
||||
We may use cookies, local storage, and similar technologies for authentication, preferences, security, and basic
|
||||
analytics. You can control cookies through your browser settings; disabling some cookies may limit site functionality.
|
||||
</p>
|
||||
</LegalSection>
|
||||
|
||||
<LegalSection title="7. Data retention">
|
||||
<p>
|
||||
We retain personal information only as long as needed for the purposes described in this policy, including legal,
|
||||
accounting, and compliance requirements. Retention periods may vary by data type and obligation.
|
||||
</p>
|
||||
<p>
|
||||
To request deletion of personal data associated with your account, see our{" "}
|
||||
<Link href="/datadeletion" className="text-sky-300 hover:text-sky-200">
|
||||
Data Deletion
|
||||
</Link>{" "}
|
||||
page.
|
||||
</p>
|
||||
</LegalSection>
|
||||
|
||||
<LegalSection title="8. Your rights">
|
||||
<p>
|
||||
Depending on your location, you may have rights to access, correct, delete, restrict, or port your personal information,
|
||||
and to object to certain processing. You may also withdraw consent where processing is consent-based.
|
||||
</p>
|
||||
<p>
|
||||
To exercise these rights, contact us at{" "}
|
||||
<a href={`mailto:${email}`} className="text-sky-300 hover:text-sky-200">
|
||||
{email}
|
||||
</a>
|
||||
. We may need to verify your identity before fulfilling a request.
|
||||
</p>
|
||||
</LegalSection>
|
||||
|
||||
<LegalSection title="9. Security">
|
||||
<p>
|
||||
We implement reasonable administrative, technical, and organizational measures designed to protect personal
|
||||
information. No method of transmission or storage is completely secure; we cannot guarantee absolute security.
|
||||
</p>
|
||||
</LegalSection>
|
||||
|
||||
<LegalSection title="10. Children">
|
||||
<p>
|
||||
Our services are not directed to children under 13 (or the minimum age required in your jurisdiction). We do not
|
||||
knowingly collect personal information from children. If you believe we have done so, contact us and we will take
|
||||
appropriate steps.
|
||||
</p>
|
||||
</LegalSection>
|
||||
|
||||
<LegalSection title="11. International users">
|
||||
<p>
|
||||
If you access the site from outside the United States, your information may be processed in the United States or other
|
||||
locations where our service providers operate. Applicable laws in your region may provide additional rights.
|
||||
</p>
|
||||
</LegalSection>
|
||||
|
||||
<LegalSection title="12. Changes to this policy">
|
||||
<p>
|
||||
We may update this Privacy Policy from time to time. The "Last updated" date at the top reflects the latest
|
||||
revision. Material changes may be communicated through the site or other reasonable means.
|
||||
</p>
|
||||
</LegalSection>
|
||||
|
||||
<LegalSection title="13. Contact">
|
||||
<p>
|
||||
Questions about this policy or our privacy practices:{" "}
|
||||
<a href={`mailto:${email}`} className="text-sky-300 hover:text-sky-200">
|
||||
{email}
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
<p className="text-slate-500">
|
||||
Operator: {site} · Website:{" "}
|
||||
<a href={url} className="text-sky-300 hover:text-sky-200">
|
||||
{url}
|
||||
</a>
|
||||
</p>
|
||||
</LegalSection>
|
||||
</LegalPageLayout>
|
||||
);
|
||||
}
|
||||
@@ -36,6 +36,18 @@ export default function sitemap(): MetadataRoute.Sitemap {
|
||||
changeFrequency: "yearly",
|
||||
priority: 0.3,
|
||||
},
|
||||
{
|
||||
url: `${base}/privacy-policy`,
|
||||
lastModified: now,
|
||||
changeFrequency: "yearly",
|
||||
priority: 0.4,
|
||||
},
|
||||
{
|
||||
url: `${base}/datadeletion`,
|
||||
lastModified: now,
|
||||
changeFrequency: "yearly",
|
||||
priority: 0.4,
|
||||
},
|
||||
{
|
||||
url: `${base}/donate`,
|
||||
lastModified: now,
|
||||
|
||||
@@ -144,7 +144,7 @@ export default function SpotlightPage() {
|
||||
<div className="mt-10 rounded-3xl border border-white/10 bg-white/[0.03] p-6">
|
||||
<h2 className="text-lg font-semibold text-white">Place your bid</h2>
|
||||
{!session ? (
|
||||
<p className="mt-4 text-slate-400"><Link href="/login" className="text-sky-300 hover:underline">Sign in</Link> to bid.</p>
|
||||
<p className="mt-4 text-slate-400"><Link href="/login?callbackUrl=%2Fspotlight" className="text-sky-300 hover:underline">Sign in</Link> to bid.</p>
|
||||
) : (
|
||||
<>
|
||||
{catalog.length === 0 ? (
|
||||
|
||||
48
src/app/threads-callback/ThreadsCallbackClient.tsx
Normal file
48
src/app/threads-callback/ThreadsCallbackClient.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
|
||||
export function ThreadsCallbackClient() {
|
||||
const searchParams = useSearchParams();
|
||||
const [result, setResult] = useState("Processing...");
|
||||
|
||||
const code = useMemo(() => searchParams.get("code"), [searchParams]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!code) {
|
||||
setResult("Waiting for Threads authorization code...");
|
||||
return;
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
|
||||
async function exchangeCode() {
|
||||
try {
|
||||
setResult("Processing...");
|
||||
const response = await fetch("/api/threads-exchange", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ code }),
|
||||
signal: controller.signal,
|
||||
});
|
||||
const data = await response.json();
|
||||
setResult(JSON.stringify(data, null, 2));
|
||||
} catch (error) {
|
||||
if (controller.signal.aborted) return;
|
||||
const message = error instanceof Error ? error.message : "Unknown error";
|
||||
setResult(`Error: ${message}`);
|
||||
}
|
||||
}
|
||||
|
||||
exchangeCode();
|
||||
|
||||
return () => controller.abort();
|
||||
}, [code]);
|
||||
|
||||
return (
|
||||
<pre className="mt-6 overflow-x-auto rounded-2xl border border-white/10 bg-black/40 p-5 text-sm leading-relaxed text-slate-200">
|
||||
{result}
|
||||
</pre>
|
||||
);
|
||||
}
|
||||
26
src/app/threads-callback/page.tsx
Normal file
26
src/app/threads-callback/page.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import { Suspense } from "react";
|
||||
import type { Metadata } from "next";
|
||||
import { ThreadsCallbackClient } from "./ThreadsCallbackClient";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Threads Callback",
|
||||
robots: { index: false, follow: false },
|
||||
};
|
||||
|
||||
export default function ThreadsCallbackPage() {
|
||||
return (
|
||||
<main className="mx-auto max-w-3xl px-4 py-16 sm:px-6">
|
||||
<p className="text-xs uppercase tracking-[0.28em] text-sky-300/80">Threads OAuth</p>
|
||||
<h1 className="mt-3 text-3xl font-semibold text-white">Threads callback</h1>
|
||||
<Suspense
|
||||
fallback={
|
||||
<pre className="mt-6 overflow-x-auto rounded-2xl border border-white/10 bg-black/40 p-5 text-sm leading-relaxed text-slate-200">
|
||||
Processing...
|
||||
</pre>
|
||||
}
|
||||
>
|
||||
<ThreadsCallbackClient />
|
||||
</Suspense>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -74,6 +74,21 @@ export function WalletActions({
|
||||
setInfiniteCredits(!!data.infiniteCredits);
|
||||
}, []); // setters from useState are stable — no deps needed
|
||||
|
||||
// Keep this view in sync with games, donates, and other wallet UIs via the
|
||||
// shared `wallet:refresh` event bus + tab visibility.
|
||||
useEffect(() => {
|
||||
const onCustom = () => void refreshBalance();
|
||||
const onVisibility = () => {
|
||||
if (document.visibilityState === "visible") void refreshBalance();
|
||||
};
|
||||
window.addEventListener("wallet:refresh", onCustom);
|
||||
document.addEventListener("visibilitychange", onVisibility);
|
||||
return () => {
|
||||
window.removeEventListener("wallet:refresh", onCustom);
|
||||
document.removeEventListener("visibilitychange", onVisibility);
|
||||
};
|
||||
}, [refreshBalance]);
|
||||
|
||||
// Supporter credit spot index (USD per credit unit)
|
||||
useEffect(() => {
|
||||
if (infiniteCredits) return;
|
||||
|
||||
@@ -31,6 +31,14 @@ const SPEND_DESTINATIONS = [
|
||||
|
||||
const CHART_COLORS = ["#38bdf8", "#a78bfa", "#34d399", "#f472b6", "#fbbf24", "#fb7185", "#818cf8"];
|
||||
|
||||
function supporterTier(earned: number): { label: string; note: string } {
|
||||
if (earned >= 10_000) return { label: "Movement Whale", note: "Top-tier capital steering power" };
|
||||
if (earned >= 2_500) return { label: "Coalition Captain", note: "High-impact recurring backer" };
|
||||
if (earned >= 750) return { label: "Civic Builder", note: "Actively shaping priorities" };
|
||||
if (earned >= 100) return { label: "Verified Supporter", note: "Wallet is active and growing" };
|
||||
return { label: "Fresh Wallet", note: "First credits are the hardest" };
|
||||
}
|
||||
|
||||
function ImpactBar({ label, pct, credits, count, color }: { label: string; pct: number; credits: number; count: number; color: string }) {
|
||||
return (
|
||||
<div>
|
||||
@@ -101,6 +109,19 @@ export function WalletDashboard() {
|
||||
const spendTotal = summary.spent || 1;
|
||||
const initiativePct = summary.spent > 0 ? Math.round((summary.initiativePledges / spendTotal) * 100) : 0;
|
||||
const missionPct = summary.spent > 0 ? Math.round((summary.missionPledges / spendTotal) * 100) : 0;
|
||||
const tier = supporterTier(summary.earned);
|
||||
const utilizationPct = summary.earned > 0 ? Math.min(100, Math.round((summary.spent / summary.earned) * 100)) : 0;
|
||||
|
||||
const netFlowBars = useMemo(() => {
|
||||
const map = new Map<string, number>();
|
||||
for (const item of summary.recentActivity) {
|
||||
const day = item.at.slice(5, 10);
|
||||
map.set(day, (map.get(day) ?? 0) + item.delta);
|
||||
}
|
||||
const items = [...map.entries()].slice(-10).map(([day, delta]) => ({ day, delta }));
|
||||
const maxAbs = Math.max(1, ...items.map((i) => Math.abs(i.delta)));
|
||||
return { items, maxAbs };
|
||||
}, [summary.recentActivity]);
|
||||
|
||||
const sparkPath =
|
||||
historyPoints.length < 2
|
||||
@@ -122,6 +143,9 @@ export function WalletDashboard() {
|
||||
missionPct={missionPct}
|
||||
sparkPath={sparkPath}
|
||||
maxSpend={maxSpend}
|
||||
tier={tier}
|
||||
utilizationPct={utilizationPct}
|
||||
netFlowBars={netFlowBars}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -134,8 +158,11 @@ function WalletDashboardView(props: {
|
||||
missionPct: number;
|
||||
sparkPath: string;
|
||||
maxSpend: number;
|
||||
tier: { label: string; note: string };
|
||||
utilizationPct: number;
|
||||
netFlowBars: { items: { day: string; delta: number }[]; maxAbs: number };
|
||||
}) {
|
||||
const { summary, t, indexUsd, initiativePct, missionPct, sparkPath, maxSpend } = props;
|
||||
const { summary, t, indexUsd, initiativePct, missionPct, sparkPath, maxSpend, tier, utilizationPct, netFlowBars } = props;
|
||||
|
||||
return (
|
||||
<div className="mb-10 space-y-8">
|
||||
@@ -192,6 +219,55 @@ function WalletDashboardView(props: {
|
||||
</motion.div>
|
||||
|
||||
<div className="grid gap-6 lg:grid-cols-2">
|
||||
<div className="rounded-2xl border border-violet-400/25 bg-violet-950/20 p-5">
|
||||
<p className="text-sm font-medium text-white">Token profile</p>
|
||||
<p className="mt-3 text-2xl font-semibold text-violet-200">{tier.label}</p>
|
||||
<p className="mt-1 text-sm text-slate-400">{tier.note}</p>
|
||||
<div className="mt-4 grid grid-cols-2 gap-3">
|
||||
<div className="rounded-xl border border-white/10 bg-black/20 p-3">
|
||||
<p className="text-xs uppercase tracking-wide text-slate-500">Total earned</p>
|
||||
<p className="mt-1 font-mono text-lg text-emerald-300">+{summary.earned.toLocaleString()} {t}</p>
|
||||
</div>
|
||||
<div className="rounded-xl border border-white/10 bg-black/20 p-3">
|
||||
<p className="text-xs uppercase tracking-wide text-slate-500">Utilization</p>
|
||||
<p className="mt-1 font-mono text-lg text-sky-300">{utilizationPct}%</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 h-2 overflow-hidden rounded-full bg-white/10">
|
||||
<div className="h-full rounded-full bg-gradient-to-r from-sky-400 via-violet-400 to-fuchsia-400" style={{ width: `${utilizationPct}%` }} />
|
||||
</div>
|
||||
<p className="mt-2 text-xs text-slate-500">How much of your earned {t} is already deployed into movement actions.</p>
|
||||
</div>
|
||||
|
||||
<div className="rounded-2xl border border-white/10 bg-white/[0.03] p-5">
|
||||
<p className="text-sm font-medium text-white">Personal net flow (recent)</p>
|
||||
{netFlowBars.items.length === 0 ? (
|
||||
<p className="mt-4 text-sm text-slate-500">No flow yet — your first transactions will render here.</p>
|
||||
) : (
|
||||
<div className="mt-4 space-y-2">
|
||||
{netFlowBars.items.map((bar) => {
|
||||
const width = Math.round((Math.abs(bar.delta) / netFlowBars.maxAbs) * 100);
|
||||
const positive = bar.delta >= 0;
|
||||
return (
|
||||
<div key={bar.day} className="grid grid-cols-[52px_1fr_90px] items-center gap-3 text-xs">
|
||||
<span className="font-mono text-slate-500">{bar.day}</span>
|
||||
<div className="h-2.5 overflow-hidden rounded-full bg-white/10">
|
||||
<div
|
||||
className={`h-full rounded-full ${positive ? "bg-emerald-400" : "bg-rose-400"}`}
|
||||
style={{ width: `${Math.max(6, width)}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className={`text-right font-mono ${positive ? "text-emerald-300" : "text-rose-300"}`}>
|
||||
{positive ? "+" : ""}
|
||||
{bar.delta.toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="rounded-2xl border border-white/10 bg-white/[0.03] p-5">
|
||||
<p className="text-sm font-medium text-white">Where you've spent {t}</p>
|
||||
{summary.spendBreakdown.length === 0 ? (
|
||||
|
||||
@@ -9,6 +9,7 @@ import { Suspense } from "react";
|
||||
import { WalletActions } from "./WalletActions";
|
||||
import { WalletDashboard } from "./WalletDashboard";
|
||||
import { WalletGuestView } from "./WalletGuestView";
|
||||
import { ActiveGamesBanner } from "@/components/casino/ActiveGamesBanner";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: `Supporter wallet — ${appTitle()}`,
|
||||
@@ -44,6 +45,7 @@ export default async function WalletPage() {
|
||||
<div className="relative overflow-hidden">
|
||||
<div className="pointer-events-none absolute inset-0 bg-[radial-gradient(ellipse_70%_45%_at_50%_0%,rgba(139,92,246,0.12),transparent_55%)]" />
|
||||
<div className="relative mx-auto max-w-5xl px-4 py-10 sm:px-6 sm:py-12">
|
||||
<ActiveGamesBanner userId={userId} className="mb-8" />
|
||||
<WalletDashboard />
|
||||
<div id="wallet-perks" className="scroll-mt-28">
|
||||
<p className="mb-6 text-xs uppercase tracking-[0.28em] text-slate-400">Perks & ledger</p>
|
||||
|
||||
@@ -53,6 +53,7 @@ export const { handlers, auth, signIn, signOut } = NextAuth({
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
name: user.name ?? undefined,
|
||||
username: user.username,
|
||||
role: user.role,
|
||||
};
|
||||
},
|
||||
@@ -62,14 +63,18 @@ export const { handlers, auth, signIn, signOut } = NextAuth({
|
||||
jwt({ token, user }) {
|
||||
if (user) {
|
||||
token.id = user.id;
|
||||
token.role = (user as { role: string }).role;
|
||||
if (user.username) token.username = user.username;
|
||||
const role = (user as { role?: string }).role;
|
||||
token.role = role === "ADMIN" || role === "USER" ? role : "USER";
|
||||
}
|
||||
return token;
|
||||
},
|
||||
session({ session, token }) {
|
||||
if (session.user) {
|
||||
session.user.id = (token.id as string) ?? (token.sub as string);
|
||||
session.user.role = (token.role as "USER" | "ADMIN") ?? "USER";
|
||||
if (typeof token.username === "string") session.user.username = token.username;
|
||||
const role = token.role;
|
||||
session.user.role = role === "ADMIN" || role === "USER" ? role : "USER";
|
||||
}
|
||||
return session;
|
||||
},
|
||||
|
||||
64
src/components/LegalPageLayout.tsx
Normal file
64
src/components/LegalPageLayout.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
import Link from "next/link";
|
||||
import { SiteFooter } from "@/components/SiteFooter";
|
||||
import { appTitle } from "@/lib/public-env";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
type LegalPageLayoutProps = {
|
||||
title: string;
|
||||
description: string;
|
||||
lastUpdated: string;
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
export function LegalPageLayout({ title, description, lastUpdated, children }: LegalPageLayoutProps) {
|
||||
const site = appTitle();
|
||||
|
||||
return (
|
||||
<>
|
||||
<article className="mx-auto max-w-3xl px-4 py-16 sm:px-6 sm:py-20">
|
||||
<p className="text-xs uppercase tracking-[0.28em] text-sky-300/80">Legal</p>
|
||||
<h1 className="mt-3 text-3xl font-semibold text-white sm:text-4xl">{title}</h1>
|
||||
<p className="mt-3 text-sm text-slate-500">Last updated: {lastUpdated}</p>
|
||||
<p className="mt-4 text-slate-400">{description}</p>
|
||||
|
||||
<div className="mt-10 space-y-10 text-sm leading-relaxed text-slate-300">{children}</div>
|
||||
|
||||
<nav
|
||||
className="mt-12 flex flex-wrap gap-4 border-t border-white/10 pt-8 text-sm"
|
||||
aria-label="Related legal pages"
|
||||
>
|
||||
<Link href="/privacy-policy" className="text-sky-300 hover:text-sky-200">
|
||||
Privacy Policy
|
||||
</Link>
|
||||
<span className="text-slate-600" aria-hidden>
|
||||
·
|
||||
</span>
|
||||
<Link href="/datadeletion" className="text-sky-300 hover:text-sky-200">
|
||||
Data Deletion
|
||||
</Link>
|
||||
<span className="text-slate-600" aria-hidden>
|
||||
·
|
||||
</span>
|
||||
<Link href="/" className="text-slate-400 hover:text-slate-200">
|
||||
{site} home
|
||||
</Link>
|
||||
</nav>
|
||||
|
||||
<p className="mt-8 rounded-2xl border border-amber-500/20 bg-amber-950/20 px-4 py-3 text-xs leading-relaxed text-amber-100/80">
|
||||
This document is provided for general informational purposes only and does not constitute legal advice. Replace
|
||||
placeholders and have qualified counsel review before production use.
|
||||
</p>
|
||||
</article>
|
||||
<SiteFooter />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function LegalSection({ title, children }: { title: string; children: ReactNode }) {
|
||||
return (
|
||||
<section>
|
||||
<h2 className="text-lg font-semibold text-white">{title}</h2>
|
||||
<div className="mt-3 space-y-3">{children}</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
95
src/components/NavWalletBalance.tsx
Normal file
95
src/components/NavWalletBalance.tsx
Normal file
@@ -0,0 +1,95 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { creditTicker } from "@/lib/credits-brand";
|
||||
|
||||
const T = creditTicker();
|
||||
|
||||
interface Props {
|
||||
initialBalance: number;
|
||||
initialInfinite?: boolean;
|
||||
/** Optional className lets the nav pick tighter spacing on mobile. */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Live wallet pill mounted in the top nav.
|
||||
*
|
||||
* Refresh strategy:
|
||||
* - 15 s polling (cheap; same cadence as the per-game `useLiveWalletBalance`)
|
||||
* - Cross-tab + cross-component instant sync via the `wallet:refresh` custom
|
||||
* event on `window`. Any client code can call
|
||||
* `window.dispatchEvent(new Event("wallet:refresh"))` to force a re-fetch
|
||||
* (the wallet hook does this automatically after every game action).
|
||||
* - Tab visibility — refetch on tab focus so balance is fresh.
|
||||
*/
|
||||
export function NavWalletBalance({ initialBalance, initialInfinite = false, className = "" }: Props) {
|
||||
const [balance, setBalance] = useState<number>(initialBalance);
|
||||
const [infinite, setInfinite] = useState<boolean>(initialInfinite);
|
||||
const [flash, setFlash] = useState<"up" | "down" | null>(null);
|
||||
const lastBalanceRef = useRef<number>(initialBalance);
|
||||
|
||||
const fetchBalance = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch("/api/wallet", { cache: "no-store" });
|
||||
if (!res.ok) return;
|
||||
const data = await res.json();
|
||||
const next = typeof data.balanceCredits === "number" ? data.balanceCredits : 0;
|
||||
const isInf = !!data.infiniteCredits;
|
||||
setInfinite(isInf);
|
||||
if (!isInf) {
|
||||
setBalance((prev) => {
|
||||
if (next !== prev) {
|
||||
const dir = next > lastBalanceRef.current ? "up" : "down";
|
||||
setFlash(dir);
|
||||
window.setTimeout(() => setFlash(null), 700);
|
||||
lastBalanceRef.current = next;
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
/* ignore — keep last known */
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void fetchBalance();
|
||||
const id = window.setInterval(() => void fetchBalance(), 15_000);
|
||||
const onCustom = () => void fetchBalance();
|
||||
const onVisibility = () => { if (document.visibilityState === "visible") void fetchBalance(); };
|
||||
window.addEventListener("wallet:refresh", onCustom);
|
||||
document.addEventListener("visibilitychange", onVisibility);
|
||||
return () => {
|
||||
window.clearInterval(id);
|
||||
window.removeEventListener("wallet:refresh", onCustom);
|
||||
document.removeEventListener("visibilitychange", onVisibility);
|
||||
};
|
||||
}, [fetchBalance]);
|
||||
|
||||
const display = infinite
|
||||
? "∞"
|
||||
: balance.toLocaleString(undefined, { maximumFractionDigits: 0 });
|
||||
|
||||
const flashClass =
|
||||
flash === "up"
|
||||
? "ring-1 ring-emerald-400/60 bg-emerald-500/15 text-emerald-100"
|
||||
: flash === "down"
|
||||
? "ring-1 ring-rose-400/60 bg-rose-500/15 text-rose-100"
|
||||
: "border border-white/10 bg-white/5 text-sky-100";
|
||||
|
||||
return (
|
||||
<Link
|
||||
href="/wallet"
|
||||
title={`Wallet — ${display} ${T}`}
|
||||
className={`group inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-xs font-semibold tabular-nums transition-colors ${flashClass} ${className}`}
|
||||
>
|
||||
<span aria-hidden className="text-[10px] leading-none text-sky-300/80 group-hover:text-sky-200">
|
||||
◆
|
||||
</span>
|
||||
<span className="leading-none">{display}</span>
|
||||
<span className="leading-none text-[10px] text-sky-300/70">{T}</span>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
@@ -98,6 +98,16 @@ export function SiteFooter() {
|
||||
FAQ
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link href="/privacy-policy" className="hover:text-sky-300">
|
||||
Privacy Policy
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link href="/datadeletion" className="hover:text-sky-300">
|
||||
Data Deletion
|
||||
</Link>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import Link from "next/link";
|
||||
import { auth } from "@/auth";
|
||||
import { appTitle } from "@/lib/public-env";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { ADMIN_WALLET_DISPLAY, isAdminRole } from "@/lib/admin";
|
||||
import { MobileNav } from "./MobileNav";
|
||||
import { NavWalletBalance } from "./NavWalletBalance";
|
||||
|
||||
const primaryLinks = [
|
||||
{ href: "/raised", label: "Raised" },
|
||||
@@ -15,6 +18,24 @@ export async function SiteNav() {
|
||||
const session = await auth();
|
||||
const title = appTitle();
|
||||
|
||||
// Fetch username + balance in a single query for signed-in users so the nav
|
||||
// can render the live wallet pill with a correct server-side initial value.
|
||||
let username: string | null = null;
|
||||
let initialBalance = 0;
|
||||
let initialInfinite = false;
|
||||
if (session?.user?.id) {
|
||||
const [dbUser, wallet] = await Promise.all([
|
||||
prisma.user.findUnique({
|
||||
where: { id: session.user.id },
|
||||
select: { username: true, role: true },
|
||||
}),
|
||||
prisma.wallet.findUnique({ where: { userId: session.user.id } }),
|
||||
]);
|
||||
username = session.user.username ?? dbUser?.username ?? null;
|
||||
initialInfinite = isAdminRole(dbUser?.role ?? session.user.role);
|
||||
initialBalance = initialInfinite ? ADMIN_WALLET_DISPLAY : wallet?.balanceCredits ?? 0;
|
||||
}
|
||||
|
||||
return (
|
||||
<header
|
||||
id="site-header"
|
||||
@@ -30,6 +51,22 @@ export async function SiteNav() {
|
||||
{title}
|
||||
</span>
|
||||
</Link>
|
||||
{username ? (
|
||||
<Link
|
||||
href="/wallet"
|
||||
className="hidden max-w-[9rem] truncate rounded-full border border-white/10 bg-white/5 px-2.5 py-1 text-xs font-semibold text-slate-200 hover:bg-white/10 sm:inline-flex"
|
||||
title={`Signed in as @${username}`}
|
||||
>
|
||||
@{username}
|
||||
</Link>
|
||||
) : null}
|
||||
{session?.user?.id ? (
|
||||
<NavWalletBalance
|
||||
initialBalance={initialBalance}
|
||||
initialInfinite={initialInfinite}
|
||||
className="hidden sm:inline-flex"
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<div className="min-w-2 flex-1" aria-hidden />
|
||||
|
||||
@@ -73,12 +110,16 @@ export async function SiteNav() {
|
||||
</nav>
|
||||
|
||||
<div className="flex shrink-0 items-center gap-2 md:hidden">
|
||||
<Link
|
||||
href="/donate"
|
||||
className="rounded-full bg-gradient-to-r from-sky-500 to-indigo-500 px-3 py-1.5 text-sm font-semibold text-white shadow-md shadow-sky-500/20"
|
||||
>
|
||||
Donate
|
||||
</Link>
|
||||
{session?.user?.id ? (
|
||||
<NavWalletBalance initialBalance={initialBalance} initialInfinite={initialInfinite} />
|
||||
) : (
|
||||
<Link
|
||||
href="/donate"
|
||||
className="rounded-full bg-gradient-to-r from-sky-500 to-indigo-500 px-3 py-1.5 text-sm font-semibold text-white shadow-md shadow-sky-500/20"
|
||||
>
|
||||
Donate
|
||||
</Link>
|
||||
)}
|
||||
<MobileNav loggedIn={!!session?.user} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
168
src/components/casino/ActiveGamesBanner.tsx
Normal file
168
src/components/casino/ActiveGamesBanner.tsx
Normal file
@@ -0,0 +1,168 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { creditTicker } from "@/lib/credits-brand";
|
||||
|
||||
const T = creditTicker();
|
||||
|
||||
interface ActiveSession {
|
||||
id: string;
|
||||
gameType: string;
|
||||
wageredBLW: number;
|
||||
}
|
||||
|
||||
interface ActiveRoom {
|
||||
id: string;
|
||||
gameType: string;
|
||||
wageBLW: number;
|
||||
status: "WAITING" | "ACTIVE";
|
||||
creatorId: string;
|
||||
joinerId: string | null;
|
||||
}
|
||||
|
||||
const GAME_META: Record<string, { icon: string; slug: string; label: string }> = {
|
||||
CRASH: { icon: "📈", slug: "crash", label: "Crash" },
|
||||
DICE: { icon: "🎲", slug: "dice", label: "Dice" },
|
||||
MINES: { icon: "💣", slug: "mines", label: "Mines" },
|
||||
TOWER: { icon: "🏰", slug: "tower", label: "Tower" },
|
||||
SLOTS: { icon: "🎰", slug: "slots", label: "Slots" },
|
||||
BLACKJACK: { icon: "🃏", slug: "blackjack", label: "Blackjack" },
|
||||
ROULETTE: { icon: "🎡", slug: "roulette", label: "Roulette" },
|
||||
COIN_FLIP: { icon: "🪙", slug: "coinflip", label: "Coin Flip" },
|
||||
PONG: { icon: "🏓", slug: "pong", label: "Pong" },
|
||||
PREDICTION: { icon: "📊", slug: "prediction", label: "Prediction" },
|
||||
};
|
||||
|
||||
interface Props {
|
||||
/** Current user id — for distinguishing creator vs joiner rooms. */
|
||||
userId?: string;
|
||||
/** Optional className to position within parent layout. */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function ActiveGamesBanner({ userId, className = "" }: Props) {
|
||||
const [sessions, setSessions] = useState<ActiveSession[]>([]);
|
||||
const [rooms, setRooms] = useState<ActiveRoom[]>([]);
|
||||
const [busy, setBusy] = useState<string | null>(null);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
try {
|
||||
const r = await fetch("/api/games/active", { cache: "no-store" });
|
||||
if (!r.ok) return;
|
||||
const d = await r.json();
|
||||
setSessions(Array.isArray(d.sessions) ? d.sessions : []);
|
||||
setRooms(Array.isArray(d.rooms) ? d.rooms : []);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void refresh();
|
||||
const id = setInterval(() => void refresh(), 20_000);
|
||||
return () => clearInterval(id);
|
||||
}, [refresh]);
|
||||
|
||||
const total = sessions.length + rooms.length;
|
||||
if (total === 0) return null;
|
||||
|
||||
async function cancelRoom(roomId: string) {
|
||||
setBusy(roomId);
|
||||
try {
|
||||
const res = await fetch(`/api/games/rooms?id=${encodeURIComponent(roomId)}`, { method: "DELETE" });
|
||||
if (res.ok) await refresh();
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`rounded-2xl border border-amber-400/30 bg-gradient-to-r from-amber-500/10 via-orange-500/10 to-rose-500/10 p-4 ${className}`}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-[0.22em] text-amber-200/80">Unfinished games</p>
|
||||
<p className="mt-1 text-sm font-medium text-amber-50">
|
||||
You have {total} active {total === 1 ? "round" : "rounds"} — resume to keep your wager.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ul className="mt-4 space-y-2">
|
||||
{sessions.map((s) => {
|
||||
const meta = GAME_META[s.gameType] ?? { icon: "🎮", slug: s.gameType.toLowerCase(), label: s.gameType };
|
||||
return (
|
||||
<li
|
||||
key={s.id}
|
||||
className="flex items-center justify-between gap-3 rounded-xl border border-white/10 bg-black/30 px-3 py-2"
|
||||
>
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<span className="text-2xl" aria-hidden>
|
||||
{meta.icon}
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-sm font-medium text-white">{meta.label} round in progress</p>
|
||||
<p className="text-xs text-slate-400">
|
||||
Wager locked: <span className="font-mono">{s.wageredBLW.toLocaleString()}</span> {T}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Link
|
||||
href={`/casino/${meta.slug}`}
|
||||
className="shrink-0 rounded-lg bg-gradient-to-r from-amber-500 to-orange-500 px-3 py-1.5 text-xs font-semibold text-white hover:opacity-90"
|
||||
>
|
||||
Resume →
|
||||
</Link>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
|
||||
{rooms.map((r) => {
|
||||
const meta = GAME_META[r.gameType] ?? { icon: "🎮", slug: r.gameType.toLowerCase(), label: r.gameType };
|
||||
const isCreator = userId ? r.creatorId === userId : true;
|
||||
const canCancel = isCreator && r.status === "WAITING";
|
||||
return (
|
||||
<li
|
||||
key={r.id}
|
||||
className="flex items-center justify-between gap-3 rounded-xl border border-white/10 bg-black/30 px-3 py-2"
|
||||
>
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<span className="text-2xl" aria-hidden>
|
||||
{meta.icon}
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-sm font-medium text-white">
|
||||
{meta.label} {r.status === "WAITING" ? "room — waiting" : "room — in play"}
|
||||
</p>
|
||||
<p className="text-xs text-slate-400">
|
||||
Locked: <span className="font-mono">{r.wageBLW.toLocaleString()}</span> {T}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex shrink-0 gap-2">
|
||||
<Link
|
||||
href={`/casino/${meta.slug}`}
|
||||
className="rounded-lg bg-gradient-to-r from-sky-500 to-indigo-500 px-3 py-1.5 text-xs font-semibold text-white hover:opacity-90"
|
||||
>
|
||||
{r.status === "WAITING" ? "Resume" : "Rejoin"}
|
||||
</Link>
|
||||
{canCancel ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void cancelRoom(r.id)}
|
||||
disabled={busy === r.id}
|
||||
className="rounded-lg border border-white/15 px-3 py-1.5 text-xs font-medium text-slate-200 hover:bg-white/10 disabled:opacity-50"
|
||||
>
|
||||
{busy === r.id ? "…" : "Cancel & refund"}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
import { creditTicker } from "@/lib/credits-brand";
|
||||
import { useState } from "react";
|
||||
import { useLiveWalletBalance } from "./useLiveWalletBalance";
|
||||
|
||||
const T = creditTicker();
|
||||
|
||||
@@ -37,7 +38,8 @@ function Hand({ cards, label }: { cards: string[]; label: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
export function BlackjackGame({ balance }: { balance: number }) {
|
||||
export function BlackjackGame({ balance: initialBalance }: { balance: number }) {
|
||||
const { balance, refreshBalance } = useLiveWalletBalance(initialBalance);
|
||||
const [wager, setWager] = useState(10);
|
||||
const [clientSeed, setClientSeed] = useState("my-seed");
|
||||
const [phase, setPhase] = useState<"idle" | "playing" | "done">("idle");
|
||||
@@ -47,8 +49,10 @@ export function BlackjackGame({ balance }: { balance: number }) {
|
||||
const [dealerFull, setDealerFull] = useState<string[]>([]);
|
||||
const [result, setResult] = useState<{ outcome: string; payout?: number; playerVal?: number; dealerVal?: number } | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [errorMsg, setErrorMsg] = useState<string | null>(null);
|
||||
|
||||
async function startGame() {
|
||||
setErrorMsg(null);
|
||||
setLoading(true);
|
||||
const res = await fetch("/api/games/blackjack", {
|
||||
method: "POST",
|
||||
@@ -56,12 +60,17 @@ export function BlackjackGame({ balance }: { balance: number }) {
|
||||
body: JSON.stringify({ wageBLW: wager, clientSeed }),
|
||||
});
|
||||
setLoading(false);
|
||||
if (!res.ok) { const e = await res.json(); alert(e.error); return; }
|
||||
if (!res.ok) {
|
||||
const e = await res.json();
|
||||
setErrorMsg(e.error ?? "Could not deal");
|
||||
return;
|
||||
}
|
||||
const data = await res.json();
|
||||
setRoundId(data.roundId);
|
||||
setPlayerHand(data.playerHand);
|
||||
setDealerVisible(data.dealerVisible ?? []);
|
||||
setDealerFull([]);
|
||||
if (data.dealerHand) setDealerFull(data.dealerHand);
|
||||
else setDealerFull([]);
|
||||
setResult(null);
|
||||
if (data.outcome !== "active") {
|
||||
setResult(data);
|
||||
@@ -69,9 +78,11 @@ export function BlackjackGame({ balance }: { balance: number }) {
|
||||
} else {
|
||||
setPhase("playing");
|
||||
}
|
||||
void refreshBalance();
|
||||
}
|
||||
|
||||
async function action(act: "hit" | "stand" | "double") {
|
||||
setErrorMsg(null);
|
||||
setLoading(true);
|
||||
const res = await fetch("/api/games/blackjack", {
|
||||
method: "PATCH",
|
||||
@@ -79,13 +90,18 @@ export function BlackjackGame({ balance }: { balance: number }) {
|
||||
body: JSON.stringify({ roundId, action: act }),
|
||||
});
|
||||
setLoading(false);
|
||||
if (!res.ok) return;
|
||||
if (!res.ok) {
|
||||
const e = await res.json().catch(() => ({ error: "Action failed" }));
|
||||
setErrorMsg(e.error ?? "Action failed");
|
||||
return;
|
||||
}
|
||||
const data = await res.json();
|
||||
if (data.playerHand) setPlayerHand(data.playerHand);
|
||||
if (data.dealerHand) setDealerFull(data.dealerHand);
|
||||
if (data.outcome !== "active") {
|
||||
setResult(data);
|
||||
setPhase("done");
|
||||
void refreshBalance();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -102,6 +118,9 @@ export function BlackjackGame({ balance }: { balance: number }) {
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
{errorMsg ? (
|
||||
<div className="rounded-xl border border-rose-500/40 bg-rose-500/10 p-3 text-sm text-rose-200">{errorMsg}</div>
|
||||
) : null}
|
||||
{result && (
|
||||
<div className={`rounded-xl border p-4 text-center font-bold ${outcomeColor}`}>
|
||||
{outcomeLabel}
|
||||
|
||||
@@ -2,36 +2,66 @@
|
||||
import { creditTicker } from "@/lib/credits-brand";
|
||||
import { useState, useEffect } from "react";
|
||||
import { io, Socket } from "socket.io-client";
|
||||
import { useLiveWalletBalance } from "./useLiveWalletBalance";
|
||||
|
||||
const T = creditTicker();
|
||||
|
||||
interface Room { id: string; wageBLW: number; creatorId: string; }
|
||||
|
||||
export function CoinFlipRoom({ userId, balance }: { userId: string; balance: number }) {
|
||||
export function CoinFlipRoom({ userId, balance: initialBalance }: { userId: string; balance: number }) {
|
||||
const { balance, refreshBalance } = useLiveWalletBalance(initialBalance);
|
||||
const [rooms, setRooms] = useState<Room[]>([]);
|
||||
const [wager, setWager] = useState(50);
|
||||
const [phase, setPhase] = useState<"lobby" | "waiting" | "result">("lobby");
|
||||
const [socket, setSocket] = useState<Socket | null>(null);
|
||||
const [result, setResult] = useState<{ resultLabel: string; winnerId: string; payout: number; serverSeed: string } | null>(null);
|
||||
const [myRoomId, setMyRoomId] = useState<string | null>(null);
|
||||
const [errorMsg, setErrorMsg] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetchRooms();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
socket?.disconnect();
|
||||
};
|
||||
}, [socket]);
|
||||
|
||||
async function fetchRooms() {
|
||||
const r = await fetch("/api/games/rooms?gameType=COIN_FLIP");
|
||||
if (r.ok) { const d = await r.json(); setRooms(d.rooms); }
|
||||
}
|
||||
|
||||
function connect(roomId: string) {
|
||||
setErrorMsg(null);
|
||||
const s = io("/coin-flip", { path: "/api/socket" });
|
||||
setSocket(s);
|
||||
s.emit("join_room", { roomId, userId });
|
||||
s.emit("join_room", { roomId });
|
||||
s.on("waiting", () => setPhase("waiting"));
|
||||
s.on("game_start", () => setPhase("waiting"));
|
||||
s.on("result", (data) => { setResult(data); setPhase("result"); s.disconnect(); });
|
||||
s.on("error", (msg: string) => { alert(msg); s.disconnect(); setPhase("lobby"); });
|
||||
s.on("result", (data) => { setResult(data); setPhase("result"); void refreshBalance(); s.disconnect(); });
|
||||
s.on("error", (msg: string) => {
|
||||
setErrorMsg(msg);
|
||||
void refreshBalance();
|
||||
s.disconnect();
|
||||
setPhase("lobby");
|
||||
});
|
||||
}
|
||||
|
||||
async function cancelRoom() {
|
||||
if (!myRoomId) return;
|
||||
const res = await fetch(`/api/games/rooms?id=${encodeURIComponent(myRoomId)}`, { method: "DELETE" });
|
||||
socket?.disconnect();
|
||||
if (res.ok) {
|
||||
setMyRoomId(null);
|
||||
setPhase("lobby");
|
||||
void refreshBalance();
|
||||
fetchRooms();
|
||||
} else {
|
||||
const e = await res.json().catch(() => ({ error: "Could not cancel" }));
|
||||
setErrorMsg(e.error ?? "Could not cancel");
|
||||
}
|
||||
}
|
||||
|
||||
async function createRoom() {
|
||||
@@ -40,7 +70,11 @@ export function CoinFlipRoom({ userId, balance }: { userId: string; balance: num
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ gameType: "COIN_FLIP", wageBLW: wager }),
|
||||
});
|
||||
if (!res.ok) { const e = await res.json(); alert(e.error); return; }
|
||||
if (!res.ok) {
|
||||
const e = await res.json();
|
||||
setErrorMsg(e.error ?? "Could not create room");
|
||||
return;
|
||||
}
|
||||
const { room } = await res.json();
|
||||
setMyRoomId(room.id);
|
||||
connect(room.id);
|
||||
@@ -54,6 +88,14 @@ export function CoinFlipRoom({ userId, balance }: { userId: string; balance: num
|
||||
<p className="text-white font-semibold">Waiting for opponent…</p>
|
||||
<p className="text-slate-400 text-sm">Room ID: <span className="font-mono text-sky-300">{myRoomId}</span></p>
|
||||
<p className="text-slate-500 text-xs">Share this with a friend to flip instantly!</p>
|
||||
{myRoomId ? (
|
||||
<button
|
||||
onClick={cancelRoom}
|
||||
className="rounded-xl border border-white/15 bg-white/5 px-5 py-2 text-sm font-semibold text-slate-200 hover:bg-white/10"
|
||||
>
|
||||
Cancel & Refund
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -78,6 +120,11 @@ export function CoinFlipRoom({ userId, balance }: { userId: string; balance: num
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
{errorMsg ? (
|
||||
<div className="rounded-xl border border-rose-500/40 bg-rose-500/10 p-3 text-sm text-rose-200">
|
||||
{errorMsg}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="rounded-xl border border-white/10 bg-white/5 p-4 space-y-3">
|
||||
<p className="text-sm font-semibold text-white">Create a Room</p>
|
||||
<div className="flex gap-3">
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
"use client";
|
||||
import { creditTicker } from "@/lib/credits-brand";
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { useLiveWalletBalance } from "./useLiveWalletBalance";
|
||||
|
||||
const T = creditTicker();
|
||||
|
||||
export function CrashGame({ balance }: { balance: number }) {
|
||||
export function CrashGame({ balance: initialBalance }: { balance: number }) {
|
||||
const { balance, refreshBalance } = useLiveWalletBalance(initialBalance);
|
||||
const [wager, setWager] = useState(10);
|
||||
const [clientSeed, setClientSeed] = useState("my-seed");
|
||||
const [phase, setPhase] = useState<"idle" | "running" | "done">("idle");
|
||||
@@ -12,65 +14,102 @@ export function CrashGame({ balance }: { balance: number }) {
|
||||
const [multiplier, setMultiplier] = useState(1.0);
|
||||
const [crashAt, setCrashAt] = useState<number | null>(null);
|
||||
const [result, setResult] = useState<{ outcome: string; multiplier?: number; payout?: number; serverSeed?: string } | null>(null);
|
||||
const [errorMsg, setErrorMsg] = useState<string | null>(null);
|
||||
const rafRef = useRef<number | null>(null);
|
||||
const startRef = useRef<number>(0);
|
||||
const multiplierRef = useRef<number>(1);
|
||||
|
||||
function startAnimation(crash: number) {
|
||||
function startAnimation() {
|
||||
startRef.current = Date.now();
|
||||
function tick() {
|
||||
const elapsed = (Date.now() - startRef.current) / 1000;
|
||||
const current = Math.pow(Math.E, 0.2 * elapsed);
|
||||
setMultiplier(parseFloat(current.toFixed(2)));
|
||||
if (current < crash) {
|
||||
const rounded = parseFloat(current.toFixed(2));
|
||||
multiplierRef.current = rounded;
|
||||
setMultiplier(rounded);
|
||||
if (current < 100) {
|
||||
rafRef.current = requestAnimationFrame(tick);
|
||||
} else {
|
||||
setMultiplier(crash);
|
||||
multiplierRef.current = 100;
|
||||
setMultiplier(100);
|
||||
}
|
||||
}
|
||||
rafRef.current = requestAnimationFrame(tick);
|
||||
}
|
||||
|
||||
async function startRound() {
|
||||
setErrorMsg(null);
|
||||
const res = await fetch("/api/games/crash", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ wageBLW: wager, clientSeed }),
|
||||
});
|
||||
if (!res.ok) { const e = await res.json(); alert(e.error); return; }
|
||||
if (!res.ok) {
|
||||
const e = await res.json();
|
||||
setErrorMsg(e.error ?? "Could not start round");
|
||||
return;
|
||||
}
|
||||
const data = await res.json();
|
||||
void refreshBalance();
|
||||
setRoundId(data.roundId);
|
||||
setCrashAt(data.crashAt);
|
||||
setCrashAt(null);
|
||||
multiplierRef.current = 1;
|
||||
setMultiplier(1.0);
|
||||
setPhase("running");
|
||||
setResult(null);
|
||||
startAnimation(data.crashAt);
|
||||
startAnimation();
|
||||
}
|
||||
|
||||
async function cashOut() {
|
||||
if (!roundId || phase !== "running") return;
|
||||
setErrorMsg(null);
|
||||
if (rafRef.current) cancelAnimationFrame(rafRef.current);
|
||||
const res = await fetch("/api/games/crash", {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ roundId, cashoutAt: multiplier }),
|
||||
body: JSON.stringify({ roundId, action: "cashout", cashoutAt: multiplierRef.current }),
|
||||
});
|
||||
if (!res.ok) return;
|
||||
if (!res.ok) {
|
||||
const e = await res.json();
|
||||
setErrorMsg(e.error ?? "Cashout failed");
|
||||
setPhase("done");
|
||||
return;
|
||||
}
|
||||
const data = await res.json();
|
||||
void refreshBalance();
|
||||
if (typeof data.crashAt === "number") setCrashAt(data.crashAt);
|
||||
setResult(data);
|
||||
setPhase("done");
|
||||
}
|
||||
|
||||
// Auto-crash detection
|
||||
// Crash probe loop (server never leaks crash point before round ends).
|
||||
useEffect(() => {
|
||||
if (phase === "running" && crashAt !== null && multiplier >= crashAt) {
|
||||
if (rafRef.current) cancelAnimationFrame(rafRef.current);
|
||||
// Auto-resolve as loss if player didn't cash out
|
||||
fetch("/api/games/crash", {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ roundId, cashoutAt: crashAt + 1 }),
|
||||
}).then(r => r.json()).then(data => { setResult(data); setPhase("done"); });
|
||||
}
|
||||
}, [multiplier, crashAt, phase, roundId]);
|
||||
if (phase !== "running" || !roundId) return;
|
||||
const id = setInterval(async () => {
|
||||
try {
|
||||
const res = await fetch("/api/games/crash", {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ roundId, action: "tick", currentMultiplier: multiplierRef.current }),
|
||||
});
|
||||
if (!res.ok) return;
|
||||
const data = await res.json();
|
||||
if (data.outcome === "loss") {
|
||||
if (rafRef.current) cancelAnimationFrame(rafRef.current);
|
||||
if (typeof data.crashAt === "number") {
|
||||
setCrashAt(data.crashAt);
|
||||
setMultiplier(data.crashAt);
|
||||
}
|
||||
setResult(data);
|
||||
setPhase("done");
|
||||
void refreshBalance();
|
||||
}
|
||||
} catch {
|
||||
// keep UI running; transient probe errors should not break gameplay
|
||||
}
|
||||
}, 220);
|
||||
return () => clearInterval(id);
|
||||
}, [phase, roundId]);
|
||||
|
||||
useEffect(() => () => { if (rafRef.current) cancelAnimationFrame(rafRef.current); }, []);
|
||||
|
||||
@@ -79,6 +118,11 @@ export function CrashGame({ balance }: { balance: number }) {
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{errorMsg ? (
|
||||
<div className="rounded-xl border border-rose-500/40 bg-rose-500/10 p-3 text-sm text-rose-200">
|
||||
{errorMsg}
|
||||
</div>
|
||||
) : null}
|
||||
<div className={`relative rounded-2xl border flex flex-col items-center justify-center h-56 overflow-hidden transition-colors ${
|
||||
crashed ? "border-red-500/50 bg-red-900/20" : won ? "border-green-500/50 bg-green-900/20" : "border-white/10 bg-white/5"
|
||||
}`}>
|
||||
|
||||
@@ -1,34 +1,47 @@
|
||||
"use client";
|
||||
import { creditTicker } from "@/lib/credits-brand";
|
||||
import { useState } from "react";
|
||||
import { useLiveWalletBalance } from "./useLiveWalletBalance";
|
||||
|
||||
const T = creditTicker();
|
||||
|
||||
export function DiceGame({ balance }: { balance: number }) {
|
||||
export function DiceGame({ balance: initialBalance }: { balance: number }) {
|
||||
const { balance, refreshBalance } = useLiveWalletBalance(initialBalance);
|
||||
const [wager, setWager] = useState(10);
|
||||
const [threshold, setThreshold] = useState(50);
|
||||
const [direction, setDirection] = useState<"over" | "under">("over");
|
||||
const [clientSeed, setClientSeed] = useState("my-seed");
|
||||
const [result, setResult] = useState<{ roll: number; won: boolean; payout: number; multiplier: number; serverSeed: string } | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [errorMsg, setErrorMsg] = useState<string | null>(null);
|
||||
|
||||
const winProb = direction === "over" ? (99 - threshold) / 100 : threshold / 100;
|
||||
const multiplier = (0.98 / winProb).toFixed(4);
|
||||
|
||||
async function roll() {
|
||||
setErrorMsg(null);
|
||||
setLoading(true);
|
||||
setResult(null); // clear previous outcome so the result card animates in fresh
|
||||
const res = await fetch("/api/games/dice", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ wageBLW: wager, clientSeed, threshold, direction }),
|
||||
});
|
||||
setLoading(false);
|
||||
if (!res.ok) { const e = await res.json(); alert(e.error); return; }
|
||||
if (!res.ok) {
|
||||
const e = await res.json();
|
||||
setErrorMsg(e.error ?? "Could not roll");
|
||||
return;
|
||||
}
|
||||
setResult(await res.json());
|
||||
void refreshBalance();
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{errorMsg ? (
|
||||
<div className="rounded-xl border border-rose-500/40 bg-rose-500/10 p-3 text-sm text-rose-200">{errorMsg}</div>
|
||||
) : null}
|
||||
{result && (
|
||||
<div className={`rounded-2xl border p-6 text-center ${result.won ? "border-green-500/50 bg-green-900/20" : "border-red-500/50 bg-red-900/20"}`}>
|
||||
<p className="text-xs uppercase tracking-widest text-slate-400 mb-2">Roll</p>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { creditTicker } from "@/lib/credits-brand";
|
||||
import { useEffect, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useLiveWalletBalance } from "./useLiveWalletBalance";
|
||||
|
||||
const T = creditTicker();
|
||||
|
||||
@@ -16,57 +17,67 @@ interface Props {
|
||||
}
|
||||
|
||||
export function ExchangePanel({ initialBalance }: Props) {
|
||||
const [balance, setBalance] = useState(initialBalance);
|
||||
// Single source of truth for balance — same hook the games use, so the panel
|
||||
// and the active game always show the same number.
|
||||
const { balance } = useLiveWalletBalance(initialBalance);
|
||||
const [rate, setRate] = useState<RateData | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
async function fetchRate() {
|
||||
const r = await fetch("/api/exchange/rate");
|
||||
if (r.ok) setRate(await r.json());
|
||||
}
|
||||
async function fetchBalance() {
|
||||
const r = await fetch("/api/wallet");
|
||||
if (r.ok) {
|
||||
const d = await r.json();
|
||||
setBalance(d.balanceCredits ?? d.balance ?? 0);
|
||||
let alive = true;
|
||||
const fetchRate = async () => {
|
||||
try {
|
||||
const r = await fetch("/api/exchange/rate", { cache: "no-store" });
|
||||
if (r.ok && alive) setRate(await r.json());
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
fetchRate();
|
||||
fetchBalance();
|
||||
const iv = setInterval(() => { fetchRate(); fetchBalance(); }, 15_000);
|
||||
return () => clearInterval(iv);
|
||||
};
|
||||
void fetchRate();
|
||||
const iv = setInterval(() => void fetchRate(), 15_000);
|
||||
return () => {
|
||||
alive = false;
|
||||
clearInterval(iv);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const usdValue = rate ? (balance * rate.blwUsd).toFixed(2) : "—";
|
||||
|
||||
// Mini sparkline
|
||||
const spark = rate?.sparkline ?? [];
|
||||
const sparkMin = Math.min(...spark);
|
||||
const sparkMax = Math.max(...spark);
|
||||
const sparkMin = spark.length ? Math.min(...spark) : 0;
|
||||
const sparkMax = spark.length ? Math.max(...spark) : 0;
|
||||
const sparkRange = sparkMax - sparkMin || 1;
|
||||
|
||||
return (
|
||||
<div className="rounded-2xl border border-white/10 bg-white/5 p-5 backdrop-blur-sm">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div>
|
||||
<div className="rounded-2xl border border-white/10 bg-gradient-to-br from-white/[0.07] via-white/[0.03] to-transparent p-5 backdrop-blur-sm">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<p className="text-xs uppercase tracking-widest text-slate-400">Balance</p>
|
||||
<p className="text-3xl font-bold text-white tabular-nums">
|
||||
{balance.toLocaleString()} <span className="text-sky-400 text-lg">{rate?.symbol ?? T}</span>
|
||||
<p className="text-3xl font-bold text-white tabular-nums leading-tight">
|
||||
{balance.toLocaleString()}{" "}
|
||||
<span className="text-sky-400 text-lg">{rate?.symbol ?? T}</span>
|
||||
</p>
|
||||
<p className="text-sm text-slate-400 mt-0.5">≈ ${usdValue} USD</p>
|
||||
</div>
|
||||
<Link
|
||||
href="/donate"
|
||||
className="rounded-xl bg-gradient-to-r from-sky-500 to-indigo-500 px-4 py-2 text-sm font-semibold text-white shadow-lg shadow-sky-500/20 hover:opacity-90 transition-opacity"
|
||||
>
|
||||
Earn {T}
|
||||
</Link>
|
||||
<div className="flex shrink-0 flex-col items-stretch gap-1.5">
|
||||
<Link
|
||||
href="/donate"
|
||||
className="rounded-xl bg-gradient-to-r from-sky-500 to-indigo-500 px-4 py-2 text-center text-sm font-semibold text-white shadow-lg shadow-sky-500/20 hover:opacity-90 transition-opacity"
|
||||
>
|
||||
Earn {T}
|
||||
</Link>
|
||||
<Link
|
||||
href="/wallet"
|
||||
className="rounded-xl border border-white/15 px-4 py-1.5 text-center text-xs font-medium text-slate-200 hover:bg-white/5"
|
||||
>
|
||||
Wallet →
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{spark.length > 1 && (
|
||||
<div className="mt-3">
|
||||
<div className="mt-4">
|
||||
<p className="text-xs text-slate-500 mb-1">{T}/USD — 48h</p>
|
||||
<svg viewBox={`0 0 ${spark.length} 30`} className="w-full h-8" preserveAspectRatio="none">
|
||||
<svg viewBox={`0 0 ${spark.length} 30`} className="w-full h-8" preserveAspectRatio="none" aria-hidden>
|
||||
<polyline
|
||||
fill="none"
|
||||
stroke="url(#sg)"
|
||||
|
||||
@@ -22,14 +22,21 @@ const GAME_ICONS: Record<string, string> = {
|
||||
export function GameHistory() {
|
||||
const [sessions, setSessions] = useState<GameSession[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [errorMsg, setErrorMsg] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetch("/api/games/history?limit=15")
|
||||
.then(r => r.json())
|
||||
.then(d => { setSessions(d.sessions ?? []); setLoading(false); });
|
||||
.then(async (r) => {
|
||||
if (!r.ok) throw new Error("Could not load game history");
|
||||
return r.json();
|
||||
})
|
||||
.then(d => { setSessions(d.sessions ?? []); setErrorMsg(null); })
|
||||
.catch((e) => setErrorMsg(e instanceof Error ? e.message : "Could not load game history"))
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
if (loading) return <div className="animate-pulse h-32 rounded-xl bg-white/5" />;
|
||||
if (errorMsg) return <p className="text-center text-sm text-rose-300 py-6">{errorMsg}</p>;
|
||||
if (sessions.length === 0) return <p className="text-center text-slate-500 py-6">No games played yet.</p>;
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,12 +1,25 @@
|
||||
"use client";
|
||||
import { creditTicker } from "@/lib/credits-brand";
|
||||
import { useState } from "react";
|
||||
import { useState, useEffect } from "react";
|
||||
import { useLiveWalletBalance } from "./useLiveWalletBalance";
|
||||
|
||||
const T = creditTicker();
|
||||
|
||||
type TileState = "hidden" | "safe" | "mine";
|
||||
|
||||
export function MinesGame({ balance }: { balance: number }) {
|
||||
const GRID = 25;
|
||||
|
||||
function calcMultiplier(revealed: number, mines: number): number {
|
||||
let mult = 1.0;
|
||||
const safe = GRID - mines;
|
||||
for (let i = 0; i < revealed; i++) {
|
||||
mult *= ((safe - i) / (GRID - i)) * 0.99;
|
||||
}
|
||||
return parseFloat((1 / mult).toFixed(4));
|
||||
}
|
||||
|
||||
export function MinesGame({ balance: initialBalance }: { balance: number }) {
|
||||
const { balance, refreshBalance } = useLiveWalletBalance(initialBalance);
|
||||
const [wager, setWager] = useState(10);
|
||||
const [mineCount, setMineCount] = useState(5);
|
||||
const [clientSeed, setClientSeed] = useState("my-seed");
|
||||
@@ -15,20 +28,56 @@ export function MinesGame({ balance }: { balance: number }) {
|
||||
const [tiles, setTiles] = useState<TileState[]>(Array(25).fill("hidden"));
|
||||
const [multiplier, setMultiplier] = useState(1.0);
|
||||
const [result, setResult] = useState<{ outcome: string; payout?: number; minePositions?: number[] } | null>(null);
|
||||
const [errorMsg, setErrorMsg] = useState<string | null>(null);
|
||||
|
||||
// Resume an in-progress round after refresh/reconnect.
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
try {
|
||||
const r = await fetch("/api/games/active?gameType=MINES");
|
||||
if (!r.ok) return;
|
||||
const d = await r.json();
|
||||
if (cancelled || !d.session) return;
|
||||
const rd = d.session.resultData ?? {};
|
||||
const revealed: number[] = Array.isArray(rd.revealed) ? rd.revealed : [];
|
||||
const mines: number = typeof rd.mineCount === "number" ? rd.mineCount : 0;
|
||||
const tilesNext = Array<TileState>(GRID).fill("hidden");
|
||||
// We don't know which revealed tiles were mines — but a still-active
|
||||
// round can only contain safe reveals, so all are "safe".
|
||||
revealed.forEach((idx) => { if (idx >= 0 && idx < GRID) tilesNext[idx] = "safe"; });
|
||||
setRoundId(d.session.id);
|
||||
setTiles(tilesNext);
|
||||
setMineCount(mines || 5);
|
||||
setWager(d.session.wageredBLW);
|
||||
setMultiplier(calcMultiplier(revealed.length, mines));
|
||||
setPhase("playing");
|
||||
} catch {
|
||||
/* ignore — fall back to idle */
|
||||
}
|
||||
})();
|
||||
return () => { cancelled = true; };
|
||||
}, []);
|
||||
|
||||
async function startGame() {
|
||||
setErrorMsg(null);
|
||||
const res = await fetch("/api/games/mines", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ wageBLW: wager, clientSeed, mineCount }),
|
||||
});
|
||||
if (!res.ok) { const e = await res.json(); alert(e.error); return; }
|
||||
if (!res.ok) {
|
||||
const e = await res.json();
|
||||
setErrorMsg(e.error ?? "Could not start game");
|
||||
return;
|
||||
}
|
||||
const data = await res.json();
|
||||
setRoundId(data.roundId);
|
||||
setTiles(Array(25).fill("hidden"));
|
||||
setMultiplier(1.0);
|
||||
setResult(null);
|
||||
setPhase("playing");
|
||||
void refreshBalance();
|
||||
}
|
||||
|
||||
async function revealTile(idx: number) {
|
||||
@@ -38,7 +87,11 @@ export function MinesGame({ balance }: { balance: number }) {
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ roundId, action: "reveal", tile: idx }),
|
||||
});
|
||||
if (!res.ok) return;
|
||||
if (!res.ok) {
|
||||
const e = await res.json().catch(() => ({ error: "Move rejected" }));
|
||||
setErrorMsg(e.error ?? "Move rejected");
|
||||
return;
|
||||
}
|
||||
const data = await res.json();
|
||||
|
||||
const newTiles = [...tiles];
|
||||
@@ -50,11 +103,13 @@ export function MinesGame({ balance }: { balance: number }) {
|
||||
setTiles(newTiles);
|
||||
setResult(data);
|
||||
setPhase("done");
|
||||
void refreshBalance();
|
||||
} else if (data.outcome === "win" || data.outcome === "cashout") {
|
||||
setTiles(newTiles);
|
||||
setMultiplier(data.multiplier);
|
||||
setResult(data);
|
||||
setPhase("done");
|
||||
void refreshBalance();
|
||||
} else {
|
||||
setTiles(newTiles);
|
||||
setMultiplier(data.multiplier);
|
||||
@@ -63,12 +118,17 @@ export function MinesGame({ balance }: { balance: number }) {
|
||||
|
||||
async function cashOut() {
|
||||
if (phase !== "playing") return;
|
||||
setErrorMsg(null);
|
||||
const res = await fetch("/api/games/mines", {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ roundId, action: "cashout" }),
|
||||
});
|
||||
if (!res.ok) return;
|
||||
if (!res.ok) {
|
||||
const e = await res.json().catch(() => ({ error: "Cashout failed" }));
|
||||
setErrorMsg(e.error ?? "Cashout failed");
|
||||
return;
|
||||
}
|
||||
const data = await res.json();
|
||||
if (data.minePositions) {
|
||||
const newTiles = [...tiles];
|
||||
@@ -77,13 +137,21 @@ export function MinesGame({ balance }: { balance: number }) {
|
||||
}
|
||||
setResult(data);
|
||||
setPhase("done");
|
||||
void refreshBalance();
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
{errorMsg ? (
|
||||
<div className="rounded-xl border border-rose-500/40 bg-rose-500/10 p-3 text-sm text-rose-200">{errorMsg}</div>
|
||||
) : null}
|
||||
{result && phase === "done" && (
|
||||
<div className={`rounded-xl border p-4 text-center ${result.outcome === "loss" ? "border-red-500/50 bg-red-900/20 text-red-300" : "border-green-500/50 bg-green-900/20 text-green-300"}`}>
|
||||
{result.outcome === "loss" ? "💥 Hit a mine!" : `💰 +${result.payout} ${T} at ${multiplier.toFixed(2)}x`}
|
||||
{result.outcome === "loss"
|
||||
? "💥 Hit a mine!"
|
||||
: result.outcome === "push"
|
||||
? `↩ Refunded ${result.payout} ${T}`
|
||||
: `💰 +${result.payout} ${T} at ${multiplier.toFixed(2)}x`}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -2,12 +2,14 @@
|
||||
import { creditTicker } from "@/lib/credits-brand";
|
||||
import { useState, useEffect, useRef, useCallback } from "react";
|
||||
import { io, Socket } from "socket.io-client";
|
||||
import { useLiveWalletBalance } from "./useLiveWalletBalance";
|
||||
|
||||
const T = creditTicker();
|
||||
|
||||
interface Room { id: string; wageBLW: number; creatorId: string; }
|
||||
|
||||
export function PongGame({ userId, balance }: { userId: string; balance: number }) {
|
||||
export function PongGame({ userId, balance: initialBalance }: { userId: string; balance: number }) {
|
||||
const { balance, refreshBalance } = useLiveWalletBalance(initialBalance);
|
||||
const [rooms, setRooms] = useState<Room[]>([]);
|
||||
const [wager, setWager] = useState(100);
|
||||
const [phase, setPhase] = useState<"lobby" | "waiting" | "playing" | "done">("lobby");
|
||||
@@ -16,7 +18,7 @@ export function PongGame({ userId, balance }: { userId: string; balance: number
|
||||
const [scores, setScores] = useState({ creator: 0, joiner: 0 });
|
||||
const [winner, setWinner] = useState<{ winnerId: string; payout: number } | null>(null);
|
||||
const [role, setRole] = useState<"creator" | "joiner" | null>(null);
|
||||
const [creatorId, setCreatorId] = useState<string | null>(null);
|
||||
const [errorMsg, setErrorMsg] = useState<string | null>(null);
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const gameState = useRef({ ball: { x: 400, y: 200 }, paddles: { creator: 180, joiner: 180 } });
|
||||
|
||||
@@ -25,6 +27,12 @@ export function PongGame({ userId, balance }: { userId: string; balance: number
|
||||
|
||||
useEffect(() => { fetchRooms(); }, []);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
socket?.disconnect();
|
||||
};
|
||||
}, [socket]);
|
||||
|
||||
async function fetchRooms() {
|
||||
const r = await fetch("/api/games/rooms?gameType=PONG");
|
||||
if (r.ok) { const d = await r.json(); setRooms(d.rooms); }
|
||||
@@ -57,15 +65,16 @@ export function PongGame({ userId, balance }: { userId: string; balance: number
|
||||
ctx.fillRect(CANVAS_W - 20, paddles.joiner, 12, 80);
|
||||
}
|
||||
|
||||
function connectSocket(roomId: string, r: "creator" | "joiner") {
|
||||
function connectSocket(roomId: string) {
|
||||
setErrorMsg(null);
|
||||
const s = io("/pong", { path: "/api/socket" });
|
||||
setSocket(s);
|
||||
s.emit("join_room", { roomId, userId });
|
||||
s.emit("join_room", { roomId });
|
||||
|
||||
s.on("waiting", () => setPhase("waiting"));
|
||||
s.on("game_start", ({ creatorId: cid }: { creatorId: string; joinerId: string }) => {
|
||||
setCreatorId(cid);
|
||||
s.on("game_start", ({ creatorId: _cid }: { creatorId: string; joinerId: string }) => {
|
||||
setPhase("playing");
|
||||
void refreshBalance();
|
||||
});
|
||||
s.on("tick", ({ ball, paddles, scores: sc }: { ball: { x: number; y: number }; paddles: { creator: number; joiner: number }; scores: { creator: number; joiner: number } }) => {
|
||||
gameState.current = { ball, paddles };
|
||||
@@ -75,20 +84,36 @@ export function PongGame({ userId, balance }: { userId: string; balance: number
|
||||
s.on("game_over", (data: { winnerId: string; payout: number }) => {
|
||||
setWinner(data);
|
||||
setPhase("done");
|
||||
void refreshBalance();
|
||||
s.disconnect();
|
||||
});
|
||||
s.on("error", (msg: string) => { alert(msg); s.disconnect(); setPhase("lobby"); });
|
||||
s.on("error", (msg: string) => {
|
||||
setErrorMsg(msg);
|
||||
void refreshBalance();
|
||||
s.disconnect();
|
||||
setPhase("lobby");
|
||||
});
|
||||
}
|
||||
|
||||
// Mouse/touch paddle control
|
||||
const handleMouseMove = useCallback((e: React.MouseEvent<HTMLCanvasElement>) => {
|
||||
const emitPaddleMove = useCallback((clientY: number) => {
|
||||
if (phase !== "playing" || !socket || !myRoomId) return;
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const y = ((e.clientY - rect.top) / rect.height) * CANVAS_H - 40;
|
||||
socket.emit("paddle_move", { roomId: myRoomId, userId, y });
|
||||
}, [phase, socket, myRoomId, userId]);
|
||||
const y = ((clientY - rect.top) / rect.height) * CANVAS_H - 40;
|
||||
socket.emit("paddle_move", { roomId: myRoomId, y });
|
||||
}, [phase, socket, myRoomId]);
|
||||
|
||||
const handleMouseMove = useCallback((e: React.MouseEvent<HTMLCanvasElement>) => {
|
||||
emitPaddleMove(e.clientY);
|
||||
}, [emitPaddleMove]);
|
||||
|
||||
const handleTouchMove = useCallback((e: React.TouchEvent<HTMLCanvasElement>) => {
|
||||
e.preventDefault();
|
||||
const touch = e.touches[0];
|
||||
if (touch) emitPaddleMove(touch.clientY);
|
||||
}, [emitPaddleMove]);
|
||||
|
||||
async function createRoom() {
|
||||
const res = await fetch("/api/games/rooms", {
|
||||
@@ -96,18 +121,39 @@ export function PongGame({ userId, balance }: { userId: string; balance: number
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ gameType: "PONG", wageBLW: wager }),
|
||||
});
|
||||
if (!res.ok) { const e = await res.json(); alert(e.error); return; }
|
||||
if (!res.ok) {
|
||||
const e = await res.json();
|
||||
setErrorMsg(e.error ?? "Could not create room");
|
||||
return;
|
||||
}
|
||||
const { room } = await res.json();
|
||||
setMyRoomId(room.id);
|
||||
setRole("creator");
|
||||
connectSocket(room.id, "creator");
|
||||
connectSocket(room.id);
|
||||
setPhase("waiting");
|
||||
void refreshBalance();
|
||||
}
|
||||
|
||||
async function cancelRoom() {
|
||||
if (!myRoomId) return;
|
||||
const res = await fetch(`/api/games/rooms?id=${encodeURIComponent(myRoomId)}`, { method: "DELETE" });
|
||||
socket?.disconnect();
|
||||
if (res.ok) {
|
||||
setMyRoomId(null);
|
||||
setRole(null);
|
||||
setPhase("lobby");
|
||||
void refreshBalance();
|
||||
fetchRooms();
|
||||
} else {
|
||||
const e = await res.json().catch(() => ({ error: "Could not cancel" }));
|
||||
setErrorMsg(e.error ?? "Could not cancel");
|
||||
}
|
||||
}
|
||||
|
||||
function joinRoom(room: Room) {
|
||||
setMyRoomId(room.id);
|
||||
setRole("joiner");
|
||||
connectSocket(room.id, "joiner");
|
||||
connectSocket(room.id);
|
||||
}
|
||||
|
||||
if (phase === "waiting") {
|
||||
@@ -116,6 +162,14 @@ export function PongGame({ userId, balance }: { userId: string; balance: number
|
||||
<div className="text-5xl animate-bounce">🏓</div>
|
||||
<p className="text-white font-semibold">Waiting for opponent…</p>
|
||||
{myRoomId && <p className="text-slate-400 text-sm font-mono">Room: {myRoomId.slice(0, 12)}…</p>}
|
||||
{role === "creator" && myRoomId ? (
|
||||
<button
|
||||
onClick={cancelRoom}
|
||||
className="rounded-xl border border-white/15 bg-white/5 px-5 py-2 text-sm font-semibold text-slate-200 hover:bg-white/10"
|
||||
>
|
||||
Cancel & Refund
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -137,8 +191,8 @@ export function PongGame({ userId, balance }: { userId: string; balance: number
|
||||
</div>
|
||||
</div>
|
||||
<div className="relative rounded-xl overflow-hidden border border-white/10">
|
||||
<canvas ref={canvasRef} width={CANVAS_W} height={CANVAS_H} onMouseMove={handleMouseMove}
|
||||
className="w-full cursor-none" style={{ aspectRatio: `${CANVAS_W}/${CANVAS_H}` }} />
|
||||
<canvas ref={canvasRef} width={CANVAS_W} height={CANVAS_H} onMouseMove={handleMouseMove} onTouchMove={handleTouchMove}
|
||||
className="w-full cursor-none touch-none" style={{ aspectRatio: `${CANVAS_W}/${CANVAS_H}` }} />
|
||||
{phase === "done" && winner && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-black/70 rounded-xl">
|
||||
<div className={`text-center p-8 rounded-2xl border ${won ? "border-green-500/50 bg-green-900/30" : "border-red-500/50 bg-red-900/30"}`}>
|
||||
@@ -152,13 +206,18 @@ export function PongGame({ userId, balance }: { userId: string; balance: number
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-center text-slate-500">Move your mouse over the canvas to control your paddle</p>
|
||||
<p className="text-xs text-center text-slate-500">Move your mouse or drag on the canvas to control your paddle</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
{errorMsg ? (
|
||||
<div className="rounded-xl border border-rose-500/40 bg-rose-500/10 p-3 text-sm text-rose-200">
|
||||
{errorMsg}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="rounded-xl border border-white/10 bg-white/5 p-4 space-y-3">
|
||||
<p className="text-sm font-semibold text-white">Create a Room</p>
|
||||
<div className="flex gap-3">
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
import { creditTicker } from "@/lib/credits-brand";
|
||||
import { useState, useEffect } from "react";
|
||||
import { useLiveWalletBalance } from "./useLiveWalletBalance";
|
||||
|
||||
const T = creditTicker();
|
||||
|
||||
@@ -15,19 +16,20 @@ interface Market {
|
||||
_count?: { bets: number };
|
||||
}
|
||||
|
||||
export function PredictionMarket({ userId, balance }: { userId: string; balance: number }) {
|
||||
export function PredictionMarket({ userId, balance: initialBalance }: { userId: string; balance: number }) {
|
||||
const { balance, refreshBalance } = useLiveWalletBalance(initialBalance);
|
||||
const [markets, setMarkets] = useState<Market[]>([]);
|
||||
const [tab, setTab] = useState<"open" | "create">("open");
|
||||
const [tab, setTab] = useState<"open" | "resolved" | "create">("open");
|
||||
const [question, setQuestion] = useState("");
|
||||
const [endsAt, setEndsAt] = useState("");
|
||||
const [betAmounts, setBetAmounts] = useState<Record<string, number>>({});
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [msg, setMsg] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => { fetchMarkets(); }, []);
|
||||
useEffect(() => { fetchMarkets(); }, [tab]);
|
||||
|
||||
async function fetchMarkets() {
|
||||
const r = await fetch("/api/games/prediction");
|
||||
const r = await fetch(`/api/games/prediction${tab === "resolved" ? "?resolved=1" : ""}`);
|
||||
if (r.ok) { const d = await r.json(); setMarkets(d.markets); }
|
||||
}
|
||||
|
||||
@@ -57,6 +59,7 @@ export function PredictionMarket({ userId, balance }: { userId: string; balance:
|
||||
setLoading(false);
|
||||
if (!res.ok) { const e = await res.json(); setMsg(e.error); return; }
|
||||
setMsg(`Bet placed on ${side ? "YES" : "NO"}!`);
|
||||
void refreshBalance();
|
||||
fetchMarkets();
|
||||
}
|
||||
|
||||
@@ -68,10 +71,12 @@ export function PredictionMarket({ userId, balance }: { userId: string; balance:
|
||||
});
|
||||
if (!res.ok) { const e = await res.json(); setMsg(e.error); return; }
|
||||
setMsg("Market resolved!");
|
||||
void refreshBalance();
|
||||
fetchMarkets();
|
||||
}
|
||||
|
||||
const minDate = new Date(Date.now() + 60_000).toISOString().slice(0, 16);
|
||||
const visibleMarkets = tab === "resolved" ? markets.filter((m) => m.resolvedTo !== null) : markets;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
@@ -82,10 +87,10 @@ export function PredictionMarket({ userId, balance }: { userId: string; balance:
|
||||
)}
|
||||
|
||||
<div className="flex gap-2">
|
||||
{(["open", "create"] as const).map(t => (
|
||||
{(["open", "resolved", "create"] as const).map(t => (
|
||||
<button key={t} onClick={() => setTab(t)}
|
||||
className={`rounded-lg px-4 py-2 text-sm font-semibold transition-colors capitalize ${tab === t ? "bg-sky-600 text-white" : "border border-white/10 text-slate-400 hover:bg-white/5"}`}>
|
||||
{t === "open" ? "📊 Open Markets" : "➕ Create Market"}
|
||||
{t === "open" ? "📊 Open Markets" : t === "resolved" ? "✅ Resolved" : "➕ Create Market"}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
@@ -111,10 +116,10 @@ export function PredictionMarket({ userId, balance }: { userId: string; balance:
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tab === "open" && (
|
||||
{(tab === "open" || tab === "resolved") && (
|
||||
<div className="space-y-3">
|
||||
{markets.length === 0 && <p className="text-center text-slate-500 py-8">No open markets — create one!</p>}
|
||||
{markets.map(m => {
|
||||
{visibleMarkets.length === 0 && <p className="text-center text-slate-500 py-8">{tab === "resolved" ? "No resolved markets yet." : "No open markets — create one!"}</p>}
|
||||
{visibleMarkets.map(m => {
|
||||
const total = m.totalYes + m.totalNo;
|
||||
const yesPercent = total > 0 ? Math.round((m.totalYes / total) * 100) : 50;
|
||||
const isExpired = new Date(m.endsAt) < new Date();
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
import { creditTicker } from "@/lib/credits-brand";
|
||||
import { useState } from "react";
|
||||
import { useLiveWalletBalance } from "./useLiveWalletBalance";
|
||||
|
||||
const T = creditTicker();
|
||||
|
||||
@@ -21,7 +22,8 @@ const BET_TYPES = [
|
||||
|
||||
const RED_NUMS = new Set([1,3,5,7,9,12,14,16,18,19,21,23,25,27,30,32,34,36]);
|
||||
|
||||
export function RouletteGame({ balance }: { balance: number }) {
|
||||
export function RouletteGame({ balance: initialBalance }: { balance: number }) {
|
||||
const { balance, refreshBalance } = useLiveWalletBalance(initialBalance);
|
||||
const [wager, setWager] = useState(10);
|
||||
const [selectedBet, setSelectedBet] = useState(BET_TYPES[0]);
|
||||
const [straightNum, setStraightNum] = useState(7);
|
||||
@@ -29,8 +31,10 @@ export function RouletteGame({ balance }: { balance: number }) {
|
||||
const [result, setResult] = useState<{ result: number; color: string; won: boolean; payout: number; multiplier: number; serverSeed: string } | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [ballPos, setBallPos] = useState<number | null>(null);
|
||||
const [errorMsg, setErrorMsg] = useState<string | null>(null);
|
||||
|
||||
async function spin() {
|
||||
setErrorMsg(null);
|
||||
setLoading(true);
|
||||
setResult(null);
|
||||
setBallPos(null);
|
||||
@@ -45,16 +49,24 @@ export function RouletteGame({ balance }: { balance: number }) {
|
||||
body: JSON.stringify({ wageBLW: wager, clientSeed, betType, betValue }),
|
||||
});
|
||||
setLoading(false);
|
||||
if (!res.ok) { const e = await res.json(); alert(e.error); return; }
|
||||
if (!res.ok) {
|
||||
const e = await res.json();
|
||||
setErrorMsg(e.error ?? "Spin failed");
|
||||
return;
|
||||
}
|
||||
const data = await res.json();
|
||||
setBallPos(data.result);
|
||||
setResult(data);
|
||||
void refreshBalance();
|
||||
}
|
||||
|
||||
const numColor = (n: number) => n === 0 ? "bg-green-700" : RED_NUMS.has(n) ? "bg-red-700" : "bg-slate-800";
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
{errorMsg ? (
|
||||
<div className="rounded-xl border border-rose-500/40 bg-rose-500/10 p-3 text-sm text-rose-200">{errorMsg}</div>
|
||||
) : null}
|
||||
{result && (
|
||||
<div className={`rounded-2xl border p-4 flex items-center justify-between ${result.won ? "border-green-500/50 bg-green-900/20" : "border-red-500/50 bg-red-900/20"}`}>
|
||||
<div className={`w-14 h-14 rounded-full ${numColor(result.result)} flex items-center justify-center text-xl font-black text-white border-2 border-white/20`}>
|
||||
|
||||
@@ -1,31 +1,33 @@
|
||||
"use client";
|
||||
import { creditTicker } from "@/lib/credits-brand";
|
||||
import { useState, useRef } from "react";
|
||||
import { useState } from "react";
|
||||
import { useLiveWalletBalance } from "./useLiveWalletBalance";
|
||||
|
||||
const T = creditTicker();
|
||||
|
||||
export function SlotsGame({ balance }: { balance: number }) {
|
||||
export function SlotsGame({ balance: initialBalance }: { balance: number }) {
|
||||
const { balance, refreshBalance } = useLiveWalletBalance(initialBalance);
|
||||
const [wager, setWager] = useState(10);
|
||||
const [clientSeed, setClientSeed] = useState("my-seed");
|
||||
const [spinning, setSpinning] = useState(false);
|
||||
const [displayReels, setDisplayReels] = useState(["🎰", "🎰", "🎰"]);
|
||||
const [result, setResult] = useState<{ reels: string[]; multiplier: number; payout: number; outcome: string; serverSeed: string } | null>(null);
|
||||
const [errorMsg, setErrorMsg] = useState<string | null>(null);
|
||||
|
||||
const SYMBOLS = ["🍒", "🍋", "🍊", "🍇", "💎", "7️⃣"];
|
||||
|
||||
async function spin() {
|
||||
setErrorMsg(null);
|
||||
setSpinning(true);
|
||||
setResult(null);
|
||||
|
||||
// Animate reels
|
||||
let ticks = 0;
|
||||
// Animate reels while the server resolves the spin.
|
||||
const iv = setInterval(() => {
|
||||
setDisplayReels([
|
||||
SYMBOLS[Math.floor(Math.random() * SYMBOLS.length)],
|
||||
SYMBOLS[Math.floor(Math.random() * SYMBOLS.length)],
|
||||
SYMBOLS[Math.floor(Math.random() * SYMBOLS.length)],
|
||||
]);
|
||||
ticks++;
|
||||
}, 80);
|
||||
|
||||
const res = await fetch("/api/games/slots", {
|
||||
@@ -37,16 +39,24 @@ export function SlotsGame({ balance }: { balance: number }) {
|
||||
clearInterval(iv);
|
||||
setSpinning(false);
|
||||
|
||||
if (!res.ok) { const e = await res.json(); alert(e.error); return; }
|
||||
if (!res.ok) {
|
||||
const e = await res.json();
|
||||
setErrorMsg(e.error ?? "Spin failed");
|
||||
return;
|
||||
}
|
||||
const data = await res.json();
|
||||
setResult(data);
|
||||
setDisplayReels(data.reels);
|
||||
void refreshBalance();
|
||||
}
|
||||
|
||||
const won = result && result.payout > 0;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{errorMsg ? (
|
||||
<div className="rounded-xl border border-rose-500/40 bg-rose-500/10 p-3 text-sm text-rose-200">{errorMsg}</div>
|
||||
) : null}
|
||||
<div className={`rounded-2xl border p-6 transition-colors ${won ? "border-yellow-500/50 bg-yellow-900/20" : "border-white/10 bg-white/5"}`}>
|
||||
<div className="flex justify-center gap-4 mb-4">
|
||||
{displayReels.map((sym, i) => (
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
import { creditTicker } from "@/lib/credits-brand";
|
||||
import { useState } from "react";
|
||||
import { useState, useEffect } from "react";
|
||||
import { useLiveWalletBalance } from "./useLiveWalletBalance";
|
||||
|
||||
const T = creditTicker();
|
||||
|
||||
@@ -8,23 +9,56 @@ const FLOOR_MULTS = [1.4, 2.0, 2.8, 4.0, 5.6, 8.0, 12.0, 18.0];
|
||||
const FLOORS = 8;
|
||||
const TILES = 3;
|
||||
|
||||
export function TowerGame({ balance }: { balance: number }) {
|
||||
export function TowerGame({ balance: initialBalance }: { balance: number }) {
|
||||
const { balance, refreshBalance } = useLiveWalletBalance(initialBalance);
|
||||
const [wager, setWager] = useState(10);
|
||||
const [clientSeed, setClientSeed] = useState("my-seed");
|
||||
const [phase, setPhase] = useState<"idle" | "playing" | "done">("idle");
|
||||
const [roundId, setRoundId] = useState<string | null>(null);
|
||||
const [currentFloor, setCurrentFloor] = useState(0);
|
||||
const [revealedBombs, setRevealedBombs] = useState<number[][]>(Array(FLOORS).fill(null).map(() => []));
|
||||
const [result, setResult] = useState<{ outcome: string; payout?: number; bombPositions?: number[] } | null>(null);
|
||||
const [result, setResult] = useState<{ outcome: string; payout?: number; multiplier?: number; bombPositions?: number[] } | null>(null);
|
||||
const [lockedFloors, setLockedFloors] = useState<number[]>([]);
|
||||
const [errorMsg, setErrorMsg] = useState<string | null>(null);
|
||||
|
||||
// Resume an in-progress climb after refresh/reconnect.
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
try {
|
||||
const r = await fetch("/api/games/active?gameType=TOWER");
|
||||
if (!r.ok) return;
|
||||
const d = await r.json();
|
||||
if (cancelled || !d.session) return;
|
||||
const floor: number = typeof d.session.resultData?.currentFloor === "number"
|
||||
? d.session.resultData.currentFloor : 0;
|
||||
setRoundId(d.session.id);
|
||||
setCurrentFloor(floor);
|
||||
setWager(d.session.wageredBLW);
|
||||
// Cleared floors are visible as "locked" green rows; bombs stay hidden
|
||||
// since they haven't been picked.
|
||||
setLockedFloors(Array.from({ length: floor }, (_, i) => i));
|
||||
setRevealedBombs(Array(FLOORS).fill(null).map(() => []));
|
||||
setPhase("playing");
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
})();
|
||||
return () => { cancelled = true; };
|
||||
}, []);
|
||||
|
||||
async function startGame() {
|
||||
setErrorMsg(null);
|
||||
const res = await fetch("/api/games/tower", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ wageBLW: wager, clientSeed }),
|
||||
});
|
||||
if (!res.ok) { const e = await res.json(); alert(e.error); return; }
|
||||
if (!res.ok) {
|
||||
const e = await res.json();
|
||||
setErrorMsg(e.error ?? "Could not start climb");
|
||||
return;
|
||||
}
|
||||
const data = await res.json();
|
||||
setRoundId(data.roundId);
|
||||
setCurrentFloor(0);
|
||||
@@ -32,6 +66,7 @@ export function TowerGame({ balance }: { balance: number }) {
|
||||
setLockedFloors([]);
|
||||
setResult(null);
|
||||
setPhase("playing");
|
||||
void refreshBalance();
|
||||
}
|
||||
|
||||
async function pickTile(floor: number, tile: number) {
|
||||
@@ -41,7 +76,11 @@ export function TowerGame({ balance }: { balance: number }) {
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ roundId, action: "pick", tile }),
|
||||
});
|
||||
if (!res.ok) return;
|
||||
if (!res.ok) {
|
||||
const e = await res.json().catch(() => ({ error: "Move rejected" }));
|
||||
setErrorMsg(e.error ?? "Move rejected");
|
||||
return;
|
||||
}
|
||||
const data = await res.json();
|
||||
|
||||
const newBombs = revealedBombs.map((f, i) => i === floor ? [data.bombPos] : f);
|
||||
@@ -50,10 +89,12 @@ export function TowerGame({ balance }: { balance: number }) {
|
||||
if (data.outcome === "loss") {
|
||||
setResult(data);
|
||||
setPhase("done");
|
||||
void refreshBalance();
|
||||
} else if (data.outcome === "win") {
|
||||
setLockedFloors(prev => [...prev, floor]);
|
||||
setResult(data);
|
||||
setPhase("done");
|
||||
void refreshBalance();
|
||||
} else {
|
||||
setLockedFloors(prev => [...prev, floor]);
|
||||
setCurrentFloor(data.newFloor);
|
||||
@@ -62,23 +103,32 @@ export function TowerGame({ balance }: { balance: number }) {
|
||||
|
||||
async function cashOut() {
|
||||
if (phase !== "playing" || currentFloor === 0) return;
|
||||
setErrorMsg(null);
|
||||
const res = await fetch("/api/games/tower", {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ roundId, action: "cashout" }),
|
||||
});
|
||||
if (!res.ok) return;
|
||||
if (!res.ok) {
|
||||
const e = await res.json().catch(() => ({ error: "Cashout failed" }));
|
||||
setErrorMsg(e.error ?? "Cashout failed");
|
||||
return;
|
||||
}
|
||||
setResult(await res.json());
|
||||
setPhase("done");
|
||||
void refreshBalance();
|
||||
}
|
||||
|
||||
const multiplierAtFloor = (floor: number) => FLOOR_MULTS[floor] ?? FLOOR_MULTS[FLOORS - 1];
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{errorMsg ? (
|
||||
<div className="rounded-xl border border-rose-500/40 bg-rose-500/10 p-3 text-sm text-rose-200">{errorMsg}</div>
|
||||
) : null}
|
||||
{result && phase === "done" && (
|
||||
<div className={`rounded-xl border p-4 text-center ${result.outcome === "loss" ? "border-red-500/50 bg-red-900/20 text-red-300" : "border-green-500/50 bg-green-900/20 text-green-300"}`}>
|
||||
{result.outcome === "loss" ? "💥 Hit a bomb!" : `🏆 +${result.payout} ${T} at ${multiplierAtFloor(currentFloor - 1)}x`}
|
||||
{result.outcome === "loss" ? "💥 Hit a bomb!" : `🏆 +${result.payout} ${T} at ${(result.multiplier ?? multiplierAtFloor(currentFloor - 1)).toFixed(2)}x`}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
57
src/components/casino/useLiveWalletBalance.ts
Normal file
57
src/components/casino/useLiveWalletBalance.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
/**
|
||||
* Keeps a game component's balance UI in sync with the server.
|
||||
*
|
||||
* Sync sources:
|
||||
* - 15 s polling (covers passive drift, donations, refunds).
|
||||
* - The `wallet:refresh` window event — broadcast by any other game or
|
||||
* wallet UI when it knows the balance just changed.
|
||||
* - `visibilitychange` — refetch when the user returns to the tab.
|
||||
*
|
||||
* After any local mutation the consumer calls `refreshBalance()`, which both
|
||||
* re-fetches and broadcasts so the top-nav pill (and any other listening
|
||||
* component) updates instantly.
|
||||
*/
|
||||
export function useLiveWalletBalance(initialBalance: number) {
|
||||
const [balance, setBalance] = useState(initialBalance);
|
||||
const lastBroadcastRef = useRef<number>(initialBalance);
|
||||
|
||||
const refreshBalance = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch("/api/wallet", { cache: "no-store" });
|
||||
if (!res.ok) return;
|
||||
const data = await res.json();
|
||||
const next = data.balanceCredits ?? data.balance;
|
||||
if (typeof next === "number" && Number.isFinite(next)) {
|
||||
setBalance(next);
|
||||
if (next !== lastBroadcastRef.current && typeof window !== "undefined") {
|
||||
lastBroadcastRef.current = next;
|
||||
window.dispatchEvent(new Event("wallet:refresh"));
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Keep the last known balance; server-side wager validation remains final.
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void refreshBalance();
|
||||
const id = window.setInterval(() => void refreshBalance(), 15_000);
|
||||
const onCustom = () => void refreshBalance();
|
||||
const onVisibility = () => {
|
||||
if (document.visibilityState === "visible") void refreshBalance();
|
||||
};
|
||||
window.addEventListener("wallet:refresh", onCustom);
|
||||
document.addEventListener("visibilitychange", onVisibility);
|
||||
return () => {
|
||||
window.clearInterval(id);
|
||||
window.removeEventListener("wallet:refresh", onCustom);
|
||||
document.removeEventListener("visibilitychange", onVisibility);
|
||||
};
|
||||
}, [refreshBalance]);
|
||||
|
||||
return { balance, refreshBalance };
|
||||
}
|
||||
@@ -2,7 +2,18 @@ import { prisma } from "./prisma";
|
||||
import { creditWalletCredits, debitWalletCredits } from "./wallet-safety";
|
||||
import type { GameType } from "@prisma/client";
|
||||
|
||||
export async function debitForBet(userId: string, blwAmount: number, gameType: GameType | string, memo?: string): Promise<void> {
|
||||
interface GameLink {
|
||||
gameSessionId?: string;
|
||||
gameRoomId?: string;
|
||||
}
|
||||
|
||||
export async function debitForBet(
|
||||
userId: string,
|
||||
blwAmount: number,
|
||||
gameType: GameType | string,
|
||||
memo?: string,
|
||||
link?: GameLink,
|
||||
): Promise<void> {
|
||||
await prisma.$transaction(async (tx) => {
|
||||
await debitWalletCredits(tx, userId, blwAmount);
|
||||
await tx.ledgerEntry.create({
|
||||
@@ -11,12 +22,20 @@ export async function debitForBet(userId: string, blwAmount: number, gameType: G
|
||||
delta: -blwAmount,
|
||||
type: "DEBIT_GAME_BET",
|
||||
memo: memo ?? `${gameType} bet`,
|
||||
gameSessionId: link?.gameSessionId,
|
||||
gameRoomId: link?.gameRoomId,
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export async function creditForWin(userId: string, blwAmount: number, gameType: GameType | string, memo?: string): Promise<void> {
|
||||
export async function creditForWin(
|
||||
userId: string,
|
||||
blwAmount: number,
|
||||
gameType: GameType | string,
|
||||
memo?: string,
|
||||
link?: GameLink,
|
||||
): Promise<void> {
|
||||
await prisma.$transaction(async (tx) => {
|
||||
await creditWalletCredits(tx, userId, blwAmount);
|
||||
await tx.ledgerEntry.create({
|
||||
@@ -25,12 +44,20 @@ export async function creditForWin(userId: string, blwAmount: number, gameType:
|
||||
delta: blwAmount,
|
||||
type: "CREDIT_GAME_WIN",
|
||||
memo: memo ?? `${gameType} payout`,
|
||||
gameSessionId: link?.gameSessionId,
|
||||
gameRoomId: link?.gameRoomId,
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export async function refundBet(userId: string, blwAmount: number, gameType: GameType | string): Promise<void> {
|
||||
export async function refundBet(
|
||||
userId: string,
|
||||
blwAmount: number,
|
||||
gameType: GameType | string,
|
||||
memo?: string,
|
||||
link?: GameLink,
|
||||
): Promise<void> {
|
||||
await prisma.$transaction(async (tx) => {
|
||||
await creditWalletCredits(tx, userId, blwAmount);
|
||||
await tx.ledgerEntry.create({
|
||||
@@ -38,8 +65,50 @@ export async function refundBet(userId: string, blwAmount: number, gameType: Gam
|
||||
userId,
|
||||
delta: blwAmount,
|
||||
type: "CREDIT_GAME_REFUND",
|
||||
memo: `${gameType} refund`,
|
||||
memo: memo ?? `${gameType} refund`,
|
||||
gameSessionId: link?.gameSessionId,
|
||||
gameRoomId: link?.gameRoomId,
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Idempotent refund — credits a room participant (creator or joiner) only if
|
||||
* their original lock entry exists for this room and no refund has been recorded
|
||||
* yet. Returns true if it actually refunded, false otherwise.
|
||||
*
|
||||
* Used by the expiry sweep, cancel endpoint, and boot-time recovery.
|
||||
*/
|
||||
export async function refundRoomPartyIfNotRefunded(
|
||||
roomId: string,
|
||||
userId: string,
|
||||
blwAmount: number,
|
||||
gameType: GameType | string,
|
||||
): Promise<boolean> {
|
||||
return prisma.$transaction(async (tx) => {
|
||||
const existingRefund = await tx.ledgerEntry.findFirst({
|
||||
where: { gameRoomId: roomId, userId, type: "CREDIT_GAME_REFUND" },
|
||||
select: { id: true },
|
||||
});
|
||||
if (existingRefund) return false;
|
||||
|
||||
const originalLock = await tx.ledgerEntry.findFirst({
|
||||
where: { gameRoomId: roomId, userId, type: "DEBIT_GAME_BET" },
|
||||
select: { id: true },
|
||||
});
|
||||
if (!originalLock) return false;
|
||||
|
||||
await creditWalletCredits(tx, userId, blwAmount);
|
||||
await tx.ledgerEntry.create({
|
||||
data: {
|
||||
userId,
|
||||
delta: blwAmount,
|
||||
type: "CREDIT_GAME_REFUND",
|
||||
memo: `${gameType} room refund`,
|
||||
gameRoomId: roomId,
|
||||
},
|
||||
});
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
20
src/lib/legal-contact.ts
Normal file
20
src/lib/legal-contact.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { siteUrl } from "@/lib/public-env";
|
||||
|
||||
/** Public contact for privacy and data-deletion requests. */
|
||||
export function legalContactEmail(): string {
|
||||
const configured =
|
||||
process.env.NEXT_PUBLIC_LEGAL_CONTACT_EMAIL?.trim() ||
|
||||
process.env.LEGAL_CONTACT_EMAIL?.trim();
|
||||
if (configured) return configured;
|
||||
|
||||
try {
|
||||
const host = new URL(siteUrl()).hostname.replace(/^www\./, "");
|
||||
if (host && host !== "localhost" && !host.startsWith("127.")) {
|
||||
return `privacy@${host}`;
|
||||
}
|
||||
} catch {
|
||||
/* fall through */
|
||||
}
|
||||
|
||||
return "privacy@example.com";
|
||||
}
|
||||
7
src/types/next-auth.d.ts
vendored
7
src/types/next-auth.d.ts
vendored
@@ -2,7 +2,11 @@ import type { DefaultSession } from "next-auth";
|
||||
|
||||
declare module "next-auth" {
|
||||
interface Session {
|
||||
user: DefaultSession["user"] & { id: string; role: "USER" | "ADMIN" };
|
||||
user: DefaultSession["user"] & { id: string; role: "USER" | "ADMIN"; username?: string };
|
||||
}
|
||||
|
||||
interface User {
|
||||
username?: string;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,5 +14,6 @@ declare module "next-auth/jwt" {
|
||||
interface JWT {
|
||||
id?: string;
|
||||
role?: "USER" | "ADMIN";
|
||||
username?: string;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user